io-adapters 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- io_adapters-0.1.0/PKG-INFO +55 -0
- io_adapters-0.1.0/README.md +45 -0
- io_adapters-0.1.0/pyproject.toml +27 -0
- io_adapters-0.1.0/src/io_adapters/__init__.py +22 -0
- io_adapters-0.1.0/src/io_adapters/_adapters.py +178 -0
- io_adapters-0.1.0/src/io_adapters/_container.py +357 -0
- io_adapters-0.1.0/src/io_adapters/_io_funcs.py +18 -0
- io_adapters-0.1.0/src/io_adapters/_registries.py +44 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: io-adapters
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: ed cuss
|
|
6
|
+
Author-email: ed cuss <edcussmusic@gmail.com>
|
|
7
|
+
Requires-Dist: attrs>=25.4.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# io-adapters
|
|
12
|
+
### API Reference
|
|
13
|
+
|
|
14
|
+
[io-adapters API docs](https://second-ed.github.io/io-adapters/)
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
The result is faster, more reliable tests that focus on behaviour rather than infrastructure.
|
|
21
|
+
|
|
22
|
+
However, creating these fakes can be time consuming and result in a maintenance burden that may not outweigh the benefit.
|
|
23
|
+
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
# Repo map
|
|
27
|
+
```
|
|
28
|
+
├── .github
|
|
29
|
+
│ └── workflows
|
|
30
|
+
│ ├── ci_tests.yaml
|
|
31
|
+
│ └── publish.yaml
|
|
32
|
+
├── .pytest_cache
|
|
33
|
+
│ └── README.md
|
|
34
|
+
├── docs
|
|
35
|
+
│ └── source
|
|
36
|
+
│ └── conf.py
|
|
37
|
+
├── src
|
|
38
|
+
│ └── io_adapters
|
|
39
|
+
│ ├── __init__.py
|
|
40
|
+
│ ├── _adapters.py
|
|
41
|
+
│ ├── _container.py
|
|
42
|
+
│ ├── _io_funcs.py
|
|
43
|
+
│ └── _registries.py
|
|
44
|
+
├── tests
|
|
45
|
+
│ ├── __init__.py
|
|
46
|
+
│ ├── test_adapters.py
|
|
47
|
+
│ ├── test_adapters_apis.py
|
|
48
|
+
│ └── test_container.py
|
|
49
|
+
├── .pre-commit-config.yaml
|
|
50
|
+
├── README.md
|
|
51
|
+
├── pyproject.toml
|
|
52
|
+
├── ruff.toml
|
|
53
|
+
└── uv.lock
|
|
54
|
+
::
|
|
55
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# io-adapters
|
|
2
|
+
### API Reference
|
|
3
|
+
|
|
4
|
+
[io-adapters API docs](https://second-ed.github.io/io-adapters/)
|
|
5
|
+
|
|
6
|
+
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.
|
|
7
|
+
|
|
8
|
+
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.
|
|
9
|
+
|
|
10
|
+
The result is faster, more reliable tests that focus on behaviour rather than infrastructure.
|
|
11
|
+
|
|
12
|
+
However, creating these fakes can be time consuming and result in a maintenance burden that may not outweigh the benefit.
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
# Repo map
|
|
17
|
+
```
|
|
18
|
+
├── .github
|
|
19
|
+
│ └── workflows
|
|
20
|
+
│ ├── ci_tests.yaml
|
|
21
|
+
│ └── publish.yaml
|
|
22
|
+
├── .pytest_cache
|
|
23
|
+
│ └── README.md
|
|
24
|
+
├── docs
|
|
25
|
+
│ └── source
|
|
26
|
+
│ └── conf.py
|
|
27
|
+
├── src
|
|
28
|
+
│ └── io_adapters
|
|
29
|
+
│ ├── __init__.py
|
|
30
|
+
│ ├── _adapters.py
|
|
31
|
+
│ ├── _container.py
|
|
32
|
+
│ ├── _io_funcs.py
|
|
33
|
+
│ └── _registries.py
|
|
34
|
+
├── tests
|
|
35
|
+
│ ├── __init__.py
|
|
36
|
+
│ ├── test_adapters.py
|
|
37
|
+
│ ├── test_adapters_apis.py
|
|
38
|
+
│ └── test_container.py
|
|
39
|
+
├── .pre-commit-config.yaml
|
|
40
|
+
├── README.md
|
|
41
|
+
├── pyproject.toml
|
|
42
|
+
├── ruff.toml
|
|
43
|
+
└── uv.lock
|
|
44
|
+
::
|
|
45
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "io-adapters"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "ed cuss", email = "edcussmusic@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"attrs>=25.4.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.8.13,<0.9.0"]
|
|
16
|
+
build-backend = "uv_build"
|
|
17
|
+
|
|
18
|
+
[dependency-groups]
|
|
19
|
+
dev = [
|
|
20
|
+
"ipykernel>=7.1.0",
|
|
21
|
+
"pre-commit>=4.5.0",
|
|
22
|
+
"pytest>=9.0.2",
|
|
23
|
+
"pytest-cov>=7.0.0",
|
|
24
|
+
"repo-mapper-rs>=0.3.0",
|
|
25
|
+
"ruff>=0.14.9",
|
|
26
|
+
"sphinx>=9.0.4",
|
|
27
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from io_adapters._adapters import FakeAdapter, IoAdapter, RealAdapter
|
|
2
|
+
from io_adapters._container import (
|
|
3
|
+
Container,
|
|
4
|
+
add_domain,
|
|
5
|
+
get_fake_adapter,
|
|
6
|
+
get_real_adapter,
|
|
7
|
+
register_domain_read_fn,
|
|
8
|
+
register_domain_write_fn,
|
|
9
|
+
)
|
|
10
|
+
from io_adapters._io_funcs import read_json, write_json # noqa: F401
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Container",
|
|
14
|
+
"FakeAdapter",
|
|
15
|
+
"IoAdapter",
|
|
16
|
+
"RealAdapter",
|
|
17
|
+
"add_domain",
|
|
18
|
+
"get_fake_adapter",
|
|
19
|
+
"get_real_adapter",
|
|
20
|
+
"register_domain_read_fn",
|
|
21
|
+
"register_domain_write_fn",
|
|
22
|
+
]
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import logging
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from collections.abc import Hashable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
import attrs
|
|
12
|
+
from attrs.validators import deep_mapping, instance_of, is_callable
|
|
13
|
+
|
|
14
|
+
from io_adapters._registries import READ_FNS, WRITE_FNS, Data, standardise_key
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@attrs.define
|
|
20
|
+
class IoAdapter(ABC):
|
|
21
|
+
read_fns: MappingProxyType = attrs.field(
|
|
22
|
+
default=READ_FNS,
|
|
23
|
+
validator=[
|
|
24
|
+
deep_mapping(
|
|
25
|
+
key_validator=instance_of(Hashable),
|
|
26
|
+
value_validator=is_callable(),
|
|
27
|
+
mapping_validator=instance_of(MappingProxyType),
|
|
28
|
+
)
|
|
29
|
+
],
|
|
30
|
+
converter=MappingProxyType,
|
|
31
|
+
)
|
|
32
|
+
write_fns: MappingProxyType = attrs.field(
|
|
33
|
+
default=WRITE_FNS,
|
|
34
|
+
validator=[
|
|
35
|
+
deep_mapping(
|
|
36
|
+
key_validator=instance_of(Hashable),
|
|
37
|
+
value_validator=is_callable(),
|
|
38
|
+
mapping_validator=instance_of(MappingProxyType),
|
|
39
|
+
)
|
|
40
|
+
],
|
|
41
|
+
converter=MappingProxyType,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def read(self, path: str | Path, file_type: str, **kwargs: dict) -> Data:
|
|
45
|
+
"""Read `path` using the registered function for `file_type`.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
NotImplementedError: If the given `file_type` does not have a registered function.
|
|
49
|
+
|
|
50
|
+
Usage
|
|
51
|
+
-----
|
|
52
|
+
|
|
53
|
+
Here the ``read_json`` function is registered with the ``register_read_fn`` decorator.
|
|
54
|
+
|
|
55
|
+
Then when the ``adapter`` object calls ``read`` with the ``"json"`` ``file_type`` it will use the registered function.
|
|
56
|
+
|
|
57
|
+
The ``key`` used to register the function doesn't have to be a string, as long as it's ``Hashable`` it can be used.
|
|
58
|
+
|
|
59
|
+
.. code-block:: python
|
|
60
|
+
|
|
61
|
+
from io_adapters import RealAdapter, register_read_fn
|
|
62
|
+
|
|
63
|
+
@register_read_fn("json")
|
|
64
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict:
|
|
65
|
+
return json.loads(Path(path).read_text(), **kwargs)
|
|
66
|
+
|
|
67
|
+
adapter = RealAdapter()
|
|
68
|
+
data = adapter.read("some/path/to/file.json", "json")
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
logger.info(f"{path = } {file_type = } {kwargs = }")
|
|
72
|
+
file_type = standardise_key(file_type)
|
|
73
|
+
|
|
74
|
+
if file_type not in self.read_fns:
|
|
75
|
+
msg = f"`read` is not implemented for {file_type}"
|
|
76
|
+
logger.error(msg)
|
|
77
|
+
raise NotImplementedError(msg)
|
|
78
|
+
return self.read_fns[file_type](path, **kwargs)
|
|
79
|
+
|
|
80
|
+
def write(self, data: Data, path: str | Path, file_type: str, **kwargs: dict) -> None:
|
|
81
|
+
"""Write `data` to `path` using the registered function for `file_type`.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
NotImplementedError: If the given `file_type` does not have a registered function.
|
|
85
|
+
|
|
86
|
+
Usage
|
|
87
|
+
-----
|
|
88
|
+
|
|
89
|
+
Here the ``write_json`` function is registered with the ``register_write_fn`` decorator.
|
|
90
|
+
|
|
91
|
+
Then when the ``adapter`` object calls ``write`` with the ``WriteFormat.JSON`` ``file_type`` it will use the registered function.
|
|
92
|
+
|
|
93
|
+
.. code-block:: python
|
|
94
|
+
|
|
95
|
+
from enum import Enum
|
|
96
|
+
from io_adapters import RealAdapter, register_write_fn
|
|
97
|
+
|
|
98
|
+
class WriteFormat(Enum):
|
|
99
|
+
JSON = "json"
|
|
100
|
+
|
|
101
|
+
@register_write_fn(WriteFormat.JSON)
|
|
102
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
103
|
+
path = Path(path)
|
|
104
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
path.write_text(json.dumps(data, **kwargs))
|
|
106
|
+
|
|
107
|
+
adapter = RealAdapter()
|
|
108
|
+
adapter.write({"a": 1}, "some/path/to/file.json", WriteFormat.JSON)
|
|
109
|
+
|
|
110
|
+
fake_adapter = FakeAdapter()
|
|
111
|
+
fake_adapter.write({"a": 1}, "some/path/to/file.json", WriteFormat.JSON)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
The interfaces between the ``FakeAdapter`` and the ``RealAdapter`` means that the two can be passed in interchangeably, making testing much easier
|
|
115
|
+
|
|
116
|
+
.. code-block:: python
|
|
117
|
+
|
|
118
|
+
def some_usecase(adapter: IoAdapter, path: str) -> None:
|
|
119
|
+
# Some business logic
|
|
120
|
+
|
|
121
|
+
adapter.write({"a": 1}, , WriteFormat.JSON)
|
|
122
|
+
|
|
123
|
+
# in production inject the real adapter
|
|
124
|
+
some_usecase(RealAdapter(), "some/path/to/file.json")
|
|
125
|
+
|
|
126
|
+
# in testing inject the fake and assert that the fakes end state is as expected
|
|
127
|
+
fake = FakeAdapter()
|
|
128
|
+
some_usecase(fake, "some/path/to/file.json")
|
|
129
|
+
assert fake.files["some/path/to/file.json"] == {"a": 1}
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
logger.info(f"{path = } {file_type = } {kwargs = }")
|
|
133
|
+
file_type = standardise_key(file_type)
|
|
134
|
+
|
|
135
|
+
if file_type not in self.write_fns:
|
|
136
|
+
msg = f"`write` is not implemented for {file_type}"
|
|
137
|
+
logger.error(msg)
|
|
138
|
+
raise NotImplementedError(msg)
|
|
139
|
+
return self.write_fns[file_type](data, path, **kwargs)
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def get_guid(self) -> str: ...
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def get_datetime(self) -> datetime.datetime: ...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@attrs.define
|
|
149
|
+
class RealAdapter(IoAdapter):
|
|
150
|
+
def get_guid(self) -> str:
|
|
151
|
+
return str(uuid4())
|
|
152
|
+
|
|
153
|
+
def get_datetime(self) -> datetime.datetime:
|
|
154
|
+
return datetime.datetime.now(datetime.UTC)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@attrs.define
|
|
158
|
+
class FakeAdapter(IoAdapter):
|
|
159
|
+
files: dict[str, Data] = attrs.field(factory=dict, validator=instance_of(dict))
|
|
160
|
+
|
|
161
|
+
def __attrs_post_init__(self) -> None:
|
|
162
|
+
self.read_fns = MappingProxyType(dict.fromkeys(self.read_fns.keys(), self._read_fn))
|
|
163
|
+
self.write_fns = MappingProxyType(dict.fromkeys(self.write_fns.keys(), self._write_fn))
|
|
164
|
+
|
|
165
|
+
def _read_fn(self, path: str) -> Data:
|
|
166
|
+
try:
|
|
167
|
+
return self.files[path]
|
|
168
|
+
except KeyError as e:
|
|
169
|
+
raise FileNotFoundError(f"{path = } {self.files = }") from e
|
|
170
|
+
|
|
171
|
+
def _write_fn(self, data: Data, path: str) -> None:
|
|
172
|
+
self.files[str(path)] = data
|
|
173
|
+
|
|
174
|
+
def get_guid(self) -> str:
|
|
175
|
+
return "abc-123"
|
|
176
|
+
|
|
177
|
+
def get_datetime(self) -> datetime.datetime:
|
|
178
|
+
return datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.UTC)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable, Hashable, Iterable
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
import attrs
|
|
8
|
+
from attrs.validators import deep_iterable, instance_of
|
|
9
|
+
|
|
10
|
+
from io_adapters import FakeAdapter, RealAdapter
|
|
11
|
+
from io_adapters._registries import standardise_key
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _FnType(Enum):
|
|
17
|
+
READ = auto()
|
|
18
|
+
WRITE = auto()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@attrs.define
|
|
22
|
+
class Container:
|
|
23
|
+
"""Registry and factory for domain-scoped I/O adapters.
|
|
24
|
+
|
|
25
|
+
The ``Container`` provides a central registry for I/O functions that are
|
|
26
|
+
grouped by domain. Each domain has isolated access to a specific set of
|
|
27
|
+
read and write functions, allowing different parts of an application to
|
|
28
|
+
share common I/O implementations without violating domain boundaries.
|
|
29
|
+
|
|
30
|
+
Domain isolation
|
|
31
|
+
----------------
|
|
32
|
+
In some cases, different domains need isolated access to the same underlying
|
|
33
|
+
functionality.
|
|
34
|
+
|
|
35
|
+
For example:
|
|
36
|
+
|
|
37
|
+
- The ``orders`` domain may:
|
|
38
|
+
- read ``json`` files
|
|
39
|
+
- write ``parquet`` files
|
|
40
|
+
- The ``reporting`` domain may:
|
|
41
|
+
- read from a database
|
|
42
|
+
- write ``parquet`` files
|
|
43
|
+
|
|
44
|
+
While both domains can reuse a shared ``write_parquet`` implementation,
|
|
45
|
+
they must not have access to each other's read capabilities. The
|
|
46
|
+
``Container`` enforces this isolation by maintaining separate registries
|
|
47
|
+
per domain.
|
|
48
|
+
|
|
49
|
+
Design
|
|
50
|
+
------
|
|
51
|
+
The ``Container``:
|
|
52
|
+
|
|
53
|
+
- Maintains a mapping of domains to read/write function registries
|
|
54
|
+
- Provides decorator-style registration APIs for read and write functions
|
|
55
|
+
- Acts as a factory for creating domain-specific adapters
|
|
56
|
+
|
|
57
|
+
Two adapter types are supported:
|
|
58
|
+
|
|
59
|
+
- ``RealAdapter`` for production usage
|
|
60
|
+
- ``FakeAdapter`` for testing, allowing stateful simulation of I/O
|
|
61
|
+
|
|
62
|
+
Testing
|
|
63
|
+
-------
|
|
64
|
+
Using a ``FakeAdapter`` makes it possible to simulate external I/O while
|
|
65
|
+
keeping all state in memory. This allows tests to:
|
|
66
|
+
|
|
67
|
+
- Accumulate state across reads and writes
|
|
68
|
+
- Assert against the final external state
|
|
69
|
+
- Avoid filesystem or network dependencies entirely
|
|
70
|
+
|
|
71
|
+
This results in tests that are faster, deterministic, and easier to reason
|
|
72
|
+
about.
|
|
73
|
+
|
|
74
|
+
Usage overview
|
|
75
|
+
--------------
|
|
76
|
+
|
|
77
|
+
#. Define a ``Container`` with one or more domains
|
|
78
|
+
#. Register read/write functions per domain
|
|
79
|
+
#. Request either a real or fake adapter for a given domain
|
|
80
|
+
#. Inject the adapter into application code
|
|
81
|
+
|
|
82
|
+
The ``Container`` itself is intentionally simple and does not perform any
|
|
83
|
+
I/O; it only wires together domain-specific capabilities.
|
|
84
|
+
|
|
85
|
+
Tip
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
Usage of a custom ``Container`` is only recommended if you have a complex set of domains and need multiple ``Container``s initialised at the same time.
|
|
89
|
+
|
|
90
|
+
If you have a simple set of domains you can use the convenience functions with the same names as the ``Container`` methods which will be added to the default ``Container`` which is an instance you don't need to initialise yourself.
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
domains: Iterable = attrs.field(validator=deep_iterable(member_validator=instance_of(Hashable)))
|
|
95
|
+
domain_fns: dict[Hashable, dict[Hashable, Callable]] = attrs.field(init=False)
|
|
96
|
+
|
|
97
|
+
def __attrs_post_init__(self) -> None:
|
|
98
|
+
self.domain_fns = {domain: {_FnType.READ: {}, _FnType.WRITE: {}} for domain in self.domains}
|
|
99
|
+
|
|
100
|
+
def add_domain(self, domain: Hashable) -> None:
|
|
101
|
+
"""Add a domain to a ``Container``
|
|
102
|
+
|
|
103
|
+
.. code-block:: python
|
|
104
|
+
|
|
105
|
+
from io_adapters import Container
|
|
106
|
+
|
|
107
|
+
container = Container()
|
|
108
|
+
container.add_domain("orders")
|
|
109
|
+
|
|
110
|
+
The ``orders`` domain is now added to the ``Container`` and can have IO functions registered to it.
|
|
111
|
+
|
|
112
|
+
Relying on deliberate registering of a domain avoids situations where a typo could register a function to a non-existent domain: e.g. ``'ordesr'`` instead of the intended ``'orders'``.
|
|
113
|
+
|
|
114
|
+
Domains can also be passed into the Container on initialisation.
|
|
115
|
+
|
|
116
|
+
.. code-block:: python
|
|
117
|
+
|
|
118
|
+
container = Container(domains=["orders"])
|
|
119
|
+
|
|
120
|
+
"""
|
|
121
|
+
self.domain_fns[domain] = {_FnType.READ: {}, _FnType.WRITE: {}}
|
|
122
|
+
|
|
123
|
+
def register_domain_read_fn(self, domain: Hashable, key: Hashable) -> Callable:
|
|
124
|
+
"""Register a read function to a domain in a ``Container``.
|
|
125
|
+
|
|
126
|
+
Decorators can be stacked to register the same function to multiple domains.
|
|
127
|
+
|
|
128
|
+
.. code-block:: python
|
|
129
|
+
|
|
130
|
+
from io_adapters import Container
|
|
131
|
+
|
|
132
|
+
container = Container(domains=["orders", "payment"])
|
|
133
|
+
|
|
134
|
+
@container.register_domain_read_fn("orders", "str")
|
|
135
|
+
def read_str(path: str | Path, **kwargs: dict) -> str:
|
|
136
|
+
...
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@container.register_domain_read_fn("orders", "json")
|
|
140
|
+
@container.register_domain_read_fn("payment", "json")
|
|
141
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict:
|
|
142
|
+
...
|
|
143
|
+
"""
|
|
144
|
+
domain = standardise_key(domain)
|
|
145
|
+
key = standardise_key(key)
|
|
146
|
+
|
|
147
|
+
def wrapper(func: Callable) -> Callable:
|
|
148
|
+
logger.info(f"registering read fn {key = } {func = }")
|
|
149
|
+
self.domain_fns[domain][_FnType.READ][key] = func
|
|
150
|
+
return func
|
|
151
|
+
|
|
152
|
+
return wrapper
|
|
153
|
+
|
|
154
|
+
def register_domain_write_fn(self, domain: Hashable, key: Hashable) -> Callable:
|
|
155
|
+
"""Register a write function to a domain in a ``Container``.
|
|
156
|
+
|
|
157
|
+
Decorators can be stacked to register the same function to multiple domains.
|
|
158
|
+
|
|
159
|
+
.. code-block:: python
|
|
160
|
+
|
|
161
|
+
from io_adapters import Container
|
|
162
|
+
|
|
163
|
+
container = Container(domains=["orders"])
|
|
164
|
+
|
|
165
|
+
@container.register_domain_write_fn("orders", "str")
|
|
166
|
+
def write_str(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
container.add_domain("payment")
|
|
170
|
+
|
|
171
|
+
@container.register_domain_write_fn("orders", "json")
|
|
172
|
+
@container.register_domain_write_fn("payment", "json")
|
|
173
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
174
|
+
...
|
|
175
|
+
"""
|
|
176
|
+
domain = standardise_key(domain)
|
|
177
|
+
key = standardise_key(key)
|
|
178
|
+
|
|
179
|
+
def wrapper(func: Callable) -> Callable:
|
|
180
|
+
logger.info(f"registering read fn {key = } {func = }")
|
|
181
|
+
self.domain_fns[domain][_FnType.WRITE][key] = func
|
|
182
|
+
return func
|
|
183
|
+
|
|
184
|
+
return wrapper
|
|
185
|
+
|
|
186
|
+
def get_real_adapter(self, domain: Hashable) -> RealAdapter:
|
|
187
|
+
"""Get a ``RealAdapter`` for the given domain from a ``Container``.
|
|
188
|
+
|
|
189
|
+
The returned adapter will have all of the functions registered to that domain.
|
|
190
|
+
|
|
191
|
+
.. code-block:: python
|
|
192
|
+
|
|
193
|
+
from io_adapters import RealAdapter, Container
|
|
194
|
+
|
|
195
|
+
container = Container(domains=["orders"])
|
|
196
|
+
orders_adapter: RealAdapter = container.get_real_adapter("orders")
|
|
197
|
+
|
|
198
|
+
The ``RealAdapter`` that is assigned to the ``orders_adapter`` variable will have all of the registered read and write I/O functions.
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
return RealAdapter(
|
|
202
|
+
read_fns=self.domain_fns[domain][_FnType.READ],
|
|
203
|
+
write_fns=self.domain_fns[domain][_FnType.WRITE],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def get_fake_adapter(self, domain: Hashable, files: dict | None = None) -> RealAdapter:
|
|
207
|
+
"""Get a ``FakeAdapter`` for the given domain.
|
|
208
|
+
|
|
209
|
+
The returned adapter will have all of the functions registered to that domain.
|
|
210
|
+
|
|
211
|
+
.. code-block:: python
|
|
212
|
+
|
|
213
|
+
from io_adapters import FakeAdapter, Container
|
|
214
|
+
|
|
215
|
+
container = Container(domains=["orders"])
|
|
216
|
+
orders_adapter: FakeAdapter = container.get_fake_adapter("orders")
|
|
217
|
+
|
|
218
|
+
The ``FakeAdapter`` that is assigned to the ``orders_adapter`` variable will have fake representations for all of the registered read and write I/O functions.
|
|
219
|
+
|
|
220
|
+
This can optionally be given a dictionary of files to setup the initial state for testing. An example of how this could be used is below:
|
|
221
|
+
|
|
222
|
+
.. code-block:: python
|
|
223
|
+
|
|
224
|
+
from io_adapters import FakeAdapter, Container
|
|
225
|
+
|
|
226
|
+
starting_files = {"path/to/data.json": {"a": 0, "b": 1}}
|
|
227
|
+
|
|
228
|
+
container = Container(domains=["orders"])
|
|
229
|
+
orders_adapter: FakeAdapter = container.get_fake_adapter("orders", starting_files)
|
|
230
|
+
|
|
231
|
+
some_orders_usecase(adapter=orders_adapter, data_path="path/to/data.json")
|
|
232
|
+
|
|
233
|
+
assert orders_adapter.files["path/to/modified_data.json"] == {"a": 1, "b": 2}
|
|
234
|
+
|
|
235
|
+
"""
|
|
236
|
+
return FakeAdapter(
|
|
237
|
+
read_fns=self.domain_fns[domain][_FnType.READ],
|
|
238
|
+
write_fns=self.domain_fns[domain][_FnType.WRITE],
|
|
239
|
+
files=files or {},
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
DEFAULT_CONTAINER = Container(domains=[])
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def add_domain(domain: Hashable) -> None:
|
|
247
|
+
"""Add a domain to the default ``Container``
|
|
248
|
+
|
|
249
|
+
.. code-block:: python
|
|
250
|
+
|
|
251
|
+
from io_adapters import add_domain
|
|
252
|
+
|
|
253
|
+
add_domain("orders")
|
|
254
|
+
|
|
255
|
+
The ``orders`` domain is now added to the default ``Container`` and can have IO functions registered to it.
|
|
256
|
+
|
|
257
|
+
Relying on deliberate registering of a domain avoids situations where a typo could register a function to a non-existent domain: e.g. ``'ordesr'`` instead of the intended ``'orders'``.
|
|
258
|
+
"""
|
|
259
|
+
return DEFAULT_CONTAINER.add_domain(domain)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def register_domain_read_fn(domain: Hashable, key: Hashable) -> Callable:
|
|
263
|
+
"""Register a read function to a domain in the default ``Container``.
|
|
264
|
+
|
|
265
|
+
Decorators can be stacked to register the same function to multiple domains.
|
|
266
|
+
|
|
267
|
+
.. code-block:: python
|
|
268
|
+
|
|
269
|
+
from io_adapters import add_domain, register_domain_read_fn
|
|
270
|
+
|
|
271
|
+
add_domain("orders")
|
|
272
|
+
|
|
273
|
+
@register_domain_read_fn("orders", "str")
|
|
274
|
+
def read_str(path: str | Path, **kwargs: dict) -> str:
|
|
275
|
+
...
|
|
276
|
+
|
|
277
|
+
add_domain("payment")
|
|
278
|
+
|
|
279
|
+
@register_domain_read_fn("orders", "json")
|
|
280
|
+
@register_domain_read_fn("payment", "json")
|
|
281
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict:
|
|
282
|
+
...
|
|
283
|
+
"""
|
|
284
|
+
return DEFAULT_CONTAINER.register_domain_read_fn(domain, key)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def register_domain_write_fn(domain: Hashable, key: Hashable) -> Callable:
|
|
288
|
+
"""Register a write function to a domain in the default ``Container``.
|
|
289
|
+
|
|
290
|
+
Decorators can be stacked to register the same function to multiple domains.
|
|
291
|
+
|
|
292
|
+
.. code-block:: python
|
|
293
|
+
|
|
294
|
+
from io_adapters import add_domain, register_domain_write_fn
|
|
295
|
+
|
|
296
|
+
add_domain("orders")
|
|
297
|
+
|
|
298
|
+
@register_domain_write_fn("orders", "str")
|
|
299
|
+
def write_str(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
300
|
+
...
|
|
301
|
+
|
|
302
|
+
add_domain("payment")
|
|
303
|
+
|
|
304
|
+
@register_domain_write_fn("orders", "json")
|
|
305
|
+
@register_domain_write_fn("payment", "json")
|
|
306
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
307
|
+
...
|
|
308
|
+
"""
|
|
309
|
+
return DEFAULT_CONTAINER.register_domain_write_fn(domain, key)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_real_adapter(domain: Hashable) -> RealAdapter:
|
|
313
|
+
"""Get a ``RealAdapter`` for the given domain.
|
|
314
|
+
|
|
315
|
+
The returned adapter will have all of the functions registered to that domain.
|
|
316
|
+
|
|
317
|
+
.. code-block:: python
|
|
318
|
+
|
|
319
|
+
from io_adapters import RealAdapter, get_real_adapter
|
|
320
|
+
|
|
321
|
+
orders_adapter: RealAdapter = get_real_adapter("orders")
|
|
322
|
+
|
|
323
|
+
The ``RealAdapter`` that is assigned to the ``orders_adapter`` variable will have all of the registered read and write I/O functions.
|
|
324
|
+
|
|
325
|
+
"""
|
|
326
|
+
return DEFAULT_CONTAINER.get_real_adapter(domain)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def get_fake_adapter(domain: Hashable, files: dict | None = None) -> FakeAdapter:
|
|
330
|
+
"""Get a ``FakeAdapter`` for the given domain.
|
|
331
|
+
|
|
332
|
+
The returned adapter will have all of the functions registered to that domain.
|
|
333
|
+
|
|
334
|
+
.. code-block:: python
|
|
335
|
+
|
|
336
|
+
from io_adapters import FakeAdapter, get_fake_adapter
|
|
337
|
+
|
|
338
|
+
orders_adapter: FakeAdapter = get_fake_adapter("orders")
|
|
339
|
+
|
|
340
|
+
The ``FakeAdapter`` that is assigned to the ``orders_adapter`` variable will have fake representations for all of the registered read and write I/O functions.
|
|
341
|
+
|
|
342
|
+
This can optionally be given a dictionary of files to setup the initial state for testing. An example of how this could be used is below:
|
|
343
|
+
|
|
344
|
+
.. code-block:: python
|
|
345
|
+
|
|
346
|
+
from io_adapters import FakeAdapter, get_fake_adapter
|
|
347
|
+
|
|
348
|
+
starting_files = {"path/to/data.json": {"a": 0, "b": 1}}
|
|
349
|
+
|
|
350
|
+
orders_adapter: FakeAdapter = get_fake_adapter("orders", starting_files)
|
|
351
|
+
|
|
352
|
+
some_orders_usecase(adapter=orders_adapter, data_path="path/to/data.json")
|
|
353
|
+
|
|
354
|
+
assert orders_adapter.files["path/to/modified_data.json"] == {"a": 1, "b": 2}
|
|
355
|
+
|
|
356
|
+
"""
|
|
357
|
+
return DEFAULT_CONTAINER.get_fake_adapter(domain, files)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from io_adapters._registries import register_read_fn, register_write_fn
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register_read_fn("json")
|
|
10
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict:
|
|
11
|
+
return json.loads(Path(path).read_text(), **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register_write_fn("json")
|
|
15
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
16
|
+
path = Path(path)
|
|
17
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
path.write_text(json.dumps(data, **kwargs))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable, Hashable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Concatenate, ParamSpec, TypeAlias
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
Data: TypeAlias = "Data"
|
|
11
|
+
P = ParamSpec("P")
|
|
12
|
+
|
|
13
|
+
ReadFn = Callable[Concatenate[str | Path, P], Data]
|
|
14
|
+
WriteFn = Callable[Concatenate[Data, str | Path, P], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
READ_FNS: dict[str, ReadFn] = {}
|
|
18
|
+
WRITE_FNS: dict[str, WriteFn] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_read_fn(key: Hashable) -> Callable:
|
|
22
|
+
key = standardise_key(key)
|
|
23
|
+
|
|
24
|
+
def wrapper(func: Callable) -> Callable:
|
|
25
|
+
logger.info(f"registering read fn {key = } {func = }")
|
|
26
|
+
READ_FNS[key] = func
|
|
27
|
+
return func
|
|
28
|
+
|
|
29
|
+
return wrapper
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_write_fn(key: Hashable) -> Callable:
|
|
33
|
+
key = standardise_key(key)
|
|
34
|
+
|
|
35
|
+
def wrapper(func: Callable) -> Callable:
|
|
36
|
+
logger.info(f"registering write fn {key = } {func = }")
|
|
37
|
+
WRITE_FNS[key] = func
|
|
38
|
+
return func
|
|
39
|
+
|
|
40
|
+
return wrapper
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def standardise_key(key: Hashable) -> Hashable:
|
|
44
|
+
return key.strip().lower() if isinstance(key, str) else key
|