macrostates-cli 0.3.1__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.
@@ -0,0 +1,153 @@
1
+ """GitHub transport. Tokens never enter manifests, locks, URLs or diagnostics."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from typing import Any, Protocol
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.parse import quote, urlparse
12
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
13
+
14
+ from ._integrity import MAX_ARCHIVE
15
+ from ._models import MacrostatesError
16
+
17
+
18
+ class SourceProvider(Protocol):
19
+ """Injectable source boundary used by install and lock."""
20
+
21
+ def resolve(self, repository: str, tag: str) -> str:
22
+ """Return the full commit ID selected by a release tag."""
23
+ ...
24
+
25
+ def download(self, repository: str, commit: str) -> bytes:
26
+ """Return a tar archive of that exact commit with a single root directory."""
27
+ ...
28
+
29
+
30
+ def github_repository(value: str) -> str:
31
+ patterns = (
32
+ r"https://github\.com/([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?/?",
33
+ r"git@github\.com:([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+?)(?:\.git)?",
34
+ )
35
+ for pattern in patterns:
36
+ match = re.fullmatch(pattern, value)
37
+ if match:
38
+ if any(part in (".", "..") for part in match[1].split("/")):
39
+ break
40
+ return match[1]
41
+ raise MacrostatesError("Sources must use a credential-free github.com repository URL")
42
+
43
+
44
+ class SafeRedirect(HTTPRedirectHandler):
45
+ def redirect_request(
46
+ self, req: Request, fp: Any, code: int, msg: str, headers: Any, newurl: str
47
+ ) -> Request | None:
48
+ parsed = urlparse(newurl)
49
+ if parsed.scheme != "https" or parsed.hostname not in (
50
+ "github.com",
51
+ "api.github.com",
52
+ "codeload.github.com",
53
+ ):
54
+ raise MacrostatesError("GitHub archive redirected to an unsupported host")
55
+ redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
56
+ if redirected and parsed.hostname != urlparse(req.full_url).hostname:
57
+ redirected.remove_header("Authorization")
58
+ return redirected
59
+
60
+
61
+ class GitHubSource:
62
+ """Download GitHub snapshots using an explicit token, environment, or gh login.
63
+
64
+ Creating this object performs no I/O. Credential lookup occurs only when a
65
+ request is made. Passing token explicitly takes precedence over environment.
66
+ """
67
+
68
+ def __init__(self, *, token: str | None = None, timeout: float = 30) -> None:
69
+ self._explicit_token = token
70
+ self.timeout = timeout
71
+
72
+ def _token(self) -> str | None:
73
+ if self._explicit_token is not None:
74
+ return self._explicit_token
75
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
76
+ if token:
77
+ return token
78
+ if shutil.which("gh"):
79
+ try:
80
+ result = subprocess.run(
81
+ ["gh", "auth", "token", "--hostname", "github.com"],
82
+ capture_output=True,
83
+ text=True,
84
+ timeout=self.timeout,
85
+ check=False,
86
+ )
87
+ except (OSError, subprocess.SubprocessError) as exc:
88
+ raise MacrostatesError("GitHub CLI credential lookup failed") from exc
89
+ if result.returncode == 0:
90
+ return result.stdout.strip()
91
+ return None
92
+
93
+ def _request(self, endpoint: str, *, archive: bool = False) -> bytes:
94
+ try:
95
+ user_agent = "macrostates-cli/" + version("macrostates-cli")
96
+ except PackageNotFoundError:
97
+ user_agent = "macrostates-cli"
98
+ headers = {
99
+ "Accept": "application/vnd.github+json",
100
+ "User-Agent": user_agent,
101
+ "X-GitHub-Api-Version": "2022-11-28",
102
+ }
103
+ token = self._token()
104
+ if token:
105
+ headers["Authorization"] = "Bearer " + token
106
+ request = Request("https://api.github.com/" + endpoint, headers=headers)
107
+ limit = MAX_ARCHIVE if archive else 2 * 1024 * 1024
108
+ try:
109
+ with build_opener(SafeRedirect()).open(request, timeout=self.timeout) as response:
110
+ data = response.read(limit + 1)
111
+ except HTTPError as exc:
112
+ raise MacrostatesError(
113
+ f"GitHub request failed (HTTP {exc.code}); check access and the release tag"
114
+ ) from None
115
+ except (URLError, OSError, ValueError) as exc:
116
+ raise MacrostatesError(
117
+ "GitHub request failed; check connectivity and authentication"
118
+ ) from exc
119
+ if len(data) > limit:
120
+ raise MacrostatesError("GitHub response exceeds the download limit")
121
+ return data
122
+
123
+ def _json(self, endpoint: str) -> dict[str, Any]:
124
+ try:
125
+ result = json.loads(self._request(endpoint))
126
+ except (ValueError, UnicodeError) as exc:
127
+ raise MacrostatesError("Invalid GitHub API response") from exc
128
+ if not isinstance(result, dict):
129
+ raise MacrostatesError("Invalid GitHub API response")
130
+ return result
131
+
132
+ def resolve(self, repository: str, tag: str) -> str:
133
+ repository_id = github_repository(repository)
134
+ result = self._json(f"repos/{repository_id}/git/ref/tags/{quote(tag, safe='')}")
135
+ for _ in range(8):
136
+ obj = result.get("object", {})
137
+ if not isinstance(obj, dict):
138
+ raise MacrostatesError("GitHub returned an invalid tag object")
139
+ sha = obj.get("sha")
140
+ if not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", sha):
141
+ raise MacrostatesError("GitHub returned an invalid object ID")
142
+ if obj.get("type") == "commit":
143
+ return sha
144
+ if obj.get("type") != "tag":
145
+ raise MacrostatesError("Release tag does not select a commit")
146
+ result = self._json(f"repos/{repository_id}/git/tags/{sha}")
147
+ raise MacrostatesError("Release tag nesting exceeds the supported depth")
148
+
149
+ def download(self, repository: str, commit: str) -> bytes:
150
+ repository_id = github_repository(repository)
151
+ if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", commit):
152
+ raise MacrostatesError("Archive download requires a full commit ID")
153
+ return self._request(f"repos/{repository_id}/tarball/{commit}", archive=True)
macrostates/_staged.py ADDED
@@ -0,0 +1,56 @@
1
+ """Read a Git index without executing hooks or changing the checkout."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from ._integrity import MAX_EXTRACTED
8
+ from ._io import relative_path, safe_path
9
+ from ._models import MacrostatesError
10
+
11
+
12
+ def git(root: Path, *arguments: str) -> bytes:
13
+ result = subprocess.run(["git", "-C", str(root), *arguments], capture_output=True, check=False)
14
+ if result.returncode:
15
+ raise MacrostatesError(
16
+ "Cannot inspect Git index; ensure the project is inside a Git repository"
17
+ )
18
+ return result.stdout
19
+
20
+
21
+ def export_index(project_root: Path, target: Path) -> Path:
22
+ git_root = Path(
23
+ os.fsdecode(git(project_root, "rev-parse", "--show-toplevel")).strip()
24
+ ).resolve()
25
+ relative_project = project_root.resolve().relative_to(git_root)
26
+ entries = git(git_root, "ls-files", "--stage", "-z").split(b"\0")
27
+ links: list[tuple[Path, str]] = []
28
+ total = 0
29
+ for entry in entries:
30
+ if not entry:
31
+ continue
32
+ header, encoded_path = entry.split(b"\t", 1)
33
+ mode, sha, stage = header.decode("ascii").split()
34
+ if stage != "0":
35
+ raise MacrostatesError("Git index has unresolved merge conflicts")
36
+ if mode == "160000":
37
+ # Submodule contents are outside this repository's staged snapshot.
38
+ continue
39
+ if mode not in ("100644", "100755", "120000"):
40
+ raise MacrostatesError("Git index contains an unsupported file mode")
41
+ name = relative_path(os.fsdecode(encoded_path), "staged path")
42
+ destination = safe_path(target, name)
43
+ contents = git(git_root, "cat-file", "blob", sha)
44
+ total += len(contents)
45
+ if total > MAX_EXTRACTED:
46
+ raise MacrostatesError("Staged snapshot exceeds 128 MiB")
47
+ destination.parent.mkdir(parents=True, exist_ok=True)
48
+ if mode == "120000":
49
+ links.append((destination, os.fsdecode(contents)))
50
+ else:
51
+ destination.write_bytes(contents)
52
+ destination.chmod(0o755 if mode == "100755" else 0o644)
53
+ # Creating links last prevents a staged link from directing writes elsewhere.
54
+ for destination, contents in links:
55
+ destination.symlink_to(contents)
56
+ return target / relative_project
@@ -0,0 +1,274 @@
1
+ """Structural validation and dependency checks over normalized metadata."""
2
+
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from urllib.parse import unquote, urlsplit
7
+
8
+ from ._formats import read_metadata
9
+ from ._formats.common import dependencies
10
+ from ._io import read_yaml, safe_path, version
11
+ from ._models import Composition, MacrostatesError, Metadata, Report
12
+
13
+
14
+ def validate_packages(
15
+ composition: Composition, roots: dict[str, Path]
16
+ ) -> tuple[Report, dict[str, Metadata]]:
17
+ report = Report()
18
+ metadata: dict[str, Metadata] = {}
19
+ selected = {package.name: package for package in composition.packages}
20
+ for package in composition.packages:
21
+ root = roots[package.name]
22
+ try:
23
+ item = read_metadata(read_yaml(safe_path(root, "package.yaml")))
24
+ if (item.name, item.version, item.entrypoint) != (
25
+ package.name,
26
+ package.version,
27
+ package.entrypoint,
28
+ ):
29
+ report.add(
30
+ "package.mismatch",
31
+ f"Metadata does not match selection for {package.name}",
32
+ package.path,
33
+ )
34
+ if not safe_path(root, item.entrypoint).is_file():
35
+ report.add(
36
+ "entrypoint.missing", f"Missing entrypoint for {package.name}", package.path
37
+ )
38
+ for key, raw in package.legacy_dependencies.items():
39
+ expected = (
40
+ item.dependencies if key == "dependencies" else item.optional_dependencies
41
+ )
42
+ if dependencies(raw, key) != expected:
43
+ report.add(
44
+ "dependency.legacy_mismatch",
45
+ f"Legacy dependency copies disagree for {package.name}",
46
+ package.path,
47
+ )
48
+ metadata[package.name] = item
49
+ except (MacrostatesError, OSError) as exc:
50
+ report.add("package.invalid", f"{package.name}: {exc}", package.path)
51
+ graph: dict[str, list[str]] = {}
52
+ for name, item in metadata.items():
53
+ graph[name] = []
54
+ for dependency in item.dependencies + item.optional_dependencies:
55
+ if dependency.name not in selected:
56
+ if dependency in item.dependencies:
57
+ report.add("dependency.missing", f"{name} requires {dependency.name}")
58
+ continue
59
+ actual = version(selected[dependency.name].version)
60
+ required = version(dependency.version)
61
+ satisfied = (
62
+ actual == required if dependency.constraint == "exact" else actual >= required
63
+ )
64
+ if dependency.constraint == "compatible":
65
+ satisfied = satisfied and actual[0] == required[0]
66
+ if not satisfied:
67
+ report.add(
68
+ "dependency.incompatible",
69
+ f"{name} requires {dependency.name} {dependency.constraint} {dependency.version}; selected {selected[dependency.name].version}",
70
+ )
71
+ graph[name].append(dependency.name)
72
+
73
+ visiting: set[str] = set()
74
+ visited: set[str] = set()
75
+
76
+ def visit(name: str) -> None:
77
+ if name in visiting:
78
+ report.add("dependency.cycle", f"Dependency cycle contains {name}")
79
+ return
80
+ if name in visited:
81
+ return
82
+ visiting.add(name)
83
+ for target in graph.get(name, []):
84
+ visit(target)
85
+ visiting.remove(name)
86
+ visited.add(name)
87
+
88
+ for name in graph:
89
+ visit(name)
90
+ return report, metadata
91
+
92
+
93
+ def check_links(root: Path, boundary: Path, report: Report) -> None:
94
+ # This is a conservative local-link checker, not a Markdown semantic parser.
95
+ for path in sorted(root.rglob("*.md")):
96
+ if path.is_symlink():
97
+ report.add(
98
+ "path.symlink",
99
+ "Specification documents must not be symlinks",
100
+ path.relative_to(boundary).as_posix(),
101
+ )
102
+ continue
103
+ text = path.read_text(encoding="utf-8")
104
+ text = re.sub(r"```.*?```|~~~.*?~~~", "", text, flags=re.S)
105
+ targets = re.findall(r"!?\[[^\]\n]*\]\(([^\n)]*)\)", text)
106
+ targets += re.findall(r"^\s*\[[^\]]+\]:\s*(\S+)", text, flags=re.M)
107
+ for raw in targets:
108
+ target = raw.strip().split(' "', 1)[0].strip("<>")
109
+ parsed = urlsplit(target)
110
+ if not target or parsed.scheme or parsed.netloc or not parsed.path:
111
+ continue
112
+ destination = path.parent / unquote(parsed.path)
113
+ if not destination.resolve().is_relative_to(boundary.resolve()):
114
+ report.add(
115
+ "link.escape",
116
+ "Local specification link escapes the project",
117
+ path.relative_to(boundary).as_posix(),
118
+ )
119
+ elif not destination.exists():
120
+ report.add(
121
+ "link.missing",
122
+ f"Missing local link target: {parsed.path}",
123
+ path.relative_to(boundary).as_posix(),
124
+ )
125
+
126
+
127
+ class Process23Policy:
128
+ """Process 2.3.x declaration policy; earlier releases retain their own rules."""
129
+
130
+ label = "Process 2.3"
131
+ requires_modern_layout = False
132
+
133
+ def check(self, composition: Composition, implementation: Path, report: Report) -> None:
134
+ declared = composition.raw["project"].get("version")
135
+ if not isinstance(declared, str) or not declared.startswith("spec-"):
136
+ report.add(
137
+ "process.spec_version",
138
+ f"{self.label} requires project.version: spec-MAJOR.MINOR.REVISION",
139
+ )
140
+ return
141
+ try:
142
+ spec = version(declared.removeprefix("spec-"))
143
+ release = safe_path(implementation, "release.yaml")
144
+ if not release.exists():
145
+ overview = safe_path(implementation, "main.md")
146
+ if overview.is_file() and re.search(
147
+ r"(?im)^\s*project phase:\s*active\b", overview.read_text()
148
+ ):
149
+ report.add(
150
+ "process.release_missing", "Active implementation has no release.yaml"
151
+ )
152
+ return
153
+ data = read_yaml(release)
154
+ current = version(data.get("version"))
155
+ baseline_text = data.get("specification")
156
+ if not isinstance(baseline_text, str) or not baseline_text.startswith("spec-"):
157
+ raise MacrostatesError("release.specification must be spec-MAJOR.MINOR.REVISION")
158
+ baseline = version(baseline_text.removeprefix("spec-"))
159
+ stamp = data.get("release_date")
160
+ if not isinstance(stamp, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", stamp):
161
+ raise MacrostatesError("release_date must be YYYY-MM-DD")
162
+ date.fromisoformat(stamp)
163
+ if current[:2] != baseline[:2] or current[:2] != spec[:2]:
164
+ report.add(
165
+ "process.version_alignment",
166
+ "Composition, implementation and baseline Major.Minor differ",
167
+ )
168
+ if baseline[:2] == spec[:2] and baseline[2] > spec[2]:
169
+ report.add(
170
+ "process.future_baseline",
171
+ "Implementation references a later specification revision",
172
+ )
173
+ except (MacrostatesError, ValueError, OSError) as exc:
174
+ report.add("process.release_invalid", str(exc))
175
+
176
+
177
+ class Process24Policy(Process23Policy):
178
+ """2.4 adds optional verification guidance without changing declarations."""
179
+
180
+ label = "Process 2.4"
181
+
182
+
183
+ class Process30Policy(Process23Policy):
184
+ """3.0 retains contract versions and adopts Meta 2 artifact locations."""
185
+
186
+ label = "Process 3.0"
187
+ requires_modern_layout = True
188
+
189
+ def check(self, composition: Composition, implementation: Path, report: Report) -> None:
190
+ if implementation.parent.name != ".macrostates":
191
+ report.add("process.layout", f"{self.label} requires .macrostates/implementation/")
192
+ super().check(composition, implementation, report)
193
+
194
+
195
+ class Process40Policy(Process30Policy):
196
+ """4.0 retains declarations/layout; branch and PR compliance needs manual review."""
197
+
198
+ label = "Process 4.0"
199
+
200
+
201
+ class Process41Policy(Process40Policy):
202
+ """4.1 expands dependency constraints without changing declaration checks."""
203
+
204
+ label = "Process 4.1"
205
+
206
+
207
+ PROCESS_POLICIES = {
208
+ (2, 3): Process23Policy(),
209
+ (2, 4): Process24Policy(),
210
+ (3, 0): Process30Policy(),
211
+ (4, 0): Process40Policy(),
212
+ (4, 1): Process41Policy(),
213
+ }
214
+
215
+
216
+ def has_contract_version(process_version: str) -> bool:
217
+ """Only known release policies opt new projects into contract versions."""
218
+ return version(process_version)[:2] in PROCESS_POLICIES
219
+
220
+
221
+ def process_requires_modern_layout(process_version: str) -> bool:
222
+ policy = PROCESS_POLICIES.get(version(process_version)[:2])
223
+ return policy is not None and policy.requires_modern_layout
224
+
225
+
226
+ class Meta20Policy:
227
+ """Meta 2.0 layout and explicit composition-format checks."""
228
+
229
+ label = "Meta 2.0"
230
+
231
+ def check(self, composition: Composition, specs: Path, root: Path, report: Report) -> None:
232
+ if specs != root / ".macrostates" / "specs":
233
+ report.add("meta.layout", f"{self.label} requires .macrostates/specs/")
234
+ if composition.schema_version != 1:
235
+ report.add(
236
+ "meta.composition_format", f"{self.label} requires composition schema_version: 1"
237
+ )
238
+
239
+
240
+ class Meta21Policy(Meta20Policy):
241
+ """2.1 changes authoring guidance; explicit dependency constraints remain required."""
242
+
243
+ label = "Meta 2.1"
244
+
245
+
246
+ META_POLICIES = {(2, 0): Meta20Policy(), (2, 1): Meta21Policy()}
247
+
248
+
249
+ def check_meta(composition: Composition, specs: Path, root: Path, report: Report) -> None:
250
+ """Release-selected layout policy; legacy projects keep their own conventions."""
251
+ meta = next((item for item in composition.packages if item.name == "meta"), None)
252
+ if meta is None or version(meta.version)[0] < 2:
253
+ return
254
+ policy = META_POLICIES.get(version(meta.version)[:2])
255
+ if policy is None:
256
+ report.add("meta.unsupported_policy", f"No layout-policy adapter for Meta {meta.version}")
257
+ return
258
+ policy.check(composition, specs, root, report)
259
+
260
+
261
+ def check_process(composition: Composition, implementation: Path, report: Report) -> None:
262
+ process = next((package for package in composition.packages if package.name == "process"), None)
263
+ if process is None:
264
+ return
265
+ selected = version(process.version)
266
+ if selected[0] == 1 or (selected[0] == 2 and selected[1] < 3):
267
+ return
268
+ policy = PROCESS_POLICIES.get(selected[:2])
269
+ if policy is not None:
270
+ policy.check(composition, implementation, report)
271
+ else:
272
+ report.add(
273
+ "process.unsupported_policy", f"No process-policy adapter for Process {process.version}"
274
+ )
macrostates/py.typed ADDED
File without changes