Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import nox


@nox.session(python=["3.10", "3.11", "3.12", "3.13", "3.14"])
def tests(session):
session.run("uv", "run", "--python", session.python, "pytest", external=True)
32 changes: 30 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,44 @@ name = "render-engine-api"
dynamic = ["version"]
description = "Shared API layer for render-engine CLI, TUI, and other tools"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.10"
license = "MIT"
dependencies = [
"toml>=0.10.2"
"rich>=13.0",
"tomli>=1.0; python_version < '3.11'"
]

[dependency-groups]
dev = [
"nox",
"pytest",
"pytest-cov",
"hypothesis",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should at least pin >= 6.0.0

"ty",
"deptry"
]

[tool.pytest.ini_options]
pythonpath = ["render_engine_api"]
addopts = ["--cov=render_engine_api", "--cov-report=term-missing", "-ra", "-q"]

[tool.semantic_release]
version_toml = "pyproject.toml:project.version"
branch = "main"

[tool.ruff]
line-length = 120
indent-width = 4
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

[tool.ty.environment]
python = ".venv"

[tool.ty.rules]
unresolved-import = "warn"

[tool.deptry]
extend_exclude = ["noxfile.py"]
66 changes: 0 additions & 66 deletions render-engine-api/config.py

This file was deleted.

10 changes: 0 additions & 10 deletions render-engine-api/site.py

This file was deleted.

File renamed without changes.
91 changes: 91 additions & 0 deletions render_engine_api/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import sys

# TODO: Remove tomli fallback once Python 3.10 is no longer supported
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib

from dataclasses import dataclass
from os import getenv
from pathlib import Path

from rich import print as rprint

CONFIG_FILE_NAME = Path("pyproject.toml")


@dataclass
class ApiConfig:
"""Handles loading and storing the config from disk"""

# Initialize the arguments and default values
_module: str | None = None
_site: str | None = None
_collection: str | None = None
_config_loaded: bool = False
config_file: str | Path | None = CONFIG_FILE_NAME
Comment on lines +23 to +27
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dataclass uses mutable default values for private attributes. While None, False, and instances are safe, if this pattern is extended to mutable types like lists or dicts in the future, it could cause bugs. Consider using field(default_factory=...) from dataclasses for any future mutable defaults.

Copilot uses AI. Check for mistakes.

def __post_init__(self):
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The config_file parameter accepts str | Path | None, but when passed as a string, it won't be converted to a Path object before use in open(). While open() accepts both strings and Path objects, it's better to normalize config_file to a Path in __post_init__ for consistency and to enable Path-specific methods. Add: if isinstance(self.config_file, str): self.config_file = Path(self.config_file) in __post_init__.

Suggested change
def __post_init__(self):
def __post_init__(self):
if isinstance(self.config_file, str):
self.config_file = Path(self.config_file)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brass75 what do you think about this suggestion?

We aren't doing anything other than reading the path so I'm not too concerned.

self._editor = getenv("EDITOR")

# Properties are only updated if needed.
# Check via self.load_config if previously run.
@property
def module(self):
self.load_config()
return self._module

@property
def site(self):
self.load_config()
return self._site

@property
def collection(self):
self.load_config()
return self._collection

@property
def editor(self):
self.load_config()
return self._editor

def load_config(self) -> None:
"""
Load the config from the file.
This should only be run once per ApiConfig call.
"""

if self._config_loaded or not self.config_file:
return

# set self.config_loaded to prevent from running multiple times
self._config_loaded = True

stored_config = {}
try:
with open(self.config_file, "rb") as stored_config_file:
try:
stored_config = (
tomllib.load(stored_config_file).get("tool", {}).get("render-engine", {}).get("cli", {})
)
except tomllib.TOMLDecodeError as exc:
# TODO: Raise a custom except that can be caught in try/except in tooling
# raise ConfigFileError("Error parsing config_file") from exc
rprint(
f"[red]Encountered an error while parsing {self.config_file}[/red]\n{exc}\n",
file=sys.stderr,
)
return
else:
rprint(f"Config loaded from {self.config_file}")
except FileNotFoundError:
# TODO: Raise a custom except that can be caught in try/except in tooling
rprint(f"No config file found at {self.config_file}")
return
Comment on lines +82 to +86
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The output messages from load_config (lines 82 and 85) will be printed every time a config is loaded during tests, which could create noisy test output. Consider using a logging framework or adding a quiet parameter to suppress output during testing, or capturing the output in tests to verify the behavior.

Copilot uses AI. Check for mistakes.

Comment on lines +81 to +87
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The success message "Config loaded from {self.config_file}" is printed even when the TOML file doesn't contain the expected [tool.render-engine.cli] section, resulting in an empty stored_config dict. This could mislead users into thinking their config was loaded successfully when it wasn't. Consider only printing the success message when the config section is found and has values.

