caspian-utils 0.0.12__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.
casp/cache_handler.py ADDED
@@ -0,0 +1,180 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import re
5
+ from datetime import datetime
6
+ from typing import Optional, Union, List, Dict
7
+
8
+
9
+ class CacheHandler:
10
+ is_cacheable: Optional[bool] = None
11
+ ttl: int = 0
12
+
13
+ CACHE_DIR = os.path.join(os.getcwd(), 'caches')
14
+ MANIFEST_FILE = os.path.join(CACHE_DIR, 'cache_manifest.json')
15
+
16
+ @classmethod
17
+ def ensure_cache_dir(cls):
18
+ """Ensure cache dir and manifest file exist."""
19
+ if not os.path.exists(cls.CACHE_DIR):
20
+ os.makedirs(cls.CACHE_DIR, exist_ok=True)
21
+
22
+ if not os.path.exists(cls.MANIFEST_FILE):
23
+ cls._write_manifest({})
24
+
25
+ @classmethod
26
+ def _read_manifest(cls) -> Dict:
27
+ """Helper to read the single metadata registry."""
28
+ if not os.path.exists(cls.MANIFEST_FILE):
29
+ return {}
30
+ try:
31
+ with open(cls.MANIFEST_FILE, 'r', encoding='utf-8') as f:
32
+ return json.load(f)
33
+ except (json.JSONDecodeError, OSError):
34
+ return {}
35
+
36
+ @classmethod
37
+ def _write_manifest(cls, data: Dict):
38
+ """Helper to save the single metadata registry."""
39
+ try:
40
+ with open(cls.MANIFEST_FILE, 'w', encoding='utf-8') as f:
41
+ json.dump(data, f, indent=2)
42
+ except OSError as e:
43
+ print(f"[Cache] Error writing manifest: {e}")
44
+
45
+ @staticmethod
46
+ def get_filename(uri: str) -> str:
47
+ """Generate safe filename from URI."""
48
+ clean_uri = uri.split('?')[0]
49
+ if clean_uri == '/' or clean_uri == '':
50
+ filename = 'index'
51
+ else:
52
+ filename = re.sub(r'[^a-zA-Z0-9\-_]', '_', clean_uri).lower()
53
+ filename = filename.strip('_')
54
+ return f"{filename}.html"
55
+
56
+ @classmethod
57
+ def serve_cache(cls, uri: str, default_ttl: int = 600) -> Optional[str]:
58
+ if not uri:
59
+ return None
60
+
61
+ cls.ensure_cache_dir()
62
+ manifest = cls._read_manifest()
63
+
64
+ # 1. Lookup URI in the registry
65
+ if uri not in manifest:
66
+ return None
67
+
68
+ entry = manifest[uri]
69
+ html_file = os.path.join(cls.CACHE_DIR, entry['file'])
70
+
71
+ # 2. Check if the actual HTML file still exists
72
+ if not os.path.exists(html_file):
73
+ # Consistency check: Manifest has it, but file is gone. Clean up.
74
+ cls.invalidate_by_uri(uri)
75
+ return None
76
+
77
+ # 3. Check Expiration
78
+ saved_ttl = entry.get('ttl', default_ttl)
79
+ created_at = entry.get('created_at', 0)
80
+
81
+ # If saved_ttl is 0, fallback to default passed arg
82
+ active_ttl = saved_ttl if saved_ttl > 0 else default_ttl
83
+
84
+ age = time.time() - created_at
85
+
86
+ if age > active_ttl:
87
+ # Expired: Invalidate and return None so it regenerates
88
+ cls.invalidate_by_uri(uri)
89
+ return None
90
+
91
+ # 4. Serve Content
92
+ try:
93
+ with open(html_file, 'r', encoding='utf-8') as f:
94
+ content = f.read()
95
+
96
+ timestamp = datetime.fromtimestamp(
97
+ created_at).strftime('%Y-%m-%d %H:%M:%S')
98
+ # Append debug info (optional) so you know it came from cache
99
+ debug_info = f"\n"
100
+ return content + debug_info
101
+ except Exception:
102
+ return None
103
+
104
+ @classmethod
105
+ def save_cache(cls, uri: str, content: str, ttl: int = 0):
106
+ if not uri:
107
+ return
108
+
109
+ cls.ensure_cache_dir()
110
+ filename = cls.get_filename(uri)
111
+ html_path = os.path.join(cls.CACHE_DIR, filename)
112
+
113
+ # 1. Update Manifest (The Registry)
114
+ manifest = cls._read_manifest()
115
+
116
+ manifest[uri] = {
117
+ "file": filename,
118
+ "ttl": ttl,
119
+ "created_at": time.time()
120
+ }
121
+
122
+ cls._write_manifest(manifest)
123
+
124
+ # 2. Save Pure HTML Content
125
+ try:
126
+ with open(html_path, 'w', encoding='utf-8') as f:
127
+ f.write(content)
128
+ print(f"[Cache] Saved: {uri} (TTL: {ttl}s)")
129
+ except Exception as e:
130
+ print(f"[Cache] Error saving HTML: {e}")
131
+
132
+ @classmethod
133
+ def invalidate_by_uri(cls, uris: Union[str, List[str]]):
134
+ if isinstance(uris, str):
135
+ uris = [uris]
136
+
137
+ cls.ensure_cache_dir()
138
+ manifest = cls._read_manifest()
139
+ updated = False
140
+
141
+ for uri in uris:
142
+ normalized_uri = '/' + uri.lstrip('/')
143
+
144
+ if normalized_uri in manifest:
145
+ entry = manifest[normalized_uri]
146
+ file_path = os.path.join(cls.CACHE_DIR, entry['file'])
147
+
148
+ # Remove the HTML file
149
+ if os.path.exists(file_path):
150
+ try:
151
+ os.remove(file_path)
152
+ except OSError:
153
+ pass
154
+
155
+ # Remove from manifest
156
+ del manifest[normalized_uri]
157
+ updated = True
158
+ print(f"[Cache] Invalidated: {normalized_uri}")
159
+
160
+ if updated:
161
+ cls._write_manifest(manifest)
162
+
163
+ @classmethod
164
+ def reset_cache(cls, uri: Optional[str] = None):
165
+ if uri:
166
+ cls.invalidate_by_uri(uri)
167
+ return
168
+
169
+ # 1. Delete all files in directory
170
+ if os.path.exists(cls.CACHE_DIR):
171
+ for f in os.listdir(cls.CACHE_DIR):
172
+ file_path = os.path.join(cls.CACHE_DIR, f)
173
+ try:
174
+ os.remove(file_path)
175
+ except OSError:
176
+ pass
177
+
178
+ # 2. Re-create empty manifest
179
+ cls.ensure_cache_dir()
180
+ cls._write_manifest({})
casp/caspian_config.py ADDED
@@ -0,0 +1,441 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Mapping, Optional, Tuple
5
+ import json
6
+ import os
7
+
8
+ # ====
9
+ # Path & Root Logic (Fixed for PyPI Package)
10
+ # ====
11
+
12
+
13
+ def _get_project_root() -> Path:
14
+ """
15
+ Determine the project root.
16
+ 1. Checks CASPIAN_ROOT env var (allows explicit override).
17
+ 2. Falls back to Path.cwd() (the user's current working directory).
18
+ """
19
+ env_root = os.getenv("CASPIAN_ROOT")
20
+ if env_root:
21
+ return Path(env_root).resolve()
22
+
23
+ # This ensures we look in the folder where the user runs the command,
24
+ # rather than inside the installed site-packages folder.
25
+ return Path.cwd().resolve()
26
+
27
+
28
+ PROJECT_ROOT = _get_project_root()
29
+
30
+ # Define default paths relative to the user's project root
31
+ DEFAULT_CONFIG_PATH = (PROJECT_ROOT / "caspian.config.json").resolve()
32
+ DEFAULT_FILES_LIST_PATH = (
33
+ PROJECT_ROOT / "settings" / "files-list.json").resolve()
34
+
35
+
36
+ # ====
37
+ # Errors
38
+ # ====
39
+
40
+ class CaspianConfigError(ValueError):
41
+ """Raised when caspian.config.json is missing fields or has invalid types."""
42
+
43
+
44
+ class FilesListError(ValueError):
45
+ """Raised when settings/files-list.json is invalid or cannot be categorized."""
46
+
47
+
48
+ # ====
49
+ # Small validators
50
+ # ====
51
+
52
+ def _require(data: Mapping[str, Any], key: str) -> Any:
53
+ if key not in data:
54
+ raise CaspianConfigError(f"Missing required key: {key}")
55
+ return data[key]
56
+
57
+
58
+ def _expect_str(value: Any, key: str) -> str:
59
+ if not isinstance(value, str):
60
+ raise CaspianConfigError(
61
+ f"Expected '{key}' to be str, got {type(value).__name__}")
62
+ return value
63
+
64
+
65
+ def _expect_bool(value: Any, key: str) -> bool:
66
+ if not isinstance(value, bool):
67
+ raise CaspianConfigError(
68
+ f"Expected '{key}' to be bool, got {type(value).__name__}")
69
+ return value
70
+
71
+
72
+ def _expect_str_list(value: Any, key: str) -> List[str]:
73
+ if not isinstance(value, list) or any(not isinstance(x, str) for x in value):
74
+ raise CaspianConfigError(f"Expected '{key}' to be List[str]")
75
+ return value
76
+
77
+
78
+ def _expect_str_dict(value: Any, key: str) -> Dict[str, str]:
79
+ if not isinstance(value, dict):
80
+ raise CaspianConfigError(
81
+ f"Expected '{key}' to be Dict[str, str], got {type(value).__name__}")
82
+ for k, v in value.items():
83
+ if not isinstance(k, str) or not isinstance(v, str):
84
+ raise CaspianConfigError(
85
+ f"Expected '{key}' to be Dict[str, str] (found non-str key/value)")
86
+ return dict(value)
87
+
88
+
89
+ # ====
90
+ # caspian.config.json (type-safe)
91
+ # ====
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class CaspianConfig:
95
+ projectName: str
96
+ projectRootPath: Path
97
+ bsTarget: str
98
+ bsPathRewrite: Dict[str, str]
99
+
100
+ backendOnly: bool
101
+ tailwindcss: bool
102
+ mcp: bool
103
+ prisma: bool
104
+ typescript: bool
105
+
106
+ version: str
107
+ componentScanDirs: List[str]
108
+ excludeFiles: List[str]
109
+
110
+ @staticmethod
111
+ def from_dict(data: Mapping[str, Any]) -> "CaspianConfig":
112
+ projectName = _expect_str(_require(data, "projectName"), "projectName")
113
+ projectRootPath_raw = _expect_str(
114
+ _require(data, "projectRootPath"), "projectRootPath")
115
+ bsTarget = _expect_str(_require(data, "bsTarget"), "bsTarget")
116
+ bsPathRewrite = _expect_str_dict(
117
+ _require(data, "bsPathRewrite"), "bsPathRewrite")
118
+
119
+ backendOnly = _expect_bool(
120
+ _require(data, "backendOnly"), "backendOnly")
121
+ tailwindcss = _expect_bool(
122
+ _require(data, "tailwindcss"), "tailwindcss")
123
+ mcp = _expect_bool(_require(data, "mcp"), "mcp")
124
+ prisma = _expect_bool(_require(data, "prisma"), "prisma")
125
+ typescript = _expect_bool(_require(data, "typescript"), "typescript")
126
+
127
+ version = _expect_str(_require(data, "version"), "version")
128
+ componentScanDirs = _expect_str_list(
129
+ _require(data, "componentScanDirs"), "componentScanDirs")
130
+ excludeFiles = _expect_str_list(
131
+ _require(data, "excludeFiles"), "excludeFiles")
132
+
133
+ return CaspianConfig(
134
+ projectName=projectName,
135
+ projectRootPath=Path(projectRootPath_raw),
136
+ bsTarget=bsTarget,
137
+ bsPathRewrite=bsPathRewrite,
138
+ backendOnly=backendOnly,
139
+ tailwindcss=tailwindcss,
140
+ mcp=mcp,
141
+ prisma=prisma,
142
+ typescript=typescript,
143
+ version=version,
144
+ componentScanDirs=componentScanDirs,
145
+ excludeFiles=excludeFiles,
146
+ )
147
+
148
+ @staticmethod
149
+ def from_json(text: str) -> "CaspianConfig":
150
+ try:
151
+ data = json.loads(text)
152
+ except json.JSONDecodeError as e:
153
+ raise CaspianConfigError(f"Invalid JSON: {e}") from e
154
+ if not isinstance(data, dict):
155
+ raise CaspianConfigError("Top-level JSON must be an object")
156
+ return CaspianConfig.from_dict(data)
157
+
158
+ @staticmethod
159
+ def from_file(path: str | Path) -> "CaspianConfig":
160
+ p = Path(path)
161
+ if not p.exists():
162
+ raise CaspianConfigError(f"Config file not found: {p}")
163
+ return CaspianConfig.from_json(p.read_text(encoding="utf-8"))
164
+
165
+ def to_dict(self) -> Dict[str, Any]:
166
+ return {
167
+ "projectName": self.projectName,
168
+ "projectRootPath": str(self.projectRootPath),
169
+ "bsTarget": self.bsTarget,
170
+ "bsPathRewrite": dict(self.bsPathRewrite),
171
+ "backendOnly": self.backendOnly,
172
+ "tailwindcss": self.tailwindcss,
173
+ "mcp": self.mcp,
174
+ "prisma": self.prisma,
175
+ "typescript": self.typescript,
176
+ "version": self.version,
177
+ "componentScanDirs": list(self.componentScanDirs),
178
+ "excludeFiles": list(self.excludeFiles),
179
+ }
180
+
181
+ def to_json(self, *, indent: int = 2) -> str:
182
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
183
+
184
+ def save(self, path: str | Path, *, indent: int = 2) -> None:
185
+ Path(path).write_text(self.to_json(indent=indent), encoding="utf-8")
186
+
187
+
188
+ def load_config(path: str | Path | None = None) -> CaspianConfig:
189
+ """
190
+ Load and validate ./caspian.config.json.
191
+ Defaults to the file in the current working directory if no path is provided.
192
+ """
193
+ if path is None:
194
+ path = DEFAULT_CONFIG_PATH
195
+ return CaspianConfig.from_file(path)
196
+
197
+
198
+ # ====
199
+ # settings/files-list.json (type-safe + categorized)
200
+ # ====
201
+
202
+ APP_ROOT_PREFIX = "./src/app/"
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class RouteEntry:
207
+ """
208
+ Derived from ./src/app/**/index.(py|html)
209
+ - fs_dir: filesystem-relative dir inside src/app (keeps groups like (auth))
210
+ - url_path: URL path (removes groups like (auth))
211
+ - fastapi_rule: URL path converted to a FastAPI-friendly rule ({id}, {tags:path})
212
+ - has_py/has_html: whether index.py / index.html exists for that route
213
+ """
214
+ fs_dir: str
215
+ url_path: str
216
+ fastapi_rule: str
217
+ has_py: bool
218
+ has_html: bool
219
+
220
+
221
+ @dataclass(frozen=True, slots=True)
222
+ class LayoutEntry:
223
+ """Derived from ./src/app/**/layout.html"""
224
+ fs_dir: str
225
+ url_scope: str
226
+ file: str
227
+
228
+
229
+ @dataclass(frozen=True, slots=True)
230
+ class LoadingEntry:
231
+ """Derived from ./src/app/**/loading.html"""
232
+ fs_dir: str
233
+ url_scope: str
234
+ file: str
235
+
236
+
237
+ @dataclass(frozen=True, slots=True)
238
+ class FilesIndex:
239
+ """
240
+ Categorization rules:
241
+ - routing: only index.py + index.html under ./src/app
242
+ - layout: layout.html under ./src/app
243
+ - loadings: loading.html under ./src/app
244
+ - general_app: everything else under ./src/app
245
+ - general_other: everything outside ./src/app
246
+ """
247
+ routes: List[RouteEntry]
248
+ layouts: List[LayoutEntry]
249
+ loadings: List[LoadingEntry]
250
+ general_app: List[str]
251
+ general_other: List[str]
252
+
253
+
254
+ def _is_ignored_build_artifact(p: str) -> bool:
255
+ return "/__pycache__/" in p or p.endswith(".pyc")
256
+
257
+
258
+ def _strip_app_prefix(p: str) -> Optional[str]:
259
+ if p.startswith(APP_ROOT_PREFIX):
260
+ return p[len(APP_ROOT_PREFIX):]
261
+ return None
262
+
263
+
264
+ def _split_dir_and_file(app_rel: str) -> Tuple[str, str]:
265
+ parts = app_rel.split("/")
266
+ if len(parts) == 1:
267
+ return "", parts[0]
268
+ return "/".join(parts[:-1]), parts[-1]
269
+
270
+
271
+ def _is_group_segment(seg: str) -> bool:
272
+ return seg.startswith("(") and seg.endswith(")")
273
+
274
+
275
+ def _to_url_path(fs_dir: str) -> str:
276
+ if not fs_dir:
277
+ return "/"
278
+ segs = [s for s in fs_dir.split("/") if s and not _is_group_segment(s)]
279
+ return "/" + "/".join(segs) if segs else "/"
280
+
281
+
282
+ def _to_fastapi_rule(url_path: str) -> str:
283
+ """Convert URL path to FastAPI format: [id] -> {id}, [...tags] -> {tags:path}"""
284
+ if url_path == "/":
285
+ return "/"
286
+ out: List[str] = []
287
+ for seg in url_path.strip("/").split("/"):
288
+ if seg.startswith("[...") and seg.endswith("]") and len(seg) > 5:
289
+ name = seg[4:-1].strip() or "path"
290
+ out.append(f"{{{name}:path}}")
291
+ elif seg.startswith("[") and seg.endswith("]") and len(seg) > 2:
292
+ name = seg[1:-1].strip() or "param"
293
+ out.append(f"{{{name}}}")
294
+ else:
295
+ out.append(seg)
296
+ return "/" + "/".join(out)
297
+
298
+
299
+ def load_files_list(path: str | Path | None = None) -> List[str]:
300
+ """Load settings/files-list.json (expects a JSON array of strings)."""
301
+ if path is None:
302
+ path = DEFAULT_FILES_LIST_PATH
303
+
304
+ p = Path(path)
305
+ if not p.exists():
306
+ raise FilesListError(f"Files list not found: {p}")
307
+
308
+ try:
309
+ raw = json.loads(p.read_text(encoding="utf-8"))
310
+ except json.JSONDecodeError as e:
311
+ raise FilesListError(f"Invalid JSON in files-list.json: {e}") from e
312
+
313
+ if not isinstance(raw, list) or any(not isinstance(x, str) for x in raw):
314
+ raise FilesListError("files-list.json must be a JSON array of strings")
315
+
316
+ return raw
317
+
318
+
319
+ def build_files_index(file_paths: List[str]) -> FilesIndex:
320
+ routes_map: Dict[str, Dict[str, bool]] = {}
321
+ layouts: List[LayoutEntry] = []
322
+ loadings: List[LoadingEntry] = []
323
+ general_app: List[str] = []
324
+ general_other: List[str] = []
325
+
326
+ for p in file_paths:
327
+ if _is_ignored_build_artifact(p):
328
+ continue
329
+
330
+ app_rel = _strip_app_prefix(p)
331
+ if app_rel is None:
332
+ general_other.append(p)
333
+ continue
334
+
335
+ fs_dir, filename = _split_dir_and_file(app_rel)
336
+
337
+ if filename == "index.py" or filename == "index.html":
338
+ bucket = routes_map.setdefault(
339
+ fs_dir, {"py": False, "html": False})
340
+ if filename == "index.py":
341
+ bucket["py"] = True
342
+ else:
343
+ bucket["html"] = True
344
+ continue
345
+
346
+ if filename == "layout.html":
347
+ url_scope = _to_url_path(fs_dir)
348
+ layouts.append(LayoutEntry(
349
+ fs_dir=fs_dir, url_scope=url_scope, file=p))
350
+ continue
351
+
352
+ if filename == "loading.html":
353
+ url_scope = _to_url_path(fs_dir)
354
+ loadings.append(LoadingEntry(
355
+ fs_dir=fs_dir, url_scope=url_scope, file=p))
356
+ continue
357
+
358
+ general_app.append(p)
359
+
360
+ routes: List[RouteEntry] = []
361
+ for fs_dir, flags in sorted(routes_map.items(), key=lambda kv: kv[0]):
362
+ url_path = _to_url_path(fs_dir)
363
+ routes.append(
364
+ RouteEntry(
365
+ fs_dir=fs_dir,
366
+ url_path=url_path,
367
+ fastapi_rule=_to_fastapi_rule(url_path),
368
+ has_py=bool(flags.get("py")),
369
+ has_html=bool(flags.get("html")),
370
+ )
371
+ )
372
+
373
+ layouts.sort(key=lambda x: x.fs_dir)
374
+ loadings.sort(key=lambda x: x.fs_dir)
375
+ general_app.sort()
376
+ general_other.sort()
377
+
378
+ return FilesIndex(
379
+ routes=routes,
380
+ layouts=layouts,
381
+ loadings=loadings,
382
+ general_app=general_app,
383
+ general_other=general_other,
384
+ )
385
+
386
+
387
+ def load_files_index(path: str | Path | None = None) -> FilesIndex:
388
+ """Convenience: load + categorize ./settings/files-list.json."""
389
+ return build_files_index(load_files_list(path))
390
+
391
+
392
+ _cached_config: CaspianConfig | None = None
393
+ _cached_files_index: FilesIndex | None = None
394
+
395
+
396
+ def get_config() -> CaspianConfig:
397
+ """Cached single source of truth for caspian.config.json"""
398
+ global _cached_config
399
+ if _cached_config is None:
400
+ _cached_config = load_config()
401
+ return _cached_config
402
+
403
+
404
+ def get_files_index() -> FilesIndex:
405
+ """Cached single source of truth for files-list.json"""
406
+ global _cached_files_index
407
+ if _cached_files_index is None:
408
+ _cached_files_index = load_files_index()
409
+ return _cached_files_index
410
+
411
+
412
+ if __name__ == "__main__":
413
+ try:
414
+ cfg = load_config()
415
+ idx = load_files_index()
416
+
417
+ print("Project:", cfg.projectName)
418
+ print("Config root:", cfg.projectRootPath)
419
+
420
+ print("\nRoutes:")
421
+ for r in idx.routes:
422
+ print(
423
+ f" - fs_dir='{r.fs_dir or '.'}' url='{r.url_path}' fastapi='{r.fastapi_rule}' "
424
+ f"(py={r.has_py}, html={r.has_html})"
425
+ )
426
+
427
+ print("\nLayouts:")
428
+ for l in idx.layouts:
429
+ print(
430
+ f" - scope='{l.url_scope}' fs_dir='{l.fs_dir or '.'}' file='{l.file}'")
431
+
432
+ print("\nLoadings:")
433
+ for l in idx.loadings:
434
+ print(
435
+ f" - scope='{l.url_scope}' fs_dir='{l.fs_dir or '.'}' file='{l.file}'")
436
+
437
+ print(f"\nGeneral (src/app): {len(idx.general_app)} files")
438
+ print(f"General (outside src/app): {len(idx.general_other)} files")
439
+
440
+ except Exception as e:
441
+ print(f"Error running configuration test: {e}")