frequenz-dispatch 0.4.0__tar.gz → 0.5.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 (22) hide show
  1. {frequenz-dispatch-0.4.0/src/frequenz_dispatch.egg-info → frequenz_dispatch-0.5.0}/PKG-INFO +63 -26
  2. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/README.md +24 -24
  3. frequenz_dispatch-0.5.0/RELEASE_NOTES.md +18 -0
  4. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/pyproject.toml +17 -16
  5. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/__init__.py +1 -2
  6. frequenz_dispatch-0.5.0/src/frequenz/dispatch/_dispatch.py +71 -0
  7. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/_dispatcher.py +24 -28
  8. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/_managing_actor.py +22 -22
  9. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/actor.py +10 -12
  10. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0/src/frequenz_dispatch.egg-info}/PKG-INFO +63 -26
  11. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz_dispatch.egg-info/requires.txt +10 -12
  12. frequenz-dispatch-0.4.0/RELEASE_NOTES.md +0 -21
  13. frequenz-dispatch-0.4.0/src/frequenz/dispatch/_dispatch.py +0 -272
  14. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/LICENSE +0 -0
  15. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/MANIFEST.in +0 -0
  16. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/setup.cfg +0 -0
  17. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/_event.py +0 -0
  18. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/conftest.py +0 -0
  19. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz/dispatch/py.typed +0 -0
  20. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz_dispatch.egg-info/SOURCES.txt +0 -0
  21. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz_dispatch.egg-info/dependency_links.txt +0 -0
  22. {frequenz-dispatch-0.4.0 → frequenz_dispatch-0.5.0}/src/frequenz_dispatch.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: frequenz-dispatch
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A highlevel interface for the dispatch API
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License: MIT
@@ -19,15 +19,52 @@ Classifier: Topic :: Software Development :: Libraries
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: <4,>=3.11
21
21
  Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: typing-extensions<5.0.0,>=4.11.0
24
+ Requires-Dist: frequenz-sdk<1.0.0-rc1500,>=1.0.0-rc1302
25
+ Requires-Dist: frequenz-channels<2.0.0,>=1.3.0
26
+ Requires-Dist: frequenz-client-dispatch<0.9.0,>=0.8.2
22
27
  Provides-Extra: dev-flake8
28
+ Requires-Dist: flake8==7.1.1; extra == "dev-flake8"
29
+ Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
30
+ Requires-Dist: flake8-pyproject==1.2.3; extra == "dev-flake8"
31
+ Requires-Dist: pydoclint==0.5.9; extra == "dev-flake8"
32
+ Requires-Dist: pydocstyle==6.3.0; extra == "dev-flake8"
23
33
  Provides-Extra: dev-formatting
34
+ Requires-Dist: black==24.10.0; extra == "dev-formatting"
35
+ Requires-Dist: isort==5.13.2; extra == "dev-formatting"
24
36
  Provides-Extra: dev-mkdocs
37
+ Requires-Dist: black==24.10.0; extra == "dev-mkdocs"
38
+ Requires-Dist: Markdown==3.7; extra == "dev-mkdocs"
39
+ Requires-Dist: mike==2.1.3; extra == "dev-mkdocs"
40
+ Requires-Dist: mkdocs-gen-files==0.5.0; extra == "dev-mkdocs"
41
+ Requires-Dist: mkdocs-literate-nav==0.6.1; extra == "dev-mkdocs"
42
+ Requires-Dist: mkdocs-macros-plugin==1.3.7; extra == "dev-mkdocs"
43
+ Requires-Dist: mkdocs-material==9.5.47; extra == "dev-mkdocs"
44
+ Requires-Dist: mkdocstrings[python]==0.27.0; extra == "dev-mkdocs"
45
+ Requires-Dist: mkdocstrings-python==1.12.2; extra == "dev-mkdocs"
46
+ Requires-Dist: frequenz-repo-config[lib]==0.11.0; extra == "dev-mkdocs"
25
47
  Provides-Extra: dev-mypy
48
+ Requires-Dist: mypy==1.13.0; extra == "dev-mypy"
49
+ Requires-Dist: grpc-stubs==1.53.0.5; extra == "dev-mypy"
50
+ Requires-Dist: types-Markdown==3.7.0.20240822; extra == "dev-mypy"
51
+ Requires-Dist: frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
26
52
  Provides-Extra: dev-noxfile
53
+ Requires-Dist: uv==0.5.5; extra == "dev-noxfile"
54
+ Requires-Dist: nox==2024.10.9; extra == "dev-noxfile"
55
+ Requires-Dist: frequenz-repo-config[lib]==0.11.0; extra == "dev-noxfile"
27
56
  Provides-Extra: dev-pylint
57
+ Requires-Dist: pylint==3.3.2; extra == "dev-pylint"
58
+ Requires-Dist: frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
28
59
  Provides-Extra: dev-pytest
60
+ Requires-Dist: pytest==8.3.4; extra == "dev-pytest"
61
+ Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.11.0; extra == "dev-pytest"
62
+ Requires-Dist: pytest-mock==3.14.0; extra == "dev-pytest"
63
+ Requires-Dist: pytest-asyncio==0.24.0; extra == "dev-pytest"
64
+ Requires-Dist: async-solipsism==0.7; extra == "dev-pytest"
65
+ Requires-Dist: time-machine==2.16.0; extra == "dev-pytest"
29
66
  Provides-Extra: dev
30
- License-File: LICENSE
67
+ Requires-Dist: frequenz-dispatch[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]; extra == "dev"
31
68
 
32
69
  # Dispatch Highlevel Interface
33
70
 
