async-graph-data-flow 1.5.1__tar.gz → 2.0.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.
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/CHANGELOG.md +29 -2
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/LICENSE.txt +1 -1
- {async_graph_data_flow-1.5.1/src/async_graph_data_flow.egg-info → async_graph_data_flow-2.0.0}/PKG-INFO +26 -24
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/README.md +9 -8
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/pyproject.toml +16 -16
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/__init__.py +0 -1
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/executor.py +13 -14
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/graph.py +103 -57
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0/src/async_graph_data_flow.egg-info}/PKG-INFO +26 -24
- async_graph_data_flow-2.0.0/src/async_graph_data_flow.egg-info/requires.txt +15 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/tests/test_executor.py +98 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/tests/test_graph.py +98 -3
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/tests/test_version_number.py +0 -1
- async_graph_data_flow-1.5.1/src/async_graph_data_flow.egg-info/requires.txt +0 -15
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/MANIFEST.in +0 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/setup.cfg +0 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/py.typed +0 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow.egg-info/SOURCES.txt +0 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow.egg-info/dependency_links.txt +0 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow.egg-info/not-zip-safe +0 -0
- {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow.egg-info/top_level.txt +0 -0
|
@@ -8,16 +8,43 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
|
|
|
8
8
|
## [Unreleased] - YYYY-MM-DD
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
|
-
|
|
12
11
|
### Changed
|
|
13
|
-
|
|
14
12
|
### Deprecated
|
|
13
|
+
### Removed
|
|
14
|
+
### Fixed
|
|
15
|
+
### Security
|
|
16
|
+
|
|
17
|
+
## [2.0.0] - 2026-04-21
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Python 3.14 is officially supported and tested on CI. (#16)
|
|
21
|
+
- `AsyncGraph.add_node` now accepts `functools.partial` objects and functions
|
|
22
|
+
decorated with `functools.wraps` as node functions. The default node name and
|
|
23
|
+
the async-generator function check resolve through the embedded function, so
|
|
24
|
+
neither `name=` nor `check_async_gen=False` is required in these cases. (#16)
|
|
25
|
+
- Added GitHub Actions for PyPI releases. (#16)
|
|
15
26
|
|
|
16
27
|
### Removed
|
|
28
|
+
- Removed the `queue_size` argument at `add_node`, deprecated in v1.6.0.
|
|
29
|
+
To configure queue size, pass a pre-sized `asyncio.Queue` via the `queue`
|
|
30
|
+
argument instead. (#16)
|
|
17
31
|
|
|
18
32
|
### Fixed
|
|
33
|
+
- Fixed a bug where tail items could be dropped when nodes were not added in
|
|
34
|
+
topological order. Queues are now drained in topological order. (#16)
|
|
35
|
+
|
|
36
|
+
## [1.6.0] - 2025-03-19
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
- Added support for custom edge behaviors, through the new `queue` argument at `add_node`
|
|
40
|
+
for a custom queue object. (#14)
|
|
41
|
+
|
|
42
|
+
### Deprecated
|
|
43
|
+
- Deprecated the `queue_size` argument at `add_node` -- use the new `queue` instead. (#14)
|
|
19
44
|
|
|
20
45
|
### Security
|
|
46
|
+
- Resolved CVE-2024-56326, CVE-2024-56201, and CVE-2025-27516
|
|
47
|
+
by updating jinja2 to v3.1.6 in requirements for Sphinx docs. (#14)
|
|
21
48
|
|
|
22
49
|
## [1.5.1] - 2024-11-07
|
|
23
50
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: async-graph-data-flow
|
|
3
|
-
Version:
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Data flow along a directed acyclic graph of asynchronous generator functions
|
|
5
5
|
Author-email: Civis Analytics <opensource@civisanalytics.com>
|
|
6
|
-
License: BSD
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
7
|
Project-URL: Documentation, https://async-graph-data-flow.readthedocs.io
|
|
8
8
|
Project-URL: Source, https://github.com/civisanalytics/async-graph-data-flow
|
|
9
9
|
Keywords: async,asynchronous,asyncio,data flow,graph
|
|
@@ -11,7 +11,6 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Intended Audience :: Information Technology
|
|
13
13
|
Classifier: Intended Audience :: Science/Research
|
|
14
|
-
Classifier: License :: OSI Approved :: BSD License
|
|
15
14
|
Classifier: Operating System :: OS Independent
|
|
16
15
|
Classifier: Programming Language :: Python :: 3
|
|
17
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -19,23 +18,25 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
22
|
Classifier: Framework :: AsyncIO
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE.txt
|
|
26
26
|
Provides-Extra: dev
|
|
27
|
-
Requires-Dist: black
|
|
28
|
-
Requires-Dist: flake8
|
|
29
|
-
Requires-Dist: pytest
|
|
30
|
-
Requires-Dist: mypy
|
|
31
|
-
Requires-Dist: build
|
|
32
|
-
Requires-Dist: twine
|
|
27
|
+
Requires-Dist: black; extra == "dev"
|
|
28
|
+
Requires-Dist: flake8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy; extra == "dev"
|
|
31
|
+
Requires-Dist: build; extra == "dev"
|
|
32
|
+
Requires-Dist: twine; extra == "dev"
|
|
33
33
|
Provides-Extra: docs
|
|
34
|
-
Requires-Dist: Sphinx
|
|
35
|
-
Requires-Dist: furo
|
|
36
|
-
Requires-Dist: numpydoc
|
|
37
|
-
Requires-Dist: sphinx-copybutton
|
|
38
|
-
Requires-Dist: sphinxcontrib-mermaid
|
|
34
|
+
Requires-Dist: Sphinx; extra == "docs"
|
|
35
|
+
Requires-Dist: furo; extra == "docs"
|
|
36
|
+
Requires-Dist: numpydoc; extra == "docs"
|
|
37
|
+
Requires-Dist: sphinx-copybutton; extra == "docs"
|
|
38
|
+
Requires-Dist: sphinxcontrib-mermaid; extra == "docs"
|
|
39
|
+
Dynamic: license-file
|
|
39
40
|
|
|
40
41
|
# async-graph-data-flow
|
|
41
42
|
|
|
@@ -59,25 +60,26 @@ BSD 3-Clause License. Please see `LICENSE.txt` in the GitHub source code for det
|
|
|
59
60
|
|
|
60
61
|
The latest code under development is available on GitHub at
|
|
61
62
|
https://github.com/civisanalytics/async-graph-data-flow.
|
|
62
|
-
To obtain this version for experimental features or for development
|
|
63
|
+
To obtain this version for experimental features or for development,
|
|
64
|
+
we recommend using [uv](https://docs.astral.sh/uv/) to manage the development environment:
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
67
|
git clone https://github.com/civisanalytics/async-graph-data-flow.git
|
|
66
68
|
cd async-graph-data-flow
|
|
67
|
-
pip install -e ".[dev]"
|
|
69
|
+
uv pip install -e ".[dev]"
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
To run tests and styling checks:
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
pytest
|
|
74
|
-
flake8 src tests examples
|
|
75
|
-
black --check src tests examples
|
|
75
|
+
uv run pytest
|
|
76
|
+
uv run flake8 src tests examples
|
|
77
|
+
uv run black --check src tests examples
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
## Building Documentation
|
|
79
81
|
|
|
80
|
-
We use the Sphinx framework. The documentation source files are in `docs
|
|
82
|
+
We use the Sphinx framework. The documentation source files are in `docs/`.
|
|
81
83
|
These files can be updated as necessary.
|
|
82
84
|
|
|
83
85
|
The public documentation is accessible at https://async-graph-data-flow.readthedocs.io.
|
|
@@ -88,5 +90,5 @@ The builds by the Read The Docs site generate the necessary files (the HTML page
|
|
|
88
90
|
for the public documentation. All these auto-generated files are explicitly not versioned (see `.gitignore`).
|
|
89
91
|
|
|
90
92
|
To build the documentation locally (for testing and development),
|
|
91
|
-
install the full doc-related dependencies: `pip install -r docs/requirements.txt`,
|
|
92
|
-
then run `sphinx-build -b html docs/ docs/build/`.
|
|
93
|
+
install the full doc-related dependencies: `uv pip install -r docs/requirements.txt`,
|
|
94
|
+
then run `uv run sphinx-build -b html docs/ docs/build/`.
|
|
@@ -20,25 +20,26 @@ BSD 3-Clause License. Please see `LICENSE.txt` in the GitHub source code for det
|
|
|
20
20
|
|
|
21
21
|
The latest code under development is available on GitHub at
|
|
22
22
|
https://github.com/civisanalytics/async-graph-data-flow.
|
|
23
|
-
To obtain this version for experimental features or for development
|
|
23
|
+
To obtain this version for experimental features or for development,
|
|
24
|
+
we recommend using [uv](https://docs.astral.sh/uv/) to manage the development environment:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
27
|
git clone https://github.com/civisanalytics/async-graph-data-flow.git
|
|
27
28
|
cd async-graph-data-flow
|
|
28
|
-
pip install -e ".[dev]"
|
|
29
|
+
uv pip install -e ".[dev]"
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
To run tests and styling checks:
|
|
32
33
|
|
|
33
34
|
```bash
|
|
34
|
-
pytest
|
|
35
|
-
flake8 src tests examples
|
|
36
|
-
black --check src tests examples
|
|
35
|
+
uv run pytest
|
|
36
|
+
uv run flake8 src tests examples
|
|
37
|
+
uv run black --check src tests examples
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
## Building Documentation
|
|
40
41
|
|
|
41
|
-
We use the Sphinx framework. The documentation source files are in `docs
|
|
42
|
+
We use the Sphinx framework. The documentation source files are in `docs/`.
|
|
42
43
|
These files can be updated as necessary.
|
|
43
44
|
|
|
44
45
|
The public documentation is accessible at https://async-graph-data-flow.readthedocs.io.
|
|
@@ -49,5 +50,5 @@ The builds by the Read The Docs site generate the necessary files (the HTML page
|
|
|
49
50
|
for the public documentation. All these auto-generated files are explicitly not versioned (see `.gitignore`).
|
|
50
51
|
|
|
51
52
|
To build the documentation locally (for testing and development),
|
|
52
|
-
install the full doc-related dependencies: `pip install -r docs/requirements.txt`,
|
|
53
|
-
then run `sphinx-build -b html docs/ docs/build/`.
|
|
53
|
+
install the full doc-related dependencies: `uv pip install -r docs/requirements.txt`,
|
|
54
|
+
then run `uv run sphinx-build -b html docs/ docs/build/`.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["setuptools >=
|
|
2
|
+
requires = ["setuptools >= 77.0.0", "wheel"]
|
|
3
3
|
build-backend = "setuptools.build_meta"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "async-graph-data-flow"
|
|
7
|
-
version = "
|
|
8
|
-
description = "
|
|
7
|
+
version = "2.0.0"
|
|
8
|
+
description = "Data flow along a directed acyclic graph of asynchronous generator functions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">= 3.10"
|
|
11
|
-
license =
|
|
11
|
+
license = "BSD-3-Clause"
|
|
12
12
|
authors = [ { name = "Civis Analytics", email = "opensource@civisanalytics.com" } ]
|
|
13
13
|
keywords = [
|
|
14
14
|
"async",
|
|
@@ -22,7 +22,6 @@ classifiers = [
|
|
|
22
22
|
"Intended Audience :: Developers",
|
|
23
23
|
"Intended Audience :: Information Technology",
|
|
24
24
|
"Intended Audience :: Science/Research",
|
|
25
|
-
"License :: OSI Approved :: BSD License",
|
|
26
25
|
"Operating System :: OS Independent",
|
|
27
26
|
"Programming Language :: Python :: 3",
|
|
28
27
|
"Programming Language :: Python :: 3 :: Only",
|
|
@@ -30,6 +29,7 @@ classifiers = [
|
|
|
30
29
|
"Programming Language :: Python :: 3.11",
|
|
31
30
|
"Programming Language :: Python :: 3.12",
|
|
32
31
|
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Programming Language :: Python :: 3.14",
|
|
33
33
|
"Framework :: AsyncIO",
|
|
34
34
|
]
|
|
35
35
|
|
|
@@ -40,21 +40,21 @@ Source = "https://github.com/civisanalytics/async-graph-data-flow"
|
|
|
40
40
|
[project.optional-dependencies]
|
|
41
41
|
dev = [
|
|
42
42
|
# Running tests and linters
|
|
43
|
-
"black
|
|
44
|
-
"flake8
|
|
45
|
-
"pytest
|
|
46
|
-
"mypy
|
|
43
|
+
"black",
|
|
44
|
+
"flake8",
|
|
45
|
+
"pytest",
|
|
46
|
+
"mypy",
|
|
47
47
|
|
|
48
48
|
# Managing source distributions
|
|
49
|
-
"build
|
|
50
|
-
"twine
|
|
49
|
+
"build",
|
|
50
|
+
"twine",
|
|
51
51
|
]
|
|
52
52
|
docs = [
|
|
53
|
-
"Sphinx
|
|
54
|
-
"furo
|
|
55
|
-
"numpydoc
|
|
56
|
-
"sphinx-copybutton
|
|
57
|
-
"sphinxcontrib-mermaid
|
|
53
|
+
"Sphinx",
|
|
54
|
+
"furo",
|
|
55
|
+
"numpydoc",
|
|
56
|
+
"sphinx-copybutton",
|
|
57
|
+
"sphinxcontrib-mermaid",
|
|
58
58
|
]
|
|
59
59
|
|
|
60
60
|
[tool.setuptools.packages.find]
|
{async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/executor.py
RENAMED
|
@@ -4,11 +4,11 @@ import logging
|
|
|
4
4
|
import time
|
|
5
5
|
import traceback
|
|
6
6
|
from collections import deque
|
|
7
|
-
from
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
from .graph import AsyncGraph, InvalidAsyncGraphError
|
|
10
11
|
|
|
11
|
-
|
|
12
12
|
_LOG = logging.getLogger(__name__)
|
|
13
13
|
|
|
14
14
|
_DEFAULT_DATA_FLOW_LOGGING_NODE_FORMAT = " {node} - in={in}, out={out}, err={err}"
|
|
@@ -132,20 +132,16 @@ class AsyncExecutor:
|
|
|
132
132
|
"""
|
|
133
133
|
self._data_flow_logging = True
|
|
134
134
|
|
|
135
|
-
if node_format
|
|
135
|
+
if node_format:
|
|
136
136
|
self._data_flow_logging_node_format = node_format
|
|
137
137
|
|
|
138
|
-
if
|
|
139
|
-
node_filter
|
|
140
|
-
and not isinstance(node_filter, str)
|
|
141
|
-
and all(map(lambda x: isinstance(x, str), node_filter))
|
|
142
|
-
):
|
|
138
|
+
if node_filter:
|
|
143
139
|
self._data_flow_logging_node_filter = set(node_filter)
|
|
144
140
|
|
|
145
|
-
if time_interval
|
|
141
|
+
if time_interval:
|
|
146
142
|
self._data_flow_logging_time_interval = time_interval
|
|
147
143
|
|
|
148
|
-
if logger
|
|
144
|
+
if logger:
|
|
149
145
|
self._logger = logger
|
|
150
146
|
|
|
151
147
|
def turn_off_data_flow_logging(self) -> None:
|
|
@@ -284,7 +280,10 @@ class AsyncExecutor:
|
|
|
284
280
|
self._exceptions = {}
|
|
285
281
|
|
|
286
282
|
for node_name, node in self._graph._nodes.items():
|
|
287
|
-
queue
|
|
283
|
+
if node.queue is None:
|
|
284
|
+
queue = asyncio.Queue()
|
|
285
|
+
else:
|
|
286
|
+
queue = node.queue
|
|
288
287
|
self._node_queues[node_name] = queue
|
|
289
288
|
self._data_flow_stats[node_name] = {"in": 0, "out": 0, "err": 0}
|
|
290
289
|
self._exceptions[node_name] = deque(maxlen=self._max_exceptions)
|
|
@@ -296,8 +295,8 @@ class AsyncExecutor:
|
|
|
296
295
|
|
|
297
296
|
await self._producer()
|
|
298
297
|
|
|
299
|
-
for
|
|
300
|
-
await
|
|
298
|
+
for node_name in self._graph._topological_sort():
|
|
299
|
+
await self._node_queues[node_name].join()
|
|
301
300
|
|
|
302
301
|
for task in self._consumer_tasks.values():
|
|
303
302
|
task.cancel()
|
|
@@ -329,7 +328,7 @@ class AsyncExecutor:
|
|
|
329
328
|
Each key in this dictionary is the name (str) of the node function,
|
|
330
329
|
and its corresponding value is the args (tuple)
|
|
331
330
|
(in which case the node function will be called as ``func(*args)``
|
|
332
|
-
-- provide ``
|
|
331
|
+
-- provide ``()`` (an empty tuple) if you want ``func()`` with no args).
|
|
333
332
|
If ``start_nodes`` is ``None`` or isn't provided,
|
|
334
333
|
nodes that have no incoming edges are treated as start nodes.
|
|
335
334
|
"""
|
{async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/graph.py
RENAMED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
1
3
|
import inspect
|
|
2
4
|
from collections import OrderedDict
|
|
3
|
-
from
|
|
5
|
+
from collections.abc import AsyncGenerator, Callable
|
|
6
|
+
from typing import Any, NamedTuple
|
|
4
7
|
|
|
5
8
|
|
|
6
9
|
class InvalidAsyncGraphError(Exception):
|
|
@@ -8,9 +11,9 @@ class InvalidAsyncGraphError(Exception):
|
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
class _Node(NamedTuple):
|
|
11
|
-
func: Callable
|
|
14
|
+
func: Callable[..., AsyncGenerator]
|
|
12
15
|
name: str
|
|
13
|
-
|
|
16
|
+
queue: asyncio.Queue | None
|
|
14
17
|
max_tasks: int
|
|
15
18
|
halt_on_exception: bool
|
|
16
19
|
unpack_input: bool
|
|
@@ -32,25 +35,32 @@ class AsyncGraph:
|
|
|
32
35
|
|
|
33
36
|
def add_node(
|
|
34
37
|
self,
|
|
35
|
-
func: Callable,
|
|
38
|
+
func: Callable[..., AsyncGenerator],
|
|
36
39
|
*,
|
|
37
40
|
name: str | None = None,
|
|
38
41
|
halt_on_exception: bool = False,
|
|
39
42
|
unpack_input: bool = True,
|
|
40
43
|
max_tasks: int = 1,
|
|
41
|
-
|
|
44
|
+
queue: asyncio.Queue | None = None,
|
|
42
45
|
check_async_gen: bool = True,
|
|
43
46
|
) -> None:
|
|
44
47
|
"""Add a node by providing its function and optional configurations.
|
|
45
48
|
|
|
46
49
|
Parameters
|
|
47
50
|
----------
|
|
48
|
-
func : Callable
|
|
49
|
-
The function that this node runs.
|
|
51
|
+
func : Callable[..., AsyncGenerator]
|
|
52
|
+
The asynchronous generator function that this node runs.
|
|
50
53
|
See notes below for the function's requirements.
|
|
51
54
|
name : str, optional
|
|
52
|
-
The name of this node. If not provided, the
|
|
53
|
-
``
|
|
55
|
+
The name of this node. If not provided, the name is taken from
|
|
56
|
+
the node function's ``__name__``, resolved as follows:
|
|
57
|
+
|
|
58
|
+
* If ``func`` is a :func:`functools.partial` object, use
|
|
59
|
+
``func.func.__name__``.
|
|
60
|
+
* If ``func`` has a ``__wrapped__`` attribute (because it was
|
|
61
|
+
decorated via :func:`functools.wraps`), use
|
|
62
|
+
``func.__wrapped__.__name__``.
|
|
63
|
+
* Otherwise, use ``func.__name__``.
|
|
54
64
|
halt_on_exception : bool, optional
|
|
55
65
|
To halt graph execution when this node has an unhandled exception,
|
|
56
66
|
set this argument to ``True``. Defaults to ``False``.
|
|
@@ -62,16 +72,21 @@ class AsyncGraph:
|
|
|
62
72
|
See notes below for more details.
|
|
63
73
|
max_tasks : int, optional
|
|
64
74
|
The number of tasks that this node runs concurrently.
|
|
65
|
-
|
|
66
|
-
The
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
queue : asyncio.Queue, optional
|
|
76
|
+
The queue object that collects items from this node's source nodes,
|
|
77
|
+
via ``await queue.put(item)``, and then feed into this node
|
|
78
|
+
with items retrieved by ``await queue.get()``.
|
|
79
|
+
This queue object must be an instance of either :class:`~asyncio.Queue` or
|
|
80
|
+
a subclass of :class:`~asyncio.Queue`.
|
|
81
|
+
If ``None`` or not given, it defaults to an unbounded ``asyncio.Queue()``.
|
|
69
82
|
check_async_gen : bool, optional
|
|
70
83
|
If ``True`` (the default), the callable ``func`` is verified to be an async
|
|
71
|
-
generator function by :func:`inspect.isasyncgenfunction`.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
84
|
+
generator function by :func:`inspect.isasyncgenfunction`. If ``func`` is a
|
|
85
|
+
:func:`functools.partial` object, or if it has a ``__wrapped__`` attribute
|
|
86
|
+
set by :func:`functools.wraps`, the embedded function is checked instead.
|
|
87
|
+
Pass in ``False`` to disable this check if ``func`` would still fail the
|
|
88
|
+
check after that unwrapping (e.g., your decorator doesn't use
|
|
89
|
+
:func:`functools.wraps`).
|
|
75
90
|
|
|
76
91
|
Notes
|
|
77
92
|
-----
|
|
@@ -124,38 +139,64 @@ class AsyncGraph:
|
|
|
124
139
|
own default values.
|
|
125
140
|
""" # noqa: E501
|
|
126
141
|
|
|
127
|
-
|
|
128
|
-
|
|
142
|
+
embedded_func = self._get_embedded_func(func)
|
|
143
|
+
name = name or embedded_func.__name__
|
|
144
|
+
if check_async_gen and not inspect.isasyncgenfunction(embedded_func):
|
|
129
145
|
raise TypeError(f"node '{name}' isn't an async generator function")
|
|
130
146
|
if name in self._nodes:
|
|
131
147
|
raise ValueError(f"node '{name}' already exists in the graph")
|
|
148
|
+
if queue is not None and not isinstance(queue, asyncio.Queue):
|
|
149
|
+
raise TypeError(f"queue must be an instance of asyncio.Queue: {queue}")
|
|
132
150
|
self._nodes[name] = _Node(
|
|
133
151
|
func=func,
|
|
134
152
|
name=name,
|
|
135
|
-
|
|
153
|
+
queue=queue,
|
|
136
154
|
max_tasks=max_tasks,
|
|
137
155
|
halt_on_exception=halt_on_exception,
|
|
138
156
|
unpack_input=unpack_input,
|
|
139
157
|
)
|
|
140
158
|
self._nodes_to_edges[name] = set()
|
|
141
159
|
|
|
142
|
-
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _get_embedded_func(
|
|
162
|
+
func: Callable[..., AsyncGenerator],
|
|
163
|
+
) -> Callable[..., AsyncGenerator]:
|
|
164
|
+
"""Resolve the underlying function for name and async-gen detection.
|
|
165
|
+
|
|
166
|
+
Repeatedly unwraps :func:`functools.partial` objects and callables with
|
|
167
|
+
a ``__wrapped__`` attribute (set by :func:`functools.wraps`), so that
|
|
168
|
+
stacked decorators and ``partial(wrapped_func)`` combinations resolve
|
|
169
|
+
to the innermost function.
|
|
170
|
+
"""
|
|
171
|
+
while True:
|
|
172
|
+
if isinstance(func, functools.partial):
|
|
173
|
+
func = func.func
|
|
174
|
+
elif hasattr(func, "__wrapped__"):
|
|
175
|
+
func = func.__wrapped__
|
|
176
|
+
else:
|
|
177
|
+
return func
|
|
178
|
+
|
|
179
|
+
def add_edge(
|
|
180
|
+
self,
|
|
181
|
+
src_node: str | Callable[..., AsyncGenerator],
|
|
182
|
+
dst_node: str | Callable[..., AsyncGenerator],
|
|
183
|
+
) -> None:
|
|
143
184
|
"""Add an edge.
|
|
144
185
|
|
|
145
186
|
Parameters
|
|
146
187
|
----------
|
|
147
|
-
src_node : str | Callable
|
|
188
|
+
src_node : str | Callable[..., AsyncGenerator]
|
|
148
189
|
The source node, either the function name or the function itself.
|
|
149
|
-
dst_node : str | Callable
|
|
190
|
+
dst_node : str | Callable[..., AsyncGenerator]
|
|
150
191
|
The destination node, either the function name or the function itself.
|
|
151
192
|
"""
|
|
152
193
|
if not isinstance(src_node, str):
|
|
153
|
-
src_node = src_node.__name__
|
|
194
|
+
src_node = self._get_embedded_func(src_node).__name__
|
|
154
195
|
if src_node not in self._nodes:
|
|
155
196
|
raise ValueError(f"src_node '{src_node}' not registered in the graph")
|
|
156
197
|
|
|
157
198
|
if not isinstance(dst_node, str):
|
|
158
|
-
dst_node = dst_node.__name__
|
|
199
|
+
dst_node = self._get_embedded_func(dst_node).__name__
|
|
159
200
|
if dst_node not in self._nodes:
|
|
160
201
|
raise ValueError(f"dst_node '{dst_node}' not registered in the graph")
|
|
161
202
|
|
|
@@ -188,29 +229,16 @@ class AsyncGraph:
|
|
|
188
229
|
i: int,
|
|
189
230
|
is_checked: list[bool],
|
|
190
231
|
iter_stack: list[bool],
|
|
191
|
-
|
|
232
|
+
nodes: list[str],
|
|
192
233
|
) -> bool:
|
|
193
234
|
"""Code based on: https://www.geeksforgeeks.org/detect-cycle-in-a-graph/"""
|
|
194
|
-
# Use the OrderedDict self._nodes_to_edges for its ordering.
|
|
195
|
-
nodes = list(self._nodes_to_edges.keys())
|
|
196
235
|
is_checked[i] = True
|
|
197
236
|
iter_stack[i] = True
|
|
198
237
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
except KeyError:
|
|
202
|
-
iter_stack[i] = False
|
|
203
|
-
return False
|
|
204
|
-
|
|
205
|
-
for dst_node in dst_nodes:
|
|
206
|
-
try:
|
|
207
|
-
edge_pos = nodes.index(dst_node)
|
|
208
|
-
except ValueError:
|
|
209
|
-
iter_stack[i] = False
|
|
210
|
-
return False
|
|
211
|
-
|
|
238
|
+
for dst_node in self._nodes_to_edges[nodes[i]]:
|
|
239
|
+
edge_pos = nodes.index(dst_node)
|
|
212
240
|
if not is_checked[edge_pos] and self._graph_validator(
|
|
213
|
-
edge_pos, is_checked, iter_stack,
|
|
241
|
+
edge_pos, is_checked, iter_stack, nodes
|
|
214
242
|
):
|
|
215
243
|
return True
|
|
216
244
|
elif iter_stack[edge_pos]:
|
|
@@ -220,24 +248,42 @@ class AsyncGraph:
|
|
|
220
248
|
return False
|
|
221
249
|
|
|
222
250
|
def _is_graph_cyclic(self) -> bool:
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
251
|
+
nodes = list(self._nodes_to_edges.keys())
|
|
252
|
+
is_checked = [False] * len(nodes)
|
|
253
|
+
iter_stack = [False] * len(nodes)
|
|
254
|
+
for i in range(len(nodes)):
|
|
226
255
|
if not is_checked[i] and self._graph_validator(
|
|
227
|
-
i, is_checked, iter_stack,
|
|
256
|
+
i, is_checked, iter_stack, nodes
|
|
228
257
|
):
|
|
229
258
|
return True
|
|
230
259
|
return False
|
|
231
260
|
|
|
261
|
+
def _topological_sort(self) -> list[str]:
|
|
262
|
+
"""Return node names in a topological order (sources before sinks).
|
|
263
|
+
|
|
264
|
+
Sibling order among nodes at the same depth is unspecified (the
|
|
265
|
+
underlying edge storage is a ``set``). Callers must not rely on it.
|
|
266
|
+
The graph is guaranteed acyclic by :meth:`add_edge`, so this always succeeds.
|
|
267
|
+
"""
|
|
268
|
+
indegree: dict[str, int] = {name: 0 for name in self._nodes}
|
|
269
|
+
for dst_nodes in self._nodes_to_edges.values():
|
|
270
|
+
for dst in dst_nodes:
|
|
271
|
+
indegree[dst] += 1
|
|
272
|
+
|
|
273
|
+
ready = [name for name in self._nodes if indegree[name] == 0]
|
|
274
|
+
ordered: list[str] = []
|
|
275
|
+
while ready:
|
|
276
|
+
name = ready.pop(0)
|
|
277
|
+
ordered.append(name)
|
|
278
|
+
for dst in self._nodes_to_edges[name]:
|
|
279
|
+
indegree[dst] -= 1
|
|
280
|
+
if indegree[dst] == 0:
|
|
281
|
+
ready.append(dst)
|
|
282
|
+
return ordered
|
|
283
|
+
|
|
232
284
|
def _get_start_nodes(self) -> set[str]:
|
|
233
|
-
|
|
234
|
-
for
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
root_node = False
|
|
239
|
-
break
|
|
240
|
-
|
|
241
|
-
if root_node:
|
|
242
|
-
root_nodes.add(src_node)
|
|
243
|
-
return root_nodes
|
|
285
|
+
indegree: dict[str, int] = {name: 0 for name in self._nodes}
|
|
286
|
+
for dst_nodes in self._nodes_to_edges.values():
|
|
287
|
+
for dst in dst_nodes:
|
|
288
|
+
indegree[dst] += 1
|
|
289
|
+
return {name for name, d in indegree.items() if d == 0}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: async-graph-data-flow
|
|
3
|
-
Version:
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Data flow along a directed acyclic graph of asynchronous generator functions
|
|
5
5
|
Author-email: Civis Analytics <opensource@civisanalytics.com>
|
|
6
|
-
License: BSD
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
7
|
Project-URL: Documentation, https://async-graph-data-flow.readthedocs.io
|
|
8
8
|
Project-URL: Source, https://github.com/civisanalytics/async-graph-data-flow
|
|
9
9
|
Keywords: async,asynchronous,asyncio,data flow,graph
|
|
@@ -11,7 +11,6 @@ Classifier: Development Status :: 5 - Production/Stable
|
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: Intended Audience :: Information Technology
|
|
13
13
|
Classifier: Intended Audience :: Science/Research
|
|
14
|
-
Classifier: License :: OSI Approved :: BSD License
|
|
15
14
|
Classifier: Operating System :: OS Independent
|
|
16
15
|
Classifier: Programming Language :: Python :: 3
|
|
17
16
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
@@ -19,23 +18,25 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
19
18
|
Classifier: Programming Language :: Python :: 3.11
|
|
20
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
21
20
|
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
22
|
Classifier: Framework :: AsyncIO
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Description-Content-Type: text/markdown
|
|
25
25
|
License-File: LICENSE.txt
|
|
26
26
|
Provides-Extra: dev
|
|
27
|
-
Requires-Dist: black
|
|
28
|
-
Requires-Dist: flake8
|
|
29
|
-
Requires-Dist: pytest
|
|
30
|
-
Requires-Dist: mypy
|
|
31
|
-
Requires-Dist: build
|
|
32
|
-
Requires-Dist: twine
|
|
27
|
+
Requires-Dist: black; extra == "dev"
|
|
28
|
+
Requires-Dist: flake8; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy; extra == "dev"
|
|
31
|
+
Requires-Dist: build; extra == "dev"
|
|
32
|
+
Requires-Dist: twine; extra == "dev"
|
|
33
33
|
Provides-Extra: docs
|
|
34
|
-
Requires-Dist: Sphinx
|
|
35
|
-
Requires-Dist: furo
|
|
36
|
-
Requires-Dist: numpydoc
|
|
37
|
-
Requires-Dist: sphinx-copybutton
|
|
38
|
-
Requires-Dist: sphinxcontrib-mermaid
|
|
34
|
+
Requires-Dist: Sphinx; extra == "docs"
|
|
35
|
+
Requires-Dist: furo; extra == "docs"
|
|
36
|
+
Requires-Dist: numpydoc; extra == "docs"
|
|
37
|
+
Requires-Dist: sphinx-copybutton; extra == "docs"
|
|
38
|
+
Requires-Dist: sphinxcontrib-mermaid; extra == "docs"
|
|
39
|
+
Dynamic: license-file
|
|
39
40
|
|
|
40
41
|
# async-graph-data-flow
|
|
41
42
|
|
|
@@ -59,25 +60,26 @@ BSD 3-Clause License. Please see `LICENSE.txt` in the GitHub source code for det
|
|
|
59
60
|
|
|
60
61
|
The latest code under development is available on GitHub at
|
|
61
62
|
https://github.com/civisanalytics/async-graph-data-flow.
|
|
62
|
-
To obtain this version for experimental features or for development
|
|
63
|
+
To obtain this version for experimental features or for development,
|
|
64
|
+
we recommend using [uv](https://docs.astral.sh/uv/) to manage the development environment:
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
67
|
git clone https://github.com/civisanalytics/async-graph-data-flow.git
|
|
66
68
|
cd async-graph-data-flow
|
|
67
|
-
pip install -e ".[dev]"
|
|
69
|
+
uv pip install -e ".[dev]"
|
|
68
70
|
```
|
|
69
71
|
|
|
70
72
|
To run tests and styling checks:
|
|
71
73
|
|
|
72
74
|
```bash
|
|
73
|
-
pytest
|
|
74
|
-
flake8 src tests examples
|
|
75
|
-
black --check src tests examples
|
|
75
|
+
uv run pytest
|
|
76
|
+
uv run flake8 src tests examples
|
|
77
|
+
uv run black --check src tests examples
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
## Building Documentation
|
|
79
81
|
|
|
80
|
-
We use the Sphinx framework. The documentation source files are in `docs
|
|
82
|
+
We use the Sphinx framework. The documentation source files are in `docs/`.
|
|
81
83
|
These files can be updated as necessary.
|
|
82
84
|
|
|
83
85
|
The public documentation is accessible at https://async-graph-data-flow.readthedocs.io.
|
|
@@ -88,5 +90,5 @@ The builds by the Read The Docs site generate the necessary files (the HTML page
|
|
|
88
90
|
for the public documentation. All these auto-generated files are explicitly not versioned (see `.gitignore`).
|
|
89
91
|
|
|
90
92
|
To build the documentation locally (for testing and development),
|
|
91
|
-
install the full doc-related dependencies: `pip install -r docs/requirements.txt`,
|
|
92
|
-
then run `sphinx-build -b html docs/ docs/build/`.
|
|
93
|
+
install the full doc-related dependencies: `uv pip install -r docs/requirements.txt`,
|
|
94
|
+
then run `uv run sphinx-build -b html docs/ docs/build/`.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import functools
|
|
2
3
|
import time
|
|
3
4
|
|
|
4
5
|
import pytest
|
|
@@ -75,6 +76,34 @@ def test_async_executor_linear():
|
|
|
75
76
|
assert etl_executor.data_flow_stats["load"].get("err") == 0
|
|
76
77
|
|
|
77
78
|
|
|
79
|
+
def test_async_executor_with_functools_partial_node():
|
|
80
|
+
async def transform(prefix, data):
|
|
81
|
+
yield f"{prefix}:{data}"
|
|
82
|
+
|
|
83
|
+
async def extract():
|
|
84
|
+
yield "hello"
|
|
85
|
+
yield "world"
|
|
86
|
+
|
|
87
|
+
collected: list[str] = []
|
|
88
|
+
|
|
89
|
+
async def load(data):
|
|
90
|
+
collected.append(data)
|
|
91
|
+
yield
|
|
92
|
+
|
|
93
|
+
partial_transform = functools.partial(transform, "greet")
|
|
94
|
+
|
|
95
|
+
etl_graph = AsyncGraph()
|
|
96
|
+
etl_graph.add_node(extract)
|
|
97
|
+
etl_graph.add_node(partial_transform)
|
|
98
|
+
etl_graph.add_node(load)
|
|
99
|
+
etl_graph.add_edge("extract", "transform")
|
|
100
|
+
etl_graph.add_edge("transform", "load")
|
|
101
|
+
|
|
102
|
+
AsyncExecutor(etl_graph).execute()
|
|
103
|
+
|
|
104
|
+
assert collected == ["greet:hello", "greet:world"]
|
|
105
|
+
|
|
106
|
+
|
|
78
107
|
def test_async_executor_linear_with_exception_with_halt():
|
|
79
108
|
async def extract():
|
|
80
109
|
yield "data1"
|
|
@@ -469,3 +498,72 @@ def test_graph_with_no_edges():
|
|
|
469
498
|
|
|
470
499
|
executor.execute(start_nodes={"node1": ("foo",)})
|
|
471
500
|
assert executor.start_nodes == {"node1": ("foo",)}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_diamond_dag_reverse_insertion_order():
|
|
504
|
+
"""Regression test for issue #15: tail items must be processed even when
|
|
505
|
+
nodes are added in reverse topological order (sink before sources)."""
|
|
506
|
+
sink_items: list[int] = []
|
|
507
|
+
|
|
508
|
+
async def src():
|
|
509
|
+
for i in range(4):
|
|
510
|
+
yield i
|
|
511
|
+
|
|
512
|
+
async def branch_a(x):
|
|
513
|
+
yield x
|
|
514
|
+
|
|
515
|
+
async def branch_b(x):
|
|
516
|
+
yield x + 100
|
|
517
|
+
|
|
518
|
+
async def sink(x):
|
|
519
|
+
sink_items.append(x)
|
|
520
|
+
yield
|
|
521
|
+
|
|
522
|
+
graph = AsyncGraph()
|
|
523
|
+
graph.add_node(sink, unpack_input=False)
|
|
524
|
+
graph.add_node(branch_b, unpack_input=False)
|
|
525
|
+
graph.add_node(branch_a, unpack_input=False)
|
|
526
|
+
graph.add_node(src, unpack_input=False)
|
|
527
|
+
|
|
528
|
+
graph.add_edge(src, branch_a)
|
|
529
|
+
graph.add_edge(src, branch_b)
|
|
530
|
+
graph.add_edge(branch_a, sink)
|
|
531
|
+
graph.add_edge(branch_b, sink)
|
|
532
|
+
|
|
533
|
+
AsyncExecutor(graph).execute()
|
|
534
|
+
|
|
535
|
+
assert sorted(sink_items) == [0, 1, 2, 3, 100, 101, 102, 103]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_diamond_dag_forward_insertion_order():
|
|
539
|
+
"""Symmetric sanity check: adding nodes in forward topological order still works."""
|
|
540
|
+
sink_items: list[int] = []
|
|
541
|
+
|
|
542
|
+
async def src():
|
|
543
|
+
for i in range(4):
|
|
544
|
+
yield i
|
|
545
|
+
|
|
546
|
+
async def branch_a(x):
|
|
547
|
+
yield x
|
|
548
|
+
|
|
549
|
+
async def branch_b(x):
|
|
550
|
+
yield x + 100
|
|
551
|
+
|
|
552
|
+
async def sink(x):
|
|
553
|
+
sink_items.append(x)
|
|
554
|
+
yield
|
|
555
|
+
|
|
556
|
+
graph = AsyncGraph()
|
|
557
|
+
graph.add_node(src, unpack_input=False)
|
|
558
|
+
graph.add_node(branch_a, unpack_input=False)
|
|
559
|
+
graph.add_node(branch_b, unpack_input=False)
|
|
560
|
+
graph.add_node(sink, unpack_input=False)
|
|
561
|
+
|
|
562
|
+
graph.add_edge(src, branch_a)
|
|
563
|
+
graph.add_edge(src, branch_b)
|
|
564
|
+
graph.add_edge(branch_a, sink)
|
|
565
|
+
graph.add_edge(branch_b, sink)
|
|
566
|
+
|
|
567
|
+
AsyncExecutor(graph).execute()
|
|
568
|
+
|
|
569
|
+
assert sorted(sink_items) == [0, 1, 2, 3, 100, 101, 102, 103]
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import functools
|
|
1
2
|
import inspect
|
|
2
3
|
from unittest import mock
|
|
3
4
|
|
|
@@ -64,6 +65,66 @@ class TestAsyncGraphAddNode:
|
|
|
64
65
|
)
|
|
65
66
|
AsyncGraph().add_node(some_func, check_async_gen=False)
|
|
66
67
|
|
|
68
|
+
def test_add_node_with_functools_partial(self):
|
|
69
|
+
async def producer(prefix, message):
|
|
70
|
+
yield f"{prefix}:{message}"
|
|
71
|
+
|
|
72
|
+
partial_producer = functools.partial(producer, "hello")
|
|
73
|
+
|
|
74
|
+
graph = AsyncGraph()
|
|
75
|
+
graph.add_node(partial_producer)
|
|
76
|
+
|
|
77
|
+
assert "producer" in graph._nodes
|
|
78
|
+
assert graph._nodes["producer"].name == "producer"
|
|
79
|
+
assert graph._nodes["producer"].func is partial_producer
|
|
80
|
+
|
|
81
|
+
def test_add_node_with_functools_partial_explicit_name(self):
|
|
82
|
+
async def producer(prefix, message):
|
|
83
|
+
yield f"{prefix}:{message}"
|
|
84
|
+
|
|
85
|
+
partial_producer = functools.partial(producer, "hello")
|
|
86
|
+
|
|
87
|
+
graph = AsyncGraph()
|
|
88
|
+
graph.add_node(partial_producer, name="custom")
|
|
89
|
+
|
|
90
|
+
assert "custom" in graph._nodes
|
|
91
|
+
assert "producer" not in graph._nodes
|
|
92
|
+
assert graph._nodes["custom"].func is partial_producer
|
|
93
|
+
|
|
94
|
+
def test_add_node_with_functools_partial_non_async_gen(self):
|
|
95
|
+
def not_async_gen(prefix, message):
|
|
96
|
+
return f"{prefix}:{message}"
|
|
97
|
+
|
|
98
|
+
partial_fn = functools.partial(not_async_gen, "hello")
|
|
99
|
+
|
|
100
|
+
with pytest.raises(TypeError) as excinfo:
|
|
101
|
+
AsyncGraph().add_node(partial_fn)
|
|
102
|
+
assert str(excinfo.value) == (
|
|
103
|
+
"node 'not_async_gen' isn't an async generator function"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def test_add_node_with_functools_wraps_decorator(self):
|
|
107
|
+
async def some_func():
|
|
108
|
+
yield "foo"
|
|
109
|
+
|
|
110
|
+
def some_decorator(func):
|
|
111
|
+
@functools.wraps(func)
|
|
112
|
+
def inner_func(*args, **kwargs):
|
|
113
|
+
return func(*args, **kwargs)
|
|
114
|
+
|
|
115
|
+
return inner_func
|
|
116
|
+
|
|
117
|
+
wrapped = some_decorator(some_func)
|
|
118
|
+
|
|
119
|
+
# The wrapper itself is not an async generator function, but because
|
|
120
|
+
# functools.wraps sets __wrapped__, add_node unwraps to the inner
|
|
121
|
+
# async generator and the check passes without check_async_gen=False.
|
|
122
|
+
assert not inspect.isasyncgenfunction(wrapped)
|
|
123
|
+
graph = AsyncGraph()
|
|
124
|
+
graph.add_node(wrapped)
|
|
125
|
+
assert "some_func" in graph._nodes
|
|
126
|
+
assert graph._nodes["some_func"].func is wrapped
|
|
127
|
+
|
|
67
128
|
def test_add_node_with_valid_node_args(self):
|
|
68
129
|
etl_graph = async_graph_without_nodes_mock()
|
|
69
130
|
assert len(etl_graph._nodes.keys()) == 0
|
|
@@ -84,7 +145,7 @@ class TestAsyncGraphAddNode:
|
|
|
84
145
|
"halt_on_exception": False,
|
|
85
146
|
"max_tasks": 1,
|
|
86
147
|
"name": "extract_node",
|
|
87
|
-
"
|
|
148
|
+
"queue": None,
|
|
88
149
|
"unpack_input": True,
|
|
89
150
|
},
|
|
90
151
|
{
|
|
@@ -92,7 +153,7 @@ class TestAsyncGraphAddNode:
|
|
|
92
153
|
"halt_on_exception": False,
|
|
93
154
|
"max_tasks": 1,
|
|
94
155
|
"name": "transform_node",
|
|
95
|
-
"
|
|
156
|
+
"queue": None,
|
|
96
157
|
"unpack_input": True,
|
|
97
158
|
},
|
|
98
159
|
{
|
|
@@ -100,7 +161,7 @@ class TestAsyncGraphAddNode:
|
|
|
100
161
|
"halt_on_exception": False,
|
|
101
162
|
"max_tasks": 1,
|
|
102
163
|
"name": "load_node",
|
|
103
|
-
"
|
|
164
|
+
"queue": None,
|
|
104
165
|
"unpack_input": True,
|
|
105
166
|
},
|
|
106
167
|
]
|
|
@@ -135,6 +196,40 @@ class TestAsyncGraphAddEdge:
|
|
|
135
196
|
assert len(etl_graph.edges) == 1
|
|
136
197
|
assert etl_graph.edges == {("extract_node", "transform_node")}
|
|
137
198
|
|
|
199
|
+
def test_add_edge_with_functools_partial_callable(self):
|
|
200
|
+
async def producer(prefix, message):
|
|
201
|
+
yield f"{prefix}:{message}"
|
|
202
|
+
|
|
203
|
+
async def consumer(message):
|
|
204
|
+
yield message
|
|
205
|
+
|
|
206
|
+
partial_producer = functools.partial(producer, "hello")
|
|
207
|
+
|
|
208
|
+
graph = AsyncGraph()
|
|
209
|
+
graph.add_node(partial_producer)
|
|
210
|
+
graph.add_node(consumer)
|
|
211
|
+
graph.add_edge(src_node=partial_producer, dst_node=consumer)
|
|
212
|
+
|
|
213
|
+
assert graph.edges == {("producer", "consumer")}
|
|
214
|
+
|
|
215
|
+
def test_add_node_unwraps_stacked_wrappers(self):
|
|
216
|
+
async def some_func():
|
|
217
|
+
yield "foo"
|
|
218
|
+
|
|
219
|
+
def outer(func):
|
|
220
|
+
@functools.wraps(func)
|
|
221
|
+
def inner(*args, **kwargs):
|
|
222
|
+
return func(*args, **kwargs)
|
|
223
|
+
|
|
224
|
+
return inner
|
|
225
|
+
|
|
226
|
+
doubly_wrapped = outer(outer(some_func))
|
|
227
|
+
partial_over_wrapped = functools.partial(doubly_wrapped)
|
|
228
|
+
|
|
229
|
+
graph = AsyncGraph()
|
|
230
|
+
graph.add_node(partial_over_wrapped)
|
|
231
|
+
assert "some_func" in graph._nodes
|
|
232
|
+
|
|
138
233
|
def test_add_edge_graph_acyclic(self):
|
|
139
234
|
etl_graph = async_graph_with_nodes_mock()
|
|
140
235
|
|
|
File without changes
|
|
File without changes
|
{async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|