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/__init__.py +0 -0
- casp/auth.py +537 -0
- casp/cache_handler.py +180 -0
- casp/caspian_config.py +441 -0
- casp/component_decorator.py +183 -0
- casp/components_compiler.py +293 -0
- casp/html_attrs.py +93 -0
- casp/layout.py +474 -0
- casp/loading.py +25 -0
- casp/rpc.py +230 -0
- casp/scripts_type.py +21 -0
- casp/state_manager.py +134 -0
- casp/string_helpers.py +18 -0
- casp/tw.py +31 -0
- casp/validate.py +747 -0
- caspian_utils-0.0.12.dist-info/METADATA +214 -0
- caspian_utils-0.0.12.dist-info/RECORD +19 -0
- caspian_utils-0.0.12.dist-info/WHEEL +5 -0
- caspian_utils-0.0.12.dist-info/top_level.txt +1 -0
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}")
|