omdev 0.0.0.dev439__py3-none-any.whl → 0.0.0.dev486__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 (134) hide show
  1. omdev/.omlish-manifests.json +18 -30
  2. omdev/__about__.py +9 -7
  3. omdev/amalg/gen/gen.py +49 -6
  4. omdev/amalg/gen/imports.py +1 -1
  5. omdev/amalg/gen/manifests.py +1 -1
  6. omdev/amalg/gen/resources.py +1 -1
  7. omdev/amalg/gen/srcfiles.py +13 -3
  8. omdev/amalg/gen/strip.py +1 -1
  9. omdev/amalg/gen/types.py +1 -1
  10. omdev/amalg/gen/typing.py +1 -1
  11. omdev/amalg/info.py +32 -0
  12. omdev/cache/data/actions.py +1 -1
  13. omdev/cache/data/specs.py +1 -1
  14. omdev/cexts/_boilerplate.cc +2 -3
  15. omdev/cexts/cmake.py +4 -1
  16. omdev/ci/cli.py +1 -2
  17. omdev/ci/github/api/v2/api.py +2 -0
  18. omdev/cmdlog/cli.py +1 -2
  19. omdev/dataclasses/_dumping.py +1960 -0
  20. omdev/dataclasses/_template.py +22 -0
  21. omdev/dataclasses/cli.py +6 -1
  22. omdev/dataclasses/codegen.py +340 -60
  23. omdev/dataclasses/dumping.py +200 -0
  24. omdev/interp/uv/provider.py +1 -0
  25. omdev/interp/venvs.py +1 -0
  26. omdev/irc/messages/base.py +50 -0
  27. omdev/irc/messages/formats.py +92 -0
  28. omdev/irc/messages/messages.py +775 -0
  29. omdev/irc/messages/parsing.py +99 -0
  30. omdev/irc/numerics/__init__.py +0 -0
  31. omdev/irc/numerics/formats.py +97 -0
  32. omdev/irc/numerics/numerics.py +865 -0
  33. omdev/irc/numerics/types.py +59 -0
  34. omdev/irc/protocol/LICENSE +11 -0
  35. omdev/irc/protocol/__init__.py +61 -0
  36. omdev/irc/protocol/consts.py +6 -0
  37. omdev/irc/protocol/errors.py +30 -0
  38. omdev/irc/protocol/message.py +21 -0
  39. omdev/irc/protocol/nuh.py +55 -0
  40. omdev/irc/protocol/parsing.py +158 -0
  41. omdev/irc/protocol/rendering.py +153 -0
  42. omdev/irc/protocol/tags.py +102 -0
  43. omdev/irc/protocol/utils.py +30 -0
  44. omdev/manifests/_dumping.py +125 -25
  45. omdev/markdown/__init__.py +0 -0
  46. omdev/markdown/incparse.py +116 -0
  47. omdev/markdown/tokens.py +51 -0
  48. omdev/packaging/marshal.py +8 -8
  49. omdev/packaging/requires.py +6 -6
  50. omdev/packaging/specifiers.py +2 -1
  51. omdev/packaging/versions.py +4 -4
  52. omdev/packaging/wheelfile.py +2 -0
  53. omdev/precheck/blanklines.py +66 -0
  54. omdev/precheck/caches.py +1 -1
  55. omdev/precheck/imports.py +14 -1
  56. omdev/precheck/main.py +4 -3
  57. omdev/precheck/unicode.py +39 -15
  58. omdev/py/asts/__init__.py +0 -0
  59. omdev/py/asts/parents.py +28 -0
  60. omdev/py/asts/toplevel.py +123 -0
  61. omdev/py/asts/visitors.py +18 -0
  62. omdev/py/attrdocs.py +6 -7
  63. omdev/py/bracepy.py +12 -4
  64. omdev/py/reprs.py +32 -0
  65. omdev/py/srcheaders.py +1 -1
  66. omdev/py/tokens/__init__.py +0 -0
  67. omdev/py/tools/mkrelimp.py +1 -1
  68. omdev/py/tools/pipdepup.py +629 -0
  69. omdev/pyproject/pkg.py +190 -45
  70. omdev/pyproject/reqs.py +31 -9
  71. omdev/pyproject/tools/__init__.py +0 -0
  72. omdev/pyproject/tools/aboutdeps.py +55 -0
  73. omdev/pyproject/venvs.py +8 -1
  74. omdev/rs/__init__.py +0 -0
  75. omdev/scripts/ci.py +400 -80
  76. omdev/scripts/interp.py +193 -35
  77. omdev/scripts/lib/__init__.py +0 -0
  78. omdev/scripts/{inject.py → lib/inject.py} +75 -28
  79. omdev/scripts/lib/logs.py +2079 -0
  80. omdev/scripts/{marshal.py → lib/marshal.py} +68 -26
  81. omdev/scripts/pyproject.py +941 -90
  82. omdev/tools/git/cli.py +12 -1
  83. omdev/tools/json/processing.py +5 -2
  84. omdev/tools/jsonview/cli.py +31 -5
  85. omdev/tools/pawk/pawk.py +2 -2
  86. omdev/tools/pip.py +8 -0
  87. omdev/tui/__init__.py +0 -0
  88. omdev/tui/apps/__init__.py +0 -0
  89. omdev/tui/apps/edit/__init__.py +0 -0
  90. omdev/tui/apps/edit/main.py +163 -0
  91. omdev/tui/apps/irc/__init__.py +0 -0
  92. omdev/tui/apps/irc/__main__.py +4 -0
  93. omdev/tui/apps/irc/app.py +278 -0
  94. omdev/tui/apps/irc/client.py +187 -0
  95. omdev/tui/apps/irc/commands.py +175 -0
  96. omdev/tui/apps/irc/main.py +26 -0
  97. omdev/tui/apps/markdown/__init__.py +0 -0
  98. omdev/tui/apps/markdown/__main__.py +11 -0
  99. omdev/{ptk → tui/apps}/markdown/cli.py +5 -7
  100. omdev/tui/rich/__init__.py +34 -0
  101. omdev/tui/rich/console2.py +20 -0
  102. omdev/tui/rich/markdown2.py +186 -0
  103. omdev/tui/textual/__init__.py +226 -0
  104. omdev/tui/textual/app2.py +11 -0
  105. omdev/tui/textual/autocomplete/LICENSE +21 -0
  106. omdev/tui/textual/autocomplete/__init__.py +33 -0
  107. omdev/tui/textual/autocomplete/matching.py +226 -0
  108. omdev/tui/textual/autocomplete/paths.py +202 -0
  109. omdev/tui/textual/autocomplete/widget.py +612 -0
  110. omdev/tui/textual/drivers2.py +55 -0
  111. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/METADATA +11 -9
  112. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/RECORD +121 -73
  113. omdev/ptk/__init__.py +0 -103
  114. omdev/ptk/apps/ncdu.py +0 -167
  115. omdev/ptk/confirm.py +0 -60
  116. omdev/ptk/markdown/LICENSE +0 -22
  117. omdev/ptk/markdown/__init__.py +0 -10
  118. omdev/ptk/markdown/__main__.py +0 -11
  119. omdev/ptk/markdown/border.py +0 -94
  120. omdev/ptk/markdown/markdown.py +0 -390
  121. omdev/ptk/markdown/parser.py +0 -42
  122. omdev/ptk/markdown/styles.py +0 -29
  123. omdev/ptk/markdown/tags.py +0 -299
  124. omdev/ptk/markdown/utils.py +0 -366
  125. omdev/pyproject/cexts.py +0 -110
  126. /omdev/{ptk/apps → irc}/__init__.py +0 -0
  127. /omdev/{tokens → irc/messages}/__init__.py +0 -0
  128. /omdev/{tokens → py/tokens}/all.py +0 -0
  129. /omdev/{tokens → py/tokens}/tokenizert.py +0 -0
  130. /omdev/{tokens → py/tokens}/utils.py +0 -0
  131. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/WHEEL +0 -0
  132. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/entry_points.txt +0 -0
  133. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/licenses/LICENSE +0 -0
  134. {omdev-0.0.0.dev439.dist-info → omdev-0.0.0.dev486.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,629 @@
1
+ """
2
+ TODO:
3
+ - min_time_since_prev_version
4
+ - how to handle non-linearity? new minor vers come out in parallel for diff major vers
5
+ - trie?
6
+ - find which reqs file + lineno to update
7
+ - auto update
8
+
9
+ #
10
+
11
+ https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns
12
+ https://news.ycombinator.com/item?id=46005111
13
+ """
14
+ # Copyright (c) 2008-present The pip developers (see AUTHORS.txt file)
15
+ #
16
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
17
+ # documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
18
+ # rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
19
+ # persons to whom the Software is furnished to do so, subject to the following conditions:
20
+ #
21
+ # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
22
+ # Software.
23
+ #
24
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
25
+ # WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
26
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
27
+ # OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28
+ # ~> https://github.com/pypa/pip/blob/a52069365063ea813fe3a3f8bac90397c9426d35/src/pip/_internal/commands/list.py (25.3)
29
+ import dataclasses as dc
30
+ import datetime
31
+ import email.parser
32
+ import os.path
33
+ import ssl
34
+ import typing as ta
35
+
36
+ from pip._internal.index.package_finder import LinkEvaluator # noqa
37
+ from pip._internal.index.package_finder import PackageFinder # noqa
38
+ from pip._internal.metadata import BaseDistribution # noqa
39
+ from pip._internal.models.candidate import InstallationCandidate # noqa
40
+ from pip._internal.models.link import Link # noqa
41
+ from pip._internal.network.session import PipSession # noqa
42
+ from pip._vendor.packaging.version import Version # noqa
43
+
44
+ from omlish import cached
45
+ from omlish import check
46
+ from omlish import collections as col
47
+ from omlish.concurrent import all as conc
48
+ from omlish.formats import json
49
+ from omlish.sync import ObjectPool
50
+
51
+
52
+ ##
53
+
54
+
55
+ @cached.function
56
+ def now_utc() -> datetime.datetime:
57
+ return datetime.datetime.now(datetime.UTC)
58
+
59
+
60
+ ##
61
+
62
+
63
+ @dc.dataclass(frozen=True, kw_only=True)
64
+ class IndexOptions:
65
+ no_index: bool = False
66
+ index_url: str | None = None
67
+ extra_index_urls: ta.Sequence[str] | None = None
68
+
69
+ def get_index_urls(self) -> list[str] | None:
70
+ if self.no_index:
71
+ return []
72
+
73
+ index_urls: list[str] = []
74
+ if (index_url := self.index_url) is None:
75
+ from pip._internal.models.index import PyPI # noqa
76
+ index_url = PyPI.simple_url
77
+ if index_url:
78
+ index_urls.append(index_url)
79
+
80
+ if (ext := self.extra_index_urls):
81
+ index_urls.extend(ext)
82
+
83
+ return index_urls or None
84
+
85
+
86
+ ##
87
+
88
+
89
+ @dc.dataclass(frozen=True, kw_only=True)
90
+ class CacheOpts:
91
+ no_cache: bool = False
92
+ cache_dir: str | None = None
93
+
94
+ def get_cache_dir(self) -> str | None:
95
+ if self.no_cache:
96
+ return None
97
+
98
+ if (cache_dir := self.cache_dir) is None:
99
+ from pip._internal.locations.base import USER_CACHE_DIR # noqa
100
+ cache_dir = USER_CACHE_DIR
101
+
102
+ check.state(not cache_dir or os.path.isabs(cache_dir))
103
+ return cache_dir
104
+
105
+
106
+ ##
107
+
108
+
109
+ @dc.dataclass(frozen=True, kw_only=True)
110
+ class SessionOpts:
111
+ DEFAULT_RETRIES: ta.ClassVar[int] = 5
112
+ retries: int | None = DEFAULT_RETRIES
113
+
114
+ DEFAULT_TIMEOUT: ta.ClassVar[int | None] = 15
115
+ timeout: int | None = DEFAULT_TIMEOUT
116
+
117
+ cert: str | None = None
118
+ client_cert: str | None = None
119
+ trusted_hosts: ta.Sequence[str] | None = None
120
+ proxy: str | None = None
121
+
122
+ keyring_provider: ta.Literal['auto', 'disabled', 'import', 'subprocess'] = 'auto'
123
+ no_input: bool = False
124
+
125
+
126
+ def _create_truststore_ssl_context() -> ssl.SSLContext:
127
+ from pip._vendor import certifi # noqa
128
+ from pip._vendor import truststore # noqa
129
+
130
+ ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
131
+ ctx.load_verify_locations(certifi.where())
132
+ return ctx
133
+
134
+
135
+ def build_session(
136
+ session_opts: SessionOpts = SessionOpts(),
137
+ *,
138
+ cache_opts: CacheOpts = CacheOpts(),
139
+ index_opts: IndexOptions = IndexOptions(),
140
+ ) -> PipSession:
141
+ ssl_context = _create_truststore_ssl_context()
142
+
143
+ session = PipSession(
144
+ cache=os.path.join(cache_dir, 'http-v2') if (cache_dir := cache_opts.get_cache_dir()) else None,
145
+ retries=session_opts.retries or 0,
146
+ trusted_hosts=session_opts.trusted_hosts or [],
147
+ index_urls=index_opts.get_index_urls(),
148
+ ssl_context=ssl_context,
149
+ )
150
+
151
+ # Handle custom ca-bundles from the user
152
+ if session_opts.cert:
153
+ session.verify = session_opts.cert # type: ignore[assignment]
154
+
155
+ # Handle SSL client certificate
156
+ if session_opts.client_cert:
157
+ session.cert = session_opts.client_cert
158
+
159
+ # Handle timeouts
160
+ if session_opts.timeout:
161
+ session.timeout = session_opts.timeout
162
+
163
+ # Handle configured proxies
164
+ if session_opts.proxy:
165
+ session.proxies = {
166
+ 'http': session_opts.proxy,
167
+ 'https': session_opts.proxy,
168
+ }
169
+ session.trust_env = False
170
+ session.pip_proxy = session_opts.proxy # type: ignore[assignment]
171
+
172
+ # Determine if we can prompt the user for authentication or not
173
+ auth: ta.Any = session.auth
174
+ auth.prompting = not session_opts.no_input
175
+ auth.keyring_provider = session_opts.keyring_provider
176
+
177
+ return session
178
+
179
+
180
+ ##
181
+
182
+
183
+ class MyPackageFinder(PackageFinder):
184
+ def __init__(self, *args: ta.Any, **kwargs: ta.Any) -> None:
185
+ super().__init__(*args, **kwargs)
186
+
187
+ self._link_pypi_dict_by_hash: dict[str, dict[str, ta.Any]] = {}
188
+
189
+ def get_link_pypi_dict(self, link: Link) -> dict[str, ta.Any] | None:
190
+ if link.hash is None:
191
+ return None
192
+ return self._link_pypi_dict_by_hash.get(link.hash)
193
+
194
+ def process_project_url(
195
+ self,
196
+ project_url: Link,
197
+ link_evaluator: LinkEvaluator,
198
+ ) -> list[InstallationCandidate]:
199
+ index_response = self._link_collector.fetch_response(project_url)
200
+ if index_response is None:
201
+ return []
202
+
203
+ page_links: list[Link] = []
204
+ if index_response.content_type.lower().startswith('application/vnd.pypi.simple.v1+json'):
205
+ data = json.loads(index_response.content)
206
+ for file in data.get('files', []):
207
+ link = Link.from_json(file, index_response.url)
208
+ if link is None:
209
+ continue
210
+ if link.hash is not None:
211
+ self._link_pypi_dict_by_hash[link.hash] = file
212
+ page_links.append(link)
213
+
214
+ else:
215
+ from pip._internal.index.collector import parse_links # noqa
216
+ page_links = list(parse_links(index_response))
217
+
218
+ from pip._internal.utils.logging import indent_log # noqa
219
+ with indent_log():
220
+ package_links = self.evaluate_links(
221
+ link_evaluator,
222
+ links=page_links,
223
+ )
224
+
225
+ return package_links
226
+
227
+
228
+ def build_package_finder(
229
+ session: PipSession,
230
+ *,
231
+ index_opts: IndexOptions = IndexOptions(),
232
+
233
+ find_links: ta.Sequence[str] | None = None,
234
+ pre: bool = False,
235
+ ) -> MyPackageFinder:
236
+ from pip._internal.models.search_scope import SearchScope # noqa
237
+ search_scope = SearchScope.create(
238
+ find_links=list(find_links or []),
239
+ index_urls=list(index_opts.get_index_urls() or []),
240
+ no_index=index_opts.no_index,
241
+ )
242
+
243
+ from pip._internal.index.collector import LinkCollector # noqa
244
+ link_collector = LinkCollector(
245
+ session=session,
246
+ search_scope=search_scope,
247
+ )
248
+
249
+ # Pass allow_yanked=False to ignore yanked versions.
250
+ from pip._internal.models.selection_prefs import SelectionPreferences # noqa
251
+ selection_prefs = SelectionPreferences(
252
+ allow_yanked=False,
253
+ allow_all_prereleases=pre,
254
+ )
255
+
256
+ return check.isinstance(MyPackageFinder.create(
257
+ link_collector=link_collector,
258
+ selection_prefs=selection_prefs,
259
+ ), MyPackageFinder)
260
+
261
+
262
+ ##
263
+
264
+
265
+ def get_dists(
266
+ *,
267
+ path: list[str] | None = None,
268
+ local: bool = False,
269
+ user: bool = False,
270
+ editable: bool = False,
271
+ include_editable: bool = True,
272
+ skip: ta.Container | None = None,
273
+ ) -> list[BaseDistribution]:
274
+ from pip._internal.metadata import get_environment # noqa
275
+ return list(get_environment(path).iter_installed_distributions(
276
+ local_only=local,
277
+ user_only=user,
278
+ editables_only=editable,
279
+ include_editables=include_editable,
280
+ skip=skip or set(),
281
+ ))
282
+
283
+
284
+ ##
285
+
286
+
287
+ @dc.dataclass()
288
+ class Package:
289
+ dist: BaseDistribution
290
+
291
+ @cached.function
292
+ def version(self) -> str:
293
+ from pip._vendor.packaging.version import InvalidVersion # noqa
294
+ try:
295
+ return str(self.dist.version)
296
+ except InvalidVersion:
297
+ pass
298
+ return self.dist.raw_version
299
+
300
+ @dc.dataclass(frozen=True)
301
+ class Candidate:
302
+ install: InstallationCandidate
303
+
304
+ pypi_dict: dict[str, ta.Any] | None = None
305
+
306
+ @cached.function
307
+ def upload_time(self) -> datetime.datetime | None:
308
+ if (ut := (self.pypi_dict or {}).get('upload-time')) is None:
309
+ return None
310
+
311
+ return datetime.datetime.fromisoformat(check.isinstance(ut, str)) # noqa
312
+
313
+ candidates: ta.Sequence[Candidate] | None = None
314
+
315
+ @dc.dataclass(frozen=True, kw_only=True)
316
+ class LatestInfo:
317
+ candidate: 'Package.Candidate'
318
+
319
+ version: Version
320
+ filetype: str
321
+
322
+ latest_info: LatestInfo | None = None
323
+
324
+
325
+ def set_package_finder_info(
326
+ pkg: Package,
327
+ finder: MyPackageFinder,
328
+ *,
329
+ pre: bool = False,
330
+ max_uploaded_at: datetime.datetime | None = None,
331
+ min_time_since_prev_version: datetime.timedelta | None = None,
332
+ ) -> None:
333
+ candidates = [
334
+ Package.Candidate(
335
+ c,
336
+ finder.get_link_pypi_dict(c.link),
337
+ )
338
+ for c in finder.find_all_candidates(pkg.dist.canonical_name)
339
+ ]
340
+ pkg.candidates = candidates
341
+
342
+ #
343
+
344
+ if not pre:
345
+ # Remove prereleases
346
+ candidates = [
347
+ c
348
+ for c in candidates
349
+ if not c.install.version.is_prerelease
350
+ ]
351
+
352
+ #
353
+
354
+ if min_time_since_prev_version is not None:
355
+ # candidates_by_version = col.multi_map((c.install.version, c) for c in candidates)
356
+ # uploaded_at_by_version = {
357
+ # v: min([c_ut for c in cs if (c_ut := c.upload_time()) is not None], default=None)
358
+ # for v, cs in candidates_by_version.items()
359
+ # }
360
+ raise NotImplementedError
361
+
362
+ #
363
+
364
+ if max_uploaded_at is not None:
365
+ candidates = [
366
+ c
367
+ for c in candidates
368
+ if not (
369
+ (c_dt := c.upload_time()) is not None and
370
+ c_dt > max_uploaded_at
371
+ )
372
+ ]
373
+
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
+ )
402
+
403
+
404
+ ##
405
+
406
+
407
+ class Context:
408
+ def __init__(self) -> None:
409
+ super().__init__()
410
+
411
+ #
412
+
413
+ _session: PipSession | None = None
414
+
415
+ def session(self) -> PipSession:
416
+ if self._session is None:
417
+ self._session = build_session()
418
+ return self._session
419
+
420
+ #
421
+
422
+ _finder: MyPackageFinder | None = None
423
+
424
+ def finder(self) -> MyPackageFinder:
425
+ if self._finder is None:
426
+ self._finder = build_package_finder(self.session())
427
+ return self._finder
428
+
429
+ #
430
+
431
+ def close(self) -> None:
432
+ if self._session is not None:
433
+ self._session.close()
434
+ self._session = None
435
+
436
+
437
+ ##
438
+
439
+
440
+ def human_round_td(td: datetime.timedelta) -> str:
441
+ """Round a timedelta to its largest sensible unit."""
442
+
443
+ seconds = td.total_seconds()
444
+
445
+ # Define unit sizes in seconds
446
+ units = [
447
+ ('y', 365 * 24 * 3600),
448
+ ('mo', 30 * 24 * 3600),
449
+ ('w', 7 * 24 * 3600),
450
+ ('d', 24 * 3600),
451
+ ('h', 3600),
452
+ ('m', 60),
453
+ ('s', 1),
454
+ ]
455
+
456
+ for suffix, unit_seconds in units:
457
+ value = seconds / unit_seconds
458
+ if abs(value) >= 1: # first unit where magnitude is >= 1
459
+ return f'{round(value)}{suffix}'
460
+
461
+ return '0s'
462
+
463
+
464
+ #
465
+
466
+
467
+ def format_for_json(
468
+ pkgs: ta.Sequence[Package],
469
+ *,
470
+ now: datetime.datetime | None = None,
471
+ ) -> list[dict[str, ta.Any]]:
472
+ infos: list[dict[str, ta.Any]] = []
473
+
474
+ for pkg in pkgs:
475
+ latest_info = check.not_none(pkg.latest_info)
476
+
477
+ info = {
478
+ 'name': pkg.dist.raw_name,
479
+ 'version': pkg.version(),
480
+ 'location': pkg.dist.location or '',
481
+ 'installer': pkg.dist.installer,
482
+ 'latest_version': str(latest_info.version),
483
+ 'latest_filetype': latest_info.filetype,
484
+ }
485
+
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
+ if editable_project_location := pkg.dist.editable_project_location:
490
+ info['editable_project_location'] = editable_project_location
491
+
492
+ infos.append(info)
493
+
494
+ return infos
495
+
496
+
497
+ #
498
+
499
+
500
+ def format_for_columns(pkgs: ta.Sequence[Package]) -> tuple[list[list[str]], list[str]]:
501
+ """Convert the package data into something usable by output_package_listing_columns."""
502
+
503
+ header = ['Package', 'Version', 'Latest', 'Age', 'Type']
504
+
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)
533
+
534
+ if has_build_tags:
535
+ row.append(build_tags[i] or '')
536
+
537
+ if has_editables:
538
+ row.append(proj.dist.editable_project_location or '')
539
+
540
+ data.append(row)
541
+
542
+ return data, header
543
+
544
+
545
+ def render_package_listing_columns(data: list[list[str]], header: list[str]) -> list[str]:
546
+ # insert the header first: we need to know the size of column names
547
+ if len(data) > 0:
548
+ data.insert(0, header)
549
+
550
+ from pip._internal.utils.misc import tabulate # noqa
551
+ pkg_strings, sizes = tabulate(data)
552
+
553
+ # Create and add a separator.
554
+ if len(data) > 0:
555
+ pkg_strings.insert(1, ' '.join('-' * x for x in sizes))
556
+
557
+ return pkg_strings
558
+
559
+
560
+ ##
561
+
562
+
563
+ def _main() -> None:
564
+ import argparse
565
+
566
+ parser = argparse.ArgumentParser()
567
+ 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)
570
+ parser.add_argument('--json', action='store_true')
571
+ args = parser.parse_args()
572
+
573
+ max_uploaded_at: datetime.datetime | None = now_utc() - datetime.timedelta(hours=args.min_age_h)
574
+ min_time_since_prev_version: datetime.timedelta | None = None # datetime.timedelta(days=1)
575
+
576
+ #
577
+
578
+ from pip._internal.utils.compat import stdlib_pkgs # noqa
579
+ skip = set(stdlib_pkgs)
580
+ if args.excludes:
581
+ from pip._vendor.packaging.utils import canonicalize_name # noqa
582
+ skip.update(canonicalize_name(n) for n in args.excludes)
583
+
584
+ pkgs = [
585
+ Package(dist)
586
+ for dist in get_dists(
587
+ skip=skip,
588
+ )
589
+ ]
590
+
591
+ #
592
+
593
+ with ObjectPool[Context](Context).manage(lambda ctx: ctx.close()) as ctx_pool:
594
+ with conc.new_executor(args.parallelism) as exe:
595
+ def set_pkg_latest_info(pkg: Package) -> None:
596
+ with ctx_pool.acquire() as ctx: # noqa
597
+ set_package_finder_info(
598
+ pkg,
599
+ ctx.finder(),
600
+ max_uploaded_at=max_uploaded_at,
601
+ min_time_since_prev_version=min_time_since_prev_version,
602
+ )
603
+
604
+ conc.wait_all_futures_or_raise([
605
+ exe.submit(set_pkg_latest_info, pkg)
606
+ for pkg in pkgs
607
+ ])
608
+
609
+ #
610
+
611
+ pkgs = [
612
+ pkg
613
+ for pkg in pkgs
614
+ if (li := pkg.latest_info) is not None
615
+ and li.version > pkg.dist.version
616
+ ]
617
+
618
+ pkgs.sort(key=lambda x: x.dist.raw_name)
619
+
620
+ #
621
+
622
+ if args.json:
623
+ print(json.dumps_pretty(format_for_json(pkgs)))
624
+ else:
625
+ print('\n'.join(render_package_listing_columns(*format_for_columns(pkgs))))
626
+
627
+
628
+ if __name__ == '__main__':
629
+ _main()