async-graph-data-flow 1.6.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/CHANGELOG.md +30 -4
  2. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/LICENSE.txt +1 -1
  3. {async_graph_data_flow-1.6.0/src/async_graph_data_flow.egg-info → async_graph_data_flow-2.1.0}/PKG-INFO +27 -25
  4. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/README.md +8 -7
  5. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/pyproject.toml +18 -18
  6. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow/__init__.py +0 -1
  7. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow/executor.py +8 -13
  8. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow/graph.py +78 -56
  9. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0/src/async_graph_data_flow.egg-info}/PKG-INFO +27 -25
  10. async_graph_data_flow-2.1.0/src/async_graph_data_flow.egg-info/requires.txt +15 -0
  11. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/tests/test_executor.py +98 -0
  12. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/tests/test_graph.py +95 -3
  13. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/tests/test_version_number.py +0 -1
  14. async_graph_data_flow-1.6.0/src/async_graph_data_flow.egg-info/requires.txt +0 -15
  15. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/MANIFEST.in +0 -0
  16. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/setup.cfg +0 -0
  17. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow/py.typed +0 -0
  18. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow.egg-info/SOURCES.txt +0 -0
  19. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow.egg-info/dependency_links.txt +0 -0
  20. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow.egg-info/not-zip-safe +0 -0
  21. {async_graph_data_flow-1.6.0 → async_graph_data_flow-2.1.0}/src/async_graph_data_flow.egg-info/top_level.txt +0 -0
@@ -8,16 +8,42 @@ 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
15
-
16
13
  ### Removed
17
-
18
14
  ### Fixed
