ixt-cli 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ixt/__init__.py +8 -0
- ixt/__main__.py +8 -0
- ixt/backends/__init__.py +1 -0
- ixt/backends/binary.py +935 -0
- ixt/backends/binary_resolver.py +307 -0
- ixt/backends/node.py +490 -0
- ixt/backends/python.py +234 -0
- ixt/cli/__init__.py +31 -0
- ixt/cli/argparse_completion.py +557 -0
- ixt/cli/cmd_apply.py +404 -0
- ixt/cli/cmd_cache.py +86 -0
- ixt/cli/cmd_config.py +295 -0
- ixt/cli/cmd_info.py +116 -0
- ixt/cli/cmd_install.py +508 -0
- ixt/cli/cmd_misc.py +261 -0
- ixt/cli/cmd_registry.py +35 -0
- ixt/cli/cmd_upgrade.py +336 -0
- ixt/cli/commands.py +70 -0
- ixt/cli/parser.py +555 -0
- ixt/cli/render.py +85 -0
- ixt/config/__init__.py +5 -0
- ixt/config/asset_index.py +305 -0
- ixt/config/asset_pattern_cache.py +87 -0
- ixt/config/env_policy.py +340 -0
- ixt/config/flags.py +29 -0
- ixt/config/fs_policy.py +17 -0
- ixt/config/heuristics.py +465 -0
- ixt/config/models.py +176 -0
- ixt/config/registry.py +145 -0
- ixt/config/settings.py +173 -0
- ixt/config/setup_toml.py +179 -0
- ixt/config/toml.py +416 -0
- ixt/core/__init__.py +16 -0
- ixt/core/apply.py +564 -0
- ixt/core/apply_actions.py +106 -0
- ixt/core/backend.py +187 -0
- ixt/core/bootstrap.py +410 -0
- ixt/core/cache.py +332 -0
- ixt/core/discover.py +150 -0
- ixt/core/doctor.py +591 -0
- ixt/core/export.py +419 -0
- ixt/core/expose.py +350 -0
- ixt/core/extract.py +261 -0
- ixt/core/hooks.py +182 -0
- ixt/core/identity.py +148 -0
- ixt/core/inject.py +143 -0
- ixt/core/install.py +509 -0
- ixt/core/install_local.py +229 -0
- ixt/core/locks.py +54 -0
- ixt/core/pathlink.py +86 -0
- ixt/core/resolution_stats.py +191 -0
- ixt/core/resolve.py +150 -0
- ixt/core/resolve_cache.py +185 -0
- ixt/core/runtimes.py +192 -0
- ixt/core/save.py +237 -0
- ixt/core/setup_completions.py +11 -0
- ixt/core/setup_path.py +368 -0
- ixt/core/upgrade.py +596 -0
- ixt/data/__init__.py +10 -0
- ixt/data/asset_index.json +574 -0
- ixt/data/heuristics.toml +98 -0
- ixt/data/registry.toml +71 -0
- ixt/libs/__init__.py +3 -0
- ixt/libs/constants.py +4 -0
- ixt/libs/fmt.py +108 -0
- ixt/libs/logger.py +109 -0
- ixt/libs/output.py +25 -0
- ixt/libs/req_spec.py +115 -0
- ixt/libs/semver.py +149 -0
- ixt/libs/shell.py +126 -0
- ixt/libs/style.py +238 -0
- ixt/net/__init__.py +1 -0
- ixt/net/github_api.py +158 -0
- ixt/net/gitlab_api.py +149 -0
- ixt/net/http.py +194 -0
- ixt/net/npm.py +24 -0
- ixt/net/pypi.py +26 -0
- ixt/net/source.py +163 -0
- ixt/platform/__init__.py +131 -0
- ixt/platform/win.py +68 -0
- ixt_cli-0.8.0.dist-info/METADATA +294 -0
- ixt_cli-0.8.0.dist-info/RECORD +84 -0
- ixt_cli-0.8.0.dist-info/WHEEL +4 -0
- ixt_cli-0.8.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Asset-index loading for GitHub binary resolution.
|
|
2
|
+
|
|
3
|
+
``asset_index.json`` is public resolution metadata. It maps canonical GitHub
|
|
4
|
+
specs (``@gh:owner/repo``) to platform-specific asset patterns or exact assets.
|
|
5
|
+
It is intentionally separate from ``registry.toml``: registry resolves names,
|
|
6
|
+
asset-index resolves release assets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ixt.config.settings import Settings, get_settings
|
|
18
|
+
from ixt.data import get_data_path
|
|
19
|
+
from ixt.net.source import RepoSpec, parse_spec
|
|
20
|
+
from ixt.platform import OS, Arch, get_arch, get_os
|
|
21
|
+
|
|
22
|
+
ASSET_INDEX_FILENAME = "asset_index.json"
|
|
23
|
+
GITHUB_API_ENV = "IXT_GITHUB_API"
|
|
24
|
+
|
|
25
|
+
_OS_KEY = {
|
|
26
|
+
OS.LINUX: "linux",
|
|
27
|
+
OS.MACOS: "macos",
|
|
28
|
+
OS.WINDOWS: "windows",
|
|
29
|
+
}
|
|
30
|
+
_ARCH_KEY = {
|
|
31
|
+
Arch.X86_64: "x86_64",
|
|
32
|
+
Arch.ARM64: "aarch64",
|
|
33
|
+
Arch.ARMV7: "armv7",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class GitHubApiDisabledError(RuntimeError):
|
|
38
|
+
"""Raised when strict no-GitHub-API mode lacks local resolution metadata."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class AssetIndexAsset:
|
|
43
|
+
"""An exact platform asset already known by an asset-index source."""
|
|
44
|
+
|
|
45
|
+
tag: str
|
|
46
|
+
asset: str
|
|
47
|
+
url: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class AssetIndexEntry:
|
|
52
|
+
"""Resolution metadata for one canonical GitHub spec."""
|
|
53
|
+
|
|
54
|
+
canonical_spec: str
|
|
55
|
+
names: list[str] = field(default_factory=list)
|
|
56
|
+
patterns: dict[str, str] = field(default_factory=dict)
|
|
57
|
+
assets: dict[str, AssetIndexAsset] = field(default_factory=dict)
|
|
58
|
+
source: str = ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def github_api_mode() -> str:
|
|
62
|
+
"""Return the configured GitHub API mode (``auto`` or ``never``)."""
|
|
63
|
+
value = os.environ.get(GITHUB_API_ENV, "auto").strip().lower()
|
|
64
|
+
return value or "auto"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_github_api_allowed() -> bool:
|
|
68
|
+
"""Whether calls to ``api.github.com`` are allowed."""
|
|
69
|
+
return github_api_mode() != "never"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def disabled_api_message(repo_spec: RepoSpec) -> str:
|
|
73
|
+
"""Clear error text for strict mode when local metadata is insufficient."""
|
|
74
|
+
return (
|
|
75
|
+
f"Cannot resolve GitHub release for {repo_spec.owner}/{repo_spec.repo}: "
|
|
76
|
+
f"{GITHUB_API_ENV}=never is set and no matching asset_index.json entry, "
|
|
77
|
+
"learned asset pattern, exact cached asset, or downloaded cache metadata "
|
|
78
|
+
"could resolve the asset. Preload an asset_index.json with IXT_ASSET_INDEX "
|
|
79
|
+
"or unset IXT_GITHUB_API to allow the GitHub Releases API fallback."
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def platform_key(
|
|
84
|
+
*,
|
|
85
|
+
os_override: OS | None = None,
|
|
86
|
+
arch_override: Arch | None = None,
|
|
87
|
+
) -> str:
|
|
88
|
+
"""Return the public ``os/arch`` key for the current or supplied platform."""
|
|
89
|
+
os_key = _OS_KEY[os_override or get_os()]
|
|
90
|
+
arch_key = _ARCH_KEY[arch_override or get_arch()]
|
|
91
|
+
return f"{os_key}/{arch_key}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def canonical_github_spec(repo_spec: RepoSpec) -> str | None:
|
|
95
|
+
"""Return ``@gh:owner/repo`` for GitHub specs; otherwise None."""
|
|
96
|
+
if repo_spec.platform != "github":
|
|
97
|
+
return None
|
|
98
|
+
return f"@gh:{repo_spec.owner}/{repo_spec.repo}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_configured_asset_index(*, settings: Settings | None = None) -> dict[str, AssetIndexEntry]:
|
|
102
|
+
"""Load built-in, user-config and env-supplied asset indexes.
|
|
103
|
+
|
|
104
|
+
Lower-priority sources are loaded first. Later sources replace an entire
|
|
105
|
+
canonical entry when the same top-level key is present.
|
|
106
|
+
"""
|
|
107
|
+
settings = settings or get_settings()
|
|
108
|
+
merged: dict[str, AssetIndexEntry] = {}
|
|
109
|
+
for path, source in _configured_sources(settings):
|
|
110
|
+
merged.update(load_asset_index_file(path, source=source))
|
|
111
|
+
return merged
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def load_learned_asset_index(*, settings: Settings | None = None) -> dict[str, AssetIndexEntry]:
|
|
115
|
+
"""Expose learned cache metadata as low-priority asset-index entries."""
|
|
116
|
+
settings = settings or get_settings()
|
|
117
|
+
entries: dict[str, AssetIndexEntry] = {}
|
|
118
|
+
key = platform_key()
|
|
119
|
+
|
|
120
|
+
for owner_repo, pattern in _load_asset_patterns(settings).items():
|
|
121
|
+
owner, sep, repo = owner_repo.partition("/")
|
|
122
|
+
if not sep or not owner or not repo:
|
|
123
|
+
continue
|
|
124
|
+
canonical = f"@gh:{owner}/{repo}"
|
|
125
|
+
entry = entries.setdefault(
|
|
126
|
+
canonical,
|
|
127
|
+
AssetIndexEntry(canonical_spec=canonical, source="cache"),
|
|
128
|
+
)
|
|
129
|
+
entry.patterns[key] = pattern
|
|
130
|
+
|
|
131
|
+
for item in _load_download_assets(settings):
|
|
132
|
+
canonical = f"@gh:{item['owner']}/{item['repo']}"
|
|
133
|
+
entry = entries.setdefault(
|
|
134
|
+
canonical,
|
|
135
|
+
AssetIndexEntry(canonical_spec=canonical, source="cache"),
|
|
136
|
+
)
|
|
137
|
+
entry.assets[key] = AssetIndexAsset(
|
|
138
|
+
tag=item["tag"],
|
|
139
|
+
asset=item["asset"],
|
|
140
|
+
url=item["url"] or None,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return entries
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_asset_index_file(path: Path, *, source: str | None = None) -> dict[str, AssetIndexEntry]:
|
|
147
|
+
"""Parse one asset-index file. Missing or malformed files are ignored."""
|
|
148
|
+
if _is_remote_placeholder(path):
|
|
149
|
+
return {}
|
|
150
|
+
try:
|
|
151
|
+
with path.open(encoding="utf-8") as f:
|
|
152
|
+
data = json.load(f)
|
|
153
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
154
|
+
return {}
|
|
155
|
+
if not isinstance(data, dict):
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
source_label = source or str(path)
|
|
159
|
+
entries: dict[str, AssetIndexEntry] = {}
|
|
160
|
+
for raw_spec, raw_entry in data.items():
|
|
161
|
+
if not isinstance(raw_spec, str) or not isinstance(raw_entry, dict):
|
|
162
|
+
continue
|
|
163
|
+
canonical = _canonical_key(raw_spec)
|
|
164
|
+
if canonical is None:
|
|
165
|
+
continue
|
|
166
|
+
entry = _parse_entry(canonical, raw_entry, source=source_label)
|
|
167
|
+
if entry is not None:
|
|
168
|
+
entries[canonical] = entry
|
|
169
|
+
return entries
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _configured_sources(settings: Settings) -> list[tuple[Path, str]]:
|
|
173
|
+
sources = [
|
|
174
|
+
(get_data_path(ASSET_INDEX_FILENAME), "built-in"),
|
|
175
|
+
(settings.config_dir / ASSET_INDEX_FILENAME, "config"),
|
|
176
|
+
]
|
|
177
|
+
for raw in _split_env_paths(os.environ.get("IXT_ASSET_INDEX", "")):
|
|
178
|
+
if raw.startswith(("http://", "https://")):
|
|
179
|
+
continue
|
|
180
|
+
sources.append((Path(raw).expanduser(), f"env:{raw}"))
|
|
181
|
+
return sources
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _split_env_paths(value: str) -> list[str]:
|
|
185
|
+
return [part for part in (item.strip() for item in value.split(";")) if part]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_remote_placeholder(path: Path) -> bool:
|
|
189
|
+
raw = str(path)
|
|
190
|
+
return raw.startswith(("http://", "https://"))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _canonical_key(raw_spec: str) -> str | None:
|
|
194
|
+
raw = raw_spec.strip()
|
|
195
|
+
if raw.startswith("@gh:"):
|
|
196
|
+
raw = raw[4:]
|
|
197
|
+
repo_spec = parse_spec(raw)
|
|
198
|
+
if repo_spec is None or repo_spec.platform != "github":
|
|
199
|
+
return None
|
|
200
|
+
return f"@gh:{repo_spec.owner}/{repo_spec.repo}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _parse_entry(
|
|
204
|
+
canonical: str,
|
|
205
|
+
data: dict[str, Any],
|
|
206
|
+
*,
|
|
207
|
+
source: str,
|
|
208
|
+
) -> AssetIndexEntry | None:
|
|
209
|
+
names = _string_list(data.get("names"))
|
|
210
|
+
patterns = _string_map(data.get("patterns"))
|
|
211
|
+
assets = _asset_map(data.get("assets"))
|
|
212
|
+
if not patterns and not assets:
|
|
213
|
+
return None
|
|
214
|
+
return AssetIndexEntry(
|
|
215
|
+
canonical_spec=canonical,
|
|
216
|
+
names=names,
|
|
217
|
+
patterns=patterns,
|
|
218
|
+
assets=assets,
|
|
219
|
+
source=source,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _string_list(value: object) -> list[str]:
|
|
224
|
+
if not isinstance(value, list):
|
|
225
|
+
return []
|
|
226
|
+
return [item for item in value if isinstance(item, str)]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _string_map(value: object) -> dict[str, str]:
|
|
230
|
+
if not isinstance(value, dict):
|
|
231
|
+
return {}
|
|
232
|
+
return {str(k): v for k, v in value.items() if isinstance(k, str) and isinstance(v, str)}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _asset_map(value: object) -> dict[str, AssetIndexAsset]:
|
|
236
|
+
if not isinstance(value, dict):
|
|
237
|
+
return {}
|
|
238
|
+
result: dict[str, AssetIndexAsset] = {}
|
|
239
|
+
for platform, raw_asset in value.items():
|
|
240
|
+
if not isinstance(platform, str) or not isinstance(raw_asset, dict):
|
|
241
|
+
continue
|
|
242
|
+
tag = raw_asset.get("tag")
|
|
243
|
+
asset = raw_asset.get("asset")
|
|
244
|
+
url = raw_asset.get("url")
|
|
245
|
+
if not isinstance(tag, str) or not isinstance(asset, str):
|
|
246
|
+
continue
|
|
247
|
+
result[platform] = AssetIndexAsset(
|
|
248
|
+
tag=tag,
|
|
249
|
+
asset=asset,
|
|
250
|
+
url=url if isinstance(url, str) else None,
|
|
251
|
+
)
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _load_asset_patterns(settings: Settings) -> dict[str, str]:
|
|
256
|
+
path = settings.metadata_dir / "asset_patterns.json"
|
|
257
|
+
try:
|
|
258
|
+
with path.open(encoding="utf-8") as f:
|
|
259
|
+
data = json.load(f)
|
|
260
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
261
|
+
return {}
|
|
262
|
+
if not isinstance(data, dict):
|
|
263
|
+
return {}
|
|
264
|
+
return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _load_download_assets(settings: Settings) -> list[dict[str, str]]:
|
|
268
|
+
from ixt.core.cache import downloads_index_path
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
with downloads_index_path(settings=settings).open(encoding="utf-8") as f:
|
|
272
|
+
data = json.load(f)
|
|
273
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
274
|
+
return []
|
|
275
|
+
if not isinstance(data, dict) or not isinstance(data.get("entries"), list):
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
items: list[dict[str, str]] = []
|
|
279
|
+
for entry in data["entries"]:
|
|
280
|
+
if not isinstance(entry, dict) or entry.get("kind") != "binary-asset":
|
|
281
|
+
continue
|
|
282
|
+
if entry.get("platform") != "github" or entry.get("host") != "github.com":
|
|
283
|
+
continue
|
|
284
|
+
owner = entry.get("owner")
|
|
285
|
+
repo = entry.get("repo")
|
|
286
|
+
tag = entry.get("tag")
|
|
287
|
+
asset = entry.get("asset")
|
|
288
|
+
url = entry.get("url")
|
|
289
|
+
if (
|
|
290
|
+
not isinstance(owner, str)
|
|
291
|
+
or not isinstance(repo, str)
|
|
292
|
+
or not isinstance(tag, str)
|
|
293
|
+
or not isinstance(asset, str)
|
|
294
|
+
):
|
|
295
|
+
continue
|
|
296
|
+
items.append(
|
|
297
|
+
{
|
|
298
|
+
"owner": owner,
|
|
299
|
+
"repo": repo,
|
|
300
|
+
"tag": tag,
|
|
301
|
+
"asset": asset,
|
|
302
|
+
"url": url if isinstance(url, str) else "",
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
return items
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Cache of learned asset patterns for GitHub Releases.
|
|
2
|
+
|
|
3
|
+
Stored in ``$IXT_CACHE_HOME/metadata/asset_patterns.json``. When
|
|
4
|
+
``IXT_CACHE_HOME`` is unset, ixt falls back to ``$XDG_CACHE_HOME/ixt`` or
|
|
5
|
+
``~/.cache/ixt``.
|
|
6
|
+
|
|
7
|
+
Schema::
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"sharkdp/bat": "bat-v{version}-x86_64-unknown-linux-gnu.tar.gz",
|
|
11
|
+
"BurntSushi/ripgrep": "ripgrep-{version}-x86_64-unknown-linux-musl.tar.gz"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
No TTL. Patterns are stable across years — when one breaks (rare rename
|
|
15
|
+
by a maintainer), the caller invalidates the entry and relearns from a
|
|
16
|
+
fresh release API call.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ixt.config.settings import get_ixt_metadata_dir
|
|
27
|
+
|
|
28
|
+
CACHE_FILENAME = "asset_patterns.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _cache_path() -> Path:
|
|
32
|
+
return get_ixt_metadata_dir() / CACHE_FILENAME
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _make_key(owner: str, repo: str) -> str:
|
|
36
|
+
return f"{owner}/{repo}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load() -> dict[str, str]:
|
|
40
|
+
path = _cache_path()
|
|
41
|
+
if not path.exists():
|
|
42
|
+
return {}
|
|
43
|
+
try:
|
|
44
|
+
with path.open(encoding="utf-8") as f:
|
|
45
|
+
data = json.load(f)
|
|
46
|
+
except (json.JSONDecodeError, OSError):
|
|
47
|
+
return {}
|
|
48
|
+
if not isinstance(data, dict):
|
|
49
|
+
return {}
|
|
50
|
+
return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _atomic_write(data: dict[str, str]) -> None:
|
|
54
|
+
path = _cache_path()
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
|
|
57
|
+
try:
|
|
58
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
59
|
+
json.dump(data, f, indent=2, sort_keys=True)
|
|
60
|
+
f.write("\n")
|
|
61
|
+
os.replace(tmp, path)
|
|
62
|
+
except Exception:
|
|
63
|
+
Path(tmp).unlink(missing_ok=True)
|
|
64
|
+
raise
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def get(owner: str, repo: str) -> str | None:
|
|
68
|
+
"""Return the cached pattern for ``owner/repo``, or None if absent."""
|
|
69
|
+
return _load().get(_make_key(owner, repo))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def set_(owner: str, repo: str, pattern: str) -> None:
|
|
73
|
+
"""Upsert a pattern for ``owner/repo``. Skips the write on no-op."""
|
|
74
|
+
data = _load()
|
|
75
|
+
key = _make_key(owner, repo)
|
|
76
|
+
if data.get(key) == pattern:
|
|
77
|
+
return
|
|
78
|
+
data[key] = pattern
|
|
79
|
+
_atomic_write(data)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def invalidate(owner: str, repo: str) -> None:
|
|
83
|
+
"""Drop ``owner/repo`` from the cache if present."""
|
|
84
|
+
data = _load()
|
|
85
|
+
key = _make_key(owner, repo)
|
|
86
|
+
if data.pop(key, None) is not None:
|
|
87
|
+
_atomic_write(data)
|