anolis-workbench 0.1.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.
Files changed (33) hide show
  1. anolis_workbench/__init__.py +1 -0
  2. anolis_workbench/catalog/providers.json +96 -0
  3. anolis_workbench/cli/__init__.py +1 -0
  4. anolis_workbench/cli/main.py +13 -0
  5. anolis_workbench/cli/package_cli.py +46 -0
  6. anolis_workbench/cli/validate_cli.py +44 -0
  7. anolis_workbench/core/__init__.py +1 -0
  8. anolis_workbench/core/exporter.py +393 -0
  9. anolis_workbench/core/launcher.py +732 -0
  10. anolis_workbench/core/package_validator.py +396 -0
  11. anolis_workbench/core/paths.py +72 -0
  12. anolis_workbench/core/projects.py +271 -0
  13. anolis_workbench/core/renderer.py +278 -0
  14. anolis_workbench/core/validator.py +143 -0
  15. anolis_workbench/frontend/css/style.css +1167 -0
  16. anolis_workbench/frontend/index.html +252 -0
  17. anolis_workbench/schema/system.schema.json +136 -0
  18. anolis_workbench/schemas/machine-profile.schema.json +182 -0
  19. anolis_workbench/schemas/runtime-config.schema.json +732 -0
  20. anolis_workbench/server/__init__.py +1 -0
  21. anolis_workbench/server/app.py +256 -0
  22. anolis_workbench/server/routes/__init__.py +1 -0
  23. anolis_workbench/server/routes/commission.py +136 -0
  24. anolis_workbench/server/routes/compose.py +177 -0
  25. anolis_workbench/server/routes/operate.py +110 -0
  26. anolis_workbench/templates/bioreactor-manual/system.json +101 -0
  27. anolis_workbench/templates/mixed-bus-mock/system.json +93 -0
  28. anolis_workbench/templates/sim-quickstart/system.json +58 -0
  29. anolis_workbench-0.1.0.dist-info/METADATA +85 -0
  30. anolis_workbench-0.1.0.dist-info/RECORD +33 -0
  31. anolis_workbench-0.1.0.dist-info/WHEEL +4 -0
  32. anolis_workbench-0.1.0.dist-info/entry_points.txt +4 -0
  33. anolis_workbench-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1 @@
