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 ADDED
File without changes
homebase/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from homebase.cli import main
2
+
3
+ main()
@@ -0,0 +1 @@
1
+ from homebase.cli.cli import main as main
homebase/cli/cli.py ADDED
@@ -0,0 +1,2 @@
1
+ def main():
2
+ print("Hello from homebase!")
File without changes
@@ -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])
@@ -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