cli-tools-kit 0.6.3__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.3 → cli_tools_kit-0.7.1}/PKG-INFO +35 -1
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/README.md +34 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/gui_installer.py +37 -3
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/sources.py +407 -10
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/PKG-INFO +35 -1
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/pyproject.toml +1 -1
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_sources.py +454 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/LICENSE +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/tool_installer.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit/tui_installer.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/SOURCES.txt +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/setup.cfg +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_host.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_taxonomy_cluster.py +0 -0
- {cli_tools_kit-0.6.3 → cli_tools_kit-0.7.1}/tests/test_tool_installer.py +0 -0
- {cli_tools_kit-0.6.3 → 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
|
|
@@ -5434,13 +5434,37 @@ class InstallerApp:
|
|
|
5434
5434
|
# ================= CLI FUNCTIONS =================
|
|
5435
5435
|
|
|
5436
5436
|
def _self_shortcut_path() -> str:
|
|
5437
|
-
"""The manager's own shortcut: a .desktop file, or a .lnk on Windows.
|
|
5437
|
+
"""The manager's own shortcut: a .desktop file, or a .lnk on Windows.
|
|
5438
|
+
|
|
5439
|
+
Windows shows the file name in the Start Menu, where freedesktop reads the
|
|
5440
|
+
Name= line inside the file, so the .lnk is named after SELF_DESKTOP_NAME.
|
|
5441
|
+
Characters a file name cannot hold become spaces, and a name that is empty
|
|
5442
|
+
or all-punctuation falls back to the desktop file's stem.
|
|
5443
|
+
"""
|
|
5438
5444
|
if host.IS_WINDOWS:
|
|
5439
|
-
|
|
5440
|
-
|
|
5445
|
+
stem = "".join(" " if c in '<>:"/\\|?*' else c
|
|
5446
|
+
for c in SELF_DESKTOP_NAME).strip(" .")
|
|
5447
|
+
stem = " ".join(stem.split()) or os.path.splitext(SELF_DESKTOP_FILE)[0]
|
|
5448
|
+
return os.path.join(APPS_DIR, stem + ".lnk")
|
|
5441
5449
|
return os.path.join(APPS_DIR, SELF_DESKTOP_FILE)
|
|
5442
5450
|
|
|
5443
5451
|
|
|
5452
|
+
def _windows_icon(icon: str) -> Optional[str]:
|
|
5453
|
+
"""The .ico Windows can draw for `icon`, or None.
|
|
5454
|
+
|
|
5455
|
+
A .lnk renders only .ico/.exe/.dll; the shell stores a .png path without
|
|
5456
|
+
complaint and then draws nothing. Consumers configure one icon, normally a
|
|
5457
|
+
.png for freedesktop, so take a sibling .ico of the same stem when there is
|
|
5458
|
+
one and otherwise leave the shortcut on its default picture.
|
|
5459
|
+
"""
|
|
5460
|
+
if not icon:
|
|
5461
|
+
return None
|
|
5462
|
+
for candidate in (icon, os.path.splitext(icon)[0] + ".ico"):
|
|
5463
|
+
if candidate.lower().endswith(".ico") and os.path.isfile(candidate):
|
|
5464
|
+
return candidate
|
|
5465
|
+
return None
|
|
5466
|
+
|
|
5467
|
+
|
|
5444
5468
|
def cli_install_self(quiet: bool = False) -> tuple[bool, str]:
|
|
5445
5469
|
"""Install the manager's own desktop shortcut. Returns (success, message)."""
|
|
5446
5470
|
try:
|
|
@@ -5450,9 +5474,19 @@ def cli_install_self(quiet: bool = False) -> tuple[bool, str]:
|
|
|
5450
5474
|
if host.IS_WINDOWS:
|
|
5451
5475
|
lnk_path = _self_shortcut_path()
|
|
5452
5476
|
ok = host.write_shortcut(lnk_path, python_exec, f'"{script_path}"',
|
|
5477
|
+
icon=_windows_icon(SELF_DESKTOP_ICON),
|
|
5453
5478
|
workdir=ROOT_DIR)
|
|
5454
5479
|
if not ok:
|
|
5455
5480
|
return False, f"Could not write {lnk_path}"
|
|
5481
|
+
# Before 0.6.4 the .lnk was named after the desktop file's stem.
|
|
5482
|
+
# Drop that one, or the Start Menu keeps both.
|
|
5483
|
+
legacy = os.path.join(APPS_DIR,
|
|
5484
|
+
os.path.splitext(SELF_DESKTOP_FILE)[0] + ".lnk")
|
|
5485
|
+
if legacy != lnk_path and os.path.isfile(legacy):
|
|
5486
|
+
try:
|
|
5487
|
+
os.remove(legacy)
|
|
5488
|
+
except OSError:
|
|
5489
|
+
pass
|
|
5456
5490
|
if not quiet:
|
|
5457
5491
|
print(f"Installed: {lnk_path}")
|
|
5458
5492
|
return True, lnk_path
|
|
@@ -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():
|
|
@@ -157,7 +226,15 @@ def save_local_root(config_path, root, local_path=None, log: Callable = print) -
|
|
|
157
226
|
except OSError as exc:
|
|
158
227
|
log(f"{local.name}: not updated ({exc})")
|
|
159
228
|
return False
|
|
160
|
-
|
|
229
|
+
# A TOML literal string, because a Windows root is full of backslashes and
|
|
230
|
+
# a basic string would read them as escapes: "C:\Users\..." dies on \U and
|
|
231
|
+
# takes the whole file with it, so the question would be asked again on
|
|
232
|
+
# every launch. A path holding a single quote falls back to a basic string
|
|
233
|
+
# with the two characters TOML needs escaped there.
|
|
234
|
+
if "'" in root:
|
|
235
|
+
line = 'root = "{}"\n'.format(root.replace("\\", "\\\\").replace('"', '\\"'))
|
|
236
|
+
else:
|
|
237
|
+
line = f"root = '{root}'\n"
|
|
161
238
|
try:
|
|
162
239
|
local.write_text(line + ("\n" + existing.lstrip("\n") if existing.strip() else ""),
|
|
163
240
|
encoding="utf-8")
|
|
@@ -167,12 +244,51 @@ def save_local_root(config_path, root, local_path=None, log: Callable = print) -
|
|
|
167
244
|
return True
|
|
168
245
|
|
|
169
246
|
|
|
170
|
-
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:
|
|
171
281
|
"""The ``[[source]]`` entries of one TOML file, local overrides applied.
|
|
172
282
|
|
|
173
283
|
``local_path`` defaults to ``installer.local.toml`` next to ``config_path``.
|
|
174
284
|
A ``path`` is taken relative to the file it is written in. An entry without
|
|
175
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.
|
|
176
292
|
"""
|
|
177
293
|
config_path = Path(config_path)
|
|
178
294
|
local_path = Path(local_path) if local_path is not None else _local_path_for(config_path)
|
|
@@ -183,14 +299,29 @@ def load_sources(config_path, local_path=None, log: Callable = print) -> List[So
|
|
|
183
299
|
for entry in local.get("source") or []
|
|
184
300
|
if isinstance(entry, dict) and entry.get("name")}
|
|
185
301
|
|
|
186
|
-
|
|
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 = []
|
|
187
310
|
for entry in data.get("source") or []:
|
|
188
311
|
if not isinstance(entry, dict):
|
|
189
312
|
continue
|
|
190
313
|
name = entry.get("name")
|
|
191
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
|
|
192
320
|
log(f"{config_path.name}: a [[source]] without a name, skipped")
|
|
193
321
|
continue
|
|
322
|
+
if entry.get("org"):
|
|
323
|
+
log(f"{name}: a [[source]] cannot have both org and url/path, skipped")
|
|
324
|
+
continue
|
|
194
325
|
override = overrides.get(name, {}).get("path")
|
|
195
326
|
raw = override or entry.get("path")
|
|
196
327
|
base = local_path.parent if override else config_path.parent
|
|
@@ -199,6 +330,253 @@ def load_sources(config_path, local_path=None, log: Callable = print) -> List[So
|
|
|
199
330
|
return sources
|
|
200
331
|
|
|
201
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
|
+
|
|
202
580
|
# --- resolution -------------------------------------------------------------
|
|
203
581
|
|
|
204
582
|
def _git(*args):
|
|
@@ -265,6 +643,10 @@ def _resolve_level(sources: Sequence[Source], root: Path, refresh: bool, log: Ca
|
|
|
265
643
|
clone: bool, config_name: str, depth: int,
|
|
266
644
|
found: List[Path], seen: set) -> None:
|
|
267
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
|
|
268
650
|
path = _resolve_one(source, root, refresh, log, clone)
|
|
269
651
|
if path is None or path in seen:
|
|
270
652
|
continue
|
|
@@ -458,6 +840,12 @@ def run_installer(config_path, argv=None, default_root_name: str = "tools", **ru
|
|
|
458
840
|
pre-discovery hook, which the engine skips on the ``--check`` path, so that
|
|
459
841
|
check stays network-free and sees whatever is already on disk.
|
|
460
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
|
+
|
|
461
849
|
``default_root_name`` is the folder name the suggestion ends in, so an
|
|
462
850
|
organisation's installer can suggest ``<cwd>/WW3-tools`` rather than
|
|
463
851
|
``<cwd>/tools``.
|
|
@@ -482,15 +870,24 @@ def run_installer(config_path, argv=None, default_root_name: str = "tools", **ru
|
|
|
482
870
|
except OSError as exc:
|
|
483
871
|
print(f"{root}: not created ({exc})")
|
|
484
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
|
|
485
876
|
|
|
486
877
|
# The engine reads DISCOVERY_ROOTS after the hook has run, so the hook fills
|
|
487
878
|
# this list in place with what it resolved. It is pre-filled with what is on
|
|
488
|
-
# disk already, for the --check path that never calls the hook
|
|
489
|
-
|
|
490
|
-
|
|
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)]
|
|
491
886
|
|
|
492
887
|
def pre_discovery(refresh):
|
|
493
|
-
|
|
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)]
|
|
494
891
|
|
|
495
892
|
from . import gui_installer # noqa: PLC0415 — imports tkinter, keep it lazy
|
|
496
893
|
run_kwargs.setdefault("root_dir", root)
|