cpyte-cpm 1.1.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.
- cpyte/__init__.py +1 -0
- cpyte/cli/__init__.py +0 -0
- cpyte/cli/commands.py +108 -0
- cpyte/cli/errors.py +49 -0
- cpyte/cli/executor.py +263 -0
- cpyte/cli/gethins.py +36 -0
- cpyte/cli/lockfile.py +183 -0
- cpyte/cli/main.py +84 -0
- cpyte/cli/manifest.py +363 -0
- cpyte/cli/parser.py +336 -0
- cpyte/cli/sat.py +315 -0
- cpyte/cli/things.py +720 -0
- cpyte_cpm-1.1.1.dist-info/METADATA +145 -0
- cpyte_cpm-1.1.1.dist-info/RECORD +17 -0
- cpyte_cpm-1.1.1.dist-info/WHEEL +5 -0
- cpyte_cpm-1.1.1.dist-info/entry_points.txt +2 -0
- cpyte_cpm-1.1.1.dist-info/top_level.txt +1 -0
cpyte/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CPM - A package manager for Cpyte."""
|
cpyte/cli/__init__.py
ADDED
|
File without changes
|
cpyte/cli/commands.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Structured types for parsed CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import List, Optional, Union
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class GlobalOptions:
|
|
11
|
+
verbose: bool = False
|
|
12
|
+
quiet: bool = False
|
|
13
|
+
yes: bool = False
|
|
14
|
+
offline: bool = False
|
|
15
|
+
no_cache: bool = False
|
|
16
|
+
config: Optional[str] = None
|
|
17
|
+
server: List[str] = field(default_factory=list)
|
|
18
|
+
target: Optional[str] = None
|
|
19
|
+
llvm_version: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class InitCommand:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AddCommand:
|
|
29
|
+
packages: List[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class RemoveCommand:
|
|
34
|
+
packages: List[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class InstallCommand:
|
|
39
|
+
packages: List[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class UpdateCommand:
|
|
44
|
+
packages: List[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class BuildCommand:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class RunCommand:
|
|
54
|
+
script: str = ""
|
|
55
|
+
args: List[str] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class VersionCommand:
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PublishCommand:
|
|
65
|
+
directory: str = ""
|
|
66
|
+
name: str = ""
|
|
67
|
+
version: str = ""
|
|
68
|
+
requires: List[str] = field(default_factory=list)
|
|
69
|
+
prebuilt: bool = False
|
|
70
|
+
llvm_version: str = ""
|
|
71
|
+
cpyte_version: str = ""
|
|
72
|
+
server: str = ""
|
|
73
|
+
token: str = ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class UnpublishCommand:
|
|
78
|
+
name: str = ""
|
|
79
|
+
version: str = ""
|
|
80
|
+
all: bool = False
|
|
81
|
+
block: bool = False
|
|
82
|
+
server: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class SearchCommand:
|
|
87
|
+
query: str = ""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
Command = Union[
|
|
91
|
+
InitCommand,
|
|
92
|
+
AddCommand,
|
|
93
|
+
RemoveCommand,
|
|
94
|
+
InstallCommand,
|
|
95
|
+
UpdateCommand,
|
|
96
|
+
BuildCommand,
|
|
97
|
+
RunCommand,
|
|
98
|
+
VersionCommand,
|
|
99
|
+
PublishCommand,
|
|
100
|
+
UnpublishCommand,
|
|
101
|
+
SearchCommand,
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class ParsedCLI:
|
|
107
|
+
global_options: GlobalOptions = field(default_factory=GlobalOptions)
|
|
108
|
+
command: Optional[Command] = None
|
cpyte/cli/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""CLI error types for clear, user-facing error messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CLIError(Exception):
|
|
7
|
+
"""Base error for CLI parsing issues."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UnknownCommandError(CLIError):
|
|
11
|
+
"""Raised when an unrecognized subcommand is given."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, command: str, suggestions: list[str] | None = None) -> None:
|
|
14
|
+
self.command = command
|
|
15
|
+
self.suggestions = suggestions or []
|
|
16
|
+
msg = f"unknown command '{command}'"
|
|
17
|
+
if self.suggestions:
|
|
18
|
+
msg += f". Did you mean: {', '.join(self.suggestions)}?"
|
|
19
|
+
super().__init__(msg)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MissingArgumentError(CLIError):
|
|
23
|
+
"""Raised when a required argument is missing."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, argument: str, command: str) -> None:
|
|
26
|
+
self.argument = argument
|
|
27
|
+
self.command = command
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"command '{command}' requires {argument}"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InvalidPackageSpecError(CLIError):
|
|
34
|
+
"""Raised when a package specification is invalid."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, spec: str) -> None:
|
|
37
|
+
self.spec = spec
|
|
38
|
+
super().__init__(f"invalid package specification: '{spec}'")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class InvalidOptionError(CLIError):
|
|
42
|
+
"""Raised when an invalid option is provided."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, option: str, message: str = "") -> None:
|
|
45
|
+
self.option = option
|
|
46
|
+
msg = f"invalid option '{option}'"
|
|
47
|
+
if message:
|
|
48
|
+
msg += f": {message}"
|
|
49
|
+
super().__init__(msg)
|
cpyte/cli/executor.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Execution engine for CPM instruction streams.
|
|
2
|
+
|
|
3
|
+
Pipeline stage: Execute
|
|
4
|
+
Consumes the flat instruction list produced by deduplicator() and
|
|
5
|
+
performs actual filesystem operations.
|
|
6
|
+
|
|
7
|
+
Directory layout:
|
|
8
|
+
~/.cpm/cache/<name>/<version>/ — downloaded artifacts (shared cache)
|
|
9
|
+
<project>/.cpm/modules/<name>/<version>/ — installed packages (project-local)
|
|
10
|
+
|
|
11
|
+
Modes:
|
|
12
|
+
prebuilt = false (default): install source (.cpy files)
|
|
13
|
+
prebuilt = true: install precompiled LLVM IR (.ll)
|
|
14
|
+
registry serves prebuilt artifacts
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import tarfile
|
|
21
|
+
import tempfile
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import requests as rq
|
|
26
|
+
|
|
27
|
+
from .gethins import fetch_repo
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
CPM_HOME = Path.home() / ".cpm"
|
|
31
|
+
CACHE_DIR = CPM_HOME / "cache"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ensure_cache_dir():
|
|
35
|
+
"""Create CPM cache directory if it doesn't exist."""
|
|
36
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cache_path(name: str, version: str) -> Path:
|
|
40
|
+
"""Return the cache directory for a specific package version."""
|
|
41
|
+
return CACHE_DIR / name / version
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _module_path(project_root: Path, name: str, version: str) -> Path:
|
|
45
|
+
"""Return the project-local install directory for a specific package version."""
|
|
46
|
+
return project_root / ".cpm" / "modules" / name / version
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def calculate_checksum(file_path: str, algorithm: str = "sha256") -> str:
|
|
50
|
+
"""Calculate checksum of a file."""
|
|
51
|
+
hash_func = hashlib.new(algorithm)
|
|
52
|
+
with open(file_path, "rb") as fp:
|
|
53
|
+
for chunk in iter(lambda: fp.read(4096), b""):
|
|
54
|
+
hash_func.update(chunk)
|
|
55
|
+
return f"{algorithm}:{hash_func.hexdigest()}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _verify_checksum(file_path: str, expected: str) -> None:
|
|
59
|
+
"""Verify a file's checksum. Expected format: 'algorithm:hex'."""
|
|
60
|
+
if ":" not in expected:
|
|
61
|
+
raise ValueError(f"Checksum must be 'algorithm:hex', got: {expected}")
|
|
62
|
+
algorithm, expected_hex = expected.split(":", 1)
|
|
63
|
+
actual = calculate_checksum(file_path, algorithm)
|
|
64
|
+
actual_hex = actual.split(":", 1)[1]
|
|
65
|
+
if actual_hex != expected_hex:
|
|
66
|
+
os.remove(file_path)
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Checksum mismatch for {file_path}: "
|
|
69
|
+
f"expected {expected}, got {actual}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _download(url: str, dest: Path) -> None:
|
|
74
|
+
"""Download a file from url to dest."""
|
|
75
|
+
response = rq.get(url, stream=True, timeout=30)
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
with open(dest, "wb") as fp:
|
|
78
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
79
|
+
fp.write(chunk)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract(archive_path: Path, dest: Path) -> None:
|
|
83
|
+
"""Extract a tar.gz or zip archive into dest."""
|
|
84
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
name = archive_path.name
|
|
86
|
+
if name.endswith(".tar.gz") or name.endswith(".tgz"):
|
|
87
|
+
with tarfile.open(archive_path, "r:gz") as tar:
|
|
88
|
+
tar.extractall(dest)
|
|
89
|
+
elif name.endswith(".tar.bz2"):
|
|
90
|
+
with tarfile.open(archive_path, "r:bz2") as tar:
|
|
91
|
+
tar.extractall(dest)
|
|
92
|
+
elif name.endswith(".tar"):
|
|
93
|
+
with tarfile.open(archive_path, "r") as tar:
|
|
94
|
+
tar.extractall(dest)
|
|
95
|
+
elif name.endswith(".zip"):
|
|
96
|
+
with zipfile.ZipFile(archive_path, "r") as zf:
|
|
97
|
+
zf.extractall(dest)
|
|
98
|
+
else:
|
|
99
|
+
# Raw file (e.g. .ll, .bc, .cpy) — copy directly
|
|
100
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
shutil.copy2(archive_path, dest / name)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _is_cached(name: str, version: str, checksum: str) -> bool:
|
|
105
|
+
"""Check if a package is already in cache with matching checksum."""
|
|
106
|
+
cache = _cache_path(name, version)
|
|
107
|
+
if not cache.exists():
|
|
108
|
+
return False
|
|
109
|
+
for f in cache.iterdir():
|
|
110
|
+
if f.is_file():
|
|
111
|
+
try:
|
|
112
|
+
actual = calculate_checksum(str(f))
|
|
113
|
+
if actual == checksum:
|
|
114
|
+
return True
|
|
115
|
+
except Exception:
|
|
116
|
+
continue
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _install_from_cache(project_root: Path, name: str, version: str) -> None:
|
|
121
|
+
"""Copy a cached archive into the project modules directory and extract."""
|
|
122
|
+
cache = _cache_path(name, version)
|
|
123
|
+
target = _module_path(project_root, name, version)
|
|
124
|
+
|
|
125
|
+
if target.exists():
|
|
126
|
+
shutil.rmtree(target)
|
|
127
|
+
|
|
128
|
+
for f in cache.iterdir():
|
|
129
|
+
if f.is_file():
|
|
130
|
+
_extract(f, target)
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def execute_get(inst: dict, project_root: Path, prebuilt: bool = False) -> None:
|
|
135
|
+
"""Execute a single GET instruction.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
inst:
|
|
140
|
+
Instruction dict with GET, url, version, checksum.
|
|
141
|
+
project_root:
|
|
142
|
+
Project root directory (where .cpm/modules/ lives).
|
|
143
|
+
prebuilt:
|
|
144
|
+
If True, fetch prebuilt .ll from registry.
|
|
145
|
+
"""
|
|
146
|
+
name = inst["GET"]
|
|
147
|
+
url = inst.get("url")
|
|
148
|
+
version = inst.get("version", "latest")
|
|
149
|
+
checksum = inst.get("checksum")
|
|
150
|
+
|
|
151
|
+
if not url:
|
|
152
|
+
raise ValueError(f"No URL provided for package '{name}'")
|
|
153
|
+
|
|
154
|
+
target = _module_path(project_root, name, version)
|
|
155
|
+
|
|
156
|
+
# Already installed — skip
|
|
157
|
+
if target.exists():
|
|
158
|
+
print(f" {name}@{version} already installed")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
cache = _cache_path(name, version)
|
|
162
|
+
|
|
163
|
+
# Check cache
|
|
164
|
+
if checksum and _is_cached(name, version, checksum):
|
|
165
|
+
print(f" {name}@{version} found in cache, installing...")
|
|
166
|
+
_install_from_cache(project_root, name, version)
|
|
167
|
+
print(f" {name}@{version} installed")
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
# Download
|
|
171
|
+
print(f" downloading {name}@{version}...")
|
|
172
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
filename = url.rsplit("/", 1)[-1] or f"{name}-{version}.tar.gz"
|
|
174
|
+
dest = cache / filename
|
|
175
|
+
_download(url, dest)
|
|
176
|
+
|
|
177
|
+
# Verify checksum
|
|
178
|
+
if checksum:
|
|
179
|
+
print(f" verifying checksum...")
|
|
180
|
+
_verify_checksum(str(dest), checksum)
|
|
181
|
+
|
|
182
|
+
# Extract to cache first
|
|
183
|
+
print(f" installing {name}@{version}...")
|
|
184
|
+
_extract(dest, cache)
|
|
185
|
+
|
|
186
|
+
# Install from cache to project
|
|
187
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
shutil.copytree(cache, target)
|
|
189
|
+
|
|
190
|
+
mode = "prebuilt" if prebuilt else "source"
|
|
191
|
+
print(f" {name}@{version} installed [{mode}]")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def execute_remove(inst: dict, project_root: Path) -> None:
|
|
195
|
+
"""Execute a single REMOVE instruction."""
|
|
196
|
+
name = inst["REMOVE"]
|
|
197
|
+
target = project_root / ".cpm" / "modules" / name
|
|
198
|
+
|
|
199
|
+
if not target.exists():
|
|
200
|
+
print(f" {name} not installed, skipping")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
shutil.rmtree(target)
|
|
204
|
+
print(f" {name} removed")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def execute(
|
|
208
|
+
instructions: list[dict],
|
|
209
|
+
project_root: Path,
|
|
210
|
+
repo: str = None,
|
|
211
|
+
ver: str = "latest",
|
|
212
|
+
prebuilt: bool = False,
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Execute a flat instruction stream.
|
|
215
|
+
|
|
216
|
+
Pipeline stage: Execute
|
|
217
|
+
This is the final stage — instructions are consumed and filesystem
|
|
218
|
+
operations are performed.
|
|
219
|
+
|
|
220
|
+
Parameters
|
|
221
|
+
----------
|
|
222
|
+
instructions:
|
|
223
|
+
Flat list of dicts from deduplicator(), e.g.
|
|
224
|
+
[{"GET": "D", "url": "...", ...}, {"GET": "B", ...}]
|
|
225
|
+
project_root:
|
|
226
|
+
Project root directory (where .cpm/modules/ lives).
|
|
227
|
+
repo:
|
|
228
|
+
Repository URL. If provided, missing metadata (url, checksum)
|
|
229
|
+
will be fetched during execution.
|
|
230
|
+
ver:
|
|
231
|
+
Package version to resolve against.
|
|
232
|
+
prebuilt:
|
|
233
|
+
If True, registry serves prebuilt artifacts (.ll).
|
|
234
|
+
"""
|
|
235
|
+
_ensure_cache_dir()
|
|
236
|
+
|
|
237
|
+
total = len(instructions)
|
|
238
|
+
mode = "prebuilt" if prebuilt else "source"
|
|
239
|
+
print(f"\nExecuting {total} instruction(s) [{mode}]...\n")
|
|
240
|
+
|
|
241
|
+
for i, inst in enumerate(instructions, 1):
|
|
242
|
+
# If metadata is missing from instruction, fetch it
|
|
243
|
+
if "GET" in inst and "url" not in inst:
|
|
244
|
+
name = inst["GET"]
|
|
245
|
+
if repo is None:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"Instruction for '{name}' has no URL and no repo provided"
|
|
248
|
+
)
|
|
249
|
+
metadata = fetch_repo(repo, f"metadata/{name}/{ver}")
|
|
250
|
+
inst["url"] = metadata["url"]
|
|
251
|
+
inst["checksum"] = metadata.get("checksum", "")
|
|
252
|
+
inst["version"] = metadata.get("version", ver)
|
|
253
|
+
|
|
254
|
+
if "GET" in inst:
|
|
255
|
+
print(f"[{i}/{total}] GET {inst['GET']}")
|
|
256
|
+
execute_get(inst, project_root, prebuilt=prebuilt)
|
|
257
|
+
elif "REMOVE" in inst:
|
|
258
|
+
print(f"[{i}/{total}] REMOVE {inst['REMOVE']}")
|
|
259
|
+
execute_remove(inst, project_root)
|
|
260
|
+
else:
|
|
261
|
+
print(f"[{i}/{total}] SKIP unknown instruction: {inst}")
|
|
262
|
+
|
|
263
|
+
print(f"\nDone. {total} instruction(s) executed.")
|
cpyte/cli/gethins.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import requests as rq
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def fetch_repo(url: str, back: str):
|
|
5
|
+
"""Fetch metadata from a single repo."""
|
|
6
|
+
cleaned = url.strip("/")
|
|
7
|
+
based = "/".join([cleaned, back])
|
|
8
|
+
repo = rq.get(based)
|
|
9
|
+
repo.raise_for_status()
|
|
10
|
+
return repo.json()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fetch_repo_multi(repos: list[str], back: str):
|
|
14
|
+
"""Fetch metadata trying repos in priority order.
|
|
15
|
+
|
|
16
|
+
Tries each repo in order. Returns the first successful result.
|
|
17
|
+
Raises the last error if all repos fail.
|
|
18
|
+
"""
|
|
19
|
+
last_error = None
|
|
20
|
+
for url in repos:
|
|
21
|
+
try:
|
|
22
|
+
return fetch_repo(url, back)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
last_error = e
|
|
25
|
+
continue
|
|
26
|
+
if last_error:
|
|
27
|
+
raise last_error
|
|
28
|
+
raise RuntimeError("no repositories configured")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fetch_group(repos: list[str], group: str) -> list[str]:
|
|
32
|
+
"""Fetch package list for a group (e.g., @std -> ["@std/json", ...])."""
|
|
33
|
+
# Strip the @ for the metadata path
|
|
34
|
+
group_id = group.lstrip("@")
|
|
35
|
+
data = fetch_repo_multi(repos, f"metadata/@{group_id}/latest")
|
|
36
|
+
return data.get("packages", [])
|
cpyte/cli/lockfile.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Lockfile management for CPM.
|
|
2
|
+
|
|
3
|
+
Handles reading and writing cpm.lock, which stores exact resolved
|
|
4
|
+
versions for reproducible builds.
|
|
5
|
+
|
|
6
|
+
Lockfile format:
|
|
7
|
+
[[package]]
|
|
8
|
+
name = "@std/json"
|
|
9
|
+
version = "2.0.3"
|
|
10
|
+
resolved = "https://repo.example.com/group/std/json/2.0.3.tar.gz"
|
|
11
|
+
checksum = "sha256:abc123..."
|
|
12
|
+
dependencies = ["@std/encoding@1.2.0"]
|
|
13
|
+
llvm_version = "18.1.0"
|
|
14
|
+
cpyte_version = "0.5.0"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LOCKFILE_NAME = "cpm.lock"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LockEntry:
|
|
29
|
+
"""A single locked package."""
|
|
30
|
+
name: str
|
|
31
|
+
version: str
|
|
32
|
+
resolved: str = ""
|
|
33
|
+
checksum: str = ""
|
|
34
|
+
dependencies: list[str] = field(default_factory=list)
|
|
35
|
+
llvm_version: str = ""
|
|
36
|
+
cpyte_version: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Lockfile:
|
|
41
|
+
"""Represents a cpm.lock file."""
|
|
42
|
+
entries: list[LockEntry] = field(default_factory=list)
|
|
43
|
+
path: Optional[Path] = None
|
|
44
|
+
|
|
45
|
+
def get(self, name: str) -> Optional[LockEntry]:
|
|
46
|
+
"""Get a locked entry by package name."""
|
|
47
|
+
for entry in self.entries:
|
|
48
|
+
if entry.name == name:
|
|
49
|
+
return entry
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def add(self, entry: LockEntry) -> None:
|
|
53
|
+
"""Add or update a locked entry."""
|
|
54
|
+
for i, existing in enumerate(self.entries):
|
|
55
|
+
if existing.name == entry.name:
|
|
56
|
+
self.entries[i] = entry
|
|
57
|
+
return
|
|
58
|
+
self.entries.append(entry)
|
|
59
|
+
|
|
60
|
+
def remove(self, name: str) -> bool:
|
|
61
|
+
"""Remove a locked entry by name. Returns True if removed."""
|
|
62
|
+
for i, entry in enumerate(self.entries):
|
|
63
|
+
if entry.name == name:
|
|
64
|
+
self.entries.pop(i)
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def toml_str(self) -> str:
|
|
69
|
+
"""Serialize lockfile to TOML string."""
|
|
70
|
+
if not self.entries:
|
|
71
|
+
return "# cpm.lock — auto-generated, do not edit manually\n"
|
|
72
|
+
|
|
73
|
+
lines = ["# cpm.lock — auto-generated, do not edit manually", ""]
|
|
74
|
+
for entry in self.entries:
|
|
75
|
+
lines.append("[[package]]")
|
|
76
|
+
lines.append(f'name = "{entry.name}"')
|
|
77
|
+
lines.append(f'version = "{entry.version}"')
|
|
78
|
+
if entry.resolved:
|
|
79
|
+
lines.append(f'resolved = "{entry.resolved}"')
|
|
80
|
+
if entry.checksum:
|
|
81
|
+
lines.append(f'checksum = "{entry.checksum}"')
|
|
82
|
+
if entry.dependencies:
|
|
83
|
+
deps_str = ", ".join(f'"{d}"' for d in entry.dependencies)
|
|
84
|
+
lines.append(f"dependencies = [{deps_str}]")
|
|
85
|
+
if entry.llvm_version:
|
|
86
|
+
lines.append(f'llvm_version = "{entry.llvm_version}"')
|
|
87
|
+
if entry.cpyte_version:
|
|
88
|
+
lines.append(f'cpyte_version = "{entry.cpyte_version}"')
|
|
89
|
+
lines.append("")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def find_lockfile(start: Optional[Path] = None) -> Optional[Path]:
|
|
94
|
+
"""Walk up from start directory to find cpm.lock."""
|
|
95
|
+
if start is None:
|
|
96
|
+
start = Path.cwd()
|
|
97
|
+
|
|
98
|
+
current = start.resolve()
|
|
99
|
+
while True:
|
|
100
|
+
candidate = current / LOCKFILE_NAME
|
|
101
|
+
if candidate.exists():
|
|
102
|
+
return candidate
|
|
103
|
+
parent = current.parent
|
|
104
|
+
if parent == current:
|
|
105
|
+
return None
|
|
106
|
+
current = parent
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_lockfile(path: Optional[Path] = None) -> Lockfile:
|
|
110
|
+
"""Read a cpm.lock file.
|
|
111
|
+
|
|
112
|
+
If path is None, searches upward from cwd.
|
|
113
|
+
Returns an empty lockfile if not found.
|
|
114
|
+
"""
|
|
115
|
+
if path is None:
|
|
116
|
+
path = find_lockfile()
|
|
117
|
+
|
|
118
|
+
lockfile = Lockfile(path=path)
|
|
119
|
+
|
|
120
|
+
if path is None or not path.exists():
|
|
121
|
+
return lockfile
|
|
122
|
+
|
|
123
|
+
content = path.read_text()
|
|
124
|
+
_parse_lockfile(content, lockfile)
|
|
125
|
+
return lockfile
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_lockfile(lockfile: Lockfile) -> Path:
|
|
129
|
+
"""Write lockfile to its path. Creates cpm.lock in cwd if no path set."""
|
|
130
|
+
if lockfile.path is None:
|
|
131
|
+
lockfile.path = Path.cwd() / LOCKFILE_NAME
|
|
132
|
+
|
|
133
|
+
lockfile.path.write_text(lockfile.toml_str())
|
|
134
|
+
return lockfile.path
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _parse_lockfile(content: str, lockfile: Lockfile) -> None:
|
|
138
|
+
"""Minimal TOML parser for [[package]] sections."""
|
|
139
|
+
current_entry: Optional[LockEntry] = None
|
|
140
|
+
|
|
141
|
+
for line in content.splitlines():
|
|
142
|
+
stripped = line.strip()
|
|
143
|
+
|
|
144
|
+
# Skip empty lines and comments
|
|
145
|
+
if not stripped or stripped.startswith("#"):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Section header
|
|
149
|
+
if stripped == "[[package]]":
|
|
150
|
+
current_entry = LockEntry(name="", version="")
|
|
151
|
+
lockfile.entries.append(current_entry)
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
if current_entry is None:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# key = value
|
|
158
|
+
if "=" in stripped:
|
|
159
|
+
key, _, value = stripped.partition("=")
|
|
160
|
+
key = key.strip()
|
|
161
|
+
value = value.strip()
|
|
162
|
+
|
|
163
|
+
if key == "name":
|
|
164
|
+
current_entry.name = value.strip('"')
|
|
165
|
+
elif key == "version":
|
|
166
|
+
current_entry.version = value.strip('"')
|
|
167
|
+
elif key == "resolved":
|
|
168
|
+
current_entry.resolved = value.strip('"')
|
|
169
|
+
elif key == "checksum":
|
|
170
|
+
current_entry.checksum = value.strip('"')
|
|
171
|
+
elif key == "llvm_version":
|
|
172
|
+
current_entry.llvm_version = value.strip('"')
|
|
173
|
+
elif key == "cpyte_version":
|
|
174
|
+
current_entry.cpyte_version = value.strip('"')
|
|
175
|
+
elif key == "dependencies":
|
|
176
|
+
# Parse array: ["dep1", "dep2"]
|
|
177
|
+
inner = value.strip("[]")
|
|
178
|
+
if inner:
|
|
179
|
+
current_entry.dependencies = [
|
|
180
|
+
d.strip().strip('"')
|
|
181
|
+
for d in inner.split(",")
|
|
182
|
+
if d.strip()
|
|
183
|
+
]
|