homebase-server 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.
- homebase/__init__.py +0 -0
- homebase/__main__.py +3 -0
- homebase/cli/__init__.py +1 -0
- homebase/cli/cli.py +2 -0
- homebase/core/__init__.py +0 -0
- homebase/core/config.py +14 -0
- homebase/core/db.py +39 -0
- homebase/core/schema.py +572 -0
- homebase/server/__init__.py +0 -0
- homebase/server/api.py +63 -0
- homebase/server/helpers.py +65 -0
- homebase/server/inventory.py +207 -0
- homebase/server/static/style.css +720 -0
- homebase/server/templates/base.html +139 -0
- homebase/server/templates/entity_detail.html +193 -0
- homebase/server/templates/entity_form.html +184 -0
- homebase/server/templates/entity_list.html +93 -0
- homebase/server/templates/search_results.html +43 -0
- homebase_server-0.1.0.dist-info/METADATA +14 -0
- homebase_server-0.1.0.dist-info/RECORD +22 -0
- homebase_server-0.1.0.dist-info/WHEEL +4 -0
- homebase_server-0.1.0.dist-info/entry_points.txt +3 -0
homebase/__init__.py
ADDED
|
File without changes
|
homebase/__main__.py
ADDED
homebase/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from homebase.cli.cli import main as main
|
homebase/cli/cli.py
ADDED
|
File without changes
|
homebase/core/config.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from homebase.core.db import Database
|
|
5
|
+
from homebase.core.schema import Schema
|
|
6
|
+
|
|
7
|
+
SCHEMA_PATH = Path(os.environ.get("HOMEBASE_SCHEMA_PATH", "schema.yaml"))
|
|
8
|
+
DB_PATH = Path(os.environ.get("HOMEBASE_DB_PATH", "data/db.json"))
|
|
9
|
+
HOST = os.environ.get("HOMEBASE_HOST", "0.0.0.0")
|
|
10
|
+
PORT = int(os.environ.get("HOMEBASE_PORT", "8000"))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
schema = Schema.from_file(SCHEMA_PATH)
|
|
14
|
+
db = Database(DB_PATH)
|
homebase/core/db.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Callable, Iterable
|
|
3
|
+
|
|
4
|
+
from tinydb import TinyDB
|
|
5
|
+
from tinydb.table import Document
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _with_id(doc: Document) -> dict:
|
|
9
|
+
return dict(doc) | {"id": doc.doc_id}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Database:
|
|
13
|
+
def __init__(self, path: Path):
|
|
14
|
+
path.parent.mkdir(exist_ok=True)
|
|
15
|
+
self._db = TinyDB(path)
|
|
16
|
+
|
|
17
|
+
def counts(self, entity_names: Iterable[str]) -> dict[str, int]:
|
|
18
|
+
return {name: len(self._db.table(name).all()) for name in entity_names}
|
|
19
|
+
|
|
20
|
+
def all(self, entity_type: str) -> list[dict]:
|
|
21
|
+
return [_with_id(d) for d in self._db.table(entity_type).all()]
|
|
22
|
+
|
|
23
|
+
def get(self, entity_type: str, doc_id: int) -> dict | None:
|
|
24
|
+
doc = self._db.table(entity_type).get(doc_id=doc_id)
|
|
25
|
+
if doc is None or isinstance(doc, list):
|
|
26
|
+
return None
|
|
27
|
+
return _with_id(doc)
|
|
28
|
+
|
|
29
|
+
def search(self, entity_type: str, predicate: Callable) -> list[dict]:
|
|
30
|
+
return [_with_id(d) for d in self._db.table(entity_type).search(predicate)]
|
|
31
|
+
|
|
32
|
+
def create(self, entity_type: str, doc: dict) -> int:
|
|
33
|
+
return self._db.table(entity_type).insert(doc)
|
|
34
|
+
|
|
35
|
+
def update(self, entity_type: str, doc_id: int, doc: dict) -> None:
|
|
36
|
+
self._db.table(entity_type).update(doc, doc_ids=[doc_id])
|
|
37
|
+
|
|
38
|
+
def delete(self, entity_type: str, doc_id: int) -> None:
|
|
39
|
+
self._db.table(entity_type).remove(doc_ids=[doc_id])
|
homebase/core/schema.py
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
# CONSTANTS
|
|
11
|
+
|
|
12
|
+
MAX_COLORS = 12 # for auto-assigning entity colors
|
|
13
|
+
|
|
14
|
+
# ──────────────────────────────────────────────
|
|
15
|
+
# Field type registry
|
|
16
|
+
# ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FieldType(str, Enum):
|
|
20
|
+
STRING = "string"
|
|
21
|
+
NUMBER = "number"
|
|
22
|
+
BOOLEAN = "boolean"
|
|
23
|
+
DATE = "date"
|
|
24
|
+
DATETIME = "datetime"
|
|
25
|
+
URL = "url"
|
|
26
|
+
EMAIL = "email"
|
|
27
|
+
ENUM = "enum"
|
|
28
|
+
TAGS = "tags"
|
|
29
|
+
MARKDOWN = "markdown"
|
|
30
|
+
RELATION = "relation"
|
|
31
|
+
JSON = "json" # arbitrary nested data
|
|
32
|
+
LIST = "list" # list of primitives
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Per-type validators. Each returns (ok: bool, cleaned_value | error_msg).
|
|
36
|
+
def _validate_string(value: Any, field_def: dict) -> tuple[bool, Any]:
|
|
37
|
+
if not isinstance(value, str):
|
|
38
|
+
return False, "expected string"
|
|
39
|
+
mx = field_def.get("max_length")
|
|
40
|
+
if mx and len(value) > mx:
|
|
41
|
+
return False, f"exceeds max_length ({mx})"
|
|
42
|
+
mn = field_def.get("min_length")
|
|
43
|
+
if mn and len(value) < mn:
|
|
44
|
+
return False, f"below min_length ({mn})"
|
|
45
|
+
pattern = field_def.get("pattern")
|
|
46
|
+
if pattern and not re.match(pattern, value):
|
|
47
|
+
return False, f"does not match pattern {pattern}"
|
|
48
|
+
return True, value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_number(value: Any, field_def: dict) -> tuple[bool, Any]:
|
|
52
|
+
if isinstance(value, bool):
|
|
53
|
+
return False, "expected number, got bool"
|
|
54
|
+
if not isinstance(value, (int, float)):
|
|
55
|
+
return False, "expected number"
|
|
56
|
+
mn = field_def.get("min")
|
|
57
|
+
if mn is not None and value < mn:
|
|
58
|
+
return False, f"below minimum ({mn})"
|
|
59
|
+
mx = field_def.get("max")
|
|
60
|
+
if mx is not None and value > mx:
|
|
61
|
+
return False, f"exceeds maximum ({mx})"
|
|
62
|
+
return True, value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_boolean(value: Any, _: dict) -> tuple[bool, Any]:
|
|
66
|
+
if not isinstance(value, bool):
|
|
67
|
+
return False, "expected boolean"
|
|
68
|
+
return True, value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
72
|
+
_ISO_DT = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _validate_date(value: Any, _: dict) -> tuple[bool, Any]:
|
|
76
|
+
if isinstance(value, date) and not isinstance(value, datetime):
|
|
77
|
+
return True, value.isoformat()
|
|
78
|
+
if isinstance(value, str) and _ISO_DATE.match(value):
|
|
79
|
+
try:
|
|
80
|
+
date.fromisoformat(value)
|
|
81
|
+
return True, value
|
|
82
|
+
except ValueError:
|
|
83
|
+
pass
|
|
84
|
+
return False, "expected ISO date (YYYY-MM-DD)"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _validate_datetime(value: Any, _: dict) -> tuple[bool, Any]:
|
|
88
|
+
if isinstance(value, datetime):
|
|
89
|
+
return True, value.isoformat()
|
|
90
|
+
if isinstance(value, str) and _ISO_DT.match(value):
|
|
91
|
+
try:
|
|
92
|
+
datetime.fromisoformat(value)
|
|
93
|
+
return True, value
|
|
94
|
+
except ValueError:
|
|
95
|
+
pass
|
|
96
|
+
return False, "expected ISO datetime"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_URL_RE = re.compile(r"^https?://\S+$")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _validate_url(value: Any, _: dict) -> tuple[bool, Any]:
|
|
103
|
+
if isinstance(value, str) and _URL_RE.match(value):
|
|
104
|
+
return True, value
|
|
105
|
+
return False, "expected a valid URL (http/https)"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_email(value: Any, _: dict) -> tuple[bool, Any]:
|
|
112
|
+
if isinstance(value, str) and _EMAIL_RE.match(value):
|
|
113
|
+
return True, value.lower()
|
|
114
|
+
return False, "expected a valid email"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_enum(value: Any, field_def: dict) -> tuple[bool, Any]:
|
|
118
|
+
options = field_def.get("options", [])
|
|
119
|
+
if value not in options:
|
|
120
|
+
return False, f"must be one of {options}"
|
|
121
|
+
return True, value
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_tags(value: Any, _: dict) -> tuple[bool, Any]:
|
|
125
|
+
if isinstance(value, str):
|
|
126
|
+
value = [t.strip() for t in value.split(",") if t.strip()]
|
|
127
|
+
if not isinstance(value, list) or not all(isinstance(t, str) for t in value):
|
|
128
|
+
return False, "expected list of strings"
|
|
129
|
+
return True, value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _validate_markdown(value: Any, _: dict) -> tuple[bool, Any]:
|
|
133
|
+
if not isinstance(value, str):
|
|
134
|
+
return False, "expected string (markdown)"
|
|
135
|
+
return True, value
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _validate_relation(value: Any, field_def: dict) -> tuple[bool, Any]:
|
|
139
|
+
many = field_def.get("many", False)
|
|
140
|
+
if many:
|
|
141
|
+
if isinstance(value, list) and all(isinstance(v, (str, int)) for v in value):
|
|
142
|
+
return True, value
|
|
143
|
+
return False, "expected list of IDs"
|
|
144
|
+
if isinstance(value, (str, int)):
|
|
145
|
+
return True, value
|
|
146
|
+
return False, "expected a single ID"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _validate_json(value: Any, _: dict) -> tuple[bool, Any]:
|
|
150
|
+
# Accept anything JSON-serialisable.
|
|
151
|
+
try:
|
|
152
|
+
json.dumps(value)
|
|
153
|
+
return True, value
|
|
154
|
+
except TypeError, ValueError:
|
|
155
|
+
return False, "value is not JSON-serialisable"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validate_list(value: Any, field_def: dict) -> tuple[bool, Any]:
|
|
159
|
+
if not isinstance(value, list):
|
|
160
|
+
return False, "expected list"
|
|
161
|
+
item_type = field_def.get("item_type", "string")
|
|
162
|
+
validator = _VALIDATORS.get(item_type, _validate_string)
|
|
163
|
+
cleaned = []
|
|
164
|
+
for i, item in enumerate(value):
|
|
165
|
+
ok, result = validator(item, field_def)
|
|
166
|
+
if not ok:
|
|
167
|
+
return False, f"item [{i}]: {result}"
|
|
168
|
+
cleaned.append(result)
|
|
169
|
+
return True, cleaned
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
_VALIDATORS: dict[str, Any] = {
|
|
173
|
+
FieldType.STRING: _validate_string,
|
|
174
|
+
FieldType.NUMBER: _validate_number,
|
|
175
|
+
FieldType.BOOLEAN: _validate_boolean,
|
|
176
|
+
FieldType.DATE: _validate_date,
|
|
177
|
+
FieldType.DATETIME: _validate_datetime,
|
|
178
|
+
FieldType.URL: _validate_url,
|
|
179
|
+
FieldType.EMAIL: _validate_email,
|
|
180
|
+
FieldType.ENUM: _validate_enum,
|
|
181
|
+
FieldType.TAGS: _validate_tags,
|
|
182
|
+
FieldType.MARKDOWN: _validate_markdown,
|
|
183
|
+
FieldType.RELATION: _validate_relation,
|
|
184
|
+
FieldType.JSON: _validate_json,
|
|
185
|
+
FieldType.LIST: _validate_list,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# Schema loader & manager
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SchemaError(Exception):
|
|
193
|
+
"""Raised when the schema definition itself is invalid."""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ValidationError(Exception):
|
|
197
|
+
"""Raised when a document fails validation against its entity schema."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, errors: dict[str, str]):
|
|
200
|
+
self.errors = errors
|
|
201
|
+
super().__init__(f"Validation failed: {errors}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class FieldDef:
|
|
205
|
+
"""Parsed field definition with helper properties."""
|
|
206
|
+
|
|
207
|
+
__slots__ = (
|
|
208
|
+
"name",
|
|
209
|
+
"raw",
|
|
210
|
+
"type",
|
|
211
|
+
"required",
|
|
212
|
+
"default",
|
|
213
|
+
"many",
|
|
214
|
+
"target",
|
|
215
|
+
"options",
|
|
216
|
+
"searchable",
|
|
217
|
+
"hidden",
|
|
218
|
+
"related_name",
|
|
219
|
+
"label",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def __init__(self, name: str, raw: dict):
|
|
223
|
+
self.name = name
|
|
224
|
+
self.raw = raw
|
|
225
|
+
self.type = FieldType(raw.get("type", "string"))
|
|
226
|
+
self.required = raw.get("required", False)
|
|
227
|
+
self.default = raw.get("default")
|
|
228
|
+
self.many = raw.get("many", False)
|
|
229
|
+
self.target = raw.get("target") # relation target entity
|
|
230
|
+
self.related_name = raw.get("related_name") # optional reverse relation name
|
|
231
|
+
self.options = raw.get("options", []) # enum options
|
|
232
|
+
self.searchable = raw.get("searchable", True)
|
|
233
|
+
self.hidden = raw.get("hidden", False) # hide from list views
|
|
234
|
+
self.label = raw.get("label", name.replace("_", " ").title())
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def is_relation(self) -> bool:
|
|
238
|
+
return self.type == FieldType.RELATION
|
|
239
|
+
|
|
240
|
+
def to_dict(self) -> dict:
|
|
241
|
+
"""JSON-safe representation for the frontend."""
|
|
242
|
+
d: dict[str, Any] = {
|
|
243
|
+
"type": self.type.value,
|
|
244
|
+
"required": self.required,
|
|
245
|
+
"label": self.label,
|
|
246
|
+
}
|
|
247
|
+
if self.default is not None:
|
|
248
|
+
d["default"] = self.default
|
|
249
|
+
if self.options:
|
|
250
|
+
d["options"] = self.options
|
|
251
|
+
if self.target:
|
|
252
|
+
d["target"] = self.target
|
|
253
|
+
d["many"] = self.many
|
|
254
|
+
if self.hidden:
|
|
255
|
+
d["hidden"] = True
|
|
256
|
+
if self.related_name:
|
|
257
|
+
d["related_name"] = self.related_name
|
|
258
|
+
# Forward any extra keys the user put in the field def (display hints etc.)
|
|
259
|
+
|
|
260
|
+
extras = {
|
|
261
|
+
k: v
|
|
262
|
+
for k, v in self.raw.items()
|
|
263
|
+
if k
|
|
264
|
+
not in {
|
|
265
|
+
"type",
|
|
266
|
+
"required",
|
|
267
|
+
"default",
|
|
268
|
+
"options",
|
|
269
|
+
"target",
|
|
270
|
+
"many",
|
|
271
|
+
"searchable",
|
|
272
|
+
"hidden",
|
|
273
|
+
"related_name",
|
|
274
|
+
"label",
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
d.update(extras)
|
|
278
|
+
return d
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class EntityDef:
|
|
282
|
+
"""Parsed entity definition."""
|
|
283
|
+
|
|
284
|
+
__slots__ = (
|
|
285
|
+
"name",
|
|
286
|
+
"raw",
|
|
287
|
+
"fields",
|
|
288
|
+
"icon",
|
|
289
|
+
"label",
|
|
290
|
+
"plural",
|
|
291
|
+
"list_columns",
|
|
292
|
+
"sort_default",
|
|
293
|
+
"display_field",
|
|
294
|
+
"junction",
|
|
295
|
+
"color_id",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def __init__(self, name: str, raw: dict):
|
|
299
|
+
self.name = name
|
|
300
|
+
self.raw = raw
|
|
301
|
+
self.icon = raw.get("icon", "box")
|
|
302
|
+
self.label = raw.get("label", name.replace("_", " ").title())
|
|
303
|
+
self.plural = raw.get("plural", self.label + "s")
|
|
304
|
+
self.display_field = raw.get("display_field", "name")
|
|
305
|
+
self.sort_default = raw.get("sort_default", self.display_field)
|
|
306
|
+
self.list_columns = raw.get("list_columns", [])
|
|
307
|
+
self.junction = raw.get("junction", None)
|
|
308
|
+
self.color_id = raw.get("color_id", None)
|
|
309
|
+
|
|
310
|
+
raw_fields = raw.get("fields", {})
|
|
311
|
+
if not raw_fields:
|
|
312
|
+
raise SchemaError(f"Entity '{name}' has no fields defined")
|
|
313
|
+
self.fields: dict[str, FieldDef] = {
|
|
314
|
+
fname: FieldDef(fname, fdef if isinstance(fdef, dict) else {"type": fdef})
|
|
315
|
+
for fname, fdef in raw_fields.items()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# If list_columns is empty, auto-generate: required fields first, then first 4.
|
|
319
|
+
if not self.list_columns:
|
|
320
|
+
self.list_columns = [
|
|
321
|
+
f.name
|
|
322
|
+
for f in sorted(
|
|
323
|
+
self.fields.values(),
|
|
324
|
+
key=lambda f: (not f.required, f.name),
|
|
325
|
+
)
|
|
326
|
+
if not f.hidden
|
|
327
|
+
][:5]
|
|
328
|
+
|
|
329
|
+
if self.junction:
|
|
330
|
+
if not isinstance(self.junction, dict):
|
|
331
|
+
raise SchemaError(
|
|
332
|
+
f"Entity '{name}': junction must be a dict with 'left' and 'right'"
|
|
333
|
+
)
|
|
334
|
+
if "left" not in self.junction or "left" not in self.junction:
|
|
335
|
+
raise SchemaError(
|
|
336
|
+
f"Entity '{name}': junction must contain 'entities' and 'fields' keys"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def required_fields(self) -> list[FieldDef]:
|
|
341
|
+
return [f for f in self.fields.values() if f.required]
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def relation_fields(self) -> list[FieldDef]:
|
|
345
|
+
return [f for f in self.fields.values() if f.is_relation]
|
|
346
|
+
|
|
347
|
+
@property
|
|
348
|
+
def searchable_fields(self) -> list[FieldDef]:
|
|
349
|
+
return [
|
|
350
|
+
f
|
|
351
|
+
for f in self.fields.values()
|
|
352
|
+
if f.searchable
|
|
353
|
+
and f.type
|
|
354
|
+
in (
|
|
355
|
+
FieldType.STRING,
|
|
356
|
+
FieldType.MARKDOWN,
|
|
357
|
+
FieldType.URL,
|
|
358
|
+
FieldType.EMAIL,
|
|
359
|
+
FieldType.TAGS,
|
|
360
|
+
)
|
|
361
|
+
]
|
|
362
|
+
|
|
363
|
+
def to_dict(self) -> dict:
|
|
364
|
+
return {
|
|
365
|
+
"name": self.name,
|
|
366
|
+
"icon": self.icon,
|
|
367
|
+
"label": self.label,
|
|
368
|
+
"plural": self.plural,
|
|
369
|
+
"display_field": self.display_field,
|
|
370
|
+
"sort_default": self.sort_default,
|
|
371
|
+
"list_columns": self.list_columns,
|
|
372
|
+
"fields": {fname: fdef.to_dict() for fname, fdef in self.fields.items()},
|
|
373
|
+
"junction": self.junction,
|
|
374
|
+
"color_id": self.color_id,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class Schema:
|
|
379
|
+
"""
|
|
380
|
+
The main schema manager.
|
|
381
|
+
|
|
382
|
+
Usage:
|
|
383
|
+
schema = Schema.from_file("schema.yaml")
|
|
384
|
+
schema.validate("service", {"name": "Plex", "status": "running"})
|
|
385
|
+
frontend_payload = schema.to_dict()
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
def __init__(self, raw: dict):
|
|
389
|
+
raw_entities = raw.get("entities", {})
|
|
390
|
+
if not raw_entities:
|
|
391
|
+
raise SchemaError(
|
|
392
|
+
"Schema must define at least one entity under 'entities:'"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
self.meta: dict = raw.get("meta", {}) # optional top-level metadata
|
|
396
|
+
self.globals: dict = raw.get("globals", {}) # shared defaults / settings
|
|
397
|
+
|
|
398
|
+
self.entities: dict[str, EntityDef] = {}
|
|
399
|
+
for ename, edef in raw_entities.items():
|
|
400
|
+
self.entities[ename] = EntityDef(ename, edef)
|
|
401
|
+
|
|
402
|
+
self._validate_relations()
|
|
403
|
+
self._build_reverse_relations()
|
|
404
|
+
|
|
405
|
+
# Assign colors
|
|
406
|
+
for i, ename in enumerate(self.entities):
|
|
407
|
+
if not self.entities[ename].color_id:
|
|
408
|
+
self.entities[ename].color_id = (
|
|
409
|
+
i % MAX_COLORS
|
|
410
|
+
) + 1 # cycle through 8 colors
|
|
411
|
+
|
|
412
|
+
# ── Loaders ───────────────────────────────
|
|
413
|
+
|
|
414
|
+
@classmethod
|
|
415
|
+
def from_file(cls, path: str | Path) -> "Schema":
|
|
416
|
+
path = Path(path)
|
|
417
|
+
text = path.read_text(encoding="utf-8")
|
|
418
|
+
if path.suffix in (".yaml", ".yml"):
|
|
419
|
+
data = yaml.safe_load(text)
|
|
420
|
+
elif path.suffix == ".json":
|
|
421
|
+
data = json.loads(text)
|
|
422
|
+
else:
|
|
423
|
+
# Try YAML first, fall back to JSON.
|
|
424
|
+
try:
|
|
425
|
+
data = yaml.safe_load(text)
|
|
426
|
+
except yaml.YAMLError:
|
|
427
|
+
data = json.loads(text)
|
|
428
|
+
return cls(data)
|
|
429
|
+
|
|
430
|
+
@classmethod
|
|
431
|
+
def from_dict(cls, data: dict) -> "Schema":
|
|
432
|
+
return cls(data)
|
|
433
|
+
|
|
434
|
+
# ── Validation ────────────────────────────
|
|
435
|
+
|
|
436
|
+
def validate(
|
|
437
|
+
self,
|
|
438
|
+
entity_type: str,
|
|
439
|
+
doc: dict,
|
|
440
|
+
*,
|
|
441
|
+
partial: bool = False,
|
|
442
|
+
) -> dict:
|
|
443
|
+
"""
|
|
444
|
+
Validate a document against its entity schema.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
entity_type: Key from schema.yaml entities.
|
|
448
|
+
doc: The document (dict) to validate.
|
|
449
|
+
partial: If True, skip required-field checks (for PATCH updates).
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Cleaned document with defaults applied and values coerced.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
SchemaError: If entity_type is unknown.
|
|
456
|
+
ValidationError: If any field fails validation.
|
|
457
|
+
"""
|
|
458
|
+
entity = self._get_entity(entity_type)
|
|
459
|
+
errors: dict[str, str] = {}
|
|
460
|
+
cleaned: dict[str, Any] = {}
|
|
461
|
+
|
|
462
|
+
for fname, fdef in entity.fields.items():
|
|
463
|
+
value = doc.get(fname)
|
|
464
|
+
|
|
465
|
+
# Handle missing fields.
|
|
466
|
+
if value is None:
|
|
467
|
+
if fdef.required and not partial:
|
|
468
|
+
errors[fname] = "required"
|
|
469
|
+
elif fdef.default is not None:
|
|
470
|
+
cleaned[fname] = fdef.default
|
|
471
|
+
# else: field simply absent — fine.
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
# Run the type validator.
|
|
475
|
+
validator = _VALIDATORS.get(fdef.type, _validate_string)
|
|
476
|
+
ok, result = validator(value, fdef.raw)
|
|
477
|
+
if ok:
|
|
478
|
+
cleaned[fname] = result
|
|
479
|
+
else:
|
|
480
|
+
errors[fname] = result
|
|
481
|
+
|
|
482
|
+
# Reject unknown fields (configurable: strict mode).
|
|
483
|
+
if self.globals.get("strict_fields", False):
|
|
484
|
+
extra = set(doc.keys()) - set(entity.fields.keys())
|
|
485
|
+
for key in extra:
|
|
486
|
+
errors[key] = "unknown field"
|
|
487
|
+
else:
|
|
488
|
+
# Permissive: pass through unknown fields untouched.
|
|
489
|
+
for key, val in doc.items():
|
|
490
|
+
if key not in entity.fields and key not in cleaned:
|
|
491
|
+
cleaned[key] = val
|
|
492
|
+
|
|
493
|
+
if errors:
|
|
494
|
+
raise ValidationError(errors)
|
|
495
|
+
|
|
496
|
+
return cleaned
|
|
497
|
+
|
|
498
|
+
# ── Relationship helpers ──────────────────
|
|
499
|
+
|
|
500
|
+
def get_relations_for(self, entity_type: str) -> list[dict]:
|
|
501
|
+
"""Forward relations: fields on this entity that point elsewhere."""
|
|
502
|
+
entity = self._get_entity(entity_type)
|
|
503
|
+
return [
|
|
504
|
+
{"field": f.name, "target": f.target, "many": f.many}
|
|
505
|
+
for f in entity.relation_fields
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
def get_reverse_relations_for(self, entity_type: str) -> list[dict]:
|
|
509
|
+
"""Reverse relations: other entities that point to this one."""
|
|
510
|
+
return self._reverse_relations.get(entity_type, [])
|
|
511
|
+
|
|
512
|
+
# ── Schema export ─────────────────────────
|
|
513
|
+
|
|
514
|
+
def entity_names(self) -> list[str]:
|
|
515
|
+
return list(self.entities.keys())
|
|
516
|
+
|
|
517
|
+
def get_entity(self, entity_type: str) -> EntityDef:
|
|
518
|
+
return self._get_entity(entity_type)
|
|
519
|
+
|
|
520
|
+
def to_dict(self) -> dict:
|
|
521
|
+
"""Full schema payload for GET /api/schema."""
|
|
522
|
+
return {
|
|
523
|
+
"meta": self.meta,
|
|
524
|
+
"globals": self.globals,
|
|
525
|
+
"entities": {
|
|
526
|
+
ename: edef.to_dict() for ename, edef in self.entities.items()
|
|
527
|
+
},
|
|
528
|
+
"relations": {
|
|
529
|
+
ename: {
|
|
530
|
+
"forward": self.get_relations_for(ename),
|
|
531
|
+
"reverse": self.get_reverse_relations_for(ename),
|
|
532
|
+
}
|
|
533
|
+
for ename in self.entities
|
|
534
|
+
},
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
# ── Internals ─────────────────────────────
|
|
538
|
+
|
|
539
|
+
def _get_entity(self, entity_type: str) -> EntityDef:
|
|
540
|
+
if entity_type not in self.entities:
|
|
541
|
+
raise SchemaError(
|
|
542
|
+
f"Unknown entity type '{entity_type}'. "
|
|
543
|
+
f"Available: {list(self.entities.keys())}"
|
|
544
|
+
)
|
|
545
|
+
return self.entities[entity_type]
|
|
546
|
+
|
|
547
|
+
def _validate_relations(self) -> None:
|
|
548
|
+
"""Ensure all relation targets reference existing entities."""
|
|
549
|
+
for ename, edef in self.entities.items():
|
|
550
|
+
for fdef in edef.relation_fields:
|
|
551
|
+
if fdef.target not in self.entities:
|
|
552
|
+
raise SchemaError(
|
|
553
|
+
f"Entity '{ename}', field '{fdef.name}': "
|
|
554
|
+
f"relation target '{fdef.target}' does not exist"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def _build_reverse_relations(self) -> None:
|
|
558
|
+
"""Pre-compute reverse relation map for fast lookups."""
|
|
559
|
+
self._reverse_relations: dict[str, list[dict]] = {
|
|
560
|
+
ename: [] for ename in self.entities
|
|
561
|
+
}
|
|
562
|
+
for ename, edef in self.entities.items():
|
|
563
|
+
for fdef in edef.relation_fields:
|
|
564
|
+
# TODO fix type warning
|
|
565
|
+
self._reverse_relations[fdef.target].append( # type: ignore
|
|
566
|
+
{
|
|
567
|
+
"entity": ename,
|
|
568
|
+
"field": fdef.name,
|
|
569
|
+
"many": fdef.many,
|
|
570
|
+
"related_name": fdef.related_name,
|
|
571
|
+
}
|
|
572
|
+
)
|
|
File without changes
|