15
+ ### Security
16
+
17
+ ## [2.1.0] - 2026-11-02
18
+
19
+ ### Added
20
+ - Python 3.15 is officially supported and tested on CI. (#18)
21
+
22
+ ### Removed
23
+ - Dropped support for Python 3.10. (#18)
19
24
 
20
25
  ### Security
26
+ - Resolved GHSA-6q48-6c7w-9gw2
27
+ by updating certifi to v2026.10.23 in requirements for Sphinx docs. (#17)
28
+
29
+ ## [2.0.0] - 2026-04-21
30
+
31
+ ### Added
32
+ - Python 3.14 is officially supported and tested on CI. (#16)
33
+ - `AsyncGraph.add_node` now accepts `functools.partial` objects and functions
34
+ decorated with `functools.wraps` as node functions. The default node name and
35
+ the async-generator function check resolve through the embedded function, so
36
+ neither `name=` nor `check_async_gen=False` is required in these cases. (#16)
37
+ - Added GitHub Actions for PyPI releases. (#16)
38
+
39
+ ### Removed
40
+ - Removed the `queue_size` argument at `add_node`, deprecated in v1.6.0.
41
+ To configure queue size, pass a pre-sized `asyncio.Queue` via the `queue`
42
+ argument instead. (#16)
43
+
44
+ ### Fixed
45
+ - Fixed a bug where tail items could be dropped when nodes were not added in
46
+ topological order. Queues are now drained in topological order. (#16)
21
47
 
22
48
  ## [1.6.0] - 2025-03-19
23
49
 
@@ -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.2
1
+ Metadata-Version: 2.4
2
2
  Name: async-graph-data-flow
3
- Version: 1.6.0
4
- Summary: Asynchronous functions that pass data along a directed acyclic graph
3
+ Version: 2.1.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,31 +11,32 @@ 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
18
- Classifier: Programming Language :: Python :: 3.10
19
17
  Classifier: Programming Language :: Python :: 3.11
20
18
  Classifier: Programming Language :: Python :: 3.12
21
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
22
22
  Classifier: Framework :: AsyncIO
23
- Requires-Python: >=3.10
23
+ Requires-Python: >=3.11
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE.txt
26
26
  Provides-Extra: dev
27
- Requires-Dist: black==25.1.0; extra == "dev"
28
- Requires-Dist: flake8==7.1.2; extra == "dev"
29
- Requires-Dist: pytest==8.3.5; extra == "dev"
30
- Requires-Dist: mypy==1.15.0; extra == "dev"
31
- Requires-Dist: build==1.2.2; extra == "dev"
32
- Requires-Dist: twine==6.1.0; 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.2.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,20 +60,21 @@ 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
@@ -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,20 +20,21 @@ 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
@@ -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.6.0"
8
- description = "Asynchronous functions that pass data along a directed acyclic graph"
7
+ version = "2.1.0"
8
+ description = "Data flow along a directed acyclic graph of asynchronous generator functions"
9
9
  readme = "README.md"
10
- requires-python = ">= 3.10"
11
- license = { text = "BSD 3-Clause" }
10
+ requires-python = ">= 3.11"
11
+ license = "BSD-3-Clause"
12
12
  authors = [ { name = "Civis Analytics", email = "opensource@civisanalytics.com" } ]
13
13
  keywords = [
14
14
  "async",
@@ -22,14 +22,14 @@ 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",
29
- "Programming Language :: Python :: 3.10",
30
28
  "Programming Language :: Python :: 3.11",
31
29
  "Programming Language :: Python :: 3.12",
32
30
  "Programming Language :: Python :: 3.13",
31
+ "Programming Language :: Python :: 3.14",
32
+ "Programming Language :: Python :: 3.15",
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 == 25.1.0",
44
- "flake8 == 7.1.2",
45
- "pytest == 8.3.5",
46
- "mypy == 1.15.0",
43
+ "black",
44
+ "flake8",
45
+ "pytest",
46
+ "mypy",
47
47
 
48
48
  # Managing source distributions
49
- "build == 1.2.2",
50
- "twine == 6.1.0",
49
+ "build",
50
+ "twine",
51
51
  ]
52
52
  docs = [
53
- "Sphinx == 8.2.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"]
@@ -9,7 +9,6 @@ from typing import Any
9
9
 
10
10
  from .graph import AsyncGraph, InvalidAsyncGraphError
11
11
 
12
-
13
12
  _LOG = logging.getLogger(__name__)
14
13
 
15
14
  _DEFAULT_DATA_FLOW_LOGGING_NODE_FORMAT = " {node} - in={in}, out={out}, err={err}"
@@ -133,20 +132,16 @@ class AsyncExecutor:
133
132
  """
134
133
  self._data_flow_logging = True
135
134
 
136
- if node_format and isinstance(node_format, str):
135
+ if node_format:
137
136
  self._data_flow_logging_node_format = node_format
138
137
 
139
- if (
140
- node_filter
141
- and not isinstance(node_filter, str)
142
- and all(map(lambda x: isinstance(x, str), node_filter))
143
- ):
138
+ if node_filter:
144
139
  self._data_flow_logging_node_filter = set(node_filter)
145
140
 
146
- if time_interval and isinstance(time_interval, int):
141
+ if time_interval:
147
142
  self._data_flow_logging_time_interval = time_interval
148
143
 
149
- if logger and isinstance(logger, logging.Logger):
144
+ if logger:
150
145
  self._logger = logger
151
146
 
152
147
  def turn_off_data_flow_logging(self) -> None:
@@ -286,7 +281,7 @@ class AsyncExecutor:
286
281
 
287
282
  for node_name, node in self._graph._nodes.items():
288
283
  if node.queue is None:
289
- queue = asyncio.Queue(maxsize=node.queue_size)
284
+ queue = asyncio.Queue()
290
285
  else:
291
286
  queue = node.queue
292
287
  self._node_queues[node_name] = queue
@@ -300,8 +295,8 @@ class AsyncExecutor:
300
295
 
301
296
  await self._producer()
302
297
 
303
- for queue in self._node_queues.values():
304
- await queue.join()
298
+ for node_name in self._graph._topological_sort():
299
+ await self._node_queues[node_name].join()
305
300
 
306
301
  for task in self._consumer_tasks.values():
307
302
  task.cancel()
@@ -333,7 +328,7 @@ class AsyncExecutor:
333
328
  Each key in this dictionary is the name (str) of the node function,
334
329
  and its corresponding value is the args (tuple)
335
330
  (in which case the node function will be called as ``func(*args)``
336
- -- provide ``None`` if you want ``func()`` with no args).
331
+ -- provide ``()`` (an empty tuple) if you want ``func()`` with no args).
337
332
  If ``start_nodes`` is ``None`` or isn't provided,
338
333
  nodes that have no incoming edges are treated as start nodes.
339
334
  """
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import functools
2
3
  import inspect
3
4
  from collections import OrderedDict
4
5
  from collections.abc import AsyncGenerator, Callable
@@ -13,7 +14,6 @@ class _Node(NamedTuple):
13
14
  func: Callable[..., AsyncGenerator]
14
15
  name: str
15
16
  queue: asyncio.Queue | None
16
- queue_size: int
17
17
  max_tasks: int
18
18
  halt_on_exception: bool
19
19
  unpack_input: bool
@@ -42,7 +42,6 @@ class AsyncGraph:
42
42
  unpack_input: bool = True,
43
43
  max_tasks: int = 1,
44
44
  queue: asyncio.Queue | None = None,
45
- queue_size: int = 10_000,
46
45
  check_async_gen: bool = True,
47
46
  ) -> None:
48
47
  """Add a node by providing its function and optional configurations.
@@ -53,8 +52,15 @@ class AsyncGraph:
53
52
  The asynchronous generator function that this node runs.
54
53
  See notes below for the function's requirements.
55
54
  name : str, optional
56
- The name of this node. If not provided, the ``__name__`` attribute
57
- ``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__``.
58
64
  halt_on_exception : bool, optional
59
65
  To halt graph execution when this node has an unhandled exception,
60
66
  set this argument to ``True``. Defaults to ``False``.
@@ -72,23 +78,15 @@ class AsyncGraph:
72
78
  with items retrieved by ``await queue.get()``.
73
79
  This queue object must be an instance of either :class:`~asyncio.Queue` or
74
80
  a subclass of :class:`~asyncio.Queue`.
75
- If ``None`` or not given, it defaults to an ``asyncio.Queue()`` with max
76
- size set by ``queue_size``.
77
- queue_size : int, optional
78
- The maximum number of data items allowed to be
79
- in the queue object between this node as a destination node
80
- and its source node(s).
81
-
82
- .. deprecated:: 1.6.0
83
- The argument ``queue_size`` is deprecated and will be removed in
84
- v2.0.0. To configure the queue size, please use the argument ``queue``
85
- for a queue object whose queue size is set.
81
+ If ``None`` or not given, it defaults to an unbounded ``asyncio.Queue()``.
86
82
  check_async_gen : bool, optional
87
83
  If ``True`` (the default), the callable ``func`` is verified to be an async
88
- generator function by :func:`inspect.isasyncgenfunction`.
89
- Pass in ``False`` to disable this check if ``func`` would fail the check
90
- while the callable under the hood is still an async generator function
91
- (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`).
92
90
 
93
91
  Notes
94
92
  -----
@@ -141,8 +139,9 @@ class AsyncGraph:
141
139
  own default values.
142
140
  """ # noqa: E501
143
141
 
144
- name = name or func.__name__
145
- 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):
146
145
  raise TypeError(f"node '{name}' isn't an async generator function")
147
146
  if name in self._nodes:
148
147
  raise ValueError(f"node '{name}' already exists in the graph")
@@ -152,13 +151,31 @@ class AsyncGraph:
152
151
  func=func,
153
152
  name=name,
154
153
  queue=queue,
155
- queue_size=queue_size,
156
154
  max_tasks=max_tasks,
157
155
  halt_on_exception=halt_on_exception,
158
156
  unpack_input=unpack_input,
159
157
  )
160
158
  self._nodes_to_edges[name] = set()
161
159
 
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
+
162
179
  def add_edge(
163
180
  self,
164
181
  src_node: str | Callable[..., AsyncGenerator],
@@ -174,12 +191,12 @@ class AsyncGraph:
174
191
  The destination node, either the function name or the function itself.
175
192
  """
176
193
  if not isinstance(src_node, str):
177
- src_node = src_node.__name__
194
+ src_node = self._get_embedded_func(src_node).__name__
178
195
  if src_node not in self._nodes:
179
196
  raise ValueError(f"src_node '{src_node}' not registered in the graph")
180
197
 
181
198
  if not isinstance(dst_node, str):
182
- dst_node = dst_node.__name__
199
+ dst_node = self._get_embedded_func(dst_node).__name__
183
200
  if dst_node not in self._nodes:
184
201
  raise ValueError(f"dst_node '{dst_node}' not registered in the graph")
185
202
 
@@ -212,29 +229,16 @@ class AsyncGraph:
212
229
  i: int,
213
230
  is_checked: list[bool],
214
231
  iter_stack: list[bool],
215
- graph: dict[str, set[str]],
232
+ nodes: list[str],
216
233
  ) -> bool:
217
234
  """Code based on: https://www.geeksforgeeks.org/detect-cycle-in-a-graph/"""
218
- # Use the OrderedDict self._nodes_to_edges for its ordering.
219
- nodes = list(self._nodes_to_edges.keys())
220
235
  is_checked[i] = True
221
236
  iter_stack[i] = True
222
237
 
223
- try:
224
- dst_nodes = graph[nodes[i]]
225
- except KeyError:
226
- iter_stack[i] = False
227
- return False
228
-
229
- for dst_node in dst_nodes:
230
- try:
231
- edge_pos = nodes.index(dst_node)
232
- except ValueError:
233
- iter_stack[i] = False
234
- return False
235
-
238
+ for dst_node in self._nodes_to_edges[nodes[i]]:
239
+ edge_pos = nodes.index(dst_node)
236
240
  if not is_checked[edge_pos] and self._graph_validator(
237
- edge_pos, is_checked, iter_stack, graph
241
+ edge_pos, is_checked, iter_stack, nodes
238
242
  ):
239
243
  return True
240
244
  elif iter_stack[edge_pos]:
@@ -244,24 +248,42 @@ class AsyncGraph:
244
248
  return False
245
249
 
246
250
  def _is_graph_cyclic(self) -> bool:
247
- """Code based on: https://www.geeksforgeeks.org/detect-cycle-in-a-graph/"""
248
- iter_stack = is_checked = [False] * (len(self._nodes) + 1)
249
- 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)):
250
255
  if not is_checked[i] and self._graph_validator(
251
- i, is_checked, iter_stack, self._nodes_to_edges
256
+ i, is_checked, iter_stack, nodes
252
257
  ):
253
258
  return True
254
259
  return False
255
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
+
256
284
  def _get_start_nodes(self) -> set[str]:
257
- root_nodes = set()
258
- for src_node in self._nodes_to_edges.keys():
259
- root_node = True
260
- for dst_nodes in self._nodes_to_edges.values():
261
- if src_node in dst_nodes:
262
- root_node = False
263
- break
264
-
265
- if root_node:
266
- root_nodes.add(src_node)
267
- 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.2
1
+ Metadata-Version: 2.4
2
2
  Name: async-graph-data-flow
3
- Version: 1.6.0
4
- Summary: Asynchronous functions that pass data along a directed acyclic graph
3
+ Version: 2.1.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,31 +11,32 @@ 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
18
- Classifier: Programming Language :: Python :: 3.10
19
17
  Classifier: Programming Language :: Python :: 3.11
20
18
  Classifier: Programming Language :: Python :: 3.12
21
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
22
22
  Classifier: Framework :: AsyncIO
23
- Requires-Python: >=3.10
23
+ Requires-Python: >=3.11
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE.txt
26
26
  Provides-Extra: dev
27
- Requires-Dist: black==25.1.0; extra == "dev"
28
- Requires-Dist: flake8==7.1.2; extra == "dev"
29
- Requires-Dist: pytest==8.3.5; extra == "dev"
30
- Requires-Dist: mypy==1.15.0; extra == "dev"
31
- Requires-Dist: build==1.2.2; extra == "dev"
32
- Requires-Dist: twine==6.1.0; 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.2.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,20 +60,21 @@ 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
@@ -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
@@ -85,7 +146,6 @@ class TestAsyncGraphAddNode:
85
146
  "max_tasks": 1,
86
147
  "name": "extract_node",
87
148
  "queue": None,
88
- "queue_size": 10_000,
89
149
  "unpack_input": True,
90
150
  },
91
151
  {
@@ -94,7 +154,6 @@ class TestAsyncGraphAddNode:
94
154
  "max_tasks": 1,
95
155
  "name": "transform_node",
96
156
  "queue": None,
97
- "queue_size": 10_000,
98
157
  "unpack_input": True,
99
158
  },
100
159
  {
@@ -103,7 +162,6 @@ class TestAsyncGraphAddNode:
103
162
  "max_tasks": 1,
104
163
  "name": "load_node",
105
164
  "queue": None,
106
- "queue_size": 10_000,
107
165
  "unpack_input": True,
108
166
  },
109
167
  ]
@@ -138,6 +196,40 @@ class TestAsyncGraphAddEdge:
138
196
  assert len(etl_graph.edges) == 1
139
197
  assert etl_graph.edges == {("extract_node", "transform_node")}
140
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
+
141
233
  def test_add_edge_graph_acyclic(self):
142
234
  etl_graph = async_graph_with_nodes_mock()
143
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==25.1.0
4
- flake8==7.1.2
5
- pytest==8.3.5
6
- mypy==1.15.0
7
- build==1.2.2
8
- twine==6.1.0
9
-
10
- [docs]
11
- Sphinx==8.2.3
12
- furo==2024.8.6
13
- numpydoc==1.8.0
14
- sphinx-copybutton==0.5.2
15
- sphinxcontrib-mermaid==1.0.0