schale 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.
- schale/__init__.py +25 -0
- schale/__main__.py +5 -0
- schale/account.py +93 -0
- schale/adapters/__init__.py +1 -0
- schale/adapters/extraction.py +58 -0
- schale/adapters/schaledb.py +160 -0
- schale/assets.py +215 -0
- schale/assets_cli.py +49 -0
- schale/cache.py +503 -0
- schale/cache_cli.py +203 -0
- schale/cache_control.py +141 -0
- schale/cli.py +316 -0
- schale/collections.py +120 -0
- schale/data_control.py +36 -0
- schale/format_reward.py +27 -0
- schale/inputs.py +243 -0
- schale/literal.py +77 -0
- schale/localization.py +325 -0
- schale/py.typed +0 -0
- schale/reference.py +83 -0
- schale/scanner/__init__.py +139 -0
- schale/scanner/_cnn.py +290 -0
- schale/scanner/_grid.py +102 -0
- schale/scanner/_icons.py +114 -0
- schale/scanner/_identity.py +157 -0
- schale/scanner/_ocr.py +201 -0
- schale/scanner/_preprocessing.py +161 -0
- schale/scanner/training/__init__.py +182 -0
- schale/scanner/training/augment.py +76 -0
- schale/scanner/training/dataset.py +304 -0
- schale/scanner/training/export.py +305 -0
- schale/scanner/training/model.py +200 -0
- schale/scanner/training/train.py +696 -0
- schale/schema/equipments.py +60 -0
- schale/schema/furniture.py +43 -0
- schale/schema/group.py +37 -0
- schale/schema/item.py +115 -0
- schale/schema/scanner.py +95 -0
- schale/schema/stages.py +116 -0
- schale/stage_rewards.py +231 -0
- schale/students/__init__.py +1 -0
- schale/students/audit.py +38 -0
- schale/students/benchmark.py +585 -0
- schale/students/catalog.py +67 -0
- schale/students/export.py +64 -0
- schale/students/extract.py +325 -0
- schale/students/identity.py +188 -0
- schale/students/inspector.html +104 -0
- schale/students/inspector.py +470 -0
- schale/students/labels.py +196 -0
- schale/students/layout.py +150 -0
- schale/students/model_bundle.py +99 -0
- schale/students/models.py +44 -0
- schale/students/numeric.py +218 -0
- schale/students/paths.py +17 -0
- schale/students/review.py +37 -0
- schale/students/runtime.py +49 -0
- schale/students/video.py +256 -0
- schale/students/vision.py +330 -0
- schale/toolkit_cli.py +200 -0
- schale/vision/__init__.py +1 -0
- schale/vision/text.py +77 -0
- schale-0.1.0.dist-info/METADATA +179 -0
- schale-0.1.0.dist-info/RECORD +68 -0
- schale-0.1.0.dist-info/WHEEL +4 -0
- schale-0.1.0.dist-info/entry_points.txt +3 -0
- schale-0.1.0.dist-info/licenses/LICENSE +21 -0
- schale-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +48 -0
schale/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .account import AccountSnapshot, GrowthValue, SourceReference, StudentState
|
|
2
|
+
from .cache import CacheSettings, HttpCache
|
|
3
|
+
from .inputs import ImageBatch, ImageInput, VideoInput
|
|
4
|
+
from .reference import ReferenceCatalog
|
|
5
|
+
from .stage_rewards import (
|
|
6
|
+
RewardExpectation,
|
|
7
|
+
RewardMultipliers,
|
|
8
|
+
StageRewards,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CacheSettings",
|
|
13
|
+
"HttpCache",
|
|
14
|
+
"ImageBatch",
|
|
15
|
+
"ImageInput",
|
|
16
|
+
"VideoInput",
|
|
17
|
+
"AccountSnapshot",
|
|
18
|
+
"GrowthValue",
|
|
19
|
+
"SourceReference",
|
|
20
|
+
"StudentState",
|
|
21
|
+
"ReferenceCatalog",
|
|
22
|
+
"RewardExpectation",
|
|
23
|
+
"RewardMultipliers",
|
|
24
|
+
"StageRewards",
|
|
25
|
+
]
|
schale/__main__.py
ADDED
schale/account.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Source-independent player state. Game definitions live in reference catalogs."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictInt, model_validator
|
|
6
|
+
|
|
7
|
+
GROWTH_BOUNDS = {
|
|
8
|
+
"star": (1, 5),
|
|
9
|
+
"level": (1, 100),
|
|
10
|
+
"bond": (1, 100),
|
|
11
|
+
"ex": (1, 5),
|
|
12
|
+
"basic": (0, 10),
|
|
13
|
+
"passive": (0, 10),
|
|
14
|
+
"sub": (0, 10),
|
|
15
|
+
"weapon_star": (0, 4),
|
|
16
|
+
"weapon_level": (0, 100),
|
|
17
|
+
"gear": (0, 2),
|
|
18
|
+
**{f"equipment{i}": (0, 20) for i in range(1, 4)},
|
|
19
|
+
**{f"equipment{i}_level": (0, 100) for i in range(1, 4)},
|
|
20
|
+
**{f"potential_{s}": (0, 25) for s in ("hp", "attack", "heal")},
|
|
21
|
+
}
|
|
22
|
+
Status = Literal[
|
|
23
|
+
"unknown", "observed", "inferred", "confirmed", "corrected", "conflict", "imported"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SourceReference(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
kind: str
|
|
30
|
+
uri: str
|
|
31
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class GrowthValue(BaseModel):
|
|
35
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
|
+
value: StrictInt | None = None
|
|
37
|
+
status: Status = "unknown"
|
|
38
|
+
sources: list[SourceReference] = Field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def coherent_status(self):
|
|
42
|
+
if self.status in {"unknown", "conflict"} and self.value is not None:
|
|
43
|
+
raise ValueError("Unknown/conflicting values must remain null")
|
|
44
|
+
if self.value is None and self.status not in {"unknown", "conflict"}:
|
|
45
|
+
raise ValueError("A known status requires a value")
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class StudentState(BaseModel):
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
key: str
|
|
52
|
+
student_id: StrictInt | None = Field(default=None, gt=0)
|
|
53
|
+
name: str = ""
|
|
54
|
+
identity_status: Status = "unknown"
|
|
55
|
+
fields: dict[str, GrowthValue] = Field(default_factory=dict)
|
|
56
|
+
sources: list[SourceReference] = Field(default_factory=list)
|
|
57
|
+
# Adapter-owned attributes such as a website's lock flag, not game state.
|
|
58
|
+
extensions: dict[str, Any] = Field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
@model_validator(mode="after")
|
|
61
|
+
def valid_fields(self):
|
|
62
|
+
for name, reading in self.fields.items():
|
|
63
|
+
if name not in GROWTH_BOUNDS:
|
|
64
|
+
raise ValueError(f"Unsupported growth field: {name}")
|
|
65
|
+
lo, hi = GROWTH_BOUNDS[name]
|
|
66
|
+
if reading.value is not None and not lo <= reading.value <= hi:
|
|
67
|
+
raise ValueError(f"Out-of-range growth field: {name}={reading.value}")
|
|
68
|
+
if self.student_id is None and self.identity_status not in {
|
|
69
|
+
"unknown",
|
|
70
|
+
"conflict",
|
|
71
|
+
}:
|
|
72
|
+
raise ValueError("An identified student needs a SchaleDB ID")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AccountSnapshot(BaseModel):
|
|
77
|
+
"""Portable schema v1; missing observations do not imply missing ownership."""
|
|
78
|
+
|
|
79
|
+
model_config = ConfigDict(extra="forbid")
|
|
80
|
+
schema_version: Literal[1] = 1
|
|
81
|
+
kind: Literal["schale.account"] = "schale.account"
|
|
82
|
+
sources: list[SourceReference] = Field(default_factory=list)
|
|
83
|
+
students: list[StudentState] = Field(default_factory=list)
|
|
84
|
+
|
|
85
|
+
@model_validator(mode="after")
|
|
86
|
+
def unique_students(self):
|
|
87
|
+
ids = [s.student_id for s in self.students if s.student_id is not None]
|
|
88
|
+
keys = [s.key for s in self.students]
|
|
89
|
+
if len(ids) != len(set(ids)) or len(keys) != len(set(keys)):
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"Duplicate student IDs or keys; resolve identity before merging"
|
|
92
|
+
)
|
|
93
|
+
return self
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Adapters translate external formats into the shared account model."""
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Visual extraction is one input adapter, not the common state representation."""
|
|
2
|
+
|
|
3
|
+
from ..account import AccountSnapshot, GrowthValue, SourceReference, StudentState
|
|
4
|
+
from ..students.models import Extraction
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def from_extraction(result: Extraction) -> AccountSnapshot:
|
|
8
|
+
students = []
|
|
9
|
+
for student in result.students:
|
|
10
|
+
students.append(
|
|
11
|
+
StudentState.model_validate(
|
|
12
|
+
{
|
|
13
|
+
"key": student.key,
|
|
14
|
+
"student_id": student.student_id,
|
|
15
|
+
"name": student.name,
|
|
16
|
+
"identity_status": student.identity_status,
|
|
17
|
+
"fields": {
|
|
18
|
+
name: GrowthValue.model_validate(
|
|
19
|
+
{
|
|
20
|
+
"value": value.value,
|
|
21
|
+
"status": value.status,
|
|
22
|
+
"sources": [
|
|
23
|
+
SourceReference(
|
|
24
|
+
kind="visual-observation",
|
|
25
|
+
uri=e.image,
|
|
26
|
+
details=e.model_dump(exclude={"image"}),
|
|
27
|
+
)
|
|
28
|
+
for e in value.evidence
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
for name, value in student.fields.items()
|
|
33
|
+
},
|
|
34
|
+
"sources": [
|
|
35
|
+
SourceReference(
|
|
36
|
+
kind="extraction",
|
|
37
|
+
uri=result.source,
|
|
38
|
+
details={"key": student.key, "visits": student.visits},
|
|
39
|
+
)
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return AccountSnapshot(
|
|
45
|
+
students=students,
|
|
46
|
+
sources=[
|
|
47
|
+
SourceReference(
|
|
48
|
+
kind="reference-catalog",
|
|
49
|
+
uri=result.catalog_url,
|
|
50
|
+
details={"dataset": "students", "sha256": result.catalog_sha256},
|
|
51
|
+
),
|
|
52
|
+
SourceReference(
|
|
53
|
+
kind="extraction",
|
|
54
|
+
uri=result.source,
|
|
55
|
+
details={"revision": result.diagnostics.get("revision")},
|
|
56
|
+
),
|
|
57
|
+
],
|
|
58
|
+
)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""SchaleDB collection wire format isolated from the common account schema."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from ..account import (
|
|
8
|
+
AccountSnapshot,
|
|
9
|
+
GROWTH_BOUNDS,
|
|
10
|
+
GrowthValue,
|
|
11
|
+
SourceReference,
|
|
12
|
+
StudentState,
|
|
13
|
+
)
|
|
14
|
+
from ..reference import ReferenceCatalog
|
|
15
|
+
|
|
16
|
+
KEYS = {
|
|
17
|
+
"star": "s",
|
|
18
|
+
"level": "l",
|
|
19
|
+
"bond": "b",
|
|
20
|
+
"ex": "s1",
|
|
21
|
+
"basic": "s2",
|
|
22
|
+
"passive": "s3",
|
|
23
|
+
"sub": "s4",
|
|
24
|
+
"equipment1": "e1",
|
|
25
|
+
"equipment2": "e2",
|
|
26
|
+
"equipment3": "e3",
|
|
27
|
+
"gear": "e4",
|
|
28
|
+
"weapon_star": "ws",
|
|
29
|
+
"weapon_level": "wl",
|
|
30
|
+
"potential_hp": "pm",
|
|
31
|
+
"potential_attack": "pa",
|
|
32
|
+
"potential_heal": "ph",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decode_collection(text: str) -> dict:
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(base64.b64decode(text.strip(), validate=True))
|
|
39
|
+
except (ValueError, UnicodeDecodeError) as error:
|
|
40
|
+
raise ValueError("Invalid SchaleDB base64 JSON export") from error
|
|
41
|
+
if not isinstance(data, dict) or not all(
|
|
42
|
+
str(k).isdigit() and isinstance(v, dict) for k, v in data.items()
|
|
43
|
+
):
|
|
44
|
+
raise ValueError("Unsupported SchaleDB collection structure")
|
|
45
|
+
for fields in data.values():
|
|
46
|
+
for key, value in fields.items():
|
|
47
|
+
if key in KEYS.values() and (
|
|
48
|
+
type(value) is not int or value < 0 or value > 100
|
|
49
|
+
):
|
|
50
|
+
raise ValueError("Invalid numeric value in baseline collection")
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def from_schaledb(
|
|
55
|
+
text: str,
|
|
56
|
+
*,
|
|
57
|
+
source: str = "schaledb-export",
|
|
58
|
+
catalog: ReferenceCatalog | None = None,
|
|
59
|
+
) -> AccountSnapshot:
|
|
60
|
+
if catalog is not None and catalog.dataset != "students":
|
|
61
|
+
raise ValueError("A student reference catalog is required")
|
|
62
|
+
data = decode_collection(text)
|
|
63
|
+
reference = SourceReference(
|
|
64
|
+
kind="schaledb-collection",
|
|
65
|
+
uri=source,
|
|
66
|
+
details={"sha256": hashlib.sha256(text.strip().encode()).hexdigest()},
|
|
67
|
+
)
|
|
68
|
+
students = []
|
|
69
|
+
for sid, record in data.items():
|
|
70
|
+
name = str(catalog.get(int(sid)).get("Name", "")) if catalog else ""
|
|
71
|
+
students.append(
|
|
72
|
+
StudentState(
|
|
73
|
+
key=f"student_{int(sid)}",
|
|
74
|
+
student_id=int(sid),
|
|
75
|
+
name=name,
|
|
76
|
+
identity_status="imported",
|
|
77
|
+
fields={
|
|
78
|
+
field: GrowthValue(
|
|
79
|
+
value=record[key], status="imported", sources=[reference]
|
|
80
|
+
)
|
|
81
|
+
for field, key in KEYS.items()
|
|
82
|
+
if key in record
|
|
83
|
+
},
|
|
84
|
+
sources=[reference],
|
|
85
|
+
extensions={
|
|
86
|
+
"schaledb": {
|
|
87
|
+
k: v for k, v in record.items() if k not in KEYS.values()
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
sources = [reference]
|
|
93
|
+
if catalog:
|
|
94
|
+
sources.append(
|
|
95
|
+
SourceReference(
|
|
96
|
+
kind="reference-catalog",
|
|
97
|
+
uri=catalog.source,
|
|
98
|
+
details={"sha256": catalog.sha256, "dataset": catalog.dataset},
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return AccountSnapshot(students=students, sources=sources)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def to_schaledb(
|
|
105
|
+
snapshot: AccountSnapshot,
|
|
106
|
+
baseline: dict | None = None,
|
|
107
|
+
*,
|
|
108
|
+
allow_observed: bool = False,
|
|
109
|
+
) -> tuple[str, dict]:
|
|
110
|
+
collection = json.loads(json.dumps(baseline or {}))
|
|
111
|
+
accepted = {"confirmed", "corrected", "imported"}
|
|
112
|
+
if allow_observed:
|
|
113
|
+
accepted.update(("observed", "inferred"))
|
|
114
|
+
skipped, updated = [], []
|
|
115
|
+
for student in snapshot.students:
|
|
116
|
+
if student.student_id is None or student.identity_status not in accepted:
|
|
117
|
+
skipped.append({"key": student.key, "reason": "identity not confirmed"})
|
|
118
|
+
continue
|
|
119
|
+
record = dict(collection.get(str(student.student_id), {}))
|
|
120
|
+
extras = student.extensions.get("schaledb", {})
|
|
121
|
+
if not isinstance(extras, dict):
|
|
122
|
+
raise ValueError("SchaleDB extension must be an object")
|
|
123
|
+
for key, value in extras.items():
|
|
124
|
+
if key not in KEYS.values():
|
|
125
|
+
record.setdefault(key, value)
|
|
126
|
+
for field, key in KEYS.items():
|
|
127
|
+
reading = student.fields.get(field)
|
|
128
|
+
if (
|
|
129
|
+
reading is None
|
|
130
|
+
or reading.value is None
|
|
131
|
+
or reading.status not in accepted
|
|
132
|
+
):
|
|
133
|
+
continue
|
|
134
|
+
if not GROWTH_BOUNDS[field][0] <= reading.value <= GROWTH_BOUNDS[field][1]:
|
|
135
|
+
raise ValueError(f"Out-of-range extracted value: {student.key}/{field}")
|
|
136
|
+
value = reading.value
|
|
137
|
+
if field in ("basic", "passive", "sub") and value == 0:
|
|
138
|
+
value = 1
|
|
139
|
+
record[key] = value
|
|
140
|
+
missing = sorted(set(KEYS.values()) - record.keys())
|
|
141
|
+
if missing:
|
|
142
|
+
skipped.append(
|
|
143
|
+
{"key": student.key, "reason": "incomplete record", "missing": missing}
|
|
144
|
+
)
|
|
145
|
+
continue
|
|
146
|
+
record.setdefault("lock", False)
|
|
147
|
+
collection[str(student.student_id)] = record
|
|
148
|
+
updated.append(student.student_id)
|
|
149
|
+
if not updated:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"No complete confirmed records to export. Review missing fields or supply a baseline export."
|
|
152
|
+
)
|
|
153
|
+
raw = json.dumps(collection, separators=(",", ":"), ensure_ascii=True)
|
|
154
|
+
return base64.b64encode(raw.encode()).decode(), {
|
|
155
|
+
"updated": updated,
|
|
156
|
+
"skipped": skipped,
|
|
157
|
+
"total": len(collection),
|
|
158
|
+
"baseline_used": baseline is not None,
|
|
159
|
+
"warning": "SchaleDB import replaces the collection; use --base to preserve unscanned students.",
|
|
160
|
+
}
|
schale/assets.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Local, checksum-verified vision resources; no bundled game art or downloads."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
11
|
+
from zipfile import BadZipFile, ZIP_DEFLATED, ZipFile
|
|
12
|
+
|
|
13
|
+
from filelock import FileLock
|
|
14
|
+
|
|
15
|
+
from .cache import cache_directory
|
|
16
|
+
|
|
17
|
+
PROFILE = "bluearchive-ko-16x9-v1"
|
|
18
|
+
MAX_BYTES = 512 * 1024 * 1024
|
|
19
|
+
REQUIRED = {
|
|
20
|
+
"students": {
|
|
21
|
+
"labels.json",
|
|
22
|
+
"header.png",
|
|
23
|
+
"empty_gear.png",
|
|
24
|
+
"ghost_gloves.png",
|
|
25
|
+
"ghost_shoes.png",
|
|
26
|
+
"ghost_hat.png",
|
|
27
|
+
"potential_hp_25.png",
|
|
28
|
+
"potential_attack_25.png",
|
|
29
|
+
"layout_reference.npz",
|
|
30
|
+
},
|
|
31
|
+
"inventory": {"models/equipment_classifier.onnx", "models/class_mapping.json"},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resource_directory() -> Path:
|
|
36
|
+
return cache_directory() / "vision-resources"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_name(name: str) -> bool:
|
|
40
|
+
parts = PurePosixPath(name).parts
|
|
41
|
+
return bool(
|
|
42
|
+
parts
|
|
43
|
+
and re.fullmatch(r"[A-Za-z0-9_./-]+", name)
|
|
44
|
+
and not name.startswith("/")
|
|
45
|
+
and "//" not in name
|
|
46
|
+
and all(p not in {".", ".."} and not p.endswith(".") for p in name.split("/"))
|
|
47
|
+
and all(
|
|
48
|
+
p.split(".")[0].upper()
|
|
49
|
+
not in {
|
|
50
|
+
"CON",
|
|
51
|
+
"PRN",
|
|
52
|
+
"AUX",
|
|
53
|
+
"NUL",
|
|
54
|
+
*[f"COM{i}" for i in range(10)],
|
|
55
|
+
*[f"LPT{i}" for i in range(10)],
|
|
56
|
+
}
|
|
57
|
+
for p in parts
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _hash(path: Path) -> str:
|
|
63
|
+
with path.open("rb") as stream:
|
|
64
|
+
return hashlib.file_digest(stream, "sha256").hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _inventory(directory: Path) -> dict[str, str]:
|
|
68
|
+
files = {}
|
|
69
|
+
size = 0
|
|
70
|
+
for path in sorted(directory.rglob("*")):
|
|
71
|
+
if path.is_symlink():
|
|
72
|
+
raise ValueError("Resource bundles cannot contain symbolic links")
|
|
73
|
+
if not path.is_file() or path == directory / "manifest.json":
|
|
74
|
+
continue
|
|
75
|
+
name = path.relative_to(directory).as_posix()
|
|
76
|
+
if not _safe_name(name) or name.split("/")[0] not in REQUIRED:
|
|
77
|
+
raise ValueError(f"Invalid resource path: {name}")
|
|
78
|
+
if path.suffix not in {".png", ".webp", ".json", ".npz", ".onnx", ".data"}:
|
|
79
|
+
raise ValueError(f"Unsupported resource file: {name}")
|
|
80
|
+
size += path.stat().st_size
|
|
81
|
+
files[name] = _hash(path)
|
|
82
|
+
if not files or len(files) > 4096 or size > MAX_BYTES:
|
|
83
|
+
raise ValueError("Resource bundle must contain 1..4096 files, at most 512 MiB")
|
|
84
|
+
if len({name.casefold() for name in files}) != len(files):
|
|
85
|
+
raise ValueError("Case-colliding resource paths")
|
|
86
|
+
for component in {name.split("/")[0] for name in files}:
|
|
87
|
+
missing = {f"{component}/{n}" for n in REQUIRED[component]} - files.keys()
|
|
88
|
+
if missing:
|
|
89
|
+
raise ValueError(f"Missing {component} resources: {sorted(missing)}")
|
|
90
|
+
if "students/labels.json" in files:
|
|
91
|
+
labels = json.loads(
|
|
92
|
+
(directory / "students/labels.json").read_text(encoding="utf-8")
|
|
93
|
+
)
|
|
94
|
+
if not isinstance(labels, list):
|
|
95
|
+
raise ValueError("Student labels must be a list")
|
|
96
|
+
for label in labels:
|
|
97
|
+
name = label.get("image") if isinstance(label, dict) else None
|
|
98
|
+
if (
|
|
99
|
+
not isinstance(name, str)
|
|
100
|
+
or not _safe_name(name)
|
|
101
|
+
or f"students/{name}" not in files
|
|
102
|
+
):
|
|
103
|
+
raise ValueError("Student label references a missing or unsafe image")
|
|
104
|
+
return files
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_resources(directory: Path) -> dict:
|
|
108
|
+
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
|
|
109
|
+
if (
|
|
110
|
+
not isinstance(manifest, dict)
|
|
111
|
+
or manifest.get("schema_version") != 1
|
|
112
|
+
or manifest.get("profile") != PROFILE
|
|
113
|
+
):
|
|
114
|
+
raise ValueError("Unsupported vision resource profile")
|
|
115
|
+
if manifest.get("files") != _inventory(directory):
|
|
116
|
+
raise ValueError("Vision resource checksum or file inventory mismatch")
|
|
117
|
+
return manifest
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pack_resources(source: Path, output: Path) -> None:
|
|
121
|
+
"""Package a user's own reference directory; never infer redistribution rights."""
|
|
122
|
+
source, output = source.resolve(), output.resolve()
|
|
123
|
+
files = _inventory(source)
|
|
124
|
+
manifest = {"schema_version": 1, "profile": PROFILE, "files": files}
|
|
125
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
# Exclusive create: preserve any existing bundle.
|
|
127
|
+
with output.open("xb") as stream, ZipFile(stream, "w", ZIP_DEFLATED) as archive:
|
|
128
|
+
archive.writestr(
|
|
129
|
+
"manifest.json", json.dumps(manifest, sort_keys=True, indent=2)
|
|
130
|
+
)
|
|
131
|
+
for name in files:
|
|
132
|
+
archive.write(source / name, name)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def install_resources(source: Path) -> Path:
|
|
136
|
+
"""Install immutable content-addressed resources and atomically select them."""
|
|
137
|
+
root = resource_directory()
|
|
138
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
with (
|
|
140
|
+
FileLock(str(root / "install.lock")),
|
|
141
|
+
tempfile.TemporaryDirectory(prefix=".install-", dir=root) as temporary,
|
|
142
|
+
):
|
|
143
|
+
stage = Path(temporary) / "bundle"
|
|
144
|
+
stage.mkdir()
|
|
145
|
+
try:
|
|
146
|
+
with ZipFile(source) as archive:
|
|
147
|
+
members = archive.infolist()
|
|
148
|
+
if (
|
|
149
|
+
not members
|
|
150
|
+
or len(members) > 4097
|
|
151
|
+
or len({m.filename.casefold() for m in members}) != len(members)
|
|
152
|
+
or sum(m.file_size for m in members) > MAX_BYTES + 1024 * 1024
|
|
153
|
+
or any(
|
|
154
|
+
m.orig_filename != m.filename
|
|
155
|
+
or not _safe_name(m.filename)
|
|
156
|
+
or m.is_dir()
|
|
157
|
+
or stat.S_ISLNK(m.external_attr >> 16)
|
|
158
|
+
for m in members
|
|
159
|
+
)
|
|
160
|
+
):
|
|
161
|
+
raise ValueError("Unsafe or oversized resource ZIP")
|
|
162
|
+
for member in members:
|
|
163
|
+
path = stage / member.filename
|
|
164
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
with archive.open(member) as incoming, path.open("xb") as outgoing:
|
|
166
|
+
shutil.copyfileobj(incoming, outgoing)
|
|
167
|
+
except BadZipFile as error:
|
|
168
|
+
raise ValueError("Resources must be a valid ZIP bundle") from error
|
|
169
|
+
manifest = validate_resources(stage)
|
|
170
|
+
digest = hashlib.sha256(
|
|
171
|
+
json.dumps(manifest, sort_keys=True).encode()
|
|
172
|
+
).hexdigest()
|
|
173
|
+
target = root / "bundles" / digest
|
|
174
|
+
target.parent.mkdir(exist_ok=True)
|
|
175
|
+
if target.exists():
|
|
176
|
+
validate_resources(target)
|
|
177
|
+
else:
|
|
178
|
+
stage.rename(target)
|
|
179
|
+
pointer = Path(temporary) / "current.json"
|
|
180
|
+
pointer.write_text(json.dumps({"bundle": digest}), encoding="utf-8")
|
|
181
|
+
os.replace(pointer, root / "current.json")
|
|
182
|
+
return target
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def resolve_resources(component: str, *, verify: bool = True) -> Path:
|
|
186
|
+
if component not in REQUIRED:
|
|
187
|
+
raise ValueError(f"Unknown resource component: {component}")
|
|
188
|
+
root = resource_directory()
|
|
189
|
+
try:
|
|
190
|
+
selection = json.loads((root / "current.json").read_text(encoding="utf-8"))
|
|
191
|
+
digest = selection.get("bundle") if isinstance(selection, dict) else None
|
|
192
|
+
if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None:
|
|
193
|
+
raise ValueError("Invalid resource selection")
|
|
194
|
+
directory = root / "bundles" / digest
|
|
195
|
+
if verify:
|
|
196
|
+
validate_resources(directory)
|
|
197
|
+
if not (directory / component).is_dir():
|
|
198
|
+
raise ValueError(f"Bundle has no {component} resources")
|
|
199
|
+
return directory / component
|
|
200
|
+
except (OSError, ValueError) as error:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Vision resources unavailable for {component}: {error}. "
|
|
203
|
+
"Install your trusted local bundle with 'schale assets install BUNDLE.zip'."
|
|
204
|
+
) from error
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def resource_report() -> dict:
|
|
208
|
+
report: dict = {"cache": str(resource_directory()), "components": {}}
|
|
209
|
+
for component in REQUIRED:
|
|
210
|
+
try:
|
|
211
|
+
path = resolve_resources(component)
|
|
212
|
+
report["components"][component] = {"valid": True, "path": str(path)}
|
|
213
|
+
except ValueError as error:
|
|
214
|
+
report["components"][component] = {"valid": False, "error": str(error)}
|
|
215
|
+
return report
|
schale/assets_cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Manage private local vision resources without importing vision runtimes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .assets import install_resources, pack_resources, resource_report
|
|
10
|
+
|
|
11
|
+
assets_app = typer.Typer(
|
|
12
|
+
help="Install and verify your local vision resources; no downloads"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@assets_app.command("pack")
|
|
17
|
+
def pack(
|
|
18
|
+
source: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
|
|
19
|
+
output: Annotated[Path, typer.Option("--output", "-o")],
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Build a ZIP from your reference directory; this grants no distribution rights."""
|
|
22
|
+
try:
|
|
23
|
+
pack_resources(source, output)
|
|
24
|
+
except (OSError, ValueError) as error:
|
|
25
|
+
typer.echo(f"Resource packaging failed: {error}", err=True)
|
|
26
|
+
raise typer.Exit(1) from error
|
|
27
|
+
typer.echo(f"Created: {output.resolve()}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@assets_app.command("install")
|
|
31
|
+
def install(
|
|
32
|
+
source: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Verify and activate a trusted local ZIP. Previous versions are retained."""
|
|
35
|
+
try:
|
|
36
|
+
installed = install_resources(source)
|
|
37
|
+
except (OSError, ValueError) as error:
|
|
38
|
+
typer.echo(f"Resource installation failed: {error}", err=True)
|
|
39
|
+
raise typer.Exit(1) from error
|
|
40
|
+
typer.echo(f"Installed: {installed}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@assets_app.command("info")
|
|
44
|
+
def info() -> None:
|
|
45
|
+
"""Check all installed reference files against their SHA-256 manifest."""
|
|
46
|
+
report = resource_report()
|
|
47
|
+
typer.echo(json.dumps(report, ensure_ascii=False, indent=2))
|
|
48
|
+
if not all(row["valid"] for row in report["components"].values()):
|
|
49
|
+
raise typer.Exit(1)
|