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.
@@ -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