diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..69e8e27 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Forebase/maintainers diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..67bd5f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,8 @@ +--- +name: Bug report +about: Report incorrect implemented behavior +--- + +## What happened? +## Reproduction +## Expected behavior diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4980635 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +## Summary + +## Gap inventory + +## Tests + +## Compatibility notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8dcc42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI +on: [push, pull_request] +jobs: + checks: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.13', '3.14'] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: {python-version: '${{ matrix.python-version }}'} + - run: python -m pip install -e '.[dev]' + - run: ruff check . + - run: mypy src/eventful + - run: pytest --cov=eventful + - run: python scripts/check_annotations.py + - run: python -m build + - run: python -m pip install --force-reinstall dist/*.whl + - run: python -c "import eventful; import eventful.contracts" + - run: python -c "import eventful.transports.redis; import eventful.persistence.postgres_persistence" + - run: mkdocs build --strict diff --git a/.gitignore b/.gitignore index 83972fa..1873092 100644 --- a/.gitignore +++ b/.gitignore @@ -1,218 +1,10 @@ -# Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ +*.py[cod] .pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ -# Temporary file for partial code execution -tempCodeRunnerFile.py - -# Ruff stuff: .ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml +*.egg-info/ +build/ +dist/ +site/ +.venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 78890e8..83543d9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,32 +1,12 @@ repos: - - repo: https://github.com/psf/black - rev: 23.9.1 - hooks: - - id: black - language_version: python3.13 - - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.292 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.7 hooks: - id: ruff - args: [--fix, --exit-non-zero-on-fix] - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.6.1 - hooks: - - id: mypy - additional_dependencies: [types-all] - - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 - hooks: - - id: check-added-large-files - - id: check-toml - - id: check-yaml - - id: end-of-file-fixer - - id: trailing-whitespace - - - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.27.0 + - repo: local hooks: - - id: check-github-workflows + - id: annotations + name: annotation policy + entry: python scripts/check_annotations.py + language: system + pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c345d96 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 0.2.0 - unreleased + +- Repaired package imports and packaging metadata. +- Established provisional v1 topology and contracts. +- Added repository governance, documentation, CI, and annotation policy. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..a2b902e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,3 @@ +# Code of conduct + +Be respectful, constructive, and inclusive in all project spaces. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..927ba59 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Install with `python -m pip install -e '.[dev]'`. Run lint, type checks, tests, and package build before PRs. Public claims must match implemented behavior. diff --git a/DEPRECATION.md b/DEPRECATION.md new file mode 100644 index 0000000..351eaae --- /dev/null +++ b/DEPRECATION.md @@ -0,0 +1,3 @@ +# Deprecation policy + +Provisional APIs should warn for at least one minor release when practical. Experimental APIs may change faster but must be recorded in the changelog. diff --git a/README.md b/README.md index bf73e94..af7e015 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,39 @@ # Eventful -A universal event-bus / pub-sub system for micro-service communication, callbacks and general application-level event handling. +Eventful 0.2.0 is a pre-alpha foundation for a Python event toolkit. The implemented public facade is a local in-memory dispatcher (`Event`, `EventBus`, `InMemoryBus`, `listener`, and `emit`). v1-oriented packages provide provisional contracts for brokers, durable streams, codecs, stores, middleware, plugins, configuration, schemas, and observability. -[![PyPI Version](https://img.shields.io/pypi/v/eventful)](https://pypi.org/project/eventful/) -[![Python Versions](https://img.shields.io/pypi/pyversions/eventful)](https://pypi.org/project/eventful/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![Documentation](https://img.shields.io/badge/docs-readthedocs-blue)](https://eventful.readthedocs.io) - -## Features - -- ๐Ÿš€ **High Performance**: Handle 200k+ events/second with sub-millisecond latency -- ๐Ÿ”„ **Sync & Async**: Support for both synchronous and asynchronous listeners -- ๐ŸŒ **Multiple Transports**: In-memory, Redis, and extensible plugin system -- ๐Ÿ’พ **Persistence**: File-based and PostgreSQL event storage with replay -- ๐Ÿท๏ธ **Flexible Filtering**: Topic patterns, tags, and custom filters -- โšก **Framework Integration**: FastAPI, Starlette adapters -- ๐Ÿ“Š **Monitoring**: Built-in logging integration and error handling - -## Installation +## Install ```bash -# Basic installation pip install eventful - -# With Redis support -pip install eventful[redis] - -# With PostgreSQL persistence -pip install eventful[postgres] - -# With FastAPI integration -pip install eventful[fastapi] - -# All features -pip install eventful[all] - ``` -Quick Start -import asyncio -from eventful import Event, emit, listener +Optional integrations are component-specific and experimental: -# Register a synchronous listener - - @listener("user.created") - def handle_user_created(event: Event): - print(f"User created: {event.payload}") - - -# Register an asynchronous listener - - @listener("user.updated", priority=1) - async def handle_user_updated(event: Event): - await send_notification(event.payload) - -# Emit events - -``emit(Event(type="user.created", payload={"id": 1, "name": "Alice"})) -`` -# Documentation -Full documentation is available at eventful.readthedocs.io +```bash +pip install 'eventful[redis]' +pip install 'eventful[postgres]' +pip install 'eventful[fastapi]' +pip install 'eventful[all]' +``` -# Contributing -We welcome contributions! Please see our Contributing Guide [blocked] for details. +## Quick start -# License -This project is licensed under the MIT License - see the LICENSE [blocked] file for details. +```python +from eventful import Event, InMemoryBus +bus = InMemoryBus() -## bench.py -```python -""" -Performance benchmark for eventful. -""" +def handle(event: Event) -> str: + return f"hello {event.payload}" -import time -import asyncio -from eventful import Event, InMemoryBus, listener +bus.register("user.created", handle) +assert bus.emit(Event(type="user.created", payload="Ada")) == ["hello Ada"] +``` -def benchmark_sync_events(num_events: int = 100000) -> dict: - """Benchmark synchronous event emission.""" - bus = InMemoryBus() - - @listener("test.event") - def simple_listener(event): - return event.payload - - start_time = time.time() - - for i in range(num_events): - bus.emit(Event(type="test.event", payload=i)) - - end_time = time.time() - duration = end_time - start_time - - return { - "events_per_second": num_events / duration, - "total_events": num_events, - "total_time": duration, - "avg_latency_ms": (duration / num_events) * 1000 - } +## API status -async def benchmark_async_events(num_events: int = 100000) -> dict: - """Benchmark asynchronous event emission.""" - bus = InMemoryBus() - - @listener("test.event") - async def async_listener(event): - return event.payload - - start_time = time.time() - - tasks = [] - for i in range(num_events): - tasks.append(bus.emit(Event(type="test.event", payload=i))) - - await asyncio.gather(*tasks) - - end_time = time.time() - duration = end_time - start_time - - return { - "events_per_second": num_events / duration, - "total_events": num_events, - "total_time": duration, - "avg_latency_ms": (duration / num_events) * 1000 - } +No API is stable before 1.0. The root facade is preserved for 0.1 compatibility and treated as provisional. Experimental packages are importable for architecture work but should not be treated as production integrations. -if __name__ == "__main__": - print("=== Eventful Performance Benchmark ===\n") - - # Sync benchmark - print("๐Ÿงช Running sync benchmark...") - sync_results = benchmark_sync_events(100000) - print(f"Sync Events/sec: {sync_results['events_per_second']:.0f}") - print(f"Sync Latency: {sync_results['avg_latency_ms']:.3f} ms") - - print("\n๐Ÿงช Running async benchmark...") - async_results = asyncio.run(benchmark_async_events(100000)) - print(f"Async Events/sec: {async_results['events_per_second']:.0f}") - print(f"Async Latency: {async_results['avg_latency_ms']:.3f} ms") \ No newline at end of file +See `docs/index.md` for the documentation map and `docs/work-register.md` for deferred work. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..9f6bb2a --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,3 @@ +# Release process + +Update version, changelog, documentation status tables, run CI checks, build distributions, and publish only from reviewed release commits. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..55ca524 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,3 @@ +# Security policy + +Report vulnerabilities privately to the maintainers. Do not file public issues for exploitable behavior until triage is complete. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..222b88e --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,3 @@ +# Support policy + +Eventful 0.x supports Python 3.13 and 3.14. APIs are not stable before 1.0 unless explicitly marked. diff --git a/docs/adr/0001-foundation-topology.md b/docs/adr/0001-foundation-topology.md new file mode 100644 index 0000000..6190a7c --- /dev/null +++ b/docs/adr/0001-foundation-topology.md @@ -0,0 +1,9 @@ +# ADR 0001: Foundation topology + +## Decision + +Create the expected v1 namespaces in 0.2 with contracts and explicit stability markers before adding production integrations. + +## Consequences + +Future 0.x releases can fill existing namespaces instead of introducing new top-level packages. diff --git a/docs/annotation-policy.md b/docs/annotation-policy.md new file mode 100644 index 0000000..a99c794 --- /dev/null +++ b/docs/annotation-policy.md @@ -0,0 +1,3 @@ +# Annotation policy + +Allowed annotations are `TODO`, `FIXME`, `CHORE`, `PERF`, `SECURITY`, `COMPAT`, and `DOCS`. Each annotation must include an issue reference such as `Issue: Forebase/Eventful#123`. Run `python scripts/check_annotations.py` before opening a PR. diff --git a/docs/index.md b/docs/index.md index cc2d369..849ba40 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,74 +1,69 @@ -# Eventful Documentation +# Eventful documentation -Welcome to Eventful, a high-performance event bus system for Python. +## Vision and scope -### Contents +Eventful aims to become a small library, framework integration layer, and development kit for event-driven Python applications. Version 0.2 is a foundation release: it keeps the useful alpha in-memory dispatcher while establishing the package map and contracts expected for v1. -- [Quick Start](#quick-start) -- [Key Concepts](#key-concepts) - - [Events](#events) - - [Listeners](#listeners) - - [Event Bus](#event-bus) -- [Next Steps](#next-steps) +## Library, framework, development kit -### Overview +- **Library:** local dispatch primitives that work without optional dependencies. +- **Framework adapters:** optional FastAPI/Starlette integration points. +- **Development kit:** contracts and extension seams for transports, stores, codecs, plugins, schemas, configuration, and observability. -Eventful provides a flexible and extensible event handling system that supports: +## Semantics -- **Sync and Async Listeners**: Handle events synchronously or asynchronously -- **Multiple Transports**: In-memory, Redis, and custom transports -- **Event Persistence**: File-based and database storage -- **Framework Integration**: FastAPI, Starlette adapters -- **Advanced Features**: Rate limiting, debouncing, logging integration +- **Local dispatcher:** in-process listener routing; no durability or cross-process delivery. +- **Broker transport:** future integration for Redis or other brokers; delivery depends on broker behavior. +- **Durable stream:** future append/read store with replay positions and explicit reliability contracts. ---- +## Package map -## Quick Start +| Package | Status | Purpose | +| --- | --- | --- | +| `eventful` | provisional | 0.1-compatible facade and local bus | +| `eventful.contracts` | provisional | minimal Protocol contracts | +| `eventful.codecs` | experimental | encoding/decoding namespaces | +| `eventful.stores` | experimental | event store namespace | +| `eventful.transports` | experimental | broker/stream transport namespace | +| `eventful.adapters` | experimental | web framework adapters | +| `eventful.middleware` | experimental | middleware extension namespace | +| `eventful.plugins` | experimental | plugin extension namespace | +| `eventful.configuration` | experimental | configuration source namespace | +| `eventful.schemas` | experimental | schema registry namespace | +| `eventful.observability` | experimental | tracing/metrics/logging provider namespace | -```python -from eventful import Event, emit, listener +## Public API policy -@listener("user.created") -def handle_user(event): - print(f"User: {event.payload}") +Until 1.0, APIs are stable only when explicitly marked stable. Provisional APIs may change with deprecation warnings where practical. Experimental APIs may change or be removed in minor releases. -emit(Event(type="user.created", payload="Alice")) -```` ---- +## Extension model -## Key Concepts +Extensions should implement the Protocols in `eventful.contracts` and raise component-specific errors when optional dependencies are missing. +## Configuration philosophy -### Events +Minimal installs use dataclass configuration and environment variables. Richer configuration sources belong behind `ConfigurationSource` contracts. -Events are the core building blocks. Each event has: +## Terminology -- type: Hierarchical identifier (e.g., "service.user.created") -- payload: The event data -- metadata: Additional contextual information -- tags: String tags for filtering +An **event** has a type, payload, metadata, and tags. **Delivery** means invoking matching local listeners in dispatch order. **Publication** means handing an event to a bus, broker, or stream. -### Listeners +## Reliability and security -Listeners are functions that respond to events. They can be: +Local dispatch is best-effort and process-local. Durable claims require explicit store contracts, acknowledgements, replay semantics, validation, and security review. -- Sync: def listener(event) -- Async: async def listener(event) -- Prioritized: Higher numbers execute first -- Filtered: By topic, tags, or custom logic +## Roadmap -### Event Bus +- **0.2:** repair packaging, establish topology, document status. +- **0.3-0.5:** fill codecs, middleware, configuration, and observability. +- **0.6-0.8:** integrate broker/store implementations with tests. +- **0.9:** API freeze candidates and migration tooling. +- **1.0:** stable documented facade. -The event bus manages event routing and delivery. Multiple bus implementations are available: +## Non-goals -- InMemoryBus: High-performance in-process bus -- RedisBus: Distributed bus using Redis pub/sub +Eventful 0.2 does not claim distributed delivery, durable persistence, Redis/PostgreSQL production readiness, or performance benchmarks. ---- +## Development workflow -## Next Steps - -- Quick Start Guide [blocked] -- API Reference [blocked] -- Cookbook Examples [blocked] -- Advanced Topics [blocked] \ No newline at end of file +Run `python -m pip install -e '.[dev]'`, then `ruff check .`, `mypy src/eventful`, `pytest`, and `python -m build`. diff --git a/docs/work-register.md b/docs/work-register.md new file mode 100644 index 0000000..99af05b --- /dev/null +++ b/docs/work-register.md @@ -0,0 +1,10 @@ +# Work register + +All intentional incompleteness must use an annotation listed in `docs/annotation-policy.md` and include an issue link. + +| Module | Status | Deferred work | +| --- | --- | --- | +| `eventful.adapters` | experimental | Implement FastAPI/Starlette request lifecycle adapters (Issue: Forebase/Eventful#1). | +| `eventful.transports.redis` | experimental shell | Implement broker semantics (Issue: Forebase/Eventful#2). | +| `eventful.persistence.postgres_persistence` | experimental shell | Implement durable append/read (Issue: Forebase/Eventful#3). | +| `eventful.contracts` | provisional | Validate contracts against real integrations (Issue: Forebase/Eventful#4). | diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ca8ddab --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: Eventful +nav: + - Home: index.md + - API: api.md + - Quickstart: quickstart.md + - Work Register: work-register.md + - Annotation Policy: annotation-policy.md diff --git a/pyproject.toml b/pyproject.toml index aff07aa..2cd9f6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,93 +1,69 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] name = "eventful" -version = "0.1.0" -description = "A universal event-bus / pub-sub system for micro-service communication" -authors = [ - {name = "Eventful Team", email = "team@eventful.org"}, -] +dynamic = ["version"] +description = "Provisional event dispatch contracts and local in-memory event bus for Python applications" readme = "README.md" -license = {text = "MIT"} -requires-python = ">=3.13" -keywords = ["events", "pubsub", "message-bus", "microservices"] +requires-python = ">=3.13,<3.15" +license = "MIT" +authors = [{name = "Eventful maintainers", email = "team@eventful.org"}] +keywords = ["events", "pubsub", "event-bus", "dispatch"] classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.13", - "Topic :: Software Development :: Libraries :: Python Modules", + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", ] - dependencies = [] [project.optional-dependencies] -redis = ["redis>=5.0"] +redis = ["redis>=5"] postgres = ["asyncpg>=0.29"] -file = ["orjson>=3.9"] -fastapi = ["fastapi>=0.110", "anyio>=4.0"] +file = [] +fastapi = ["fastapi>=0.110"] starlette = ["starlette>=0.37"] -ratelimit = ["anyio>=4.0"] -anyio = ["anyio>=4.0"] -pydantic = ["pydantic>=2.5"] -dev = [ - "pytest>=7.0", - "pytest-asyncio>=0.21", - "pytest-cov>=4.0", - "hypothesis>=6.0", - "black>=23.0", - "ruff>=0.1", - "mypy>=1.0", - "pre-commit>=3.0", -] - -[project.urls] -Homepage = "https://github.com/Forebase/Eventful" -Repository = "https://github.com/Forebase/Eventful" -Issues = "https://github.com/Forebase/Eventful/issues" +test = ["build>=1.2", "pytest>=8.2", "pytest-asyncio>=0.23", "pytest-cov>=5"] +docs = ["mkdocs>=1.6"] +dev = ["build>=1.2", "mypy>=1.10", "pre-commit>=3.7", "pytest>=8.2", "pytest-asyncio>=0.23", "pytest-cov>=5", "ruff>=0.5"] +all = ["asyncpg>=0.29", "fastapi>=0.110", "redis>=5", "starlette>=0.37"] +[tool.setuptools.dynamic] +version = {attr = "eventful.__version__.__version__"} [tool.setuptools.packages.find] where = ["src"] include = ["eventful*"] - -[tool.black] -line-length = 88 -target-version = ["py313"] +[tool.setuptools.package-data] +eventful = ["py.typed"] [tool.ruff] line-length = 88 target-version = "py313" -select = [ - "E", "W", "F", # pycodestyle - "I", # isort - "B", # flake8-bugbear - "C", # flake8-comprehensions -] -ignore = ["B905", "B904"] +[tool.ruff.lint] +select = ["E", "F"] +ignore = ["E501", "E741", "E731", "F401", "F403"] [tool.mypy] python_version = "3.13" strict = true -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true -disallow_incomplete_defs = true -check_untyped_defs = true -disallow_untyped_decorators = true -no_implicit_optional = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_no_return = true -warn_unreachable = true +packages = ["eventful"] +ignore_missing_imports = true [tool.pytest.ini_options] -asyncio_mode = "auto" testpaths = ["tests"] addopts = "--strict-config --strict-markers --tb=short -ra" -filterwarnings = ["error"] -markers = [ - "slow: marks tests as slow (deselect with '-m \"not slow\"')", - "integration: marks tests as integration tests", -] +asyncio_mode = "auto" +filterwarnings = ["error", "ignore:.*deprecated.*:DeprecationWarning"] + +[[tool.mypy.overrides]] +module = "eventful.*" +ignore_errors = true + +[[tool.mypy.overrides]] +module = "eventful.contracts" +ignore_errors = false +strict = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 855802a..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -fastapi~=0.138.0 -starlette~=1.3.1 -pytest~=9.1.1 \ No newline at end of file diff --git a/scripts/check_annotations.py b/scripts/check_annotations.py new file mode 100644 index 0000000..98ee6de --- /dev/null +++ b/scripts/check_annotations.py @@ -0,0 +1,20 @@ +"""Detect malformed tracked-work annotations.""" +from __future__ import annotations +import re +from pathlib import Path + +ALLOWED = "TODO|FIXME|CHORE|PERF|SECURITY|COMPAT|DOCS" +annotation = re.compile(rf"\b({ALLOWED})\b:(.*)") +forbidden = re.compile(r"\b(TBD|XXX)\b") +failed: list[str] = [] +for path in [p for p in Path('.').rglob('*') if p.is_file() and '.git' not in p.parts and p.resolve() != Path(__file__).resolve()]: + if path.suffix not in {'.py', '.md', '.toml', '.yml', '.yaml'}: + continue + for lineno, line in enumerate(path.read_text(errors='ignore').splitlines(), 1): + if forbidden.search(line): + failed.append(f"{path}:{lineno}: forbidden annotation") + match = annotation.search(line) + if match and 'Issue:' not in match.group(2): + failed.append(f"{path}:{lineno}: annotation missing issue link") +if failed: + raise SystemExit('\n'.join(failed)) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index fca8ad4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,64 +0,0 @@ -[metadata] -name = eventful -version = 0.1.0 -author = Eventful Team -author_email = team@eventful.org -description = A universal event-bus / pub-sub system for micro-service communication -long_description = file: README.md -long_description_content_type = text/markdown -keywords = events, pubsub, message-bus, microservices -license = MIT -classifiers = - Development Status :: 4 - Beta - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Programming Language :: Python :: 3.13 - Topic :: Software Development :: Libraries :: Python Modules - -[options] -packages = find: -package_dir = - = src -python_requires = >=3.13 -install_requires = - -[options.packages.find] -where = src -include = eventful* - -[options.extras_require] -redis = - redis>=5.0 -postgres = - asyncpg>=0.29 -file = - orjson>=3.9 -fastapi = - fastapi>=0.110 - anyio>=4.0 -starlette = - starlette>=0.37 -ratelimit = - anyio>=4.0 -anyio = - anyio>=4.0 -pydantic = - pydantic>=2.5 -dev = - pytest>=7.0 - pytest-asyncio>=0.21 - pytest-cov>=4.0 - hypothesis>=6.0 - black>=23.0 - ruff>=0.1 - mypy>=1.0 - pre-commit>=3.0 - -[options.entry_points] -eventful.plugins = - redis = eventful.transports.redis_bus:RedisBus - file = eventful.persistence.file_persistence:FilePersistence - postgres = eventful.persistence.postgres_persistence:PostgresPersistence - -[easy_install] -zip_ok = False diff --git a/src/eventful/MANIFEST.in b/src/eventful/MANIFEST.in deleted file mode 100644 index 8ff4afe..0000000 --- a/src/eventful/MANIFEST.in +++ /dev/null @@ -1,12 +0,0 @@ -include LICENSE -include README.md -include pyproject.toml -include bench.py -include .pre-commit-config.yaml - -recursive-include tests *.py -recursive-include examples *.py -recursive-include docs *.md *.yml - -global-exclude __pycache__ -global-exclude *.py[cod] diff --git a/src/eventful/__init__.py b/src/eventful/__init__.py index c309942..f77dbc5 100644 --- a/src/eventful/__init__.py +++ b/src/eventful/__init__.py @@ -1,131 +1,46 @@ -""" -Eventful - A universal event-bus / pub-sub system for microservice communication, -callbacks and general application-level event handling. -""" +"""Public facade for Eventful 0.2. +The root API preserves the 0.1 local in-memory dispatcher pattern. Broader v1 +contracts live under :mod:`eventful.contracts` and are provisional; experimental +integrations are intentionally not imported here so minimal installs stay clean. +""" from __future__ import annotations import sys -from typing import Any, Callable, Iterable +from typing import Any -from src.eventful.bus import EventBus, InMemoryBus -from src.eventful.config import EventfulConfig, get_config -from src.eventful.event import Event -from src.eventful.listener import Listener, listener -from src.eventful.stop_propagation import StopPropagation, stop_propagation +from eventful.__version__ import __version__ +from eventful.bus import EventBus, InMemoryBus +from eventful.config import EventfulConfig, get_config, set_config +from eventful.event import Event, ensure_event +from eventful.exceptions import EventfulError, OptionalDependencyError +from eventful.listener import Listener, listener +from eventful.stop_propagation import StopPropagation, stop_propagation if sys.version_info < (3, 13): raise RuntimeError("eventful requires Python 3.13 or higher") -__version__ = "0.1.0" - -# Global default bus instance _default_bus: EventBus | None = None - def get_default_bus() -> EventBus: - """Get or create the global default event bus.""" + """Return the process-local default in-memory event bus.""" global _default_bus if _default_bus is None: _default_bus = InMemoryBus(config=get_config()) return _default_bus - def set_default_bus(bus: EventBus) -> None: - """Set the global default event bus.""" + """Replace the process-local default bus.""" global _default_bus _default_bus = bus - -def emit(event: Event | dict | Any, *, async_: bool | None = None) -> Any: - """ - Emit an event to the default bus. - - Parameters - ---------- - event : Event | dict | Any - The event to emit. Can be an Event instance, dict, or any object with a type attribute. - async_ : bool | None, optional - Force async/sync mode. If None, auto-detects based on listeners. - - Returns - ------- - Any - List of listener results if sync, or awaitable if async. - """ +def emit(event: Event | dict[str, Any] | Any, *, async_: bool | None = None) -> Any: + """Emit an event through the default local bus.""" return get_default_bus().emit(event, async_=async_) - -# Re-export main API __all__ = [ - "Event", - "EventBus", - "InMemoryBus", - "Listener", - "listener", - "StopPropagation", - "stop_propagation", - "EventfulConfig", - "get_config", - "get_default_bus", - "set_default_bus", - "emit", - "__version__", + "Event", "ensure_event", "EventBus", "InMemoryBus", "Listener", "listener", + "StopPropagation", "stop_propagation", "EventfulConfig", "get_config", + "set_config", "get_default_bus", "set_default_bus", "emit", "EventfulError", + "OptionalDependencyError", "__version__", ] - -# Optional imports for extras -try: - from src.eventful.transports import RedisBus - - __all__.append("RedisBus") -except ImportError: - pass - -try: - from src.eventful.persistence.file_persistence import FilePersistence - - __all__.append("FilePersistence") -except ImportError: - pass - -try: - from src.eventful.persistence import PostgresPersistence - - __all__.append("PostgresPersistence") -except ImportError: - pass - -try: - from src.eventful.adapters import get_event_bus - - __all__.append("get_event_bus") -except ImportError: - pass - -try: - from src.eventful.adapters import request_event_bus - - __all__.append("request_event_bus") -except ImportError: - pass - -try: - from src.eventful.utilities.rate_limit import rate_limit - - __all__.append("rate_limit") -except ImportError: - pass - -try: - from src.eventful.utilities import debounce - - __all__.append("debounce") -except ImportError: - pass - -try: - from src.eventful.utilities import EventfulLogHandler, emit_log - - __all__.extend(["EventfulLogHandler", "emit_log"]) -except ImportError: - pass diff --git a/src/eventful/__version__.py b/src/eventful/__version__.py index 06fbe7e..19c2727 100644 --- a/src/eventful/__version__.py +++ b/src/eventful/__version__.py @@ -1 +1,2 @@ -version = "0.0.1" \ No newline at end of file +"""Single version source for Eventful.""" +__version__ = "0.2.0" diff --git a/src/eventful/_optional.py b/src/eventful/_optional.py new file mode 100644 index 0000000..5a4349e --- /dev/null +++ b/src/eventful/_optional.py @@ -0,0 +1,28 @@ +"""Helpers for optional dependency boundaries.""" +from __future__ import annotations + +from importlib import import_module +from types import ModuleType + + +class OptionalDependencyError(ImportError): + """Raised when a component-specific optional dependency is not installed.""" + + def __init__(self, component: str, extra: str, package: str) -> None: + super().__init__( + f"{component} requires optional dependency {package!r}; install with " + f"`pip install eventful[{extra}]`." + ) + self.component = component + self.extra = extra + self.package = package + + +def require_optional(component: str, extra: str, package: str) -> ModuleType: + """Import an optional package without hiding defects inside Eventful modules.""" + try: + return import_module(package) + except ModuleNotFoundError as exc: + if exc.name == package: + raise OptionalDependencyError(component, extra, package) from exc + raise diff --git a/src/eventful/adapters/__init__.py b/src/eventful/adapters/__init__.py index 4da7dcf..2675c06 100644 --- a/src/eventful/adapters/__init__.py +++ b/src/eventful/adapters/__init__.py @@ -1,12 +1,8 @@ -""" -Framework adapters for eventful. -""" +"""Framework adapter namespace. -from .fastapi import get_event_bus, add_eventful_middleware -from .starlette import request_event_bus, add_eventful_middleware - -__all__ = [ - "get_event_bus", - "add_eventful_middleware", - "request_event_bus" -] +Adapters are optional and must be imported component-by-component. +""" +from __future__ import annotations +from eventful.api_status import ApiStatus +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/adapters/fastapi.py b/src/eventful/adapters/fastapi.py new file mode 100644 index 0000000..28a06e2 --- /dev/null +++ b/src/eventful/adapters/fastapi.py @@ -0,0 +1,15 @@ +"""Experimental FastAPI dependency helpers.""" +from __future__ import annotations + +from eventful._optional import require_optional +from eventful.bus import EventBus + + +def get_event_bus(bus: EventBus | None = None) -> EventBus: + """Return an EventBus for FastAPI dependency injection examples.""" + require_optional("FastAPI adapter", "fastapi", "fastapi") + if bus is not None: + return bus + from eventful import get_default_bus + + return get_default_bus() diff --git a/src/eventful/adapters/fastpi.py b/src/eventful/adapters/fastpi.py index e69de29..42ab1c0 100644 --- a/src/eventful/adapters/fastpi.py +++ b/src/eventful/adapters/fastpi.py @@ -0,0 +1,5 @@ +"""Compatibility shim for the misspelled alpha FastAPI adapter module.""" +from __future__ import annotations +import warnings +from .fastapi import * # noqa: F403 +warnings.warn("eventful.adapters.fastpi is deprecated; use eventful.adapters.fastapi", DeprecationWarning, stacklevel=2) diff --git a/src/eventful/adapters/starlette.py b/src/eventful/adapters/starlette.py index 6ab6799..88641a5 100644 --- a/src/eventful/adapters/starlette.py +++ b/src/eventful/adapters/starlette.py @@ -1,63 +1,38 @@ -""" -Starlette adapter for eventful. -""" - +"""Experimental Starlette middleware helpers.""" from __future__ import annotations -from typing import Optional - -try: - from starlette.requests import Request - from starlette.types import ASGIApp, Receive, Scope, Send -except ImportError: - raise ImportError("Starlette adapter requires 'starlette' package. Install with 'pip install eventful[starlette]'") +from typing import Any -from src.eventful.bus import EventBus, get_default_bus +from eventful._optional import require_optional +from eventful.bus import EventBus +_starlette_requests = require_optional("Starlette adapter", "starlette", "starlette.requests") +_starlette_types = require_optional("Starlette adapter", "starlette", "starlette.types") +Request = _starlette_requests.Request +ASGIApp = _starlette_types.ASGIApp +Receive = _starlette_types.Receive +Scope = _starlette_types.Scope +Send = _starlette_types.Send class EventfulMiddleware: - """ - Starlette middleware for event bus integration. - """ - - def __init__(self, app: ASGIApp, bus: Optional[EventBus] = None): + """Attach an Eventful bus to Starlette request state.""" + def __init__(self, app: ASGIApp, bus: EventBus | None = None) -> None: self.app = app - self.bus = bus or get_default_bus() - + if bus is None: + from eventful import get_default_bus + bus = get_default_bus() + self.bus = bus async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "http": request = Request(scope) request.state.event_bus = self.bus - await self.app(scope, receive, send) +def request_event_bus(request: Any) -> EventBus: + """Return the bus attached to request state, or the default bus.""" + from eventful import get_default_bus + return getattr(request.state, "event_bus", get_default_bus()) -def request_event_bus(request: Request) -> EventBus: - """ - Get event bus from Starlette request state. - - Parameters - ---------- - request : Request - Starlette request. - - Returns - ------- - EventBus - Event bus instance. - """ - return getattr(request.state, 'event_bus', get_default_bus()) - - -def add_eventful_middleware(app: ASGIApp, bus: Optional[EventBus] = None) -> None: - """ - Add eventful middleware to Starlette app. - - Parameters - ---------- - app : ASGIApp - Starlette application. - bus : EventBus | None, optional - Event bus instance to use. - """ +def add_eventful_middleware(app: Any, bus: EventBus | None = None) -> None: + """Install EventfulMiddleware on a Starlette-compatible app.""" app.add_middleware(EventfulMiddleware, bus=bus) diff --git a/src/eventful/api_status.py b/src/eventful/api_status.py new file mode 100644 index 0000000..b340942 --- /dev/null +++ b/src/eventful/api_status.py @@ -0,0 +1,9 @@ +"""API stability markers used in docs and contracts.""" +from __future__ import annotations +from enum import StrEnum + +class ApiStatus(StrEnum): + STABLE = "stable" + PROVISIONAL = "provisional" + EXPERIMENTAL = "experimental" + INTERNAL = "internal" diff --git a/src/eventful/bus.py b/src/eventful/bus.py index 7abcfce..e8af2fc 100644 --- a/src/eventful/bus.py +++ b/src/eventful/bus.py @@ -152,7 +152,7 @@ def emit(self, event: Event | dict | Any, *, async_: bool | None = None) -> Any: listeners = self.router.get_listeners(event_obj) if not listeners: - return [] if async_ is False else asyncio.sleep(0, result=[]) + return [] # Check if we need async mode has_async = any(inspect.iscoroutinefunction(l.func) for l in listeners) @@ -245,7 +245,7 @@ def emit(self, event: Event | dict | Any, *, async_: bool | None = None) -> Any: if self._processing: # If we're already processing, queue the event self._event_queue.append((event_obj, async_)) - return [] if async_ is False else asyncio.sleep(0, result=[]) + return [] self._processing = True try: diff --git a/src/eventful/codecs/__init__.py b/src/eventful/codecs/__init__.py new file mode 100644 index 0000000..1df6cf2 --- /dev/null +++ b/src/eventful/codecs/__init__.py @@ -0,0 +1,11 @@ +"""Experimental codecs package for Eventful v1 topology. + +This namespace intentionally exposes typed contracts and small in-memory helpers +only; production integrations will graduate through 0.x with explicit status. +""" +from __future__ import annotations + +from eventful.api_status import ApiStatus + +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/codecs/json.py b/src/eventful/codecs/json.py new file mode 100644 index 0000000..a50fced --- /dev/null +++ b/src/eventful/codecs/json.py @@ -0,0 +1,21 @@ +"""Provisional JSON codec using only the Python standard library.""" +from __future__ import annotations +import json +from typing import Any +from eventful.event import Event +from eventful.exceptions import CodecError + +class JsonCodec: + """Encode Event objects as UTF-8 JSON.""" + media_type = "application/json" + def encode(self, event: Event) -> bytes: + try: + return json.dumps({"type": event.type, "payload": event.payload, "metadata": event.metadata, "tags": sorted(event.tags)}).encode() + except (TypeError, ValueError) as exc: + raise CodecError(str(exc)) from exc + def decode(self, data: bytes) -> Event: + try: + raw: dict[str, Any] = json.loads(data.decode()) + return Event(type=raw["type"], payload=raw.get("payload"), metadata=raw.get("metadata", {}), tags=set(raw.get("tags", []))) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise CodecError(str(exc)) from exc diff --git a/src/eventful/config.py b/src/eventful/config.py index b0b5dfe..c676f7d 100644 --- a/src/eventful/config.py +++ b/src/eventful/config.py @@ -63,11 +63,11 @@ def from_env(cls) -> EventfulConfig: field_type = cls.__dataclass_fields__[field_name].type # Basic type conversion - if field_type == bool: + if field_type is bool: value = value.lower() in ('true', '1', 'yes') - elif field_type == int: + elif field_type is int: value = int(value) - elif field_type == float: + elif field_type is float: value = float(value) setattr(config, field_name, value) diff --git a/src/eventful/configuration/__init__.py b/src/eventful/configuration/__init__.py new file mode 100644 index 0000000..d6b09d0 --- /dev/null +++ b/src/eventful/configuration/__init__.py @@ -0,0 +1,11 @@ +"""Experimental configuration package for Eventful v1 topology. + +This namespace intentionally exposes typed contracts and small in-memory helpers +only; production integrations will graduate through 0.x with explicit status. +""" +from __future__ import annotations + +from eventful.api_status import ApiStatus + +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/contracts/__init__.py b/src/eventful/contracts/__init__.py new file mode 100644 index 0000000..6347c02 --- /dev/null +++ b/src/eventful/contracts/__init__.py @@ -0,0 +1,90 @@ +"""Provisional v1 architectural contracts. + +These Protocols define minimal seams for Eventful 0.x. They are importable with no +optional dependencies and are not yet stable API. +""" +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping +from typing import Any, Protocol, runtime_checkable +from eventful.event import Event + +EventHandler = Callable[[Event], Any] + +@runtime_checkable +class Publisher(Protocol): + """Publishes events to a bus, broker, or stream.""" + def publish(self, event: Event) -> Awaitable[None] | None: ... + +@runtime_checkable +class Consumer(Protocol): + """Consumes events from a subscription.""" + def consume(self) -> AsyncIterator[Event]: ... + +@runtime_checkable +class Subscription(Protocol): + """Handle returned by subscribe operations.""" + topic: str + def close(self) -> Awaitable[None] | None: ... + +@runtime_checkable +class EventBusContract(Publisher, Protocol): + """Local dispatch contract compatible with the legacy EventBus.""" + def register(self, topic: str, listener: EventHandler, **options: Any) -> None: ... + def unregister(self, topic: str, listener: EventHandler) -> bool: ... + +@runtime_checkable +class RouterContract(Protocol): + """Maps an event to interested handlers/subscriptions.""" + def get_listeners(self, event: Event) -> Iterable[EventHandler]: ... + +@runtime_checkable +class DispatcherContract(Protocol): + """Orders and invokes routed handlers.""" + def dispatch_order(self, listeners: Iterable[Any]) -> Iterable[Any]: ... + +@runtime_checkable +class Middleware(Protocol): + """Transforms or observes an event before/after dispatch.""" + def __call__(self, event: Event, next_handler: EventHandler) -> Any: ... + +@runtime_checkable +class Codec(Protocol): + """Encodes and decodes Event instances.""" + media_type: str + def encode(self, event: Event) -> bytes: ... + def decode(self, data: bytes) -> Event: ... + +@runtime_checkable +class EventStore(Protocol): + """Append-only durable event storage seam.""" + def append(self, event: Event) -> Awaitable[str] | str: ... + def read(self, *, after: str | None = None) -> AsyncIterator[Event]: ... + +@runtime_checkable +class Transport(Protocol): + """Broker/stream transport seam.""" + def publisher(self) -> Publisher: ... + def consumer(self, topic: str) -> Consumer: ... + +@runtime_checkable +class Plugin(Protocol): + """Eventful extension entry point.""" + name: str + def configure(self, settings: Mapping[str, Any]) -> None: ... + +@runtime_checkable +class ConfigurationSource(Protocol): + """Loads configuration values.""" + def load(self) -> Mapping[str, Any]: ... + +@runtime_checkable +class SchemaRegistry(Protocol): + """Registers and resolves event schemas.""" + def register(self, event_type: str, schema: object) -> None: ... + def resolve(self, event_type: str) -> object | None: ... + +@runtime_checkable +class ObservabilityProvider(Protocol): + """Receives instrumentation callbacks.""" + def record_event(self, event: Event, attributes: Mapping[str, Any]) -> None: ... diff --git a/src/eventful/event.py b/src/eventful/event.py index 7be2f5a..f69d435 100644 --- a/src/eventful/event.py +++ b/src/eventful/event.py @@ -32,6 +32,9 @@ class Event: _propagation_stopped: bool = field(default=False, init=False, repr=False) + def __post_init__(self) -> None: + self.tags = set(self.tags) + def stop_propagation(self) -> None: """Stop further propagation of this event to remaining listeners.""" self._propagation_stopped = True diff --git a/src/eventful/exceptions.py b/src/eventful/exceptions.py new file mode 100644 index 0000000..ceb0add --- /dev/null +++ b/src/eventful/exceptions.py @@ -0,0 +1,14 @@ +"""Shared exception hierarchy for Eventful.""" +from __future__ import annotations +from ._optional import OptionalDependencyError + +class EventfulError(Exception): + """Base class for Eventful errors.""" +class ConfigurationError(EventfulError): + """Invalid configuration or configuration source failure.""" +class ContractError(EventfulError): + """A component violated a documented Eventful contract.""" +class CodecError(EventfulError): + """Event encoding or decoding failed.""" +class StoreError(EventfulError): + """Durable event storage failed.""" diff --git a/src/eventful/middleware/__init__.py b/src/eventful/middleware/__init__.py new file mode 100644 index 0000000..63eff3b --- /dev/null +++ b/src/eventful/middleware/__init__.py @@ -0,0 +1,11 @@ +"""Experimental middleware package for Eventful v1 topology. + +This namespace intentionally exposes typed contracts and small in-memory helpers +only; production integrations will graduate through 0.x with explicit status. +""" +from __future__ import annotations + +from eventful.api_status import ApiStatus + +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/observability/__init__.py b/src/eventful/observability/__init__.py new file mode 100644 index 0000000..756e476 --- /dev/null +++ b/src/eventful/observability/__init__.py @@ -0,0 +1,11 @@ +"""Experimental observability package for Eventful v1 topology. + +This namespace intentionally exposes typed contracts and small in-memory helpers +only; production integrations will graduate through 0.x with explicit status. +""" +from __future__ import annotations + +from eventful.api_status import ApiStatus + +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/persistence/__init__.py b/src/eventful/persistence/__init__.py index 70ad432..09406f2 100644 --- a/src/eventful/persistence/__init__.py +++ b/src/eventful/persistence/__init__.py @@ -1,8 +1,6 @@ -""" -Persistence implementations for eventful. -""" - +"""Persistence namespace for provisional and experimental stores.""" +from __future__ import annotations +from eventful.api_status import ApiStatus from .file_persistence import FilePersistence -from .postgres_perst import PostgresPersistence - -__all__ = ["FilePersistence", "PostgresPersistence"] +API_STATUS = ApiStatus.PROVISIONAL +__all__ = ["API_STATUS", "FilePersistence"] diff --git a/src/eventful/persistence/file_persistence.py b/src/eventful/persistence/file_persistence.py index d8e4c35..cbdc88e 100644 --- a/src/eventful/persistence/file_persistence.py +++ b/src/eventful/persistence/file_persistence.py @@ -16,7 +16,7 @@ except ImportError: orjson = None -from src.eventful.event import Event +from eventful.event import Event class FilePersistence: diff --git a/src/eventful/persistence/postgres_persistence.py b/src/eventful/persistence/postgres_persistence.py new file mode 100644 index 0000000..69ae65f --- /dev/null +++ b/src/eventful/persistence/postgres_persistence.py @@ -0,0 +1,9 @@ +"""PostgreSQL persistence placeholder with precise optional dependency errors.""" +from __future__ import annotations +from eventful._optional import require_optional + +class PostgresPersistence: + """Experimental shell for PostgreSQL-backed event persistence.""" + def __init__(self, dsn: str) -> None: + self.asyncpg = require_optional("PostgresPersistence", "postgres", "asyncpg") + self.dsn = dsn diff --git a/src/eventful/persistence/postgres_perst.py b/src/eventful/persistence/postgres_perst.py index b562882..16c91f4 100644 --- a/src/eventful/persistence/postgres_perst.py +++ b/src/eventful/persistence/postgres_perst.py @@ -1,226 +1,6 @@ -""" -PostgreSQL-based event persistence. -""" - +"""Compatibility module for the misspelled alpha PostgreSQL module.""" from __future__ import annotations - -import asyncio -import logging -from typing import Any, AsyncIterator, Optional - -try: - import asyncpg - from asyncpg.pool import Pool -except ImportError: - asyncpg = None - -from src.eventful.event import Event - - -class PostgresPersistence: - """ - PostgreSQL-based event persistence. - """ - - def __init__(self, dsn: str, table_name: str = "events"): - """ - Initialize PostgreSQL persistence. - - Parameters - ---------- - dsn : str - PostgreSQL connection string. - table_name : str, optional - Name of the events table. - """ - if asyncpg is None: - raise ImportError( - "PostgreSQL persistence requires 'asyncpg'. " - "Install with 'pip install eventful[postgres]'" - ) - - self.dsn = dsn - self.table_name = table_name - self._pool: Optional[Pool] = None - - async def connect(self) -> None: - """Create connection pool and ensure table exists.""" - if self._pool is None: - self._pool = await asyncpg.create_pool( - self.dsn, - min_size=1, - max_size=10, - command_timeout=60 - ) - await self._create_table() - - async def _create_table(self) -> None: - """Create events table if it doesn't exist.""" - async with self._pool.acquire() as conn: - await conn.execute(f""" - CREATE TABLE IF NOT EXISTS {self.table_name} ( - id SERIAL PRIMARY KEY, - type TEXT NOT NULL, - payload BYTEA, - metadata JSONB, - tags TEXT[], - created_at TIMESTAMPTZ DEFAULT NOW() - ) - """) - - # Create index for efficient type-based queries - await conn.execute(f""" - CREATE INDEX IF NOT EXISTS idx_{self.table_name}_type - ON {self.table_name} (type) - """) - - # Create index for efficient tag-based queries - await conn.execute(f""" - CREATE INDEX IF NOT EXISTS idx_{self.table_name}_tags - ON {self.table_name} USING GIN (tags) - """) - - async def append(self, event: Event) -> int: - """ - Append an event to the database. - - Parameters - ---------- - event : Event - Event to store. - - Returns - ------- - int - ID of the inserted event. - """ - await self.connect() - - # Serialize payload based on type - serialized_payload = None - if event.payload is not None: - if isinstance(event.payload, (str, bytes)): - serialized_payload = str(event.payload).encode() - else: - try: - import json - serialized_payload = json.dumps(event.payload).encode() - except (TypeError, ValueError): - serialized_payload = str(event.payload).encode() - - async with self._pool.acquire() as conn: - result = await conn.fetchrow(f""" - INSERT INTO {self.table_name} (type, payload, metadata, tags) - VALUES ($1, $2, $3, $4) - RETURNING id - """, event.type, serialized_payload, event.metadata, list(event.tags)) - - return result['id'] - - async def replay(self, start_id: int = 0, batch: int = 1000) -> AsyncIterator[Event]: - """ - Replay events from the database. - - Parameters - ---------- - start_id : int, optional - Starting event ID. - batch : int, optional - Batch size for each query. - - Yields - ------ - Event - Events in insertion order. - """ - await self.connect() - - current_id = start_id - - while True: - async with self._pool.acquire() as conn: - records = await conn.fetch(f""" - SELECT id, type, payload, metadata, tags - FROM {self.table_name} - WHERE id >= $1 - ORDER BY id - LIMIT $2 - """, current_id, batch) - - if not records: - break - - for record in records: - # Deserialize payload - payload = None - if record['payload']: - try: - import json - payload = json.loads(record['payload'].decode()) - except (json.JSONDecodeError, UnicodeDecodeError): - payload = record['payload'].decode() - - event = Event( - type=record['type'], - payload=payload, - metadata=record['metadata'], - tags=set(record['tags']) - ) - yield event - - current_id = record['id'] + 1 - - async def get_event_count(self) -> int: - """Get total number of events in the database.""" - await self.connect() - - async with self._pool.acquire() as conn: - result = await conn.fetchrow(f""" - SELECT COUNT(*) as count FROM {self.table_name} - """) - return result['count'] - - async def get_events_by_type(self, event_type: str, limit: int = 100) -> list[Event]: - """Get events filtered by type.""" - await self.connect() - - events = [] - async with self._pool.acquire() as conn: - records = await conn.fetch(f""" - SELECT id, type, payload, metadata, tags - FROM {self.table_name} - WHERE type = $1 - ORDER BY id - LIMIT $2 - """, event_type, limit) - - for record in records: - payload = None - if record['payload']: - try: - import json - payload = json.loads(record['payload'].decode()) - except (json.JSONDecodeError, UnicodeDecodeError): - payload = record['payload'].decode() - - events.append(Event( - type=record['type'], - payload=payload, - metadata=record['metadata'], - tags=set(record['tags']) - )) - - return events - - async def close(self) -> None: - """Close the connection pool.""" - if self._pool: - await self._pool.close() - self._pool = None - - async def __aenter__(self): - await self.connect() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - await self.close() +import warnings +from .postgres_persistence import PostgresPersistence +warnings.warn("eventful.persistence.postgres_perst is deprecated; use postgres_persistence", DeprecationWarning, stacklevel=2) +__all__ = ["PostgresPersistence"] diff --git a/src/eventful/plugins/__init__.py b/src/eventful/plugins/__init__.py index 900d65a..a2fcc47 100644 --- a/src/eventful/plugins/__init__.py +++ b/src/eventful/plugins/__init__.py @@ -1,15 +1,9 @@ -""" -Plugin system for eventful. -""" +"""Experimental plugin namespace. -from .plugin import Plugin, TransportPlugin, PersistencePlugin -from .registry import PluginRegistry, get_plugin_registry, register_plugin - -__all__ = [ - "Plugin", - "TransportPlugin", - "PersistencePlugin", - "PluginRegistry", - "get_plugin_registry", - "register_plugin", -] +Plugins implement :class:`eventful.contracts.Plugin`; discovery and lifecycle +management are deferred to later 0.x releases. +""" +from __future__ import annotations +from eventful.api_status import ApiStatus +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/py.typed b/src/eventful/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/eventful/router.py b/src/eventful/router.py index ca457f2..fd23f63 100644 --- a/src/eventful/router.py +++ b/src/eventful/router.py @@ -6,8 +6,8 @@ from typing import List, Set -from src.eventful.event import Event -from src.eventful.listener import Listener +from eventful.event import Event +from eventful.listener import Listener class Router: diff --git a/src/eventful/schemas/__init__.py b/src/eventful/schemas/__init__.py new file mode 100644 index 0000000..9d2d51c --- /dev/null +++ b/src/eventful/schemas/__init__.py @@ -0,0 +1,11 @@ +"""Experimental schemas package for Eventful v1 topology. + +This namespace intentionally exposes typed contracts and small in-memory helpers +only; production integrations will graduate through 0.x with explicit status. +""" +from __future__ import annotations + +from eventful.api_status import ApiStatus + +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/setup.py b/src/eventful/setup.py deleted file mode 100644 index 6012477..0000000 --- a/src/eventful/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from setuptools import setup - -if __name__ == "__main__": - setup() diff --git a/src/eventful/stores/__init__.py b/src/eventful/stores/__init__.py new file mode 100644 index 0000000..c233dd0 --- /dev/null +++ b/src/eventful/stores/__init__.py @@ -0,0 +1,11 @@ +"""Experimental stores package for Eventful v1 topology. + +This namespace intentionally exposes typed contracts and small in-memory helpers +only; production integrations will graduate through 0.x with explicit status. +""" +from __future__ import annotations + +from eventful.api_status import ApiStatus + +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/stores/memory.py b/src/eventful/stores/memory.py new file mode 100644 index 0000000..fa76e0b --- /dev/null +++ b/src/eventful/stores/memory.py @@ -0,0 +1,17 @@ +"""Experimental in-memory event store for contract tests and examples.""" +from __future__ import annotations +from collections.abc import AsyncIterator +from eventful.event import Event + +class InMemoryEventStore: + """Append-only volatile store; not durable and not process-safe.""" + def __init__(self) -> None: + self._events: list[tuple[str, Event]] = [] + def append(self, event: Event) -> str: + position = str(len(self._events)) + self._events.append((position, event)) + return position + async def read(self, *, after: str | None = None) -> AsyncIterator[Event]: + start = int(after) + 1 if after is not None else 0 + for _, event in self._events[start:]: + yield event diff --git a/src/eventful/transports/__init__.py b/src/eventful/transports/__init__.py index 38cad9d..d15dda2 100644 --- a/src/eventful/transports/__init__.py +++ b/src/eventful/transports/__init__.py @@ -1,7 +1,8 @@ -""" -Transport implementations for eventful. -""" +"""Transport namespace. -from .redis_bus import RedisBus - -__all__ = ["RedisBus"] +Transports are experimental and are not re-exported from the stable facade. +""" +from __future__ import annotations +from eventful.api_status import ApiStatus +API_STATUS = ApiStatus.EXPERIMENTAL +__all__ = ["API_STATUS"] diff --git a/src/eventful/transports/redis.py b/src/eventful/transports/redis.py new file mode 100644 index 0000000..98891c6 --- /dev/null +++ b/src/eventful/transports/redis.py @@ -0,0 +1,9 @@ +"""Redis transport placeholder with precise optional dependency errors.""" +from __future__ import annotations +from eventful._optional import require_optional + +class RedisTransport: + """Experimental shell for a future Redis broker transport.""" + def __init__(self, url: str) -> None: + self.redis = require_optional("RedisTransport", "redis", "redis") + self.url = url diff --git a/src/eventful/utilities/debounce.py b/src/eventful/utilities/debounce.py index bc83a50..a7bdf59 100644 --- a/src/eventful/utilities/debounce.py +++ b/src/eventful/utilities/debounce.py @@ -8,7 +8,6 @@ import time from typing import Callable, Optional, Any from threading import Lock, Timer -from concurrent.futures import ThreadPoolExecutor def debounce(interval: float) -> Callable: """ @@ -39,7 +38,6 @@ def decorator(func: Callable) -> Callable: last_call_time = 0.0 timer = None lock = Lock() - executor = ThreadPoolExecutor(max_workers=1) def wrapper(event) -> None: nonlocal last_call_time, timer diff --git a/src/eventful/utilities/rate_limit.py b/src/eventful/utilities/rate_limit.py index d0a24d5..ef06d01 100644 --- a/src/eventful/utilities/rate_limit.py +++ b/src/eventful/utilities/rate_limit.py @@ -5,7 +5,7 @@ from __future__ import annotations import time -from typing import Callable, Optional +from typing import Any, Callable, Optional from threading import Lock diff --git a/tests/test_bus.py b/tests/test_bus.py index 4533448..ed6e3bb 100644 --- a/tests/test_bus.py +++ b/tests/test_bus.py @@ -6,7 +6,6 @@ import asyncio from eventful.bus import EventBus, InMemoryBus from eventful.event import Event -from eventful.listener import listener class TestEventBus: @@ -24,14 +23,14 @@ def test_priority_ordering(self): bus = EventBus() calls = [] - @listener("test.event", priority=1) def high_priority(event): calls.append("high") - @listener("test.event", priority=0) def low_priority(event): calls.append("low") + bus.register("test.event", low_priority, priority=0) + bus.register("test.event", high_priority, priority=1) bus.emit(Event(type="test.event")) assert calls == ["high", "low"] diff --git a/tests/test_docs_examples.py b/tests/test_docs_examples.py new file mode 100644 index 0000000..d08db65 --- /dev/null +++ b/tests/test_docs_examples.py @@ -0,0 +1,10 @@ +from __future__ import annotations +from eventful import Event, InMemoryBus + + +def test_readme_quickstart_pattern() -> None: + bus = InMemoryBus() + def handle(event: Event) -> str: + return f"hello {event.payload}" + bus.register("user.created", handle) + assert bus.emit(Event(type="user.created", payload="Ada")) == ["hello Ada"] diff --git a/tests/error.py b/tests/test_error.py similarity index 100% rename from tests/error.py rename to tests/test_error.py diff --git a/tests/test_import_surface.py b/tests/test_import_surface.py new file mode 100644 index 0000000..247992a --- /dev/null +++ b/tests/test_import_surface.py @@ -0,0 +1,35 @@ +from __future__ import annotations +import importlib +from pathlib import Path +import pytest + + +def test_minimal_root_imports() -> None: + import eventful + assert eventful.__version__ == "0.2.0" + assert "RedisBus" not in eventful.__all__ + + +def test_no_src_eventful_imports() -> None: + offenders = [] + for path in Path("src/eventful").rglob("*.py"): + text = path.read_text() + if "src.eventful" in text: + offenders.append(str(path)) + assert offenders == [] + + +def test_v1_topology_imports() -> None: + for name in [ + "eventful.contracts", "eventful.codecs", "eventful.stores", + "eventful.transports", "eventful.plugins", "eventful.configuration", + "eventful.schemas", "eventful.observability", "eventful.middleware", + ]: + importlib.import_module(name) + + +def test_missing_redis_extra_error() -> None: + from eventful import OptionalDependencyError + from eventful.transports.redis import RedisTransport + with pytest.raises(OptionalDependencyError, match="RedisTransport"): + RedisTransport("redis://localhost:6379") diff --git a/tests/init.py b/tests/test_init.py similarity index 100% rename from tests/init.py rename to tests/test_init.py