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.
Files changed (41) hide show
  1. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/PKG-INFO +35 -1
  2. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/README.md +34 -0
  3. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/__init__.py +1 -1
  4. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/sources.py +398 -9
  5. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/PKG-INFO +35 -1
  6. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/pyproject.toml +1 -1
  7. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_sources.py +439 -0
  8. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/LICENSE +0 -0
  9. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/__main__.py +0 -0
  10. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/advertise.py +0 -0
  11. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/cron_installer.py +0 -0
  12. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/gui_installer.py +0 -0
  13. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/host.py +0 -0
  14. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/identity.py +0 -0
  15. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/onboarding.py +0 -0
  16. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/skills.py +0 -0
  17. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
  18. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/build.py +0 -0
  19. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/capability.py +0 -0
  20. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
  21. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
  22. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
  23. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/groups.py +0 -0
  24. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  25. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/tool_installer.py +0 -0
  26. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit/tui_installer.py +0 -0
  27. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/SOURCES.txt +0 -0
  28. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  29. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  30. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/requires.txt +0 -0
  31. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
  32. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/setup.cfg +0 -0
  33. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_capability_groups.py +0 -0
  34. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_cron_installer.py +0 -0
  35. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_host.py +0 -0
  36. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_identity.py +0 -0
  37. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_llm_groups.py +0 -0
  38. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_skills.py +0 -0
  39. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_taxonomy_cluster.py +0 -0
  40. {cli_tools_kit-0.6.4 → cli_tools_kit-0.7.1}/tests/test_tool_installer.py +0 -0
  41. {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.6.4
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
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.6.1"
59
+ __version__ = "0.7.1"
@@ -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", "load_sources", "resolve_sources", "run_installer",
65
- "local_root", "save_local_root", "default_root"]
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 load_sources(config_path, local_path=None, log: Callable = print) -> List[Source]:
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
- sources: List[Source] = []
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
- roots = [str(p) for p in resolve_sources(sources, root, clone=False,
498
- log=lambda *_: None)]
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
- roots[:] = [str(p) for p in resolve_sources(sources, root, refresh=refresh)]
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.6.4
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.6.4"
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