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.
@@ -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.1.0"
4
- description = "Add your description here"
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 ABC, abstractmethod
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._registries import READ_FNS, WRITE_FNS, Data, standardise_key
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(ABC):
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: str, **kwargs: dict) -> Data:
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: str, **kwargs: dict) -> None:
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
- @abstractmethod
142
- def get_guid(self) -> str: ...
144
+ def get_guid(self) -> str:
145
+ return self.guid_fn()
143
146
 
144
- @abstractmethod
145
- def get_datetime(self) -> datetime.datetime: ...
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 get_guid(self) -> str:
151
- return str(uuid4())
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 "abc-123"
178
+ return self.guid_fn()
176
179
 
177
180
  def get_datetime(self) -> datetime.datetime:
178
- return datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.UTC)
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: dict[Hashable, dict[Hashable, Callable]] = attrs.field(init=False)
99
+ domain_fns: DomainFns = attrs.field(init=False)
96
100
 
97
101
  def __attrs_post_init__(self) -> None:
98
- self.domain_fns = {domain: {_FnType.READ: {}, _FnType.WRITE: {}} for domain in self.domains}
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) -> RealAdapter:
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
@@ -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
- ```
@@ -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