Skip to content

Commit 4b21130

Browse files
committed
import the script
1 parent 38a7a5c commit 4b21130

File tree

1 file changed

+323
-0
lines changed

1 file changed

+323
-0
lines changed

minimum_versions.py

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import asyncio
2+
import bisect
3+
import datetime
4+
import pathlib
5+
import sys
6+
from dataclasses import dataclass, field
7+
8+
import rich_click as click
9+
import yaml
10+
from dateutil.relativedelta import relativedelta
11+
from rattler import Gateway, Version
12+
from rich.console import Console
13+
from rich.panel import Panel
14+
from rich.style import Style
15+
from rich.table import Column, Table
16+
from tlz.functoolz import curry, pipe
17+
from tlz.itertoolz import concat, groupby
18+
19+
click.rich_click.SHOW_ARGUMENTS = True
20+
21+
channels = ["conda-forge"]
22+
platforms = ["noarch", "linux-64"]
23+
ignored_packages = [
24+
"coveralls",
25+
"pip",
26+
"pytest",
27+
"pytest-cov",
28+
"pytest-env",
29+
"pytest-xdist",
30+
"pytest-timeout",
31+
"hypothesis",
32+
]
33+
34+
35+
@dataclass
36+
class Policy:
37+
package_months: dict
38+
default_months: int
39+
overrides: dict[str, Version] = field(default_factory=dict)
40+
41+
def minimum_version(self, package_name, releases):
42+
if (override := self.overrides.get(package_name)) is not None:
43+
return override
44+
45+
policy_months = self.package_months.get(package_name, self.default_months)
46+
today = datetime.date.today()
47+
48+
cutoff_date = today - relativedelta(months=policy_months)
49+
50+
index = bisect.bisect_left(
51+
releases, cutoff_date, key=lambda x: x.timestamp.date()
52+
)
53+
return releases[index - 1 if index > 0 else 0]
54+
55+
56+
@dataclass
57+
class Spec:
58+
name: str
59+
version: Version | None
60+
61+
@classmethod
62+
def parse(cls, spec_text):
63+
warnings = []
64+
if ">" in spec_text or "<" in spec_text:
65+
warnings.append(
66+
f"package should be pinned with an exact version: {spec_text!r}"
67+
)
68+
69+
spec_text = spec_text.replace(">", "").replace("<", "")
70+
71+
if "=" in spec_text:
72+
name, version_text = spec_text.split("=", maxsplit=1)
73+
version = Version(version_text)
74+
segments = version.segments()
75+
76+
if len(segments) != 2 or (len(segments) == 3 and segments[2] != 0):
77+
warnings.append(
78+
f"package should be pinned to a minor version (got {version})"
79+
)
80+
else:
81+
name = spec_text
82+
version = None
83+
84+
return cls(name, version), (name, warnings)
85+
86+
87+
@dataclass(order=True)
88+
class Release:
89+
version: Version
90+
build_number: int
91+
timestamp: datetime.datetime = field(compare=False)
92+
93+
@classmethod
94+
def from_repodata_record(cls, repo_data):
95+
return cls(
96+
version=repo_data.version,
97+
build_number=repo_data.build_number,
98+
timestamp=repo_data.timestamp,
99+
)
100+
101+
102+
def parse_environment(text):
103+
env = yaml.safe_load(text)
104+
105+
specs = []
106+
warnings = []
107+
for dep in env["dependencies"]:
108+
spec, warnings_ = Spec.parse(dep)
109+
110+
warnings.append(warnings_)
111+
specs.append(spec)
112+
113+
return specs, warnings
114+
115+
116+
def is_preview(version):
117+
candidates = ["rc", "beta", "alpha"]
118+
119+
*_, last_segment = version.segments()
120+
return any(candidate in last_segment for candidate in candidates)
121+
122+
123+
def group_packages(records):
124+
groups = groupby(lambda r: r.name.normalized, records)
125+
return {
126+
name: sorted(map(Release.from_repodata_record, group))
127+
for name, group in groups.items()
128+
}
129+
130+
131+
def filter_releases(predicate, releases):
132+
return {
133+
name: [r for r in records if predicate(r)] for name, records in releases.items()
134+
}
135+
136+
137+
def deduplicate_releases(package_info):
138+
def deduplicate(releases):
139+
return min(releases, key=lambda p: p.timestamp)
140+
141+
return {
142+
name: list(map(deduplicate, groupby(lambda p: p.version, group).values()))
143+
for name, group in package_info.items()
144+
}
145+
146+
147+
def find_policy_versions(policy, releases):
148+
return {
149+
name: policy.minimum_version(name, package_releases)
150+
for name, package_releases in releases.items()
151+
}
152+
153+
154+
def is_suitable_release(release):
155+
if release.timestamp is None:
156+
return False
157+
158+
segments = release.version.extend_to_length(3).segments()
159+
160+
return segments[2] == [0]
161+
162+
163+
def lookup_spec_release(spec, releases):
164+
version = spec.version.extend_to_length(3)
165+
166+
return releases[spec.name][version]
167+
168+
169+
def compare_versions(environments, policy_versions):
170+
status = {}
171+
for env, specs in environments.items():
172+
env_status = any(
173+
spec.version > policy_versions[spec.name].version for spec in specs
174+
)
175+
status[env] = env_status
176+
return status
177+
178+
179+
def version_comparison_symbol(required, policy):
180+
if required < policy:
181+
return "<"
182+
elif required > policy:
183+
return ">"
184+
else:
185+
return "="
186+
187+
188+
def format_bump_table(specs, policy_versions, releases, warnings):
189+
table = Table(
190+
Column("Package", width=20),
191+
Column("Required", width=8),
192+
"Required (date)",
193+
Column("Policy", width=8),
194+
"Policy (date)",
195+
"Status",
196+
)
197+
198+
heading_style = Style(color="#ff0000", bold=True)
199+
warning_style = Style(color="#ffff00", bold=True)
200+
styles = {
201+
">": Style(color="#ff0000", bold=True),
202+
"=": Style(color="#008700", bold=True),
203+
"<": Style(color="#d78700", bold=True),
204+
}
205+
206+
for spec in specs:
207+
policy_release = policy_versions[spec.name]
208+
policy_version = policy_release.version.with_segments(0, 2)
209+
policy_date = policy_release.timestamp
210+
211+
required_version = spec.version
212+
required_date = lookup_spec_release(spec, releases).timestamp
213+
214+
status = version_comparison_symbol(required_version, policy_version)
215+
style = styles[status]
216+
217+
table.add_row(
218+
spec.name,
219+
str(required_version),
220+
f"{required_date:%Y-%m-%d}",
221+
str(policy_version),
222+
f"{policy_date:%Y-%m-%d}",
223+
status,
224+
style=style,
225+
)
226+
227+
grid = Table.grid(expand=True, padding=(0, 2))
228+
grid.add_column(style=heading_style, vertical="middle")
229+
grid.add_column()
230+
grid.add_row("Version summary", table)
231+
232+
if any(warnings.values()):
233+
warning_table = Table(width=table.width, expand=True)
234+
warning_table.add_column("Package")
235+
warning_table.add_column("Warning")
236+
237+
for package, messages in warnings.items():
238+
if not messages:
239+
continue
240+
warning_table.add_row(package, messages[0], style=warning_style)
241+
for message in messages[1:]:
242+
warning_table.add_row("", message, style=warning_style)
243+
244+
grid.add_row("Warnings", warning_table)
245+
246+
return grid
247+
248+
249+
@click.command()
250+
@click.argument(
251+
"environment_paths",
252+
type=click.Path(exists=True, readable=True, path_type=pathlib.Path),
253+
nargs=-1,
254+
)
255+
def main(environment_paths):
256+
console = Console()
257+
258+
parsed_environments = {
259+
path.stem: parse_environment(path.read_text()) for path in environment_paths
260+
}
261+
262+
warnings = {
263+
env: dict(warnings_) for env, (_, warnings_) in parsed_environments.items()
264+
}
265+
environments = {
266+
env: [spec for spec in specs if spec.name not in ignored_packages]
267+
for env, (specs, _) in parsed_environments.items()
268+
}
269+
270+
all_packages = list(
271+
dict.fromkeys(spec.name for spec in concat(environments.values()))
272+
)
273+
274+
policy_months = {
275+
"python": 30,
276+
"numpy": 18,
277+
}
278+
policy_months_default = 12
279+
overrides = {}
280+
281+
policy = Policy(
282+
policy_months, default_months=policy_months_default, overrides=overrides
283+
)
284+
285+
gateway = Gateway()
286+
query = gateway.query(channels, platforms, all_packages, recursive=False)
287+
records = asyncio.run(query)
288+
289+
package_releases = pipe(
290+
records,
291+
concat,
292+
group_packages,
293+
curry(filter_releases, lambda r: r.timestamp is not None),
294+
deduplicate_releases,
295+
)
296+
policy_versions = pipe(
297+
package_releases,
298+
curry(filter_releases, is_suitable_release),
299+
curry(find_policy_versions, policy),
300+
)
301+
status = compare_versions(environments, policy_versions)
302+
303+
release_lookup = {
304+
n: {r.version: r for r in releases} for n, releases in package_releases.items()
305+
}
306+
grids = {
307+
env: format_bump_table(specs, policy_versions, release_lookup, warnings[env])
308+
for env, specs in environments.items()
309+
}
310+
root_grid = Table.grid()
311+
root_grid.add_column()
312+
313+
for env, grid in grids.items():
314+
root_grid.add_row(Panel(grid, title=env, expand=True))
315+
316+
console.print(root_grid)
317+
318+
status_code = 1 if any(status.values()) else 0
319+
sys.exit(status_code)
320+
321+
322+
if __name__ == "__main__":
323+
main()

0 commit comments

Comments
 (0)