diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3068cd0..42a56e9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,24 +3,20 @@ SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas SPDX-License-Identifier: ISC --> -# Contributing +# Contributing to apywire -## Development Setup +Thanks for your interest in contributing! -1. Clone the repository -2. Install dependencies: `pip install -e .[dev]` -3. Run tests: `make test` - -## Commit Messages +For the complete development guide, please see **[docs/development.md](../docs/development.md)**. -Use meaningful titles and a brief body describing your changes. +## Quick Start -Commit titles must not exceed 72 characters. -Commit body lines must not exceed 72 characters. +1. Clone the repository +2. Run `make .venv && source .venv/bin/activate && make pip` +3. Run `make all` to verify everything works -## Code Quality +## Before Submitting a Pull Request -- Format: `make format` -- Lint: `make lint` -- Test: `make test` -- All: `make all` \ No newline at end of file +- **Development**: See [docs/development.md](../docs/development.md) for setup, testing, and workflow +- **Code Standards**: All code must pass `make lint` and maintain test coverage requirements +- **Commit Messages**: Keep titles ≤ 72 characters with meaningful descriptions diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 88efed9..9247d72 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,12 +10,13 @@ Brief description of changes. ## Type of Change - [ ] Bug fix +- [ ] Refactoring - [ ] New feature - [ ] Breaking change - [ ] Documentation update ## Checklist -- [ ] Tests pass -- [ ] Code formatted -- [ ] Documentation updated \ No newline at end of file +- [ ] Descriptive and concise commit message and PR details +- [ ] Docstrings updated +- [ ] Documentation updated diff --git a/.gitignore b/.gitignore index 2780449..e52da44 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Project Specific apywire/**.c +site/ # General .venv/ diff --git a/AGENTS.md b/AGENTS.md index 07ad1e8..e3df46e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,43 +5,58 @@ SPDX-License-Identifier: ISC --> # AI Coding Guidelines for apywire -## Overview +For detailed development workflow and standards, see **[docs/development.md](docs/development.md)**. + +## Project Context + apywire is a Python 3.12+ library for lazy object wiring and dependency injection. It uses spec-based configuration to instantiate objects on-demand with support for async access, thread safety, and code generation via Cython compilation. -## Core API +**Core API:** - **`Wiring(spec, thread_safe=False)`**: Main container for lazy instantiation from a spec dict - **`wired.name()`**: Returns callable `Accessor` that instantiates object on invocation - **`await wired.aio.name()`**: Async access via `AioAccessor` using `run_in_executor` -- **`wired.compile(aio=False, thread_safe=False)`**: Generates equivalent Python code as a `Compiled` class +- **`compiler = WiringCompiler(spec)`**: For compiling into python code instead of runtime +- **`code = compiler.compile(aio=True, thread_safe=True)`**: Generate python code for the spec - **Spec Format**: `{"module.Class name": {"param": "value", "ref": "{otherName}"}}` -## Architecture -- **Lazy Loading**: Objects instantiated via `__getattr__` only when accessed, cached after first use -- **Placeholders**: `{name}` in spec values resolve to other wired objects at instantiation -- **Thread Safety**: Optional `thread_safe=True` uses optimistic per-attribute locking with global fallback via `CompiledThreadSafeMixin` -- **Cython Build**: `setup.py` uses Cython to compile `wiring.py` → `wiring.c` with SPDX headers -- **Equivalence**: Compiled output behaves identically to runtime `Wiring` (lazy, async, thread-safe) - -## Development Workflow -- **Setup**: `make .venv` and `source .venv/bin/activate`, then `make pip` -- **Check**: `make all` (format, lint, coverage, build) -- **Test**: `pytest -xvs` (verbose), `make coverage` (95% required) -- **Build**: `make build` (Cython compile), `make dist` (package) -- **Clean**: `make clean` (remove artifacts/caches) - -## Code Standards -- **Typing**: Strict mypy with `disallow_any_*=true`, use `object` not `Any` -- **Style**: 79-char lines, `black` + `isort` formatting -- **Licensing**: SPDX headers required on all files, validate with `make reuse` -- **Tests**: Cover sync, async (`test_compile_aio.py`), threading (`test_threading.py`), edge cases - -## Key Components -- `apywire/wiring.py`: `Wiring`, `Accessor`, `AioAccessor`, `compile()` method -- `apywire/thread_safety.py`: `CompiledThreadSafeMixin` with optimistic/global locking -- `apywire/exceptions.py`: `WiringError`, `CircularWiringError`, `UnknownPlaceholderError`, `LockUnavailableError` -- `setup.py`: Cython build config with SPDX header injection - -## Examples +## Critical Architectural Patterns + +**Lazy Loading via `__getattr__`:** +Objects are instantiated only when accessed, using `__getattr__` to intercept attribute access and return `Accessor` callables that handle instantiation and caching. + +**Placeholder Resolution:** +Strings like `{name}` in spec values are converted to `_WiredRef` markers during parsing, then resolved to actual objects at instantiation time. See `constants.py` for delimiter patterns. + +**Thread Safety Model:** +Uses optimistic per-attribute locking (`dict[str, threading.Lock]`) with a global fallback lock. Thread-local state tracks the resolving stack for circular dependency detection. See `threads.py` for `ThreadSafeMixin` implementation. + +**Compilation Equivalence:** +Generated code must behave identically to runtime `Wiring`. The compiler generates accessor methods that replicate lazy loading, caching, and optional async/thread-safe behavior. + +## AI-Specific Development Notes + +**Strict Mypy Configuration:** +The project uses extremely strict mypy settings including `disallow_any_expr=true`. Never use `Any`—use `object` instead. All functions need complete type annotations. See [Code Standards](docs/development.md#type-annotations) for details. + +**Module Structure:** +- `wiring.py`: Base class `WiringBase`, type system, placeholder parsing +- `runtime.py`: Runtime `Wiring` class with `Accessor`/`AioAccessor` +- `compiler.py`: Code generation via `WiringCompiler` +- `threads.py`: Thread safety mixins and utilities +- `exceptions.py`: Custom exception classes +- `constants.py`: Shared constants (placeholder patterns, delimiters) + +**Testing Requirements:** +All changes must maintain 100% branch coverage. Test both runtime and compiled behavior, plus async and thread-safe variants where applicable. Use descriptive test names: `test___()`. + +**Common Gotchas:** +- SPDX headers required on all files (run `make format` to auto-add) +- 79-character line limit enforced by `black` +- Compiled output is verified against runtime behavior in tests +- Thread-safe tests use actual threading to verify lock behavior + +## Quick Reference + ```python # Basic lazy access wired = Wiring({"datetime.datetime now": {"year": 2025}}) @@ -54,5 +69,13 @@ obj = await wired.aio.now() wired = Wiring(spec, thread_safe=True) # Code generation -code = wired.compile(aio=True, thread_safe=True) +compiler = WiringCompiler(spec) +code = compiler.compile(aio=True, thread_safe=True) ``` + +**Make Commands:** +- `make all` - Complete check (format, lint, coverage, build) +- `make test` - Run pytest suite +- `make coverage` - Check coverage requirements +- `make lint` - Run reuse, flake8, mypy +- `make format` - Run black, isort, add SPDX headers diff --git a/Makefile b/Makefile index 3aa7dbc..c533386 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: ISC -.PHONY: all format lint test coverage clean publish pip reuse .venv +.PHONY: all format lint test coverage clean publish pip reuse .venv docs-serve docs-build all: format lint coverage build @@ -50,3 +50,9 @@ dist: publish: python -m twine upload dist/* + +docs-serve: + python -m mkdocs serve + +docs-build: + python -m mkdocs build diff --git a/README.md b/README.md index 3b614aa..4bff4ca 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,61 @@ SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas SPDX-License-Identifier: ISC --> + # apywire -A Python package to wire up objects. +[![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC) + +Lazy object wiring and dependency injection for Python 3.12+ + +## Features + +- 🚀 Lazy Loading +- ⚡ Async Support +- 🔒 Thread Safety +- 📦 Code Generation +- 📄 Naturally Configurable +- 🎯 Zero Dependencies + +## Installation + +```bash +pip install apywire +``` + +## Quick Example ---- +```python +from apywire import Wiring -Under development and unstable, API might change at any moment. +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, + "MyService service": {"start_time": "{now}"}, # Dependency injection +} + +wired = Wiring(spec) +service = wired.service() # Lazy instantiation + caching +``` + +## Documentation + +📚 **[Full Documentation](docs/index.md)** • [Getting Started](docs/getting-started.md) • [API Reference](docs/api-reference.md) • [Examples](docs/examples.md) + +Build docs locally: +```bash +make docs-serve # http://127.0.0.1:8000 +``` ## Development -See [AGENTS.md](AGENTS.md) for coding guidelines and architecture overview. +```bash +make .venv && source .venv/bin/activate # Setup +make all # Format, lint, test, build +``` + +See [AGENTS.md](AGENTS.md) for guidelines. + +## License + +ISC License - see [LICENSES/ISC.txt](LICENSES/ISC.txt) diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..3e5220f --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,231 @@ + + +# API Reference + +Comprehensive API documentation for apywire. + +## Core Classes + +### Wiring + +::: apywire.Wiring + options: + show_source: false + members: + - __init__ + +### WiringRuntime + +::: apywire.WiringRuntime + options: + show_source: false + +### WiringCompiler + +::: apywire.WiringCompiler + options: + show_source: false + members: + - __init__ + - compile + +### WiringBase + +::: apywire.WiringBase + options: + show_source: false + +## Accessor Types + +### Accessor + +::: apywire.Accessor + options: + show_source: false + +### AioAccessor + +::: apywire.AioAccessor + options: + show_source: false + +## Type Aliases + +### Spec + +```python +from apywire import Spec + +Spec = dict[str, dict[str | int, Any] | list[Any] | ConstantValue] +``` + +The `Spec` type represents the wiring specification dictionary. It maps wiring keys to configuration: + +- **Wiring keys** with format `"module.Class name"` define objects to be wired +- **Simple keys** without a class become constants +- **Values** can be dictionaries (keyword args), lists (positional args), or constants + +Example: + +```python +from apywire import Spec + +spec: Spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, + "pathlib.Path root": ["/home/user"], + "port": 8080, # Constant +} +``` + +### SpecEntry + +```python +from apywire import SpecEntry + +SpecEntry = dict[str | int, Any] +``` + +The `SpecEntry` type represents a single entry in the spec (the configuration for one wired object): + +```python +from apywire import SpecEntry + +entry: SpecEntry = { + "year": 2025, + "month": 1, + "day": 1, +} +``` + +## Exception Classes + +### WiringError + +::: apywire.WiringError + options: + show_source: false + +Base exception class for all apywire errors. + +### CircularWiringError + +::: apywire.CircularWiringError + options: + show_source: false + +Raised when a circular dependency is detected: + +```python +from apywire import CircularWiringError, Wiring + +spec = { + "MyClass a": {"dep": "{b}"}, + "MyClass b": {"dep": "{a}"}, +} + +wired = Wiring(spec) +try: + obj = wired.a() +except CircularWiringError as e: + print(f"Circular dependency: {e}") +``` + +### UnknownPlaceholderError + +::: apywire.UnknownPlaceholderError + options: + show_source: false + +Raised when a placeholder references a non-existent object: + +```python +from apywire import UnknownPlaceholderError, Wiring + +spec = { + "MyClass obj": {"dep": "{nonexistent}"}, +} + +wired = Wiring(spec) +try: + obj = wired.obj() +except UnknownPlaceholderError as e: + print(f"Unknown placeholder: {e}") +``` + +### LockUnavailableError + +::: apywire.LockUnavailableError + options: + show_source: false + +Raised in thread-safe mode when a lock cannot be acquired after maximum retry attempts: + +```python +from apywire import LockUnavailableError, Wiring + +wired = Wiring(spec, thread_safe=True, max_lock_attempts=1) + +try: + obj = wired.my_object() +except LockUnavailableError as e: + print(f"Could not acquire lock: {e}") +``` + +## Usage Examples + +### Basic Wiring + +```python +from apywire import Wiring + +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +wired = Wiring(spec) +dt = wired.now() # datetime.datetime(2025, 1, 1, 0, 0) +``` + +### Async Access + +```python +import asyncio +from apywire import Wiring + +async def main(): + wired = Wiring(spec) + obj = await wired.aio.my_object() + +asyncio.run(main()) +``` + +### Thread-Safe Instantiation + +```python +from apywire import Wiring + +wired = Wiring(spec, thread_safe=True) +# Safe to use across multiple threads +``` + +### Code Compilation + +```python +from apywire import WiringCompiler + +compiler = WiringCompiler(spec) +code = compiler.compile(aio=True, thread_safe=True) + +with open("compiled_wiring.py", "w") as f: + f.write(code) +``` + +## See Also + +- **[Getting Started](getting-started.md)** - Quick start guide +- **[User Guide](user-guide/index.md)** - Comprehensive usage documentation +- **[Examples](examples.md)** - Practical examples and patterns diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ead21db --- /dev/null +++ b/docs/development.md @@ -0,0 +1,383 @@ + + +# Development + +Contributing guide for apywire developers. + +## Getting Started + +### Prerequisites + +- Python 3.12 or later +- Git +- Make (optional but recommended) + +### Initial Setup + +Clone the repository and set up your development environment: + +```bash +git clone https://github.com/alganet/apywire.git +cd apywire + +# Create virtual environment and install dependencies +make .venv +source .venv/bin/activate +make pip +``` + +This will: +1. Create a `.venv` virtual environment +2. Install all development dependencies including mkdocs, pytest, mypy, etc. + +## Development Workflow + +### Running Tests + +```bash +# Run all tests with pytest +make test + +# Run tests with coverage +make coverage + +# Run verbose tests +pytest -xvs +``` + +The project enforces 95% test coverage or higher. New code should include comprehensive tests. + +### Code Formatting + +The project uses `black` and `isort` for code formatting: + +```bash +# Format all code +make format +``` + +This command: +1. Runs `reuse annotate` to add SPDX headers +2. Formats code with `black` (79-character line length) +3. Sorts imports with `isort` + +### Linting + +```bash +# Run all linters +make lint +``` + +This runs: +1. `reuse lint` - Validates SPDX licensing headers +2. `flake8` - Python style checker +3. `mypy` - Static type checker + +All linting must pass before submitting a pull request. + +### Building + +```bash +# Build Cython extension +make build + +# Build distribution packages +make dist +``` + +The build process compiles `apywire/wiring.py` to C using Cython for performance. + +### Complete Check + +Run all checks before committing: + +```bash +make all +``` + +This runs: `format` → `lint` → `coverage` → `build` + +## Project Structure + +``` +apywire/ +├── apywire/ # Main package +│ ├── __init__.py # Public API exports +│ ├── wiring.py # Base wiring functionality +│ ├── runtime.py # Runtime wiring implementation +│ ├── compiler.py # Code generation +│ ├── threads.py # Thread safety utilities +│ ├── exceptions.py # Exception classes +│ ├── constants.py # Constants and configuration +│ └── py.typed # PEP 561 marker file +├── tests/ # Test suite +│ ├── test_single.py # Runtime tests +│ ├── test_compile_aio.py # Async compilation tests +│ ├── test_threading.py # Thread safety tests +│ ├── test_factory_methods.py # Factory method tests +│ ├── test_edge_cases.py # Edge case tests +│ ├── test_stdlib_compat.py # Stdlib compatibility tests +│ └── test_internals.py # Internal implementation tests +├── docs/ # MkDocs documentation +├── pyproject.toml # Project configuration +├── setup.py # Build configuration (Cython) +├── Makefile # Development commands +└── README.md # Project readme +``` + +## Code Standards + +### Type Annotations + +apywire uses **strict mypy** with very aggressive settings: + +```toml +[tool.mypy] +strict = true +disallow_any_unimported = true +disallow_any_decorated = true +disallow_any_explicit = true +disallow_subclassing_any = true +disallow_any_expr = true +warn_return_any = true +``` + +Guidelines: +- **No `Any`**: Use `object` instead +- Full type annotations on all functions and methods +- Type all variables where type can't be inferred +- Use type aliases for complex types + +Example: + +```python +from typing import TypeAlias + +# Good +def process(data: dict[str, str]) -> list[str]: + return list(data.values()) + +# Bad - missing return type +def process(data: dict[str, str]): + return list(data.values()) +``` + +### Code Style + +- **Line length**: 79 characters (enforced by `black`) +- **Imports**: Sorted with `isort`, profile `black` +- **Docstrings**: Google style +- **Naming**: + - Classes: `PascalCase` + - Functions/methods: `snake_case` + - Constants: `UPPER_CASE` + - Private: `_leading_underscore` + +### SPDX Licensing + +All source files must have SPDX headers: + +```python +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC +``` + +Run `make format` to automatically add headers to new files. + +## Architecture + +### Core Concepts + +#### Lazy Loading + +Objects are instantiated via `__getattr__` only when accessed: + +```python +def __getattr__(self, name: str): + if name in self._values: + return Accessor(lambda: self._values[name]) + # Instantiate and cache... +``` + +#### Placeholder Resolution + +Strings like `{name}` are converted to `_WiredRef` markers during parsing: + +```python +class _WiredRef: + __slots__ = ("name",) + +def _resolve(self, obj): + if isinstance(obj, str) and self._is_placeholder(obj): + return _WiredRef(ref_name) + return obj +``` + +At instantiation time, `_WiredRef` markers are resolved to actual objects. + +#### Thread Safety + +Thread-safe mode uses: +1. **Per-attribute locks**: `dict[str, threading.Lock]` +2. **Global fallback lock**: Single `threading.Lock` +3. **Thread-local state**: For circular dependency detection + +See `apywire/threads.py` for details. + +#### Compilation + +The compiler generates Python code that: +1. Imports all required modules +2. Creates a `Compiled` class +3. Generates accessor methods for each wired object +4. Optionally includes async accessors and thread safety + +See `apywire/compiler.py` for the implementation. + +### Module Responsibilities + +- **`wiring.py`**: Base class, type system, placeholder parsing +- **`runtime.py`**: Runtime `Wiring` implementation, `Accessor`/`AioAccessor` +- **`compiler.py`**: Code generation via `WiringCompiler` +- **`threads.py`**: Thread safety mixins and utilities +- **`exceptions.py`**: Custom exception classes +- **`constants.py`**: Shared constants (delimiter, placeholder markers) + +## Testing + +### Test Organization + +- **`test_single.py`**: Runtime wiring tests (non-thread-safe) +- **`test_threading.py`**: Thread-safe wiring tests +- **`test_compile_aio.py`**: Async compilation tests +- **`test_factory_methods.py`**: Factory method tests +- **`test_edge_cases.py`**: Edge cases and error handling +- **`test_stdlib_compat.py`**: Standard library integration +- **`test_internals.py`**: Internal implementation details + +### Writing Tests + +Test naming convention: + +```python +def test___(): + """Brief description of what is being tested.""" + # Arrange + spec = {...} + + # Act + wired = Wiring(spec) + result = wired.something() + + # Assert + assert result == expected +``` + +Always test: +1. Runtime behavior +2. Compiled behavior (when applicable) +3. Async variants (when applicable) +4. Thread-safe variants (when applicable) +5. Error cases + +### Coverage + +Target: **95% branch coverage or higher** + +Check coverage: + +```bash +make coverage +``` + +View HTML coverage report: + +```bash +open htmlcov/index.html +``` + +## Documentation + +### Building Docs + +```bash +# Serve docs locally +make docs-serve + +# Build static site +make docs-build +``` + +### Writing Documentation + +- Use **GitHub Flavored Markdown** +- Include code examples that actually work +- Add SPDX headers to all `.md` files +- Use admonitions for important notes: + +```markdown +!!! note + This is a note + +!!! warning + This is a warning +``` + +## Pull Request Process + +1. **Create a branch** from `main`: + ```bash + git checkout -b feature/my-feature + ``` + +2. **Make changes** and ensure tests pass: + ```bash + make all + ``` + +3. **Commit changes** with descriptive messages: + ```bash + git commit -m "Add feature X to handle Y" + ``` + +4. **Push to GitHub**: + ```bash + git push origin feature/my-feature + ``` + +5. **Create pull request** on GitHub + +6. **Address review feedback** if any + +## Release Process + +(For maintainers) + +1. Update version in `pyproject.toml` +2. Update CHANGELOG (if present) +3. Run full test suite: `make all` +4. Build distribution: `make dist` +5. Tag release: `git tag v0.x.x` +6. Push tag: `git push origin v0.x.x` +7. Publish to PyPI: `make publish` + +## Getting Help + +- **Issues**: [GitHub Issues](https://github.com/alganet/apywire/issues) +- **Discussions**: [GitHub Discussions](https://github.com/alganet/apywire/discussions) +- **Documentation**: [Full Documentation](https://github.com/alganet/apywire) + +## License + +apywire is licensed under the ISC License. See [LICENSE](https://github.com/alganet/apywire/blob/main/LICENSES/ISC.txt) for details. + +## Next Steps + +- **[API Reference](api-reference.md)** - Detailed API documentation +- **[User Guide](user-guide/index.md)** - Learn how to use apywire +- **[Examples](examples.md)** - Practical examples diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..49e593b --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,510 @@ + + +# Examples + +Illustrative examples showing how apywire can be used to wire objects in Python applications. + +## Configuration Management + +Centralize configuration and inject it into services: + +```python +from apywire import Wiring +import os + +spec = { + # Configuration constants + "db_host": os.getenv("DB_HOST", "localhost"), + "db_port": int(os.getenv("DB_PORT", "5432")), + "db_name": os.getenv("DB_NAME", "myapp"), + "redis_url": os.getenv("REDIS_URL", "redis://localhost"), + "debug": os.getenv("DEBUG", "false") == "true", + + # Build database URL from config + "MyApp app": { + "db_host": "{db_host}", + "db_port": "{db_port}", + "db_name": "{db_name}", + "redis_url": "{redis_url}", + "debug": "{debug}", + }, +} + +wired = Wiring(spec) +app = wired.app() +``` + +## Database Connection Pool + +Set up a connection pool with dependency injection: + +```python +from apywire import Wiring + +spec = { + # Configuration + "database_url": "postgresql://localhost/mydb", + "pool_min": 1, + "pool_max": 20, + + # Connection pool + "psycopg2.pool.ThreadedConnectionPool pool": { + "minconn": "{pool_min}", + "maxconn": "{pool_max}", + "dsn": "{database_url}", + }, + + # Repository using the pool + "MyRepository users": { + "pool": "{pool}", + "table_name": "users", + }, +} + +wired = Wiring(spec, thread_safe=True) + +# Access repository +users_repo = wired.users() +``` + +## Multi-Layer Architecture + +Service layer with repositories and caching: + +```python +from apywire import Wiring + +spec = { + # Infrastructure layer + "psycopg2.connect database": { + "dsn": "postgresql://localhost/mydb", + }, + "redis.Redis cache": { + "host": "localhost", + "port": 6379, + }, + + # Repository layer + "UserRepository user_repo": { + "db": "{database}", + }, + "ProductRepository product_repo": { + "db": "{database}", + }, + + # Service layer + "UserService user_service": { + "repo": "{user_repo}", + "cache": "{cache}", + }, + "ProductService product_service": { + "repo": "{product_repo}", + "cache": "{cache}", + }, + + # Application layer + "Application app": { + "user_service": "{user_service}", + "product_service": "{product_service}", + }, +} + +wired = Wiring(spec, thread_safe=True) +app = wired.app() +``` + +## FastAPI Integration + +Use apywire with FastAPI for dependency injection: + +```python +from fastapi import FastAPI, Depends +from apywire import Wiring + +# Define your wiring spec +spec = { + "database_url": "postgresql://localhost/mydb", + "psycopg2.connect db": {"dsn": "{database_url}"}, + "UserRepository user_repo": {"db": "{db}"}, + "UserService user_service": {"repo": "{user_repo}"}, +} + +# Create wiring container +wired = Wiring(spec, thread_safe=True) + +# Create FastAPI app +app = FastAPI() + +# Dependency providers +async def get_user_service(): + """Provide UserService via dependency injection.""" + return await wired.aio.user_service() + +# Route using dependency +@app.get("/users/{user_id}") +async def get_user(user_id: int, service = Depends(get_user_service)): + """Get user by ID.""" + user = await service.get_user(user_id) + return user +``` + +## Testing with Mocks + +Replace real dependencies with mocks for testing: + +```python +from apywire import Wiring +from unittest.mock import Mock, MagicMock +import pytest + +# Production spec +production_spec = { + "psycopg2.connect db": {"dsn": "postgresql://prod/db"}, + "redis.Redis cache": {"url": "redis://prod"}, + "EmailService email": {"api_key": "real-api-key"}, + "UserService user_service": { + "db": "{db}", + "cache": "{cache}", + "email": "{email}", + }, +} + +# Test spec with mocks +test_spec = { + "unittest.mock.Mock db": {}, + "unittest.mock.Mock cache": {}, + "unittest.mock.Mock email": {}, + "UserService user_service": { + "db": "{db}", + "cache": "{cache}", + "email": "{email}", + }, +} + +# Pytest fixture +@pytest.fixture +def wired(): + return Wiring(test_spec) + +# Test +def test_user_service(wired): + service = wired.user_service() + + # Configure mocks + wired.db().execute = MagicMock(return_value=[{"id": 1, "name": "Alice"}]) + + # Test your service + users = service.get_users() + assert len(users) > 0 +``` + +## Environment-Based Configuration + +Different specs for different environments: + +```python +from apywire import Wiring +import os + +def get_spec(env: str): + """Get spec based on environment.""" + base_spec = { + "log_level": "DEBUG" if env == "dev" else "INFO", + } + + if env == "production": + base_spec.update({ + "database_url": "postgresql://prod-server/db", + "redis_url": "redis://prod-server", + "cache_ttl": 3600, + }) + elif env == "staging": + base_spec.update({ + "database_url": "postgresql://staging-server/db", + "redis_url": "redis://staging-server", + "cache_ttl": 600, + }) + else: # dev + base_spec.update({ + "database_url": "postgresql://localhost/dev_db", + "redis_url": "redis://localhost", + "cache_ttl": 60, + }) + + # Add wired services + base_spec.update({ + "psycopg2.connect db": {"dsn": "{database_url}"}, + "redis.Redis cache": { + "url": "{redis_url}", + "ttl": "{cache_ttl}", + }, + }) + + return base_spec + +# Usage +env = os.getenv("APP_ENV", "dev") +wired = Wiring(get_spec(env), thread_safe=env == "production") +``` + +## Factory Pattern + +Use factory methods for complex object creation: + +```python +from apywire import Wiring +from datetime import datetime + +spec = { + # Use factory method to create from timestamp + "datetime.datetime app_start.fromtimestamp": { + 0: 1704067200, # Jan 1, 2024 + }, + + # Use factory method with ISO format + "datetime.datetime launch_date.fromisoformat": { + "date_string": "2024-06-15T12:00:00", + }, + + # Custom factory method + "myapp.Config config.from_env": {}, # Loads from environment + + # Service using factory-created objects + "myapp.Scheduler scheduler": { + "start_time": "{app_start}", + "config": "{config}", + }, +} + +wired = Wiring(spec) +scheduler = wired.scheduler() +``` + +## Logging Setup + +Configure logging infrastructure: + +```python +from apywire import Wiring +import logging + +spec = { + # Log level configuration + "log_level": "INFO", + "log_format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + + # Logger instances + "logging.Logger app_logger": { + "name": "myapp", + }, + "logging.Logger db_logger": { + "name": "myapp.database", + }, + "logging.Logger api_logger": { + "name": "myapp.api", + }, + + # Services using loggers + "DatabaseService db_service": { + "logger": "{db_logger}", + }, + "APIService api_service": { + "logger": "{api_logger}", + }, +} + +wired = Wiring(spec) + +# Configure log levels +app_logger = wired.app_logger() +app_logger.setLevel(logging.INFO) + +# Use services with logging +db_service = wired.db_service() +``` + +## Microservices Communication + +Set up clients for microservices: + +```python +from apywire import Wiring + +spec = { + # Service endpoints + "auth_service_url": "https://auth.example.com", + "user_service_url": "https://users.example.com", + "payment_service_url": "https://payments.example.com", + + # HTTP clients for each service + "requests.Session auth_client": {}, + "requests.Session user_client": {}, + "requests.Session payment_client": {}, + + # Service wrappers + "AuthService auth": { + "base_url": "{auth_service_url}", + "client": "{auth_client}", + }, + "UserService users": { + "base_url": "{user_service_url}", + "client": "{user_client}", + }, + "PaymentService payments": { + "base_url": "{payment_service_url}", + "client": "{payment_client}", + }, + + # Orchestrator + "OrderOrchestrator orchestrator": { + "auth": "{auth}", + "users": "{users}", + "payments": "{payments}", + }, +} + +wired = Wiring(spec, thread_safe=True) +orchestrator = wired.orchestrator() +``` + +## File Processing Pipeline + +Build a data processing pipeline: + +```python +from apywire import Wiring + +spec = { + # Input/output paths + "input_path": "/data/input", + "output_path": "/data/output", + "temp_path": "/data/temp", + + # Path objects + "pathlib.Path input_dir": ["{input_path}"], + "pathlib.Path output_dir": ["{output_path}"], + "pathlib.Path temp_dir": ["{temp_path}"], + + # Pipeline stages + "FileReader reader": { + "input_dir": "{input_dir}", + }, + "DataValidator validator": {}, + "DataTransformer transformer": { + "temp_dir": "{temp_dir}", + }, + "DataWriter writer": { + "output_dir": "{output_dir}", + }, + + # Pipeline + "ProcessingPipeline pipeline": { + "reader": "{reader}", + "validator": "{validator}", + "transformer": "{transformer}", + "writer": "{writer}", + }, +} + +wired = Wiring(spec) +pipeline = wired.pipeline() +pipeline.run() +``` + +## Scheduled Tasks + +Set up scheduled tasks with dependencies: + +```python +from apywire import Wiring +import schedule + +spec = { + # Database connection + "psycopg2.connect db": {"dsn": "postgresql://localhost/mydb"}, + + # Email service + "EmailService email": {"api_key": "secret"}, + + # Task definitions + "DailyReportTask daily_report": { + "db": "{db}", + "email": "{email}", + "schedule": "00:00", # Midnight + }, + "HourlyCleanupTask cleanup": { + "db": "{db}", + "schedule": ":00", # Every hour + }, + + # Scheduler + "TaskScheduler scheduler": { + "tasks": ["{daily_report}", "{cleanup}"], + }, +} + +wired = Wiring(spec, thread_safe=True) +scheduler = wired.scheduler() + +# Schedule tasks +for task in [wired.daily_report(), wired.cleanup()]: + schedule.every().day.at(task.schedule).do(task.run) + +# Run scheduler +while True: + schedule.run_pending() + time.sleep(60) +``` + +## CLI Application + +Build a command-line application: + +```python +from apywire import Wiring +import argparse + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--env", default="dev") + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args() + + spec = { + "env": args.env, + "verbose": args.verbose, + + # Configuration + "Config config": { + "env": "{env}", + "verbose": "{verbose}", + }, + + # Services + "Database db": {"config": "{config}"}, + "Logger logger": {"verbose": "{verbose}"}, + + # CLI + "CLI cli": { + "db": "{db}", + "logger": "{logger}", + }, + } + + wired = Wiring(spec) + cli = wired.cli() + cli.run() + +if __name__ == "__main__": + main() +``` + +## Next Steps + +- **[User Guide](user-guide/index.md)** - Detailed documentation +- **[API Reference](api-reference.md)** - Complete API documentation +- **[Development](development.md)** - Contributing guide diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..6b1ad21 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,219 @@ + + +# Getting Started + +This guide will help you get up and running with apywire quickly. + +## Installation + +Install apywire using pip: + +```bash +pip install apywire +``` + +apywire requires Python 3.12 or later and has no external runtime dependencies. + +### Development Installation + +If you want to contribute to apywire or run the tests: + +```bash +git clone https://github.com/alganet/apywire.git +cd apywire +make .venv +source .venv/bin/activate +make pip +``` + +## Quick Start + +### Basic Wiring + +Create a simple wiring configuration to manage object instantiation: + +```python +from apywire import Wiring + +# Define your wiring spec +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +# Create the wiring container +wired = Wiring(spec) + +# Access the object (instantiated lazily on first call) +dt = wired.now() +print(dt) # 2025-01-01 00:00:00 +``` + +### Understanding the Spec Format + +The wiring spec uses the format: `"module.Class name": {parameters}` + +- **`module.Class`**: Full module path and class name +- **`name`**: Attribute name to access the wired object +- **`{parameters}`**: Dictionary or list of constructor parameters + +Examples: + +```python +spec = { + # Basic class with keyword arguments + "datetime.datetime dt": {"year": 2025, "month": 6, "day": 15}, + + # Class with positional arguments (using list) + "pathlib.Path root": ["/home/user"], + + # Class with positional arguments (using numeric dict keys) + "pathlib.Path project": {0: "/home/user/project"}, + + # Constant values (no module.Class prefix) + "port": 8080, + "host": "localhost", +} +``` + +### Using Placeholders + +Reference other wired objects using the `{name}` syntax: + +```python +from apywire import Wiring + +spec = { + "datetime.datetime start": {"year": 2025, "month": 1, "day": 1}, + "datetime.timedelta delta": {"days": 7}, + "MyScheduler scheduler": { + "start_time": "{start}", # References the 'start' object + "duration": "{delta}", # References the 'delta' object + }, +} + +wired = Wiring(spec) +scheduler = wired.scheduler() # MyScheduler with injected dependencies +``` + +### Lazy Loading + +Objects are only instantiated when you call the accessor: + +```python +wired = Wiring(spec) +# Nothing has been instantiated yet! + +obj1 = wired.my_object() # Now it's created +obj2 = wired.my_object() # Returns the same cached instance +assert obj1 is obj2 # True - same object! +``` + +## Core Concepts + +### 1. Wiring Container + +The `Wiring` class is your main container that holds the spec and manages object instantiation: + +```python +from apywire import Wiring + +wired = Wiring(spec) +``` + +### 2. Accessors + +When you access an attribute on the `Wiring` container, you get an `Accessor` - a callable that instantiates the object: + +```python +accessor = wired.my_object # Returns an Accessor +obj = accessor() # Calls the accessor to get the object +# Or in one step: +obj = wired.my_object() +``` + +### 3. Spec Dictionary + +The spec is a dictionary that maps wiring keys to configuration: + +- **Wiring keys** with `module.Class name` format create wired objects +- **Simple keys** without a dot+class create constant values + +### 4. Dependency Resolution + +When you access a wired object: + +1. apywire checks if it's already cached +2. If not, it resolves all placeholder references (`{name}`) +3. Imports the module and class +4. Instantiates the object with the resolved parameters +5. Caches it for future access + +### 5. Circular Dependency Detection + +apywire automatically detects circular dependencies: + +```python +spec = { + "MyClass a": {"dependency": "{b}"}, + "MyClass b": {"dependency": "{a}"}, # Circular! +} + +wired = Wiring(spec) +try: + obj = wired.a() +except CircularWiringError as e: + print(f"Circular dependency detected: {e}") +``` + +## Next Steps + +Now that you understand the basics, explore more advanced features: + +- **[Basic Usage](user-guide/basic-usage.md)** - Detailed usage patterns and examples +- **[Async Support](user-guide/async-support.md)** - Using `await wired.aio.name()` for async access +- **[Thread Safety](user-guide/thread-safety.md)** - Thread-safe instantiation for multi-threaded apps +- **[Compilation](user-guide/compilation.md)** - Generate standalone Python code from your spec +- **[Advanced Features](user-guide/advanced.md)** - Factory methods, positional args, and more +- **[Examples](examples.md)** - Practical use cases and patterns + +## Common Patterns + +### Configuration Management + +```python +spec = { + "host": "localhost", + "port": 8080, + "MyApp app": { + "host": "{host}", + "port": "{port}", + }, +} +``` + +### Database Connection + +```python +spec = { + "db_url": "postgresql://localhost/mydb", + "psycopg2.connect connection": {"dsn": "{db_url}"}, + "MyRepository repo": {"db": "{connection}"}, +} +``` + +### Service Layer + +```python +spec = { + "MyDatabase db": {}, + "MyCache cache": {}, + "MyService service": { + "database": "{db}", + "cache": "{cache}", + }, +} +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ef0f4b8 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,132 @@ + + +# apywire Documentation + +Welcome to the apywire documentation! apywire is a powerful, flexible dependency injection library for Python 3.12+ that makes managing object dependencies simple and elegant. + +## What is apywire? + +apywire provides **lazy object wiring** through a clean, declarative specification format. Instead of manually instantiating objects and passing dependencies around, you define a `spec` that describes what you need, and apywire handles the rest. + +```python +from apywire import Wiring + +# Define what you want +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, + "MyService service": {"start_time": "{now}"}, # Reference other objects +} + +# Get what you need +wired = Wiring(spec) +service = wired.service() # Dependencies resolved automatically +``` + +## Key Features + +### 🚀 Lazy Loading +Objects are instantiated **only when accessed**, not when you create the container. This improves startup performance and lets you define everything upfront without overhead. + +### ⚡ Async Support +Built-in `async/await` support via `AioAccessor`. Use `await wired.aio.my_object()` for asynchronous object access without blocking the event loop. + +### 🔒 Thread Safety +Optional thread-safe instantiation using optimistic locking. Enable with `thread_safe=True` for multi-threaded applications. + +### 📦 Code Generation +Compile your specs to standalone Python code with `WiringCompiler(spec).compile()`. Deploy generated code for better performance and simpler dependency management. + +### 📄 Naturally Configurable +Load specs from YAML, TOML, JSON, or INI files. Keep configuration separate from code, support multiple environments, and let non-developers manage settings. Constants can reference other constants using `{name}` placeholders for flexible configuration. + +### 🎯 Zero Dependencies +No external runtime dependencies. apywire works with just Python 3.12+ standard library. + +## Why Choose apywire? + +### Clean, Declarative Configuration +Define your entire dependency graph in a single, readable dictionary. No decorators, no magic imports, just plain Python data structures. + +### Perfect for Layered Architectures +Ideal for applications with clear separation of concerns: database layer, service layer, API layer. Wire them together without tight coupling. + +### Flexible Deployment Options +- **Development**: Use runtime `Wiring` for fast iteration +- **Production**: Compile to standalone code for deployment +- **Testing**: Easily swap real dependencies for mocks +- **Configuration**: Load specs from YAML, TOML, JSON, or INI files + +### Production-Ready +- Strict mypy type checking throughout +- 95% test coverage or higher enforced +- Support for async, threading, and edge cases +- No external dependencies to manage + +### Use Cases +- **Web Applications**: FastAPI, Flask, Django services +- **Microservices**: Service-to-service communication setup +- **CLI Tools**: Complex command-line applications with many dependencies +- **Data Pipelines**: ETL processes with configurable components +- **Testing**: Mock injection for comprehensive test coverage + +### Dependency Injection via Placeholders + +Reference other wired objects using `{name}` placeholders: + +```python +spec = { + "datetime.datetime base_time": {"year": 2025, "month": 1, "day": 1}, + "datetime.timedelta offset": {"days": 7}, + "MyClass processor": { + "start_time": "{base_time}", + "delta": "{offset}", + }, +} +``` + +### Async Support + +Access objects asynchronously in async contexts: + +```python +async def main(): + wired = Wiring(spec) + obj = await wired.aio.my_object() # Async access +``` + +### Thread-Safe Instantiation + +Enable thread safety for multi-threaded applications: + +```python +wired = Wiring(spec, thread_safe=True) +# Safe to use across multiple threads +``` + +### Code Compilation + +Generate standalone Python code from your wiring spec: + +```python +from apywire import WiringCompiler + +compiler = WiringCompiler(spec) +code = compiler.compile(aio=True, thread_safe=True) +# Returns Python code that behaves identically to the runtime container +``` + +## Next Steps + +- [Getting Started](getting-started.md) - Detailed installation and setup guide +- [User Guide](user-guide/index.md) - Comprehensive usage documentation +- [API Reference](api-reference.md) - Complete API documentation +- [Examples](examples.md) - Practical use cases and patterns +- [Development](development.md) - Contributing guide for developers + +## License + +apywire is licensed under the ISC License. See the [LICENSE](https://github.com/alganet/apywire/blob/main/LICENSES/ISC.txt) file for details. diff --git a/docs/user-guide/advanced.md b/docs/user-guide/advanced.md new file mode 100644 index 0000000..f6e3c0d --- /dev/null +++ b/docs/user-guide/advanced.md @@ -0,0 +1,622 @@ + + +# Advanced Features + +This guide covers advanced apywire features and patterns for complex use cases. + +## Factory Methods + +apywire supports class factory methods (classmethods and staticmethods) using the syntax `module.Class name.factory_method`. + +### Basic Factory Method + +```python +import datetime +from apywire import Wiring + +spec = { + "datetime.datetime dt.fromtimestamp": { + 0: 1234567890, # Unix timestamp + }, +} + +wired = Wiring(spec) +dt = wired.dt() # datetime.datetime.fromtimestamp(1234567890) +``` + +### With Keyword Arguments + +```python +spec = { + "datetime.datetime dt.fromisoformat": { + "date_string": "2025-01-01T12:00:00", + }, +} + +wired = Wiring(spec) +dt = wired.dt() # datetime.datetime.fromisoformat("2025-01-01T12:00:00") +``` + +### Custom Factory Methods + +```python +class Product: + def __init__(self, name: str, price: float): + self.name = name + self.price = price + + @classmethod + def from_dict(cls, data: dict): + return cls(name=data["name"], price=data["price"]) + +spec = { + "mymodule.Product product.from_dict": { + "data": {"name": "Widget", "price": 19.99}, + }, +} +``` + +### With Placeholders + +```python +spec = { + "mymodule.Config config": {"value": "production"}, + "mymodule.Service service.from_config": { + "cfg": "{config}", # Reference to config object + }, +} +``` + +### Static Methods + +Factory methods can be staticmethods too: + +```python +class Calculator: + def __init__(self, result: int): + self.result = result + + @staticmethod + def create_with_sum(a: int, b: int): + return Calculator(result=a + b) + +spec = { + "mymodule.Calculator calc.create_with_sum": { + "a": 10, + "b": 20, + }, +} + +wired = Wiring(spec) +calc = wired.calc() +assert calc.result == 30 +``` + +!!! warning "Nested Factory Methods Not Supported" + ```python + # ❌ This will raise ValueError + spec = { + "datetime.datetime dt.method1.method2": {}, + } + ``` + + Nested factory methods are not supported. Only one level is allowed: `name.factory_method`. + +## Positional Arguments + +apywire supports positional arguments in three ways: + +### Using Lists + +```python +spec = { + "pathlib.Path path": ["/home", "user", "project"], +} + +wired = Wiring(spec) +path = wired.path() # pathlib.Path("/home", "user", "project") +``` + +### Using Numeric Dict Keys + +```python +spec = { + "pathlib.Path path": { + 0: "/home", + 1: "user", + 2: "project", + }, +} +``` + +Keys are sorted numerically and passed as positional arguments. + +### Mixed Positional and Keyword Args + +```python +spec = { + "datetime.datetime dt": { + 0: 2025, # year (positional) + 1: 1, # month (positional) + 2: 1, # day (positional) + "hour": 12, # keyword argument + "minute": 30, # keyword argument + }, +} + +wired = Wiring(spec) +dt = wired.dt() # datetime.datetime(2025, 1, 1, hour=12, minute=30) +``` + +## Complex Nested Dependencies + +### Deep Dependency Chains + +```python +spec = { + "DatabaseConfig db_config": {}, + "Database db": {"config": "{db_config}"}, + "CacheLayer cache": {"db": "{db}"}, + "RepositoryLayer repo": { + "db": "{db}", + "cache": "{cache}", + }, + "ServiceLayer service": { + "repo": "{repo}", + "cache": "{cache}", + }, +} + +wired = Wiring(spec) +service = wired.service() +# Resolves: db_config → db → cache → repo → service +``` + +### Nested Data Structures + +Placeholders work in nested dicts, lists, and tuples: + +```python +spec = { + "api_key": "secret-key-123", + "timeout": 30, + + "MyClient client": { + "config": { + "auth": { + "type": "bearer", + "token": "{api_key}", # Nested in dict + }, + "options": { + "timeout": "{timeout}", + "retries": 3, + }, + }, + "endpoints": [ + "https://api1.example.com", + "https://api2.example.com", + ], + }, +} +``` + +### Multiple References to Same Object + +```python +spec = { + "Logger logger": {}, + "ServiceA svc_a": {"logger": "{logger}"}, + "ServiceB svc_b": {"logger": "{logger}"}, + "Coordinator coord": { + "logger": "{logger}", + "services": ["{svc_a}", "{svc_b}"], + }, +} + +wired = Wiring(spec) +coord = wired.coord() + +# All services share the same logger instance +assert coord.logger is coord.services[0].logger +assert coord.logger is coord.services[1].logger +``` + +## Error Handling + +### CircularWiringError + +Raised when a circular dependency is detected: + +```python +from apywire import CircularWiringError + +spec = { + "MyClass a": {"dependency": "{b}"}, + "MyClass b": {"dependency": "{a}"}, # Circular! +} + +wired = Wiring(spec) +try: + obj = wired.a() +except CircularWiringError as e: + print(f"Circular dependency: {e}") + # Error message shows the dependency chain +``` + +The error message includes the full resolution chain: + +``` +Circular dependency detected while resolving 'a': + a -> b -> a +``` + +### UnknownPlaceholderError + +Raised when a placeholder references a non-existent object: + +```python +from apywire import UnknownPlaceholderError + +spec = { + "MyClass obj": {"dependency": "{nonexistent}"}, +} + +wired = Wiring(spec) +try: + obj = wired.obj() +except UnknownPlaceholderError as e: + print(f"Unknown placeholder: {e}") +``` + +### LockUnavailableError + +Raised in thread-safe mode when a lock cannot be acquired: + +```python +from apywire import LockUnavailableError + +wired = Wiring(spec, thread_safe=True, max_lock_attempts=1) + +try: + obj = wired.my_object() +except LockUnavailableError as e: + print(f"Lock contention: {e}") +``` + +This is rare and usually indicates extreme contention or a configuration issue. + +### Import Errors + +If a module or class can't be imported, a standard `ImportError` is raised: + +```python +spec = { + "nonexistent.module.Class obj": {}, +} + +wired = Wiring(spec) +try: + obj = wired.obj() +except ImportError as e: + print(f"Cannot import: {e}") +``` + +### Attribute Errors + +If a class doesn't have the specified factory method: + +```python +spec = { + "datetime.datetime dt.nonexistent_method": {}, +} + +wired = Wiring(spec) +try: + dt = wired.dt() +except AttributeError as e: + print(f"Method not found: {e}") +``` + +## Standard Library Compatibility + +apywire works with Python standard library classes: + +### datetime + +```python +spec = { + "datetime.datetime now.fromtimestamp": {0: 1234567890}, + "datetime.timedelta duration": {"days": 7, "hours": 12}, + "datetime.date today.fromisoformat": {"date_string": "2025-01-01"}, +} +``` + +### pathlib + +```python +spec = { + "pathlib.Path root": ["/home/user"], + "pathlib.Path project": {0: "/home/user/project"}, +} +``` + +### collections + +```python +spec = { + "collections.Counter counter": [["a", "b", "a", "c", "b", "a"]], + "collections.defaultdict dd": {"default_factory": list}, +} +``` + +### threading + +```python +spec = { + "threading.Lock lock": {}, + "threading.Event event": {}, + "threading.Semaphore sem": {0: 5}, +} +``` + +## Dynamic Spec Building + +Build specs programmatically: + +```python +def build_spec(env: str) -> dict: + """Build spec based on environment.""" + spec = { + "log_level": "DEBUG" if env == "dev" else "INFO", + } + + if env == "production": + spec["database_url"] = "postgresql://prod-server/db" + else: + spec["database_url"] = "sqlite:///dev.db" + + spec["MyDatabase db"] = {"url": "{database_url}"} + + return spec + +# Usage +import os +env = os.getenv("APP_ENV", "dev") +wired = Wiring(build_spec(env)) +``` + +## Testing Patterns + +### Mock Dependencies + +Replace real dependencies with mocks for testing: + +```python +# production_spec.py +production_spec = { + "psycopg2.connect db": {"dsn": "postgresql://prod/db"}, + "redis.Redis cache": {"url": "redis://prod"}, +} + +# test_spec.py +from unittest.mock import Mock + +test_spec = { + "unittest.mock.Mock db": {}, # Mock database + "unittest.mock.Mock cache": {}, # Mock cache +} + +# In tests +def test_my_app(): + wired = Wiring(test_spec) + # All dependencies are mocks +``` + +### Spec Fixtures + +Use pytest fixtures for common specs: + +```python +import pytest +from apywire import Wiring + +@pytest.fixture +def wired(): + spec = { + "MyDatabase db": {}, + "MyCache cache": {}, + "MyService service": { + "db": "{db}", + "cache": "{cache}", + }, + } + return Wiring(spec) + +def test_service(wired): + service = wired.service() + assert service is not None +``` + +## Best Practices + +### 1. Use Type Aliases for Complex Specs + +```python +from typing import TypeAlias +from apywire import Spec + +DatabaseSpec: TypeAlias = Spec + +def get_database_spec() -> DatabaseSpec: + return { + "database_url": "postgresql://localhost/db", + "psycopg2.connect connection": {"dsn": "{database_url}"}, + } +``` + +### 2. Validate Specs Early + +```python +def validate_spec(wired: Wiring) -> None: + """Validate all wired objects can be instantiated.""" + # Try to access all known objects + _ = wired.database() + _ = wired.cache() + _ = wired.service() + +# Fail fast at application startup +wired = Wiring(spec) +validate_spec(wired) +``` + +### 3. Document Complex Dependencies + +```python +spec = { + # Core infrastructure + "database_url": "postgresql://localhost/db", + "psycopg2.connect db": {"dsn": "{database_url}"}, + + # Caching layer (depends on db) + "MyCache cache": {"db": "{db}"}, + + # Business logic (depends on db and cache) + "MyService service": { + "db": "{db}", + "cache": "{cache}", + }, +} +``` + +### 4. Separate Concerns + +```python +# config.py - Configuration constants +config_spec = { + "host": "localhost", + "port": 8080, + "debug": True, +} + +# infrastructure.py - Infrastructure components +infra_spec = { + "Database db": {"host": "{host}", "port": "{port}"}, + "Cache cache": {}, +} + +# services.py - Business logic +service_spec = { + "MyService service": {"db": "{db}", "cache": "{cache}"}, +} + +# Merge them +full_spec = {**config_spec, **infra_spec, **service_spec} +wired = Wiring(full_spec) +``` + +### 5. Use Constants for Configuration + +Constants now support placeholder expansion, making configuration more maintainable: + +```python +spec = { + # Configuration constants + "db_host": "localhost", + "db_port": 5432, + "db_name": "myapp", + + # Build connection string from constants (immediate expansion) + "db_url": "postgresql://{db_host}:{db_port}/{db_name}", + + # Use in wired object + "psycopg2.connect db": {"dsn": "{db_url}"}, +} +``` + +For environment-based configuration: + +```python +import os + +spec = { + "db_host": os.getenv("DB_HOST", "localhost"), + "db_port": int(os.getenv("DB_PORT", "5432")), + "db_name": os.getenv("DB_NAME", "myapp"), + + # Placeholder expansion keeps spec clean + "db_url": "postgresql://{db_host}:{db_port}/{db_name}", + "psycopg2.connect db": {"dsn": "{db_url}"}, +} +``` + +See [Configuration Files](configuration-files.md#placeholder-expansion) for more details. + +## Performance Tips + +### 1. Lazy Loading is Your Friend + +Objects are only created when accessed, so define everything in your spec: + +```python +spec = { + "ExpensiveResource resource1": {}, # Won't be created unless accessed + "ExpensiveResource resource2": {}, # Same here + "QuickResource quick": {}, +} + +wired = Wiring(spec) +quick = wired.quick() # Only this is instantiated +``` + +### 2. Cache is Per-Container + +Each `Wiring` instance has its own cache: + +```python +# ✅ Good: Single container, shared cache +wired = Wiring(spec) +obj1 = wired.resource() # Created +obj2 = wired.resource() # Cached + +# ❌ Avoid: Multiple containers, no sharing +wired1 = Wiring(spec) +wired2 = Wiring(spec) +obj1 = wired1.resource() # Created +obj2 = wired2.resource() # Created again! +``` + +### 3. Use Compilation for Production + +Compiled code is slightly faster: + +```python +# Development: runtime +wired = Wiring(spec) + +# Production: compiled +compiler = WiringCompiler(spec) +code = compiler.compile() +# Save to file, import in production +``` + +### 4. Thread Safety Has Overhead + +Only enable when needed: + +```python +# Single-threaded: fast +wired = Wiring(spec, thread_safe=False) + +# Multi-threaded: slight overhead +wired = Wiring(spec, thread_safe=True) +``` + +## Next Steps + +- **[API Reference](../api-reference.md)** - Complete API documentation +- **[Examples](../examples.md)** - Practical examples and patterns +- **[Development](../development.md)** - Contributing guide diff --git a/docs/user-guide/async-support.md b/docs/user-guide/async-support.md new file mode 100644 index 0000000..5958e32 --- /dev/null +++ b/docs/user-guide/async-support.md @@ -0,0 +1,329 @@ + + +# Async Support + +apywire provides built-in support for asynchronous object access via the `AioAccessor` pattern. + +## Overview + +The `AioAccessor` allows you to access wired objects in async contexts using `await`. This is useful when: + +- Your application is built with `asyncio` +- You want to instantiate objects without blocking the event loop +- You're working with async frameworks like FastAPI, aiohttp, or Starlette + +## Basic Async Access + +Use the `.aio` attribute to get async accessors: + +```python +import asyncio +from apywire import Wiring + +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +async def main(): + wired = Wiring(spec) + + # Async access + dt = await wired.aio.now() + print(dt) # 2025-01-01 00:00:00 + +asyncio.run(main()) +``` + +## How It Works + +When you use `.aio`, apywire wraps the object instantiation in an executor: + +```python +# Under the hood (simplified) +async def aio_accessor(): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, sync_accessor) +``` + +This means: + +1. Object instantiation runs in a thread pool executor +2. The async event loop is not blocked +3. The object is still cached after first instantiation + +## Async vs Sync Access + +### Sync Access + +```python +wired = Wiring(spec) +obj = wired.my_object() # Synchronous, blocks until instantiated +``` + +### Async Access + +```python +async def get_object(): + wired = Wiring(spec) + obj = await wired.aio.my_object() # Async, doesn't block event loop + return obj +``` + +## Caching with Async + +Just like sync access, async access caches objects: + +```python +async def main(): + wired = Wiring(spec) + + obj1 = await wired.aio.my_object() + obj2 = await wired.aio.my_object() + + assert obj1 is obj2 # True - same cached instance! +``` + +You can even mix sync and async access - they share the same cache: + +```python +async def main(): + wired = Wiring(spec) + + obj1 = wired.my_object() # Sync access + obj2 = await wired.aio.my_object() # Async access + + assert obj1 is obj2 # True - same object! +``` + +## When to Use Async Access + +### ✅ Use Async Access When: + +- Working in async contexts (`async def` functions) +- Using async frameworks (FastAPI, aiohttp, etc.) +- Object instantiation might be slow and you don't want to block +- You want to instantiate multiple objects concurrently + +### ❌ Don't Use Async Access When: + +- Working in sync code (use regular accessors instead) +- Object instantiation is very fast (overhead not worth it) +- You don't have an event loop running + +## Concurrent Instantiation + +You can instantiate multiple objects concurrently: + +```python +import asyncio +from apywire import Wiring + +spec = { + "MyDatabase db1": {"host": "server1.example.com"}, + "MyDatabase db2": {"host": "server2.example.com"}, + "MyDatabase db3": {"host": "server3.example.com"}, +} + +async def main(): + wired = Wiring(spec) + + # Instantiate all three databases concurrently + db1, db2, db3 = await asyncio.gather( + wired.aio.db1(), + wired.aio.db2(), + wired.aio.db3(), + ) + + print("All databases ready!") + +asyncio.run(main()) +``` + +## Real-World Example: FastAPI + +```python +from fastapi import FastAPI, Depends +from apywire import Wiring + +spec = { + "database_url": "postgresql://localhost/mydb", + "redis_url": "redis://localhost", + + "psycopg2.connect db": {"dsn": "{database_url}"}, + "redis.Redis cache": {"url": "{redis_url}"}, + "MyRepository repository": {"db": "{db}"}, +} + +# Create wiring container at startup +wired = Wiring(spec) + +app = FastAPI() + +async def get_repository(): + """Dependency that provides the repository.""" + return await wired.aio.repository() + +@app.get("/users/{user_id}") +async def get_user(user_id: int, repo = Depends(get_repository)): + """Get user by ID.""" + user = await repo.get_user(user_id) + return user +``` + +## Compiling with Async Support + +When compiling your wiring spec to code, you can include async support: + +```python +from apywire import WiringCompiler + +compiler = WiringCompiler(spec) +code = compiler.compile(aio=True) # Include async accessors in generated code +``` + +The generated code will include both sync and async accessor methods. + +## Thread Safety with Async + +If you're using async access in a multi-threaded environment (e.g., running multiple event loops in different threads), enable thread safety: + +```python +wired = Wiring(spec, thread_safe=True) + +async def main(): + obj = await wired.aio.my_object() # Thread-safe async access +``` + +See [Thread Safety](thread-safety.md) for more details. + +## Performance Considerations + +### Overhead + +Async access has a small overhead due to executor scheduling: + +```python +import time + +spec = { + "datetime.datetime dt": {"year": 2025, "month": 1, "day": 1}, +} + +wired = Wiring(spec) + +# Sync access (very fast) +start = time.perf_counter() +_ = wired.dt() +sync_time = time.perf_counter() - start + +# Async access (slightly slower due to executor) +async def async_test(): + start = time.perf_counter() + _ = await wired.aio.dt() + return time.perf_counter() - start + +async_time = asyncio.run(async_test()) + +# async_time will be slightly higher than sync_time +``` + +For lightweight objects, the overhead might not be worth it. Use async access when: + +- Object instantiation involves I/O (database connections, file opening, etc.) +- You need to instantiate multiple objects concurrently +- You're already in an async context and want consistency + +### Caching Mitigates Overhead + +Since objects are cached after first access, the overhead only applies to the first instantiation: + +```python +async def main(): + wired = Wiring(spec) + + # First access: pays executor overhead + obj1 = await wired.aio.my_object() + + # Subsequent accesses: instant, no overhead + obj2 = await wired.aio.my_object() # Cached! +``` + +## Error Handling + +Async access raises the same exceptions as sync access: + +```python +from apywire import UnknownPlaceholderError, CircularWiringError + +async def main(): + spec = { + "MyClass obj": {"dep": "{nonexistent}"}, + } + + wired = Wiring(spec) + + try: + obj = await wired.aio.obj() + except UnknownPlaceholderError as e: + print(f"Error: {e}") +``` + +## Best Practices + +### 1. Use Async Accessors in Async Contexts + +```python +# Good +async def setup(): + wired = Wiring(spec) + db = await wired.aio.database() + +# Avoid - don't mix contexts unnecessarily +def setup(): + wired = Wiring(spec) + db = asyncio.run(wired.aio.database()) # Awkward! +``` + +### 2. Instantiate Dependencies Concurrently + +```python +# Good - concurrent +async def setup(): + wired = Wiring(spec) + db, cache, logger = await asyncio.gather( + wired.aio.database(), + wired.aio.cache(), + wired.aio.logger(), + ) + +# Works but slower - sequential +async def setup(): + wired = Wiring(spec) + db = await wired.aio.database() + cache = await wired.aio.cache() + logger = await wired.aio.logger() +``` + +### 3. Don't Over-Optimize + +```python +# Overkill for lightweight objects +async def get_datetime(): + wired = Wiring({"datetime.datetime now": {"year": 2025, "month": 1, "day": 1}}) + dt = await wired.aio.now() # Unnecessary async for simple object + +# Just use sync access for lightweight objects +def get_datetime(): + wired = Wiring({"datetime.datetime now": {"year": 2025, "month": 1, "day": 1}}) + dt = wired.now() # Better - no async overhead +``` + +## Next Steps + +- **[Thread Safety](thread-safety.md)** - Combine async with thread safety +- **[Compilation](compilation.md)** - Generate async-capable code +- **[Basic Usage](basic-usage.md)** - Review sync accessor patterns diff --git a/docs/user-guide/basic-usage.md b/docs/user-guide/basic-usage.md new file mode 100644 index 0000000..99d8009 --- /dev/null +++ b/docs/user-guide/basic-usage.md @@ -0,0 +1,412 @@ + + +# Basic Usage + +This guide covers the fundamental usage patterns of apywire. + +## Creating a Wiring Container + +The `Wiring` class is the main entry point for dependency injection: + +```python +from apywire import Wiring + +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +wired = Wiring(spec) +``` + +By default, `Wiring` creates a non-thread-safe container suitable for single-threaded applications. For thread safety, see the [Thread Safety](thread-safety.md) guide. + +## Defining Specs + +The spec dictionary maps wiring keys to configuration. There are two types of entries: + +### Wired Objects + +Use the format `"module.Class name"` to define objects that should be lazily instantiated: + +```python +spec = { + "datetime.datetime start_time": {"year": 2025, "month": 1, "day": 1}, + "pathlib.Path project_root": {0: "/home/user/project"}, + "MyClass service": {"param1": "value1", "param2": 42}, +} +``` + +### Constants + +Simple keys without the `module.Class` format become constants: + +```python +spec = { + "host": "localhost", + "port": 8080, + "debug": True, + "api_key": "secret-key-here", +} + +wired = Wiring(spec) +# Constants are primarily used as placeholder references in other wired objects +``` + +!!! note + Constants are most useful as placeholder references for other wired objects using the `{name}` syntax. + +## Spec Value Types + +apywire supports various parameter types: + +### Keyword Arguments (Dict) + +```python +spec = { + "datetime.datetime dt": { + "year": 2025, + "month": 6, + "day": 15, + "hour": 10, + }, +} +``` + +### Positional Arguments (List) + +```python +spec = { + "pathlib.Path path": ["/home/user/project"], +} +``` + +### Positional Arguments (Numeric Dict Keys) + +```python +spec = { + "pathlib.Path path": {0: "/home", 1: "user", 2: "project"}, +} +``` + +### Mixed Arguments + +You can combine positional and keyword arguments: + +```python +spec = { + "MyClass obj": { + 0: "positional_arg1", + 1: "positional_arg2", + "keyword_arg": "value", + }, +} +``` + +Numeric keys are sorted and passed as positional arguments, while string keys become keyword arguments. + +## Using Placeholders + +Reference other wired objects or constants using `{name}` syntax: + +```python +spec = { + "database_url": "postgresql://localhost/mydb", + "max_connections": 10, + + "psycopg2.pool.SimpleConnectionPool pool": { + "minconn": 1, + "maxconn": "{max_connections}", + "dsn": "{database_url}", + }, +} + +wired = Wiring(spec) +pool = wired.pool() # placeholders are resolved to actual values +``` + +### Nested Placeholders + +Placeholders work in nested structures: + +```python +spec = { + "api_key": "secret-key", + + "MyClient client": { + "config": { + "auth": { + "api_key": "{api_key}", # Nested placeholder + }, + "timeout": 30, + }, + }, +} +``` + +### List Placeholders + +Placeholders work in lists too: + +```python +spec = { + "datetime.datetime start": {"year": 2025, "month": 1, "day": 1}, + "datetime.datetime end": {"year": 2025, "month": 12, "day": 31}, + + "MyReport report": { + "date_range": ["{start}", "{end}"], # List with placeholders + }, +} +``` + +## Accessing Wired Objects + +### The Accessor Pattern + +When you access an attribute on a `Wiring` container, you get an `Accessor`: + +```python +from apywire import Wiring, Accessor + +wired = Wiring(spec) +accessor = wired.my_object # Returns an Accessor instance +print(type(accessor)) # +``` + +Call the accessor to instantiate the object: + +```python +obj = accessor() # Instantiates the object +``` + +Most commonly, you'll do both in one line: + +```python +obj = wired.my_object() +``` + +### Caching Behavior + +Objects are instantiated once and cached: + +```python +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +wired = Wiring(spec) + +dt1 = wired.now() +dt2 = wired.now() + +assert dt1 is dt2 # True - same object instance! +``` + +Each access returns the **same instance**, not a new one. This is crucial for: + +- Maintaining singleton-like behavior +- Avoiding duplicate resource allocation (e.g., database connections) +- Ensuring consistent state across your application + +### When Instantiation Happens + +```python +wired = Wiring(spec) +# No objects instantiated yet! + +accessor = wired.my_object +# Still nothing instantiated - just got the accessor + +obj = accessor() +# NOW the object is created and cached +``` + +## Working with Multiple Objects + +```python +spec = { + "datetime.datetime start": {"year": 2025, "month": 1, "day": 1}, + "datetime.timedelta delta": {"days": 7}, + "pathlib.Path root": ["/home/user"], +} + +wired = Wiring(spec) + +# Access multiple objects +start_date = wired.start() +time_delta = wired.delta() +root_path = wired.root() +``` + +## Dependency Resolution Order + +When accessing an object with placeholder dependencies, apywire resolves them in order: + +```python +spec = { + "MyDatabase db": {}, + "MyCache cache": {"db": "{db}"}, # Depends on db + "MyService service": { + "cache": "{cache}", # Depends on cache + "db": "{db}", # Also depends on db + }, +} + +wired = Wiring(spec) +service = wired.service() +# Resolution order: db → cache → service +``` + +apywire automatically handles the dependency graph and instantiates objects in the correct order. + +## Error Handling + +### Unknown Placeholder + +```python +from apywire import Wiring, UnknownPlaceholderError + +spec = { + "MyClass obj": {"dependency": "{nonexistent}"}, +} + +wired = Wiring(spec) +try: + obj = wired.obj() +except UnknownPlaceholderError as e: + print(f"Unknown placeholder: {e}") +``` + +### Circular Dependencies + +```python +from apywire import Wiring, CircularWiringError + +spec = { + "MyClass a": {"dep": "{b}"}, + "MyClass b": {"dep": "{a}"}, +} + +wired = Wiring(spec) +try: + obj = wired.a() +except CircularWiringError as e: + print(f"Circular dependency: {e}") +``` + +### Import Errors + +```python +spec = { + "nonexistent.module.Class obj": {}, +} + +wired = Wiring(spec) +try: + obj = wired.obj() +except ImportError as e: + print(f"Cannot import: {e}") +``` + +## Best Practices + +### 1. Use Descriptive Names + +```python +# Good +spec = { + "psycopg2.connect db_connection": {"dsn": "{database_url}"}, + "MyRepository user_repository": {"db": "{db_connection}"}, +} + +# Avoid +spec = { + "psycopg2.connect conn": {"dsn": "{url}"}, + "MyRepository repo": {"db": "{conn}"}, +} +``` + +### 2. Group Related Objects + +```python +spec = { + # Database layer + "db_url": "postgresql://localhost/mydb", + "psycopg2.connect db": {"dsn": "{db_url}"}, + + # Cache layer + "redis_url": "redis://localhost", + "redis.Redis cache": {"url": "{redis_url}"}, + + # Service layer + "MyService service": {"db": "{db}", "cache": "{cache}"}, +} +``` + +### 3. Validate Configuration Early + +```python +def validate_wiring(wired: Wiring) -> None: + """Validate all wired objects can be instantiated.""" + # Access all objects to trigger any configuration errors + _ = wired.database() + _ = wired.cache() + _ = wired.service() + +# In your app startup +wired = Wiring(spec) +validate_wiring(wired) # Fail fast if configuration is wrong +``` + +### 4. Use Constants for Configuration + +```python +spec = { + # Configuration constants + "debug": True, + "log_level": "INFO", + "timeout": 30, + + # Wired objects using configuration + "logging.Logger logger": { + "name": "myapp", + "level": "{log_level}", + }, + "MyClient client": { + "timeout": "{timeout}", + "debug": "{debug}", + }, +} +``` + +### 5. Keep Specs Testable + +```python +# production_spec.py +def get_production_spec(): + return { + "psycopg2.connect db": {"dsn": "postgresql://prod/db"}, + } + +# test_spec.py +def get_test_spec(): + return { + "unittest.mock.Mock db": {}, # Mock database for testing + } + +# Usage +if os.getenv("TESTING"): + wired = Wiring(get_test_spec()) +else: + wired = Wiring(get_production_spec()) +``` + +## Next Steps + +- **[Async Support](async-support.md)** - Use `await wired.aio.name()` for async access +- **[Thread Safety](thread-safety.md)** - Enable thread-safe instantiation +- **[Compilation](compilation.md)** - Generate standalone code from your spec +- **[Advanced Features](advanced.md)** - Factory methods and complex patterns diff --git a/docs/user-guide/compilation.md b/docs/user-guide/compilation.md new file mode 100644 index 0000000..0156779 --- /dev/null +++ b/docs/user-guide/compilation.md @@ -0,0 +1,456 @@ + + +# Compilation + +apywire can generate standalone Python code from your wiring spec using the `WiringCompiler` class and its `compile()` method. This is useful for production deployments and performance optimization. + +## Overview + +The `compile()` method generates Python code that behaves identically to the runtime `Wiring` container: + +```python +from apywire import WiringCompiler + +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +compiler = WiringCompiler(spec) +code = compiler.compile() +print(code) +``` + +This generates a Python class with the same lazy loading, caching, and dependency resolution behavior. + +## Basic Compilation + +```python +from apywire import WiringCompiler + +spec = { + "datetime.datetime start": {"year": 2025, "month": 1, "day": 1}, + "datetime.timedelta delta": {"days": 7}, +} + +compiler = WiringCompiler(spec) +code = compiler.compile() + +# Save to file +with open("compiled_wiring.py", "w") as f: + f.write(code) +``` + +The generated code can be imported and used like the runtime container: + +```python +from compiled_wiring import Compiled + +wired = Compiled() +start = wired.start() +delta = wired.delta() +``` + +## Compilation Options + +### aio (Async Support) + +Include async accessors in the generated code: + +```python +code = compiler.compile(aio=True) +``` + +Generated code will support both sync and async access: + +```python +from compiled_wiring import Compiled + +wired = Compiled() + +# Sync access +obj = wired.my_object() + +# Async access +import asyncio +obj = asyncio.run(wired.aio.my_object()) +``` + +### thread_safe (Thread Safety) + +Include thread-safe instantiation in the generated code: + +```python +code = compiler.compile(thread_safe=True) +``` + +Generated code will use the same optimistic locking mechanism as runtime `Wiring`. + +### Combined Options + +```python +code = compiler.compile(aio=True, thread_safe=True) +``` + +Generates code with both async support and thread safety. + +## Generated Code Structure + +The compiled code contains: + +### 1. Imports + +All necessary imports for the wired classes: + +```python +import datetime +import pathlib +# ... other imports +``` + +### 2. The Compiled Class + +A class that mirrors your wiring spec: + +```python +class Compiled: + def __init__(self): + self._values = {} + # Thread-safe: locks initialization + # Constants initialization +``` + +### 3. Accessor Methods + +Methods for each wired object: + +```python +def start(self): + if "start" not in self._values: + # Instantiation logic + self._values["start"] = datetime.datetime(year=2025, month=1, day=1) + return self._values["start"] +``` + +### 4. Async Accessors (if aio=True) + +Async versions of accessor methods: + +```python +class AioAccessors: + async def start(self): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda: self._wired.start()) + +@property +def aio(self): + return self._aio_accessors +``` + +### 5. Thread Safety (if thread_safe=True) + +Locking mechanisms and thread-local state. + +## Why Compile? + +### Benefits + +1. **Performance**: Slightly faster than runtime `Wiring` (no dynamic `__getattr__` lookup) +2. **Deployment**: No need to include spec dictionary in production code +3. **Type Checking**: Generated code can be type-checked by mypy +4. **Inspection**: Easier to understand dependency graph by reading generated code +5. **Cython**: Can be compiled with Cython for additional performance + +### Trade-offs + +1. **Static**: Can't modify spec at runtime +2. **Code Size**: Generated code can be large for complex specs +3. **Maintenance**: Need to regenerate if spec changes + +## Cython Compilation + +apywire itself uses Cython for performance. You can compile the generated code with Cython: + +### Step 1: Generate Python Code + +```python +compiler = WiringCompiler(spec) +code = compiler.compile() +with open("wiring.py", "w") as f: + f.write(code) +``` + +### Step 2: Create setup.py + +```python +from setuptools import setup +from Cython.Build import cythonize + +setup( + ext_modules=cythonize("wiring.py"), +) +``` + +### Step 3: Build + +```bash +python setup.py build_ext --inplace +``` + +This generates `wiring.c` and `wiring.so` (compiled extension). + +## Real-World Example + +### Development: Use Runtime Wiring + +```python +# config.py +from apywire import Wiring + +def get_spec(): + return { + "database_url": "postgresql://localhost/mydb", + "psycopg2.connect db": {"dsn": "{database_url}"}, + "MyRepository repo": {"db": "{db}"}, + } + +wired = Wiring(get_spec()) +``` + +### Production: Use Compiled Code + +```python +# generate_wiring.py +from apywire import WiringCompiler +from config import get_spec + +compiler = WiringCompiler(get_spec()) +code = compiler.compile(aio=True, thread_safe=True) + +with open("compiled_wiring.py", "w") as f: + f.write(code) + +print("Compiled wiring generated!") +``` + +Run once during deployment: + +```bash +python generate_wiring.py +``` + +Use in production: + +```python +# app.py +from compiled_wiring import Compiled + +wired = Compiled() +repo = wired.repo() +``` + +## Inspecting Generated Code + +The generated code is readable Python. You can inspect it to understand dependencies: + +```python +spec = { + "MyDatabase db": {}, + "MyCache cache": {"db": "{db}"}, + "MyService service": {"cache": "{cache}"}, +} + +compiler = WiringCompiler(spec) +code = compiler.compile() + +# Save and inspect +with open("wiring.py", "w") as f: + f.write(code) + +# Look at wiring.py to see: +# - service() method calls cache() +# - cache() method calls db() +# - Dependency chain is explicit +``` + +## Licensing and Headers + +The generated code doesn't include SPDX headers by default. Add them manually if needed: + +```python +header = """# SPDX-FileCopyrightText: 2025 Your Name +# +# SPDX-License-Identifier: ISC + +""" + +compiler = WiringCompiler(spec) +code = compiler.compile() +full_code = header + code + +with open("wiring.py", "w") as f: + f.write(full_code) +``` + +## Testing Compiled Code + +Test that compiled code behaves identically to runtime: + +```python +from apywire import Wiring + +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +# Runtime +wired_runtime = Wiring(spec) + +# Compiled +compiler = WiringCompiler(spec) +code = compiler.compile() +exec(code) # Defines Compiled class +wired_compiled = Compiled() + +# Both should produce same results +assert wired_runtime.now() == wired_compiled.now() +``` + +## Best Practices + +### 1. Generate During Build/Deploy + +```bash +# In your CI/CD pipeline +python scripts/generate_wiring.py +python setup.py build_ext --inplace # Optional: Cython compile +``` + +### 2. Version Control Generated Code + +**Option A**: Don't commit generated code (regenerate on deploy) + +```gitignore +# .gitignore +compiled_wiring.py +``` + +**Option B**: Commit generated code (easier for others to use) + +Pros: Others can use without regenerating +Cons: Diffs in PRs, risk of forgetting to regenerate + +### 3. Validate Generated Code + +```python +# In your tests +def test_compiled_code_valid(): + from apywire import WiringCompiler + + compiler = WiringCompiler(spec) + code = compiler.compile() + + # Check code compiles + compile(code, "wiring.py", "exec") + + # Check code executes + exec(code) +``` + +### 4. Use Same Options as Runtime + +If you use `thread_safe=True` in runtime, compile with it too: + +```python +# Development +wired = Wiring(spec, thread_safe=True) + +# Production +compiler = WiringCompiler(spec) +code = compiler.compile(thread_safe=True) # Match! +``` + +## Debugging Generated Code + +If generated code has issues: + +### 1. Save to File and Inspect + +```python +code = compiler.compile() +with open("debug_wiring.py", "w") as f: + f.write(code) + +# Open debug_wiring.py in your editor +``` + +### 2. Check for Syntax Errors + +```python +try: + compile(code, "wiring.py", "exec") +except SyntaxError as e: + print(f"Syntax error in generated code: {e}") +``` + +### 3. Test Imports + +Make sure all modules in your spec can be imported: + +```python +spec = { + "my.custom.module.Class obj": {}, +} + +# Make sure my.custom.module is importable! +import my.custom.module +``` + +## Limitations + +### 1. Dynamic Specs Not Supported + +Compilation is static. If your spec changes at runtime, you can't use compilation: + +```python +# This won't work with compilation +spec = {} +if os.getenv("PRODUCTION"): + spec["db"] = {...} +else: + spec["db"] = {...} # Different spec! +``` + +### 2. Lambda Functions + +Specs with lambdas can't be compiled: + +```python +spec = { + "MyClass obj": {"callback": lambda x: x * 2}, # Can't compile! +} +``` + +### 3. Complex Objects + +Some Python objects can't be represented as code literals: + +```python +import re + +spec = { + "regex": re.compile(r"\d+"), # Can't compile! +} +``` + +For these cases, use constants in the compiled code or factory functions. + +## Next Steps + +- **[Basic Usage](basic-usage.md)** - Understand runtime wiring first +- **[Thread Safety](thread-safety.md)** - Learn about thread-safe compilation +- **[Async Support](async-support.md)** - Learn about async compilation +- **[Advanced Features](advanced.md)** - Advanced patterns and edge cases diff --git a/docs/user-guide/configuration-files.md b/docs/user-guide/configuration-files.md new file mode 100644 index 0000000..042cd18 --- /dev/null +++ b/docs/user-guide/configuration-files.md @@ -0,0 +1,608 @@ + + +# Configuration Files + +Since apywire specs are just Python dictionaries, you can load them from any configuration file format: YAML, TOML, JSON, INI, or any other format that converts to Python data structures. + +## Why Use Configuration Files? + +- **Separation of Concerns**: Keep configuration separate from code +- **Environment-Specific**: Different configs for dev, staging, production +- **Version Control**: Track configuration changes +- **Non-Developers**: Allow non-programmers to modify configuration +- **Validation**: Use schema validation tools for your config format + +## YAML + +YAML is readable and supports complex nested structures. + +### Example: `config.yaml` + +```yaml +# Database configuration +database_url: postgresql://localhost/mydb +pool_min: 1 +pool_max: 20 + +# Database connection pool +psycopg2.pool.ThreadedConnectionPool pool: + minconn: "{pool_min}" + maxconn: "{pool_max}" + dsn: "{database_url}" + +# Redis cache +redis_url: redis://localhost:6379 +redis.Redis cache: + url: "{redis_url}" + +# Application service +MyService service: + db: "{pool}" + cache: "{cache}" + debug: true +``` + +### Loading YAML + +```python +import yaml +from apywire import Wiring + +# Load spec from YAML file +with open("config.yaml", "r") as f: + spec = yaml.safe_load(f) + +wired = Wiring(spec, thread_safe=True) +service = wired.service() +``` + +### YAML Benefits + +- ✅ Very human-readable +- ✅ Supports comments +- ✅ Native support for lists, dicts, booleans +- ✅ Multi-line strings +- ❌ Requires `pyyaml` package + +## TOML + +TOML is Python-friendly and great for structured configuration. + +### Example: `config.toml` + +```toml +# Database configuration +database_url = "postgresql://localhost/mydb" +pool_min = 1 +pool_max = 20 + +# Redis +redis_url = "redis://localhost:6379" + +# Database connection pool +["psycopg2.pool.ThreadedConnectionPool pool"] +minconn = "{pool_min}" +maxconn = "{pool_max}" +dsn = "{database_url}" + +# Redis cache +["redis.Redis cache"] +url = "{redis_url}" + +# Application service +["MyService service"] +db = "{pool}" +cache = "{cache}" +debug = true +``` + +!!! note "TOML Section Names with Spaces" + TOML section names with spaces must be quoted: `["module.Class name"]` + +### Loading TOML + +```python +import tomllib # Python 3.11+, or use 'tomli' package +from apywire import Wiring + +# Load TOML file +with open("config.toml", "rb") as f: + spec = tomllib.load(f) + +# Use directly - TOML structure matches apywire spec format! +wired = Wiring(spec) +``` + +### TOML Benefits + +- ✅ Built into Python 3.11+ (`tomllib`) +- ✅ Type-safe (strict types) +- ✅ Supports comments +- ✅ Good for structured data +- ✅ Dots in section names work perfectly +- ✅ No conversion needed! + +## JSON + +JSON is universal and works everywhere. + +### Example: `config.json` + +```json +{ + "database_url": "postgresql://localhost/mydb", + "pool_min": 1, + "pool_max": 20, + "redis_url": "redis://localhost:6379", + + "psycopg2.pool.ThreadedConnectionPool pool": { + "minconn": "{pool_min}", + "maxconn": "{pool_max}", + "dsn": "{database_url}" + }, + + "redis.Redis cache": { + "url": "{redis_url}" + }, + + "MyService service": { + "db": "{pool}", + "cache": "{cache}", + "debug": true + } +} +``` + +### Loading JSON + +```python +import json +from apywire import Wiring + +# Load spec from JSON file +with open("config.json", "r") as f: + spec = json.load(f) + +wired = Wiring(spec) +service = wired.service() +``` + +### JSON Benefits + +- ✅ Built into Python standard library +- ✅ Universal format +- ✅ Easy to generate programmatically +- ✅ Works with REST APIs +- ❌ No comments +- ❌ Verbose syntax + +## INI/CFG + +INI files are simple and widely used for configuration. + +### Example: `config.ini` + +```ini +[constants] +database_url = postgresql://localhost/mydb +pool_min = 1 +pool_max = 20 +redis_url = redis://localhost:6379 + +[psycopg2.pool.ThreadedConnectionPool pool] +minconn = {pool_min} +maxconn = {pool_max} +dsn = {database_url} + +[redis.Redis cache] +url = {redis_url} + +[MyService service] +db = {pool} +cache = {cache} +debug = true +``` + +### Loading INI + +```python +import configparser +from apywire import Wiring + +# Load INI file +config = configparser.ConfigParser() +config.read("config.ini") + +# Convert to apywire spec +spec = {} +for section in config.sections(): + if section == "constants": + # Constants go in top level + for key, value in config[section].items(): + # Type conversion (INI reads everything as strings) + if value.isdigit(): + spec[key] = int(value) + elif value.lower() in ("true", "false"): + spec[key] = value.lower() == "true" + else: + spec[key] = value + else: + # Wired objects + spec[section] = dict(config[section]) + +wired = Wiring(spec) +``` + +### INI Benefits + +- ✅ Built into Python standard library +- ✅ Simple syntax +- ✅ Widely understood +- ✅ Supports comments +- ❌ All values are strings (need type conversion) +- ❌ Limited nesting + +## Placeholder Expansion + +Constants can now reference other constants and wired objects using placeholder syntax `{name}`. + +### Constant → Constant (Immediate Expansion) + +When a constant references only other constants, it's expanded immediately at initialization: + +```yaml +# config.yaml +host: localhost +port: 5432 +database_name: myapp + +# This is expanded immediately +database_url: "postgresql://{host}:{port}/{database_name}" +``` + +```python +import yaml +from apywire import Wiring + +with open("config.yaml") as f: + spec = yaml.safe_load(f) + +wired = Wiring(spec) + +# The database_url constant is already expanded to the full connection string +``` + +**Nested references work too:** + +```yaml +base_url: http://api.example.com +v1_url: "{base_url}/v1" +users_endpoint: "{v1_url}/users" # Becomes "http://api.example.com/v1/users" +``` + +### Constant → Wired Object (Auto-Promoted) + +When a constant references a wired object, it's automatically promoted to an accessor with lazy evaluation: + +```yaml +database_url: "postgresql://localhost/mydb" + +psycopg2.connect conn: + dsn: "{database_url}" + +# This references a wired object, so it becomes an accessor +status: "Connected to {conn}" +``` + +```python +wired = Wiring(spec) + +# status is now an accessor (not in _values) +# It lazily instantiates conn and converts it to string +status_msg = wired.status() # "Connected to " +``` + +### Mixed References + +Constants with both constant and wired object references are auto-promoted: + +```yaml +host: localhost + +datetime.datetime server_start: + year: 2025 + month: 1 + day: 1 + +# This has both constant and wired refs, so it's auto-promoted +status: "Server {host} started at {server_start}" +``` + +```python +wired = Wiring(spec) + +# Use constants via placeholders in other wired objects +msg = wired.status() # "Server localhost started at 2025-01-01 00:00:00" +``` + +### Benefits + +- ✅ **DRY Configuration**: Define values once, reference everywhere +- ✅ **Computed Constants**: Build strings from multiple parts +- ✅ **Flexible**: Mix constants and wired objects +- ✅ **Lazy Evaluation**: Wired objects only instantiated when needed +- ✅ **Type Conversion**: Non-string constants automatically converted to strings + +### Circular Dependencies + +Circular references in constants are detected at initialization: + +```yaml +a: "{b}" +b: "{a}" # ❌ CircularWiringError +``` + +Circular references with wired objects follow normal lazy detection: + +```yaml +MyClass obj_a: + dep: "{obj_b}" + +MyClass obj_b: + dep: "{obj_a}" # ❌ CircularWiringError when accessed +``` + +## Environment-Based Configuration + +Load different configs based on environment: + +```python +import os +import yaml +from apywire import Wiring + +# Determine environment +env = os.getenv("APP_ENV", "dev") + +# Load appropriate config file +config_file = f"config.{env}.yaml" +with open(config_file, "r") as f: + spec = yaml.safe_load(f) + +wired = Wiring(spec, thread_safe=(env == "production")) +``` + +File structure: +``` +config.dev.yaml +config.staging.yaml +config.production.yaml +``` + +## Environment Variables in Configs + +Mix configuration files with environment variables: + +### YAML with Environment Variables + +```yaml +# config.yaml +database_url: ${DATABASE_URL} +api_key: ${API_KEY} + +MyService service: + db_url: "{database_url}" + api_key: "{api_key}" +``` + +### Loading with Substitution + +```python +import os +import yaml +import re +from apywire import Wiring + +def substitute_env_vars(config): + """Recursively substitute ${VAR} with environment variables.""" + if isinstance(config, dict): + return {k: substitute_env_vars(v) for k, v in config.items()} + elif isinstance(config, list): + return [substitute_env_vars(item) for item in config] + elif isinstance(config, str): + # Replace ${VAR} with environment variable + pattern = r'\$\{([^}]+)\}' + return re.sub(pattern, lambda m: os.getenv(m.group(1), ''), config) + return config + +# Load and substitute +with open("config.yaml", "r") as f: + raw_spec = yaml.safe_load(f) + +spec = substitute_env_vars(raw_spec) +wired = Wiring(spec) +``` + +## Schema Validation + +Validate your configuration files before loading: + +### YAML with Schema (using pydantic) + +```python +from pydantic import BaseModel, Field +import yaml +from apywire import Wiring + +class DatabaseConfig(BaseModel): + database_url: str + pool_min: int = Field(ge=1) + pool_max: int = Field(ge=1, le=100) + +class AppConfig(BaseModel): + database: DatabaseConfig + redis_url: str + debug: bool = False + +# Load and validate +with open("config.yaml", "r") as f: + raw_config = yaml.safe_load(f) + +# Validate structure +validated = AppConfig(**raw_config) + +# Build apywire spec from validated config +spec = { + "database_url": validated.database.database_url, + "pool_min": validated.database.pool_min, + "pool_max": validated.database.pool_max, + # ... rest of spec +} + +wired = Wiring(spec) +``` + +## Best Practices + +### 1. Keep Secrets Out of Config Files + +```python +# ❌ Bad: Secrets in config file +database_url: postgresql://user:password@localhost/db + +# ✅ Good: Reference environment variables +database_url: ${DATABASE_URL} +``` + +### 2. Use Separate Configs Per Environment + +``` +config/ +├── base.yaml # Shared configuration +├── dev.yaml # Development overrides +├── staging.yaml # Staging overrides +└── production.yaml # Production overrides +``` + +### 3. Document Your Config Format + +Add a `config.example.yaml` to your repository: + +```yaml +# config.example.yaml +# Copy this to config.yaml and customize + +database_url: postgresql://localhost/mydb # Database connection string +pool_min: 1 # Minimum pool connections +pool_max: 20 # Maximum pool connections +``` + +### 4. Validate Before Loading + +Always validate configuration before passing to Wiring: + +```python +def load_config(filename): + """Load and validate configuration.""" + with open(filename) as f: + spec = yaml.safe_load(f) + + # Validate required keys + required = ["database_url", "redis_url"] + for key in required: + if key not in spec: + raise ValueError(f"Missing required config: {key}") + + return spec + +spec = load_config("config.yaml") +wired = Wiring(spec) +``` + +## Complete Example + +Putting it all together with best practices: + +### Project Structure + +``` +myapp/ +├── config/ +│ ├── base.yaml +│ ├── dev.yaml +│ ├── production.yaml +│ └── config.example.yaml +├── app/ +│ ├── __init__.py +│ ├── config.py +│ └── main.py +└── .env.example +``` + +### `app/config.py` + +```python +import os +import yaml +from pathlib import Path +from apywire import Wiring + +def load_spec(env: str = None) -> dict: + """Load wiring spec from YAML configuration.""" + if env is None: + env = os.getenv("APP_ENV", "dev") + + config_dir = Path(__file__).parent.parent / "config" + + # Load base config + with open(config_dir / "base.yaml") as f: + spec = yaml.safe_load(f) + + # Load environment-specific overrides + env_file = config_dir / f"{env}.yaml" + if env_file.exists(): + with open(env_file) as f: + env_spec = yaml.safe_load(f) + spec.update(env_spec) + + # Substitute environment variables + spec = substitute_env_vars(spec) + + return spec + +def get_wired(env: str = None) -> Wiring: + """Get configured Wiring instance.""" + spec = load_spec(env) + thread_safe = env == "production" + return Wiring(spec, thread_safe=thread_safe) +``` + +### `app/main.py` + +```python +from app.config import get_wired + +def main(): + wired = get_wired() + + # Access configured services + db = wired.database() + cache = wired.cache() + service = wired.service() + + # Run application + service.run() + +if __name__ == "__main__": + main() +``` + +## Next Steps + +- **[Basic Usage](basic-usage.md)** - Learn the fundamentals +- **[Advanced Features](advanced.md)** - Complex patterns +- **[Examples](../examples.md)** - Real-world use cases diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md new file mode 100644 index 0000000..2636ee5 --- /dev/null +++ b/docs/user-guide/index.md @@ -0,0 +1,85 @@ + + +# User Guide + +Welcome to the apywire user guide! This section provides comprehensive documentation on all features and capabilities of apywire. + +## Contents + +### [Basic Usage](basic-usage.md) +Learn the fundamentals of using apywire, including creating wiring containers, defining specs, using the Accessor pattern, and understanding caching behavior. + +### [Configuration Files](configuration-files.md) +Load specs from YAML, TOML, JSON, or INI files. Mix configuration files with environment variables and implement environment-based configs. + +### [Async Support](async-support.md) +Discover how to use apywire in asynchronous contexts with `await wired.aio.name()` for async object access. + +### [Thread Safety](thread-safety.md) +Understand thread-safe instantiation, optimistic locking mechanisms, and configuration options for multi-threaded applications. + +### [Compilation](compilation.md) +Learn how to generate standalone Python code from your wiring specs using `WiringCompiler` for production deployment and performance optimization. + +### [Advanced Features](advanced.md) +Explore advanced capabilities including factory methods, positional arguments, complex nested dependencies, error handling, and best practices. + +## Overview + +apywire is designed around a few core principles: + +1. **Lazy Loading**: Objects are instantiated only when accessed, improving startup performance +2. **Dependency Injection**: Use placeholder syntax to express dependencies between objects +3. **Flexibility**: Support for keyword arguments, positional arguments, factory methods, and constants +4. **Type Safety**: Strict mypy typing throughout the library +5. **Performance**: Optional code generation via Cython for production deployments + +## Quick Navigation + +Looking for something specific? + +- **Getting started?** → [Basic Usage](basic-usage.md) +- **Need async?** → [Async Support](async-support.md) +- **Multi-threaded app?** → [Thread Safety](thread-safety.md) +- **Production deployment?** → [Compilation](compilation.md) +- **Complex use cases?** → [Advanced Features](advanced.md) + +## Common Workflows + +### Development + +1. Define your wiring spec with dependencies +2. Create a `Wiring` container +3. Access objects via the Accessor pattern +4. Use `thread_safe=False` for single-threaded development + +### Production + +1. Test your wiring configuration thoroughly +2. Use `WiringCompiler` to generate standalone code +3. Enable `thread_safe=True` if deploying multi-threaded +4. Optionally compile with Cython for maximum performance + +### Testing + +1. Define test-specific specs with mock objects +2. Use placeholder references to inject test doubles +3. Leverage lazy loading to avoid unnecessary instantiation +4. Test circular dependency error handling + +## Best Practices + +- **Keep specs simple**: Break complex wiring into smaller, focused specs +- **Use placeholders**: Express dependencies explicitly with `{name}` syntax +- **Validate early**: Access all wired objects in tests to catch configuration errors +- **Document dependencies**: Comment complex dependency chains in your specs +- **Consider thread safety**: Enable `thread_safe=True` only when needed (has overhead) +- **Compile for production**: Use `WiringCompiler` to generate optimized code + +## Next Steps + +Start with [Basic Usage](basic-usage.md) to learn the fundamentals, then explore the other guides based on your specific needs. diff --git a/docs/user-guide/thread-safety.md b/docs/user-guide/thread-safety.md new file mode 100644 index 0000000..71052a6 --- /dev/null +++ b/docs/user-guide/thread-safety.md @@ -0,0 +1,408 @@ + + +# Thread Safety + +apywire provides optional thread-safe instantiation for multi-threaded applications using optimistic locking. + +## Overview + +By default, `Wiring` containers are **not thread-safe**. This is intentional - thread safety has overhead, and many applications don't need it. + +For multi-threaded applications, enable thread safety: + +```python +from apywire import Wiring + +wired = Wiring(spec, thread_safe=True) +``` + +## When to Enable Thread Safety + +### ✅ Enable Thread Safety When: + +- Running a multi-threaded web server (e.g., Gunicorn with multiple workers using threads) +- Sharing a `Wiring` container across multiple threads +- Instantiating objects from multiple concurrent threads + +### ❌ Don't Enable Thread Safety When: + +- Using async/await with a single event loop (use async, not threads) +- Single-threaded applications +- Each thread has its own `Wiring` container instance + +## How It Works + +apywire uses a two-level locking strategy: + +### 1. Optimistic Per-Attribute Locking + +Each wired attribute has its own lock. When accessing an object: + +1. Try to acquire the attribute-specific lock +2. If acquired, instantiate the object and cache it +3. Release the lock + +This minimizes contention - different threads can instantiate different objects simultaneously. + +```python +# Thread 1 accessing wired.db() +# Thread 2 accessing wired.cache() +# These can happen concurrently - different locks! +``` + +### 2. Global Fallback Locking + +If a thread can't acquire an attribute lock (contention), it falls back to a global lock with retries: + +1. Try to acquire the global lock +2. Retry with exponential backoff +3. If max retries exceeded, raise `LockUnavailableError` + +This handles high-contention scenarios gracefully. + +## Configuration Options + +### max_lock_attempts + +Maximum number of retry attempts when falling back to global lock: + +```python +wired = Wiring(spec, thread_safe=True, max_lock_attempts=20) # Default: 10 +``` + +Higher values mean more retries before giving up, but longer potential waiting. + +### lock_retry_sleep + +Sleep duration (in seconds) between retry attempts: + +```python +wired = Wiring(spec, thread_safe=True, lock_retry_sleep=0.005) # Default: 0.01 +``` + +Lower values mean faster retries, but more CPU usage. Higher values reduce CPU but increase latency. + +## Basic Usage + +```python +import threading +from apywire import Wiring + +spec = { + "datetime.datetime now": {"year": 2025, "month": 1, "day": 1}, +} + +wired = Wiring(spec, thread_safe=True) + +def worker(): + dt = wired.now() # Thread-safe access + print(f"Thread {threading.current_thread().name}: {dt}") + +# Create multiple threads +threads = [threading.Thread(target=worker) for _ in range(10)] + +# Start all threads +for t in threads: + t.start() + +# Wait for completion +for t in threads: + t.join() + +# All threads get the same cached instance +``` + +## Error Handling + +### LockUnavailableError + +Raised when maximum lock retry attempts are exceeded: + +```python +from apywire import LockUnavailableError + +wired = Wiring(spec, thread_safe=True, max_lock_attempts=1) + +try: + obj = wired.my_object() +except LockUnavailableError as e: + print(f"Could not acquire lock: {e}") +``` + +In practice, this is rare and usually indicates: + +- Extremely high contention +- `max_lock_attempts` set too low +- A deadlock or very slow instantiation + +## Performance Implications + +Thread safety has overhead: + +### Non-Thread-Safe (Faster) + +```python +wired = Wiring(spec, thread_safe=False) # Default +obj = wired.my_object() # No locking overhead +``` + +### Thread-Safe (Slower) + +```python +wired = Wiring(spec, thread_safe=True) +obj = wired.my_object() # Lock acquisition/release overhead +``` + +The overhead is typically negligible, but for very high-frequency access patterns, it can add up. + +### Caching Reduces Overhead + +Objects are only instantiated once, so locking only happens on first access: + +```python +wired = Wiring(spec, thread_safe=True) + +# First access: pays locking overhead +obj1 = wired.my_object() + +# Subsequent accesses: instant, no locking needed +obj2 = wired.my_object() # Cached! +``` + +## Real-World Example: Multi-Threaded Web Server + +```python +from concurrent.futures import ThreadPoolExecutor +from apywire import Wiring + +spec = { + "database_url": "postgresql://localhost/mydb", + "psycopg2.pool.ThreadedConnectionPool pool": { + "minconn": 1, + "maxconn": 20, + "dsn": "{database_url}", + }, +} + +# Create thread-safe wiring container +wired = Wiring(spec, thread_safe=True) + +def handle_request(request_id): + """Simulate handling a web request.""" + # Each thread safely accesses the connection pool + pool = wired.pool() + conn = pool.getconn() + + # Use connection... + print(f"Request {request_id} using connection") + + pool.putconn(conn) + +# Simulate multiple concurrent requests +with ThreadPoolExecutor(max_workers=10) as executor: + futures = [executor.submit(handle_request, i) for i in range(100)] + for future in futures: + future.result() +``` + +## Combining with Async + +You can combine thread safety with async access: + +```python +import asyncio +from apywire import Wiring + +wired = Wiring(spec, thread_safe=True) + +async def main(): + # Thread-safe async access + obj = await wired.aio.my_object() +``` + +This is useful when: + +- Running multiple event loops in different threads +- Mixing sync threaded code with async code + +## Compiling with Thread Safety + +Generate thread-safe compiled code: + +```python +from apywire import WiringCompiler + +compiler = WiringCompiler(spec) +code = compiler.compile(thread_safe=True) +``` + +The generated code will include the same two-level locking mechanism. + +## Thread-Local State + +apywire uses thread-local storage to track the resolution stack for circular dependency detection: + +```python +# Under the hood +import threading + +class _ThreadLocalState(threading.local): + def __init__(self): + self.resolving: list[str] = [] +``` + +This ensures circular dependency detection works correctly across threads without interference. + +## Testing Thread Safety + +### Test for Race Conditions + +```python +import threading +from apywire import Wiring + +spec = { + "MyCounter counter": {}, +} + +wired = Wiring(spec, thread_safe=True) + +results = [] + +def worker(): + obj = wired.counter() + results.append(id(obj)) # Store object id + +threads = [threading.Thread(target=worker) for _ in range(100)] + +for t in threads: + t.start() +for t in threads: + t.join() + +# All threads should get the same object +assert len(set(results)) == 1, "Race condition detected!" +``` + +### Test for Deadlocks + +```python +def test_no_deadlock(): + spec = { + "MyClass obj": {}, + } + + wired = Wiring(spec, thread_safe=True, max_lock_attempts=5) + + def worker(): + for _ in range(100): + _ = wired.obj() + + threads = [threading.Thread(target=worker) for _ in range(10)] + + for t in threads: + t.start() + for t in threads: + t.join() # Should complete without hanging +``` + +## Best Practices + +### 1. Enable Thread Safety Only When Needed + +```python +# Development (single-threaded) +wired = Wiring(spec, thread_safe=False) + +# Production (multi-threaded) +import os +thread_safe = os.getenv("MULTI_THREADED", "false") == "true" +wired = Wiring(spec, thread_safe=thread_safe) +``` + +### 2. Test with Realistic Concurrency + +```python +# Test with actual thread count you'll use in production +from concurrent.futures import ThreadPoolExecutor + +def test_with_realistic_concurrency(): + wired = Wiring(spec, thread_safe=True) + + with ThreadPoolExecutor(max_workers=20) as executor: # Match prod + futures = [executor.submit(wired.my_object) for _ in range(1000)] + for future in futures: + future.result() +``` + +### 3. Tune Lock Parameters for Your Use Case + +```python +# High contention: increase retries +wired = Wiring(spec, thread_safe=True, max_lock_attempts=50) + +# Low contention: decrease sleep time +wired = Wiring(spec, thread_safe=True, lock_retry_sleep=0.001) +``` + +### 4. Monitor for LockUnavailableError + +```python +import logging + +logger = logging.getLogger(__name__) + +try: + obj = wired.my_object() +except LockUnavailableError: + logger.error("Lock contention too high - consider increasing max_lock_attempts") + raise +``` + +## Advanced: Understanding the Lock Mechanism + +The locking implementation uses: + +1. **Per-attribute locks**: `dict[str, threading.Lock]` - one lock per wired attribute +2. **Global lock**: Single `threading.Lock` for fallback +3. **Thread-local state**: `threading.local()` for circular dependency tracking + +```python +# Simplified pseudocode +def __getattr__(self, name: str): + if name in self._values: + return self._values[name] # Already cached + + # Try optimistic lock + attr_lock = self._attr_locks.get(name) + if attr_lock.acquire(blocking=False): + try: + obj = self._instantiate(name) + self._values[name] = obj + return obj + finally: + attr_lock.release() + + # Fall back to global lock with retries + for attempt in range(self._max_lock_attempts): + if self._global_lock.acquire(blocking=False): + try: + obj = self._instantiate(name) + self._values[name] = obj + return obj + finally: + self._global_lock.release() + time.sleep(self._lock_retry_sleep) + + raise LockUnavailableError(f"Could not acquire lock for {name}") +``` + +## Next Steps + +- **[Basic Usage](basic-usage.md)** - Learn about non-thread-safe usage +- **[Async Support](async-support.md)** - Combine with async patterns +- **[Compilation](compilation.md)** - Generate thread-safe compiled code diff --git a/examples/basic_app/README.md b/examples/basic_app/README.md new file mode 100644 index 0000000..49ac4d9 --- /dev/null +++ b/examples/basic_app/README.md @@ -0,0 +1,26 @@ + + +# Basic App Example + +This example demonstrates the core concepts of `apywire`: + +1. **Defining a Spec**: A dictionary that describes your application's components and their dependencies. +2. **Wiring Objects**: Using the `Wiring` class to create a container. +3. **Dependency Injection**: Automatically injecting dependencies (like `GreetingService` into `Greeter`). +4. **Constants & Placeholders**: Using `{placeholder}` syntax to reference values and other objects. + +## Running the Example + +```bash +python app.py +``` + +Expected output: + +``` +Hello, World! +``` diff --git a/examples/basic_app/app.py b/examples/basic_app/app.py new file mode 100644 index 0000000..3cab5c5 --- /dev/null +++ b/examples/basic_app/app.py @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +from dataclasses import dataclass + +from apywire import Wiring + + +@dataclass +class GreetingService: + greeting: str + + def greet(self, name: str) -> str: + return f"{self.greeting}, {name}!" + + +@dataclass +class Greeter: + service: GreetingService + default_name: str + + def run(self) -> None: + print(self.service.greet(self.default_name)) + + +def main(): + spec = { + # Define constants + "greeting_msg": "Hello", + "default_person": "World", + # Define wired objects + "__main__.GreetingService my_service": { + "greeting": "{greeting_msg}", + }, + "__main__.Greeter my_greeter": { + "service": "{my_service}", + "default_name": "{default_person}", + }, + } + + wiring = Wiring(spec) + # Access the wired object + greeter = wiring.my_greeter() + greeter.run() + + +if __name__ == "__main__": + main() diff --git a/examples/kv_store/README.md b/examples/kv_store/README.md new file mode 100644 index 0000000..8744200 --- /dev/null +++ b/examples/kv_store/README.md @@ -0,0 +1,45 @@ + + +# Key-Value Store Example + +This example demonstrates how to wire **foreign objects** (classes from external packages) using `apywire`. + +We instantiate `fakeredis.FakeRedis` directly from the configuration file, passing constructor arguments. This shows how `apywire` can act as a universal factory for any Python class, not just your own. + +## Dependencies + +This example requires `fakeredis` and `pyyaml`. + +```bash +pip install -r requirements.txt +``` + +## Running the Example + +```bash +python main.py +``` + +Expected output: + +``` +Setting 'foo' to 'bar'... +Got 'foo': bar +``` + +## Swapping Implementations + +To use a real Redis server, you would simply change the config to: + +```yaml +redis.Redis redis: + host: "localhost" + port: 6379 + # ... +``` + +No code changes required! diff --git a/examples/kv_store/config.yaml b/examples/kv_store/config.yaml new file mode 100644 index 0000000..26c6278 --- /dev/null +++ b/examples/kv_store/config.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +# Wire a foreign object (fakeredis.FakeRedis) directly +# We can pass constructor arguments just like any other object +fakeredis.FakeRedis redis: + host: "localhost" + port: 6379 + db: 0 + decode_responses: true + +# Wire our application, injecting the redis client +__main__.KVApp app: + client: "{redis}" diff --git a/examples/kv_store/main.py b/examples/kv_store/main.py new file mode 100644 index 0000000..188e1f2 --- /dev/null +++ b/examples/kv_store/main.py @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +from dataclasses import dataclass +from typing import Protocol + +from yaml import safe_load + +from apywire import Wiring + + +class RedisClient(Protocol): + def set(self, name: str, value: str) -> bool: ... + def get(self, name: str) -> str | None: ... + + +@dataclass +class KVApp: + client: RedisClient + + def run(self) -> None: + print("Setting 'foo' to 'bar'...") + self.client.set("foo", "bar") + + val = self.client.get("foo") + print(f"Got 'foo': {val}") + + +if __name__ == "__main__": + # Load the spec + with open("config.yaml", "r") as f: + spec = safe_load(f) + + # Initialize wiring + wiring = Wiring(spec) + + # Get the app + app = wiring.app() + app.run() diff --git a/examples/kv_store/requirements.txt b/examples/kv_store/requirements.txt new file mode 100644 index 0000000..3be6677 --- /dev/null +++ b/examples/kv_store/requirements.txt @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +fakeredis +pyyaml diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..d9ff11a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2025 Alexandre Gomes Gaigalas +# +# SPDX-License-Identifier: ISC + +site_name: apywire +site_description: A Python package for lazy object wiring and dependency injection +site_url: https://github.com/alganet/apywire +repo_url: https://github.com/alganet/apywire +repo_name: alganet/apywire + +theme: + name: material + palette: + # Light mode + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + - navigation.sections + - navigation.top + - search.suggest + - search.highlight + - content.code.copy + +nav: + - Home: index.md + - Getting Started: getting-started.md + - User Guide: + - user-guide/index.md + - Basic Usage: user-guide/basic-usage.md + - Configuration Files: user-guide/configuration-files.md + - Async Support: user-guide/async-support.md + - Thread Safety: user-guide/thread-safety.md + - Compilation: user-guide/compilation.md + - Advanced Features: user-guide/advanced.md + - API Reference: api-reference.md + - Examples: examples.md + - Development: development.md + +plugins: + - search + - autorefs + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + show_source: true + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + separate_signature: true + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + - tables + - toc: + permalink: true diff --git a/pyproject.toml b/pyproject.toml index 7370de5..0b77f37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,10 @@ dev = [ "flake8", "flake8-pyproject", "isort", + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]", + "mkdocs-autorefs", "mypy", "pytest-cov", "pytest",