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.
Files changed (21) hide show
  1. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/CHANGELOG.md +29 -2
  2. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/LICENSE.txt +1 -1
  3. {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
  4. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/README.md +9 -8
  5. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/pyproject.toml +16 -16
  6. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/__init__.py +0 -1
  7. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/executor.py +13 -14
  8. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/graph.py +103 -57
  9. {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
  10. async_graph_data_flow-2.0.0/src/async_graph_data_flow.egg-info/requires.txt +15 -0
  11. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/tests/test_executor.py +98 -0
  12. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/tests/test_graph.py +98 -3
  13. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/tests/test_version_number.py +0 -1
  14. async_graph_data_flow-1.5.1/src/async_graph_data_flow.egg-info/requires.txt +0 -15
  15. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/MANIFEST.in +0 -0
  16. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/setup.cfg +0 -0
  17. {async_graph_data_flow-1.5.1 → async_graph_data_flow-2.0.0}/src/async_graph_data_flow/py.typed +0 -0
  18. {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
  19. {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
  20. {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
  21. {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,4 +1,4 @@
1
- Copyright (c) 2022-2024, Civis Analytics
1
+ Copyright (c) 2022-2026, Civis Analytics
2
2
  All rights reserved.
3
3
 
4
4
  Redistribution and use in source and binary forms, with or without
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: async-graph-data-flow
3
- Version: 1.5.1
4
- Summary: Asynchronous functions that pass data along a directed acyclic graph
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 3-Clause
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==24.10.0; extra == "dev"
28
- Requires-Dist: flake8==7.1.1; extra == "dev"
29
- Requires-Dist: pytest==8.3.3; extra == "dev"
30
- Requires-Dist: mypy==1.13.0; extra == "dev"
31
- Requires-Dist: build==1.2.2; extra == "dev"
32
- Requires-Dist: twine==5.1.1; extra == "dev"
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==8.1.3; extra == "docs"
35
- Requires-Dist: furo==2024.8.6; extra == "docs"
36
- Requires-Dist: numpydoc==1.8.0; extra == "docs"
37
- Requires-Dist: sphinx-copybutton==0.5.2; extra == "docs"
38
- Requires-Dist: sphinxcontrib-mermaid==1.0.0; extra == "docs"
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/source/`.
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/source/`.
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 >= 69.0.0", "wheel"]
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 = "1.5.1"
8
- description = "Asynchronous functions that pass data along a directed acyclic graph"
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 = { text = "BSD 3-Clause" }
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 == 24.10.0",
44
- "flake8 == 7.1.1",
45
- "pytest == 8.3.3",
46
- "mypy == 1.13.0",
43
+ "black",
44
+ "flake8",
45
+ "pytest",
46
+ "mypy",
47
47
 
48
48
  # Managing source distributions
49
- "build == 1.2.2",
50
- "twine == 5.1.1",
49
+ "build",
50
+ "twine",
51
51
  ]
52
52
  docs = [
53
- "Sphinx == 8.1.3",
54
- "furo == 2024.8.6",
55
- "numpydoc == 1.8.0",
56
- "sphinx-copybutton == 0.5.2",
57
- "sphinxcontrib-mermaid == 1.0.0",
53
+ "Sphinx",
54
+ "furo",
55
+ "numpydoc",
56
+ "sphinx-copybutton",
57
+ "sphinxcontrib-mermaid",
58
58
  ]
59
59
 
60
60
  [tool.setuptools.packages.find]
@@ -3,6 +3,5 @@ from importlib.metadata import version
3
3
  from .executor import AsyncExecutor
4
4
  from .graph import AsyncGraph
5
5
 
6
-
7
6
  __version__ = version("async-graph-data-flow")
8
7
  __all__ = ["AsyncGraph", "AsyncExecutor"]
@@ -4,11 +4,11 @@ import logging
4
4
  import time
5
5
  import traceback
6
6
  from collections import deque
7
- from typing import Any, Iterable
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 and isinstance(node_format, str):
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 and isinstance(time_interval, int):
141
+ if time_interval:
146
142
  self._data_flow_logging_time_interval = time_interval
147
143
 
148
- if logger and isinstance(logger, logging.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 = asyncio.Queue(maxsize=node.queue_size)
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 queue in self._node_queues.values():
300
- await queue.join()
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 ``None`` if you want ``func()`` with no args).
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
  """
@@ -1,6 +1,9 @@
1
+ import asyncio
2
+ import functools
1
3
  import inspect
2
4
  from collections import OrderedDict
3
- from typing import Any, Callable, NamedTuple
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
- queue_size: int
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
- queue_size: int = 10_000,
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 ``__name__`` attribute
53
- ``func`` is used.
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
- queue_size : int, optional
66
- The maximum number of data items allowed to be
67
- in the :class:`~asyncio.Queue` object between this node as a source
68
- and its destination node(s).
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
- Pass in ``False`` to disable this check if ``func`` would fail the check
73
- while the callable under the hood is still an async generator function
74
- (e.g., your function is wrapped by a decorator).
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
- name = name or func.__name__
128
- if check_async_gen and not inspect.isasyncgenfunction(func):
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
- queue_size=queue_size,
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
- def add_edge(self, src_node: str | Callable, dst_node: str | Callable) -> None:
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
- graph: dict[str, set[str]],
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
- try:
200
- dst_nodes = graph[nodes[i]]
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, graph
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
- """Code based on: https://www.geeksforgeeks.org/detect-cycle-in-a-graph/"""
224
- iter_stack = is_checked = [False] * (len(self._nodes) + 1)
225
- for i in range(len(self._nodes)):
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, self._nodes_to_edges
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
- root_nodes = set()
234
- for src_node in self._nodes_to_edges.keys():
235
- root_node = True
236
- for dst_nodes in self._nodes_to_edges.values():
237
- if src_node in dst_nodes:
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
1
+ Metadata-Version: 2.4
2
2
  Name: async-graph-data-flow
3
- Version: 1.5.1
4
- Summary: Asynchronous functions that pass data along a directed acyclic graph
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 3-Clause
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==24.10.0; extra == "dev"
28
- Requires-Dist: flake8==7.1.1; extra == "dev"
29
- Requires-Dist: pytest==8.3.3; extra == "dev"
30
- Requires-Dist: mypy==1.13.0; extra == "dev"
31
- Requires-Dist: build==1.2.2; extra == "dev"
32
- Requires-Dist: twine==5.1.1; extra == "dev"
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==8.1.3; extra == "docs"
35
- Requires-Dist: furo==2024.8.6; extra == "docs"
36
- Requires-Dist: numpydoc==1.8.0; extra == "docs"
37
- Requires-Dist: sphinx-copybutton==0.5.2; extra == "docs"
38
- Requires-Dist: sphinxcontrib-mermaid==1.0.0; extra == "docs"
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/source/`.
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/`.
@@ -0,0 +1,15 @@
1
+
2
+ [dev]
3
+ black
4
+ flake8
5
+ pytest
6
+ mypy
7
+ build
8
+ twine
9
+
10
+ [docs]
11
+ Sphinx
12
+ furo
13
+ numpydoc
14
+ sphinx-copybutton
15
+ sphinxcontrib-mermaid
@@ -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
- "queue_size": 10_000,
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
- "queue_size": 10_000,
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
- "queue_size": 10_000,
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
 
@@ -3,7 +3,6 @@ import re
3
3
 
4
4
  import async_graph_data_flow
5
5
 
6
-
7
6
  REPO_DIR = os.path.dirname(os.path.dirname(__file__))
8
7
 
9
8
 
@@ -1,15 +0,0 @@
1
-
2
- [dev]
3
- black==24.10.0
4
- flake8==7.1.1
5
- pytest==8.3.3
6
- mypy==1.13.0
7
- build==1.2.2
8
- twine==5.1.1
9
-
10
- [docs]
11
- Sphinx==8.1.3
12
- furo==2024.8.6
13
- numpydoc==1.8.0
14
- sphinx-copybutton==0.5.2
15
- sphinxcontrib-mermaid==1.0.0