cli-tools-kit 0.6.4__tar.gz → 0.7.1__tar.gz
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.
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/PKG-INFO +35 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/README.md +34 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/sources.py +398 -9
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/PKG-INFO +35 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/pyproject.toml +1 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_sources.py +439 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/LICENSE +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/gui_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/tool_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/tui_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/SOURCES.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/setup.cfg +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_host.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_taxonomy_cluster.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_tool_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_tui_installer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-tools-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
|
|
5
5
|
Author: Steffen Probst
|
|
6
6
|
License: MIT License
|
|
@@ -403,6 +403,37 @@ name = "manim-kit"
|
|
|
403
403
|
url = "https://github.com/AutomatedAlchemy/manim-kit"
|
|
404
404
|
```
|
|
405
405
|
|
|
406
|
+
An entry may name a GitHub organisation instead of one repo. The installer lists
|
|
407
|
+
the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
|
|
408
|
+
source, so a new tool in the org appears without anyone editing this file:
|
|
409
|
+
|
|
410
|
+
```toml
|
|
411
|
+
[[source]]
|
|
412
|
+
org = "AutomatedAlchemy"
|
|
413
|
+
topic = "cli-tool-kit" # the default when omitted
|
|
414
|
+
exclude = ["alchemy-installer"] # repo names to skip
|
|
415
|
+
include = ["manim-kit"] # allowlist; wins over exclude
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The topic decides what is cloned, and the walker plus the `--advertise` probe
|
|
419
|
+
decide what is a tool: a repo that carries the topic but holds no tool clones,
|
|
420
|
+
advertises nothing and is dropped like any other directory. `org` cannot be
|
|
421
|
+
combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
|
|
422
|
+
as a listed repo wins, so one tool can be pinned to a fork or a local checkout
|
|
423
|
+
while the rest of the org follows the listing. Archived repos are left out.
|
|
424
|
+
`include` is an allowlist and overrides `exclude`; the topic is required either
|
|
425
|
+
way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
|
|
426
|
+
pins a tracked source, so a checkout already on the machine is used instead of
|
|
427
|
+
being cloned.
|
|
428
|
+
|
|
429
|
+
The listing is one `GET` to `api.github.com`, cached for a day under the
|
|
430
|
+
identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
|
|
431
|
+
logged in, its token is used and the org's private repos are listed too; without
|
|
432
|
+
it the public listing is used and nothing is required. When the listing fails the
|
|
433
|
+
cached one is used however old it is, and with no cache at all the directories
|
|
434
|
+
already under the root are used — both say so in one line. `--check` never
|
|
435
|
+
fetches: it reads the cache, or the root.
|
|
436
|
+
|
|
406
437
|
`installer.local.toml` next to it is optional and belongs to one machine, so keep
|
|
407
438
|
it out of git. It sets the root and replaces a `path` for a source matched by
|
|
408
439
|
`name`:
|
|
@@ -442,6 +473,9 @@ colleague without access to a private repo still gets everybody else's tools.
|
|
|
442
473
|
given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
|
|
443
474
|
hook, which `--check` skips, so the login check stays network-free.
|
|
444
475
|
|
|
476
|
+
An `org` entry is the only thing in this module that reaches anything but git,
|
|
477
|
+
it is opt-in per entry, and nothing is fetched when no such entry exists.
|
|
478
|
+
|
|
445
479
|
A repo that is itself an installer tree can carry its own `installer.toml`. Its
|
|
446
480
|
`[[source]]` entries are resolved too, one nested level deep and no further, with
|
|
447
481
|
paths relative to that file and clones under the same root. A path that is
|
|
@@ -350,6 +350,37 @@ name = "manim-kit"
|
|
|
350
350
|
url = "https://github.com/AutomatedAlchemy/manim-kit"
|
|
351
351
|
```
|
|
352
352
|
|
|
353
|
+
An entry may name a GitHub organisation instead of one repo. The installer lists
|
|
354
|
+
the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
|
|
355
|
+
source, so a new tool in the org appears without anyone editing this file:
|
|
356
|
+
|
|
357
|
+
```toml
|
|
358
|
+
[[source]]
|
|
359
|
+
org = "AutomatedAlchemy"
|
|
360
|
+
topic = "cli-tool-kit" # the default when omitted
|
|
361
|
+
exclude = ["alchemy-installer"] # repo names to skip
|
|
362
|
+
include = ["manim-kit"] # allowlist; wins over exclude
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
The topic decides what is cloned, and the walker plus the `--advertise` probe
|
|
366
|
+
decide what is a tool: a repo that carries the topic but holds no tool clones,
|
|
367
|
+
advertises nothing and is dropped like any other directory. `org` cannot be
|
|
368
|
+
combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
|
|
369
|
+
as a listed repo wins, so one tool can be pinned to a fork or a local checkout
|
|
370
|
+
while the rest of the org follows the listing. Archived repos are left out.
|
|
371
|
+
`include` is an allowlist and overrides `exclude`; the topic is required either
|
|
372
|
+
way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
|
|
373
|
+
pins a tracked source, so a checkout already on the machine is used instead of
|
|
374
|
+
being cloned.
|
|
375
|
+
|
|
376
|
+
The listing is one `GET` to `api.github.com`, cached for a day under the
|
|
377
|
+
identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
|
|
378
|
+
logged in, its token is used and the org's private repos are listed too; without
|
|
379
|
+
it the public listing is used and nothing is required. When the listing fails the
|
|
380
|
+
cached one is used however old it is, and with no cache at all the directories
|
|
381
|
+
already under the root are used — both say so in one line. `--check` never
|
|
382
|
+
fetches: it reads the cache, or the root.
|
|
383
|
+
|
|
353
384
|
`installer.local.toml` next to it is optional and belongs to one machine, so keep
|
|
354
385
|
it out of git. It sets the root and replaces a `path` for a source matched by
|
|
355
386
|
`name`:
|
|
@@ -389,6 +420,9 @@ colleague without access to a private repo still gets everybody else's tools.
|
|
|
389
420
|
given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
|
|
390
421
|
hook, which `--check` skips, so the login check stays network-free.
|
|
391
422
|
|
|
423
|
+
An `org` entry is the only thing in this module that reaches anything but git,
|
|
424
|
+
it is opt-in per entry, and nothing is fetched when no such entry exists.
|
|
425
|
+
|
|
392
426
|
A repo that is itself an installer tree can carry its own `installer.toml`. Its
|
|
393
427
|
`[[source]]` entries are resolved too, one nested level deep and no further, with
|
|
394
428
|
paths relative to that file and clones under the same root. A path that is
|
|
@@ -40,6 +40,21 @@ are resolved too, one nested level deep and no further. Paths in a nested file
|
|
|
40
40
|
are relative to that file, clones still go under the same root, a path already
|
|
41
41
|
resolved is not visited twice, and duplicates are dropped.
|
|
42
42
|
|
|
43
|
+
An entry may name a GitHub organisation instead of one repo. The installer
|
|
44
|
+
lists the org's repos, keeps the ones carrying a topic, and turns each into an
|
|
45
|
+
ordinary source, so everything after that step is unchanged:
|
|
46
|
+
|
|
47
|
+
[[source]]
|
|
48
|
+
org = "AutomatedAlchemy"
|
|
49
|
+
topic = "cli-tool-kit" # the default when omitted
|
|
50
|
+
exclude = ["alchemy-installer"] # repo names to skip
|
|
51
|
+
include = ["manim-kit"] # allowlist; wins over exclude
|
|
52
|
+
|
|
53
|
+
``org`` is mutually exclusive with ``url`` and ``path``. The listing is cached
|
|
54
|
+
for a day, a network error falls back to the cached list and then to the
|
|
55
|
+
directories already under the root, and an explicit ``[[source]]`` with the
|
|
56
|
+
same ``name`` always wins over an org-derived one.
|
|
57
|
+
|
|
43
58
|
The whole feature is three calls:
|
|
44
59
|
|
|
45
60
|
sources = load_sources("installer.toml")
|
|
@@ -54,15 +69,22 @@ or, for a wrapper that just wants the installer:
|
|
|
54
69
|
|
|
55
70
|
from __future__ import annotations
|
|
56
71
|
|
|
72
|
+
import json
|
|
57
73
|
import os
|
|
74
|
+
import re
|
|
75
|
+
import shutil
|
|
58
76
|
import subprocess
|
|
59
77
|
import sys
|
|
78
|
+
import time
|
|
79
|
+
import urllib.error
|
|
80
|
+
import urllib.request
|
|
60
81
|
from dataclasses import dataclass
|
|
61
82
|
from pathlib import Path
|
|
62
|
-
from typing import Callable, List, Optional, Sequence
|
|
83
|
+
from typing import Callable, List, Optional, Sequence, Tuple
|
|
63
84
|
|
|
64
|
-
__all__ = ["Source", "
|
|
65
|
-
"
|
|
85
|
+
__all__ = ["Source", "OrgSource", "load_sources", "expand_org_sources",
|
|
86
|
+
"resolve_sources", "run_installer", "local_root", "save_local_root",
|
|
87
|
+
"default_root"]
|
|
66
88
|
|
|
67
89
|
# How far below the top-level installer.toml a nested one is still read.
|
|
68
90
|
MAX_NESTING = 1
|
|
@@ -87,6 +109,53 @@ class Source:
|
|
|
87
109
|
path: Optional[str] = None
|
|
88
110
|
|
|
89
111
|
|
|
112
|
+
# The default GitHub topic an org's repos are tagged with to be offered.
|
|
113
|
+
DEFAULT_ORG_TOPIC = "cli-tool-kit"
|
|
114
|
+
|
|
115
|
+
# A GitHub org or user name, and a topic: both are pasted into a URL path, so
|
|
116
|
+
# they stay to the characters GitHub itself allows. \Z, not $, so a trailing
|
|
117
|
+
# newline cannot smuggle a second path segment in.
|
|
118
|
+
_ORG_RE = re.compile(r"^[A-Za-z0-9-]{1,39}\Z")
|
|
119
|
+
_TOPIC_RE = re.compile(r"^[A-Za-z0-9-]{1,50}\Z")
|
|
120
|
+
|
|
121
|
+
# The one host this module talks to, hard-coded so a config file cannot point
|
|
122
|
+
# the listing at somewhere else.
|
|
123
|
+
GITHUB_API = "https://api.github.com"
|
|
124
|
+
|
|
125
|
+
# How long a cached listing is used without asking GitHub again.
|
|
126
|
+
ORG_CACHE_TTL = 24 * 60 * 60
|
|
127
|
+
|
|
128
|
+
# Enough for 500 repos; a listing longer than that is a config mistake.
|
|
129
|
+
ORG_MAX_PAGES = 5
|
|
130
|
+
|
|
131
|
+
ORG_TIMEOUT = 10
|
|
132
|
+
GH_TOKEN_TIMEOUT = 5
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class OrgSource:
|
|
137
|
+
"""One GitHub organisation whose topic-tagged repos become sources.
|
|
138
|
+
|
|
139
|
+
Expanded into ordinary :class:`Source` entries by
|
|
140
|
+
:func:`expand_org_sources`, which is the only place in this module that
|
|
141
|
+
reaches the network.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
org: str
|
|
145
|
+
topic: str = DEFAULT_ORG_TOPIC
|
|
146
|
+
include: Tuple[str, ...] = ()
|
|
147
|
+
exclude: Tuple[str, ...] = ()
|
|
148
|
+
# ``installer.local.toml``'s ``[[source]]`` path overrides, as already
|
|
149
|
+
# absolute paths keyed by repo name. A listed repo whose name is in here
|
|
150
|
+
# resolves to that checkout instead of being cloned, exactly as the
|
|
151
|
+
# override works for a tracked ``[[source]]``. A tuple of pairs, not a
|
|
152
|
+
# dict, because this dataclass is frozen and has to stay hashable.
|
|
153
|
+
pins: Tuple[Tuple[str, str], ...] = ()
|
|
154
|
+
|
|
155
|
+
def pin_for(self, name: str) -> Optional[str]:
|
|
156
|
+
return dict(self.pins).get(name)
|
|
157
|
+
|
|
158
|
+
|
|
90
159
|
# --- TOML -------------------------------------------------------------------
|
|
91
160
|
|
|
92
161
|
def _toml_module():
|
|
@@ -175,12 +244,51 @@ def save_local_root(config_path, root, local_path=None, log: Callable = print) -
|
|
|
175
244
|
return True
|
|
176
245
|
|
|
177
246
|
|
|
178
|
-
def
|
|
247
|
+
def _names(entry: dict, key: str) -> Tuple[str, ...]:
|
|
248
|
+
"""One of the ``include`` / ``exclude`` lists, as a tuple of strings."""
|
|
249
|
+
raw = entry.get(key)
|
|
250
|
+
if not isinstance(raw, list):
|
|
251
|
+
return ()
|
|
252
|
+
return tuple(item for item in raw if isinstance(item, str) and item)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _org_source(entry: dict, config_name: str, log: Callable,
|
|
256
|
+
pins: Tuple[Tuple[str, str], ...] = ()) -> Optional[OrgSource]:
|
|
257
|
+
"""One ``[[source]]`` table with an ``org``, or None when it is unusable.
|
|
258
|
+
|
|
259
|
+
Reported and dropped the same way an https-only violation is: one line
|
|
260
|
+
naming what is wrong, and the other sources still install.
|
|
261
|
+
"""
|
|
262
|
+
org = entry.get("org")
|
|
263
|
+
if not isinstance(org, str) or not _ORG_RE.match(org):
|
|
264
|
+
log(f"{config_name}: a [[source]] with an unusable org "
|
|
265
|
+
f"({org!r}), skipped")
|
|
266
|
+
return None
|
|
267
|
+
if entry.get("url") or entry.get("path"):
|
|
268
|
+
log(f"{org}: a [[source]] cannot have both org and url/path, skipped")
|
|
269
|
+
return None
|
|
270
|
+
topic = entry.get("topic", DEFAULT_ORG_TOPIC)
|
|
271
|
+
if not isinstance(topic, str) or not _TOPIC_RE.match(topic):
|
|
272
|
+
log(f"{org}: {topic!r} is not a usable topic, skipped")
|
|
273
|
+
return None
|
|
274
|
+
return OrgSource(org=org, topic=topic,
|
|
275
|
+
include=_names(entry, "include"),
|
|
276
|
+
exclude=_names(entry, "exclude"),
|
|
277
|
+
pins=pins)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def load_sources(config_path, local_path=None, log: Callable = print) -> List:
|
|
179
281
|
"""The ``[[source]]`` entries of one TOML file, local overrides applied.
|
|
180
282
|
|
|
181
283
|
``local_path`` defaults to ``installer.local.toml`` next to ``config_path``.
|
|
182
284
|
A ``path`` is taken relative to the file it is written in. An entry without
|
|
183
285
|
a name is reported and dropped.
|
|
286
|
+
|
|
287
|
+
An entry that carries an ``org`` instead of a ``name`` becomes an
|
|
288
|
+
:class:`OrgSource` in the returned list. :func:`expand_org_sources` turns
|
|
289
|
+
those into ordinary :class:`Source` entries; :func:`resolve_sources` ignores
|
|
290
|
+
any that are left, so a caller that does not expand simply gets no tools
|
|
291
|
+
from the org rather than an error.
|
|
184
292
|
"""
|
|
185
293
|
config_path = Path(config_path)
|
|
186
294
|
local_path = Path(local_path) if local_path is not None else _local_path_for(config_path)
|
|
@@ -191,14 +299,29 @@ def load_sources(config_path, local_path=None, log: Callable = print) -> List[So
|
|
|
191
299
|
for entry in local.get("source") or []
|
|
192
300
|
if isinstance(entry, dict) and entry.get("name")}
|
|
193
301
|
|
|
194
|
-
|
|
302
|
+
# The same overrides an explicit [[source]] gets, kept for the org
|
|
303
|
+
# expansion: the repos it derives do not exist yet at this point, so a pin
|
|
304
|
+
# naming one of them can only be applied later, by name.
|
|
305
|
+
pins = tuple((name, _absolute(local_path.parent, entry["path"]))
|
|
306
|
+
for name, entry in overrides.items()
|
|
307
|
+
if isinstance(entry.get("path"), str) and entry["path"])
|
|
308
|
+
|
|
309
|
+
sources: List = []
|
|
195
310
|
for entry in data.get("source") or []:
|
|
196
311
|
if not isinstance(entry, dict):
|
|
197
312
|
continue
|
|
198
313
|
name = entry.get("name")
|
|
199
314
|
if not name:
|
|
315
|
+
if "org" in entry:
|
|
316
|
+
org_source = _org_source(entry, config_path.name, log, pins)
|
|
317
|
+
if org_source is not None:
|
|
318
|
+
sources.append(org_source)
|
|
319
|
+
continue
|
|
200
320
|
log(f"{config_path.name}: a [[source]] without a name, skipped")
|
|
201
321
|
continue
|
|
322
|
+
if entry.get("org"):
|
|
323
|
+
log(f"{name}: a [[source]] cannot have both org and url/path, skipped")
|
|
324
|
+
continue
|
|
202
325
|
override = overrides.get(name, {}).get("path")
|
|
203
326
|
raw = override or entry.get("path")
|
|
204
327
|
base = local_path.parent if override else config_path.parent
|
|
@@ -207,6 +330,253 @@ def load_sources(config_path, local_path=None, log: Callable = print) -> List[So
|
|
|
207
330
|
return sources
|
|
208
331
|
|
|
209
332
|
|
|
333
|
+
# --- GitHub org listings ----------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def _gh_token() -> Optional[str]:
|
|
336
|
+
"""The token ``gh auth token`` prints, or None.
|
|
337
|
+
|
|
338
|
+
Opportunistic: with the GitHub CLI logged in, the listing also sees the
|
|
339
|
+
org's private repos. Without it the public listing is used. The token is
|
|
340
|
+
never logged.
|
|
341
|
+
"""
|
|
342
|
+
if not shutil.which("gh"):
|
|
343
|
+
return None
|
|
344
|
+
try:
|
|
345
|
+
result = subprocess.run(["gh", "auth", "token"], capture_output=True,
|
|
346
|
+
text=True, timeout=GH_TOKEN_TIMEOUT)
|
|
347
|
+
except (OSError, subprocess.SubprocessError):
|
|
348
|
+
return None
|
|
349
|
+
if result.returncode != 0:
|
|
350
|
+
return None
|
|
351
|
+
token = result.stdout.strip()
|
|
352
|
+
return token or None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _api_version() -> str:
|
|
356
|
+
from . import __version__ # noqa: PLC0415 — avoids an import cycle at module load
|
|
357
|
+
return __version__
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _fetch_org_repos(org: str, token: Optional[str]) -> List[dict]:
|
|
361
|
+
"""Every repo of one org, over as many pages as GitHub needs.
|
|
362
|
+
|
|
363
|
+
Raises ``OSError`` (which ``urllib`` errors are) on anything that goes
|
|
364
|
+
wrong, so the one caller can fall back in a single place.
|
|
365
|
+
"""
|
|
366
|
+
headers = {"Accept": "application/vnd.github+json",
|
|
367
|
+
"User-Agent": f"cli-tools-kit/{_api_version()}"}
|
|
368
|
+
if token:
|
|
369
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
370
|
+
repos: List[dict] = []
|
|
371
|
+
for page in range(1, ORG_MAX_PAGES + 1):
|
|
372
|
+
query = f"per_page=100&page={page}"
|
|
373
|
+
if not token:
|
|
374
|
+
# Without a token only public repos are visible anyway; asking for
|
|
375
|
+
# them explicitly keeps the response small.
|
|
376
|
+
query += "&type=public"
|
|
377
|
+
request = urllib.request.Request( # noqa: S310 — the host is hard-coded above
|
|
378
|
+
f"{GITHUB_API}/orgs/{org}/repos?{query}", headers=headers)
|
|
379
|
+
with urllib.request.urlopen(request, timeout=ORG_TIMEOUT) as response:
|
|
380
|
+
batch = json.loads(response.read().decode("utf-8"))
|
|
381
|
+
if not isinstance(batch, list):
|
|
382
|
+
raise OSError("the listing was not a JSON array")
|
|
383
|
+
repos.extend(item for item in batch if isinstance(item, dict))
|
|
384
|
+
if len(batch) < 100:
|
|
385
|
+
break
|
|
386
|
+
return repos
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _keep_fields(repos: Sequence[dict]) -> List[dict]:
|
|
390
|
+
"""Only the fields this module uses, so the cache stays small and readable."""
|
|
391
|
+
keep = ("name", "clone_url", "topics", "archived", "default_branch",
|
|
392
|
+
"description")
|
|
393
|
+
return [{field_name: repo.get(field_name) for field_name in keep}
|
|
394
|
+
for repo in repos if repo.get("name")]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _cache_file(cache_dir, org: str) -> Path:
|
|
398
|
+
return Path(os.path.expanduser(str(cache_dir))) / f"org-{org}.json"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _read_org_cache(cache_dir, org: str) -> Optional[dict]:
|
|
402
|
+
"""The cached listing for one org, or None when there is none to read."""
|
|
403
|
+
try:
|
|
404
|
+
with open(_cache_file(cache_dir, org), encoding="utf-8") as fh:
|
|
405
|
+
cached = json.load(fh)
|
|
406
|
+
except (OSError, ValueError):
|
|
407
|
+
return None
|
|
408
|
+
if not isinstance(cached, dict) or not isinstance(cached.get("repos"), list):
|
|
409
|
+
return None
|
|
410
|
+
return cached
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _write_org_cache(cache_dir, org: str, topic: str, repos: Sequence[dict],
|
|
414
|
+
log: Callable) -> None:
|
|
415
|
+
path = _cache_file(cache_dir, org)
|
|
416
|
+
try:
|
|
417
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
418
|
+
path.write_text(json.dumps({"fetched_at": time.time(), "topic": topic,
|
|
419
|
+
"repos": list(repos)}, indent=1),
|
|
420
|
+
encoding="utf-8")
|
|
421
|
+
except OSError as exc:
|
|
422
|
+
log(f"{org}: the listing was not cached ({exc})")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _age(seconds: float) -> str:
|
|
426
|
+
""""2h ago", "3 days ago" — how long ago a listing was fetched."""
|
|
427
|
+
delta = max(0.0, time.time() - seconds)
|
|
428
|
+
if delta < 90 * 60:
|
|
429
|
+
return f"{int(delta // 60)}min ago"
|
|
430
|
+
if delta < 36 * 3600:
|
|
431
|
+
return f"{int(delta // 3600)}h ago"
|
|
432
|
+
return f"{int(delta // 86400)} days ago"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _selected(entry: OrgSource, repos: Sequence[dict]) -> List[dict]:
|
|
436
|
+
"""The repos of one listing this entry offers.
|
|
437
|
+
|
|
438
|
+
``include`` is an allowlist and wins over ``exclude``; the topic is still
|
|
439
|
+
required either way, so a repo that lost its tag stops being offered
|
|
440
|
+
without anyone having to edit the config.
|
|
441
|
+
"""
|
|
442
|
+
chosen = []
|
|
443
|
+
for repo in repos:
|
|
444
|
+
name = repo.get("name")
|
|
445
|
+
if not name or repo.get("archived"):
|
|
446
|
+
continue
|
|
447
|
+
topics = repo.get("topics") or []
|
|
448
|
+
if entry.topic not in topics:
|
|
449
|
+
continue
|
|
450
|
+
if entry.include:
|
|
451
|
+
if name not in entry.include:
|
|
452
|
+
continue
|
|
453
|
+
elif name in entry.exclude:
|
|
454
|
+
continue
|
|
455
|
+
chosen.append(repo)
|
|
456
|
+
return chosen
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _from_root(entry: OrgSource, root, log: Callable) -> List[Source]:
|
|
460
|
+
"""The last fallback: the org's checkouts that are already under the root.
|
|
461
|
+
|
|
462
|
+
No listing and no cache, so what is on disk is all this run knows about.
|
|
463
|
+
A path-only source, because without a listing there is no clone URL.
|
|
464
|
+
"""
|
|
465
|
+
base = Path(os.path.expanduser(str(root)))
|
|
466
|
+
found = []
|
|
467
|
+
try:
|
|
468
|
+
entries = sorted(item for item in base.iterdir() if item.is_dir())
|
|
469
|
+
except OSError:
|
|
470
|
+
entries = []
|
|
471
|
+
for item in entries:
|
|
472
|
+
if entry.include:
|
|
473
|
+
if item.name not in entry.include:
|
|
474
|
+
continue
|
|
475
|
+
elif item.name in entry.exclude:
|
|
476
|
+
continue
|
|
477
|
+
found.append(Source(name=item.name, path=str(item.resolve())))
|
|
478
|
+
log(f"{entry.org}: no listing and no cache, using the "
|
|
479
|
+
f"{len(found)} checkout(s) already under {base}")
|
|
480
|
+
return found
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _org_listing(entry: OrgSource, *, refresh: bool, clone: bool, cache_dir,
|
|
484
|
+
log: Callable) -> Optional[Tuple[List[dict], str]]:
|
|
485
|
+
"""One org's repo list plus the line describing where it came from.
|
|
486
|
+
|
|
487
|
+
None when neither the network nor the cache produced one. ``clone=False``
|
|
488
|
+
is the network-free path, so it never fetches.
|
|
489
|
+
"""
|
|
490
|
+
cached = _read_org_cache(cache_dir, entry.org)
|
|
491
|
+
fetched_at = cached.get("fetched_at") if cached else None
|
|
492
|
+
fresh_enough = (isinstance(fetched_at, (int, float))
|
|
493
|
+
and time.time() - fetched_at < ORG_CACHE_TTL)
|
|
494
|
+
|
|
495
|
+
if not clone:
|
|
496
|
+
if cached is None:
|
|
497
|
+
return None
|
|
498
|
+
return cached["repos"], f"cached listing from {_age(fetched_at or 0)}"
|
|
499
|
+
if cached is not None and fresh_enough and not refresh:
|
|
500
|
+
return cached["repos"], f"listed {_age(fetched_at)}"
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
repos = _keep_fields(_fetch_org_repos(entry.org, _gh_token()))
|
|
504
|
+
except (OSError, ValueError, urllib.error.HTTPError) as exc:
|
|
505
|
+
reason = getattr(exc, "reason", None) or exc
|
|
506
|
+
if cached is None:
|
|
507
|
+
return None
|
|
508
|
+
log(f"{entry.org}: not listed, using the cached listing from "
|
|
509
|
+
f"{_age(fetched_at or 0)} ({reason})")
|
|
510
|
+
return cached["repos"], f"cached listing from {_age(fetched_at or 0)}"
|
|
511
|
+
|
|
512
|
+
if cached is not None:
|
|
513
|
+
before = {repo.get("name") for repo in _selected(entry, cached["repos"])}
|
|
514
|
+
gone = sorted(before - {repo.get("name") for repo in _selected(entry, repos)})
|
|
515
|
+
if gone:
|
|
516
|
+
log(f"{entry.org}: {len(gone)} repos dropped since the last "
|
|
517
|
+
f"listing: {', '.join(gone)}")
|
|
518
|
+
_write_org_cache(cache_dir, entry.org, entry.topic, repos, log)
|
|
519
|
+
return repos, "listed just now"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def expand_org_sources(sources: Sequence, *, refresh: bool = False, cache_dir,
|
|
523
|
+
root=None, clone: bool = True,
|
|
524
|
+
log: Callable = print) -> List[Source]:
|
|
525
|
+
"""Replace every :class:`OrgSource` with the repos it stands for.
|
|
526
|
+
|
|
527
|
+
Each kept repo becomes an ordinary ``Source(name=<repo>, url=<clone_url>)``,
|
|
528
|
+
so resolution, cloning, nesting, discovery and the ``--advertise`` probe all
|
|
529
|
+
work on it unchanged. A repo that carries the topic but turns out to hold no
|
|
530
|
+
tool clones, advertises nothing, and is dropped by the walker as any other
|
|
531
|
+
directory is.
|
|
532
|
+
|
|
533
|
+
An explicit ``[[source]]`` with the same ``name`` wins, so one repo can be
|
|
534
|
+
pinned to a fork or a local checkout while the rest of the org follows the
|
|
535
|
+
listing. A ``path`` in ``installer.local.toml`` naming a listed repo pins it
|
|
536
|
+
the same way, carried here on ``OrgSource.pins`` because the repo it names
|
|
537
|
+
does not exist yet when that file is read.
|
|
538
|
+
|
|
539
|
+
``clone=False`` is the network-free path the login check takes: it uses the
|
|
540
|
+
cache, then the directories already under ``root``, and never fetches.
|
|
541
|
+
"""
|
|
542
|
+
explicit = {source.name for source in sources if isinstance(source, Source)}
|
|
543
|
+
expanded: List[Source] = []
|
|
544
|
+
for source in sources:
|
|
545
|
+
if isinstance(source, Source):
|
|
546
|
+
expanded.append(source)
|
|
547
|
+
continue
|
|
548
|
+
if not isinstance(source, OrgSource):
|
|
549
|
+
continue
|
|
550
|
+
listing = _org_listing(source, refresh=refresh, clone=clone,
|
|
551
|
+
cache_dir=cache_dir, log=log)
|
|
552
|
+
if listing is None:
|
|
553
|
+
derived = _from_root(source, root, log) if root is not None else []
|
|
554
|
+
else:
|
|
555
|
+
repos, provenance = listing
|
|
556
|
+
kept = _selected(source, repos)
|
|
557
|
+
log(f"{source.org}: {len(kept)} repos tagged {source.topic} "
|
|
558
|
+
f"({provenance})")
|
|
559
|
+
derived = [Source(name=repo["name"], url=repo.get("clone_url"))
|
|
560
|
+
for repo in kept]
|
|
561
|
+
# A local pin wins over the clone URL, so the checkout on this machine
|
|
562
|
+
# is used and nothing is fetched. A pin whose directory is gone is left
|
|
563
|
+
# in place: _resolve_one falls through to the URL, which is what the
|
|
564
|
+
# override does for a tracked source too.
|
|
565
|
+
derived = [Source(name=candidate.name, url=candidate.url,
|
|
566
|
+
path=source.pin_for(candidate.name) or candidate.path)
|
|
567
|
+
for candidate in derived]
|
|
568
|
+
for candidate in derived:
|
|
569
|
+
if candidate.name in explicit:
|
|
570
|
+
continue
|
|
571
|
+
url = candidate.url
|
|
572
|
+
if url is not None and not str(url).lower().startswith("https://"):
|
|
573
|
+
log(f"{candidate.name}: {url} is not an https:// URL, skipped")
|
|
574
|
+
continue
|
|
575
|
+
explicit.add(candidate.name)
|
|
576
|
+
expanded.append(candidate)
|
|
577
|
+
return expanded
|
|
578
|
+
|
|
579
|
+
|
|
210
580
|
# --- resolution -------------------------------------------------------------
|
|
211
581
|
|
|
212
582
|
def _git(*args):
|
|
@@ -273,6 +643,10 @@ def _resolve_level(sources: Sequence[Source], root: Path, refresh: bool, log: Ca
|
|
|
273
643
|
clone: bool, config_name: str, depth: int,
|
|
274
644
|
found: List[Path], seen: set) -> None:
|
|
275
645
|
for source in sources:
|
|
646
|
+
if not isinstance(source, Source):
|
|
647
|
+
# An OrgSource nobody expanded. Ignored rather than fatal, so a
|
|
648
|
+
# caller that skipped expand_org_sources still installs the rest.
|
|
649
|
+
continue
|
|
276
650
|
path = _resolve_one(source, root, refresh, log, clone)
|
|
277
651
|
if path is None or path in seen:
|
|
278
652
|
continue
|
|
@@ -466,6 +840,12 @@ def run_installer(config_path, argv=None, default_root_name: str = "tools", **ru
|
|
|
466
840
|
pre-discovery hook, which the engine skips on the ``--check`` path, so that
|
|
467
841
|
check stays network-free and sees whatever is already on disk.
|
|
468
842
|
|
|
843
|
+
An ``org`` entry in the sources file is expanded into one source per
|
|
844
|
+
topic-tagged repo before resolution, using the identity's cache directory
|
|
845
|
+
for the listing. That expansion is the only network call this module makes
|
|
846
|
+
besides git, it happens only when such an entry exists, and on the
|
|
847
|
+
``--check`` path it reads the cache instead of GitHub.
|
|
848
|
+
|
|
469
849
|
``default_root_name`` is the folder name the suggestion ends in, so an
|
|
470
850
|
organisation's installer can suggest ``<cwd>/WW3-tools`` rather than
|
|
471
851
|
``<cwd>/tools``.
|
|
@@ -490,15 +870,24 @@ def run_installer(config_path, argv=None, default_root_name: str = "tools", **ru
|
|
|
490
870
|
except OSError as exc:
|
|
491
871
|
print(f"{root}: not created ({exc})")
|
|
492
872
|
sources = load_sources(config_path)
|
|
873
|
+
from .identity import LEGACY_IDENTITY # noqa: PLC0415 — keeps the import graph flat
|
|
874
|
+
identity = run_kwargs.get("identity") or LEGACY_IDENTITY
|
|
875
|
+
cache_dir = identity.cache_path
|
|
493
876
|
|
|
494
877
|
# The engine reads DISCOVERY_ROOTS after the hook has run, so the hook fills
|
|
495
878
|
# this list in place with what it resolved. It is pre-filled with what is on
|
|
496
|
-
# disk already, for the --check path that never calls the hook
|
|
497
|
-
|
|
498
|
-
|
|
879
|
+
# disk already, for the --check path that never calls the hook — which is
|
|
880
|
+
# why the expansion here is the network-free one.
|
|
881
|
+
quiet = lambda *_: None # noqa: E731
|
|
882
|
+
roots = [str(p) for p in resolve_sources(
|
|
883
|
+
expand_org_sources(sources, cache_dir=cache_dir, root=root, clone=False,
|
|
884
|
+
log=quiet),
|
|
885
|
+
root, clone=False, log=quiet)]
|
|
499
886
|
|
|
500
887
|
def pre_discovery(refresh):
|
|
501
|
-
|
|
888
|
+
expanded = expand_org_sources(sources, refresh=refresh, cache_dir=cache_dir,
|
|
889
|
+
root=root)
|
|
890
|
+
roots[:] = [str(p) for p in resolve_sources(expanded, root, refresh=refresh)]
|
|
502
891
|
|
|
503
892
|
from . import gui_installer # noqa: PLC0415 — imports tkinter, keep it lazy
|
|
504
893
|
run_kwargs.setdefault("root_dir", root)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cli-tools-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.1
|
|
4
4
|
Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
|
|
5
5
|
Author: Steffen Probst
|
|
6
6
|
License: MIT License
|
|
@@ -403,6 +403,37 @@ name = "manim-kit"
|
|
|
403
403
|
url = "https://github.com/AutomatedAlchemy/manim-kit"
|
|
404
404
|
```
|
|
405
405
|
|
|
406
|
+
An entry may name a GitHub organisation instead of one repo. The installer lists
|
|
407
|
+
the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
|
|
408
|
+
source, so a new tool in the org appears without anyone editing this file:
|
|
409
|
+
|
|
410
|
+
```toml
|
|
411
|
+
[[source]]
|
|
412
|
+
org = "AutomatedAlchemy"
|
|
413
|
+
topic = "cli-tool-kit" # the default when omitted
|
|
414
|
+
exclude = ["alchemy-installer"] # repo names to skip
|
|
415
|
+
include = ["manim-kit"] # allowlist; wins over exclude
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
The topic decides what is cloned, and the walker plus the `--advertise` probe
|
|
419
|
+
decide what is a tool: a repo that carries the topic but holds no tool clones,
|
|
420
|
+
advertises nothing and is dropped like any other directory. `org` cannot be
|
|
421
|
+
combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
|
|
422
|
+
as a listed repo wins, so one tool can be pinned to a fork or a local checkout
|
|
423
|
+
while the rest of the org follows the listing. Archived repos are left out.
|
|
424
|
+
`include` is an allowlist and overrides `exclude`; the topic is required either
|
|
425
|
+
way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
|
|
426
|
+
pins a tracked source, so a checkout already on the machine is used instead of
|
|
427
|
+
being cloned.
|
|
428
|
+
|
|
429
|
+
The listing is one `GET` to `api.github.com`, cached for a day under the
|
|
430
|
+
identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
|
|
431
|
+
logged in, its token is used and the org's private repos are listed too; without
|
|
432
|
+
it the public listing is used and nothing is required. When the listing fails the
|
|
433
|
+
cached one is used however old it is, and with no cache at all the directories
|
|
434
|
+
already under the root are used — both say so in one line. `--check` never
|
|
435
|
+
fetches: it reads the cache, or the root.
|
|
436
|
+
|
|
406
437
|
`installer.local.toml` next to it is optional and belongs to one machine, so keep
|
|
407
438
|
it out of git. It sets the root and replaces a `path` for a source matched by
|
|
408
439
|
`name`:
|
|
@@ -442,6 +473,9 @@ colleague without access to a private repo still gets everybody else's tools.
|
|
|
442
473
|
given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
|
|
443
474
|
hook, which `--check` skips, so the login check stays network-free.
|
|
444
475
|
|
|
476
|
+
An `org` entry is the only thing in this module that reaches anything but git,
|
|
477
|
+
it is opt-in per entry, and nothing is fetched when no such entry exists.
|
|
478
|
+
|
|
445
479
|
A repo that is itself an installer tree can carry its own `installer.toml`. Its
|
|
446
480
|
`[[source]]` entries are resolved too, one nested level deep and no further, with
|
|
447
481
|
paths relative to that file and clones under the same root. A path that is
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-tools-kit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.1"
|
|
8
8
|
description = "Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -523,3 +523,442 @@ def test_run_installer_refuses_to_have_its_own_arguments_overridden(tmp_path: Pa
|
|
|
523
523
|
sources.run_installer(config, argv=[], discovery_roots=["/x"])
|
|
524
524
|
with pytest.raises(TypeError):
|
|
525
525
|
sources.run_installer(config, argv=[], pre_discovery=lambda refresh: None)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# --- GitHub org sources -----------------------------------------------------
|
|
529
|
+
|
|
530
|
+
def _repo_json(name: str, topics=("cli-tool-kit",), archived: bool = False,
|
|
531
|
+
scheme: str = "https") -> dict:
|
|
532
|
+
return {"name": name, "clone_url": f"{scheme}://github.com/acme/{name}.git",
|
|
533
|
+
"topics": list(topics), "archived": archived,
|
|
534
|
+
"default_branch": "main", "description": f"the {name} tool"}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _fake_urlopen(pages, calls: list):
|
|
538
|
+
"""A urlopen stand-in serving canned pages of JSON.
|
|
539
|
+
|
|
540
|
+
``pages`` is a list of repo lists, one per requested page; anything past
|
|
541
|
+
the end is served as an empty page, which is how GitHub ends a listing.
|
|
542
|
+
"""
|
|
543
|
+
import io
|
|
544
|
+
import json as _json
|
|
545
|
+
|
|
546
|
+
class Response(io.BytesIO):
|
|
547
|
+
def __enter__(self):
|
|
548
|
+
return self
|
|
549
|
+
|
|
550
|
+
def __exit__(self, *_):
|
|
551
|
+
self.close()
|
|
552
|
+
return False
|
|
553
|
+
|
|
554
|
+
def urlopen(request, timeout=None):
|
|
555
|
+
calls.append(request.full_url)
|
|
556
|
+
page = 1
|
|
557
|
+
for part in request.full_url.split("?")[-1].split("&"):
|
|
558
|
+
if part.startswith("page="):
|
|
559
|
+
page = int(part[len("page="):])
|
|
560
|
+
body = pages[page - 1] if page <= len(pages) else []
|
|
561
|
+
return Response(_json.dumps(body).encode("utf-8"))
|
|
562
|
+
|
|
563
|
+
return urlopen
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@pytest.fixture
|
|
567
|
+
def no_gh(monkeypatch):
|
|
568
|
+
"""No GitHub CLI on this host, so the listing stays anonymous."""
|
|
569
|
+
monkeypatch.setattr(sources.shutil, "which", lambda name: None)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
@pytest.fixture
|
|
573
|
+
def api(monkeypatch, no_gh):
|
|
574
|
+
"""Serve canned listings and record the URLs that were requested."""
|
|
575
|
+
calls: list = []
|
|
576
|
+
|
|
577
|
+
def serve(*pages):
|
|
578
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen",
|
|
579
|
+
_fake_urlopen(list(pages), calls))
|
|
580
|
+
return calls
|
|
581
|
+
|
|
582
|
+
return serve
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _expand(tmp_path: Path, entry, lines=None, **kwargs):
|
|
586
|
+
return sources.expand_org_sources(
|
|
587
|
+
[entry], cache_dir=tmp_path / "cache",
|
|
588
|
+
log=(lines.append if lines is not None else (lambda *_: None)), **kwargs)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_only_topic_tagged_repos_become_sources(tmp_path: Path, api) -> None:
|
|
592
|
+
api([_repo_json("kept"), _repo_json("other", topics=("website",))])
|
|
593
|
+
expanded = _expand(tmp_path, sources.OrgSource(org="acme"))
|
|
594
|
+
assert [(s.name, s.url) for s in expanded] == [
|
|
595
|
+
("kept", "https://github.com/acme/kept.git")]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def test_archived_repos_are_left_out(tmp_path: Path, api) -> None:
|
|
599
|
+
api([_repo_json("live"), _repo_json("old", archived=True)])
|
|
600
|
+
assert [s.name for s in _expand(tmp_path, sources.OrgSource(org="acme"))] == ["live"]
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def test_a_listing_is_read_across_pages(tmp_path: Path, api) -> None:
|
|
604
|
+
first = [_repo_json(f"tool{i:02d}") for i in range(100)]
|
|
605
|
+
calls = api(first, [_repo_json("last")])
|
|
606
|
+
expanded = _expand(tmp_path, sources.OrgSource(org="acme"))
|
|
607
|
+
assert len(expanded) == 101
|
|
608
|
+
assert expanded[-1].name == "last"
|
|
609
|
+
assert len(calls) == 2 and "page=2" in calls[1]
|
|
610
|
+
assert all(call.startswith("https://api.github.com/orgs/acme/repos?") for call in calls)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def test_an_http_only_clone_url_is_refused(tmp_path: Path, api) -> None:
|
|
614
|
+
api([_repo_json("plain", scheme="http")])
|
|
615
|
+
lines: list = []
|
|
616
|
+
assert _expand(tmp_path, sources.OrgSource(org="acme"), lines) == []
|
|
617
|
+
assert any("not an https:// URL" in line for line in lines)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def test_a_cached_listing_within_the_ttl_makes_no_call(tmp_path: Path, api) -> None:
|
|
621
|
+
calls = api([_repo_json("kept")])
|
|
622
|
+
entry = sources.OrgSource(org="acme")
|
|
623
|
+
assert len(_expand(tmp_path, entry)) == 1
|
|
624
|
+
assert len(calls) == 1
|
|
625
|
+
assert [s.name for s in _expand(tmp_path, entry)] == ["kept"]
|
|
626
|
+
assert len(calls) == 1 # the cache answered the second time
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def test_refresh_fetches_although_the_cache_is_fresh(tmp_path: Path, api) -> None:
|
|
630
|
+
calls = api([_repo_json("kept")])
|
|
631
|
+
entry = sources.OrgSource(org="acme")
|
|
632
|
+
_expand(tmp_path, entry)
|
|
633
|
+
_expand(tmp_path, entry, refresh=True)
|
|
634
|
+
assert len(calls) == 2
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def test_a_stale_cache_is_used_when_the_listing_fails(tmp_path: Path, api,
|
|
638
|
+
monkeypatch) -> None:
|
|
639
|
+
api([_repo_json("kept")])
|
|
640
|
+
entry = sources.OrgSource(org="acme")
|
|
641
|
+
_expand(tmp_path, entry)
|
|
642
|
+
cache = tmp_path / "cache" / "org-acme.json"
|
|
643
|
+
payload = sources.json.loads(cache.read_text(encoding="utf-8"))
|
|
644
|
+
payload["fetched_at"] -= 10 * 24 * 3600 # ten days old
|
|
645
|
+
cache.write_text(sources.json.dumps(payload), encoding="utf-8")
|
|
646
|
+
|
|
647
|
+
def boom(request, timeout=None):
|
|
648
|
+
raise sources.urllib.error.URLError("no route to host")
|
|
649
|
+
|
|
650
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen", boom)
|
|
651
|
+
lines: list = []
|
|
652
|
+
assert [s.name for s in _expand(tmp_path, entry, lines)] == ["kept"]
|
|
653
|
+
assert any("not listed, using the cached listing from" in line for line in lines)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def test_no_cache_and_no_network_falls_back_to_the_root(tmp_path: Path,
|
|
657
|
+
monkeypatch, no_gh) -> None:
|
|
658
|
+
root = tmp_path / "root"
|
|
659
|
+
_repo(root / "on-disk")
|
|
660
|
+
_repo(root / "skipped")
|
|
661
|
+
|
|
662
|
+
def boom(request, timeout=None):
|
|
663
|
+
raise sources.urllib.error.URLError("no route to host")
|
|
664
|
+
|
|
665
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen", boom)
|
|
666
|
+
lines: list = []
|
|
667
|
+
expanded = _expand(tmp_path, sources.OrgSource(org="acme", exclude=("skipped",)),
|
|
668
|
+
lines, root=root)
|
|
669
|
+
assert [(s.name, s.path) for s in expanded] == [
|
|
670
|
+
("on-disk", str((root / "on-disk").resolve()))]
|
|
671
|
+
assert any("no listing and no cache" in line for line in lines)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_the_root_fallback_cannot_tell_which_directories_are_the_orgs(
|
|
675
|
+
tmp_path: Path, monkeypatch, no_gh) -> None:
|
|
676
|
+
"""Without a listing there is nothing to match names against.
|
|
677
|
+
|
|
678
|
+
So the last resort offers every directory under the root that ``exclude``
|
|
679
|
+
does not name. A directory that holds no tool advertises nothing and the
|
|
680
|
+
walker drops it, which is what keeps this safe rather than clever.
|
|
681
|
+
"""
|
|
682
|
+
root = tmp_path / "root"
|
|
683
|
+
_repo(root / "a-tool")
|
|
684
|
+
(root / "not-a-repo").mkdir(parents=True)
|
|
685
|
+
|
|
686
|
+
def boom(request, timeout=None):
|
|
687
|
+
raise sources.urllib.error.URLError("no route to host")
|
|
688
|
+
|
|
689
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen", boom)
|
|
690
|
+
expanded = _expand(tmp_path, sources.OrgSource(org="acme"), root=root)
|
|
691
|
+
assert [s.name for s in expanded] == ["a-tool", "not-a-repo"]
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def test_the_network_free_path_never_fetches(tmp_path: Path, monkeypatch,
|
|
695
|
+
no_gh) -> None:
|
|
696
|
+
root = tmp_path / "root"
|
|
697
|
+
_repo(root / "on-disk")
|
|
698
|
+
|
|
699
|
+
def refuse(request, timeout=None):
|
|
700
|
+
raise AssertionError("clone=False reached the network")
|
|
701
|
+
|
|
702
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen", refuse)
|
|
703
|
+
expanded = _expand(tmp_path, sources.OrgSource(org="acme"), root=root, clone=False)
|
|
704
|
+
assert [s.name for s in expanded] == ["on-disk"]
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def test_clone_false_prefers_the_cache_over_the_root(tmp_path: Path, api,
|
|
708
|
+
monkeypatch) -> None:
|
|
709
|
+
root = tmp_path / "root"
|
|
710
|
+
_repo(root / "on-disk")
|
|
711
|
+
entry = sources.OrgSource(org="acme")
|
|
712
|
+
api([_repo_json("kept")])
|
|
713
|
+
_expand(tmp_path, entry) # fills the cache
|
|
714
|
+
|
|
715
|
+
def refuse(request, timeout=None):
|
|
716
|
+
raise AssertionError("clone=False reached the network")
|
|
717
|
+
|
|
718
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen", refuse)
|
|
719
|
+
assert [s.name for s in _expand(tmp_path, entry, root=root, clone=False)] == ["kept"]
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def test_an_explicit_source_wins_over_the_org_listing(tmp_path: Path, api) -> None:
|
|
723
|
+
api([_repo_json("kept"), _repo_json("pinned")])
|
|
724
|
+
fork = _repo(tmp_path / "fork")
|
|
725
|
+
expanded = sources.expand_org_sources(
|
|
726
|
+
[Source(name="pinned", path=str(fork)), sources.OrgSource(org="acme")],
|
|
727
|
+
cache_dir=tmp_path / "cache", log=lambda *_: None)
|
|
728
|
+
assert [(s.name, s.path, s.url) for s in expanded] == [
|
|
729
|
+
("pinned", str(fork), None),
|
|
730
|
+
("kept", None, "https://github.com/acme/kept.git")]
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def test_exclude_drops_a_repo_and_include_is_an_allowlist(tmp_path: Path, api) -> None:
|
|
734
|
+
listing = [_repo_json("a"), _repo_json("b"), _repo_json("c"),
|
|
735
|
+
_repo_json("untagged", topics=("website",))]
|
|
736
|
+
api(listing)
|
|
737
|
+
assert [s.name for s in _expand(tmp_path, sources.OrgSource(org="acme",
|
|
738
|
+
exclude=("b",)))] \
|
|
739
|
+
== ["a", "c"]
|
|
740
|
+
# include wins over exclude, and the topic is still required.
|
|
741
|
+
assert [s.name for s in _expand(
|
|
742
|
+
tmp_path / "second", sources.OrgSource(org="acme", include=("b", "untagged"),
|
|
743
|
+
exclude=("b",)))] == ["b"]
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def test_the_list_header_names_the_count_the_topic_and_the_age(tmp_path: Path,
|
|
747
|
+
api) -> None:
|
|
748
|
+
api([_repo_json("a"), _repo_json("b")])
|
|
749
|
+
lines: list = []
|
|
750
|
+
_expand(tmp_path, sources.OrgSource(org="acme"), lines)
|
|
751
|
+
assert lines[0] == "acme: 2 repos tagged cli-tool-kit (listed just now)"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_a_shrinking_listing_is_reported(tmp_path: Path, api, monkeypatch) -> None:
|
|
755
|
+
calls = api([_repo_json("a"), _repo_json("b"), _repo_json("c")])
|
|
756
|
+
entry = sources.OrgSource(org="acme")
|
|
757
|
+
_expand(tmp_path, entry)
|
|
758
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen",
|
|
759
|
+
_fake_urlopen([[_repo_json("a")]], calls))
|
|
760
|
+
lines: list = []
|
|
761
|
+
_expand(tmp_path, entry, lines, refresh=True)
|
|
762
|
+
assert any("2 repos dropped since the last listing: b, c" in line for line in lines)
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def test_a_token_from_the_gh_cli_is_sent_and_never_logged(tmp_path: Path,
|
|
766
|
+
monkeypatch) -> None:
|
|
767
|
+
calls: list = []
|
|
768
|
+
monkeypatch.setattr(sources.shutil, "which", lambda name: "/usr/bin/gh")
|
|
769
|
+
|
|
770
|
+
class Result:
|
|
771
|
+
returncode = 0
|
|
772
|
+
stdout = "gho_secret\n"
|
|
773
|
+
stderr = ""
|
|
774
|
+
|
|
775
|
+
monkeypatch.setattr(sources.subprocess, "run", lambda cmd, **kw: Result())
|
|
776
|
+
headers: list = []
|
|
777
|
+
inner = _fake_urlopen([[_repo_json("kept")]], calls)
|
|
778
|
+
|
|
779
|
+
def urlopen(request, timeout=None):
|
|
780
|
+
headers.append(dict(request.headers))
|
|
781
|
+
return inner(request, timeout=timeout)
|
|
782
|
+
|
|
783
|
+
monkeypatch.setattr(sources.urllib.request, "urlopen", urlopen)
|
|
784
|
+
lines: list = []
|
|
785
|
+
assert [s.name for s in _expand(tmp_path, sources.OrgSource(org="acme"),
|
|
786
|
+
lines)] == ["kept"]
|
|
787
|
+
assert headers[0].get("Authorization") == "Bearer gho_secret"
|
|
788
|
+
# With a token the private repos are wanted too, so the filter comes off.
|
|
789
|
+
assert "type=public" not in calls[0]
|
|
790
|
+
assert not any("gho_secret" in line for line in lines)
|
|
791
|
+
cached = (tmp_path / "cache" / "org-acme.json").read_text(encoding="utf-8")
|
|
792
|
+
assert "gho_secret" not in cached
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
# --- reading org entries out of the TOML file --------------------------------
|
|
796
|
+
|
|
797
|
+
def test_an_org_entry_is_loaded_as_an_org_source(tmp_path: Path) -> None:
|
|
798
|
+
config = _write(tmp_path / "installer.toml", """
|
|
799
|
+
[[source]]
|
|
800
|
+
org = "AutomatedAlchemy"
|
|
801
|
+
topic = "cli-tool-kit"
|
|
802
|
+
exclude = ["alchemy-installer"]
|
|
803
|
+
|
|
804
|
+
[[source]]
|
|
805
|
+
name = "org/tools"
|
|
806
|
+
path = "."
|
|
807
|
+
""")
|
|
808
|
+
loaded = load_sources(config)
|
|
809
|
+
assert loaded[0] == sources.OrgSource(org="AutomatedAlchemy", topic="cli-tool-kit",
|
|
810
|
+
include=(), exclude=("alchemy-installer",))
|
|
811
|
+
assert loaded[1] == Source(name="org/tools", path=str(tmp_path))
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def test_the_topic_defaults_when_it_is_omitted(tmp_path: Path) -> None:
|
|
815
|
+
config = _write(tmp_path / "installer.toml", '[[source]]\norg = "acme"\n')
|
|
816
|
+
assert load_sources(config)[0].topic == sources.DEFAULT_ORG_TOPIC
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@pytest.mark.parametrize("body, complaint", [
|
|
820
|
+
('org = "acme/sub"', "unusable org"),
|
|
821
|
+
('org = "' + "a" * 40 + '"', "unusable org"),
|
|
822
|
+
('org = ""', "unusable org"),
|
|
823
|
+
('org = 7', "unusable org"),
|
|
824
|
+
('org = "acme"\ntopic = "not a topic"', "not a usable topic"),
|
|
825
|
+
('org = "acme"\nurl = "https://example.invalid/x.git"', "both org and url/path"),
|
|
826
|
+
('org = "acme"\npath = "."', "both org and url/path"),
|
|
827
|
+
('name = "acme"\norg = "acme"', "both org and url/path"),
|
|
828
|
+
])
|
|
829
|
+
def test_an_unusable_org_entry_is_reported_and_dropped(tmp_path: Path, body: str,
|
|
830
|
+
complaint: str) -> None:
|
|
831
|
+
config = _write(tmp_path / "installer.toml", f"[[source]]\n{body}\n")
|
|
832
|
+
lines: list = []
|
|
833
|
+
assert load_sources(config, log=lines.append) == []
|
|
834
|
+
assert any(complaint in line for line in lines)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def test_resolve_sources_ignores_an_unexpanded_org_entry(tmp_path: Path) -> None:
|
|
838
|
+
given = _repo(tmp_path / "given")
|
|
839
|
+
resolved = resolve_sources([sources.OrgSource(org="acme"),
|
|
840
|
+
Source(name="lab", path=str(given))],
|
|
841
|
+
tmp_path / "root")
|
|
842
|
+
assert resolved == [given]
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def test_run_installer_expands_an_org_entry_in_the_hook(tmp_path: Path, engine,
|
|
846
|
+
api, monkeypatch) -> None:
|
|
847
|
+
config = _write(_repo(tmp_path / "org" / "tools") / "installer.toml", """
|
|
848
|
+
[[source]]
|
|
849
|
+
name = "org/tools"
|
|
850
|
+
path = "."
|
|
851
|
+
|
|
852
|
+
[[source]]
|
|
853
|
+
org = "acme"
|
|
854
|
+
""")
|
|
855
|
+
api([_repo_json("kept")])
|
|
856
|
+
git_calls: list = []
|
|
857
|
+
monkeypatch.setattr(sources.subprocess, "run", _fake_git(git_calls))
|
|
858
|
+
monkeypatch.chdir(tmp_path)
|
|
859
|
+
root = tmp_path / "root"
|
|
860
|
+
root.mkdir()
|
|
861
|
+
sources.run_installer(config, argv=["--root", str(root)])
|
|
862
|
+
roots = engine["discovery_roots"]
|
|
863
|
+
assert roots == [str(tmp_path / "org" / "tools")] # nothing fetched yet
|
|
864
|
+
engine["pre_discovery"](False)
|
|
865
|
+
assert roots == [str(tmp_path / "org" / "tools"), str(root / "kept")]
|
|
866
|
+
assert any("clone" in call for call in git_calls)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
# --- local path pins on org-derived names ------------------------------------
|
|
870
|
+
|
|
871
|
+
def _org_tree(tmp_path: Path, local_body: str) -> Path:
|
|
872
|
+
"""installer.toml with only an org entry, plus a local file beside it."""
|
|
873
|
+
config = _write(tmp_path / "installer.toml", """
|
|
874
|
+
[[source]]
|
|
875
|
+
org = "AutomatedAlchemy"
|
|
876
|
+
topic = "cli-tool-kit"
|
|
877
|
+
exclude = ["alchemy-installer"]
|
|
878
|
+
""")
|
|
879
|
+
_write(config.with_name("installer.local.toml"), local_body)
|
|
880
|
+
return config
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def test_a_local_pin_on_an_org_derived_name_is_used_without_cloning(
|
|
884
|
+
tmp_path: Path, api, monkeypatch) -> None:
|
|
885
|
+
"""The repro: a local [[source]] naming a listed repo must pin its path.
|
|
886
|
+
|
|
887
|
+
The names do not exist when installer.local.toml is read, so the override
|
|
888
|
+
can only be matched after the listing — which is what regressed: both repos
|
|
889
|
+
were cloned over the checkouts that were already there.
|
|
890
|
+
"""
|
|
891
|
+
root = tmp_path / "root"
|
|
892
|
+
pinned = _repo(root / "bloggen")
|
|
893
|
+
also = _repo(root / "lernclaude")
|
|
894
|
+
config = _org_tree(tmp_path, f"""
|
|
895
|
+
root = "{root}"
|
|
896
|
+
|
|
897
|
+
[[source]]
|
|
898
|
+
name = "BlogGen"
|
|
899
|
+
path = "{pinned}"
|
|
900
|
+
|
|
901
|
+
[[source]]
|
|
902
|
+
name = "lernclaude-fau"
|
|
903
|
+
path = "{also}"
|
|
904
|
+
""")
|
|
905
|
+
api([_repo_json("BlogGen"), _repo_json("lernclaude-fau"), _repo_json("manim-kit")])
|
|
906
|
+
git_calls: list = []
|
|
907
|
+
monkeypatch.setattr(sources.subprocess, "run", _fake_git(git_calls))
|
|
908
|
+
|
|
909
|
+
expanded = sources.expand_org_sources(load_sources(config),
|
|
910
|
+
cache_dir=tmp_path / "cache", root=root,
|
|
911
|
+
log=lambda *_: None)
|
|
912
|
+
assert [(s.name, s.path) for s in expanded] == [
|
|
913
|
+
("BlogGen", str(pinned)), ("lernclaude-fau", str(also)), ("manim-kit", None)]
|
|
914
|
+
|
|
915
|
+
resolved = resolve_sources(expanded, root, log=lambda *_: None)
|
|
916
|
+
assert resolved[:2] == [pinned, also]
|
|
917
|
+
# The listing may be fetched; the pinned repos must not be cloned.
|
|
918
|
+
assert not any("clone" in call and "BlogGen" in " ".join(call)
|
|
919
|
+
for call in git_calls)
|
|
920
|
+
assert not any("clone" in call and "lernclaude" in " ".join(call)
|
|
921
|
+
for call in git_calls)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def test_a_local_pin_whose_path_is_gone_still_clones(tmp_path: Path, api,
|
|
925
|
+
monkeypatch) -> None:
|
|
926
|
+
root = tmp_path / "root"
|
|
927
|
+
root.mkdir()
|
|
928
|
+
config = _org_tree(tmp_path, f"""
|
|
929
|
+
root = "{root}"
|
|
930
|
+
|
|
931
|
+
[[source]]
|
|
932
|
+
name = "BlogGen"
|
|
933
|
+
path = "{tmp_path / 'never-checked-out'}"
|
|
934
|
+
""")
|
|
935
|
+
api([_repo_json("BlogGen")])
|
|
936
|
+
git_calls: list = []
|
|
937
|
+
monkeypatch.setattr(sources.subprocess, "run", _fake_git(git_calls))
|
|
938
|
+
expanded = sources.expand_org_sources(load_sources(config),
|
|
939
|
+
cache_dir=tmp_path / "cache", root=root,
|
|
940
|
+
log=lambda *_: None)
|
|
941
|
+
assert expanded[0].url == "https://github.com/acme/BlogGen.git"
|
|
942
|
+
assert resolve_sources(expanded, root, log=lambda *_: None) == [root / "BlogGen"]
|
|
943
|
+
assert any("clone" in call for call in git_calls)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def test_a_tracked_explicit_source_still_wins_over_the_listing(tmp_path: Path,
|
|
947
|
+
api) -> None:
|
|
948
|
+
"""The older rule is unchanged: a tracked [[source]] replaces the repo."""
|
|
949
|
+
fork = _repo(tmp_path / "fork")
|
|
950
|
+
config = _write(tmp_path / "installer.toml", f"""
|
|
951
|
+
[[source]]
|
|
952
|
+
name = "BlogGen"
|
|
953
|
+
path = "{fork}"
|
|
954
|
+
|
|
955
|
+
[[source]]
|
|
956
|
+
org = "acme"
|
|
957
|
+
""")
|
|
958
|
+
api([_repo_json("BlogGen"), _repo_json("manim-kit")])
|
|
959
|
+
expanded = sources.expand_org_sources(load_sources(config),
|
|
960
|
+
cache_dir=tmp_path / "cache",
|
|
961
|
+
log=lambda *_: None)
|
|
962
|
+
assert [(s.name, s.path, s.url) for s in expanded] == [
|
|
963
|
+
("BlogGen", str(fork), None),
|
|
964
|
+
("manim-kit", None, "https://github.com/acme/manim-kit.git")]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|