Skip to content

Commit deee07a

Browse files
committed
prek
1 parent 446def9 commit deee07a

8 files changed

Lines changed: 552 additions & 86 deletions

File tree

noxfile.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import nox
2+
3+
4+
@nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"])
5+
def tests(session):
6+
session.run("uv", "run", "--python", session.python, "pytest", external=True)

pyproject.toml

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,44 @@ name = "render-engine-api"
77
dynamic = ["version"]
88
description = "Shared API layer for render-engine CLI, TUI, and other tools"
99
readme = "README.md"
10-
requires-python = ">=3.12"
10+
requires-python = ">=3.10"
1111
license = "MIT"
1212
dependencies = [
13-
"toml>=0.10.2"
13+
"rich>=13.0",
14+
"tomli>=1.0; python_version < '3.11'"
1415
]
1516

1617
[dependency-groups]
1718
dev = [
19+
"nox",
1820
"pytest",
1921
"pytest-cov",
22+
"hypothesis",
2023
"ty",
2124
"deptry"
2225
]
26+
27+
[tool.pytest.ini_options]
28+
pythonpath = ["render_engine_api"]
29+
addopts = ["--cov=render_engine_api", "--cov-report=term-missing", "-ra", "-q"]
30+
31+
[tool.semantic_release]
32+
version_toml = "pyproject.toml:project.version"
33+
branch = "main"
34+
35+
[tool.ruff]
36+
line-length = 120
37+
indent-width = 4
38+
target-version = "py310"
39+
40+
[tool.ruff.lint]
41+
select = ["E", "F", "I", "UP"]
42+
43+
[tool.ty.environment]
44+
python = ".venv"
45+
46+
[tool.ty.rules]
47+
unresolved-import = "warn"
48+
49+
[tool.deptry]
50+
extend_exclude = ["noxfile.py"]

render-engine-api/config.py

Lines changed: 0 additions & 66 deletions
This file was deleted.

render-engine-api/site.py

Lines changed: 0 additions & 10 deletions
This file was deleted.

render_engine_api/config.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import sys
2+
3+
# TODO: Remove tomli fallback once Python 3.10 is no longer supported
4+
if sys.version_info >= (3, 11):
5+
import tomllib
6+
else:
7+
import tomli as tomllib
8+
9+
from dataclasses import dataclass
10+
from os import getenv
11+
from pathlib import Path
12+
13+
from rich import print as rprint
14+
15+
CONFIG_FILE_NAME = Path("pyproject.toml")
16+
17+
18+
@dataclass
19+
class ApiConfig:
20+
"""Handles loading and storing the config from disk"""
21+
22+
# Initialize the arguments and default values
23+
_module: str | None = None
24+
_site: str | None = None
25+
_collection: str | None = None
26+
_config_loaded: bool = False
27+
config_file: str | Path | None = CONFIG_FILE_NAME
28+
29+
def __post_init__(self):
30+
self._editor = getenv("EDITOR")
31+
32+
# Properties are only updated if needed.
33+
# Check via self.load_config if previously run.
34+
@property
35+
def module(self):
36+
self.load_config()
37+
return self._module
38+
39+
@property
40+
def site(self):
41+
self.load_config()
42+
return self._site
43+
44+
@property
45+
def collection(self):
46+
self.load_config()
47+
return self._collection
48+
49+
@property
50+
def editor(self):
51+
self.load_config()
52+
return self._editor
53+
54+
def load_config(self) -> None:
55+
"""
56+
Load the config from the file.
57+
This should only be run once per ApiConfig call.
58+
"""
59+
60+
if self._config_loaded or not self.config_file:
61+
return
62+
63+
# set self.config_loaded to prevent from running multiple times
64+
self._config_loaded = True
65+
66+
stored_config = {}
67+
try:
68+
with open(self.config_file, "rb") as stored_config_file:
69+
try:
70+
stored_config = (
71+
tomllib.load(stored_config_file).get("tool", {}).get("render-engine", {}).get("cli", {})
72+
)
73+
except tomllib.TOMLDecodeError as exc:
74+
# TODO: Raise a custom except that can be caught in try/except in tooling
75+
# raise ConfigFileError("Error parsing config_file") from exc
76+
rprint(
77+
f"[red]Encountered an error while parsing {self.config_file}[/red]\n{exc}\n",
78+
file=sys.stderr,
79+
)
80+
return
81+
else:
82+
rprint(f"Config loaded from {self.config_file}")
83+
except FileNotFoundError:
84+
# TODO: Raise a custom except that can be caught in try/except in tooling
85+
rprint(f"No config file found at {self.config_file}")
86+
return
87+
88+
self._editor = stored_config.get("editor", self._editor)
89+
self._module = stored_config.get("module")
90+
self._site = stored_config.get("site")
91+
self._collection = stored_config.get("collection")

