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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
The MIT License (MIT)

Copyright (c) 2025 Ed Cuss and any other contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
103 changes: 98 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,109 @@
# io-adapters
A small utility library for decoupling I/O from business logic by combining
dependency injection with lightweight, automatically generated fakes.

### Install
```shell
uv add io-adapters
```

### API Reference

[io-adapters API docs](https://second-ed.github.io/io-adapters/)

Testing use cases that involve I/O is inherently difficult because they depend on external state and side effects. However, combining Dependency Injection (DI) with the Repository pattern significantly reduces this complexity.
Testing use cases that involve I/O is inherently difficult because they depend on:

- external state (filesystems, databases, services)

- side effects that are hard to observe directly

- slow or flaky infrastructure

A common mitigation is to combine:

- Dependency Injection (DI)

- The Repository / Adapter pattern

This allows business logic to depend on an abstract interface rather than concrete I/O.

However, in practice this usually requires:

- writing and maintaining bespoke fake implementations

- keeping fake behaviour in sync with real implementations

- duplicating boilerplate across domains

For small or medium-sized projects, this overhead can outweigh the benefits.

Simply register each I/O function with one of the register decorators and the functionality will be added to the `RealAdapter` object, on top of that a stub will be added to the `FakeAdapter` object too so you can pass in either to your usecase and the functionality will work.

### Example

```python
from enum import Enum
from pathlib import Path

By substituting real I/O implementations with fakes that simulate their behaviour, stateful interactions can be captured entirely in memory. This allows changes to the external world to be accumulated deterministically and the final state to be asserted directly, without relying on real filesystems, networks, or services.
from io_adapters import (
IoAdapter,
RealAdapter,
add_domain,
get_fake_adapter,
get_real_adapter,
register_domain_read_fn,
register_domain_write_fn,
)

The result is faster, more reliable tests that focus on behaviour rather than infrastructure.

However, creating these fakes can be time consuming and result in a maintenance burden that may not outweigh the benefit.
# you can use any hashable object to register an I/O function
class FileFormat(Enum):
JSON = "json"


add_domain("orders")
add_domain("payment")


@register_domain_read_fn("orders", "str")
def read_str(path: str | Path, **kwargs: dict) -> str: ...


# stack decorators to register the same function to multiple domains
@register_domain_read_fn("orders", FileFormat.JSON)
@register_domain_read_fn("payment", FileFormat.JSON)
def read_json(path: str | Path, **kwargs: dict) -> dict: ...


@register_domain_write_fn("orders", "str")
def write_str(data: dict, path: str | Path, **kwargs: dict) -> None: ...


@register_domain_write_fn("orders", FileFormat.JSON)
@register_domain_write_fn("payment", FileFormat.JSON)
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: ...


def some_usecase(adapter: IoAdapter, path: str) -> None:
adapter.read(path, "str")
# Some business logic
new_path = f"{path}_new.json"

adapter.write({"a": 1}, new_path, FileFormat.JSON)


# in production inject the real adapter
orders_adapter: RealAdapter = get_real_adapter("orders")
some_usecase(orders_adapter, "some/path/to/file.json")


# in testing inject the fake which has all the same funcitonality as the
# `RealAdapter` and assert that the fakes end state is as expected
fake = get_fake_adapter("orders")
some_usecase(fake, "some/path/to/file.json")
assert fake.files["some/path/to/file.json"] == {"a": 1}
```

This is where `io-adapters` can help. Simply register each I/O function with one of the register decorators and the functionality will be added to the `RealAdapter` object, on top of that a stub will be added to the `FakeAdapter` object too so you can pass in either to your usecase and the functionality will work.

# Repo map
```
Expand All @@ -28,6 +120,7 @@ This is where `io-adapters` can help. Simply register each I/O function with one
│ └── io_adapters
│ ├── __init__.py
│ ├── _adapters.py
│ ├── _clock.py
│ ├── _container.py
│ ├── _io_funcs.py
│ └── _registries.py
Expand Down
4 changes: 4 additions & 0 deletions docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ deploy:
fi
@cd docs-build && git worktree add -f html gh-pages || true

local:
@uv run sphinx-apidoc -o source src/io_adapters/ --separate ; uv run sphinx-build source docs-build/html


# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
Expand Down
95 changes: 90 additions & 5 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,100 @@ io-adapters documentation
Motivation
----------

Testing use cases that involve I/O is inherently difficult because they depend on external state and side effects. However, combining Dependency Injection (DI) with the Repository pattern significantly reduces this complexity.
Testing use cases that involve I/O is inherently difficult because they depend on:

By substituting real I/O implementations with fakes that simulate their behaviour, stateful interactions can be captured entirely in memory. This allows changes to the external world to be accumulated deterministically and the final state to be asserted directly, without relying on real filesystems, networks, or services.
- external state (filesystems, databases, services)

The result is faster, more reliable tests that focus on behaviour rather than infrastructure.
- side effects that are hard to observe directly

However, creating these fakes can be time consuming and result in a maintenance burden that may not outweigh the benefit.
- slow or flaky infrastructure

A common mitigation is to combine:

- Dependency Injection (DI)

- The Repository / Adapter pattern

This allows business logic to depend on an abstract interface rather than concrete I/O.

However, in practice this usually requires:

- writing and maintaining bespoke fake implementations

- keeping fake behaviour in sync with real implementations

- duplicating boilerplate across domains

For small or medium-sized projects, this overhead can outweigh the benefits.

Simply register each I/O function with one of the register decorators and the functionality will be added to the ``RealAdapter`` object, on top of that a stub will be added to the ``FakeAdapter`` object too so you can pass in either to your usecase and the functionality will work.

Example
-------

.. code-block:: python

from enum import Enum
from pathlib import Path

from io_adapters import (
IoAdapter,
RealAdapter,
add_domain,
get_fake_adapter,
get_real_adapter,
register_domain_read_fn,
register_domain_write_fn,
)


# you can use any hashable object to register an I/O function
class FileFormat(Enum):
JSON = "json"


add_domain("orders")
add_domain("payment")


@register_domain_read_fn("orders", "str")
def read_str(path: str | Path, **kwargs: dict) -> str: ...


# stack decorators to register the same function to multiple domains
@register_domain_read_fn("orders", FileFormat.JSON)
@register_domain_read_fn("payment", FileFormat.JSON)
def read_json(path: str | Path, **kwargs: dict) -> dict: ...


@register_domain_write_fn("orders", "str")
def write_str(data: dict, path: str | Path, **kwargs: dict) -> None: ...


@register_domain_write_fn("orders", FileFormat.JSON)
@register_domain_write_fn("payment", FileFormat.JSON)
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: ...


def some_usecase(adapter: IoAdapter, path: str) -> None:
adapter.read(path, "str")
# Some business logic
new_path = f"{path}_new.json"

adapter.write({"a": 1}, new_path, FileFormat.JSON)


# in production inject the real adapter
orders_adapter: RealAdapter = get_real_adapter("orders")
some_usecase(orders_adapter, "some/path/to/file.json")


# in testing inject the fake which has all the same funcitonality as the
# `RealAdapter` and assert that the fakes end state is as expected
fake_adapter = get_fake_adapter("orders")
some_usecase(fake_adapter, "some/path/to/file.json")
assert fake_adapter.files["some/path/to/file.json"] == {"a": 1}

This is where ``io-adapters`` can help. Simply register each I/O function with one of the register decorators and the functionality will be added to the ``RealAdapter`` object, on top of that a stub will be added to the ``FakeAdapter`` object too so you can pass in either to your usecase and the functionality will work.

.. automodule:: io_adapters
:members:
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
[project]
name = "io-adapters"
version = "0.1.0"
description = "Add your description here"
description = "Dependency Injection Adapters"
readme = "README.md"
authors = [
{ name = "ed cuss", email = "edcussmusic@gmail.com" }
]
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.11"
dependencies = [
"attrs>=25.4.0",
Expand All @@ -24,4 +26,9 @@ dev = [
"repo-mapper-rs>=0.3.0",
"ruff>=0.14.9",
"sphinx>=9.0.4",
"ty>=0.0.9",
]

[tool.ty.src]
include = ["src", "tests"]
exclude = ["src/io_adapters/_io_funcs.py"]
3 changes: 3 additions & 0 deletions src/io_adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
register_domain_write_fn,
)
from io_adapters._io_funcs import read_json, write_json # noqa: F401
from io_adapters._registries import register_read_fn, register_write_fn

__all__ = [
"Container",
Expand All @@ -19,4 +20,6 @@
"get_real_adapter",
"register_domain_read_fn",
"register_domain_write_fn",
"register_read_fn",
"register_write_fn",
]
Loading