Suggested change
else:
rprint(f"Config loaded from {self.config_file}")
except FileNotFoundError:
# TODO: Raise a custom except that can be caught in try/except in tooling
rprint(f"No config file found at {self.config_file}")
return
except FileNotFoundError:
# TODO: Raise a custom except that can be caught in try/except in tooling
rprint(f"No config file found at {self.config_file}")
return
if stored_config:
rprint(f"Config loaded from {self.config_file}")

Copilot uses AI. Check for mistakes.
self._editor = stored_config.get("editor", self._editor)
self._module = stored_config.get("module")
self._site = stored_config.get("site")
self._collection = stored_config.get("collection")
134 changes: 134 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import tempfile
from pathlib import Path

from hypothesis import given, settings
from hypothesis import strategies as st

from render_engine_api.config import ApiConfig


def _write_config(tmp_path, content):
"""Helper to write a pyproject.toml in tmp_path."""
config_file = tmp_path / "pyproject.toml"
config_file.write_text(content)
return config_file


# Strategy for valid TOML-safe identifier strings (no quotes, newlines, etc.)
toml_safe_text = st.text(
alphabet=st.characters(categories=("L", "N"), include_characters="_-"),
min_size=1,
max_size=50,
)

# Strategy that produces an optional config key (present or absent)
optional_toml_value = st.one_of(st.none(), toml_safe_text)


class TestAPIConfigLoadConfig:
"""Tests for APIConfig.load_config parsing pyproject.toml."""

@given(
module=optional_toml_value,
site=optional_toml_value,
collection=optional_toml_value,
)
@settings(max_examples=50, deadline=None)
def test_loads_module_site_from_valid_config(self, module, site, collection):
"""Config correctly populates module, site, and collection from any combination of keys."""
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
lines = ["[tool.render-engine.cli]"]
if module is not None:
lines.append(f'module = "{module}"')
if site is not None:
lines.append(f'site = "{site}"')
if collection is not None:
lines.append(f'collection = "{collection}"')

temp_config_file = _write_config(tmp_path, "\n".join(lines) + "\n")
config = ApiConfig(config_file=temp_config_file)
config.load_config()

assert config._module == module
assert config._site == site
assert config._collection == collection

def test_config_file_not_passed(self):
"""Returns None for all properties when no config_file is passed."""
config = ApiConfig(config_file=None) # intentional path that doesn't exist
assert config.module is None
assert config.site is None
assert config.collection is None
# Editor pulls from editor by default

def test_config_file_not_found(self, tmp_path):
"""Returns None for all properties when the supplied config file does not exist."""
config = ApiConfig(config_file=tmp_path / "no-pyproject.toml") # intentional path that doesn't exist
assert config.module is None
assert config.site is None
assert config.collection is None
# Editor pulls from editor by default

def test_invalid_toml_prints_error_and_returns_none(self, tmp_path, capsys):
"""Returns None for all properties when the config file contains invalid TOML."""
config_file = _write_config(tmp_path, "not valid toml [[[")
config = ApiConfig(config_file=config_file)
# Accessing properties should trigger config loading and TOML error handling.
assert config.module is None
assert config.site is None
assert config.collection is None
captured = capsys.readouterr()
# Ensure that an error message was written to stderr.
assert captured.err

def test_config_file_not_ran_if_self_config_loaded_equals_true(self, tmp_path):
config_file = _write_config(
tmp_path,
content="""
module="app"
site="app"
editor="nvim"
""",
)
config = ApiConfig(config_file=config_file, _config_loaded=True)
assert config.module is None
assert config.site is None
assert config.collection is None

def test_editor_from_config(self, tmp_path, monkeypatch):
"""Reads the editor value from the config file."""
config_file = _write_config(tmp_path, content='[tool.render-engine.cli]\neditor="nvim"\n')
config = ApiConfig(config_file=config_file)
assert config.editor == "nvim"

def test_editor_falls_back_to_env(self, tmp_path, monkeypatch):
"""Falls back to the EDITOR environment variable when not in config."""
monkeypatch.setenv("EDITOR", "fake-editor")
config = ApiConfig()
assert config.editor == "fake-editor"

def test_editor_none_when_not_set(self, monkeypatch):
"""Returns None when editor is not in config or environment."""
monkeypatch.delenv("EDITOR", raising=False)
config = ApiConfig()
assert config.editor is None


class TestApiConfigLazyLoading:
"""Tests that ApiConfig lazily loads the config on first property access."""

def test_config_not_loaded_until_property_accessed(self, tmp_path):
"""Config file is not read until a property is first accessed."""
config_file = _write_config(
tmp_path,
"""
[tool.render-engine.cli]
module = "myapp"
site = "MySite"
""",
)
config = ApiConfig(config_file=config_file)
assert config._config_loaded is False
_ = config.module
assert config._config_loaded is True
Loading