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 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
+ ]