` |
+| `TableHeaderCell` | `| ` |
+| `TableDataCell` | ` | ` |
### Attribute Naming
@@ -1077,4 +1077,4 @@ All of these are installed automatically with `pip install nitro-cli`:
## Version
-Current: nitro-cli 1.0.8
+Current: nitro-cli 1.0.10
diff --git a/pyproject.toml b/pyproject.toml
index 2aa83e3..9bf8fa5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,7 +24,7 @@ classifiers = [
"Operating System :: OS Independent",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
- "License :: OSI Approved :: MIT License",
+ "License :: OSI Approved :: BSD License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@@ -54,6 +54,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
+ "pytest-cov>=5.0.0",
"html5lib>=1.1",
"black>=24.0.0",
"flake8>=7.0.0",
@@ -73,10 +74,9 @@ dotenv = [
nitro = "nitro.cli:main"
[project.urls]
-Homepage = "https://github.com/nitro-sh/nitro-cli"
-Documentation = "https://nitro-cli.readthedocs.io"
-Repository = "https://github.com/nitro-sh/nitro-cli"
-Issues = "https://github.com/nitro-sh/nitro-cli/issues"
+Homepage = "https://github.com/nitrosh/nitro-cli"
+Repository = "https://github.com/nitrosh/nitro-cli"
+Issues = "https://github.com/nitrosh/nitro-cli/issues"
[tool.setuptools]
package-dir = {"" = "src"}
diff --git a/src/nitro/templates/default/README.md b/src/nitro/templates/default/README.md
index 3d4a779..8b73758 100644
--- a/src/nitro/templates/default/README.md
+++ b/src/nitro/templates/default/README.md
@@ -45,4 +45,5 @@ Nitro isn’t just one library - it's a growing toolkit of focused building bloc
- **[nitro-ui](https://github.com/nitrosh/nitro-ui)** - Generate clean, reusable HTML with a lightweight, developer-friendly API
- **[nitro-datastore](https://github.com/nitrosh/nitro-datastore)** - Load and access data effortlessly using simple dot-notation paths
- **[nitro-dispatch](https://github.com/nitrosh/nitro-dispatch)** - A flexible plugin system to extend features without touching core code
+- **[nitro-image](https://github.com/nitrosh/nitro-image)** - Responsive image optimization with WebP/AVIF conversion
- **[nitro-validate](https://github.com/nitrosh/nitro-validate)** - Fast, reliable data validation to keep your inputs and payloads rock-solid
diff --git a/tests/test_cache.py b/tests/test_cache.py
new file mode 100644
index 0000000..0a70cd5
--- /dev/null
+++ b/tests/test_cache.py
@@ -0,0 +1,583 @@
+"""Tests for core/cache.py."""
+
+import json
+import tempfile
+from pathlib import Path
+
+import pytest
+
+from nitro.core.cache import BuildCache
+
+
+class TestBuildCacheInit:
+ """Tests for BuildCache initialization."""
+
+ def test_empty_cache_when_no_file(self):
+ """Should initialise with an empty cache when no cache file exists."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ cache = BuildCache(Path(tmpdir))
+
+ assert cache._cache["version"] == BuildCache.CACHE_VERSION
+ assert cache._cache["pages"] == {}
+ assert cache._cache["components"] == {}
+ assert cache._cache["data"] == {}
+ assert cache._cache["config_hash"] is None
+ assert cache._cache["last_build"] is None
+
+ def test_loads_existing_valid_cache(self):
+ """Should load and use a valid cache file that exists on disk."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache_dir = project_root / ".nitro"
+ cache_dir.mkdir()
+ cache_file = cache_dir / "cache.json"
+ existing = {
+ "version": BuildCache.CACHE_VERSION,
+ "pages": {"src/pages/index.py": {"hash": "abc123", "built_at": "2026-01-01T00:00:00"}},
+ "components": {},
+ "data": {},
+ "config_hash": "def456",
+ "last_build": "2026-01-01T00:00:00",
+ }
+ cache_file.write_text(json.dumps(existing))
+
+ cache = BuildCache(project_root)
+
+ assert cache._cache["pages"]["src/pages/index.py"]["hash"] == "abc123"
+ assert cache._cache["config_hash"] == "def456"
+
+ def test_resets_cache_on_wrong_version(self):
+ """Should discard an existing cache whose version does not match."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache_dir = project_root / ".nitro"
+ cache_dir.mkdir()
+ cache_file = cache_dir / "cache.json"
+ stale = {
+ "version": 999,
+ "pages": {"src/pages/index.py": {"hash": "oldvalue"}},
+ "components": {},
+ "data": {},
+ "config_hash": "stale",
+ "last_build": None,
+ }
+ cache_file.write_text(json.dumps(stale))
+
+ cache = BuildCache(project_root)
+
+ assert cache._cache["pages"] == {}
+ assert cache._cache["config_hash"] is None
+ assert cache._cache["version"] == BuildCache.CACHE_VERSION
+
+ def test_resets_cache_on_corrupted_json(self):
+ """Should fall back to an empty cache when the JSON on disk is corrupt."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache_dir = project_root / ".nitro"
+ cache_dir.mkdir()
+ (cache_dir / "cache.json").write_text("{ this is not valid json !!!")
+
+ cache = BuildCache(project_root)
+
+ assert cache._cache["pages"] == {}
+ assert cache._cache["version"] == BuildCache.CACHE_VERSION
+
+ def test_sets_correct_paths(self):
+ """project_root, cache_path, and cache_dir should be derived correctly."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache = BuildCache(project_root)
+
+ assert cache.project_root == project_root
+ assert cache.cache_path == project_root / ".nitro" / "cache.json"
+ assert cache.cache_dir == project_root / ".nitro"
+
+
+class TestBuildCacheSave:
+ """Tests for BuildCache.save()."""
+
+ def test_save_creates_nitro_directory(self):
+ """save() should create the .nitro directory when it does not exist."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache = BuildCache(project_root)
+
+ assert not (project_root / ".nitro").exists()
+ cache.save()
+
+ assert (project_root / ".nitro").is_dir()
+
+ def test_save_writes_json_file(self):
+ """save() should write a valid JSON file at the expected path."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache = BuildCache(project_root)
+ cache.save()
+
+ cache_file = project_root / ".nitro" / "cache.json"
+ assert cache_file.exists()
+
+ data = json.loads(cache_file.read_text())
+ assert data["version"] == BuildCache.CACHE_VERSION
+
+ def test_save_sets_last_build_timestamp(self):
+ """save() should record a last_build ISO timestamp."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache = BuildCache(project_root)
+ cache.save()
+
+ data = json.loads((project_root / ".nitro" / "cache.json").read_text())
+ assert data["last_build"] is not None
+ # Should be a non-empty ISO 8601 string
+ assert "T" in data["last_build"]
+
+ def test_save_then_reload_preserves_data(self):
+ """Data stored before save() should be loadable by a new BuildCache instance."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache = BuildCache(project_root)
+ cache._cache["config_hash"] = "persist_me"
+ cache.save()
+
+ reloaded = BuildCache(project_root)
+ assert reloaded._cache["config_hash"] == "persist_me"
+
+ def test_save_is_idempotent(self):
+ """Calling save() twice should not raise and the file should remain valid."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ cache = BuildCache(project_root)
+ cache.save()
+ cache.save()
+
+ data = json.loads((project_root / ".nitro" / "cache.json").read_text())
+ assert data["version"] == BuildCache.CACHE_VERSION
+
+
+class TestIsConfigChanged:
+ """Tests for BuildCache.is_config_changed() and update_config_hash()."""
+
+ def test_config_changed_when_cache_empty(self):
+ """Any existing config file should appear changed against an empty cache."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ config_path = project_root / "nitro.config.py"
+ config_path.write_text("config = None")
+
+ cache = BuildCache(project_root)
+
+ assert cache.is_config_changed(config_path) is True
+
+ def test_config_unchanged_after_update(self):
+ """After update_config_hash(), is_config_changed() should return False."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ config_path = project_root / "nitro.config.py"
+ config_path.write_text("config = None")
+
+ cache = BuildCache(project_root)
+ cache.update_config_hash(config_path)
+
+ assert cache.is_config_changed(config_path) is False
+
+ def test_config_changed_after_content_modification(self):
+ """Modifying the config file should cause is_config_changed() to return True."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ config_path = project_root / "nitro.config.py"
+ config_path.write_text("config = None")
+
+ cache = BuildCache(project_root)
+ cache.update_config_hash(config_path)
+
+ # Mutate the file
+ config_path.write_text("config = None # changed")
+
+ assert cache.is_config_changed(config_path) is True
+
+ def test_config_changed_when_file_missing(self):
+ """A missing config file should report as changed (hash is None vs stored)."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ config_path = project_root / "nitro.config.py"
+ config_path.write_text("config = None")
+
+ cache = BuildCache(project_root)
+ cache.update_config_hash(config_path)
+
+ config_path.unlink()
+
+ assert cache.is_config_changed(config_path) is True
+
+ def test_update_config_hash_stores_hash(self):
+ """update_config_hash() should write the file's hash into the cache dict."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ config_path = project_root / "nitro.config.py"
+ config_path.write_text("config = None")
+
+ cache = BuildCache(project_root)
+ cache.update_config_hash(config_path)
+
+ assert cache._cache["config_hash"] is not None
+ assert len(cache._cache["config_hash"]) == 16 # truncated SHA256
+
+
+class TestGetChangedPages:
+ """Tests for BuildCache.get_changed_pages()."""
+
+ def _make_page(self, pages_dir: Path, name: str, content: str = "render = None") -> Path:
+ path = pages_dir / name
+ path.write_text(content)
+ return path
+
+ def test_all_pages_changed_on_empty_cache(self):
+ """All pages should appear as changed when the cache is empty."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+
+ pages = [
+ self._make_page(pages_dir, "index.py"),
+ self._make_page(pages_dir, "about.py"),
+ ]
+ components_dir = project_root / "src" / "components"
+ data_dir = project_root / "src" / "data"
+
+ cache = BuildCache(project_root)
+ changed = cache.get_changed_pages(pages, components_dir, data_dir)
+
+ assert set(changed) == set(pages)
+
+ def test_unchanged_pages_not_returned(self):
+ """Pages whose hash matches the cache should not be returned."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ data_dir = project_root / "src" / "data"
+
+ page = self._make_page(pages_dir, "index.py")
+ cache = BuildCache(project_root)
+
+ # Record the page as already built
+ cache.update_page_hash(page)
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert changed == []
+
+ def test_modified_page_detected(self):
+ """A page whose content changed since the last build should be returned."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ data_dir = project_root / "src" / "data"
+
+ page = self._make_page(pages_dir, "index.py", "v1 = True")
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+
+ # Simulate an edit
+ page.write_text("v2 = True")
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert page in changed
+
+ def test_component_change_triggers_all_pages(self):
+ """When a component changes, every page must be rebuilt."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ components_dir.mkdir(parents=True)
+ data_dir = project_root / "src" / "data"
+
+ page1 = self._make_page(pages_dir, "index.py")
+ page2 = self._make_page(pages_dir, "about.py")
+
+ component = components_dir / "header.py"
+ component.write_text("def header(): pass")
+
+ cache = BuildCache(project_root)
+
+ # Mark pages and component as already known
+ cache.update_page_hash(page1)
+ cache.update_page_hash(page2)
+ # Seed the component hashes so a first call does not auto-flag as new
+ cache._update_component_hashes(components_dir)
+
+ # Modify the component
+ component.write_text("def header(): return 'changed'")
+
+ changed = cache.get_changed_pages([page1, page2], components_dir, data_dir)
+
+ assert page1 in changed
+ assert page2 in changed
+
+ def test_data_change_triggers_all_pages(self):
+ """When a data file changes, every page must be rebuilt."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ data_dir = project_root / "src" / "data"
+ data_dir.mkdir(parents=True)
+
+ page1 = self._make_page(pages_dir, "index.py")
+ page2 = self._make_page(pages_dir, "blog.py")
+
+ data_file = data_dir / "posts.json"
+ data_file.write_text('{"posts": []}')
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page1)
+ cache.update_page_hash(page2)
+ # Seed data hashes
+ cache._update_data_hashes(data_dir)
+
+ # Modify the data file
+ data_file.write_text('{"posts": [{"title": "New"}]}')
+
+ changed = cache.get_changed_pages([page1, page2], components_dir, data_dir)
+
+ assert page1 in changed
+ assert page2 in changed
+
+ def test_no_changes_returns_empty_list(self):
+ """Returns an empty list when no pages, components, or data have changed."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ components_dir.mkdir(parents=True)
+ data_dir = project_root / "src" / "data"
+ data_dir.mkdir(parents=True)
+
+ component = components_dir / "nav.py"
+ component.write_text("def nav(): pass")
+
+ data_file = data_dir / "site.yaml"
+ data_file.write_text("title: My Site")
+
+ page = self._make_page(pages_dir, "index.py")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ # Seed component and data hashes so nothing appears new
+ cache._update_component_hashes(components_dir)
+ cache._update_data_hashes(data_dir)
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert changed == []
+
+ def test_missing_components_dir_does_not_raise(self):
+ """get_changed_pages() should work normally when components_dir is absent."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components" # does not exist
+ data_dir = project_root / "src" / "data" # does not exist
+
+ page = self._make_page(pages_dir, "index.py")
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert changed == []
+
+ def test_deleted_component_triggers_all_pages(self):
+ """Removing a component file should flag all pages for rebuild."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ components_dir.mkdir(parents=True)
+ data_dir = project_root / "src" / "data"
+
+ component = components_dir / "footer.py"
+ component.write_text("def footer(): pass")
+
+ page = self._make_page(pages_dir, "index.py")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ cache._update_component_hashes(components_dir)
+
+ # Remove the component
+ component.unlink()
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert page in changed
+
+ def test_deleted_data_file_triggers_all_pages(self):
+ """Removing a data file should flag all pages for rebuild."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ data_dir = project_root / "src" / "data"
+ data_dir.mkdir(parents=True)
+
+ data_file = data_dir / "config.yml"
+ data_file.write_text("key: value")
+
+ page = self._make_page(pages_dir, "index.py")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ cache._update_data_hashes(data_dir)
+
+ data_file.unlink()
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert page in changed
+
+ def test_init_py_in_components_ignored(self):
+ """__init__.py files inside components_dir should not affect change detection."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ components_dir.mkdir(parents=True)
+ data_dir = project_root / "src" / "data"
+
+ # Only an __init__.py present – should be ignored entirely
+ (components_dir / "__init__.py").write_text("")
+
+ page = self._make_page(pages_dir, "index.py")
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ # Seed with the __init__.py present (it is ignored, so hashes stay empty)
+ cache._update_component_hashes(components_dir)
+
+ # Modify __init__.py – should not trigger a rebuild
+ (components_dir / "__init__.py").write_text("# modified")
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert changed == []
+
+ def test_non_data_extension_files_ignored(self):
+ """Files without .json/.yaml/.yml extension in data_dir should be ignored."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+ components_dir = project_root / "src" / "components"
+ data_dir = project_root / "src" / "data"
+ data_dir.mkdir(parents=True)
+
+ (data_dir / "readme.txt").write_text("docs")
+ (data_dir / "script.py").write_text("x = 1")
+
+ page = self._make_page(pages_dir, "index.py")
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ cache._update_data_hashes(data_dir)
+
+ # Modify ignored files
+ (data_dir / "readme.txt").write_text("docs updated")
+
+ changed = cache.get_changed_pages([page], components_dir, data_dir)
+
+ assert changed == []
+
+
+class TestUpdatePageHash:
+ """Tests for BuildCache.update_page_hash()."""
+
+ def test_stores_hash_and_built_at(self):
+ """update_page_hash() should record hash and built_at for the page."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+
+ page = pages_dir / "index.py"
+ page.write_text("render = None")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+
+ rel_path = str(page.relative_to(project_root))
+ entry = cache._cache["pages"][rel_path]
+
+ assert entry["hash"] is not None
+ assert len(entry["hash"]) == 16
+ assert "built_at" in entry
+ assert "T" in entry["built_at"]
+
+ def test_hash_changes_with_file_content(self):
+ """The stored hash should differ when the file content differs."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+
+ page = pages_dir / "index.py"
+ page.write_text("version = 1")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ rel_path = str(page.relative_to(project_root))
+ hash_v1 = cache._cache["pages"][rel_path]["hash"]
+
+ page.write_text("version = 2")
+ cache.update_page_hash(page)
+ hash_v2 = cache._cache["pages"][rel_path]["hash"]
+
+ assert hash_v1 != hash_v2
+
+ def test_page_outside_project_root_uses_absolute_path(self):
+ """A page path that cannot be made relative should be stored as its absolute path."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir) / "project"
+ project_root.mkdir()
+
+ # Page lives outside project_root
+ with tempfile.TemporaryDirectory() as other:
+ external_page = Path(other) / "external.py"
+ external_page.write_text("render = None")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(external_page)
+
+ # Key should be the absolute path string, not a relative one
+ assert str(external_page) in cache._cache["pages"]
+
+ def test_update_page_hash_persists_across_save_reload(self):
+ """A page hash saved to disk should be present after reloading the cache."""
+ with tempfile.TemporaryDirectory() as tmpdir:
+ project_root = Path(tmpdir)
+ pages_dir = project_root / "src" / "pages"
+ pages_dir.mkdir(parents=True)
+
+ page = pages_dir / "index.py"
+ page.write_text("render = None")
+
+ cache = BuildCache(project_root)
+ cache.update_page_hash(page)
+ cache.save()
+
+ reloaded = BuildCache(project_root)
+ rel_path = str(page.relative_to(project_root))
+ assert rel_path in reloaded._cache["pages"]
+ assert reloaded._cache["pages"][rel_path]["hash"] is not None
diff --git a/tests/test_env.py b/tests/test_env.py
new file mode 100644
index 0000000..e4fb1fb
--- /dev/null
+++ b/tests/test_env.py
@@ -0,0 +1,225 @@
+"""Tests for core/env.py."""
+
+import os
+import tempfile
+from pathlib import Path
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from nitro.core.env import Env
+
+
+class TestEnvAttributeAccess:
+ """Tests for __getattr__ attribute-style access."""
+
+ def test_returns_value_for_existing_var(self):
+ """Attribute access should return the env var value when set."""
+ env = Env()
+ with patch.dict(os.environ, {"MY_VAR": "hello"}):
+ assert env.MY_VAR == "hello"
+
+ def test_returns_empty_string_for_missing_var(self):
+ """Attribute access should return empty string when var is not set."""
+ env = Env()
+ key = "NITRO_TEST_NONEXISTENT_XYZ"
+ os.environ.pop(key, None)
+ assert env.__getattr__(key) == ""
+
+ def test_private_attribute_raises_attribute_error(self):
+ """Attributes starting with underscore should raise AttributeError."""
+ env = Env()
+ with pytest.raises(AttributeError, match="_hidden"):
+ _ = env._hidden
+
+ def test_double_underscore_raises_attribute_error(self):
+ """Dunder attributes should raise AttributeError."""
+ env = Env()
+ with pytest.raises(AttributeError):
+ _ = env.__bogus__
+
+ def test_returns_updated_value_after_env_change(self):
+ """Attribute access reflects the current env state each call."""
+ env = Env()
+ key = "NITRO_DYNAMIC_VAR"
+ os.environ.pop(key, None)
+
+ assert env.__getattr__(key) == ""
+
+ with patch.dict(os.environ, {key: "updated"}):
+ assert env.__getattr__(key) == "updated"
+
+
+class TestEnvGet:
+ """Tests for the get() method."""
+
+ def test_get_returns_value_for_existing_var(self):
+ """get() should return the set value."""
+ env = Env()
+ with patch.dict(os.environ, {"API_KEY": "secret"}):
+ assert env.get("API_KEY") == "secret"
+
+ def test_get_returns_empty_string_default(self):
+ """get() should return empty string when var is missing and no default given."""
+ env = Env()
+ key = "NITRO_TEST_MISSING_ABC"
+ os.environ.pop(key, None)
+ assert env.get(key) == ""
+
+ def test_get_returns_custom_default_for_missing_var(self):
+ """get() should return supplied default when var is not set."""
+ env = Env()
+ key = "NITRO_TEST_MISSING_DEF"
+ os.environ.pop(key, None)
+ assert env.get(key, "fallback") == "fallback"
+
+ def test_get_does_not_use_default_when_var_is_set(self):
+ """get() should ignore default when the var is actually set."""
+ env = Env()
+ with patch.dict(os.environ, {"MY_SETTING": "real_value"}):
+ assert env.get("MY_SETTING", "fallback") == "real_value"
+
+
+class TestEnvProductionMode:
+ """Tests for is_production() and is_development()."""
+
+ def test_is_production_true_when_nitro_env_production(self):
+ """is_production() returns True only when NITRO_ENV == 'production'."""
+ env = Env()
+ with patch.dict(os.environ, {"NITRO_ENV": "production"}):
+ assert env.is_production() is True
+
+ def test_is_production_false_when_nitro_env_not_set(self):
+ """is_production() returns False when NITRO_ENV is absent."""
+ env = Env()
+ os.environ.pop("NITRO_ENV", None)
+ assert env.is_production() is False
+
+ def test_is_production_false_for_other_values(self):
+ """is_production() returns False for values other than 'production'."""
+ env = Env()
+ for value in ("development", "staging", "PRODUCTION", "prod", ""):
+ with patch.dict(os.environ, {"NITRO_ENV": value}):
+ assert env.is_production() is False, f"Expected False for NITRO_ENV={value!r}"
+
+ def test_is_development_true_when_not_production(self):
+ """is_development() returns True when NITRO_ENV is not 'production'."""
+ env = Env()
+ os.environ.pop("NITRO_ENV", None)
+ assert env.is_development() is True
+
+ def test_is_development_false_in_production(self):
+ """is_development() returns False when NITRO_ENV == 'production'."""
+ env = Env()
+ with patch.dict(os.environ, {"NITRO_ENV": "production"}):
+ assert env.is_development() is False
+
+ def test_is_production_and_is_development_are_mutually_exclusive(self):
+ """is_production() and is_development() must always disagree."""
+ env = Env()
+ with patch.dict(os.environ, {"NITRO_ENV": "production"}):
+ assert env.is_production() != env.is_development()
+
+ os.environ.pop("NITRO_ENV", None)
+ assert env.is_production() != env.is_development()
+
+
+class TestEnvLazyLoading:
+ """Tests for the lazy _load() behaviour."""
+
+ def test_load_not_called_before_first_access(self):
+ """_loaded flag should be False immediately after construction."""
+ env = Env()
+ assert env._loaded is False
+
+ def test_load_called_on_first_attribute_access(self):
+ """_loaded flag should be True after first attribute access."""
+ env = Env()
+ _ = env.__getattr__("SOME_VAR")
+ assert env._loaded is True
+
+ def test_load_called_on_get(self):
+ """_loaded flag should be True after calling get()."""
+ env = Env()
+ env.get("SOME_VAR")
+ assert env._loaded is True
+
+ def test_load_only_runs_once(self):
+ """_load() should invoke load_dotenv at most once across multiple accesses."""
+ env = Env()
+ mock_load = MagicMock()
+
+ with patch("nitro.core.env.Path.cwd", return_value=Path("/nonexistent_dir_xyz")):
+ with patch("nitro.core.env.Path.exists", return_value=False):
+ with patch.dict("sys.modules", {"dotenv": MagicMock(load_dotenv=mock_load)}):
+ env._load()
+ env._load()
+ env._load()
+
+ # Regardless of dotenv availability, _loaded must be True after first call
+ assert env._loaded is True
+
+ def test_load_sets_loaded_even_without_dotenv(self):
+ """_load() should set _loaded=True even when python-dotenv is not installed."""
+ env = Env()
+ with patch.dict("sys.modules", {"dotenv": None}):
+ env._load()
+ assert env._loaded is True
+
+ def test_load_reads_dotenv_file_when_present(self):
+ """_load() should call load_dotenv when a .env file exists in cwd."""
+ env = Env()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ env_file = Path(tmpdir) / ".env"
+ env_file.write_text("DOTENV_TEST_VAR=from_dotenv\n")
+
+ mock_load_dotenv = MagicMock()
+ mock_dotenv = MagicMock()
+ mock_dotenv.load_dotenv = mock_load_dotenv
+
+ with patch("nitro.core.env.Path.cwd", return_value=Path(tmpdir)):
+ with patch.dict("sys.modules", {"dotenv": mock_dotenv}):
+ # Patch the import inside _load by overriding the module lookup
+ with patch("builtins.__import__", side_effect=_make_import_shim(mock_load_dotenv)):
+ env._load()
+
+ assert env._loaded is True
+
+ def test_second_access_does_not_reload(self):
+ """After _loaded is True, subsequent attribute accesses skip _load logic."""
+ env = Env()
+ load_count = 0
+ original_load = env._load
+
+ def counting_load():
+ nonlocal load_count
+ load_count += 1
+ original_load()
+
+ env._load = counting_load
+
+ _ = env.__getattr__("VAR_ONE")
+ _ = env.__getattr__("VAR_TWO")
+ _ = env.get("VAR_THREE")
+
+ assert load_count == 3 # called each time, but internal guard short-circuits
+ assert env._loaded is True
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_import_shim(mock_load_dotenv):
+ """Return an __import__ side-effect that intercepts 'dotenv' imports."""
+ real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
+
+ def shim(name, *args, **kwargs):
+ if name == "dotenv":
+ mod = MagicMock()
+ mod.load_dotenv = mock_load_dotenv
+ return mod
+ return real_import(name, *args, **kwargs)
+
+ return shim
diff --git a/tests/test_islands.py b/tests/test_islands.py
new file mode 100644
index 0000000..277ef3f
--- /dev/null
+++ b/tests/test_islands.py
@@ -0,0 +1,530 @@
+"""Tests for core/islands.py."""
+
+import hashlib
+import json
+from unittest.mock import patch
+
+import pytest
+
+from nitro.core.islands import Island, IslandConfig, IslandProcessor
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _expected_id(name: str, props: dict) -> str:
+ """Reproduce the ID hashing logic from Island.__post_init__."""
+ props_hash = hashlib.sha256(
+ json.dumps(props, sort_keys=True, default=str).encode()
+ ).hexdigest()[:8]
+ return f"{name}-{props_hash}"
+
+
+class MockElement:
+ """Minimal stub with a .render() method."""
+
+ def __init__(self, html: str):
+ self._html = html
+
+ def render(self) -> str:
+ return self._html
+
+
+class MockHtmlObject:
+ """Stub with a .__html__() method (but no .render())."""
+
+ def __init__(self, html: str):
+ self._html = html
+
+ def __html__(self) -> str:
+ return self._html
+
+
+# ---------------------------------------------------------------------------
+# IslandConfig
+# ---------------------------------------------------------------------------
+
+
+class TestIslandConfigDefaults:
+ """Tests for IslandConfig default values."""
+
+ def test_default_output_dir(self):
+ """Default output_dir should be '_islands'."""
+ config = IslandConfig()
+
+ assert config.output_dir == "_islands"
+
+ def test_default_strategy(self):
+ """Default hydration strategy should be 'idle'."""
+ config = IslandConfig()
+
+ assert config.default_strategy == "idle"
+
+ def test_default_debug_is_false(self):
+ """Debug mode should be off by default."""
+ config = IslandConfig()
+
+ assert config.debug is False
+
+ def test_custom_values_are_stored(self):
+ """Custom values should be stored as supplied."""
+ config = IslandConfig(output_dir="assets/islands", default_strategy="load", debug=True)
+
+ assert config.output_dir == "assets/islands"
+ assert config.default_strategy == "load"
+ assert config.debug is True
+
+
+# ---------------------------------------------------------------------------
+# Island ID generation
+# ---------------------------------------------------------------------------
+
+
+class TestIslandIdGeneration:
+ """Tests for Island._id computed in __post_init__."""
+
+ def test_id_contains_name(self):
+ """Generated ID should start with the island name."""
+ island = Island(name="counter", component=lambda: None)
+
+ assert island._id.startswith("counter-")
+
+ def test_id_suffix_is_eight_chars(self):
+ """The hash suffix should be exactly 8 hex characters."""
+ island = Island(name="counter", component=lambda: None)
+
+ suffix = island._id.split("-", 1)[1]
+ assert len(suffix) == 8
+
+ def test_id_is_deterministic(self):
+ """Same name + props should always produce the same ID."""
+ props = {"count": 0, "label": "Click me"}
+ island_a = Island(name="btn", component=lambda: None, props=props)
+ island_b = Island(name="btn", component=lambda: None, props=props)
+
+ assert island_a._id == island_b._id
+
+ def test_id_differs_for_different_props(self):
+ """Different props should produce different IDs for the same name."""
+ island_a = Island(name="btn", component=lambda: None, props={"count": 0})
+ island_b = Island(name="btn", component=lambda: None, props={"count": 1})
+
+ assert island_a._id != island_b._id
+
+ def test_id_differs_for_different_names(self):
+ """Different names with identical props should produce different IDs."""
+ island_a = Island(name="alpha", component=lambda: None, props={"x": 1})
+ island_b = Island(name="beta", component=lambda: None, props={"x": 1})
+
+ assert island_a._id != island_b._id
+
+ def test_id_matches_expected_hash(self):
+ """ID should match the manually computed expected value."""
+ props = {"value": 42}
+ island = Island(name="widget", component=lambda: None, props=props)
+
+ assert island._id == _expected_id("widget", props)
+
+ def test_id_with_empty_props(self):
+ """Empty props should still produce a valid ID."""
+ island = Island(name="static", component=lambda: None)
+
+ assert island._id == _expected_id("static", {})
+
+
+# ---------------------------------------------------------------------------
+# Island.render() — callable component
+# ---------------------------------------------------------------------------
+
+
+class TestIslandRenderCallable:
+ """Tests for Island.render() with a plain callable component."""
+
+ def test_callable_result_is_stringified(self):
+ """A callable returning a plain value should be converted with str()."""
+
+ def my_component():
+ return "hello world"
+
+ island = Island(name="greet", component=my_component)
+ html = island.render()
+
+ assert "hello world" in html
+
+ def test_callable_receives_props(self):
+ """Props should be passed as keyword arguments to the callable."""
+
+ def label_component(text: str) -> str:
+ return f"{text}"
+
+ island = Island(name="label", component=label_component, props={"text": "Hi"})
+ html = island.render()
+
+ assert "Hi" in html
+
+ def test_data_island_attribute_present(self):
+ """Rendered div must carry data-island with the island name."""
+ island = Island(name="myisland", component=lambda: "content")
+ html = island.render()
+
+ assert 'data-island="myisland"' in html
+
+ def test_data_island_id_attribute_present(self):
+ """Rendered div must carry data-island-id matching island._id."""
+ island = Island(name="myisland", component=lambda: "content")
+ html = island.render()
+
+ assert f'data-island-id="{island._id}"' in html
+
+ def test_data_hydrate_attribute_present(self):
+ """Rendered div must carry data-hydrate matching the client strategy."""
+ island = Island(name="myisland", component=lambda: "content", client="visible")
+ html = island.render()
+
+ assert 'data-hydrate="visible"' in html
+
+ def test_default_hydrate_strategy_is_idle(self):
+ """Default data-hydrate value should be 'idle'."""
+ island = Island(name="myisland", component=lambda: "content")
+ html = island.render()
+
+ assert 'data-hydrate="idle"' in html
+
+ def test_props_serialised_into_data_props(self):
+ """Props should be serialised and HTML-escaped into data-props."""
+ props = {"title": "Hello", "count": 3}
+ island = Island(name="card", component=lambda **kw: "x", props=props)
+ html = island.render()
+
+ assert "data-props=" in html
+ # Quotes in JSON are escaped to "
+ assert """ in html
+
+ def test_no_data_props_when_empty_props(self):
+ """data-props attribute should be absent when props is empty."""
+ island = Island(name="static", component=lambda: "body")
+ html = island.render()
+
+ assert "data-props" not in html
+
+
+# ---------------------------------------------------------------------------
+# Island.render() — component with .render() method
+# ---------------------------------------------------------------------------
+
+
+class TestIslandRenderWithRenderMethod:
+ """Tests for Island.render() when component returns an object with .render()."""
+
+ def test_calls_render_on_result(self):
+ """Should call .render() on the returned object."""
+ element = MockElement(" Rich element ")
+
+ def component_factory():
+ return element
+
+ island = Island(name="richcomp", component=component_factory)
+ html = island.render()
+
+ assert "Rich element " in html
+
+ def test_render_result_is_inner_html(self):
+ """The .render() output should appear as the inner HTML of the wrapper div."""
+ inner = "bold"
+ island = Island(name="bold", component=lambda: MockElement(inner))
+ html = island.render()
+
+ assert html.startswith("italic")
+
+ island = Island(name="em", component=component_factory)
+ html = island.render()
+
+ assert "italic" in html
+
+
+# ---------------------------------------------------------------------------
+# Island.render() — non-callable component
+# ---------------------------------------------------------------------------
+
+
+class TestIslandRenderNonCallable:
+ """Tests for Island.render() when component is not callable."""
+
+ def test_non_callable_is_stringified(self):
+ """A non-callable component should be converted with str()."""
+ island = Island(name="raw", component="static")
+ html = island.render()
+
+ assert "static" in html
+
+
+# ---------------------------------------------------------------------------
+# Island.render() — client_only=True
+# ---------------------------------------------------------------------------
+
+
+class TestIslandRenderClientOnly:
+ """Tests for Island.render() when client_only=True."""
+
+ def test_placeholder_comment_is_used(self):
+ """client_only island should render the loading placeholder comment."""
+ island = Island(name="spa", component=lambda: "should not appear", client_only=True)
+ html = island.render()
+
+ assert "" in html
+
+ def test_component_is_not_called(self):
+ """The component function should never be invoked for client_only islands."""
+ called = []
+
+ def spy_component():
+ called.append(True)
+ return "content"
+
+ island = Island(name="spa", component=spy_component, client_only=True)
+ island.render()
+
+ assert called == []
+
+ def test_hydration_attributes_still_present(self):
+ """Hydration attributes must still be present for client_only islands."""
+ island = Island(name="spa", component=lambda: None, client_only=True, client="load")
+ html = island.render()
+
+ assert 'data-island="spa"' in html
+ assert 'data-hydrate="load"' in html
+
+
+# ---------------------------------------------------------------------------
+# Island.render() — media strategy
+# ---------------------------------------------------------------------------
+
+
+class TestIslandRenderMediaStrategy:
+ """Tests for Island.render() with client='media' and a media query."""
+
+ def test_data_media_attribute_present(self):
+ """data-media should appear when client='media' and media is set."""
+ island = Island(
+ name="responsive",
+ component=lambda: "content",
+ client="media",
+ media="(max-width: 768px)",
+ )
+ html = island.render()
+
+ assert 'data-media="(max-width: 768px)"' in html
+
+ def test_data_media_absent_for_non_media_strategy(self):
+ """data-media should not appear when client strategy is not 'media'."""
+ island = Island(
+ name="responsive",
+ component=lambda: "content",
+ client="visible",
+ media="(max-width: 768px)",
+ )
+ html = island.render()
+
+ assert "data-media" not in html
+
+ def test_data_media_absent_when_media_is_none(self):
+ """data-media should not appear when media is None even if client='media'."""
+ island = Island(
+ name="responsive",
+ component=lambda: "content",
+ client="media",
+ media=None,
+ )
+ html = island.render()
+
+ assert "data-media" not in html
+
+
+# ---------------------------------------------------------------------------
+# Island.render() — error handling
+# ---------------------------------------------------------------------------
+
+
+class TestIslandRenderErrorHandling:
+ """Tests for Island.render() error handling when the component raises."""
+
+ def test_error_renders_comment(self):
+ """A component that raises should produce an HTML comment with the error."""
+
+ def broken_component():
+ raise ValueError("something went wrong")
+
+ island = Island(name="broken", component=broken_component)
+
+ with patch("nitro.core.islands.warning"):
+ html = island.render()
+
+ assert " |