omdev 0.0.0.dev486__py3-none-any.whl → 0.0.0.dev506__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of omdev might be problematic. Click here for more details.

Files changed (50) hide show
  1. omdev/.omlish-manifests.json +2 -2
  2. omdev/README.md +51 -0
  3. omdev/__about__.py +4 -2
  4. omdev/ci/cli.py +1 -1
  5. omdev/cli/clicli.py +37 -7
  6. omdev/dataclasses/cli.py +1 -1
  7. omdev/interp/cli.py +1 -1
  8. omdev/interp/types.py +3 -2
  9. omdev/interp/uv/provider.py +36 -0
  10. omdev/manifests/main.py +1 -1
  11. omdev/markdown/incparse.py +392 -0
  12. omdev/packaging/revisions.py +1 -1
  13. omdev/py/tools/pipdepup.py +150 -93
  14. omdev/pyproject/cli.py +2 -36
  15. omdev/pyproject/configs.py +1 -1
  16. omdev/pyproject/pkg.py +1 -1
  17. omdev/pyproject/reqs.py +8 -7
  18. omdev/pyproject/tools/aboutdeps.py +5 -0
  19. omdev/pyproject/tools/pyversions.py +47 -0
  20. omdev/pyproject/versions.py +40 -0
  21. omdev/scripts/ci.py +369 -26
  22. omdev/scripts/interp.py +51 -9
  23. omdev/scripts/lib/inject.py +8 -1
  24. omdev/scripts/lib/logs.py +117 -21
  25. omdev/scripts/pyproject.py +479 -76
  26. omdev/tools/git/cli.py +43 -13
  27. omdev/tools/json/formats.py +2 -0
  28. omdev/tools/jsonview/cli.py +19 -61
  29. omdev/tools/jsonview/resources/jsonview.html.j2 +43 -0
  30. omdev/tools/pawk/README.md +195 -0
  31. omdev/tools/sqlrepl.py +189 -78
  32. omdev/tui/apps/edit/main.py +5 -1
  33. omdev/tui/apps/irc/app.py +28 -20
  34. omdev/tui/apps/irc/commands.py +1 -1
  35. omdev/tui/rich/__init__.py +12 -0
  36. omdev/tui/rich/markdown2.py +219 -18
  37. omdev/tui/textual/__init__.py +41 -2
  38. omdev/tui/textual/app2.py +6 -1
  39. omdev/tui/textual/debug/__init__.py +10 -0
  40. omdev/tui/textual/debug/dominfo.py +151 -0
  41. omdev/tui/textual/debug/screen.py +24 -0
  42. omdev/tui/textual/devtools.py +187 -0
  43. omdev/tui/textual/logging2.py +20 -0
  44. omdev/tui/textual/types.py +45 -0
  45. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/METADATA +10 -6
  46. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/RECORD +50 -39
  47. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/WHEEL +0 -0
  48. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/entry_points.txt +0 -0
  49. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/licenses/LICENSE +0 -0
  50. {omdev-0.0.0.dev486.dist-info → omdev-0.0.0.dev506.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,9 @@
1
1
  """
2
2
  TODO:
3
+ - output 2 tables lol
3
4
  - min_time_since_prev_version
5
+ - without this the min age is moot lol, can still catch a bad release at the same time of day just n days later
6
+ - * at least show 'suggested', 'suggested age', 'latest', 'latest age', 'number of releases between the 2' *
4
7
  - how to handle non-linearity? new minor vers come out in parallel for diff major vers
5
8
  - trie?
6
9
  - find which reqs file + lineno to update
@@ -28,7 +31,7 @@ https://news.ycombinator.com/item?id=46005111
28
31
  # ~> https://github.com/pypa/pip/blob/a52069365063ea813fe3a3f8bac90397c9426d35/src/pip/_internal/commands/list.py (25.3)
29
32
  import dataclasses as dc
30
33
  import datetime
31
- import email.parser
34
+ import itertools
32
35
  import os.path
33
36
  import ssl
34
37
  import typing as ta
@@ -310,16 +313,42 @@ class Package:
310
313
 
311
314
  return datetime.datetime.fromisoformat(check.isinstance(ut, str)) # noqa
312
315
 
316
+ @cached.property
317
+ def version(self) -> Version:
318
+ return self.install.version
319
+
320
+ @cached.property
321
+ def filetype(self) -> ta.Literal['wheel', 'sdist']:
322
+ if self.install.link.is_wheel:
323
+ return 'wheel'
324
+ else:
325
+ return 'sdist'
326
+
327
+ unfiltered_candidates: ta.Sequence[Candidate] | None = None
313
328
  candidates: ta.Sequence[Candidate] | None = None
314
329
 
315
- @dc.dataclass(frozen=True, kw_only=True)
316
- class LatestInfo:
317
- candidate: 'Package.Candidate'
330
+ latest_candidate: Candidate | None = None
331
+ suggested_candidate: Candidate | None = None
332
+
333
+
334
+ def get_best_candidate(
335
+ pkg: Package,
336
+ finder: MyPackageFinder,
337
+ candidates: ta.Sequence[Package.Candidate],
338
+ ) -> Package.Candidate | None:
339
+ candidates_by_install: ta.Mapping[InstallationCandidate, Package.Candidate] = col.make_map((
340
+ (c.install, c)
341
+ for c in candidates
342
+ ), strict=True, identity=True)
318
343
 
319
- version: Version
320
- filetype: str
344
+ evaluator = finder.make_candidate_evaluator(
345
+ project_name=pkg.dist.canonical_name,
346
+ )
347
+ best_install = evaluator.sort_best_candidate([c.install for c in candidates])
348
+ if best_install is None:
349
+ return None
321
350
 
322
- latest_info: LatestInfo | None = None
351
+ return candidates_by_install[best_install]
323
352
 
324
353
 
325
354
  def set_package_finder_info(
@@ -330,17 +359,18 @@ def set_package_finder_info(
330
359
  max_uploaded_at: datetime.datetime | None = None,
331
360
  min_time_since_prev_version: datetime.timedelta | None = None,
332
361
  ) -> None:
333
- candidates = [
362
+ pkg.unfiltered_candidates = [
334
363
  Package.Candidate(
335
364
  c,
336
365
  finder.get_link_pypi_dict(c.link),
337
366
  )
338
367
  for c in finder.find_all_candidates(pkg.dist.canonical_name)
339
368
  ]
340
- pkg.candidates = candidates
341
369
 
342
370
  #
343
371
 
372
+ candidates = pkg.unfiltered_candidates
373
+
344
374
  if not pre:
345
375
  # Remove prereleases
346
376
  candidates = [
@@ -349,8 +379,16 @@ def set_package_finder_info(
349
379
  if not c.install.version.is_prerelease
350
380
  ]
351
381
 
382
+ pkg.candidates = candidates
383
+
352
384
  #
353
385
 
386
+ pkg.latest_candidate = get_best_candidate(pkg, finder, pkg.candidates)
387
+
388
+ #
389
+
390
+ suggested_candidates = candidates
391
+
354
392
  if min_time_since_prev_version is not None:
355
393
  # candidates_by_version = col.multi_map((c.install.version, c) for c in candidates)
356
394
  # uploaded_at_by_version = {
@@ -359,46 +397,17 @@ def set_package_finder_info(
359
397
  # }
360
398
  raise NotImplementedError
361
399
 
362
- #
363
-
364
400
  if max_uploaded_at is not None:
365
- candidates = [
401
+ suggested_candidates = [
366
402
  c
367
- for c in candidates
403
+ for c in suggested_candidates
368
404
  if not (
369
405
  (c_dt := c.upload_time()) is not None and
370
406
  c_dt > max_uploaded_at
371
407
  )
372
408
  ]
373
409
 
374
- #
375
-
376
- candidates_by_install: ta.Mapping[InstallationCandidate, Package.Candidate] = col.make_map((
377
- (c.install, c)
378
- for c in candidates
379
- ), strict=True, identity=True)
380
-
381
- evaluator = finder.make_candidate_evaluator(
382
- project_name=pkg.dist.canonical_name,
383
- )
384
- best_install = evaluator.sort_best_candidate([c.install for c in candidates])
385
- if best_install is None:
386
- return
387
- best_candidate = candidates_by_install[best_install]
388
-
389
- #
390
-
391
- remote_version = best_candidate.install.version
392
- if best_candidate.install.link.is_wheel:
393
- typ = 'wheel'
394
- else:
395
- typ = 'sdist'
396
-
397
- pkg.latest_info = Package.LatestInfo(
398
- candidate=best_candidate,
399
- version=remote_version,
400
- filetype=typ,
401
- )
410
+ pkg.suggested_candidate = get_best_candidate(pkg, finder, suggested_candidates)
402
411
 
403
412
 
404
413
  ##
@@ -472,20 +481,20 @@ def format_for_json(
472
481
  infos: list[dict[str, ta.Any]] = []
473
482
 
474
483
  for pkg in pkgs:
475
- latest_info = check.not_none(pkg.latest_info)
484
+ latest = check.not_none(pkg.latest_candidate)
485
+ suggested = check.not_none(pkg.suggested_candidate)
476
486
 
477
487
  info = {
478
488
  'name': pkg.dist.raw_name,
479
489
  'version': pkg.version(),
480
490
  'location': pkg.dist.location or '',
481
491
  'installer': pkg.dist.installer,
482
- 'latest_version': str(latest_info.version),
483
- 'latest_filetype': latest_info.filetype,
492
+ 'latest_version': str(latest.install.version),
493
+ 'latest_upload_time': lut.isoformat() if (lut := latest.upload_time()) is not None else None,
494
+ 'suggested_version': str(suggested.install.version),
495
+ 'suggested_upload_time': sut.isoformat() if (sut := suggested.upload_time()) is not None else None,
484
496
  }
485
497
 
486
- if (l_ut := latest_info.candidate.upload_time()) is not None:
487
- info['latest_age'] = human_round_td(now_utc() - l_ut)
488
-
489
498
  if editable_project_location := pkg.dist.editable_project_location:
490
499
  info['editable_project_location'] = editable_project_location
491
500
 
@@ -500,46 +509,87 @@ def format_for_json(
500
509
  def format_for_columns(pkgs: ta.Sequence[Package]) -> tuple[list[list[str]], list[str]]:
501
510
  """Convert the package data into something usable by output_package_listing_columns."""
502
511
 
503
- header = ['Package', 'Version', 'Latest', 'Age', 'Type']
512
+ header = [
513
+ 'Package',
514
+ 'Current',
504
515
 
505
- def wheel_build_tag(dist: BaseDistribution) -> str | None:
506
- try:
507
- wheel_file = dist.read_text('WHEEL')
508
- except FileNotFoundError:
509
- return None
510
- return email.parser.Parser().parsestr(wheel_file).get('Build')
511
-
512
- build_tags = [wheel_build_tag(p.dist) for p in pkgs]
513
- has_build_tags = any(build_tags)
514
- if has_build_tags:
515
- header.append('Build')
516
-
517
- has_editables = any(x.dist.editable for x in pkgs)
518
- if has_editables:
519
- header.append('Editable project location')
520
-
521
- data = []
522
- for i, proj in enumerate(pkgs):
523
- # if we're working on the 'outdated' list, separate out the latest_version and type
524
- row = [proj.dist.raw_name, proj.dist.raw_version]
525
-
526
- latest_info = check.not_none(proj.latest_info)
527
- row.append(str(latest_info.version))
528
- if (l_ut := latest_info.candidate.upload_time()) is not None:
529
- row.append(human_round_td(now_utc() - l_ut))
530
- else:
531
- row.append('')
532
- row.append(latest_info.filetype)
516
+ 'Suggested',
517
+ 'Age',
533
518
 
534
- if has_build_tags:
535
- row.append(build_tags[i] or '')
519
+ 'Latest',
520
+ 'Age',
521
+ ]
536
522
 
537
- if has_editables:
538
- row.append(proj.dist.editable_project_location or '')
523
+ # def wheel_build_tag(dist: BaseDistribution) -> str | None:
524
+ # try:
525
+ # wheel_file = dist.read_text('WHEEL')
526
+ # except FileNotFoundError:
527
+ # return None
528
+ # return email.parser.Parser().parsestr(wheel_file).get('Build')
539
529
 
540
- data.append(row)
530
+ # build_tags = [wheel_build_tag(p.dist) for p in pkgs]
531
+ # has_build_tags = any(build_tags)
532
+ # if has_build_tags:
533
+ # header.append('Build')
541
534
 
542
- return data, header
535
+ # has_editables = any(x.dist.editable for x in pkgs)
536
+ # if has_editables:
537
+ # header.append('Editable project location')
538
+
539
+ rows = []
540
+ for pkg in pkgs:
541
+ sc = check.not_none(pkg.suggested_candidate)
542
+ lc = check.not_none(pkg.latest_candidate)
543
+
544
+ row = [
545
+ pkg.dist.raw_name,
546
+ pkg.dist.raw_version,
547
+ ]
548
+
549
+ def add_c(c):
550
+ if c is None:
551
+ row.extend(['', ''])
552
+ return
553
+
554
+ row.append(str(c.version))
555
+
556
+ if (l_ut := c.upload_time()) is not None:
557
+ row.append(human_round_td(now_utc() - l_ut))
558
+ else:
559
+ row.append('')
560
+
561
+ add_c(sc if sc.version != pkg.dist.version else None)
562
+ add_c(lc if sc is not lc else None)
563
+
564
+ # if has_build_tags:
565
+ # row.append(build_tags[i] or '')
566
+
567
+ # if has_editables:
568
+ # row.append(pkg.dist.editable_project_location or '')
569
+
570
+ rows.append(row)
571
+
572
+ return rows, header
573
+
574
+
575
+ def _tabulate(
576
+ rows: ta.Iterable[ta.Iterable[ta.Any]],
577
+ *,
578
+ sep: str = ' ',
579
+ ) -> tuple[list[str], list[int]]:
580
+ """
581
+ Return a list of formatted rows and a list of column sizes.
582
+
583
+ For example::
584
+
585
+ >>> tabulate([['foobar', 2000], [0xdeadbeef]])
586
+ (['foobar 2000', '3735928559'], [10, 4])
587
+ """
588
+
589
+ rows = [tuple(map(str, row)) for row in rows]
590
+ sizes = [max(map(len, col)) for col in itertools.zip_longest(*rows, fillvalue='')]
591
+ table = [sep.join(map(str.ljust, row, sizes)).rstrip() for row in rows]
592
+ return table, sizes
543
593
 
544
594
 
545
595
  def render_package_listing_columns(data: list[list[str]], header: list[str]) -> list[str]:
@@ -547,12 +597,11 @@ def render_package_listing_columns(data: list[list[str]], header: list[str]) ->
547
597
  if len(data) > 0:
548
598
  data.insert(0, header)
549
599
 
550
- from pip._internal.utils.misc import tabulate # noqa
551
- pkg_strings, sizes = tabulate(data)
600
+ pkg_strings, sizes = _tabulate(data, sep=' ')
552
601
 
553
602
  # Create and add a separator.
554
603
  if len(data) > 0:
555
- pkg_strings.insert(1, ' '.join('-' * x for x in sizes))
604
+ pkg_strings.insert(1, ' '.join('-' * x for x in sizes))
556
605
 
557
606
  return pkg_strings
558
607
 
@@ -565,12 +614,14 @@ def _main() -> None:
565
614
 
566
615
  parser = argparse.ArgumentParser()
567
616
  parser.add_argument('--exclude', action='append', dest='excludes')
568
- parser.add_argument('--min-age-h', type=float, default=4 * 24)
569
- parser.add_argument('-P', '--parallelism', type=int, default=4)
617
+ parser.add_argument('--min-age-h', type=float, default=24)
618
+ parser.add_argument('-P', '--parallelism', type=int, default=3)
570
619
  parser.add_argument('--json', action='store_true')
571
620
  args = parser.parse_args()
572
621
 
573
- max_uploaded_at: datetime.datetime | None = now_utc() - datetime.timedelta(hours=args.min_age_h)
622
+ max_uploaded_at: datetime.datetime | None = None
623
+ if args.min_age_h is not None:
624
+ max_uploaded_at = now_utc() - datetime.timedelta(hours=args.min_age_h)
574
625
  min_time_since_prev_version: datetime.timedelta | None = None # datetime.timedelta(days=1)
575
626
 
576
627
  #
@@ -608,21 +659,27 @@ def _main() -> None:
608
659
 
609
660
  #
610
661
 
611
- pkgs = [
662
+ outdated_pkgs = [
612
663
  pkg
613
664
  for pkg in pkgs
614
- if (li := pkg.latest_info) is not None
665
+ if (li := pkg.latest_candidate) is not None
615
666
  and li.version > pkg.dist.version
616
667
  ]
617
668
 
618
- pkgs.sort(key=lambda x: x.dist.raw_name)
669
+ outdated_pkgs.sort(key=lambda x: x.dist.raw_name)
619
670
 
620
671
  #
621
672
 
622
673
  if args.json:
623
- print(json.dumps_pretty(format_for_json(pkgs)))
674
+ print(json.dumps_pretty(format_for_json(outdated_pkgs)))
675
+
624
676
  else:
625
- print('\n'.join(render_package_listing_columns(*format_for_columns(pkgs))))
677
+ # stable_pkgs, unstable_pkgs = col.partition(
678
+ # outdated_pkgs,
679
+ # lambda pkg: pkg.latest_candidate is pkg.suggested_candidate,
680
+ # )
681
+
682
+ print('\n'.join(render_package_listing_columns(*format_for_columns(outdated_pkgs))))
626
683
 
627
684
 
628
685
  if __name__ == '__main__':
omdev/pyproject/cli.py CHANGED
@@ -22,7 +22,6 @@ See:
22
22
  import argparse
23
23
  import asyncio
24
24
  import concurrent.futures as cf
25
- import dataclasses as dc
26
25
  import functools
27
26
  import itertools
28
27
  import multiprocessing as mp
@@ -40,47 +39,14 @@ from omlish.formats.toml.parser import toml_loads
40
39
  from omlish.lite.cached import cached_nullary
41
40
  from omlish.lite.check import check
42
41
  from omlish.lite.runtime import check_lite_runtime_version
43
- from omlish.logs.standard import configure_standard_logging
42
+ from omlish.logs.std.standard import configure_standard_logging
44
43
 
45
44
  from .configs import PyprojectConfig
46
45
  from .configs import PyprojectConfigPreparer
47
46
  from .pkg import BasePyprojectPackageGenerator
48
47
  from .pkg import PyprojectPackageGenerator
49
48
  from .venvs import Venv
50
-
51
-
52
- ##
53
-
54
-
55
- @dc.dataclass(frozen=True)
56
- class VersionsFile:
57
- name: ta.Optional[str] = '.versions'
58
-
59
- @staticmethod
60
- def parse(s: str) -> ta.Mapping[str, str]:
61
- return {
62
- k: v
63
- for l in s.splitlines()
64
- if (sl := l.split('#')[0].strip())
65
- for k, _, v in (sl.partition('='),)
66
- }
67
-
68
- @cached_nullary
69
- def contents(self) -> ta.Mapping[str, str]:
70
- if not self.name or not os.path.exists(self.name):
71
- return {}
72
- with open(self.name) as f:
73
- s = f.read()
74
- return self.parse(s)
75
-
76
- @staticmethod
77
- def get_pythons(d: ta.Mapping[str, str]) -> ta.Mapping[str, str]:
78
- pfx = 'PYTHON_'
79
- return {k[len(pfx):].lower(): v for k, v in d.items() if k.startswith(pfx)}
80
-
81
- @cached_nullary
82
- def pythons(self) -> ta.Mapping[str, str]:
83
- return self.get_pythons(self.contents())
49
+ from .versions import VersionsFile
84
50
 
85
51
 
86
52
  ##
@@ -24,7 +24,7 @@ class PyprojectConfig:
24
24
  venvs: ta.Mapping[str, VenvConfig] = dc.field(default_factory=dict)
25
25
 
26
26
  venvs_dir: str = '.venvs'
27
- versions_file: ta.Optional[str] = '.versions'
27
+ # versions_file: ta.Optional[str] = '.versions' # FIXME:
28
28
 
29
29
 
30
30
  class PyprojectConfigPreparer:
omdev/pyproject/pkg.py CHANGED
@@ -587,7 +587,7 @@ class _PyprojectRsPackageGenerator(_PyprojectExtensionPackageGenerator):
587
587
  # `sdist.add_defaults` as an unbound function, not a bound method:
588
588
  # https://github.com/pypa/setuptools/blob/9c4d383631d3951fcae0afd73b5d08ff5a262976/setuptools/command/egg_info.py#L581
589
589
  from setuptools.command.sdist import sdist # noqa
590
- sdist.add_defaults = (lambda old: lambda sdist: _sdist_add_defaults(old, sdist))(sdist.add_defaults) # noqa
590
+ setattr(sdist, 'add_defaults', (lambda old: lambda sdist: _sdist_add_defaults(old, sdist))(sdist.add_defaults)) # noqa
591
591
 
592
592
  _patch_sdist()
593
593
 
omdev/pyproject/reqs.py CHANGED
@@ -53,13 +53,14 @@ class RequirementsRewriter:
53
53
  if self.VENV_MAGIC in l:
54
54
  lp, _, rp = l.partition(self.VENV_MAGIC)
55
55
  rp = rp.partition('#')[0]
56
- for v in rp.split():
57
- if v[0] == '!':
58
- if self._venv is not None and self._venv == v[1:]:
59
- omit = True
60
- break
61
- else:
62
- raise NotImplementedError
56
+ vs = set(rp.split())
57
+ nvs = {v[1:] for v in vs if v.startswith('!')}
58
+ pvs = {v for v in vs if not v.startswith('!')}
59
+ if (
60
+ (nvs and self._venv in nvs) or
61
+ (pvs and self._venv not in pvs)
62
+ ):
63
+ omit = True
63
64
 
64
65
  if (
65
66
  not omit and
@@ -38,11 +38,16 @@ def _main() -> None:
38
38
  pkg_opt_deps = {d for ds in pkg_prj.optional_dependencies.values() for d in ds}
39
39
  for opt_dep in sorted(pkg_opt_deps):
40
40
  opt_req = parse_requirement(opt_dep)
41
+
41
42
  opt_cn = canonicalize_name(opt_req.name, validate=True)
43
+
42
44
  opt_spec = Specifier(opt_req.specifier)
43
45
  if re.fullmatch(r'~=\s*\d+(\.\d+)*', str(opt_spec)):
44
46
  opt_spec = Specifier(str(opt_spec) + '.0')
45
47
 
48
+ if opt_cn in pkgs:
49
+ continue
50
+
46
51
  opt_dist = dist_dct[opt_cn]
47
52
  opt_ver = opt_dist.version
48
53
 
@@ -0,0 +1,47 @@
1
+ # ruff: noqa: UP006 UP045
2
+ import dataclasses as dc
3
+ import json
4
+ import typing as ta
5
+ import urllib.request
6
+
7
+ from ..versions import VersionsFile
8
+
9
+
10
+ ##
11
+
12
+
13
+ @dc.dataclass(frozen=True)
14
+ class PyVersion:
15
+ name: str # "Python 3.13.11",
16
+ slug: str # "python-31311"
17
+ version: int # 3
18
+ is_published: bool
19
+ is_latest: bool
20
+ release_date: str # "2025-12-05T19:24:49Z"
21
+ pre_release: bool
22
+ release_page: ta.Optional[str]
23
+ release_notes_url: str # "https://docs.python.org/release/3.13.11/whatsnew/changelog.html"
24
+ show_on_download_page: bool
25
+ resource_uri: str # "https://www.python.org/api/v2/downloads/release/1083/"
26
+
27
+
28
+ PY_VERSIONS_URL = 'https://www.python.org/api/v2/downloads/release/?is_published=true'
29
+
30
+
31
+ def get_py_versions() -> ta.List[PyVersion]:
32
+ with urllib.request.urlopen(PY_VERSIONS_URL) as r: # noqa
33
+ data = json.load(r)
34
+
35
+ return [PyVersion(**dct) for dct in data]
36
+
37
+
38
+ ##
39
+
40
+
41
+ def _main() -> None:
42
+ print(get_py_versions())
43
+ print(VersionsFile().pythons())
44
+
45
+
46
+ if __name__ == '__main__':
47
+ _main()
@@ -0,0 +1,40 @@
1
+ # ruff: noqa: UP045
2
+ import dataclasses as dc
3
+ import os.path
4
+ import typing as ta
5
+
6
+ from omlish.lite.cached import cached_nullary
7
+
8
+
9
+ ##
10
+
11
+
12
+ @dc.dataclass(frozen=True)
13
+ class VersionsFile:
14
+ name: ta.Optional[str] = '.versions'
15
+
16
+ @staticmethod
17
+ def parse(s: str) -> ta.Mapping[str, str]:
18
+ return {
19
+ k: v
20
+ for l in s.splitlines()
21
+ if (sl := l.split('#')[0].strip())
22
+ for k, _, v in (sl.partition('='),)
23
+ }
24
+
25
+ @cached_nullary
26
+ def contents(self) -> ta.Mapping[str, str]:
27
+ if not self.name or not os.path.exists(self.name):
28
+ return {}
29
+ with open(self.name) as f:
30
+ s = f.read()
31
+ return self.parse(s)
32
+
33
+ @staticmethod
34
+ def get_pythons(d: ta.Mapping[str, str]) -> ta.Mapping[str, str]:
35
+ pfx = 'PYTHON_'
36
+ return {k[len(pfx):].lower(): v for k, v in d.items() if k.startswith(pfx)}
37
+
38
+ @cached_nullary
39
+ def pythons(self) -> ta.Mapping[str, str]:
40
+ return self.get_pythons(self.contents())