@@ -52,7 +89,7 @@ The [`Dispatcher` class](https://frequenz-floss.github.io/frequenz-dispatch-pyth
52
89
 
53
90
  ```python
54
91
  import os
55
- from frequenz.dispatch import Dispatcher, RunningState
92
+ from frequenz.dispatch import Dispatcher
56
93
  from unittest.mock import MagicMock
57
94
 
58
95
  async def run():
@@ -73,29 +110,29 @@ async def run():
73
110
  changed_running_status_rx = dispatcher.running_status_change.new_receiver()
74
111
 
75
112
  async for dispatch in changed_running_status_rx:
76
- match dispatch.running("DEMO_TYPE"):
77
- case RunningState.RUNNING:
78
- print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
79
- if actor.is_running:
80
- actor.reconfigure(
81
- components=dispatch.target,
82
- run_parameters=dispatch.payload, # custom actor parameters
83
- dry_run=dispatch.dry_run,
84
- until=dispatch.until,
85
- ) # this will reconfigure the actor
86
- else:
87
- # this will start a new actor with the given components
88
- # and run it for the duration of the dispatch
89
- actor.start(
90
- components=dispatch.target,
91
- run_parameters=dispatch.payload, # custom actor parameters
92
- dry_run=dispatch.dry_run,
93
- until=dispatch.until,
94
- )
95
- case RunningState.STOPPED:
96
- actor.stop() # this will stop the actor
97
- case RunningState.DIFFERENT_TYPE:
98
- pass # dispatch not for this type
113
+ if dispatch.type != "MY_TYPE":
114
+ continue
115
+
116
+ if dispatch.started:
117
+ print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
118
+ if actor.is_running:
119
+ actor.reconfigure(
120
+ components=dispatch.target,
121
+ run_parameters=dispatch.payload, # custom actor parameters
122
+ dry_run=dispatch.dry_run,
123
+ until=dispatch.until,
124
+ ) # this will reconfigure the actor
125
+ else:
126
+ # this will start a new actor with the given components
127
+ # and run it for the duration of the dispatch
128
+ actor.start(
129
+ components=dispatch.target,
130
+ run_parameters=dispatch.payload, # custom actor parameters
131
+ dry_run=dispatch.dry_run,
132
+ until=dispatch.until,
133
+ )
134
+ else:
135
+ actor.stop() # this will stop the actor
99
136
  ```
100
137
 
101
138
  ## Supported Platforms
@@ -21,7 +21,7 @@ The [`Dispatcher` class](https://frequenz-floss.github.io/frequenz-dispatch-pyth
21
21
 
22
22
  ```python
23
23
  import os
24
- from frequenz.dispatch import Dispatcher, RunningState
24
+ from frequenz.dispatch import Dispatcher
25
25
  from unittest.mock import MagicMock
26
26
 
27
27
  async def run():
@@ -42,29 +42,29 @@ async def run():
42
42
  changed_running_status_rx = dispatcher.running_status_change.new_receiver()
43
43
 
44
44
  async for dispatch in changed_running_status_rx:
45
- match dispatch.running("DEMO_TYPE"):
46
- case RunningState.RUNNING:
47
- print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
48
- if actor.is_running:
49
- actor.reconfigure(
50
- components=dispatch.target,
51
- run_parameters=dispatch.payload, # custom actor parameters
52
- dry_run=dispatch.dry_run,
53
- until=dispatch.until,
54
- ) # this will reconfigure the actor
55
- else:
56
- # this will start a new actor with the given components
57
- # and run it for the duration of the dispatch
58
- actor.start(
59
- components=dispatch.target,
60
- run_parameters=dispatch.payload, # custom actor parameters
61
- dry_run=dispatch.dry_run,
62
- until=dispatch.until,
63
- )
64
- case RunningState.STOPPED:
65
- actor.stop() # this will stop the actor
66
- case RunningState.DIFFERENT_TYPE:
67
- pass # dispatch not for this type
45
+ if dispatch.type != "MY_TYPE":
46
+ continue
47
+
48
+ if dispatch.started:
49
+ print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
50
+ if actor.is_running:
51
+ actor.reconfigure(
52
+ components=dispatch.target,
53
+ run_parameters=dispatch.payload, # custom actor parameters
54
+ dry_run=dispatch.dry_run,
55
+ until=dispatch.until,
56
+ ) # this will reconfigure the actor
57
+ else:
58
+ # this will start a new actor with the given components
59
+ # and run it for the duration of the dispatch
60
+ actor.start(
61
+ components=dispatch.target,
62
+ run_parameters=dispatch.payload, # custom actor parameters
63
+ dry_run=dispatch.dry_run,
64
+ until=dispatch.until,
65
+ )
66
+ else:
67
+ actor.stop() # this will stop the actor
68
68
  ```
69
69
 
70
70
  ## Supported Platforms
@@ -0,0 +1,18 @@
1
+ # Dispatch Highlevel Interface Release Notes
2
+
3
+ ## Summary
4
+
5
+ <!-- Here goes a general summary of what this release is about -->
6
+
7
+ ## Upgrading
8
+
9
+ * The method `Dispatch.running(type: str)` was replaced with the property `Dispatch.started: bool`.
10
+ * The SDK dependency was widened to allow versions up to (excluding) v1.0.0-rc1500
11
+
12
+ ## New Features
13
+
14
+ <!-- Here goes the main new features and examples or instructions on how to use them -->
15
+
16
+ ## Bug Fixes
17
+
18
+ * Fixed a crash when reading a Dispatch with frequency YEARLY.
@@ -3,9 +3,9 @@
3
3
 
4
4
  [build-system]
5
5
  requires = [
6
- "setuptools == 68.1.0",
7
- "setuptools_scm[toml] == 7.1.0",
8
- "frequenz-repo-config[lib] == 0.10.0",
6
+ "setuptools == 75.6.0",
7
+ "setuptools_scm[toml] == 8.1.0",
8
+ "frequenz-repo-config[lib] == 0.11.0",
9
9
  ]
10
10
  build-backend = "setuptools.build_meta"
11
11
 
@@ -34,14 +34,13 @@ classifiers = [
34
34
  ]
35
35
  requires-python = ">= 3.11, < 4"
36
36
  dependencies = [
37
- "python-dateutil >= 2.8.2, < 3.0",
38
37
  "typing-extensions >= 4.11.0, < 5.0.0",
39
38
  # Make sure to update the version for cross-referencing also in the
40
39
  # mkdocs.yml file when changing the version here (look for the config key
41
40
  # plugins.mkdocstrings.handlers.python.import)
42
- "frequenz-sdk >= 1.0.0-rc1300, < 1.0.0-rc1400",
41
+ "frequenz-sdk >= 1.0.0-rc1302, < 1.0.0-rc1500",
43
42
  "frequenz-channels >= 1.3.0, < 2.0.0",
44
- "frequenz-client-dispatch >= 0.8.1, < 0.9.0",
43
+ "frequenz-client-dispatch >= 0.8.2, < 0.9.0",
45
44
  ]
46
45
  dynamic = ["version"]
47
46
 
@@ -65,32 +64,32 @@ dev-mkdocs = [
65
64
  "mkdocs-gen-files == 0.5.0",
66
65
  "mkdocs-literate-nav == 0.6.1",
67
66
  "mkdocs-macros-plugin == 1.3.7",
68
- "mkdocs-material == 9.5.43",
69
- "mkdocstrings[python] == 0.26.2",
67
+ "mkdocs-material == 9.5.47",
68
+ "mkdocstrings[python] == 0.27.0",
70
69
  "mkdocstrings-python == 1.12.2",
71
- "frequenz-repo-config[lib] == 0.10.0",
70
+ "frequenz-repo-config[lib] == 0.11.0",
72
71
  ]
73
72
  dev-mypy = [
74
73
  "mypy == 1.13.0",
75
- "grpc-stubs == 1.53.0.5", # This dependency introduces breaking changes in patch releases
74
+ # This dependency introduces breaking changes in patch releases
75
+ "grpc-stubs == 1.53.0.5",
76
76
  "types-Markdown == 3.7.0.20240822",
77
- "types-python-dateutil==2.9.0.20241003",
78
77
  # For checking the noxfile, docs/ script, and tests
79
78
  "frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]",
80
79
  ]
81
80
  dev-noxfile = [
82
- "uv == 0.4.29",
81
+ "uv == 0.5.5",
83
82
  "nox == 2024.10.9",
84
- "frequenz-repo-config[lib] == 0.10.0",
83
+ "frequenz-repo-config[lib] == 0.11.0",
85
84
  ]
86
85
  dev-pylint = [
87
- "pylint == 3.3.1",
86
+ "pylint == 3.3.2",
88
87
  # For checking the noxfile, docs/ script, and tests
89
88
  "frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]",
90
89
  ]
91
90
  dev-pytest = [
92
- "pytest == 8.3.3",
93
- "frequenz-repo-config[extra-lint-examples] == 0.10.0",
91
+ "pytest == 8.3.4",
92
+ "frequenz-repo-config[extra-lint-examples] == 0.11.0",
94
93
  "pytest-mock == 3.14.0",
95
94
  "pytest-asyncio == 0.24.0",
96
95
  "async-solipsism == 0.7",
@@ -152,6 +151,8 @@ disable = [
152
151
  "unsubscriptable-object",
153
152
  # Checked by mypy
154
153
  "no-member",
154
+ "possibly-used-before-assignment",
155
+ "no-name-in-module",
155
156
  # Checked by flake8
156
157
  "f-string-without-interpolation",
157
158
  "line-too-long",
@@ -15,7 +15,7 @@ A small overview of the most important classes in this module:
15
15
 
16
16
  """
17
17
 
18
- from ._dispatch import Dispatch, RunningState
18
+ from ._dispatch import Dispatch
19
19
  from ._dispatcher import Dispatcher, ReceiverFetcher
20
20
  from ._event import Created, Deleted, DispatchEvent, Updated
21
21
  from ._managing_actor import DispatchManagingActor, DispatchUpdate
@@ -28,7 +28,6 @@ __all__ = [
28
28
  "ReceiverFetcher",
29
29
  "Updated",
30
30
  "Dispatch",
31
- "RunningState",
32
31
  "DispatchManagingActor",
33
32
  "DispatchUpdate",
34
33
  ]
@@ -0,0 +1,71 @@
1
+ # License: MIT
2
+ # Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3
+
4
+ """Dispatch type with support for next_run calculation."""
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+ from typing import Iterator
9
+
10
+ from frequenz.client.dispatch.types import Dispatch as BaseDispatch
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Dispatch(BaseDispatch):
15
+ """Dispatch type with extra functionality."""
16
+
17
+ deleted: bool = False
18
+ """Whether the dispatch is deleted."""
19
+
20
+ def __init__(
21
+ self,
22
+ client_dispatch: BaseDispatch,
23
+ deleted: bool = False,
24
+ ):
25
+ """Initialize the dispatch.
26
+
27
+ Args:
28
+ client_dispatch: The client dispatch.
29
+ deleted: Whether the dispatch is deleted.
30
+ """
31
+ super().__init__(**client_dispatch.__dict__)
32
+ # Work around frozen to set deleted
33
+ object.__setattr__(self, "deleted", deleted)
34
+
35
+ def _set_deleted(self) -> None:
36
+ """Mark the dispatch as deleted."""
37
+ object.__setattr__(self, "deleted", True)
38
+
39
+ @property
40
+ def started(self) -> bool:
41
+ """Check if the dispatch is started.
42
+
43
+ Returns:
44
+ True if the dispatch is started, False otherwise.
45
+ """
46
+ if self.deleted:
47
+ return False
48
+
49
+ return super().started
50
+
51
+ # noqa is needed because of a bug in pydoclint that makes it think a `return` without a return
52
+ # value needs documenting
53
+ def missed_runs(self, since: datetime) -> Iterator[datetime]: # noqa: DOC405
54
+ """Yield all missed runs of a dispatch.
55
+
56
+ Yields all missed runs of a dispatch.
57
+
58
+ Args:
59
+ since: The time to start checking for missed runs.
60
+
61
+ Returns:
62
+ A generator that yields all missed runs of a dispatch.
63
+
64
+ Yields:
65
+ datetime: The missed run.
66
+ """
67
+ now = datetime.now(tz=timezone.utc)
68
+
69
+ while (next_run := self.next_run_after(since)) and next_run < now:
70
+ yield next_run
71
+ since = next_run
@@ -54,7 +54,7 @@ class Dispatcher:
54
54
  Example: Processing running state change dispatches
55
55
  ```python
56
56
  import os
57
- from frequenz.dispatch import Dispatcher, RunningState
57
+ from frequenz.dispatch import Dispatcher
58
58
  from unittest.mock import MagicMock
59
59
 
60
60
  async def run():
@@ -75,29 +75,29 @@ class Dispatcher:
75
75
  changed_running_status = dispatcher.running_status_change.new_receiver()
76
76
 
77
77
  async for dispatch in changed_running_status:
78
- match dispatch.running("DEMO_TYPE"):
79
- case RunningState.RUNNING:
80
- print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
81
- if actor.is_running:
82
- actor.reconfigure(
83
- components=dispatch.target,
84
- run_parameters=dispatch.payload, # custom actor parameters
85
- dry_run=dispatch.dry_run,
86
- until=dispatch.until,
87
- ) # this will reconfigure the actor
88
- else:
89
- # this will start a new actor with the given components
90
- # and run it for the duration of the dispatch
91
- actor.start(
92
- components=dispatch.target,
93
- run_parameters=dispatch.payload, # custom actor parameters
94
- dry_run=dispatch.dry_run,
95
- until=dispatch.until,
96
- )
97
- case RunningState.STOPPED:
98
- actor.stop() # this will stop the actor
99
- case RunningState.DIFFERENT_TYPE:
100
- pass # dispatch not for this type
78
+ if dispatch.type != "YOUR_DISPATCH_TYPE":
79
+ continue
80
+
81
+ if dispatch.started:
82
+ print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
83
+ if actor.is_running:
84
+ actor.reconfigure(
85
+ components=dispatch.target,
86
+ run_parameters=dispatch.payload, # custom actor parameters
87
+ dry_run=dispatch.dry_run,
88
+ until=dispatch.until,
89
+ ) # this will reconfigure the actor
90
+ else:
91
+ # this will start a new actor with the given components
92
+ # and run it for the duration of the dispatch
93
+ actor.start(
94
+ components=dispatch.target,
95
+ run_parameters=dispatch.payload, # custom actor parameters
96
+ dry_run=dispatch.dry_run,
97
+ until=dispatch.until,
98
+ )
99
+ else:
100
+ actor.stop() # this will stop the actor
101
101
  ```
102
102
 
103
103
  Example: Getting notification about dispatch lifecycle events
@@ -255,10 +255,6 @@ class Dispatcher:
255
255
  - The payload changed
256
256
  - The dispatch was deleted
257
257
 
258
- Note: Reaching the end time (start_time + duration) will not
259
- send a message, except when it was reached by modifying the duration.
260
-
261
-
262
258
  Returns:
263
259
  A new receiver for dispatches whose running status changed.
264
260
  """
@@ -11,7 +11,7 @@ from frequenz.channels import Receiver, Sender
11
11
  from frequenz.client.dispatch.types import TargetComponents
12
12
  from frequenz.sdk.actor import Actor
13
13
 
14
- from ._dispatch import Dispatch, RunningState
14
+ from ._dispatch import Dispatch
15
15
 
16
16
  _logger = logging.getLogger(__name__)
17
17
 
@@ -121,7 +121,9 @@ class DispatchManagingActor(Actor):
121
121
  """
122
122
  super().__init__()
123
123
  self._dispatch_rx = running_status_receiver
124
- self._actors = frozenset([actor] if isinstance(actor, Actor) else actor)
124
+ self._actors: frozenset[Actor] = frozenset(
125
+ [actor] if isinstance(actor, Actor) else actor
126
+ )
125
127
  self._dispatch_type = dispatch_type
126
128
  self._updates_sender = updates_sender
127
129
 
@@ -156,25 +158,23 @@ class DispatchManagingActor(Actor):
156
158
  Args:
157
159
  dispatch: The dispatch to handle.
158
160
  """
159
- running = dispatch.running(self._dispatch_type)
160
- match running:
161
- case RunningState.STOPPED:
162
- _logger.info("Stopped by dispatch %s", dispatch.id)
163
- await self._stop_actors("Dispatch stopped")
164
- case RunningState.RUNNING:
165
- if self._updates_sender is not None:
166
- _logger.info("Updated by dispatch %s", dispatch.id)
167
- await self._updates_sender.send(
168
- DispatchUpdate(
169
- components=dispatch.target,
170
- dry_run=dispatch.dry_run,
171
- options=dispatch.payload,
172
- )
161
+ if dispatch.type != self._dispatch_type:
162
+ _logger.debug("Ignoring dispatch %s", dispatch.id)
163
+ return
164
+
165
+ if dispatch.started:
166
+ if self._updates_sender is not None:
167
+ _logger.info("Updated by dispatch %s", dispatch.id)
168
+ await self._updates_sender.send(
169
+ DispatchUpdate(
170
+ components=dispatch.target,
171
+ dry_run=dispatch.dry_run,
172
+ options=dispatch.payload,
173
173
  )
174
-
175
- _logger.info("Started by dispatch %s", dispatch.id)
176
- self._start_actors()
177
- case RunningState.DIFFERENT_TYPE:
178
- _logger.debug(
179
- "Unknown dispatch! Ignoring dispatch of type %s", dispatch.type
180
174
  )
175
+
176
+ _logger.info("Started by dispatch %s", dispatch.id)
177
+ self._start_actors()
178
+ else:
179
+ _logger.info("Stopped by dispatch %s", dispatch.id)
180
+ await self._stop_actors("Dispatch stopped")
@@ -15,7 +15,7 @@ from frequenz.client.dispatch import Client
15
15
  from frequenz.client.dispatch.types import Event
16
16
  from frequenz.sdk.actor import Actor
17
17
 
18
- from ._dispatch import Dispatch, RunningState
18
+ from ._dispatch import Dispatch
19
19
  from ._event import Created, Deleted, DispatchEvent, Updated
20
20
 
21
21
  _logger = logging.getLogger(__name__)
@@ -126,7 +126,6 @@ class DispatchingActor(Actor):
126
126
  self._dispatches.pop(dispatch.id)
127
127
  await self._update_dispatch_schedule_and_notify(None, dispatch)
128
128
 
129
- dispatch._set_deleted() # pylint: disable=protected-access
130
129
  await self._lifecycle_updates_sender.send(
131
130
  Deleted(dispatch=dispatch)
132
131
  )
@@ -142,7 +141,7 @@ class DispatchingActor(Actor):
142
141
  # The timer is always a tiny bit delayed, so we need to check if the
143
142
  # actor is supposed to be running now (we're assuming it wasn't already
144
143
  # running, as all checks are done before scheduling)
145
- if dispatch.running(dispatch.type) == RunningState.RUNNING:
144
+ if dispatch.started:
146
145
  # If it should be running, schedule the stop event
147
146
  self._schedule_stop(dispatch)
148
147
  # If the actor is not running, we need to schedule the next start
@@ -193,7 +192,7 @@ class DispatchingActor(Actor):
193
192
  await self._lifecycle_updates_sender.send(Deleted(dispatch=dispatch))
194
193
  await self._update_dispatch_schedule_and_notify(None, dispatch)
195
194
 
196
- # Set deleted only here as it influences the result of dispatch.running()
195
+ # Set deleted only here as it influences the result of dispatch.started
197
196
  # which is used in above in _running_state_change
198
197
  dispatch._set_deleted() # pylint: disable=protected-access
199
198
  await self._lifecycle_updates_sender.send(Deleted(dispatch=dispatch))
@@ -221,8 +220,11 @@ class DispatchingActor(Actor):
221
220
  if not dispatch and old_dispatch:
222
221
  self._remove_scheduled(old_dispatch)
223
222
 
223
+ was_running = old_dispatch.started
224
+ old_dispatch._set_deleted() # pylint: disable=protected-access)
225
+
224
226
  # If the dispatch was running, we need to notify
225
- if old_dispatch.running(old_dispatch.type) == RunningState.RUNNING:
227
+ if was_running:
226
228
  await self._send_running_state_change(old_dispatch)
227
229
 
228
230
  # A new dispatch was created
@@ -230,9 +232,8 @@ class DispatchingActor(Actor):
230
232
  assert not self._remove_scheduled(
231
233
  dispatch
232
234
  ), "New dispatch already scheduled?!"
233
-
234
235
  # If its currently running, send notification right away
235
- if dispatch.running(dispatch.type) == RunningState.RUNNING:
236
+ if dispatch.started:
236
237
  await self._send_running_state_change(dispatch)
237
238
 
238
239
  self._schedule_stop(dispatch)
@@ -249,7 +250,7 @@ class DispatchingActor(Actor):
249
250
  if self._update_changed_running_state(dispatch, old_dispatch):
250
251
  await self._send_running_state_change(dispatch)
251
252
 
252
- if dispatch.running(dispatch.type) == RunningState.RUNNING:
253
+ if dispatch.started:
253
254
  self._schedule_stop(dispatch)
254
255
  else:
255
256
  self._schedule_start(dispatch)
@@ -336,7 +337,7 @@ class DispatchingActor(Actor):
336
337
  """
337
338
  # If any of the runtime attributes changed, we need to send a message
338
339
  runtime_state_attributes = [
339
- "running",
340
+ "started",
340
341
  "type",
341
342
  "target",
342
343
  "duration",
@@ -359,6 +360,3 @@ class DispatchingActor(Actor):
359
360
  dispatch: The dispatch that changed.
360
361
  """
361
362
  await self._running_state_change_sender.send(dispatch)
362
- # Update the last sent notification time
363
- # so we know if this change was already sent
364
- dispatch._set_running_status_notified() # pylint: disable=protected-access
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: frequenz-dispatch
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: A highlevel interface for the dispatch API
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License: MIT
@@ -19,15 +19,52 @@ Classifier: Topic :: Software Development :: Libraries
19
19
  Classifier: Typing :: Typed
20
20
  Requires-Python: <4,>=3.11
21
21
  Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: typing-extensions<5.0.0,>=4.11.0
24
+ Requires-Dist: frequenz-sdk<1.0.0-rc1500,>=1.0.0-rc1302
25
+ Requires-Dist: frequenz-channels<2.0.0,>=1.3.0
26
+ Requires-Dist: frequenz-client-dispatch<0.9.0,>=0.8.2
22
27
  Provides-Extra: dev-flake8
28
+ Requires-Dist: flake8==7.1.1; extra == "dev-flake8"
29
+ Requires-Dist: flake8-docstrings==1.7.0; extra == "dev-flake8"
30
+ Requires-Dist: flake8-pyproject==1.2.3; extra == "dev-flake8"
31
+ Requires-Dist: pydoclint==0.5.9; extra == "dev-flake8"
32
+ Requires-Dist: pydocstyle==6.3.0; extra == "dev-flake8"
23
33
  Provides-Extra: dev-formatting
34
+ Requires-Dist: black==24.10.0; extra == "dev-formatting"
35
+ Requires-Dist: isort==5.13.2; extra == "dev-formatting"
24
36
  Provides-Extra: dev-mkdocs
37
+ Requires-Dist: black==24.10.0; extra == "dev-mkdocs"
38
+ Requires-Dist: Markdown==3.7; extra == "dev-mkdocs"
39
+ Requires-Dist: mike==2.1.3; extra == "dev-mkdocs"
40
+ Requires-Dist: mkdocs-gen-files==0.5.0; extra == "dev-mkdocs"
41
+ Requires-Dist: mkdocs-literate-nav==0.6.1; extra == "dev-mkdocs"
42
+ Requires-Dist: mkdocs-macros-plugin==1.3.7; extra == "dev-mkdocs"
43
+ Requires-Dist: mkdocs-material==9.5.47; extra == "dev-mkdocs"
44
+ Requires-Dist: mkdocstrings[python]==0.27.0; extra == "dev-mkdocs"
45
+ Requires-Dist: mkdocstrings-python==1.12.2; extra == "dev-mkdocs"
46
+ Requires-Dist: frequenz-repo-config[lib]==0.11.0; extra == "dev-mkdocs"
25
47
  Provides-Extra: dev-mypy
48
+ Requires-Dist: mypy==1.13.0; extra == "dev-mypy"
49
+ Requires-Dist: grpc-stubs==1.53.0.5; extra == "dev-mypy"
50
+ Requires-Dist: types-Markdown==3.7.0.20240822; extra == "dev-mypy"
51
+ Requires-Dist: frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-mypy"
26
52
  Provides-Extra: dev-noxfile
53
+ Requires-Dist: uv==0.5.5; extra == "dev-noxfile"
54
+ Requires-Dist: nox==2024.10.9; extra == "dev-noxfile"
55
+ Requires-Dist: frequenz-repo-config[lib]==0.11.0; extra == "dev-noxfile"
27
56
  Provides-Extra: dev-pylint
57
+ Requires-Dist: pylint==3.3.2; extra == "dev-pylint"
58
+ Requires-Dist: frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]; extra == "dev-pylint"
28
59
  Provides-Extra: dev-pytest
60
+ Requires-Dist: pytest==8.3.4; extra == "dev-pytest"
61
+ Requires-Dist: frequenz-repo-config[extra-lint-examples]==0.11.0; extra == "dev-pytest"
62
+ Requires-Dist: pytest-mock==3.14.0; extra == "dev-pytest"
63
+ Requires-Dist: pytest-asyncio==0.24.0; extra == "dev-pytest"
64
+ Requires-Dist: async-solipsism==0.7; extra == "dev-pytest"
65
+ Requires-Dist: time-machine==2.16.0; extra == "dev-pytest"
29
66
  Provides-Extra: dev
30
- License-File: LICENSE
67
+ Requires-Dist: frequenz-dispatch[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]; extra == "dev"
31
68
 
32
69
  # Dispatch Highlevel Interface
33
70
 
@@ -52,7 +89,7 @@ The [`Dispatcher` class](https://frequenz-floss.github.io/frequenz-dispatch-pyth
52
89
 
53
90
  ```python
54
91
  import os
55
- from frequenz.dispatch import Dispatcher, RunningState
92
+ from frequenz.dispatch import Dispatcher
56
93
  from unittest.mock import MagicMock
57
94
 
58
95
  async def run():
@@ -73,29 +110,29 @@ async def run():
73
110
  changed_running_status_rx = dispatcher.running_status_change.new_receiver()
74
111
 
75
112
  async for dispatch in changed_running_status_rx:
76
- match dispatch.running("DEMO_TYPE"):
77
- case RunningState.RUNNING:
78
- print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
79
- if actor.is_running:
80
- actor.reconfigure(
81
- components=dispatch.target,
82
- run_parameters=dispatch.payload, # custom actor parameters
83
- dry_run=dispatch.dry_run,
84
- until=dispatch.until,
85
- ) # this will reconfigure the actor
86
- else:
87
- # this will start a new actor with the given components
88
- # and run it for the duration of the dispatch
89
- actor.start(
90
- components=dispatch.target,
91
- run_parameters=dispatch.payload, # custom actor parameters
92
- dry_run=dispatch.dry_run,
93
- until=dispatch.until,
94
- )
95
- case RunningState.STOPPED:
96
- actor.stop() # this will stop the actor
97
- case RunningState.DIFFERENT_TYPE:
98
- pass # dispatch not for this type
113
+ if dispatch.type != "MY_TYPE":
114
+ continue
115
+
116
+ if dispatch.started:
117
+ print(f"Executing dispatch {dispatch.id}, due on {dispatch.start_time}")
118
+ if actor.is_running:
119
+ actor.reconfigure(
120
+ components=dispatch.target,
121
+ run_parameters=dispatch.payload, # custom actor parameters
122
+ dry_run=dispatch.dry_run,
123
+ until=dispatch.until,
124
+ ) # this will reconfigure the actor
125
+ else:
126
+ # this will start a new actor with the given components
127
+ # and run it for the duration of the dispatch
128
+ actor.start(
129
+ components=dispatch.target,
130
+ run_parameters=dispatch.payload, # custom actor parameters
131
+ dry_run=dispatch.dry_run,
132
+ until=dispatch.until,
133
+ )
134
+ else:
135
+ actor.stop() # this will stop the actor
99
136
  ```
100
137
 
101
138
  ## Supported Platforms
@@ -1,8 +1,7 @@
1
- python-dateutil<3.0,>=2.8.2
2
1
  typing-extensions<5.0.0,>=4.11.0
3
- frequenz-sdk<1.0.0-rc1400,>=1.0.0-rc1300
2
+ frequenz-sdk<1.0.0-rc1500,>=1.0.0-rc1302
4
3
  frequenz-channels<2.0.0,>=1.3.0
5
- frequenz-client-dispatch<0.9.0,>=0.8.1
4
+ frequenz-client-dispatch<0.9.0,>=0.8.2
6
5
 
7
6
  [dev]
8
7
  frequenz-dispatch[dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]
@@ -25,30 +24,29 @@ mike==2.1.3
25
24
  mkdocs-gen-files==0.5.0
26
25
  mkdocs-literate-nav==0.6.1
27
26
  mkdocs-macros-plugin==1.3.7
28
- mkdocs-material==9.5.43
29
- mkdocstrings[python]==0.26.2
27
+ mkdocs-material==9.5.47
28
+ mkdocstrings[python]==0.27.0
30
29
  mkdocstrings-python==1.12.2
31
- frequenz-repo-config[lib]==0.10.0
30
+ frequenz-repo-config[lib]==0.11.0
32
31
 
33
32
  [dev-mypy]
34
33
  mypy==1.13.0
35
34
  grpc-stubs==1.53.0.5
36
35
  types-Markdown==3.7.0.20240822
37
- types-python-dateutil==2.9.0.20241003
38
36
  frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]
39
37
 
40
38
  [dev-noxfile]
41
- uv==0.4.29
39
+ uv==0.5.5
42
40
  nox==2024.10.9
43
- frequenz-repo-config[lib]==0.10.0
41
+ frequenz-repo-config[lib]==0.11.0
44
42
 
45
43
  [dev-pylint]
46
- pylint==3.3.1
44
+ pylint==3.3.2
47
45
  frequenz-dispatch[dev-mkdocs,dev-noxfile,dev-pytest]
48
46
 
49
47
  [dev-pytest]
50
- pytest==8.3.3
51
- frequenz-repo-config[extra-lint-examples]==0.10.0
48
+ pytest==8.3.4
49
+ frequenz-repo-config[extra-lint-examples]==0.11.0
52
50
  pytest-mock==3.14.0
53
51
  pytest-asyncio==0.24.0
54
52
  async-solipsism==0.7
@@ -1,21 +0,0 @@
1
- # Dispatch Highlevel Interface Release Notes
2
-
3
- ## Summary
4
-
5
- * Updates lots of dependencies and through those gets a few new features:
6
- * `start_immediately` when creating dispatches is now supported.
7
- * `http2 keepalive` is now supported and enabled by default.
8
- * Some bugfixes from the channels & sdk libraries. are now included.
9
-
10
-
11
- ## Upgrading
12
-
13
- <!-- Here goes notes on how to upgrade from previous versions, including deprecations and what they should be replaced with -->
14
-
15
- ## New Features
16
-
17
- <!-- Here goes the main new features and examples or instructions on how to use them -->
18
-
19
- ## Bug Fixes
20
-
21
- * Fixed a crash in the `DispatchManagingActor` when dispatches shared an equal start time.
@@ -1,272 +0,0 @@
1
- # License: MIT
2
- # Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3
-
4
- """Dispatch type with support for next_run calculation."""
5
-
6
-
7
- import logging
8
- from dataclasses import dataclass
9
- from datetime import datetime, timezone
10
- from enum import Enum
11
- from typing import Iterator, cast
12
-
13
- from dateutil import rrule
14
- from frequenz.client.dispatch.recurrence import Frequency, Weekday
15
- from frequenz.client.dispatch.types import Dispatch as BaseDispatch
16
-
17
- _logger = logging.getLogger(__name__)
18
- """The logger for this module."""
19
-
20
- _RRULE_FREQ_MAP = {
21
- Frequency.MINUTELY: rrule.MINUTELY,
22
- Frequency.HOURLY: rrule.HOURLY,
23
- Frequency.DAILY: rrule.DAILY,
24
- Frequency.WEEKLY: rrule.WEEKLY,
25
- Frequency.MONTHLY: rrule.MONTHLY,
26
- }
27
- """To map from our Frequency enum to the dateutil library enum."""
28
-
29
- _RRULE_WEEKDAY_MAP = {
30
- Weekday.MONDAY: rrule.MO,
31
- Weekday.TUESDAY: rrule.TU,
32
- Weekday.WEDNESDAY: rrule.WE,
33
- Weekday.THURSDAY: rrule.TH,
34
- Weekday.FRIDAY: rrule.FR,
35
- Weekday.SATURDAY: rrule.SA,
36
- Weekday.SUNDAY: rrule.SU,
37
- }
38
- """To map from our Weekday enum to the dateutil library enum."""
39
-
40
-
41
- class RunningState(Enum):
42
- """The running state of a dispatch."""
43
-
44
- RUNNING = "RUNNING"
45
- """The dispatch is running."""
46
-
47
- STOPPED = "STOPPED"
48
- """The dispatch is stopped."""
49
-
50
- DIFFERENT_TYPE = "DIFFERENT_TYPE"
51
- """The dispatch is for a different type."""
52
-
53
-
54
- @dataclass(frozen=True)
55
- class Dispatch(BaseDispatch):
56
- """Dispatch type with extra functionality."""
57
-
58
- deleted: bool = False
59
- """Whether the dispatch is deleted."""
60
-
61
- running_state_change_synced: datetime | None = None
62
- """The last time a message was sent about the running state change."""
63
-
64
- def __init__(
65
- self,
66
- client_dispatch: BaseDispatch,
67
- deleted: bool = False,
68
- running_state_change_synced: datetime | None = None,
69
- ):
70
- """Initialize the dispatch.
71
-
72
- Args:
73
- client_dispatch: The client dispatch.
74
- deleted: Whether the dispatch is deleted.
75
- running_state_change_synced: Timestamp of the last running state change message.
76
- """
77
- super().__init__(**client_dispatch.__dict__)
78
- # Work around frozen to set deleted
79
- object.__setattr__(self, "deleted", deleted)
80
- object.__setattr__(
81
- self,
82
- "running_state_change_synced",
83
- running_state_change_synced,
84
- )
85
-
86
- def _set_deleted(self) -> None:
87
- """Mark the dispatch as deleted."""
88
- object.__setattr__(self, "deleted", True)
89
-
90
- @property
91
- def _running_status_notified(self) -> bool:
92
- """Check that the latest running state change notification was sent.
93
-
94
- Returns:
95
- True if the latest running state change notification was sent, False otherwise.
96
- """
97
- return self.running_state_change_synced == self.update_time
98
-
99
- def _set_running_status_notified(self) -> None:
100
- """Mark the latest running state change notification as sent."""
101
- object.__setattr__(self, "running_state_change_synced", self.update_time)
102
-
103
- def running(self, type_: str) -> RunningState:
104
- """Check if the dispatch is currently supposed to be running.
105
-
106
- Args:
107
- type_: The type of the dispatch that should be running.
108
-
109
- Returns:
110
- RUNNING if the dispatch is running,
111
- STOPPED if it is stopped,
112
- DIFFERENT_TYPE if it is for a different type.
113
- """
114
- if self.type != type_:
115
- return RunningState.DIFFERENT_TYPE
116
-
117
- if not self.active or self.deleted:
118
- return RunningState.STOPPED
119
-
120
- now = datetime.now(tz=timezone.utc)
121
-
122
- if now < self.start_time:
123
- return RunningState.STOPPED
124
- # A dispatch without duration is always running once it started
125
- if self.duration is None:
126
- return RunningState.RUNNING
127
-
128
- if until := self._until(now):
129
- return RunningState.RUNNING if now < until else RunningState.STOPPED
130
-
131
- return RunningState.STOPPED
132
-
133
- @property
134
- def until(self) -> datetime | None:
135
- """Time when the dispatch should end.
136
-
137
- Returns the time that a running dispatch should end.
138
- If the dispatch is not running, None is returned.
139
-
140
- Returns:
141
- The time when the dispatch should end or None if the dispatch is not running.
142
- """
143
- if not self.active or self.deleted:
144
- return None
145
-
146
- now = datetime.now(tz=timezone.utc)
147
- return self._until(now)
148
-
149
- @property
150
- # noqa is needed because of a bug in pydoclint that makes it think a `return` without a return
151
- # value needs documenting
152
- def missed_runs(self) -> Iterator[datetime]: # noqa: DOC405
153
- """Yield all missed runs of a dispatch.
154
-
155
- Yields all missed runs of a dispatch.
156
-
157
- If a running state change notification was not sent in time
158
- due to connection issues, this method will yield all missed runs
159
- since the last sent notification.
160
-
161
- Returns:
162
- A generator that yields all missed runs of a dispatch.
163
- """
164
- if self.update_time == self.running_state_change_synced:
165
- return
166
-
167
- from_time = self.update_time
168
- now = datetime.now(tz=timezone.utc)
169
-
170
- while (next_run := self.next_run_after(from_time)) and next_run < now:
171
- yield next_run
172
- from_time = next_run
173
-
174
- @property
175
- def next_run(self) -> datetime | None:
176
- """Calculate the next run of a dispatch.
177
-
178
- Returns:
179
- The next run of the dispatch or None if the dispatch is finished.
180
- """
181
- return self.next_run_after(datetime.now(tz=timezone.utc))
182
-
183
- def next_run_after(self, after: datetime) -> datetime | None:
184
- """Calculate the next run of a dispatch.
185
-
186
- Args:
187
- after: The time to calculate the next run from.
188
-
189
- Returns:
190
- The next run of the dispatch or None if the dispatch is finished.
191
- """
192
- if (
193
- not self.recurrence.frequency
194
- or self.recurrence.frequency == Frequency.UNSPECIFIED
195
- or self.duration is None # Infinite duration
196
- ):
197
- if after > self.start_time:
198
- return None
199
- return self.start_time
200
-
201
- # Make sure no weekday is UNSPECIFIED
202
- if Weekday.UNSPECIFIED in self.recurrence.byweekdays:
203
- _logger.warning("Dispatch %s has UNSPECIFIED weekday, ignoring...", self.id)
204
- return None
205
-
206
- # No type information for rrule, so we need to cast
207
- return cast(datetime | None, self._prepare_rrule().after(after, inc=True))
208
-
209
- def _prepare_rrule(self) -> rrule.rrule:
210
- """Prepare the rrule object.
211
-
212
- Returns:
213
- The rrule object.
214
-
215
- Raises:
216
- ValueError: If the interval is invalid.
217
- """
218
- count, until = (None, None)
219
- if end := self.recurrence.end_criteria:
220
- count = end.count
221
- until = end.until
222
-
223
- if self.recurrence.interval is None or self.recurrence.interval < 1:
224
- raise ValueError("Interval must be at least 1")
225
-
226
- rrule_obj = rrule.rrule(
227
- freq=_RRULE_FREQ_MAP[self.recurrence.frequency],
228
- dtstart=self.start_time,
229
- count=count,
230
- until=until,
231
- byminute=self.recurrence.byminutes or None,
232
- byhour=self.recurrence.byhours or None,
233
- byweekday=[
234
- _RRULE_WEEKDAY_MAP[weekday] for weekday in self.recurrence.byweekdays
235
- ]
236
- or None,
237
- bymonthday=self.recurrence.bymonthdays or None,
238
- bymonth=self.recurrence.bymonths or None,
239
- interval=self.recurrence.interval,
240
- )
241
-
242
- return rrule_obj
243
-
244
- def _until(self, now: datetime) -> datetime | None:
245
- """Calculate the time when the dispatch should end.
246
-
247
- If no previous run is found, None is returned.
248
-
249
- Args:
250
- now: The current time.
251
-
252
- Returns:
253
- The time when the dispatch should end or None if the dispatch is not running.
254
-
255
- Raises:
256
- ValueError: If the dispatch has no duration.
257
- """
258
- if self.duration is None:
259
- raise ValueError("_until: Dispatch has no duration")
260
-
261
- if (
262
- not self.recurrence.frequency
263
- or self.recurrence.frequency == Frequency.UNSPECIFIED
264
- ):
265
- return self.start_time + self.duration
266
-
267
- latest_past_start: datetime | None = self._prepare_rrule().before(now, inc=True)
268
-
269
- if not latest_past_start:
270
- return None
271
-
272
- return latest_past_start + self.duration