tests/test_config.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import tempfile
2+
from pathlib import Path
3+
4+
from hypothesis import given, settings
5+
from hypothesis import strategies as st
6+
7+
from render_engine_api.config import ApiConfig
8+
9+
10+
def _write_config(tmp_path, content):
11+
"""Helper to write a pyproject.toml in tmp_path."""
12+
config_file = tmp_path / "pyproject.toml"
13+
config_file.write_text(content)
14+
return config_file
15+
16+
17+
# Strategy for valid TOML-safe identifier strings (no quotes, newlines, etc.)
18+
toml_safe_text = st.text(
19+
alphabet=st.characters(whitelist_categories=("L", "N"), whitelist_characters="_-"),
20+
min_size=1,
21+
max_size=50,
22+
)
23+
24+
# Strategy that produces an optional config key (present or absent)
25+
optional_toml_value = st.one_of(st.none(), toml_safe_text)
26+
27+
28+
class TestAPIConfigLoadConfig:
29+
"""Tests for APIConfig.load_config parsing pyproject.toml."""
30+
31+
@given(
32+
module=optional_toml_value,
33+
site=optional_toml_value,
34+
collection=optional_toml_value,
35+
)
36+
@settings(max_examples=50, deadline=None)
37+
def test_loads_module_site_from_valid_config(self, module, site, collection):
38+
"""Config correctly populates module, site, and collection from any combination of keys."""
39+
with tempfile.TemporaryDirectory() as tmpdir:
40+
tmp_path = Path(tmpdir)
41+
lines = ["[tool.render-engine.cli]"]
42+
if module is not None:
43+
lines.append(f'module = "{module}"')
44+
if site is not None:
45+
lines.append(f'site = "{site}"')
46+
if collection is not None:
47+
lines.append(f'collection = "{collection}"')
48+
49+
temp_config_file = _write_config(tmp_path, "\n".join(lines) + "\n")
50+
config = ApiConfig(config_file=temp_config_file)
51+
config.load_config()
52+
53+
assert config._module == module
54+
assert config._site == site
55+
assert config._collection == collection
56+
57+
def test_config_file_not_passed(self):
58+
"""Returns None for all properties when no config_file is passed."""
59+
config = ApiConfig(config_file=None) # intentional path that doesn't exist
60+
assert config.module is None
61+
assert config.site is None
62+
assert config.collection is None
63+
# Editor pulls from editor by default
64+
65+
def test_config_file_not_found(self, tmp_path):
66+
"""Returns None for all properties when the supplied config file does not exist."""
67+
config = ApiConfig(config_file=tmp_path / "no-pyproject.toml") # intentional path that doesn't exist
68+
assert config.module is None
69+
assert config.site is None
70+
assert config.collection is None
71+
# Editor pulls from editor by default
72+
73+
def test_invalid_toml_prints_error_and_returns_none(self, tmp_path):
74+
"""Returns None for all properties when the config file contains invalid TOML."""
75+
config_file = _write_config(tmp_path, "not valid toml [[[")
76+
config = ApiConfig(config_file=config_file)
77+
assert config.module is None
78+
assert config.site is None
79+
assert config.collection is None
80+
81+
def test_config_file_not_ran_if_self_config_loaded_equals_true(self, tmp_path):
82+
config_file = _write_config(
83+
tmp_path,
84+
content="""
85+
module="app"
86+
site="app"
87+
editor="nvim"
88+
""",
89+
)
90+
config = ApiConfig(config_file=config_file, _config_loaded=True)
91+
assert config.module is None
92+
assert config.site is None
93+
assert config.collection is None
94+
95+
def test_editor_from_config(self, tmp_path, monkeypatch):
96+
"""Reads the editor value from the config file."""
97+
config_file = _write_config(tmp_path, content='[tool.render-engine.cli]\neditor="nvim"\n')
98+
config = ApiConfig(config_file=config_file)
99+
assert config.editor == "nvim"
100+
101+
def test_editor_falls_back_to_env(self, tmp_path, monkeypatch):
102+
"""Falls back to the EDITOR environment variable when not in config."""
103+
monkeypatch.setenv("EDITOR", "fake-editor")
104+
config = ApiConfig()
105+
assert config.editor == "fake-editor"
106+
107+
def test_editor_none_when_not_set(self, monkeypatch):
108+
"""Returns None when editor is not in config or environment."""
109+
monkeypatch.delenv("EDITOR")
110+
config = ApiConfig()
111+
assert config.editor is None
112+
113+
114+
class TestApiConfigLazyLoading:
115+
"""Tests that ApiConfig lazily loads the config on first property access."""
116+
117+
def test_config_not_loaded_until_property_accessed(self, tmp_path):
118+
"""Config file is not read until a property is first accessed."""
119+
config_file = _write_config(
120+
tmp_path,
121+
"""
122+
[tool.render-engine.cli]
123+
module = "myapp"
124+
site = "MySite"
125+
""",
126+
)
127+
config = ApiConfig(config_file=config_file)
128+
assert config._config_loaded is False
129+
_ = config.module
130+
assert config._config_loaded is True

0 commit comments

Comments
 (0)