From c5e80956af55501cfe8ef600d91d750bd98dc1d9 Mon Sep 17 00:00:00 2001 From: Sean N Date: Sun, 17 May 2026 04:18:03 +0200 Subject: [PATCH 1/2] Fixed numerous rendering issues and locks --- src/nitro/commands/build.py | 7 +- src/nitro/commands/check.py | 7 +- src/nitro/commands/routes.py | 7 +- src/nitro/commands/serve.py | 35 ++-- src/nitro/core/bundler.py | 23 +-- src/nitro/core/generator.py | 110 +++-------- src/nitro/core/images.py | 40 ++-- src/nitro/core/islands.py | 3 +- src/nitro/core/renderer.py | 344 +++++++++++++++-------------------- src/nitro/core/watcher.py | 25 +-- tests/test_bundler.py | 9 +- 11 files changed, 256 insertions(+), 354 deletions(-) diff --git a/src/nitro/commands/build.py b/src/nitro/commands/build.py index 577a8ef..992922f 100644 --- a/src/nitro/commands/build.py +++ b/src/nitro/commands/build.py @@ -7,7 +7,6 @@ import click from ..core.bundler import Bundler -from ..core.config import load_config from ..core.generator import Generator from ..core.images import ImageOptimizer, ImageConfig from ..core.islands import IslandProcessor, IslandConfig @@ -90,7 +89,7 @@ def build( verbose(f"Output directory: {generator.build_dir}") - config = load_config(generator.project_root / "nitro.config.py") + config = generator.config if minify: config.renderer["minify_html"] = True generator.renderer.minify_html = True @@ -190,13 +189,11 @@ def build( update("Generating metadata...") html_files = list(generator.build_dir.rglob("*.html")) sitemap_path = generator.build_dir / "sitemap.xml" - # Pass page metadata for enhanced sitemap generation - page_metadata = getattr(generator, "page_metadata", None) bundler.generate_sitemap( base_url=config.base_url, html_files=html_files, output_path=sitemap_path, - page_metadata=page_metadata, + page_metadata=generator.page_metadata, clean_urls=config.clean_urls, ) verbose(f"Created sitemap.xml with {len(html_files)} URLs") diff --git a/src/nitro/commands/check.py b/src/nitro/commands/check.py index 96846d6..9c631bd 100644 --- a/src/nitro/commands/check.py +++ b/src/nitro/commands/check.py @@ -7,7 +7,7 @@ from typing import List, Tuple from ..core.page import get_project_root -from ..core.config import load_config +from ..core.config import Config, load_config from ..core.renderer import Renderer from ..utils import console, success, error, info, warning @@ -52,10 +52,9 @@ def check(verbose, links): error("Not in a Nitro project directory") sys.exit(1) - # Load config config_path = project_root / "nitro.config.py" - config = load_config(config_path) if config_path.exists() else None - source_dir = project_root / (config.source_dir if config else "src") + config = load_config(config_path) if config_path.exists() else Config() + source_dir = project_root / config.source_dir pages_dir = source_dir / "pages" if not pages_dir.exists(): diff --git a/src/nitro/commands/routes.py b/src/nitro/commands/routes.py index 88a12b8..0a04d95 100644 --- a/src/nitro/commands/routes.py +++ b/src/nitro/commands/routes.py @@ -6,7 +6,7 @@ from pathlib import Path from ..core.page import get_project_root -from ..core.config import load_config +from ..core.config import Config, load_config from ..core.renderer import Renderer from ..utils import console, error @@ -21,10 +21,9 @@ def routes(as_json): error("Not in a Nitro project directory") sys.exit(1) - # Load config config_path = project_root / "nitro.config.py" - config = load_config(config_path) if config_path.exists() else None - source_dir = project_root / (config.source_dir if config else "src") + config = load_config(config_path) if config_path.exists() else Config() + source_dir = project_root / config.source_dir pages_dir = source_dir / "pages" if not pages_dir.exists(): diff --git a/src/nitro/commands/serve.py b/src/nitro/commands/serve.py index 3dae332..9df6dc8 100644 --- a/src/nitro/commands/serve.py +++ b/src/nitro/commands/serve.py @@ -99,6 +99,13 @@ async def serve_async( loop = asyncio.get_running_loop() regeneration_lock = asyncio.Lock() + def _is_within(child: Path, parent: Path) -> bool: + try: + child.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False + async def on_file_change(path: Path) -> None: nonlocal generator @@ -110,40 +117,42 @@ async def on_file_change(path: Path) -> None: hmr_update(relative_path) - # Drop cached src/ modules on any .py change so edits to - # shared components/utils are picked up without restarting. if path.suffix == ".py": generator.renderer.invalidate_all_src_modules( generator.project_root ) + pages_dir = generator.source_dir / "pages" + components_dir = generator.source_dir / "components" + styles_dir = generator.source_dir / "styles" + public_dir = generator.source_dir / "public" + should_notify = False - # Run blocking generator operations in thread pool - if "pages" in str(path): + if path.name == "nitro.config.py": + hmr_update("config", "rebuilding...") + generator = Generator() + if await asyncio.to_thread( + generator.generate, verbose=False, quiet=True + ): + should_notify = True + elif _is_within(path, pages_dir): if path.suffix == ".py" and path.name != "__init__.py": hmr_update("page", "rebuilding...") if await asyncio.to_thread( generator.regenerate_page, path, verbose=False ): should_notify = True - elif "components" in str(path): + elif _is_within(path, components_dir): hmr_update("site", "rebuilding...") if await asyncio.to_thread( generator.generate, verbose=False, quiet=True ): should_notify = True - elif "styles" in str(path) or "public" in str(path): + elif _is_within(path, styles_dir) or _is_within(path, public_dir): hmr_update("assets", "rebuilding...") await asyncio.to_thread(generator._copy_assets, verbose=False) should_notify = True - elif path.name == "nitro.config.py": - hmr_update("config", "rebuilding...") - generator = Generator() - if await asyncio.to_thread( - generator.generate, verbose=False, quiet=True - ): - should_notify = True else: hmr_update("site", "rebuilding...") if await asyncio.to_thread( diff --git a/src/nitro/core/bundler.py b/src/nitro/core/bundler.py index 5138320..9675e7c 100644 --- a/src/nitro/core/bundler.py +++ b/src/nitro/core/bundler.py @@ -126,14 +126,11 @@ def generate_sitemap( rel_path = html_file.relative_to(self.build_dir) rel_path_str = str(rel_path).replace("\\", "/") - # Get metadata for this page meta = page_metadata.get(rel_path_str, {}) - # Check if page should be excluded from sitemap if meta.get("sitemap") is False: continue - # Skip draft pages if meta.get("draft"): continue @@ -147,7 +144,6 @@ def generate_sitemap( full_url = f"{base_url.rstrip('/')}/{url_path}" - # Determine lastmod from metadata or file mtime if meta.get("lastmod"): lastmod = meta["lastmod"] elif meta.get("published"): @@ -156,13 +152,11 @@ def generate_sitemap( mtime = datetime.fromtimestamp(html_file.stat().st_mtime) lastmod = mtime.strftime("%Y-%m-%d") - # Determine priority from metadata or defaults if meta.get("sitemap_priority"): priority = str(meta["sitemap_priority"]) else: priority = "1.0" if url_path == "" else "0.8" - # Determine changefreq from metadata or default changefreq = meta.get("sitemap_changefreq", "weekly") urls.append( @@ -209,6 +203,8 @@ def generate_robots_txt(self, base_url: str, output_path: Path) -> None: except (IOError, OSError) as e: error(f"Failed to write robots.txt: {e}") + FINGERPRINT_LEN = 12 + def create_asset_manifest(self, output_path: Path) -> None: """Create asset manifest with file hashes.""" manifest = {} @@ -217,7 +213,7 @@ def create_asset_manifest(self, output_path: Path) -> None: if file_path.is_file(): hasher = hashlib.sha256() hasher.update(file_path.read_bytes()) - file_hash = hasher.hexdigest()[:8] + file_hash = hasher.hexdigest()[: self.FINGERPRINT_LEN] rel_path = str(file_path.relative_to(self.build_dir)) manifest[rel_path] = { @@ -233,8 +229,7 @@ def create_asset_manifest(self, output_path: Path) -> None: except (IOError, OSError) as e: error(f"Failed to write asset manifest: {e}") - # Matches stems that already end with an 8-char hex fingerprint (e.g. "nav.36da3320") - _FINGERPRINT_RE = re.compile(r"\.[0-9a-f]{8}$") + _FINGERPRINT_RE = re.compile(r"\.[0-9a-f]{12}$") def fingerprint_assets(self) -> Dict[str, str]: """Add content hashes to CSS and JS filenames for cache busting.""" @@ -248,13 +243,12 @@ def fingerprint_assets(self) -> Dict[str, str]: path_mapping = {} for asset_path in asset_files: - # Skip files already fingerprinted from a previous build if self._FINGERPRINT_RE.search(asset_path.stem): continue content = asset_path.read_bytes() hasher = hashlib.sha256() hasher.update(content) - content_hash = hasher.hexdigest()[:8] + content_hash = hasher.hexdigest()[: self.FINGERPRINT_LEN] stem = asset_path.stem suffix = asset_path.suffix @@ -276,13 +270,10 @@ def fingerprint_assets(self) -> Dict[str, str]: for old_path, new_path in path_mapping.items(): old_filename = Path(old_path).name - new_filename = Path(new_path).name - # Use regex to match href/src with optional quotes and optional leading slash - # This handles quoted, unquoted, and minified HTML attributes + # Match href/src attributes with optional quotes and an + # optional leading slash so minified output is covered too. for path_variant in [old_path, old_filename]: - # Pattern matches: href="path", href='path', href=/path, href=path - # Captures the attribute name (href or src) and replaces with quoted new path pattern = ( r'((?:href|src)=)["\']?(/?' + re.escape(path_variant) diff --git a/src/nitro/core/generator.py b/src/nitro/core/generator.py index fc9528d..6bfb974 100644 --- a/src/nitro/core/generator.py +++ b/src/nitro/core/generator.py @@ -41,6 +41,9 @@ def __init__(self, project_root: Optional[Path] = None, use_cache: bool = True): self.plugin_loader = self._load_plugins() self.use_cache = use_cache self.cache = BuildCache(self.project_root) if use_cache else None + self.production = False + self.page_metadata: dict = {} + self._metadata_lock = threading.Lock() def _load_config(self) -> Config: """Load project configuration. @@ -91,22 +94,17 @@ def generate( True if successful, False otherwise """ self.production = production - self.page_metadata = {} # Store page metadata for sitemap - self._metadata_lock = threading.Lock() # Thread-safe metadata access + self.page_metadata = {} info(f"Generating site from {self.source_dir}") info(f"Output directory: {self.build_dir}") - # Check if config changed (forces full rebuild) config_path = self.project_root / "nitro.config.py" - config_changed = False if self.cache and config_path.exists(): - config_changed = self.cache.is_config_changed(config_path) - if config_changed: + if self.cache.is_config_changed(config_path): warning("Config changed, forcing full rebuild") force = True self.cache.update_config_hash(config_path) - # Trigger pre-generate hook self.plugin_loader.trigger( "nitro.pre_generate", { @@ -116,17 +114,14 @@ def generate( }, ) - # Ensure build directory exists self.build_dir.mkdir(parents=True, exist_ok=True) - # Find all page files all_pages = self._find_pages() if not all_pages: error("No pages found in src/pages/") return False - # Separate static and dynamic pages static_pages = [] dynamic_pages = [] for page in all_pages: @@ -135,7 +130,6 @@ def generate( else: static_pages.append(page) - # Determine which static pages need to be rebuilt if self.cache and not force: components_dir = self.source_dir / "components" data_dir = self.source_dir / "data" @@ -143,8 +137,8 @@ def generate( static_pages, components_dir, data_dir ) - # Rebuild any "cached" page whose output file is missing from build/ - # (e.g. user ran `rm -rf build/` between runs). + # Cache may report a page as unchanged but build/ could have been + # wiped between runs; treat missing outputs as stale. pages_to_build_set = set(pages_to_build) for page in static_pages: if page in pages_to_build_set: @@ -174,27 +168,21 @@ def generate( if dynamic_pages: info(f"Found {len(dynamic_pages)} dynamic route(s)") - # Generate pages success_count = 0 failed_pages = [] - # Use parallel generation for multiple pages use_parallel = parallel and len(pages_to_build) > 1 max_workers = min(os.cpu_count() or 4, len(pages_to_build), 8) - # Generate pages - with or without progress display if quiet: - # Quiet mode: no progress bar (for background thread execution) success_count, failed_pages = self._generate_pages_quiet( pages_to_build, use_parallel, max_workers, verbose ) else: - # Normal mode: show progress bar success_count, failed_pages = self._generate_pages_with_progress( pages_to_build, use_parallel, max_workers, verbose ) - # Show results for static pages if pages_to_build: if failed_pages: success( @@ -205,7 +193,6 @@ def generate( else: success(f"Generated {success_count} static page(s)") - # Generate dynamic pages dynamic_count = 0 if dynamic_pages: console.print("\n[dim]─── Generating Dynamic Routes [/dim]") @@ -219,7 +206,6 @@ def generate( for output_name, html in results: if html: - # Determine output directory based on page location pages_dir = self.source_dir / "pages" rel_dir = dynamic_page.parent.relative_to(pages_dir) if str(rel_dir) == ".": @@ -239,11 +225,9 @@ def generate( if dynamic_count > 0: success(f"Generated {dynamic_count} page(s) from dynamic routes") - # Save the cache if self.cache: self.cache.save() - # Copy assets self._copy_assets(verbose) success("Site generation complete!") @@ -400,24 +384,19 @@ def _render_page_sequential(self, page_path: Path, verbose: bool = False) -> boo if page_obj is None: return False - # Check for draft status in production mode - is_draft = ( - getattr(page_obj, "draft", False) if hasattr(page_obj, "draft") else False - ) - if getattr(self, "production", False) and is_draft: + is_draft = getattr(page_obj, "draft", False) + if self.production and is_draft: if verbose: console.print( f" [dim]Skipping draft: {page_path.relative_to(self.project_root)}[/]" ) - return True # Return True to not count as failure + return True - # Get HTML content if hasattr(page_obj, "content"): html = self.renderer._render_page_object(page_obj) - html = self.renderer._post_process(html) else: html = self.renderer._render_element(page_obj) - html = self.renderer._post_process(html) + html = self.renderer._post_process(html) if html: hook_result = self.plugin_loader.trigger( @@ -443,16 +422,12 @@ def _render_page_sequential(self, page_path: Path, verbose: bool = False) -> boo output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(html) - # Store metadata for sitemap - if hasattr(self, "page_metadata"): - rel_path = str(output_path.relative_to(self.build_dir)) - meta = ( - getattr(page_obj, "meta", {}) if hasattr(page_obj, "meta") else {} - ) - self.page_metadata[rel_path] = { - "draft": is_draft, - **meta, - } + rel_path = str(output_path.relative_to(self.build_dir)) + meta = getattr(page_obj, "meta", {}) + self.page_metadata[rel_path] = { + "draft": is_draft, + **meta, + } if verbose: console.print(f" → {output_path.relative_to(self.project_root)}") @@ -491,7 +466,6 @@ def _render_single_page(self, page_path: Path, verbose: bool = False) -> bool: True if successful, False otherwise """ try: - # Render with page object return to check draft status page_obj = self.renderer.render_page( page_path, self.project_root, return_page=True ) @@ -499,25 +473,17 @@ def _render_single_page(self, page_path: Path, verbose: bool = False) -> bool: if page_obj is None: return False - # Check for draft status in production mode - is_draft = ( - getattr(page_obj, "draft", False) - if hasattr(page_obj, "draft") - else False - ) - if getattr(self, "production", False) and is_draft: - return True # Skip drafts in production but count as success + is_draft = getattr(page_obj, "draft", False) + if self.production and is_draft: + return True - # Get HTML content if hasattr(page_obj, "content"): html = self.renderer._render_page_object(page_obj) - html = self.renderer._post_process(html) else: html = self.renderer._render_element(page_obj) - html = self.renderer._post_process(html) + html = self.renderer._post_process(html) if html: - # Trigger post-generate hook to allow HTML modification hook_result = self.plugin_loader.trigger( "nitro.post_generate", { @@ -527,7 +493,6 @@ def _render_single_page(self, page_path: Path, verbose: bool = False) -> bool: }, ) - # Use modified output if returned if ( hook_result and isinstance(hook_result, dict) @@ -539,25 +504,16 @@ def _render_single_page(self, page_path: Path, verbose: bool = False) -> bool: page_path, self.source_dir, self.build_dir ) - # Ensure parent directory exists (thread-safe with exist_ok) output_path.parent.mkdir(parents=True, exist_ok=True) - - # Write HTML file output_path.write_text(html) - # Store metadata for sitemap (thread-safe) - if hasattr(self, "page_metadata"): - rel_path = str(output_path.relative_to(self.build_dir)) - meta = ( - getattr(page_obj, "meta", {}) - if hasattr(page_obj, "meta") - else {} - ) - with self._metadata_lock: - self.page_metadata[rel_path] = { - "draft": is_draft, - **meta, - } + rel_path = str(output_path.relative_to(self.build_dir)) + meta = getattr(page_obj, "meta", {}) + with self._metadata_lock: + self.page_metadata[rel_path] = { + "draft": is_draft, + **meta, + } if verbose: console.print(f" → {output_path.relative_to(self.project_root)}") @@ -577,19 +533,15 @@ def _copy_assets(self, verbose: bool = False) -> None: """ info("Copying static assets...") - # Copy styles styles_src = self.source_dir / "styles" if styles_src.exists(): styles_dest = self.build_dir / "assets" / "styles" self._copy_directory(styles_src, styles_dest, "styles", verbose) - # Copy public files (src/public/ -> build/) public_src = self.source_dir / "public" if public_src.exists(): - # Copy public files to root of build directory self._copy_directory(public_src, self.build_dir, "public", verbose) - # Copy static files (src/static/ -> build/) static_src = self.source_dir / "static" if static_src.exists(): self._copy_directory(static_src, self.build_dir, "static", verbose) @@ -616,10 +568,8 @@ def _copy_directory( relative = item.relative_to(src) dest_file = dest / relative dest_file.parent.mkdir(parents=True, exist_ok=True) - # Explicitly unlink destination first to guarantee overwrite. - # Guards against stale build outputs (e.g. committed build/ - # with older mtimes than src) that a user might expect a - # build to refresh. + # Unlink first so a committed build/ with older mtimes than + # src/ still gets overwritten on the next build. if dest_file.exists() or dest_file.is_symlink(): dest_file.unlink() shutil.copy2(item, dest_file) diff --git a/src/nitro/core/images.py b/src/nitro/core/images.py index be5a4e6..4fe6622 100644 --- a/src/nitro/core/images.py +++ b/src/nitro/core/images.py @@ -405,46 +405,46 @@ def generate_picture_element( Example: >>> optimizer.generate_picture_element(result, alt="Hero image") """ + from nitro_ui import Picture, Source, Image + sizes_attr = sizes or self.config.default_sizes - lazy_attr = 'loading="lazy"' if self.config.lazy_load else "" - class_attr = f'class="{css_class}"' if css_class else "" sources = [] - - # Add sources in format preference order for format in self.config.formats: if format == "original": continue - if format in optimized.variants: - srcset = optimized.get_srcset(format) - mime = f"image/{format}" sources.append( - f' ' + Source( + type=f"image/{format}", + srcset=optimized.get_srcset(format), + sizes=sizes_attr, + ) ) - # Fallback img tag (use original format or first available) original_format = optimized.original_path.suffix.lower().lstrip(".") if original_format in optimized.variants: fallback_srcset = optimized.get_srcset(original_format) fallback_src = optimized.get_src(original_format) else: - # Use first available format first_format = list(optimized.variants.keys())[0] fallback_srcset = optimized.get_srcset(first_format) fallback_src = optimized.get_src(first_format) - img_tag = ( - f' ' - ) + img_attrs = { + "src": fallback_src, + "srcset": fallback_srcset, + "sizes": sizes_attr, + "alt": alt, + "width": optimized.original_width, + "height": optimized.original_height, + } + if css_class: + img_attrs["cls"] = css_class + if self.config.lazy_load: + img_attrs["loading"] = "lazy" - return f"\n{''.join(f'{s}' + chr(10) for s in sources)}{img_tag}\n" + return Picture(*sources, Image(**img_attrs)).render() def process_html( self, diff --git a/src/nitro/core/islands.py b/src/nitro/core/islands.py index 79a9aa1..acb6def 100644 --- a/src/nitro/core/islands.py +++ b/src/nitro/core/islands.py @@ -128,7 +128,8 @@ def render(self) -> str: inner_html = str(self.component) except Exception as e: warning(f"Failed to render island '{self.name}': {e}") - inner_html = f"" + safe_msg = str(e).replace("--", "- -") + inner_html = f"" # Build hydration attributes attrs = [ diff --git a/src/nitro/core/renderer.py b/src/nitro/core/renderer.py index 1d3a02e..3332213 100644 --- a/src/nitro/core/renderer.py +++ b/src/nitro/core/renderer.py @@ -1,6 +1,7 @@ """Renderer for generating HTML from nitro-ui pages.""" import threading +from contextlib import contextmanager from typing import Any, Optional, List from pathlib import Path import importlib.util @@ -9,8 +10,38 @@ from ..core.page import Page from ..utils import error, warning, error_panel -# Lock for thread-safe sys.path and sys.modules manipulation -_import_lock = threading.Lock() +_import_lock = threading.RLock() + + +@contextmanager +def _project_import_context(project_root: Path, invalidate_pages): + """Serialize all access to sys.path and sys.modules during page import. + + Python's import machinery touches global state. Holding the lock for the + full import (path setup, module invalidation, exec_module, sys.modules + insert/remove, path teardown) is what makes parallel page rendering + correct; releasing it earlier opens windows where another thread can + yank a path or shared module out from under an in-flight import. + """ + paths_to_remove = [] + with _import_lock: + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + paths_to_remove.append(str(project_root)) + + src_dir = project_root / "src" + if str(src_dir) not in sys.path: + sys.path.insert(0, str(src_dir)) + paths_to_remove.append(str(src_dir)) + + invalidate_pages(project_root) + + try: + yield + finally: + for path in paths_to_remove: + if path in sys.path: + sys.path.remove(path) class Renderer: @@ -35,61 +66,40 @@ def get_dynamic_paths(self, page_path: Path, project_root: Path) -> List[dict]: Returns: List of parameter dictionaries from get_paths() """ - paths_to_remove = [] - try: - with _import_lock: - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - paths_to_remove.append(str(project_root)) - - src_dir = project_root / "src" - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - paths_to_remove.append(str(src_dir)) - - self._invalidate_project_modules(project_root) - - module_name = f"dynamic_paths_{page_path.stem}_{id(self)}" - spec = importlib.util.spec_from_file_location(module_name, page_path) + with _project_import_context(project_root, self._invalidate_project_modules): + module_name = f"dynamic_paths_{page_path.stem}_{id(self)}_{threading.get_ident()}" + spec = importlib.util.spec_from_file_location(module_name, page_path) - if not spec or not spec.loader: - return [] + if not spec or not spec.loader: + return [] - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module - try: - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) - if not hasattr(module, "get_paths"): - return [] + if not hasattr(module, "get_paths"): + return [] - paths = module.get_paths() - # Normalize paths to list of dicts - result = [] - for path_params in paths: - if isinstance(path_params, dict): - result.append(path_params) - else: - # Single value - use the param name from filename - param_name = page_path.stem[1:-1] # Extract from [slug].py - result.append({param_name: path_params}) - return result + paths = module.get_paths() + result = [] + for path_params in paths: + if isinstance(path_params, dict): + result.append(path_params) + else: + param_name = page_path.stem[1:-1] + result.append({param_name: path_params}) + return result - finally: - if spec.name in sys.modules: - del sys.modules[spec.name] + finally: + if spec.name in sys.modules: + del sys.modules[spec.name] except Exception: return [] - finally: - with _import_lock: - for path in paths_to_remove: - if path in sys.path: - sys.path.remove(path) - def render_dynamic_page_single( self, page_path: Path, project_root: Path, params: dict ) -> Optional[str]: @@ -103,60 +113,44 @@ def render_dynamic_page_single( Returns: Rendered HTML or None on error """ - paths_to_remove = [] - + html = None try: - with _import_lock: - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - paths_to_remove.append(str(project_root)) - - src_dir = project_root / "src" - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - paths_to_remove.append(str(src_dir)) - - self._invalidate_project_modules(project_root) - - module_name = f"dynamic_single_{page_path.stem}_{id(self)}" - spec = importlib.util.spec_from_file_location(module_name, page_path) - - if not spec or not spec.loader: - return None - - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - - try: - spec.loader.exec_module(module) + with _project_import_context(project_root, self._invalidate_project_modules): + module_name = ( + f"dynamic_single_{page_path.stem}_{id(self)}_{threading.get_ident()}" + ) + spec = importlib.util.spec_from_file_location(module_name, page_path) - if not hasattr(module, "render"): + if not spec or not spec.loader: return None - page = module.render(**params) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module - if isinstance(page, Page): - html = self._render_page_object(page) - else: - html = self._render_element(page) + try: + spec.loader.exec_module(module) - if html: - html = self._post_process(html) + if not hasattr(module, "render"): + return None - return html + page = module.render(**params) - finally: - if spec.name in sys.modules: - del sys.modules[spec.name] + if isinstance(page, Page): + html = self._render_page_object(page) + else: + html = self._render_element(page) + + finally: + if spec.name in sys.modules: + del sys.modules[spec.name] except Exception: return None - finally: - with _import_lock: - for path in paths_to_remove: - if path in sys.path: - sys.path.remove(path) + if html: + html = self._post_process(html) + + return html def render_dynamic_page( self, @@ -165,89 +159,73 @@ def render_dynamic_page( ) -> List[tuple]: """Render a dynamic page for all its paths.""" results = [] - paths_to_remove = [] try: - with _import_lock: - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - paths_to_remove.append(str(project_root)) - - src_dir = project_root / "src" - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - paths_to_remove.append(str(src_dir)) - - self._invalidate_project_modules(project_root) - - module_name = f"dynamic_page_{page_path.stem}_{id(self)}" - spec = importlib.util.spec_from_file_location(module_name, page_path) + with _project_import_context(project_root, self._invalidate_project_modules): + module_name = ( + f"dynamic_page_{page_path.stem}_{id(self)}_{threading.get_ident()}" + ) + spec = importlib.util.spec_from_file_location(module_name, page_path) - if not spec or not spec.loader: - error(f"Failed to load dynamic page: {page_path}") - return results + if not spec or not spec.loader: + error(f"Failed to load dynamic page: {page_path}") + return results - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module - try: - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) - if not hasattr(module, "get_paths"): - error(f"Dynamic page {page_path} missing get_paths() function") - return results + if not hasattr(module, "get_paths"): + error(f"Dynamic page {page_path} missing get_paths() function") + return results - if not hasattr(module, "render"): - error(f"Dynamic page {page_path} missing render() function") - return results + if not hasattr(module, "render"): + error(f"Dynamic page {page_path} missing render() function") + return results - paths = module.get_paths() + paths = module.get_paths() - for path_params in paths: - try: - if isinstance(path_params, dict): - page = module.render(**path_params) - else: - page = module.render(path_params) + for path_params in paths: + try: + if isinstance(path_params, dict): + page = module.render(**path_params) + else: + page = module.render(path_params) - if isinstance(page, Page): - html = self._render_page_object(page) - else: - html = self._render_element(page) + if isinstance(page, Page): + html = self._render_page_object(page) + else: + html = self._render_element(page) - if html: - html = self._post_process(html) + if html: + html = self._post_process(html) - output_name = self._get_dynamic_output_name( - page_path, path_params - ) - results.append((output_name, html)) + output_name = self._get_dynamic_output_name( + page_path, path_params + ) + results.append((output_name, html)) - except Exception as e: - error( - f"Error rendering {page_path} with params {path_params}: {e}" - ) + except Exception as e: + error( + f"Error rendering {page_path} with params {path_params}: {e}" + ) - finally: - if spec.name in sys.modules: - del sys.modules[spec.name] + finally: + if spec.name in sys.modules: + del sys.modules[spec.name] except Exception as e: error(f"Error processing dynamic page {page_path}: {e}") - finally: - with _import_lock: - for path in paths_to_remove: - if path in sys.path: - sys.path.remove(path) - return results def _get_dynamic_output_name(self, page_path: Path, params: Any) -> str: """Get the output filename for a dynamic page.""" import re - stem = page_path.stem # e.g., "[slug]" + stem = page_path.stem if isinstance(params, dict): output_name = stem @@ -271,57 +249,43 @@ def render_page( Returns: HTML string, Page object (if return_page=True), or None on error """ - paths_to_remove = [] - try: - with _import_lock: - if str(project_root) not in sys.path: - sys.path.insert(0, str(project_root)) - paths_to_remove.append(str(project_root)) - - src_dir = project_root / "src" - if str(src_dir) not in sys.path: - sys.path.insert(0, str(src_dir)) - paths_to_remove.append(str(src_dir)) - - self._invalidate_project_modules(project_root) - - module_name = f"page_{page_path.stem}_{id(self)}" - spec = importlib.util.spec_from_file_location(module_name, page_path) + with _project_import_context(project_root, self._invalidate_project_modules): + module_name = f"page_{page_path.stem}_{id(self)}_{threading.get_ident()}" + spec = importlib.util.spec_from_file_location(module_name, page_path) - if not spec or not spec.loader: - error(f"Failed to load page: {page_path}") - return None + if not spec or not spec.loader: + error(f"Failed to load page: {page_path}") + return None - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module - try: - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) - if not hasattr(module, "render"): - error(f"Page {page_path} missing render() function") - return None + if not hasattr(module, "render"): + error(f"Page {page_path} missing render() function") + return None - page = module.render() + page = module.render() - # Return page object if requested - if return_page: - return page + if return_page: + return page - if isinstance(page, Page): - html = self._render_page_object(page) - else: - html = self._render_element(page) + if isinstance(page, Page): + html = self._render_page_object(page) + else: + html = self._render_element(page) - if html: - html = self._post_process(html) + if html: + html = self._post_process(html) - return html + return html - finally: - if spec.name in sys.modules: - del sys.modules[spec.name] + finally: + if spec.name in sys.modules: + del sys.modules[spec.name] except SyntaxError as e: error_panel( @@ -408,12 +372,6 @@ def render_page( error(f"Error rendering {page_path}: {e}") return None - finally: - with _import_lock: - for path in paths_to_remove: - if path in sys.path: - sys.path.remove(path) - def _suggest_name_fix(self, error_msg: str) -> Optional[str]: """Suggest fixes for common name errors.""" import difflib diff --git a/src/nitro/core/watcher.py b/src/nitro/core/watcher.py index ffea351..f97a590 100644 --- a/src/nitro/core/watcher.py +++ b/src/nitro/core/watcher.py @@ -57,25 +57,28 @@ def on_created(self, event: FileSystemEvent) -> None: self.on_change(path) def _should_ignore(self, path: Path) -> bool: - ignore_patterns = [ + ignored_dirs = { "__pycache__", - ".pyc", - ".pyo", ".git", ".nitro", - "build/", + "build", ".idea", ".vscode", - ".DS_Store", - ] + } + ignored_suffixes = {".pyc", ".pyo", ".swp"} + ignored_names = {".DS_Store"} - path_str = str(path) - for pattern in ignore_patterns: - if pattern in path_str: - return True + parts = path.parts + if any(part in ignored_dirs for part in parts): + return True + + if path.suffix in ignored_suffixes: + return True name = path.name - if name.endswith("~") or name.startswith(".#") or name.endswith(".swp"): + if name in ignored_names: + return True + if name.endswith("~") or name.startswith(".#"): return True return False diff --git a/tests/test_bundler.py b/tests/test_bundler.py index ce82bef..d1f3a49 100644 --- a/tests/test_bundler.py +++ b/tests/test_bundler.py @@ -267,22 +267,17 @@ def test_skips_already_fingerprinted_files(self): with tempfile.TemporaryDirectory() as tmpdir: build_dir = Path(tmpdir) - # Simulate state after a previous build: both the fresh source - # file and the old fingerprinted copy exist in build/ js_content = "console.log('nav');" (build_dir / "nav.js").write_text(js_content) - (build_dir / "nav.36da3320.js").write_text(js_content) + (build_dir / "nav.36da33205a1c.js").write_text(js_content) bundler = Bundler(build_dir) mapping = bundler.fingerprint_assets() - # Only the fresh file should be fingerprinted assert "nav.js" in mapping - assert "nav.36da3320.js" not in mapping + assert "nav.36da33205a1c.js" not in mapping - # No double-hashed files should exist for f in build_dir.iterdir(): - # Count dots in stem - a stacked hash would have 2+ dots assert f.stem.count(".") <= 1, f"Stacked fingerprint: {f.name}" def test_incremental_build_no_hash_stacking(self): From 8b41df0d475b0cfbf2b9b5436f331de9ac112898 Mon Sep 17 00:00:00 2001 From: Sean N Date: Sun, 17 May 2026 04:22:24 +0200 Subject: [PATCH 2/2] Bumping the version --- pyproject.toml | 2 +- src/nitro/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 747f674..82b408d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-cli" -version = "1.0.16" +version = "1.0.17" description = "Build static sites with Python code instead of template engines" authors = [ {name = "Sean Nieuwoudt", email = "sean@nitro.sh"} diff --git a/src/nitro/__init__.py b/src/nitro/__init__.py index a6f72cd..5f6f68e 100644 --- a/src/nitro/__init__.py +++ b/src/nitro/__init__.py @@ -1,6 +1,6 @@ """Nitro CLI - A static site generator""" -__version__ = "1.0.14" +__version__ = "1.0.17" __author__ = "Sean Nieuwoudt" from .core.config import Config