monosite 0.0.1__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.
monosite/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # 版本号将由 hatch-vcs 在构建时动态写入
2
+ # 开发时如果是 editable install,可能需要手动指定或依赖 setuptools_scm 的机制
3
+ try:
4
+ from ._version import __version__
5
+ except ImportError:
6
+ # Fallback for direct execution or when _version.py doesn't exist yet
7
+ __version__ = "0.0.0+unknown"
monosite/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.1'
32
+ __version_tuple__ = version_tuple = (0, 0, 1)
33
+
34
+ __commit_id__ = commit_id = None
monosite/cli/main.py ADDED
@@ -0,0 +1,12 @@
1
+ import typer
2
+
3
+ app = typer.Typer()
4
+
5
+ @app.command()
6
+ def init(name: str = typer.Argument(..., help="Name of the new project")):
7
+ """Initialize a new Monosite project."""
8
+ print(f"Initializing new project: {name}")
9
+
10
+ def main():
11
+ """Main entry point for the Monosite CLI."""
12
+ app()
@@ -0,0 +1,26 @@
1
+ import os
2
+
3
+ from fastapi import FastAPI
4
+
5
+ from .static_files import SPAStaticFiles
6
+
7
+
8
+ def mount_spa(app: FastAPI, build_dir: str, api_prefix: str = "api/"):
9
+ """
10
+ Mounts a Single Page Application (SPA) to the FastAPI app using ASGI StaticFiles.
11
+
12
+ Args:
13
+ app: The FastAPI application instance.
14
+ build_dir: The directory containing the built frontend files (dist).
15
+ api_prefix: The prefix for API routes to exclude from SPA fallback.
16
+ """
17
+ # Check if build_dir exists to avoid startup errors
18
+ if not os.path.isdir(build_dir):
19
+ print(
20
+ f"Warning: SPA build directory '{build_dir}' does not exist. SPA will not be served."
21
+ )
22
+ return
23
+
24
+ app.mount(
25
+ "/", SPAStaticFiles(directory=build_dir, api_prefix=api_prefix), name="spa"
26
+ )
@@ -0,0 +1,8 @@
1
+ import re
2
+
3
+ # Compiled Regex Patterns
4
+ ROOT_PATH_REGEX = re.compile(r"^/$")
5
+ # Vite Hashed File: name-hash.ext (8 chars of alphanumeric + '-' + '_')
6
+ HASHED_FILE_REGEX = re.compile(r"-[a-zA-Z0-9_-]{8}\.[a-zA-Z0-9]+$")
7
+ # Route Param Regex: :name(regex)?
8
+ ROUTE_PARAM_REGEX = re.compile(r"(:[a-zA-Z0-9_]+)(\(.*?\))?(\?)?")
@@ -0,0 +1,245 @@
1
+ import json
2
+ import mimetypes
3
+ import os
4
+ import re
5
+ from re import Pattern
6
+
7
+ from starlette.exceptions import HTTPException as StarletteHTTPException
8
+ from starlette.responses import Response
9
+ from starlette.staticfiles import StaticFiles
10
+ from starlette.types import Receive, Scope, Send
11
+
12
+ from .constants import HASHED_FILE_REGEX, ROOT_PATH_REGEX, ROUTE_PARAM_REGEX
13
+
14
+
15
+ class SPAStaticFiles(StaticFiles):
16
+ def __init__(self, directory: str, api_prefix: str = "api/"):
17
+ self.api_prefix = api_prefix.strip("/")
18
+ self.build_dir = directory
19
+
20
+ # Split routes into static (O(1) lookup) and dynamic (Regex)
21
+ self.static_routes: set[str] = set()
22
+ self.dynamic_routes: list[Pattern] = []
23
+
24
+ # Preloaded responses
25
+ self.index_response: Response | None = None
26
+ self.not_found_response: Response | None = None
27
+ self.file_cache: dict[str, Response] = {}
28
+
29
+ self.load_manifest()
30
+ self.preload_all_assets()
31
+ super().__init__(directory=directory, html=True)
32
+
33
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
34
+ await super().__call__(scope, receive, send)
35
+
36
+ def load_manifest(self):
37
+ manifest_path = os.path.join(self.build_dir, "spa-manifest.json")
38
+ if os.path.exists(manifest_path):
39
+ try:
40
+ with open(manifest_path, encoding="utf-8") as f:
41
+ data = json.load(f)
42
+ routes = data.get("routes", [])
43
+
44
+ for route in routes:
45
+ if self._is_static_route(route):
46
+ # Normalize: ensure leading slash, no trailing slash (unless root)
47
+ normalized = "/" + route.strip("/")
48
+ if normalized == "//":
49
+ normalized = "/"
50
+ self.static_routes.add(normalized)
51
+ else:
52
+ self.dynamic_routes.append(self._route_to_regex(route))
53
+
54
+ print(
55
+ f"Loaded {len(self.static_routes)} static routes and {len(self.dynamic_routes)} dynamic routes from manifest."
56
+ )
57
+ except Exception as e:
58
+ print(f"Error loading SPA manifest: {e}")
59
+ else:
60
+ print(
61
+ f"SPA manifest not found at {manifest_path}. SPA fallback validation disabled."
62
+ )
63
+
64
+ def preload_all_assets(self):
65
+ """Preload all static files into memory for extreme performance."""
66
+ print(f"Preloading all assets from {self.build_dir}...")
67
+ count = 0
68
+ total_size = 0
69
+
70
+ for root, _, files in os.walk(self.build_dir):
71
+ for filename in files:
72
+ file_path = os.path.join(root, filename)
73
+ rel_path = os.path.relpath(file_path, self.build_dir)
74
+ # Normalize to URL path (forward slashes)
75
+ url_path = rel_path.replace("\\", "/")
76
+
77
+ try:
78
+ with open(file_path, "rb") as f:
79
+ content = f.read()
80
+ media_type, _ = mimetypes.guess_type(file_path)
81
+ response = Response(
82
+ content=content,
83
+ media_type=media_type or "application/octet-stream",
84
+ )
85
+
86
+ # Apply aggressive caching for hashed files (Vite default: name-hash.ext)
87
+ # Hash is typically 8 chars of alphanumeric + '-' + '_'
88
+ if HASHED_FILE_REGEX.search(filename):
89
+ response.headers["Cache-Control"] = (
90
+ "public, max-age=31536000, immutable"
91
+ )
92
+
93
+ self.file_cache[url_path] = response
94
+
95
+ # Set special responses
96
+ if url_path == "index.html":
97
+ self.index_response = Response(
98
+ content=content, media_type="text/html"
99
+ )
100
+ elif url_path == "404.html":
101
+ self.not_found_response = Response(
102
+ content=content, status_code=404, media_type="text/html"
103
+ )
104
+
105
+ count += 1
106
+ total_size += len(content)
107
+ except Exception as e:
108
+ print(f"Error caching {file_path}: {e}")
109
+
110
+ print(f"Cached {count} files ({total_size / 1024 / 1024:.2f} MB) in memory.")
111
+
112
+ def _is_static_route(self, route: str) -> bool:
113
+ """Check if a route contains any dynamic parameters."""
114
+ return ":" not in route and "*" not in route and "(" not in route
115
+
116
+ def _route_to_regex(self, route: str) -> Pattern:
117
+ """
118
+ Convert Vue Router path to Regex Pattern.
119
+ """
120
+ if route == "/":
121
+ return ROOT_PATH_REGEX
122
+
123
+ parts = route.split("/")
124
+ pattern = "^"
125
+
126
+ for part in parts:
127
+ if not part:
128
+ continue
129
+
130
+ # Regex to identify params:
131
+ # Group 1: :name
132
+ # Group 2: (custom_regex) - optional
133
+ # Group 3: ? - optional
134
+
135
+ segment_pattern = ""
136
+ last_idx = 0
137
+ is_optional_segment = False
138
+
139
+ matches = list(ROUTE_PARAM_REGEX.finditer(part))
140
+
141
+ if not matches:
142
+ # Static segment
143
+ segment_pattern = re.escape(part)
144
+ else:
145
+ for match in matches:
146
+ # Append static text before match
147
+ static_text = part[last_idx : match.start()]
148
+ segment_pattern += re.escape(static_text)
149
+
150
+ # Handle Param
151
+ custom_regex = match.group(2)
152
+ is_optional = bool(match.group(3))
153
+
154
+ if custom_regex:
155
+ # Strip parens to get the regex content: :slug(.*) -> (.*) -> .*
156
+ inner_regex = custom_regex[1:-1]
157
+ segment_pattern += inner_regex
158
+ else:
159
+ segment_pattern += r"[^/]+"
160
+
161
+ # Logic for optional segment:
162
+ # If the match covers the ENTIRE part and is optional, the whole segment (including slash) is optional
163
+ if is_optional and len(part) == len(match.group(0)):
164
+ is_optional_segment = True
165
+
166
+ last_idx = match.end()
167
+
168
+ # Append remaining static text
169
+ segment_pattern += re.escape(part[last_idx:])
170
+
171
+ # Append to main pattern
172
+ if is_optional_segment:
173
+ pattern += f"(?:/{segment_pattern})?"
174
+ else:
175
+ pattern += f"/{segment_pattern}"
176
+
177
+ pattern += "$"
178
+ return re.compile(pattern)
179
+
180
+ def is_valid_route(self, path: str) -> bool:
181
+ # If no routes loaded (no manifest), allow all (backward compatibility)
182
+ if not self.static_routes and not self.dynamic_routes:
183
+ return True
184
+
185
+ # Ensure path starts with /
186
+ check_path = path if path.startswith("/") else "/" + path
187
+
188
+ # 1. Fast O(1) Lookup
189
+ if check_path in self.static_routes:
190
+ return True
191
+
192
+ # 2. Regex Lookup
193
+ for pattern in self.dynamic_routes:
194
+ if pattern.match(check_path):
195
+ return True
196
+ return False
197
+
198
+ async def get_response(self, path: str, scope: Scope):
199
+ # Normalize path separators for Windows
200
+ lookup_path = path.replace("\\", "/")
201
+
202
+ # 0. Check memory cache
203
+ if lookup_path in self.file_cache:
204
+ return self.file_cache[lookup_path]
205
+
206
+ # Check for index.html in directory (simulating StaticFiles behavior)
207
+ potential_index = f"{lookup_path}/index.html".lstrip("/")
208
+ if potential_index in self.file_cache:
209
+ return self.file_cache[potential_index]
210
+
211
+ try:
212
+ # 1. Try to serve static file directly
213
+ response = await super().get_response(path, scope)
214
+ if response.status_code == 404:
215
+ raise StarletteHTTPException(status_code=404)
216
+ return response
217
+ except (StarletteHTTPException, OSError) as e:
218
+ # Handle 404s and OSErrors (e.g., invalid filenames on Windows like 'test:case')
219
+ is_404 = isinstance(e, StarletteHTTPException) and e.status_code == 404
220
+ is_os_error = isinstance(e, OSError)
221
+
222
+ if not (is_404 or is_os_error):
223
+ raise e
224
+
225
+ # 2. If not found or invalid file path, check if it is an API route
226
+ # API routes should strictly fail if not handled by FastAPI router
227
+ if path.startswith(self.api_prefix) or path.startswith(
228
+ f"{self.api_prefix}/"
229
+ ):
230
+ raise e
231
+
232
+ # 3. Validate against SPA routes (Strict Mode)
233
+ # Normalize path separators for Windows and construct request path
234
+ normalized_path = path.replace("\\", "/")
235
+ request_path = "/" + normalized_path.lstrip("/")
236
+
237
+ if self.is_valid_route(request_path):
238
+ if self.index_response:
239
+ return self.index_response
240
+
241
+ # 4. If not a valid route, return 404.html if exists, else propagate exception
242
+ if self.not_found_response:
243
+ return self.not_found_response
244
+
245
+ raise e
File without changes
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: monosite
3
+ Version: 0.0.1
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: typer>=0.21.1
@@ -0,0 +1,11 @@
1
+ monosite/__init__.py,sha256=juCIBS1v_J1sJufJn9nbNRSDpbDIG0bt6PZAhFsLaJw,323
2
+ monosite/_version.py,sha256=qf6R-J7-UyuABBo8c0HgaquJ8bejVbf07HodXgwAwgQ,704
3
+ monosite/cli/main.py,sha256=Nq7DDTeqF2YzmJLqIUalYm1RdjCVK_fgtolw2Lpp57Q,288
4
+ monosite/core/spa/__init__.py,sha256=x-woFOS-SU9upW3B-M6qhzjIz_pBhRbh8ZwjdRHW9Xc,804
5
+ monosite/core/spa/constants.py,sha256=Ld2BVxbAPUFfX0GF-VjnTv8uvvoBP0_BWAfIr70f5jg,316
6
+ monosite/core/spa/static_files.py,sha256=db7bWXMRqkM3HZViK4YVptbH10oaq6dOh3REu8vHjd8,9566
7
+ monosite/template/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ monosite-0.0.1.dist-info/METADATA,sha256=a3N3jNKAvDQPaMP1KsjxJ2rjPG9gJKyo-FU5zBZYToA,140
9
+ monosite-0.0.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ monosite-0.0.1.dist-info/entry_points.txt,sha256=Z02DbkMhcqNCMLWl0Vr7m877IH13rq4exu17_8l53Oo,52
11
+ monosite-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ monosite = monosite.cli.main:main