Skip to content

Commit 6c6bf28

Browse files
committed
added more formal tests; updated json logger dependency; version bump to 0.0.60
1 parent 1d9d95e commit 6c6bf28

22 files changed

Lines changed: 3319 additions & 4 deletions

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,5 @@ dmypy.json
129129
.pyre/
130130
/.idea/
131131

132-
/staging/
132+
/staging/
133+
/.claude/CLAUDE.md

CLAUDE.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Project Overview
6+
7+
Scitrera Application Framework (SAF) is a lightweight Python application framework for services, CLIs, and desktop apps. It provides:
8+
- Environment/variables abstraction with layered sources and ergonomic access
9+
- Structured logging with JSON formatting option
10+
- Simple plugin/extension system supporting dependency injection and OSGi-style multi-extensions
11+
- Optional stateful working directory management
12+
- Background execution, multi-tenant configuration, and Pyroscope profiling plugins
13+
14+
## Development Commands
15+
16+
```bash
17+
# Install (editable)
18+
pip install -e .
19+
20+
# Install test dependencies
21+
pip install pytest pytest-cov python-dotenv
22+
23+
# Run full test suite
24+
pytest tests/ -v
25+
26+
# Run a single test file
27+
pytest tests/test_api_variables.py -v
28+
29+
# Run a single test
30+
pytest tests/test_api_variables.py::TestVariablesBasic::test_set_and_get -v
31+
32+
# Run with coverage
33+
pytest tests/ --cov=scitrera_app_framework --cov-report=term-missing
34+
35+
```
36+
37+
## Architecture
38+
39+
### Core Initialization Flow
40+
41+
1. `init_framework(app_name)` → creates/returns a `Variables` instance
42+
2. Logging is configured (JSON or %-format based on `LOGGING_FORMAT`)
43+
3. Stateful paths optionally set up under `STATEFUL_ROOT`
44+
4. Shutdown hooks registered (SIGTERM or atexit)
45+
5. Plugins registered and initialized
46+
47+
Alternate init functions with different defaults:
48+
- `init_framework_desktop()` – uses `~/.config/$APP_NAME`, atexit hooks
49+
- `init_framework_test_harness()` – DEBUG logging, no stateful/shutdown
50+
- `init_framework_embedded()` – avoids overriding external logging config
51+
52+
### Variables System (`api/variables.py`)
53+
54+
The `Variables` class is the central configuration container. It searches sources in priority order:
55+
1. Process environment (`os.environ` with uppercase keys)
56+
2. Local settings (`v.set()`)
57+
3. Additional sources added via `v.add_source()`
58+
4. Fallback defaults
59+
60+
Key methods:
61+
- `v.environ(key, default=, type_fn=)` – get with default/type registration
62+
- `v.set(key, value)` – set local value
63+
- `v.get_by_prefix(prefix)` – extract namespaced config as dict (useful for kwargs)
64+
- `v.import_from_env_by_prefix(prefix)` – import env vars matching prefix
65+
66+
`EnvPlacement` enum controls where environment appears in search order (TOP, BOTTOM, BOTTOM2, IGNORED).
67+
68+
### Plugin System (`api/plugins.py`, `core/plugins.py`)
69+
70+
Plugins implement the `Plugin` abstract class:
71+
- `extension_point_name(v)` – the named slot this plugin fills
72+
- `is_enabled(v)` – for single-extension mode (dependency injection)
73+
- `is_multi_extension(v)` – for OSGi-style multiple implementations
74+
- `get_dependencies(v)` – list of extension points that must init first
75+
- `initialize(v, logger)` – returns the extension value
76+
- `shutdown(v, logger, value)` – cleanup
77+
78+
Registration:
79+
```python
80+
register_plugin(MyPlugin, v, init=True) # register and initialize
81+
value = get_extension('ext-name', v) # single extension
82+
values = get_extensions('ext-name', v) # multi-extension (returns dict)
83+
```
84+
85+
Built-in plugins:
86+
- `EXT_BACKGROUND_EXEC` – thread pool via `get_background_exec()`
87+
- `EXT_PROGRESS_TRACKER` – progress tracking for UIs
88+
89+
### Package Layout
90+
91+
```
92+
scitrera_app_framework/
93+
├── api/ # Variables and Plugin base types (public API)
94+
├── core/ # Framework init, logging, plugin registry (internal)
95+
├── base_plugins/ # Built-in optional plugins (bg_exec, progress_tracker)
96+
├── ext_plugins/ # Optional extensions (pyroscope, multi-tenant)
97+
├── k8s/ # Kubernetes utilities (apply_yaml_object, start_pod, etc.)
98+
├── slaunch/ # Conda environment management and app launching utilities
99+
└── util/ # Parsing, imports, async helpers (no api/core deps)
100+
```
101+
102+
## Code Conventions
103+
104+
- Internal framework variables use "epp" keys: `=|name|` (equal-pipe-pipe pattern)
105+
- Type functions for environment parsing: `ext_parse_bool`, `ext_parse_csv`, `ext_get_python`
106+
- Use lazy string formatting in logging: `logger.debug("item: %s", item)` not f-strings
107+
- The `v` parameter is conventionally the Variables instance; `None` uses the default singleton
108+
109+
## Key Environment Variables
110+
111+
Core:
112+
- `APP_NAME` – override computed app name
113+
- `LOGGING_LEVEL` – log level (default from init kwarg)
114+
- `LOGGING_FORMAT``'json'` for JSON logs, or a %-format string
115+
116+
Stateful:
117+
- `STATEFUL_ROOT` – root directory for stateful data (default: `./scratch`)
118+
- `SAF_SETUP_STATEFUL` – enable/disable stateful features
119+
- `SAF_STATEFUL_CHDIR` – whether to chdir into stateful path
120+
121+
Plugins:
122+
- `SAF_BASE_PLUGINS` – auto-register base plugins
123+
- `PYROSCOPE_ENABLED` – enable Pyroscope profiling
124+
- `SAF_MULTITENANT_ENABLED` – enable multi-tenant plugin

