esys-lpip 0.1.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 (36) hide show
  1. esys_lpip-0.1.1/PKG-INFO +117 -0
  2. esys_lpip-0.1.1/README.md +101 -0
  3. esys_lpip-0.1.1/pyproject.toml +48 -0
  4. esys_lpip-0.1.1/setup.cfg +4 -0
  5. esys_lpip-0.1.1/src/esys_lpip.egg-info/PKG-INFO +117 -0
  6. esys_lpip-0.1.1/src/esys_lpip.egg-info/SOURCES.txt +34 -0
  7. esys_lpip-0.1.1/src/esys_lpip.egg-info/dependency_links.txt +1 -0
  8. esys_lpip-0.1.1/src/esys_lpip.egg-info/entry_points.txt +2 -0
  9. esys_lpip-0.1.1/src/esys_lpip.egg-info/requires.txt +8 -0
  10. esys_lpip-0.1.1/src/esys_lpip.egg-info/top_level.txt +1 -0
  11. esys_lpip-0.1.1/src/lpip/__init__.py +3 -0
  12. esys_lpip-0.1.1/src/lpip/cache.py +164 -0
  13. esys_lpip-0.1.1/src/lpip/catalog.py +223 -0
  14. esys_lpip-0.1.1/src/lpip/cli.py +311 -0
  15. esys_lpip-0.1.1/src/lpip/config.py +486 -0
  16. esys_lpip-0.1.1/src/lpip/errors.py +183 -0
  17. esys_lpip-0.1.1/src/lpip/gitutils.py +98 -0
  18. esys_lpip-0.1.1/src/lpip/models.py +66 -0
  19. esys_lpip-0.1.1/src/lpip/pipwrap.py +42 -0
  20. esys_lpip-0.1.1/src/lpip/validation.py +97 -0
  21. esys_lpip-0.1.1/tests/test_cache.py +125 -0
  22. esys_lpip-0.1.1/tests/test_catalog.py +198 -0
  23. esys_lpip-0.1.1/tests/test_cli.py +406 -0
  24. esys_lpip-0.1.1/tests/test_cli_catalog.py +187 -0
  25. esys_lpip-0.1.1/tests/test_cli_config.py +147 -0
  26. esys_lpip-0.1.1/tests/test_cli_doctor.py +96 -0
  27. esys_lpip-0.1.1/tests/test_config.py +268 -0
  28. esys_lpip-0.1.1/tests/test_errors.py +76 -0
  29. esys_lpip-0.1.1/tests/test_gitutils.py +48 -0
  30. esys_lpip-0.1.1/tests/test_install_git_catalog_integration.py +100 -0
  31. esys_lpip-0.1.1/tests/test_install_integration.py +65 -0
  32. esys_lpip-0.1.1/tests/test_models.py +62 -0
  33. esys_lpip-0.1.1/tests/test_pipwrap.py +82 -0
  34. esys_lpip-0.1.1/tests/test_registry_publish.py +54 -0
  35. esys_lpip-0.1.1/tests/test_smoke.py +8 -0
  36. esys_lpip-0.1.1/tests/test_url_normalize.py +66 -0
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: esys-lpip
3
+ Version: 0.1.1
4
+ Summary: Git-backed, name-oriented front end to pip for internal Python packages
5
+ Author: Project maintainers
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: platformdirs>=4.0
10
+ Requires-Dist: tomlkit>=0.11
11
+ Requires-Dist: typer>=0.12
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: ruff>=0.8; extra == "dev"
15
+ Requires-Dist: pre-commit>=3.0; extra == "dev"
16
+
17
+ # lpip
18
+
19
+ Git-backed, name-oriented front end to `pip` for internal Python packages —
20
+ no internal PyPI registry required.
21
+
22
+ Install from PyPI as **`esys-lpip`** (`pip install esys-lpip`); the command
23
+ and import remain **`lpip`**.
24
+
25
+ ## Status
26
+
27
+ | Item | Value |
28
+ |------|-------|
29
+ | Workflow | A2C 0.10.0 ([ADR-0000](docs/adr/0000-adopt-a2c-workflow.md)) |
30
+ | Profile | `python-pypi` |
31
+ | Product | Architecture, ADRs, ASRs, and ACs are Accepted; CLI not implemented yet (`src/lpip` is a version stub) |
32
+
33
+ ## What this is
34
+
35
+ `lpip` (**local-pip**) resolves a logical package name from a TOML catalog
36
+ (`lpip-sources.toml`), validates configuration and prerequisites, and delegates
37
+ installation to `python -m pip install ...`. The catalog may be a local file
38
+ or a Git-backed checkout in a persistent user cache.
39
+
40
+ It is a small client, not a package manager replacement, registry, or service
41
+ ([ADR-0001](docs/adr/0001-pip-delegation.md),
42
+ [ADR-0004](docs/adr/0004-cli-first-no-service.md)).
43
+
44
+ ## Non-goals (v1)
45
+
46
+ - Internal package index or hosted registry
47
+ - A dependency resolver other than pip
48
+ - Background daemons, lockfile solving, or workspace orchestration
49
+
50
+ ## Quick start
51
+
52
+ The installable CLI is not available yet. After clone:
53
+
54
+ ```bash
55
+ python -m venv .venv
56
+ # Windows: .venv\Scripts\activate
57
+ # Unix: source .venv/bin/activate
58
+ pip install -e ".[dev]"
59
+ pytest
60
+ ```
61
+
62
+ Re-run `scripts/setup-dev` after clone if you need pre-commit and gitlint
63
+ hooks (see [docs/workflow/adoption.md](docs/workflow/adoption.md)).
64
+
65
+ ## Intended configuration
66
+
67
+ Local catalog:
68
+
69
+ ```toml
70
+ [source]
71
+ type = "file"
72
+ path = "~/company/lpip-sources.toml"
73
+ ```
74
+
75
+ Git-backed catalog:
76
+
77
+ ```toml
78
+ [source]
79
+ type = "git"
80
+ url = "ssh://git.company/dev/lpip-catalog.git"
81
+ revision = "main"
82
+ path = "lpip-sources.toml"
83
+ refresh = "auto"
84
+ ```
85
+
86
+ Example catalog entry:
87
+
88
+ ```toml
89
+ [packages.libfoo]
90
+ url = "git+ssh://git.company/libs/libfoo.git"
91
+ revision = "main"
92
+ subdirectory = "python"
93
+ ```
94
+
95
+ Intended commands (not implemented): `lpip install`, `lpip list`,
96
+ `lpip show`, `lpip validate`, `lpip doctor`, `lpip catalog …`,
97
+ `lpip config …` (including `config init`). Details:
98
+ [docs/architecture/cli-and-configuration.md](docs/architecture/cli-and-configuration.md).
99
+
100
+ ## Documentation
101
+
102
+ | Topic | Location |
103
+ |-------|----------|
104
+ | Architecture | [docs/architecture/overview.md](docs/architecture/overview.md) |
105
+ | Artifact chain | [docs/workflow/documentation-structure.md](docs/workflow/documentation-structure.md) |
106
+ | ASRs | [docs/asr/](docs/asr/) |
107
+ | ADRs | [docs/adr/INDEX.md](docs/adr/INDEX.md) |
108
+ | Acceptance criteria | [docs/ac/](docs/ac/) |
109
+ | A2C adoption | [docs/workflow/adoption.md](docs/workflow/adoption.md) |
110
+ | Release | [docs/release-pipeline.md](docs/release-pipeline.md) |
111
+ | Publishing | [docs/package-publishing.md](docs/package-publishing.md) |
112
+ | GitLab CI | [docs/gitlab-ci.md](docs/gitlab-ci.md) |
113
+ | Agent entry | [AGENTS.md](AGENTS.md) |
114
+
115
+ ## License
116
+
117
+ MIT (`pyproject.toml`).
@@ -0,0 +1,101 @@
1
+ # lpip
2
+
3
+ Git-backed, name-oriented front end to `pip` for internal Python packages —
4
+ no internal PyPI registry required.
5
+
6
+ Install from PyPI as **`esys-lpip`** (`pip install esys-lpip`); the command
7
+ and import remain **`lpip`**.
8
+
9
+ ## Status
10
+
11
+ | Item | Value |
12
+ |------|-------|
13
+ | Workflow | A2C 0.10.0 ([ADR-0000](docs/adr/0000-adopt-a2c-workflow.md)) |
14
+ | Profile | `python-pypi` |
15
+ | Product | Architecture, ADRs, ASRs, and ACs are Accepted; CLI not implemented yet (`src/lpip` is a version stub) |
16
+
17
+ ## What this is
18
+
19
+ `lpip` (**local-pip**) resolves a logical package name from a TOML catalog
20
+ (`lpip-sources.toml`), validates configuration and prerequisites, and delegates
21
+ installation to `python -m pip install ...`. The catalog may be a local file
22
+ or a Git-backed checkout in a persistent user cache.
23
+
24
+ It is a small client, not a package manager replacement, registry, or service
25
+ ([ADR-0001](docs/adr/0001-pip-delegation.md),
26
+ [ADR-0004](docs/adr/0004-cli-first-no-service.md)).
27
+
28
+ ## Non-goals (v1)
29
+
30
+ - Internal package index or hosted registry
31
+ - A dependency resolver other than pip
32
+ - Background daemons, lockfile solving, or workspace orchestration
33
+
34
+ ## Quick start
35
+
36
+ The installable CLI is not available yet. After clone:
37
+
38
+ ```bash
39
+ python -m venv .venv
40
+ # Windows: .venv\Scripts\activate
41
+ # Unix: source .venv/bin/activate
42
+ pip install -e ".[dev]"
43
+ pytest
44
+ ```
45
+
46
+ Re-run `scripts/setup-dev` after clone if you need pre-commit and gitlint
47
+ hooks (see [docs/workflow/adoption.md](docs/workflow/adoption.md)).
48
+
49
+ ## Intended configuration
50
+
51
+ Local catalog:
52
+
53
+ ```toml
54
+ [source]
55
+ type = "file"
56
+ path = "~/company/lpip-sources.toml"
57
+ ```
58
+
59
+ Git-backed catalog:
60
+
61
+ ```toml
62
+ [source]
63
+ type = "git"
64
+ url = "ssh://git.company/dev/lpip-catalog.git"
65
+ revision = "main"
66
+ path = "lpip-sources.toml"
67
+ refresh = "auto"
68
+ ```
69
+
70
+ Example catalog entry:
71
+
72
+ ```toml
73
+ [packages.libfoo]
74
+ url = "git+ssh://git.company/libs/libfoo.git"
75
+ revision = "main"
76
+ subdirectory = "python"
77
+ ```
78
+
79
+ Intended commands (not implemented): `lpip install`, `lpip list`,
80
+ `lpip show`, `lpip validate`, `lpip doctor`, `lpip catalog …`,
81
+ `lpip config …` (including `config init`). Details:
82
+ [docs/architecture/cli-and-configuration.md](docs/architecture/cli-and-configuration.md).
83
+
84
+ ## Documentation
85
+
86
+ | Topic | Location |
87
+ |-------|----------|
88
+ | Architecture | [docs/architecture/overview.md](docs/architecture/overview.md) |
89
+ | Artifact chain | [docs/workflow/documentation-structure.md](docs/workflow/documentation-structure.md) |
90
+ | ASRs | [docs/asr/](docs/asr/) |
91
+ | ADRs | [docs/adr/INDEX.md](docs/adr/INDEX.md) |
92
+ | Acceptance criteria | [docs/ac/](docs/ac/) |
93
+ | A2C adoption | [docs/workflow/adoption.md](docs/workflow/adoption.md) |
94
+ | Release | [docs/release-pipeline.md](docs/release-pipeline.md) |
95
+ | Publishing | [docs/package-publishing.md](docs/package-publishing.md) |
96
+ | GitLab CI | [docs/gitlab-ci.md](docs/gitlab-ci.md) |
97
+ | Agent entry | [AGENTS.md](AGENTS.md) |
98
+
99
+ ## License
100
+
101
+ MIT (`pyproject.toml`).
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "esys-lpip"
7
+ version = "0.1.1"
8
+ description = "Git-backed, name-oriented front end to pip for internal Python packages"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [{ name = "Project maintainers" }]
13
+ dependencies = [
14
+ "platformdirs>=4.0",
15
+ "tomlkit>=0.11",
16
+ "typer>=0.12",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ "ruff>=0.8",
23
+ "pre-commit>=3.0",
24
+ ]
25
+
26
+ [tool.setuptools]
27
+ package-dir = { "" = "src" }
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [project.scripts]
33
+ lpip = "lpip.cli:main"
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["src", "tests"]
38
+ markers = [
39
+ "integration: live venv + pip (network/build isolation)",
40
+ ]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py311"
45
+ src = ["src", "tests"]
46
+
47
+ [tool.ruff.lint]
48
+ select = ["E", "F", "I", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: esys-lpip
3
+ Version: 0.1.1
4
+ Summary: Git-backed, name-oriented front end to pip for internal Python packages
5
+ Author: Project maintainers
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: platformdirs>=4.0
10
+ Requires-Dist: tomlkit>=0.11
11
+ Requires-Dist: typer>=0.12
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: ruff>=0.8; extra == "dev"
15
+ Requires-Dist: pre-commit>=3.0; extra == "dev"
16
+
17
+ # lpip
18
+
19
+ Git-backed, name-oriented front end to `pip` for internal Python packages —
20
+ no internal PyPI registry required.
21
+
22
+ Install from PyPI as **`esys-lpip`** (`pip install esys-lpip`); the command
23
+ and import remain **`lpip`**.
24
+
25
+ ## Status
26
+
27
+ | Item | Value |
28
+ |------|-------|
29
+ | Workflow | A2C 0.10.0 ([ADR-0000](docs/adr/0000-adopt-a2c-workflow.md)) |
30
+ | Profile | `python-pypi` |
31
+ | Product | Architecture, ADRs, ASRs, and ACs are Accepted; CLI not implemented yet (`src/lpip` is a version stub) |
32
+
33
+ ## What this is
34
+
35
+ `lpip` (**local-pip**) resolves a logical package name from a TOML catalog
36
+ (`lpip-sources.toml`), validates configuration and prerequisites, and delegates
37
+ installation to `python -m pip install ...`. The catalog may be a local file
38
+ or a Git-backed checkout in a persistent user cache.
39
+
40
+ It is a small client, not a package manager replacement, registry, or service
41
+ ([ADR-0001](docs/adr/0001-pip-delegation.md),
42
+ [ADR-0004](docs/adr/0004-cli-first-no-service.md)).
43
+
44
+ ## Non-goals (v1)
45
+
46
+ - Internal package index or hosted registry
47
+ - A dependency resolver other than pip
48
+ - Background daemons, lockfile solving, or workspace orchestration
49
+
50
+ ## Quick start
51
+
52
+ The installable CLI is not available yet. After clone:
53
+
54
+ ```bash
55
+ python -m venv .venv
56
+ # Windows: .venv\Scripts\activate
57
+ # Unix: source .venv/bin/activate
58
+ pip install -e ".[dev]"
59
+ pytest
60
+ ```
61
+
62
+ Re-run `scripts/setup-dev` after clone if you need pre-commit and gitlint
63
+ hooks (see [docs/workflow/adoption.md](docs/workflow/adoption.md)).
64
+
65
+ ## Intended configuration
66
+
67
+ Local catalog:
68
+
69
+ ```toml
70
+ [source]
71
+ type = "file"
72
+ path = "~/company/lpip-sources.toml"
73
+ ```
74
+
75
+ Git-backed catalog:
76
+
77
+ ```toml
78
+ [source]
79
+ type = "git"
80
+ url = "ssh://git.company/dev/lpip-catalog.git"
81
+ revision = "main"
82
+ path = "lpip-sources.toml"
83
+ refresh = "auto"
84
+ ```
85
+
86
+ Example catalog entry:
87
+
88
+ ```toml
89
+ [packages.libfoo]
90
+ url = "git+ssh://git.company/libs/libfoo.git"
91
+ revision = "main"
92
+ subdirectory = "python"
93
+ ```
94
+
95
+ Intended commands (not implemented): `lpip install`, `lpip list`,
96
+ `lpip show`, `lpip validate`, `lpip doctor`, `lpip catalog …`,
97
+ `lpip config …` (including `config init`). Details:
98
+ [docs/architecture/cli-and-configuration.md](docs/architecture/cli-and-configuration.md).
99
+
100
+ ## Documentation
101
+
102
+ | Topic | Location |
103
+ |-------|----------|
104
+ | Architecture | [docs/architecture/overview.md](docs/architecture/overview.md) |
105
+ | Artifact chain | [docs/workflow/documentation-structure.md](docs/workflow/documentation-structure.md) |
106
+ | ASRs | [docs/asr/](docs/asr/) |
107
+ | ADRs | [docs/adr/INDEX.md](docs/adr/INDEX.md) |
108
+ | Acceptance criteria | [docs/ac/](docs/ac/) |
109
+ | A2C adoption | [docs/workflow/adoption.md](docs/workflow/adoption.md) |
110
+ | Release | [docs/release-pipeline.md](docs/release-pipeline.md) |
111
+ | Publishing | [docs/package-publishing.md](docs/package-publishing.md) |
112
+ | GitLab CI | [docs/gitlab-ci.md](docs/gitlab-ci.md) |
113
+ | Agent entry | [AGENTS.md](AGENTS.md) |
114
+
115
+ ## License
116
+
117
+ MIT (`pyproject.toml`).
@@ -0,0 +1,34 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/esys_lpip.egg-info/PKG-INFO
4
+ src/esys_lpip.egg-info/SOURCES.txt
5
+ src/esys_lpip.egg-info/dependency_links.txt
6
+ src/esys_lpip.egg-info/entry_points.txt
7
+ src/esys_lpip.egg-info/requires.txt
8
+ src/esys_lpip.egg-info/top_level.txt
9
+ src/lpip/__init__.py
10
+ src/lpip/cache.py
11
+ src/lpip/catalog.py
12
+ src/lpip/cli.py
13
+ src/lpip/config.py
14
+ src/lpip/errors.py
15
+ src/lpip/gitutils.py
16
+ src/lpip/models.py
17
+ src/lpip/pipwrap.py
18
+ src/lpip/validation.py
19
+ tests/test_cache.py
20
+ tests/test_catalog.py
21
+ tests/test_cli.py
22
+ tests/test_cli_catalog.py
23
+ tests/test_cli_config.py
24
+ tests/test_cli_doctor.py
25
+ tests/test_config.py
26
+ tests/test_errors.py
27
+ tests/test_gitutils.py
28
+ tests/test_install_git_catalog_integration.py
29
+ tests/test_install_integration.py
30
+ tests/test_models.py
31
+ tests/test_pipwrap.py
32
+ tests/test_registry_publish.py
33
+ tests/test_smoke.py
34
+ tests/test_url_normalize.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lpip = lpip.cli:main
@@ -0,0 +1,8 @@
1
+ platformdirs>=4.0
2
+ tomlkit>=0.11
3
+ typer>=0.12
4
+
5
+ [dev]
6
+ pytest>=8.0
7
+ ruff>=0.8
8
+ pre-commit>=3.0
@@ -0,0 +1,3 @@
1
+ """lpip — installable package."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,164 @@
1
+ """Persistent Git catalog cache and auto-refresh TTL (ADR-0003, ADR-0012)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import stat
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime, timedelta
13
+ from pathlib import Path
14
+
15
+ from lpip.config import default_user_cache_dir
16
+ from lpip.gitutils import git_checkout, git_clone, git_fetch
17
+ from lpip.models import RefreshMode
18
+
19
+ AUTO_REFRESH_TTL = timedelta(hours=24)
20
+ _METADATA_NAME = "metadata.json"
21
+ _LAST_SUCCESS_KEY = "last_success"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CatalogCachePaths:
26
+ root: Path
27
+ repo: Path
28
+ metadata: Path
29
+
30
+
31
+ def catalog_identity_hash(url: str, revision: str | None) -> str:
32
+ payload = f"{url}\n{revision or ''}".encode()
33
+ return hashlib.sha256(payload).hexdigest()
34
+
35
+
36
+ def catalog_cache_paths(
37
+ url: str,
38
+ revision: str | None,
39
+ *,
40
+ cache_home: Path | None = None,
41
+ ) -> CatalogCachePaths:
42
+ home = cache_home if cache_home is not None else default_user_cache_dir()
43
+ root = home / "catalogs" / catalog_identity_hash(url, revision)
44
+ return CatalogCachePaths(root=root, repo=root / "repo", metadata=root / _METADATA_NAME)
45
+
46
+
47
+ def read_last_success(paths: CatalogCachePaths) -> datetime | None:
48
+ if not paths.metadata.is_file():
49
+ return None
50
+ try:
51
+ data = json.loads(paths.metadata.read_text(encoding="utf-8"))
52
+ except (OSError, json.JSONDecodeError):
53
+ return None
54
+ raw = data.get(_LAST_SUCCESS_KEY)
55
+ if not isinstance(raw, str) or not raw:
56
+ return None
57
+ try:
58
+ parsed = datetime.fromisoformat(raw)
59
+ except ValueError:
60
+ return None
61
+ if parsed.tzinfo is None:
62
+ parsed = parsed.replace(tzinfo=UTC)
63
+ return parsed
64
+
65
+
66
+ def write_last_success(paths: CatalogCachePaths, when: datetime | None = None) -> None:
67
+ """Record last successful clone/fetch. Call only after Git succeeds."""
68
+ stamp = when or datetime.now(UTC)
69
+ if stamp.tzinfo is None:
70
+ stamp = stamp.replace(tzinfo=UTC)
71
+ payload: dict[str, object] = {}
72
+ if paths.metadata.is_file():
73
+ try:
74
+ loaded = json.loads(paths.metadata.read_text(encoding="utf-8"))
75
+ if isinstance(loaded, dict):
76
+ payload = loaded
77
+ except (OSError, json.JSONDecodeError):
78
+ payload = {}
79
+ payload[_LAST_SUCCESS_KEY] = stamp.isoformat()
80
+ paths.root.mkdir(parents=True, exist_ok=True)
81
+ paths.metadata.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
82
+
83
+
84
+ def needs_auto_refresh(
85
+ paths: CatalogCachePaths,
86
+ *,
87
+ now: datetime | None = None,
88
+ ) -> bool:
89
+ """True if timestamp is missing or older than 24 hours (ADR-0012)."""
90
+ last = read_last_success(paths)
91
+ if last is None:
92
+ return True
93
+ clock = now or datetime.now(UTC)
94
+ if clock.tzinfo is None:
95
+ clock = clock.replace(tzinfo=UTC)
96
+ return clock - last >= AUTO_REFRESH_TTL
97
+
98
+
99
+ def should_fetch(
100
+ paths: CatalogCachePaths,
101
+ refresh: RefreshMode,
102
+ *,
103
+ force: bool = False,
104
+ now: datetime | None = None,
105
+ ) -> bool:
106
+ """Whether to fetch an existing checkout. Absent cache is always cloned."""
107
+ if force or refresh is RefreshMode.ALWAYS:
108
+ return True
109
+ if refresh is RefreshMode.NEVER:
110
+ return False
111
+ return needs_auto_refresh(paths, now=now)
112
+
113
+
114
+ def _repo_ready(repo: Path) -> bool:
115
+ git_dir = repo / ".git"
116
+ return git_dir.is_dir() or git_dir.is_file()
117
+
118
+
119
+ def ensure_catalog_checkout(
120
+ url: str,
121
+ revision: str | None,
122
+ *,
123
+ cache_home: Path | None = None,
124
+ refresh: RefreshMode = RefreshMode.AUTO,
125
+ force_refresh: bool = False,
126
+ now: datetime | None = None,
127
+ environ: Mapping[str, str] | None = None,
128
+ ) -> CatalogCachePaths:
129
+ """Clone if missing; fetch per refresh policy; stamp only on Git success."""
130
+ paths = catalog_cache_paths(url, revision, cache_home=cache_home)
131
+ if not _repo_ready(paths.repo):
132
+ git_clone(url, paths.repo, environ=environ)
133
+ write_last_success(paths, now)
134
+ if revision:
135
+ git_checkout(paths.repo, revision, environ=environ)
136
+ return paths
137
+
138
+ if should_fetch(paths, refresh, force=force_refresh, now=now):
139
+ git_fetch(paths.repo, environ=environ)
140
+ write_last_success(paths, now)
141
+ if revision:
142
+ git_checkout(paths.repo, revision, environ=environ)
143
+ return paths
144
+
145
+
146
+ def _rmtree(path: Path) -> None:
147
+ def _onerror(func: object, name: str, _exc: object) -> None:
148
+ os.chmod(name, stat.S_IWRITE)
149
+ func(name) # type: ignore[operator]
150
+
151
+ shutil.rmtree(path, onerror=_onerror)
152
+
153
+
154
+ def purge_catalog_cache(
155
+ url: str,
156
+ revision: str | None,
157
+ *,
158
+ cache_home: Path | None = None,
159
+ ) -> CatalogCachePaths:
160
+ """Remove the cache directory for this catalog identity."""
161
+ paths = catalog_cache_paths(url, revision, cache_home=cache_home)
162
+ if paths.root.exists():
163
+ _rmtree(paths.root)
164
+ return paths