io-adapters 0.1.0__tar.gz → 0.2.2__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.2.2/LICENSE +21 -0
- io_adapters-0.2.2/PKG-INFO +150 -0
- io_adapters-0.2.2/README.md +138 -0
- {io_adapters-0.1.0 → io_adapters-0.2.2}/pyproject.toml +9 -2
- {io_adapters-0.1.0 → io_adapters-0.2.2}/src/io_adapters/__init__.py +3 -0
- {io_adapters-0.1.0 → io_adapters-0.2.2}/src/io_adapters/_adapters.py +28 -25
- io_adapters-0.2.2/src/io_adapters/_clock.py +20 -0
- {io_adapters-0.1.0 → io_adapters-0.2.2}/src/io_adapters/_container.py +9 -3
- {io_adapters-0.1.0 → io_adapters-0.2.2}/src/io_adapters/_io_funcs.py +3 -2
- io_adapters-0.2.2/src/io_adapters/_registries.py +78 -0
- io_adapters-0.1.0/PKG-INFO +0 -55
- io_adapters-0.1.0/README.md +0 -45
- io_adapters-0.1.0/src/io_adapters/_registries.py +0 -44
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Ed Cuss and any other contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: io-adapters
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Dependency Injection Adapters
|
|
5
|
+
Author: ed cuss
|
|
6
|
+
Author-email: ed cuss <edcussmusic@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: attrs>=25.4.0
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# io-adapters
|
|
14
|
+
A small utility library for decoupling I/O from business logic by combining
|
|
15
|
+
dependency injection with lightweight, automatically generated fakes.
|
|
16
|
+
|
|
17
|
+
### Install
|
|
18
|
+
```shell
|
|
19
|
+
uv add io-adapters
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### API Reference
|
|
23
|
+
|
|
24
|
+
[io-adapters API docs](https://second-ed.github.io/io-adapters/)
|
|
25
|
+
|
|
26
|
+
Testing use cases that involve I/O is inherently difficult because they depend on:
|
|
27
|
+
|
|
28
|
+
- external state (filesystems, databases, services)
|
|
29
|
+
|
|
30
|
+
- side effects that are hard to observe directly
|
|
31
|
+
|
|
32
|
+
- slow or flaky infrastructure
|
|
33
|
+
|
|
34
|
+
A common mitigation is to combine:
|
|
35
|
+
|
|
36
|
+
- Dependency Injection (DI)
|
|
37
|
+
|
|
38
|
+
- The Repository / Adapter pattern
|
|
39
|
+
|
|
40
|
+
This allows business logic to depend on an abstract interface rather than concrete I/O.
|
|
41
|
+
|
|
42
|
+
However, in practice this usually requires:
|
|
43
|
+
|
|
44
|
+
- writing and maintaining bespoke fake implementations
|
|
45
|
+
|
|
46
|
+
- keeping fake behaviour in sync with real implementations
|
|
47
|
+
|
|
48
|
+
- duplicating boilerplate across domains
|
|
49
|
+
|
|
50
|
+
For small or medium-sized projects, this overhead can outweigh the benefits.
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
### Example
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from enum import Enum
|
|
58
|
+
from pathlib import Path
|
|
59
|
+
|
|
60
|
+
from io_adapters import (
|
|
61
|
+
IoAdapter,
|
|
62
|
+
RealAdapter,
|
|
63
|
+
add_domain,
|
|
64
|
+
get_fake_adapter,
|
|
65
|
+
get_real_adapter,
|
|
66
|
+
register_domain_read_fn,
|
|
67
|
+
register_domain_write_fn,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# you can use any hashable object to register an I/O function
|
|
72
|
+
class FileFormat(Enum):
|
|
73
|
+
JSON = "json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
add_domain("orders")
|
|
77
|
+
add_domain("payment")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@register_domain_read_fn("orders", "str")
|
|
81
|
+
def read_str(path: str | Path, **kwargs: dict) -> str: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# stack decorators to register the same function to multiple domains
|
|
85
|
+
@register_domain_read_fn("orders", FileFormat.JSON)
|
|
86
|
+
@register_domain_read_fn("payment", FileFormat.JSON)
|
|
87
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict: ...
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@register_domain_write_fn("orders", "str")
|
|
91
|
+
def write_str(data: dict, path: str | Path, **kwargs: dict) -> None: ...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@register_domain_write_fn("orders", FileFormat.JSON)
|
|
95
|
+
@register_domain_write_fn("payment", FileFormat.JSON)
|
|
96
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: ...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def some_usecase(adapter: IoAdapter, path: str) -> None:
|
|
100
|
+
adapter.read(path, "str")
|
|
101
|
+
# Some business logic
|
|
102
|
+
new_path = f"{path}_new.json"
|
|
103
|
+
|
|
104
|
+
adapter.write({"a": 1}, new_path, FileFormat.JSON)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# in production inject the real adapter
|
|
108
|
+
orders_adapter: RealAdapter = get_real_adapter("orders")
|
|
109
|
+
some_usecase(orders_adapter, "some/path/to/file.json")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# in testing inject the fake which has all the same funcitonality as the
|
|
113
|
+
# `RealAdapter` and assert that the fakes end state is as expected
|
|
114
|
+
fake = get_fake_adapter("orders")
|
|
115
|
+
some_usecase(fake, "some/path/to/file.json")
|
|
116
|
+
assert fake.files["some/path/to/file.json"] == {"a": 1}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Repo map
|
|
121
|
+
```
|
|
122
|
+
├── .github
|
|
123
|
+
│ └── workflows
|
|
124
|
+
│ ├── ci_tests.yaml
|
|
125
|
+
│ └── publish.yaml
|
|
126
|
+
├── .pytest_cache
|
|
127
|
+
│ └── README.md
|
|
128
|
+
├── docs
|
|
129
|
+
│ └── source
|
|
130
|
+
│ └── conf.py
|
|
131
|
+
├── src
|
|
132
|
+
│ └── io_adapters
|
|
133
|
+
│ ├── __init__.py
|
|
134
|
+
│ ├── _adapters.py
|
|
135
|
+
│ ├── _clock.py
|
|
136
|
+
│ ├── _container.py
|
|
137
|
+
│ ├── _io_funcs.py
|
|
138
|
+
│ └── _registries.py
|
|
139
|
+
├── tests
|
|
140
|
+
│ ├── __init__.py
|
|
141
|
+
│ ├── test_adapters.py
|
|
142
|
+
│ ├── test_adapters_apis.py
|
|
143
|
+
│ └── test_container.py
|
|
144
|
+
├── .pre-commit-config.yaml
|
|
145
|
+
├── README.md
|
|
146
|
+
├── pyproject.toml
|
|
147
|
+
├── ruff.toml
|
|
148
|
+
└── uv.lock
|
|
149
|
+
::
|
|
150
|
+
```
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# io-adapters
|
|
2
|
+
A small utility library for decoupling I/O from business logic by combining
|
|
3
|
+
dependency injection with lightweight, automatically generated fakes.
|
|
4
|
+
|
|
5
|
+
### Install
|
|
6
|
+
```shell
|
|
7
|
+
uv add io-adapters
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
### API Reference
|
|
11
|
+
|
|
12
|
+
[io-adapters API docs](https://second-ed.github.io/io-adapters/)
|
|
13
|
+
|
|
14
|
+
Testing use cases that involve I/O is inherently difficult because they depend on:
|
|
15
|
+
|
|
16
|
+
- external state (filesystems, databases, services)
|
|
17
|
+
|
|
18
|
+
- side effects that are hard to observe directly
|
|
19
|
+
|
|
20
|
+
- slow or flaky infrastructure
|
|
21
|
+
|
|
22
|
+
A common mitigation is to combine:
|
|
23
|
+
|
|
24
|
+
- Dependency Injection (DI)
|
|
25
|
+
|
|
26
|
+
- The Repository / Adapter pattern
|
|
27
|
+
|
|
28
|
+
This allows business logic to depend on an abstract interface rather than concrete I/O.
|
|
29
|
+
|
|
30
|
+
However, in practice this usually requires:
|
|
31
|
+
|
|
32
|
+
- writing and maintaining bespoke fake implementations
|
|
33
|
+
|
|
34
|
+
- keeping fake behaviour in sync with real implementations
|
|
35
|
+
|
|
36
|
+
- duplicating boilerplate across domains
|
|
37
|
+
|
|
38
|
+
For small or medium-sized projects, this overhead can outweigh the benefits.
|
|
39
|
+
|
|
40
|
+
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.
|
|
41
|
+
|
|
42
|
+
### Example
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from enum import Enum
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
from io_adapters import (
|
|
49
|
+
IoAdapter,
|
|
50
|
+
RealAdapter,
|
|
51
|
+
add_domain,
|
|
52
|
+
get_fake_adapter,
|
|
53
|
+
get_real_adapter,
|
|
54
|
+
register_domain_read_fn,
|
|
55
|
+
register_domain_write_fn,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# you can use any hashable object to register an I/O function
|
|
60
|
+
class FileFormat(Enum):
|
|
61
|
+
JSON = "json"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
add_domain("orders")
|
|
65
|
+
add_domain("payment")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@register_domain_read_fn("orders", "str")
|
|
69
|
+
def read_str(path: str | Path, **kwargs: dict) -> str: ...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# stack decorators to register the same function to multiple domains
|
|
73
|
+
@register_domain_read_fn("orders", FileFormat.JSON)
|
|
74
|
+
@register_domain_read_fn("payment", FileFormat.JSON)
|
|
75
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict: ...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@register_domain_write_fn("orders", "str")
|
|
79
|
+
def write_str(data: dict, path: str | Path, **kwargs: dict) -> None: ...
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@register_domain_write_fn("orders", FileFormat.JSON)
|
|
83
|
+
@register_domain_write_fn("payment", FileFormat.JSON)
|
|
84
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: ...
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def some_usecase(adapter: IoAdapter, path: str) -> None:
|
|
88
|
+
adapter.read(path, "str")
|
|
89
|
+
# Some business logic
|
|
90
|
+
new_path = f"{path}_new.json"
|
|
91
|
+
|
|
92
|
+
adapter.write({"a": 1}, new_path, FileFormat.JSON)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# in production inject the real adapter
|
|
96
|
+
orders_adapter: RealAdapter = get_real_adapter("orders")
|
|
97
|
+
some_usecase(orders_adapter, "some/path/to/file.json")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# in testing inject the fake which has all the same funcitonality as the
|
|
101
|
+
# `RealAdapter` and assert that the fakes end state is as expected
|
|
102
|
+
fake = get_fake_adapter("orders")
|
|
103
|
+
some_usecase(fake, "some/path/to/file.json")
|
|
104
|
+
assert fake.files["some/path/to/file.json"] == {"a": 1}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Repo map
|
|
109
|
+
```
|
|
110
|
+
├── .github
|
|
111
|
+
│ └── workflows
|
|
112
|
+
│ ├── ci_tests.yaml
|
|
113
|
+
│ └── publish.yaml
|
|
114
|
+
├── .pytest_cache
|
|
115
|
+
│ └── README.md
|
|
116
|
+
├── docs
|
|
117
|
+
│ └── source
|
|
118
|
+
│ └── conf.py
|
|
119
|
+
├── src
|
|
120
|
+
│ └── io_adapters
|
|
121
|
+
│ ├── __init__.py
|
|
122
|
+
│ ├── _adapters.py
|
|
123
|
+
│ ├── _clock.py
|
|
124
|
+
│ ├── _container.py
|
|
125
|
+
│ ├── _io_funcs.py
|
|
126
|
+
│ └── _registries.py
|
|
127
|
+
├── tests
|
|
128
|
+
│ ├── __init__.py
|
|
129
|
+
│ ├── test_adapters.py
|
|
130
|
+
│ ├── test_adapters_apis.py
|
|
131
|
+
│ └── test_container.py
|
|
132
|
+
├── .pre-commit-config.yaml
|
|
133
|
+
├── README.md
|
|
134
|
+
├── pyproject.toml
|
|
135
|
+
├── ruff.toml
|
|
136
|
+
└── uv.lock
|
|
137
|
+
::
|
|
138
|
+
```
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "io-adapters"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "
|
|
3
|
+
version = "0.2.2"
|
|
4
|
+
description = "Dependency Injection Adapters"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "ed cuss", email = "edcussmusic@gmail.com" }
|
|
8
8
|
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
9
11
|
requires-python = ">=3.11"
|
|
10
12
|
dependencies = [
|
|
11
13
|
"attrs>=25.4.0",
|
|
@@ -24,4 +26,9 @@ dev = [
|
|
|
24
26
|
"repo-mapper-rs>=0.3.0",
|
|
25
27
|
"ruff>=0.14.9",
|
|
26
28
|
"sphinx>=9.0.4",
|
|
29
|
+
"ty>=0.0.9",
|
|
27
30
|
]
|
|
31
|
+
|
|
32
|
+
[tool.ty.src]
|
|
33
|
+
include = ["src", "tests"]
|
|
34
|
+
exclude = ["src/io_adapters/_io_funcs.py"]
|
|
@@ -8,6 +8,7 @@ from io_adapters._container import (
|
|
|
8
8
|
register_domain_write_fn,
|
|
9
9
|
)
|
|
10
10
|
from io_adapters._io_funcs import read_json, write_json # noqa: F401
|
|
11
|
+
from io_adapters._registries import register_read_fn, register_write_fn
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"Container",
|
|
@@ -19,4 +20,6 @@ __all__ = [
|
|
|
19
20
|
"get_real_adapter",
|
|
20
21
|
"register_domain_read_fn",
|
|
21
22
|
"register_domain_write_fn",
|
|
23
|
+
"register_read_fn",
|
|
24
|
+
"register_write_fn",
|
|
22
25
|
]
|
|
@@ -2,23 +2,22 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import datetime
|
|
4
4
|
import logging
|
|
5
|
-
from abc import
|
|
6
|
-
from collections.abc import Hashable
|
|
5
|
+
from collections.abc import Callable, Hashable
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from types import MappingProxyType
|
|
9
|
-
from uuid import uuid4
|
|
10
8
|
|
|
11
9
|
import attrs
|
|
12
|
-
from attrs.validators import deep_mapping, instance_of, is_callable
|
|
10
|
+
from attrs.validators import deep_mapping, instance_of, is_callable, optional
|
|
13
11
|
|
|
14
|
-
from io_adapters.
|
|
12
|
+
from io_adapters._clock import default_datetime, default_guid, fake_datetime, fake_guid
|
|
13
|
+
from io_adapters._registries import READ_FNS, WRITE_FNS, Data, ReadFn, WriteFn, standardise_key
|
|
15
14
|
|
|
16
15
|
logger = logging.getLogger(__name__)
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
@attrs.define
|
|
20
|
-
class IoAdapter
|
|
21
|
-
read_fns: MappingProxyType = attrs.field(
|
|
19
|
+
class IoAdapter:
|
|
20
|
+
read_fns: MappingProxyType[Hashable, ReadFn] = attrs.field(
|
|
22
21
|
default=READ_FNS,
|
|
23
22
|
validator=[
|
|
24
23
|
deep_mapping(
|
|
@@ -29,7 +28,7 @@ class IoAdapter(ABC):
|
|
|
29
28
|
],
|
|
30
29
|
converter=MappingProxyType,
|
|
31
30
|
)
|
|
32
|
-
write_fns: MappingProxyType = attrs.field(
|
|
31
|
+
write_fns: MappingProxyType[Hashable, WriteFn] = attrs.field(
|
|
33
32
|
default=WRITE_FNS,
|
|
34
33
|
validator=[
|
|
35
34
|
deep_mapping(
|
|
@@ -40,8 +39,12 @@ class IoAdapter(ABC):
|
|
|
40
39
|
],
|
|
41
40
|
converter=MappingProxyType,
|
|
42
41
|
)
|
|
42
|
+
guid_fn: Callable[[], str] = attrs.field(default=None, validator=optional(is_callable()))
|
|
43
|
+
datetime_fn: Callable[[], datetime.datetime] = attrs.field(
|
|
44
|
+
default=None, validator=optional(is_callable())
|
|
45
|
+
)
|
|
43
46
|
|
|
44
|
-
def read(self, path: str | Path, file_type:
|
|
47
|
+
def read(self, path: str | Path, file_type: Hashable, **kwargs: dict) -> Data:
|
|
45
48
|
"""Read `path` using the registered function for `file_type`.
|
|
46
49
|
|
|
47
50
|
Raises:
|
|
@@ -77,7 +80,7 @@ class IoAdapter(ABC):
|
|
|
77
80
|
raise NotImplementedError(msg)
|
|
78
81
|
return self.read_fns[file_type](path, **kwargs)
|
|
79
82
|
|
|
80
|
-
def write(self, data: Data, path: str | Path, file_type:
|
|
83
|
+
def write(self, data: Data, path: str | Path, file_type: Hashable, **kwargs: dict) -> None:
|
|
81
84
|
"""Write `data` to `path` using the registered function for `file_type`.
|
|
82
85
|
|
|
83
86
|
Raises:
|
|
@@ -118,7 +121,7 @@ class IoAdapter(ABC):
|
|
|
118
121
|
def some_usecase(adapter: IoAdapter, path: str) -> None:
|
|
119
122
|
# Some business logic
|
|
120
123
|
|
|
121
|
-
adapter.write({"a": 1}, , WriteFormat.JSON)
|
|
124
|
+
adapter.write({"a": 1}, path, WriteFormat.JSON)
|
|
122
125
|
|
|
123
126
|
# in production inject the real adapter
|
|
124
127
|
some_usecase(RealAdapter(), "some/path/to/file.json")
|
|
@@ -138,20 +141,18 @@ class IoAdapter(ABC):
|
|
|
138
141
|
raise NotImplementedError(msg)
|
|
139
142
|
return self.write_fns[file_type](data, path, **kwargs)
|
|
140
143
|
|
|
141
|
-
|
|
142
|
-
|
|
144
|
+
def get_guid(self) -> str:
|
|
145
|
+
return self.guid_fn()
|
|
143
146
|
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
def get_datetime(self) -> datetime.datetime:
|
|
148
|
+
return self.datetime_fn()
|
|
146
149
|
|
|
147
150
|
|
|
148
151
|
@attrs.define
|
|
149
152
|
class RealAdapter(IoAdapter):
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def get_datetime(self) -> datetime.datetime:
|
|
154
|
-
return datetime.datetime.now(datetime.UTC)
|
|
153
|
+
def __attrs_post_init__(self) -> None:
|
|
154
|
+
self.guid_fn = self.guid_fn or default_guid
|
|
155
|
+
self.datetime_fn = self.datetime_fn or default_datetime
|
|
155
156
|
|
|
156
157
|
|
|
157
158
|
@attrs.define
|
|
@@ -161,18 +162,20 @@ class FakeAdapter(IoAdapter):
|
|
|
161
162
|
def __attrs_post_init__(self) -> None:
|
|
162
163
|
self.read_fns = MappingProxyType(dict.fromkeys(self.read_fns.keys(), self._read_fn))
|
|
163
164
|
self.write_fns = MappingProxyType(dict.fromkeys(self.write_fns.keys(), self._write_fn))
|
|
165
|
+
self.guid_fn = self.guid_fn or fake_guid
|
|
166
|
+
self.datetime_fn = self.datetime_fn or fake_datetime
|
|
164
167
|
|
|
165
|
-
def _read_fn(self, path: str) -> Data:
|
|
168
|
+
def _read_fn(self, path: str | Path) -> Data:
|
|
166
169
|
try:
|
|
167
|
-
return self.files[path]
|
|
170
|
+
return self.files[str(path)]
|
|
168
171
|
except KeyError as e:
|
|
169
172
|
raise FileNotFoundError(f"{path = } {self.files = }") from e
|
|
170
173
|
|
|
171
|
-
def _write_fn(self, data: Data, path: str) -> None:
|
|
174
|
+
def _write_fn(self, data: Data, path: str | Path) -> None:
|
|
172
175
|
self.files[str(path)] = data
|
|
173
176
|
|
|
174
177
|
def get_guid(self) -> str:
|
|
175
|
-
return
|
|
178
|
+
return self.guid_fn()
|
|
176
179
|
|
|
177
180
|
def get_datetime(self) -> datetime.datetime:
|
|
178
|
-
return
|
|
181
|
+
return self.datetime_fn()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_guid() -> str:
|
|
8
|
+
return str(uuid4())
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fake_guid() -> str:
|
|
12
|
+
return "abc-123"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_datetime() -> datetime.datetime:
|
|
16
|
+
return datetime.datetime.now(datetime.UTC)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fake_datetime() -> datetime.datetime:
|
|
20
|
+
return datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.UTC)
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections.abc import Callable, Hashable, Iterable
|
|
5
5
|
from enum import Enum, auto
|
|
6
|
+
from typing import TypeAlias
|
|
6
7
|
|
|
7
8
|
import attrs
|
|
8
9
|
from attrs.validators import deep_iterable, instance_of
|
|
@@ -18,6 +19,9 @@ class _FnType(Enum):
|
|
|
18
19
|
WRITE = auto()
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
DomainFns: TypeAlias = dict[Hashable, dict[Hashable, Callable]]
|
|
23
|
+
|
|
24
|
+
|
|
21
25
|
@attrs.define
|
|
22
26
|
class Container:
|
|
23
27
|
"""Registry and factory for domain-scoped I/O adapters.
|
|
@@ -92,10 +96,12 @@ class Container:
|
|
|
92
96
|
"""
|
|
93
97
|
|
|
94
98
|
domains: Iterable = attrs.field(validator=deep_iterable(member_validator=instance_of(Hashable)))
|
|
95
|
-
domain_fns:
|
|
99
|
+
domain_fns: DomainFns = attrs.field(init=False)
|
|
96
100
|
|
|
97
101
|
def __attrs_post_init__(self) -> None:
|
|
98
|
-
self.domain_fns
|
|
102
|
+
self.domain_fns: DomainFns = {
|
|
103
|
+
domain: {_FnType.READ: {}, _FnType.WRITE: {}} for domain in self.domains
|
|
104
|
+
}
|
|
99
105
|
|
|
100
106
|
def add_domain(self, domain: Hashable) -> None:
|
|
101
107
|
"""Add a domain to a ``Container``
|
|
@@ -203,7 +209,7 @@ class Container:
|
|
|
203
209
|
write_fns=self.domain_fns[domain][_FnType.WRITE],
|
|
204
210
|
)
|
|
205
211
|
|
|
206
|
-
def get_fake_adapter(self, domain: Hashable, files: dict | None = None) ->
|
|
212
|
+
def get_fake_adapter(self, domain: Hashable, files: dict | None = None) -> FakeAdapter:
|
|
207
213
|
"""Get a ``FakeAdapter`` for the given domain.
|
|
208
214
|
|
|
209
215
|
The returned adapter will have all of the functions registered to that domain.
|
|
@@ -2,17 +2,18 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
5
6
|
|
|
6
7
|
from io_adapters._registries import register_read_fn, register_write_fn
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
@register_read_fn("json")
|
|
10
|
-
def read_json(path: str | Path, **kwargs: dict) -> dict:
|
|
11
|
+
def read_json(path: str | Path, **kwargs: dict[str, Any]) -> dict:
|
|
11
12
|
return json.loads(Path(path).read_text(), **kwargs)
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
@register_write_fn("json")
|
|
15
|
-
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
16
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict[str, Any]) -> None:
|
|
16
17
|
path = Path(path)
|
|
17
18
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
19
|
path.write_text(json.dumps(data, **kwargs))
|
|
@@ -0,0 +1,78 @@
|
|
|
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[Hashable, ReadFn] = {}
|
|
18
|
+
WRITE_FNS: dict[Hashable, WriteFn] = {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_read_fn(key: Hashable) -> Callable:
|
|
22
|
+
"""Register a read function to the read functions constant.
|
|
23
|
+
|
|
24
|
+
This is useful for smaller projects where domain isolation isn't required.
|
|
25
|
+
|
|
26
|
+
.. code-block:: python
|
|
27
|
+
|
|
28
|
+
from io_adapters import RealAdapter, register_read_fn
|
|
29
|
+
|
|
30
|
+
@register_read_fn("json")
|
|
31
|
+
def read_json(path: str | Path, **kwargs: dict) -> dict:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
This function will be accessible when you initialise a ``RealAdapter``
|
|
35
|
+
and a stub of the functionality will be added to a ``FakeAdapter``.
|
|
36
|
+
"""
|
|
37
|
+
key = standardise_key(key)
|
|
38
|
+
|
|
39
|
+
def wrapper(func: Callable) -> Callable:
|
|
40
|
+
logger.info(f"registering read fn {key = } {func = }")
|
|
41
|
+
READ_FNS[key] = func
|
|
42
|
+
return func
|
|
43
|
+
|
|
44
|
+
return wrapper
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_write_fn(key: Hashable) -> Callable:
|
|
48
|
+
"""Register a write function to the write functions constant.
|
|
49
|
+
|
|
50
|
+
This is useful for smaller projects where domain isolation isn't required.
|
|
51
|
+
|
|
52
|
+
.. code-block:: python
|
|
53
|
+
|
|
54
|
+
from enum import Enum
|
|
55
|
+
from io_adapters import RealAdapter, register_write_fn
|
|
56
|
+
|
|
57
|
+
class WriteFormat(Enum):
|
|
58
|
+
JSON = "json"
|
|
59
|
+
|
|
60
|
+
@register_write_fn(WriteFormat.JSON)
|
|
61
|
+
def write_json(data: dict, path: str | Path, **kwargs: dict) -> None:
|
|
62
|
+
...
|
|
63
|
+
|
|
64
|
+
This function will be accessible when you initialise a ``RealAdapter``
|
|
65
|
+
and a stub of the functionality will be added to a ``FakeAdapter``.
|
|
66
|
+
"""
|
|
67
|
+
key = standardise_key(key)
|
|
68
|
+
|
|
69
|
+
def wrapper(func: Callable) -> Callable:
|
|
70
|
+
logger.info(f"registering write fn {key = } {func = }")
|
|
71
|
+
WRITE_FNS[key] = func
|
|
72
|
+
return func
|
|
73
|
+
|
|
74
|
+
return wrapper
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def standardise_key(key: Hashable) -> Hashable:
|
|
78
|
+
return key.strip().lower() if isinstance(key, str) else key
|
io_adapters-0.1.0/PKG-INFO
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
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
|
-
```
|
io_adapters-0.1.0/README.md
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
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
|
-
```
|
|
@@ -1,44 +0,0 @@
|
|
|
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
|