raceway 0.2__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.
Files changed (35) hide show
  1. raceway-0.2.2/PKG-INFO +133 -0
  2. raceway-0.2.2/README.rst +118 -0
  3. {raceway-0.2 → raceway-0.2.2}/pyproject.toml +37 -2
  4. raceway-0.2.2/src/raceway/__version__.py +1 -0
  5. raceway-0.2.2/src/raceway/callbacks.py +103 -0
  6. raceway-0.2.2/src/raceway/container.py +149 -0
  7. raceway-0.2.2/src/raceway/extractor.py +163 -0
  8. raceway-0.2.2/src/raceway/injector.py +58 -0
  9. raceway-0.2.2/src/raceway/planner.py +105 -0
  10. raceway-0.2.2/src/raceway/protocols.py +89 -0
  11. raceway-0.2.2/src/raceway/registration.py +30 -0
  12. raceway-0.2.2/src/raceway/registry.py +19 -0
  13. raceway-0.2.2/src/raceway/rules.py +10 -0
  14. raceway-0.2.2/src/raceway/starter.py +30 -0
  15. raceway-0.2.2/tests/test_callbacks.py +92 -0
  16. raceway-0.2.2/tests/test_container.py +103 -0
  17. raceway-0.2.2/tests/test_extractor.py +75 -0
  18. raceway-0.2.2/tests/test_free.py +141 -0
  19. raceway-0.2.2/tests/test_injector.py +99 -0
  20. raceway-0.2.2/tests/test_planner.py +202 -0
  21. {raceway-0.2 → raceway-0.2.2}/tests/test_simple.py +20 -23
  22. raceway-0.2/PKG-INFO +0 -10
  23. raceway-0.2/src/raceway/__version__.py +0 -1
  24. raceway-0.2/src/raceway/container.py +0 -104
  25. raceway-0.2/src/raceway/planner.py +0 -88
  26. raceway-0.2/src/raceway/protocols.py +0 -43
  27. raceway-0.2/src/raceway/registration.py +0 -23
  28. raceway-0.2/src/raceway/registry.py +0 -15
  29. raceway-0.2/src/raceway/starter.py +0 -30
  30. raceway-0.2/tests/test_cycles.py +0 -150
  31. raceway-0.2/tests/test_free.py +0 -124
  32. {raceway-0.2 → raceway-0.2.2}/.gitignore +0 -0
  33. {raceway-0.2 → raceway-0.2.2}/LICENSE +0 -0
  34. {raceway-0.2 → raceway-0.2.2}/src/raceway/__init__.py +0 -0
  35. {raceway-0.2 → raceway-0.2.2}/src/raceway/exc.py +0 -0