1
+ """Anolis Workbench package."""
@@ -0,0 +1,96 @@
1
+ {
2
+ "schema_version": 1,
3
+ "providers": [
4
+ {
5
+ "kind": "sim",
6
+ "display_name": "Provider-Sim",
7
+ "description": "Simulated devices for development and testing",
8
+ "repo": "anolis-provider-sim",
9
+ "build_docs": "docs/",
10
+ "check_config_flag": "--check-config",
11
+ "topology_fields": [
12
+ { "key": "provider_name", "type": "string", "required": false, "default": "sim0" },
13
+ { "key": "startup_policy", "type": "enum", "values": ["strict", "degraded"], "default": "degraded" },
14
+ { "key": "simulation_mode", "type": "enum", "values": ["inert", "non_interacting"], "default": "non_interacting" },
15
+ { "key": "tick_rate_hz", "type": "float", "min": 1, "max": 100, "default": 10.0 }
16
+ ],
17
+ "device_types": [
18
+ { "type": "tempctl", "display": "Temperature Controller", "fields": [
19
+ { "key": "initial_temp", "type": "float", "default": 25.0 }
20
+ ]},
21
+ { "type": "motorctl", "display": "Motor Controller", "fields": [
22
+ { "key": "max_speed", "type": "float", "default": 3000.0 }
23
+ ]},
24
+ { "type": "relayio", "display": "Relay I/O", "fields": [] },
25
+ { "type": "analogsensor","display": "Analog Sensor", "fields": [] }
26
+ ],
27
+ "readonly_devices": [
28
+ {
29
+ "type": "chaos_control",
30
+ "display": "Fault Injection (chaos_control)",
31
+ "tooltip": "Fault injection device — always included by the provider, not configurable here."
32
+ }
33
+ ],
34
+ "path_fields": [
35
+ { "key": "executable", "type": "path", "required": true }
36
+ ],
37
+ "composer_notes": {
38
+ "sim_mode": "not_supported_v1",
39
+ "sim_mode_rationale": "The 'sim' simulation_mode requires an external physics engine (physics_config_path, ambient_temp_c, ambient_signal_path). These are not part of the v1 composer form. Only 'non_interacting' and 'inert' modes are exposed in v1."
40
+ }
41
+ },
42
+ {
43
+ "kind": "bread",
44
+ "display_name": "Provider-Bread",
45
+ "description": "BREAD-over-CRUMBS hardware devices (RLHT, DCMT)",
46
+ "repo": "anolis-provider-bread",
47
+ "build_docs": "docs/build.md",
48
+ "check_config_flag": "--check-config",
49
+ "topology_fields": [
50
+ { "key": "provider_name", "type": "string", "required": false, "default": "bread0" },
51
+ { "key": "require_live_session","type": "bool", "default": false },
52
+ { "key": "query_delay_us", "type": "int", "default": 10000 },
53
+ { "key": "timeout_ms", "type": "int", "default": 100 },
54
+ { "key": "retry_count", "type": "int", "default": 2 }
55
+ ],
56
+ "device_types": [
57
+ { "type": "rlht", "display": "RLHT Heater", "fields": [] },
58
+ { "type": "dcmt", "display": "DCMT Motor", "fields": [] }
59
+ ],
60
+ "path_fields": [
61
+ { "key": "executable", "type": "path", "required": true },
62
+ { "key": "bus_path", "type": "string", "required": true, "placeholder": "/dev/i2c-1 or mock://name" }
63
+ ],
64
+ "composer_notes": {
65
+ "scan_mode": "not_supported_v1",
66
+ "scan_mode_rationale": "Scan discovery output can vary across runs depending on bus state at startup. The composer requires a stable, committed device list at save-time. Manual address entry is used in v1. A future version may add a scan-then-import flow once scan output is proven stable."
67
+ }
68
+ },
69
+ {
70
+ "kind": "ezo",
71
+ "display_name": "Provider-EZO",
72
+ "description": "Atlas Scientific EZO I2C sensors (pH, DO, EC, ORP, RTD, HUM)",
73
+ "repo": "anolis-provider-ezo",
74
+ "build_docs": "docs/",
75
+ "check_config_flag": "--check-config",
76
+ "topology_fields": [
77
+ { "key": "provider_name", "type": "string", "required": false, "default": "ezo0" },
78
+ { "key": "query_delay_us", "type": "int", "default": 300000 },
79
+ { "key": "timeout_ms", "type": "int", "default": 300 },
80
+ { "key": "retry_count", "type": "int", "default": 2 }
81
+ ],
82
+ "device_types": [
83
+ { "type": "ph", "display": "pH Sensor", "fields": [] },
84
+ { "type": "do", "display": "Dissolved Oxygen", "fields": [] },
85
+ { "type": "ec", "display": "Conductivity", "fields": [] },
86
+ { "type": "orp", "display": "ORP", "fields": [] },
87
+ { "type": "rtd", "display": "Temperature (RTD)", "fields": [] },
88
+ { "type": "hum", "display": "Humidity", "fields": [] }
89
+ ],
90
+ "path_fields": [
91
+ { "key": "executable", "type": "path", "required": true },
92
+ { "key": "bus_path", "type": "string", "required": true, "placeholder": "/dev/i2c-1 or mock://name" }
93
+ ]
94
+ }
95
+ ]
96
+ }
@@ -0,0 +1 @@
1
+ """CLI entry points for Anolis Workbench."""
@@ -0,0 +1,13 @@
1
+ """CLI entry point for the Anolis Workbench HTTP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from anolis_workbench.server.app import main as run_server
6
+
7
+
8
+ def main() -> None:
9
+ run_server()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
@@ -0,0 +1,46 @@
1
+ """CLI to build deterministic commissioning handoff packages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import pathlib
7
+ import sys
8
+
9
+ from anolis_workbench.core import exporter
10
+ from anolis_workbench.core import paths as paths_module
11
+
12
+
13
+ def _parse_args() -> argparse.Namespace:
14
+ parser = argparse.ArgumentParser(description="Build an Anolis handoff package (.anpkg) from a project.")
15
+ parser.add_argument("project_name", help="Project name under the configured systems root.")
16
+ parser.add_argument(
17
+ "output",
18
+ nargs="?",
19
+ help="Output file path (default: ./<project_name>.anpkg).",
20
+ )
21
+ return parser.parse_args()
22
+
23
+
24
+ def main() -> int:
25
+ args = _parse_args()
26
+ project_name = str(args.project_name).strip()
27
+ if project_name == "":
28
+ print("ERROR: project_name is required", file=sys.stderr)
29
+ return 2
30
+
31
+ project_dir = paths_module.SYSTEMS_ROOT / project_name
32
+ output = pathlib.Path(args.output).expanduser() if args.output else pathlib.Path(f"{project_name}.anpkg")
33
+ out_path = output.resolve()
34
+
35
+ try:
36
+ exporter.build_package(project_dir=project_dir, out_path=out_path)
37
+ except exporter.ExportError as exc:
38
+ print(f"ERROR: {exc}", file=sys.stderr)
39
+ return 1
40
+
41
+ print(out_path)
42
+ return 0
43
+
44
+
45
+ if __name__ == "__main__":
46
+ raise SystemExit(main())
@@ -0,0 +1,44 @@
1
+ """CLI to validate Anolis handoff package archives/directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import pathlib
7
+ import sys
8
+
9
+ from anolis_workbench.core import package_validator
10
+
11
+
12
+ def _parse_args() -> argparse.Namespace:
13
+ parser = argparse.ArgumentParser(
14
+ description="Validate an Anolis handoff package archive or extracted directory.",
15
+ )
16
+ parser.add_argument(
17
+ "package_path",
18
+ help="Path to .anpkg/.zip archive or extracted package directory.",
19
+ )
20
+ parser.add_argument(
21
+ "--runtime-bin",
22
+ default=None,
23
+ help="Optional runtime binary path for --check-config replay validation.",
24
+ )
25
+ return parser.parse_args()
26
+
27
+
28
+ def main() -> int:
29
+ args = _parse_args()
30
+ package_path = pathlib.Path(args.package_path).expanduser().resolve()
31
+ runtime_bin = pathlib.Path(args.runtime_bin).expanduser().resolve() if args.runtime_bin else None
32
+
33
+ try:
34
+ package_validator.validate_package(package_path, runtime_bin=runtime_bin)
35
+ except package_validator.PackageValidationError as exc:
36
+ print(f"Validation failed: {exc}", file=sys.stderr)
37
+ return 1
38
+
39
+ print(f"Package validation passed: {package_path}")
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__":
44
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Core domain library for Anolis Workbench."""
@@ -0,0 +1,393 @@
1
+ """Deterministic commissioning handoff package exporter.
2
+
3
+ Core export logic with no HTTP/UI dependency. Thin wrappers in server.py and
4
+ tools/package.py should call build_package().
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import pathlib
12
+ import re
13
+ import zipfile
14
+ from datetime import datetime, timezone
15
+ from importlib import resources
16
+ from typing import Any
17
+
18
+ import jsonschema
19
+ import yaml
20
+
21
+ from anolis_workbench.core import paths as paths_module
22
+ from anolis_workbench.core import renderer as renderer_module
23
+
24
+
25
+ class ExportError(RuntimeError):
26
+ """Raised when export cannot produce a valid package."""
27
+
28
+
29
+ _ZIP_EPOCH = (1980, 1, 1, 0, 0, 0)
30
+ _MACHINE_ID_RE = re.compile(r"[^a-z0-9-]+")
31
+ _MACHINE_PROFILE_SCHEMA_CACHE: "dict[str, Any] | None" = None
32
+
33
+
34
+ def build_package(project_dir: pathlib.Path, out_path: pathlib.Path) -> None:
35
+ """Build a deterministic .anpkg zip archive from a commissioned project."""
36
+ project_root = project_dir.resolve()
37
+ system_path = project_root / "system.json"
38
+ if not system_path.is_file():
39
+ raise ExportError(f"Project file not found: {system_path}")
40
+
41
+ system = _load_system_json(system_path)
42
+ project_name = project_root.name
43
+ rendered = renderer_module.render(system, project_name)
44
+ runtime_yaml = rendered.get("anolis-runtime.yaml")
45
+ if not isinstance(runtime_yaml, str) or runtime_yaml.strip() == "":
46
+ raise ExportError("Renderer did not produce anolis-runtime.yaml")
47
+
48
+ runtime_payload = yaml.safe_load(runtime_yaml) or {}
49
+ if not isinstance(runtime_payload, dict):
50
+ raise ExportError("Rendered runtime YAML root must be a mapping/object")
51
+
52
+ provider_ids = _rewrite_provider_args(runtime_payload)
53
+ provider_files = _collect_provider_files(rendered, project_root, provider_ids)
54
+ behavior_rel_paths = _rewrite_and_collect_behaviors(
55
+ runtime_payload=runtime_payload,
56
+ project_dir=project_root,
57
+ )
58
+
59
+ redaction_applied = _redact_runtime_secrets(runtime_payload)
60
+ runtime_text = yaml.safe_dump(runtime_payload, sort_keys=False)
61
+
62
+ machine_profile = _build_machine_profile(
63
+ system=system,
64
+ project_name=project_name,
65
+ provider_ids=provider_ids,
66
+ behavior_rel_paths=behavior_rel_paths,
67
+ )
68
+ _validate_machine_profile(machine_profile)
69
+ machine_profile_text = yaml.safe_dump(machine_profile, sort_keys=False)
70
+
71
+ exported_at = _deterministic_exported_at(system)
72
+ provenance = {
73
+ "exported_at": exported_at,
74
+ "schema_versions": {
75
+ "machine-profile": 1,
76
+ "runtime-config": 1,
77
+ },
78
+ "package_format_version": 1,
79
+ "source_project": project_name,
80
+ "redaction_policy": {
81
+ "telemetry_influxdb_token_removed": redaction_applied,
82
+ "deploy_time_env_var": "INFLUXDB_TOKEN",
83
+ },
84
+ }
85
+ provenance_text = json.dumps(provenance, indent=2, sort_keys=True) + "\n"
86
+
87
+ files: dict[str, bytes] = {
88
+ "machine-profile.yaml": machine_profile_text.encode("utf-8"),
89
+ "runtime/anolis-runtime.yaml": runtime_text.encode("utf-8"),
90
+ "meta/provenance.json": provenance_text.encode("utf-8"),
91
+ }
92
+
93
+ for rel_path, content in provider_files.items():
94
+ files[rel_path] = content
95
+
96
+ for rel_path, source_path in behavior_rel_paths.items():
97
+ files[rel_path] = source_path.read_bytes()
98
+
99
+ _assert_no_secret_leak(files)
100
+ files["meta/checksums.sha256"] = _checksums_file_bytes(files)
101
+ _write_zip_deterministic(files=files, out_path=out_path.resolve())
102
+
103
+
104
+ def _load_system_json(system_path: pathlib.Path) -> dict[str, Any]:
105
+ try:
106
+ payload = json.loads(system_path.read_text(encoding="utf-8"))
107
+ except (json.JSONDecodeError, OSError) as exc:
108
+ raise ExportError(f"Failed reading {system_path}: {exc}") from exc
109
+ if not isinstance(payload, dict):
110
+ raise ExportError("system.json root must be an object")
111
+ return payload
112
+
113
+
114
+ def _rewrite_provider_args(runtime_payload: dict[str, Any]) -> list[str]:
115
+ providers = runtime_payload.get("providers")
116
+ if not isinstance(providers, list) or not providers:
117
+ raise ExportError("runtime.providers must be a non-empty list")
118
+
119
+ provider_ids: list[str] = []
120
+ seen: set[str] = set()
121
+ for entry in providers:
122
+ if not isinstance(entry, dict):
123
+ raise ExportError("runtime.providers entries must be objects")
124
+ provider_id = entry.get("id")
125
+ if not isinstance(provider_id, str) or provider_id.strip() == "":
126
+ raise ExportError("runtime.providers[].id must be a non-empty string")
127
+ provider_id = provider_id.strip()
128
+ if provider_id in seen:
129
+ raise ExportError(f"Duplicate provider id in runtime.providers: {provider_id}")
130
+ seen.add(provider_id)
131
+ provider_ids.append(provider_id)
132
+
133
+ expected_cfg = f"providers/{provider_id}.yaml"
134
+ raw_args = entry.get("args")
135
+ args = [str(item) for item in raw_args] if isinstance(raw_args, list) else []
136
+ replaced = False
137
+ for idx, token in enumerate(args[:-1]):
138
+ if token == "--config":
139
+ args[idx + 1] = expected_cfg
140
+ replaced = True
141
+ break
142
+ if not replaced:
143
+ args.extend(["--config", expected_cfg])
144
+ entry["args"] = args
145
+
146
+ return provider_ids
147
+
148
+
149
+ def _collect_provider_files(
150
+ rendered: dict[str, str],
151
+ project_dir: pathlib.Path,
152
+ provider_ids: list[str],
153
+ ) -> dict[str, bytes]:
154
+ files: dict[str, bytes] = {}
155
+ for provider_id in provider_ids:
156
+ rel = f"providers/{provider_id}.yaml"
157
+ rendered_text = rendered.get(rel)
158
+ if isinstance(rendered_text, str) and rendered_text.strip() != "":
159
+ files[rel] = rendered_text.encode("utf-8")
160
+ continue
161
+
162
+ fallback = project_dir / rel
163
+ if fallback.is_file():
164
+ files[rel] = fallback.read_bytes()
165
+ continue
166
+
167
+ raise ExportError(f"Provider config not found for {provider_id}: expected '{rel}'")
168
+ return files
169
+
170
+
171
+ def _rewrite_and_collect_behaviors(
172
+ *,
173
+ runtime_payload: dict[str, Any],
174
+ project_dir: pathlib.Path,
175
+ ) -> dict[str, pathlib.Path]:
176
+ behavior_files: dict[str, pathlib.Path] = {}
177
+ automation = runtime_payload.get("automation")
178
+ if not isinstance(automation, dict):
179
+ return behavior_files
180
+
181
+ behavior_ref = automation.get("behavior_tree") or automation.get("behavior_tree_path")
182
+ if not isinstance(behavior_ref, str) or behavior_ref.strip() == "":
183
+ return behavior_files
184
+
185
+ source = _resolve_behavior_path(behavior_ref.strip(), project_dir)
186
+ rel = f"runtime/behaviors/{source.name}"
187
+ automation["behavior_tree"] = rel
188
+ automation.pop("behavior_tree_path", None)
189
+ behavior_files[rel] = source
190
+ return behavior_files
191
+
192
+
193
+ def _resolve_behavior_path(raw: str, project_dir: pathlib.Path) -> pathlib.Path:
194
+ raw_path = pathlib.Path(raw)
195
+ if raw_path.is_absolute():
196
+ raise ExportError("automation.behavior_tree must be a relative path")
197
+
198
+ data_root = paths_module.DATA_ROOT.resolve()
199
+ candidates = [
200
+ (project_dir / raw_path).resolve(),
201
+ (data_root / raw_path).resolve(),
202
+ ]
203
+ for candidate in candidates:
204
+ if candidate.is_file() and (_is_within(candidate, project_dir) or _is_within(candidate, data_root)):
205
+ return candidate
206
+ raise ExportError(f"Behavior tree file not found for automation.behavior_tree='{raw}'")
207
+
208
+
209
+ def _is_within(path: pathlib.Path, root: pathlib.Path) -> bool:
210
+ try:
211
+ path.resolve().relative_to(root.resolve())
212
+ except ValueError:
213
+ return False
214
+ return True
215
+
216
+
217
+ def _redact_runtime_secrets(runtime_payload: dict[str, Any]) -> bool:
218
+ redacted = False
219
+ telemetry = runtime_payload.get("telemetry")
220
+ if not isinstance(telemetry, dict):
221
+ return redacted
222
+
223
+ if "influx_token" in telemetry:
224
+ telemetry.pop("influx_token", None)
225
+ redacted = True
226
+
227
+ influxdb = telemetry.get("influxdb")
228
+ if isinstance(influxdb, dict) and "token" in influxdb:
229
+ influxdb.pop("token", None)
230
+ redacted = True
231
+
232
+ return redacted
233
+
234
+
235
+ def _build_machine_profile(
236
+ *,
237
+ system: dict[str, Any],
238
+ project_name: str,
239
+ provider_ids: list[str],
240
+ behavior_rel_paths: dict[str, pathlib.Path],
241
+ ) -> dict[str, Any]:
242
+ meta = system.get("meta")
243
+ display_name = project_name
244
+ if isinstance(meta, dict):
245
+ raw = meta.get("name")
246
+ if isinstance(raw, str) and raw.strip() != "":
247
+ display_name = raw.strip()
248
+
249
+ machine_id = _machine_id_from_name(project_name)
250
+ providers = {provider_id: {"config": f"providers/{provider_id}.yaml"} for provider_id in provider_ids}
251
+ compatibility_providers = {
252
+ provider_id: {"strategy": "local-build", "version": "unspecified"} for provider_id in provider_ids
253
+ }
254
+
255
+ payload: dict[str, Any] = {
256
+ "schema_version": 1,
257
+ "machine_id": machine_id,
258
+ "display_name": display_name,
259
+ "runtime_profiles": {
260
+ "manual": "runtime/anolis-runtime.yaml",
261
+ },
262
+ "providers": providers,
263
+ "validation": {
264
+ "expected_providers": provider_ids,
265
+ },
266
+ "contracts": {
267
+ "runtime_config_baseline": "docs/contracts/runtime-config-baseline.md",
268
+ "runtime_http_baseline": "docs/contracts/runtime-http-baseline.md",
269
+ },
270
+ "compatibility": {
271
+ "runtime": {
272
+ "config_contract": "01-runtime-config",
273
+ "http_contract": "02-runtime-http",
274
+ },
275
+ "providers": compatibility_providers,
276
+ },
277
+ }
278
+ if behavior_rel_paths:
279
+ payload["behaviors"] = sorted(behavior_rel_paths.keys())
280
+ return payload
281
+
282
+
283
+ def _validate_machine_profile(profile: dict[str, Any]) -> None:
284
+ global _MACHINE_PROFILE_SCHEMA_CACHE
285
+ if _MACHINE_PROFILE_SCHEMA_CACHE is None:
286
+ schema_file = resources.files("anolis_workbench").joinpath("schemas").joinpath("machine-profile.schema.json")
287
+ try:
288
+ payload = json.loads(schema_file.read_text(encoding="utf-8"))
289
+ except FileNotFoundError as exc:
290
+ raise ExportError("Bundled machine-profile schema not found in package data") from exc
291
+ if not isinstance(payload, dict):
292
+ raise ExportError("Bundled machine-profile schema root must be an object")
293
+ _MACHINE_PROFILE_SCHEMA_CACHE = payload
294
+ validator = jsonschema.Draft7Validator(_MACHINE_PROFILE_SCHEMA_CACHE)
295
+ errors = sorted(validator.iter_errors(profile), key=lambda e: list(e.path))
296
+ if errors:
297
+ msgs = "; ".join(f"{'.' + '.'.join(str(p) for p in e.path) if e.path else '$'}: {e.message}" for e in errors)
298
+ raise ExportError(f"Generated machine-profile failed schema validation: {msgs}")
299
+
300
+
301
+ def _machine_id_from_name(name: str) -> str:
302
+ lowered = name.strip().lower().replace("_", "-")
303
+ cleaned = _MACHINE_ID_RE.sub("-", lowered)
304
+ cleaned = re.sub(r"-{2,}", "-", cleaned).strip("-")
305
+ if cleaned == "":
306
+ cleaned = "machine"
307
+ if not cleaned[0].isalnum():
308
+ cleaned = f"m-{cleaned}"
309
+ return cleaned
310
+
311
+
312
+ def _deterministic_exported_at(system: dict[str, Any]) -> str:
313
+ meta = system.get("meta")
314
+ if isinstance(meta, dict):
315
+ created = meta.get("created")
316
+ if isinstance(created, str):
317
+ parsed = _normalize_iso_timestamp(created)
318
+ if parsed is not None:
319
+ return parsed
320
+ return "1970-01-01T00:00:00Z"
321
+
322
+
323
+ def _normalize_iso_timestamp(raw: str) -> str | None:
324
+ text = raw.strip()
325
+ if text == "":
326
+ return None
327
+ if text.endswith("Z"):
328
+ text = text[:-1] + "+00:00"
329
+ try:
330
+ parsed = datetime.fromisoformat(text)
331
+ except ValueError:
332
+ return None
333
+ if parsed.tzinfo is None:
334
+ parsed = parsed.replace(tzinfo=timezone.utc)
335
+ else:
336
+ parsed = parsed.astimezone(timezone.utc)
337
+ return parsed.replace(microsecond=0).isoformat().replace("+00:00", "Z")
338
+
339
+
340
+ def _assert_no_secret_leak(files: dict[str, bytes]) -> None:
341
+ for rel_path, content in sorted(files.items()):
342
+ if rel_path.endswith(".xml"):
343
+ continue
344
+
345
+ text = content.decode("utf-8")
346
+ if rel_path.endswith(".yaml") or rel_path.endswith(".yml"):
347
+ payload = yaml.safe_load(text)
348
+ elif rel_path.endswith(".json"):
349
+ payload = json.loads(text)
350
+ else:
351
+ continue
352
+
353
+ for key_path, value in _iter_key_values(payload):
354
+ if "token" in key_path.split(".")[-1].lower():
355
+ if isinstance(value, str) and value.strip() != "":
356
+ raise ExportError(f"Secret-like token value leaked in package file '{rel_path}' at {key_path}")
357
+
358
+
359
+ def _iter_key_values(payload: Any, prefix: str = "$"):
360
+ if isinstance(payload, dict):
361
+ for key, value in payload.items():
362
+ child = f"{prefix}.{key}" if prefix else str(key)
363
+ yield from _iter_key_values(value, child)
364
+ return
365
+ if isinstance(payload, list):
366
+ for idx, value in enumerate(payload):
367
+ child = f"{prefix}[{idx}]"
368
+ yield from _iter_key_values(value, child)
369
+ return
370
+ yield prefix, payload
371
+
372
+
373
+ def _checksums_file_bytes(files: dict[str, bytes]) -> bytes:
374
+ lines: list[str] = []
375
+ for rel_path in sorted(path for path in files if path != "meta/checksums.sha256"):
376
+ digest = hashlib.sha256(files[rel_path]).hexdigest()
377
+ lines.append(f"{digest} {rel_path}")
378
+ return ("\n".join(lines) + "\n").encode("utf-8")
379
+
380
+
381
+ def _write_zip_deterministic(*, files: dict[str, bytes], out_path: pathlib.Path) -> None:
382
+ out_path.parent.mkdir(parents=True, exist_ok=True)
383
+ tmp_path = out_path.with_suffix(out_path.suffix + ".tmp")
384
+
385
+ with zipfile.ZipFile(tmp_path, mode="w") as archive:
386
+ for rel_path in sorted(files):
387
+ info = zipfile.ZipInfo(filename=rel_path, date_time=_ZIP_EPOCH)
388
+ info.compress_type = zipfile.ZIP_STORED
389
+ info.create_system = 3
390
+ info.external_attr = (0o100644 & 0xFFFF) << 16
391
+ archive.writestr(info, files[rel_path])
392
+
393
+ tmp_path.replace(out_path)