pytest.ini

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[pytest]
2+
testpaths = tests
3+
python_files = test_*.py
4+
python_classes = Test*
5+
python_functions = test_*
6+
addopts = -v --tb=short
7+
filterwarnings =
8+
ignore::DeprecationWarning
9+
ignore::PendingDeprecationWarning
10+
markers =
11+
slow: marks tests as slow (deselect with '-m "not slow"')
12+
integration: marks tests as integration tests

requirements-test.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Test dependencies for scitrera-app-framework
2+
pytest>=7.0.0
3+
pytest-cov>=4.0.0
4+
5+
# Optional: for testing env file functionality
6+
python-dotenv>=1.0.0

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
setuptools.setup(
77
name="scitrera-app-framework",
8-
version="0.0.59",
8+
version="0.0.60",
99
author="Scitrera LLC",
10-
author_email="sales@scitrera.com",
10+
author_email="open-source-team@scitrera.com",
1111
description="Common Application Framework Code and Utilities",
1212
long_description=readme_txt,
1313
long_description_content_type="text/markdown",
@@ -16,7 +16,7 @@
1616
install_requires=[
1717
'botwinick-utils>=0.0.20',
1818
'vpd',
19-
'python-json-logger<3.0.0',
19+
'python-json-logger>=4.0.0',
2020
],
2121
classifiers=[
2222
'Development Status :: 4 - Beta',
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

tests/conftest.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""
2+
Pytest configuration and shared fixtures for scitrera_app_framework tests.
3+
"""
4+
import os
5+
import sys
6+
import pytest
7+
import logging
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def reset_framework_state():
12+
"""Reset framework global state before each test."""
13+
# Import here to avoid circular imports
14+
import scitrera_app_framework.core.core as core_module
15+
import scitrera_app_framework.core.plugins as plugins_module
16+
17+
# Store original state
18+
original_default_vars = core_module._default_vars_inst
19+
original_sigterm_hooks = core_module._sigterm_hooks.copy()
20+
21+
yield
22+
23+
# Reset state after test
24+
core_module._default_vars_inst = original_default_vars
25+
core_module._sigterm_hooks.clear()
26+
core_module._sigterm_hooks.extend(original_sigterm_hooks)
27+
28+
29+
@pytest.fixture
30+
def clean_env():
31+
"""Provide a clean environment, removing SAF-related env vars temporarily."""
32+
saf_vars = {k: v for k, v in os.environ.items()
33+
if k.startswith('SAF_') or k.startswith('LOGGING_') or k == 'APP_NAME'}
34+
for k in saf_vars:
35+
del os.environ[k]
36+
37+
yield
38+
39+
# Restore
40+
os.environ.update(saf_vars)
41+
42+
43+
@pytest.fixture
44+
def temp_env():
45+
"""Context manager to temporarily set environment variables."""
46+
original = {}
47+
48+
def _set_env(**kwargs):
49+
for k, v in kwargs.items():
50+
if k in os.environ:
51+
original[k] = os.environ[k]
52+
os.environ[k] = str(v)
53+
54+
yield _set_env
55+
56+
# Cleanup
57+
for k in list(os.environ.keys()):
58+
if k in original:
59+
os.environ[k] = original[k]
60+
elif k not in original and k in os.environ:
61+
# Only delete if we added it
62+
pass
63+
64+
65+
@pytest.fixture
66+
def fresh_variables():
67+
"""Create a fresh Variables instance for testing."""
68+
from scitrera_app_framework.api import Variables
69+
return Variables()
70+
71+
72+
@pytest.fixture
73+
def capture_logs():
74+
"""Capture log output for assertions."""
75+
class LogCapture(logging.Handler):
76+
def __init__(self):
77+
super().__init__()
78+
self.records = []
79+
80+
def emit(self, record):
81+
self.records.append(record)
82+
83+
def get_messages(self, level=None):
84+
if level is None:
85+
return [r.getMessage() for r in self.records]
86+
return [r.getMessage() for r in self.records if r.levelno == level]
87+
88+
handler = LogCapture()
89+
handler.setLevel(logging.DEBUG)
90+
91+
yield handler
92+
93+
# Cleanup happens automatically as handler goes out of scope

0 commit comments

Comments
 (0)