raceway-0.2.2/PKG-INFO ADDED
@@ -0,0 +1,133 @@
1
+ Metadata-Version: 2.5
2
+ Name: raceway
3
+ Version: 0.2.2
4
+ Summary: Simple dependency injection system.
5
+ Project-URL: GitHub, https://github.com/ianjosephwilson/raceway
6
+ Author-email: Ian Wilson <ian@gardentheory.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.14
12
+ Provides-Extra: venusian
13
+ Requires-Dist: venusian; extra == 'venusian'
14
+ Description-Content-Type: text/x-rst
15
+
16
+ raceway
17
+ #########
18
+
19
+ Minimal dependency injection, DI, system ideal for smaller systems that still need DI.
20
+
21
+ Overview
22
+ --------
23
+
24
+ - Defining: Define protocols and implementations.
25
+ - Loading: Load registrations extracted from implementations into planner.
26
+ - Starting: Run startup to create container from planner.
27
+ - Wrapping: Use injector to wrap task handlers to prepare for injection.
28
+ - Running: Create task for each "runner" and use it with the container to execute each injection enabled task handler.
29
+
30
+ Example
31
+ -------
32
+
33
+ This library is meant to be used with an external framework but this example
34
+ uses python's stdlib threading to demonstrate how the pieces fit together.
35
+
36
+ .. code-block:: python
37
+
38
+ import threading
39
+ import time
40
+ from dataclasses import dataclass
41
+ from typing import Protocol
42
+ from random import random
43
+
44
+ from raceway.extractor import configure_extractor
45
+ from raceway.registration import Registration
46
+ from raceway.planner import configure_planner
47
+ from raceway.starter import startup
48
+ from raceway.injector import configure_injector
49
+ from raceway.protocols import IContainer, ITask, IInjector, IExtractor
50
+
51
+ class ICrawlJob(ITask, Protocol):
52
+ def get_link(self) -> str: ...
53
+
54
+ @dataclass
55
+ class CrawlJob(ICrawlJob):
56
+ def __hash__(self):
57
+ return id(self)
58
+ def __eq__(self, other: object):
59
+ return self is other
60
+ id: int
61
+ link: str
62
+ def get_link(self) -> str:
63
+ return self.link
64
+
65
+ class ICrawler(Protocol):
66
+ def crawl(self, delay: float) -> str: ...
67
+
68
+ @dataclass
69
+ class CrawlerService(ICrawler):
70
+ job: ICrawlJob
71
+ def crawl(self, delay: float) -> str:
72
+ return f"Crawled {self.job.get_link()} in {delay}s."
73
+
74
+ def run_job(crawler_api: ICrawler) -> None:
75
+ delay = 3*random()
76
+ time.sleep(delay) # Blocking I/O (simulating a network request)
77
+ print (crawler_api.crawl(delay))
78
+
79
+
80
+ def main():
81
+ #
82
+ # Setup
83
+ #
84
+ extractor_api = configure_extractor()
85
+ injector_api = configure_injector(extractor=extractor_api, task_proto=ICrawlJob)
86
+ planner = configure_planner(task_proto=ICrawlJob)
87
+ planner.queue_registration(
88
+ ICrawler,
89
+ Registration(
90
+ CrawlerService, extractor_api.extract(CrawlerService), scope="task"
91
+ ),
92
+ )
93
+ container_api = startup(planner=planner)
94
+
95
+ # Prepare job runner for injection.
96
+ wrapped_with_inject = injector_api.wrap_in_inject(run_job)
97
+
98
+ #
99
+ # Payloads
100
+ #
101
+ links = [
102
+ "https://python.org",
103
+ "https://docs.python.org",
104
+ "https://peps.python.org",
105
+ *[f"https://example.com/{v}" for v in range(20)],
106
+ ]
107
+
108
+
109
+ #
110
+ # Start threads for each link
111
+ #
112
+ threads = []
113
+ for link_id, link in enumerate(links):
114
+ # Using `args` to pass positional arguments and `kwargs` for keyword arguments
115
+ # ... in our case we are passing the args the inject function will need.
116
+ job = CrawlJob(link_id, link)
117
+ t = threading.Thread(
118
+ target=wrapped_with_inject,
119
+ args=(container_api, job),
120
+ )
121
+ threads.append(t)
122
+
123
+ # Start each thread
124
+ for t in threads:
125
+ t.start()
126
+
127
+ # Wait for all threads to finish
128
+ for t in threads:
129
+ t.join()
130
+
131
+
132
+ if __name__ == '__main__':
133
+ main()
@@ -0,0 +1,118 @@
1
+ raceway
2
+ #########
3
+
4
+ Minimal dependency injection, DI, system ideal for smaller systems that still need DI.
5
+
6
+ Overview
7
+ --------
8
+
9
+ - Defining: Define protocols and implementations.
10
+ - Loading: Load registrations extracted from implementations into planner.
11
+ - Starting: Run startup to create container from planner.
12
+ - Wrapping: Use injector to wrap task handlers to prepare for injection.
13
+ - Running: Create task for each "runner" and use it with the container to execute each injection enabled task handler.
14
+
15
+ Example
16
+ -------
17
+
18
+ This library is meant to be used with an external framework but this example
19
+ uses python's stdlib threading to demonstrate how the pieces fit together.
20
+
21
+ .. code-block:: python
22
+
23
+ import threading
24
+ import time
25
+ from dataclasses import dataclass
26
+ from typing import Protocol
27
+ from random import random
28
+
29
+ from raceway.extractor import configure_extractor
30
+ from raceway.registration import Registration
31
+ from raceway.planner import configure_planner
32
+ from raceway.starter import startup
33
+ from raceway.injector import configure_injector
34
+ from raceway.protocols import IContainer, ITask, IInjector, IExtractor
35
+
36
+ class ICrawlJob(ITask, Protocol):
37
+ def get_link(self) -> str: ...
38
+
39
+ @dataclass
40
+ class CrawlJob(ICrawlJob):
41
+ def __hash__(self):
42
+ return id(self)
43
+ def __eq__(self, other: object):
44
+ return self is other
45
+ id: int
46
+ link: str
47
+ def get_link(self) -> str:
48
+ return self.link
49
+
50
+ class ICrawler(Protocol):
51
+ def crawl(self, delay: float) -> str: ...
52
+
53
+ @dataclass
54
+ class CrawlerService(ICrawler):
55
+ job: ICrawlJob
56
+ def crawl(self, delay: float) -> str:
57
+ return f"Crawled {self.job.get_link()} in {delay}s."
58
+
59
+ def run_job(crawler_api: ICrawler) -> None:
60
+ delay = 3*random()
61
+ time.sleep(delay) # Blocking I/O (simulating a network request)
62
+ print (crawler_api.crawl(delay))
63
+
64
+
65
+ def main():
66
+ #
67
+ # Setup
68
+ #
69
+ extractor_api = configure_extractor()
70
+ injector_api = configure_injector(extractor=extractor_api, task_proto=ICrawlJob)
71
+ planner = configure_planner(task_proto=ICrawlJob)
72
+ planner.queue_registration(
73
+ ICrawler,
74
+ Registration(
75
+ CrawlerService, extractor_api.extract(CrawlerService), scope="task"
76
+ ),
77
+ )
78
+ container_api = startup(planner=planner)
79
+
80
+ # Prepare job runner for injection.
81
+ wrapped_with_inject = injector_api.wrap_in_inject(run_job)
82
+
83
+ #
84
+ # Payloads
85
+ #
86
+ links = [
87
+ "https://python.org",
88
+ "https://docs.python.org",
89
+ "https://peps.python.org",
90
+ *[f"https://example.com/{v}" for v in range(20)],
91
+ ]
92
+
93
+
94
+ #
95
+ # Start threads for each link
96
+ #
97
+ threads = []
98
+ for link_id, link in enumerate(links):
99
+ # Using `args` to pass positional arguments and `kwargs` for keyword arguments
100
+ # ... in our case we are passing the args the inject function will need.
101
+ job = CrawlJob(link_id, link)
102
+ t = threading.Thread(
103
+ target=wrapped_with_inject,
104
+ args=(container_api, job),
105
+ )
106
+ threads.append(t)
107
+
108
+ # Start each thread
109
+ for t in threads:
110
+ t.start()
111
+
112
+ # Wait for all threads to finish
113
+ for t in threads:
114
+ t.join()
115
+
116
+
117
+ if __name__ == '__main__':
118
+ main()
@@ -1,11 +1,12 @@
1
1
  [tool.black]
2
- line-length = 80
3
2
  target-version = ['py314']
4
3
 
4
+
5
5
  [tool.pytest.ini_options]
6
6
  addopts = [
7
7
  "--import-mode=importlib",
8
8
  ]
9
+
9
10
  [tool.pyright]
10
11
  include = [
11
12
  "src/raceway/**/*.py"
@@ -15,6 +16,7 @@ exclude = [
15
16
  ]
16
17
 
17
18
  [tool.hatch]
19
+ installer = "pip"
18
20
 
19
21
  [tool.hatch.env]
20
22
 
@@ -35,10 +37,38 @@ packages = ["src/raceway"]
35
37
  requires = ["hatchling >= 1.26"]
36
38
  build-backend = "hatchling.build"
37
39
 
40
+ [tool.hatch.envs.default]
41
+ dependencies = [
42
+ "pytest",
43
+ "pyright",
44
+ "flake8",
45
+ "flake8-bugbear",
46
+ "black",
47
+ "twine",
48
+ ]
49
+
50
+ [tool.hatch.envs.default.scripts]
51
+ testit="pytest tests"
52
+ checkit=["pyright src tests"]
53
+ lintit=["flake8 src tests"]
54
+ shapeit=["black src tests"]
55
+ buildit=[
56
+ "python -m build --wheel",
57
+ "python -m build --sdist",
58
+ "twine check dist/*.whl"
59
+ ]
60
+ tagit=[
61
+ "git tag -a v`hatch version` -m Version' '`hatch version`",
62
+ "git push origin v`hatch version`"
63
+ ]
64
+ releaseit=[
65
+ "twine upload dist/*",
66
+ ]
38
67
 
39
68
  [project]
40
69
  name = "raceway"
41
70
  description = "Simple dependency injection system. "
71
+ readme = "README.rst"
42
72
  dynamic = ["version"]
43
73
  dependencies = []
44
74
  authors = [
@@ -51,4 +81,9 @@ classifiers = [
51
81
  ]
52
82
  license = "MIT"
53
83
  license-files = ["LICEN[CS]E*"]
54
- homepage="https://github.com/ianjosephwilson/raceway"
84
+
85
+ [project.urls]
86
+ GitHub="https://github.com/ianjosephwilson/raceway"
87
+
88
+ [project.optional-dependencies]
89
+ venusian = ["venusian"]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.2"
@@ -0,0 +1,103 @@
1
+ """
2
+ This module provides decorators that attach callbacks that venusian can find.
3
+ """
4
+
5
+ from collections.abc import Callable
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass, is_dataclass
8
+ from inspect import isclass
9
+
10
+ from .exc import RacewayError
11
+ from .protocols import ILoader, IPlanner, IExtractor, ScopeType
12
+ from .registration import Registration
13
+
14
+ DEFAULT_CATEGORY = "raceway.service"
15
+
16
+
17
+ # @TODO: Make sure this the latest and greatest pattern for doing this.
18
+ # We don't want to raise unless someone tries to use the actual
19
+ # decorators without another attach.
20
+ try:
21
+ import venusian
22
+
23
+ venusian_attach = venusian.attach
24
+ except ImportError:
25
+ venusian_attach = None
26
+
27
+
28
+ LoaderCtx: ContextVar[ILoader | None] = ContextVar("LoaderCtx")
29
+ """ContextVar that holds the loader during scanning for callbacks."""
30
+
31
+
32
+ class CallbackError(RacewayError):
33
+ pass
34
+
35
+
36
+ @dataclass
37
+ class Loader(ILoader):
38
+ planner: IPlanner
39
+ extractor: IExtractor
40
+
41
+
42
+ def configure_loader(planner: IPlanner, extractor: IExtractor) -> ILoader:
43
+ return Loader(planner=planner, extractor=extractor)
44
+
45
+
46
+ def feed_loader(
47
+ loader: ILoader,
48
+ feed: Callable[[], None],
49
+ cv: ContextVar[ILoader | None] = LoaderCtx,
50
+ ):
51
+ """Setup the loader context and then call feed."""
52
+ with cv.set(loader):
53
+ feed()
54
+
55
+
56
+ def configure_as_service[T](
57
+ register_proto: type[T],
58
+ cv: ContextVar[ILoader | None] = LoaderCtx,
59
+ attach: Callable | None = venusian_attach,
60
+ scope: ScopeType = "task",
61
+ wrap_in_dataclass: bool = True,
62
+ category: str = DEFAULT_CATEGORY,
63
+ ) -> Callable[[Callable[..., T]], Callable[..., T]]:
64
+ """
65
+ Decorate a service factory with a callback that can be executed to feed
66
+ its registration to a loader.
67
+
68
+ @NOTE: The `cv` must be set with a loader during callback's execution. This
69
+ can be done by passing a `feed()` function into `feed_loader` along with
70
+ a configured loader.
71
+ """
72
+ if attach is None:
73
+ raise CallbackError("Venusian must be installed to use callbacks.")
74
+
75
+ def wrapper(
76
+ service_factory: Callable[..., T],
77
+ ) -> Callable[..., T]:
78
+ # Wrap the service class in a dataclass here in case it is needed
79
+ # at anypoint in the future. This is almost just for convenience
80
+ # and maybe it should be moved to an extended decorator.
81
+ if (
82
+ wrap_in_dataclass
83
+ and isclass(service_factory)
84
+ and not is_dataclass(service_factory)
85
+ ):
86
+ service_factory = dataclass(service_factory)
87
+
88
+ def callback(*_):
89
+ """Callback for venusian scan."""
90
+ loader = cv.get()
91
+ if loader is None:
92
+ raise CallbackError(
93
+ f"{cv} context variable must be set when callback fires."
94
+ )
95
+ dep_specs = loader.extractor.extract(service_factory)
96
+ loader.planner.queue_registration(
97
+ register_proto, Registration(service_factory, dep_specs, scope=scope)
98
+ )
99
+
100
+ attach(service_factory, callback, category=category)
101
+ return service_factory
102
+
103
+ return wrapper
@@ -0,0 +1,149 @@
1
+ from dataclasses import dataclass
2
+ from weakref import WeakKeyDictionary, WeakValueDictionary
3
+
4
+ from .exc import RacewayError
5
+ from .protocols import (
6
+ IContainer,
7
+ IRegistry,
8
+ IRegistration,
9
+ IDepSpec,
10
+ ScopeType,
11
+ )
12
+ from .rules import can_depend_on
13
+
14
+
15
+ class ContainerError(RacewayError):
16
+ """General error during container operations."""
17
+
18
+ pass
19
+
20
+
21
+ class NotSet:
22
+ pass
23
+
24
+
25
+ NOT_SET = NotSet()
26
+
27
+
28
+ def create_task_cache() -> WeakValueDictionary:
29
+ return WeakValueDictionary()
30
+
31
+
32
+ def create_container_cache() -> WeakKeyDictionary:
33
+ return WeakKeyDictionary()
34
+
35
+
36
+ def configure_container[V, W](
37
+ registry: IRegistry,
38
+ startup_task: W,
39
+ task_proto: type[V],
40
+ ) -> IContainer[V, W]:
41
+ cache = create_container_cache()
42
+ return Container(
43
+ cache=cache, registry=registry, startup_task=startup_task, task_proto=task_proto
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class Container[V, W](IContainer[V, W]):
49
+
50
+ cache: WeakKeyDictionary
51
+
52
+ registry: IRegistry
53
+
54
+ startup_task: W
55
+
56
+ task_proto: type[V]
57
+
58
+ def validate_dep_specs(
59
+ self,
60
+ dep_specs: tuple[tuple[str, IDepSpec], ...],
61
+ scope: ScopeType = "call",
62
+ ) -> None:
63
+ for _, dep_spec in dep_specs:
64
+ reg = self.registry.find(dep_spec.proto)
65
+ if reg is None:
66
+ raise ContainerError(
67
+ f"Cannot find registration for given protocol: {dep_spec.proto}"
68
+ )
69
+ dep_scope = reg.scope
70
+ if not can_depend_on(scope, dep_scope):
71
+ raise ContainerError(
72
+ f"Scope mismatch: {scope} cannot depend on {dep_spec.proto}:{dep_scope}" # noqa B950
73
+ )
74
+
75
+ def resolve_deps( # @TODO: Should be dep specs too
76
+ self,
77
+ dep_specs: tuple[tuple[str, IDepSpec], ...],
78
+ task: V | None = None,
79
+ ) -> tuple[tuple[str, object], ...]:
80
+ resolved = []
81
+ for dep_name, dep_spec in dep_specs:
82
+ result = self.find_service(dep_spec.proto, task=task)
83
+ if dep_spec.attr:
84
+ result = getattr(result, dep_spec.attr)
85
+ if dep_spec.key:
86
+ result = result[dep_spec.key] # type: ignore
87
+ if dep_spec.call_kwargs is not None or dep_spec.call_args is not None:
88
+ call_kwargs = (
89
+ dict(dep_spec.call_kwargs)
90
+ if dep_spec.call_kwargs is not None
91
+ else {}
92
+ )
93
+ call_args = dep_spec.call_args if dep_spec.call_args is not None else ()
94
+ result = result(*call_args, **call_kwargs) # type: ignore
95
+ resolved.append((dep_name, result))
96
+ return tuple(resolved)
97
+
98
+ def make_service[T](
99
+ self,
100
+ reg: IRegistration[T],
101
+ task: V | None = None,
102
+ ) -> T:
103
+ deps = dict(self.resolve_deps(reg.dep_specs, task=task))
104
+ return reg.factory(**deps)
105
+
106
+ def find_service[T](
107
+ self,
108
+ proto: type[T],
109
+ task: V | None = None,
110
+ ) -> T:
111
+ if proto is self.task_proto:
112
+ if task is None:
113
+ raise ContainerError("Cannot find task because no task was provided!")
114
+ return task # type: ignore @TODO: Resolve this when we rewrite all the types.
115
+ reg = self.registry.find(proto)
116
+ if reg is None:
117
+ raise ContainerError(
118
+ f"Cannot find registration for given protocol: {proto}"
119
+ )
120
+ if reg.scope == "startup":
121
+ if self.startup_task in self.cache:
122
+ task_cache = self.cache[self.startup_task]
123
+ else:
124
+ maybe_cache = create_task_cache()
125
+ # We use the *result* of setdefault to try to avoid the race condition.
126
+ task_cache = self.cache.setdefault(self.startup_task, maybe_cache)
127
+ v = task_cache.get(proto, NOT_SET)
128
+ if v is NOT_SET:
129
+ maybe_v = self.make_service(reg, task=task)
130
+ v = task_cache.setdefault(proto, maybe_v)
131
+ return v
132
+ elif reg.scope == "task":
133
+ if task is None:
134
+ raise ContainerError(
135
+ "Cannot access task cache without providing a task!"
136
+ )
137
+ if task in self.cache:
138
+ task_cache = self.cache[task]
139
+ else:
140
+ # We use the *result* of setdefault to try to avoid the race condition.
141
+ maybe_cache = create_task_cache()
142
+ task_cache = self.cache.setdefault(task, maybe_cache)
143
+ v = task_cache.get(proto, NOT_SET)
144
+ if v is NOT_SET:
145
+ maybe_v = self.make_service(reg, task=task)
146
+ v = task_cache.setdefault(proto, maybe_v)
147
+ return v
148
+ else:
149
+ return self.make_service(reg, task=task)