lodol 0.2.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.
- lodol-0.2.0/LICENSE +185 -0
- lodol-0.2.0/MANIFEST.in +4 -0
- lodol-0.2.0/PKG-INFO +367 -0
- lodol-0.2.0/README.md +325 -0
- lodol-0.2.0/pyproject.toml +85 -0
- lodol-0.2.0/setup.cfg +4 -0
- lodol-0.2.0/src/lodol/__init__.py +115 -0
- lodol-0.2.0/src/lodol/_logs.py +26 -0
- lodol-0.2.0/src/lodol/_transport.py +806 -0
- lodol-0.2.0/src/lodol/client.py +163 -0
- lodol-0.2.0/src/lodol/constants.py +51 -0
- lodol-0.2.0/src/lodol/exceptions.py +219 -0
- lodol-0.2.0/src/lodol/models.py +542 -0
- lodol-0.2.0/src/lodol/pagination.py +114 -0
- lodol-0.2.0/src/lodol/py.typed +0 -0
- lodol-0.2.0/src/lodol/resources/__init__.py +16 -0
- lodol-0.2.0/src/lodol/resources/_common.py +19 -0
- lodol-0.2.0/src/lodol/resources/approvals.py +380 -0
- lodol-0.2.0/src/lodol/resources/runs.py +853 -0
- lodol-0.2.0/src/lodol/resources/workflows.py +533 -0
- lodol-0.2.0/src/lodol/version.py +3 -0
- lodol-0.2.0/src/lodol.egg-info/PKG-INFO +367 -0
- lodol-0.2.0/src/lodol.egg-info/SOURCES.txt +36 -0
- lodol-0.2.0/src/lodol.egg-info/dependency_links.txt +1 -0
- lodol-0.2.0/src/lodol.egg-info/requires.txt +15 -0
- lodol-0.2.0/src/lodol.egg-info/top_level.txt +1 -0
- lodol-0.2.0/tests/__init__.py +0 -0
- lodol-0.2.0/tests/conftest.py +181 -0
- lodol-0.2.0/tests/standin.py +1218 -0
- lodol-0.2.0/tests/test_approvals.py +532 -0
- lodol-0.2.0/tests/test_async.py +518 -0
- lodol-0.2.0/tests/test_client.py +199 -0
- lodol-0.2.0/tests/test_live_server.py +543 -0
- lodol-0.2.0/tests/test_models.py +378 -0
- lodol-0.2.0/tests/test_readme.py +49 -0
- lodol-0.2.0/tests/test_retries.py +632 -0
- lodol-0.2.0/tests/test_runs.py +1162 -0
- lodol-0.2.0/tests/test_workflows.py +679 -0
lodol-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction, and
|
|
10
|
+
distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by the
|
|
13
|
+
copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all other
|
|
16
|
+
entities that control, are controlled by, or are under common control with
|
|
17
|
+
that entity. For the purposes of this definition, "control" means (i) the
|
|
18
|
+
power, direct or indirect, to cause the direction or management of such
|
|
19
|
+
entity, whether by contract or otherwise, or (ii) ownership of fifty percent
|
|
20
|
+
(50%) or more of the outstanding shares, or (iii) beneficial ownership of such
|
|
21
|
+
entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising
|
|
24
|
+
permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation source, and
|
|
28
|
+
configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical transformation or
|
|
31
|
+
translation of a Source form, including but not limited to compiled object
|
|
32
|
+
code, generated documentation, and conversions to other media types.
|
|
33
|
+
|
|
34
|
+
"Work" shall mean the work of authorship, whether in Source or Object form,
|
|
35
|
+
made available under the License, as indicated by a copyright notice that is
|
|
36
|
+
included in or attached to the work (an example is provided in the Appendix
|
|
37
|
+
below).
|
|
38
|
+
|
|
39
|
+
"Derivative Works" shall mean any work, whether in Source or Object form, that
|
|
40
|
+
is based on (or derived from) the Work and for which the editorial revisions,
|
|
41
|
+
annotations, elaborations, or other modifications represent, as a whole, an
|
|
42
|
+
original work of authorship. For the purposes of this License, Derivative Works
|
|
43
|
+
shall not include works that remain separable from, or merely link (or bind by
|
|
44
|
+
name) to the interfaces of, the Work and Derivative Works thereof.
|
|
45
|
+
|
|
46
|
+
"Contribution" shall mean any work of authorship, including the original
|
|
47
|
+
version of the Work and any modifications or additions to that Work or
|
|
48
|
+
Derivative Works thereof, that is intentionally submitted to Licensor for
|
|
49
|
+
inclusion in the Work by the copyright owner or by an individual or Legal
|
|
50
|
+
Entity authorized to submit on behalf of the copyright owner. For the purposes
|
|
51
|
+
of this definition, "submitted" means any form of electronic, verbal, or
|
|
52
|
+
written communication sent to the Licensor or its representatives, including
|
|
53
|
+
but not limited to communication on electronic mailing lists, source code
|
|
54
|
+
control systems, and issue tracking systems that are managed by, or on behalf
|
|
55
|
+
of, the Licensor for the purpose of discussing and improving the Work, but
|
|
56
|
+
excluding communication that is conspicuously marked or otherwise designated
|
|
57
|
+
in writing by the copyright owner as "Not a Contribution."
|
|
58
|
+
|
|
59
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
|
|
60
|
+
of whom a Contribution has been received by Licensor and subsequently
|
|
61
|
+
incorporated within the Work.
|
|
62
|
+
|
|
63
|
+
2. Grant of Copyright License. Subject to the terms and conditions of this
|
|
64
|
+
License, each Contributor hereby grants to You a perpetual, worldwide,
|
|
65
|
+
non-exclusive, no-charge, royalty-free, irrevocable copyright license to
|
|
66
|
+
reproduce, prepare Derivative Works of, publicly display, publicly perform,
|
|
67
|
+
sublicense, and distribute the Work and such Derivative Works in Source or
|
|
68
|
+
Object form.
|
|
69
|
+
|
|
70
|
+
3. Grant of Patent License. Subject to the terms and conditions of this
|
|
71
|
+
License, each Contributor hereby grants to You a perpetual, worldwide,
|
|
72
|
+
non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
|
|
73
|
+
section) patent license to make, have made, use, offer to sell, sell, import,
|
|
74
|
+
and otherwise transfer the Work, where such license applies only to those
|
|
75
|
+
patent claims licensable by such Contributor that are necessarily infringed by
|
|
76
|
+
their Contribution(s) alone or by combination of their Contribution(s) with the
|
|
77
|
+
Work to which such Contribution(s) was submitted. If You institute patent
|
|
78
|
+
litigation against any entity (including a cross-claim or counterclaim in a
|
|
79
|
+
lawsuit) alleging that the Work or a Contribution incorporated within the Work
|
|
80
|
+
constitutes direct or contributory patent infringement, then any patent
|
|
81
|
+
licenses granted to You under this License for that Work shall terminate as of
|
|
82
|
+
the date such litigation is filed.
|
|
83
|
+
|
|
84
|
+
4. Redistribution. You may reproduce and distribute copies of the Work or
|
|
85
|
+
Derivative Works thereof in any medium, with or without modifications, and in
|
|
86
|
+
Source or Object form, provided that You meet the following conditions:
|
|
87
|
+
|
|
88
|
+
(a) You must give any other recipients of the Work or Derivative Works a copy
|
|
89
|
+
of this License; and
|
|
90
|
+
|
|
91
|
+
(b) You must cause any modified files to carry prominent notices stating that
|
|
92
|
+
You changed the files; and
|
|
93
|
+
|
|
94
|
+
(c) You must retain, in the Source form of any Derivative Works that You
|
|
95
|
+
distribute, all copyright, patent, trademark, and attribution notices from the
|
|
96
|
+
Source form of the Work, excluding those notices that do not pertain to any
|
|
97
|
+
part of the Derivative Works; and
|
|
98
|
+
|
|
99
|
+
(d) If the Work includes a "NOTICE" text file as part of its distribution, then
|
|
100
|
+
any Derivative Works that You distribute must include a readable copy of the
|
|
101
|
+
attribution notices contained within such NOTICE file, excluding those notices
|
|
102
|
+
that do not pertain to any part of the Derivative Works, in at least one of the
|
|
103
|
+
following places: within a NOTICE text file distributed as part of the
|
|
104
|
+
Derivative Works; within the Source form or documentation, if provided along
|
|
105
|
+
with the Derivative Works; or, within a display generated by the Derivative
|
|
106
|
+
Works, if and wherever such third-party notices normally appear. The contents
|
|
107
|
+
of the NOTICE file are for informational purposes only and do not modify the
|
|
108
|
+
License. You may add Your own attribution notices within Derivative Works that
|
|
109
|
+
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
|
110
|
+
provided that such additional attribution notices cannot be construed as
|
|
111
|
+
modifying the License.
|
|
112
|
+
|
|
113
|
+
You may add Your own copyright statement to Your modifications and may provide
|
|
114
|
+
additional or different license terms and conditions for use, reproduction, or
|
|
115
|
+
distribution of Your modifications, or for any such Derivative Works as a
|
|
116
|
+
whole, provided Your use, reproduction, and distribution of the Work otherwise
|
|
117
|
+
complies with the conditions stated in this License.
|
|
118
|
+
|
|
119
|
+
5. Submission of Contributions. Unless You explicitly state otherwise, any
|
|
120
|
+
Contribution intentionally submitted for inclusion in the Work by You to the
|
|
121
|
+
Licensor shall be under the terms and conditions of this License, without any
|
|
122
|
+
additional terms or conditions. Notwithstanding the above, nothing herein shall
|
|
123
|
+
supersede or modify the terms of any separate license agreement you may have
|
|
124
|
+
executed with Licensor regarding such Contributions.
|
|
125
|
+
|
|
126
|
+
6. Trademarks. This License does not grant permission to use the trade names,
|
|
127
|
+
trademarks, service marks, or product names of the Licensor, except as required
|
|
128
|
+
for reasonable and customary use in describing the origin of the Work and
|
|
129
|
+
reproducing the content of the NOTICE file.
|
|
130
|
+
|
|
131
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
|
|
132
|
+
writing, Licensor provides the Work (and each Contributor provides its
|
|
133
|
+
Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
134
|
+
KIND, either express or implied, including, without limitation, any warranties
|
|
135
|
+
or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
136
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
137
|
+
appropriateness of using or redistributing the Work and assume any risks
|
|
138
|
+
associated with Your exercise of permissions under this License.
|
|
139
|
+
|
|
140
|
+
8. Limitation of Liability. In no event and under no legal theory, whether in
|
|
141
|
+
tort (including negligence), contract, or otherwise, unless required by
|
|
142
|
+
applicable law (such as deliberate and grossly negligent acts) or agreed to in
|
|
143
|
+
writing, shall any Contributor be liable to You for damages, including any
|
|
144
|
+
direct, indirect, special, incidental, or consequential damages of any character
|
|
145
|
+
arising as a result of this License or out of the use or inability to use the
|
|
146
|
+
Work (including but not limited to damages for loss of goodwill, work stoppage,
|
|
147
|
+
computer failure or malfunction, or any and all other commercial damages or
|
|
148
|
+
losses), even if such Contributor has been advised of the possibility of such
|
|
149
|
+
damages.
|
|
150
|
+
|
|
151
|
+
9. Accepting Warranty or Additional Liability. While redistributing the Work or
|
|
152
|
+
Derivative Works thereof, You may choose to offer, and charge a fee for,
|
|
153
|
+
acceptance of support, warranty, indemnity, or other liability obligations
|
|
154
|
+
and/or rights consistent with this License. However, in accepting such
|
|
155
|
+
obligations, You may act only on Your own behalf and on Your sole
|
|
156
|
+
responsibility, not on behalf of any other Contributor, and only if You agree
|
|
157
|
+
to indemnify, defend, and hold each Contributor harmless for any liability
|
|
158
|
+
incurred by, or claims asserted against, such Contributor by reason of your
|
|
159
|
+
accepting any such warranty or additional liability.
|
|
160
|
+
|
|
161
|
+
END OF TERMS AND CONDITIONS
|
|
162
|
+
|
|
163
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
164
|
+
|
|
165
|
+
To apply the Apache License to your work, attach the following boilerplate
|
|
166
|
+
notice, with the fields enclosed by brackets "[]" replaced with your own
|
|
167
|
+
identifying information. (Don't include the brackets!) The text should be
|
|
168
|
+
enclosed in the appropriate comment syntax for the file format. We also
|
|
169
|
+
recommend that a file or class name and description of purpose be included on
|
|
170
|
+
the same "printed page" as the copyright notice for easier identification
|
|
171
|
+
within third-party archives.
|
|
172
|
+
|
|
173
|
+
Copyright 2026 Lodol
|
|
174
|
+
|
|
175
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
176
|
+
you may not use this file except in compliance with the License.
|
|
177
|
+
You may obtain a copy of the License at
|
|
178
|
+
|
|
179
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
180
|
+
|
|
181
|
+
Unless required by applicable law or agreed to in writing, software
|
|
182
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
183
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
184
|
+
See the License for the specific language governing permissions and
|
|
185
|
+
limitations under the License.
|
lodol-0.2.0/MANIFEST.in
ADDED
lodol-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lodol
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Run your Lodol workflows from Python: start runs, wait for them, and answer approvals
|
|
5
|
+
Author: Lodol
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://lodol.com
|
|
8
|
+
Project-URL: Documentation, https://docs.lodol.com
|
|
9
|
+
Project-URL: Source, https://github.com/LodolAI/lodol-python
|
|
10
|
+
Project-URL: Issues, https://github.com/LodolAI/lodol-python/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/LodolAI/lodol-python/blob/main/CHANGELOG.md
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Framework :: AsyncIO
|
|
23
|
+
Classifier: Framework :: Trio
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Requires-Dist: httpx2<3,>=2.14.0
|
|
30
|
+
Requires-Dist: anyio<5,>=4.15.1
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
33
|
+
Requires-Dist: pytest-cov>=7.1.0; extra == "test"
|
|
34
|
+
Requires-Dist: trio>=0.34.0; extra == "test"
|
|
35
|
+
Provides-Extra: lint
|
|
36
|
+
Requires-Dist: black==26.10.1; extra == "lint"
|
|
37
|
+
Requires-Dist: mypy==2.4.0; extra == "lint"
|
|
38
|
+
Requires-Dist: pylint==4.1.3; extra == "lint"
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: lodol[lint,test]; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# Lodol for Python
|
|
44
|
+
|
|
45
|
+
Run your Lodol workflows from Python: start runs, wait for them, follow their history, and answer the approvals they ask for.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install lodol
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Python 3.10 or later.
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
Create an API key in Lodol under **Settings > API Keys**, then:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export LODOL_API_KEY="sk_live_..."
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from lodol import Lodol
|
|
63
|
+
|
|
64
|
+
lodol = Lodol()
|
|
65
|
+
|
|
66
|
+
run = lodol.workflows.run("665f...", inputs={"client": "Acme"}, wait=True)
|
|
67
|
+
print(run.outputs) # {"total": 1250}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
That starts the workflow, waits for it to finish, and gives you what it handed back: `run.outputs` holds each output the workflow declares, by name. If the run fails or is stopped, it raises `RunFailedError` instead, so a script never carries on as if it worked.
|
|
71
|
+
|
|
72
|
+
Make one `Lodol()` and reuse it. It keeps connections open between calls and can be shared between threads.
|
|
73
|
+
|
|
74
|
+
## Run a workflow
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
workflow = lodol.workflows.get("665f...")
|
|
78
|
+
|
|
79
|
+
for workflow_input in workflow.inputs:
|
|
80
|
+
print(workflow_input.name, workflow_input.type, workflow_input.required)
|
|
81
|
+
for output in workflow.outputs:
|
|
82
|
+
print(output.name, output.type) # what run.outputs will hold
|
|
83
|
+
|
|
84
|
+
run = lodol.workflows.run(workflow, inputs={"client": "Acme", "month": "2026-05"})
|
|
85
|
+
print(run.id, run.status) # "683b...", "queued"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Without `wait=True`, `run` returns as soon as the run is queued. A run started while the workspace already has as many going as its plan allows comes back `queued` too, and starts when one of them finishes. A workflow's `id` stays the same when it's edited, so it's safe to store.
|
|
89
|
+
|
|
90
|
+
**Inputs** are given by name and converted to each input's type, so `"21"` reaches a number input as `21`, and an optional input you leave out takes its default. Dates, times and `Decimal`s are sent as text Lodol reads (`date(2026, 5, 1)` becomes `"2026-05-01"`). An input the workflow doesn't declare, a missing required one, or a value of the wrong type raises `BadRequestError` naming the input, and nothing runs.
|
|
91
|
+
|
|
92
|
+
**Running something exactly once.** A retried request never starts a second run. To make sure a run happens once even across separate attempts, such as a nightly job that may be restarted, pass the same `idempotency_key` each time:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
lodol.workflows.run(
|
|
96
|
+
"665f...",
|
|
97
|
+
inputs={"client": "Acme", "month": "2026-05"},
|
|
98
|
+
idempotency_key="monthly-report:acme:2026-05",
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Within 24 hours, the same key with the same inputs returns the run it already started, even if the first attempt is still under way. The same key with different inputs raises `UnprocessableEntityError`. Any text up to 256 characters works as a key.
|
|
103
|
+
|
|
104
|
+
**Workflows that start themselves.** A workflow that starts from its own trigger or schedule, which `workflow.starts_from_trigger` says, doesn't run to an end: running it sets it listening, and each event it catches starts a run of its own, which `runs.list` lists. The listening run has `run.is_trigger_listener` set, and its status reads `"running"` for as long as it listens. Waiting on it, with `wait=True` or `runs.wait`, returns it at once, with a warning, since there's no end to wait for.
|
|
105
|
+
|
|
106
|
+
## Wait for a run
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from lodol import RunFailedError, WaitTimeoutError
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
run = lodol.runs.wait("683b...", timeout=600)
|
|
113
|
+
except RunFailedError as error:
|
|
114
|
+
print("It didn't work:", error.run.error)
|
|
115
|
+
except WaitTimeoutError:
|
|
116
|
+
print("Still going after 10 minutes. It carries on in Lodol.")
|
|
117
|
+
else:
|
|
118
|
+
print(run.outputs)
|
|
119
|
+
for step in run.steps or []:
|
|
120
|
+
print(step.index, step.status, step.result)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`wait` checks on the run often at first, then less often, at most every 10 seconds, and never faster than your plan's API allowance (the Free plan's 5 requests a minute means a check every 12 seconds). Rate limits and brief outages don't end a wait: it slows down and keeps going, and gives up only when `timeout` passes, or after five minutes without a successful check.
|
|
124
|
+
|
|
125
|
+
The finished run comes with its outputs (`run.outputs`) and its step results (`run.steps`). An output the run never set is `None`. `RunFailedError` carries the run too, as `error.run`, so you can see which step failed. If the run has finished but its step results can't be fetched in time, you get the run without them rather than an error. Without a `timeout`, `wait` waits as long as the run takes, including while it waits for someone to answer an approval.
|
|
126
|
+
|
|
127
|
+
Each step's `status` is one of `StepStatus`: `"completed"`, `"failed"`, `"continued"` (it failed, but its setting let the run carry on; `step.error` says what went wrong) or `"skipped"` (it's turned off, so it has no result, and `step.reason` says so). A step that retried says how many tries it took in `step.attempts`.
|
|
128
|
+
|
|
129
|
+
## Retry a failed run
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from lodol import RunFailedError
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
run = lodol.workflows.run("5c3d...", wait=True)
|
|
136
|
+
except RunFailedError as error:
|
|
137
|
+
print(error.run.error) # "The accounting system didn't answer"
|
|
138
|
+
# Once it's back, pick up at the step that failed:
|
|
139
|
+
run = lodol.runs.retry(error.run, wait=True)
|
|
140
|
+
|
|
141
|
+
print(run.outputs) # {"synced": 12}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A retry is a new run, with the data the failed one started with (its inputs, or the event its trigger caught), and its `retry_of` is the failed run's `id`. By default it picks up at the step that failed, without running the steps before it again, so what they did (an email sent, a row added) isn't done twice. If the workflow has been edited since, the retry runs it as it is now when the steps before the one that failed are unchanged, so a fix to that step is picked up. `from_start=True` runs the whole workflow again instead. `wait`, `timeout` and `idempotency_key` work as they do for `workflows.run`.
|
|
145
|
+
|
|
146
|
+
`runs.get(run).retry_options` says which retries a failed run offers. A run that didn't fail can't be retried, nor can one a run calling its workflow started (retry that run instead), and one that failed before Lodol kept where runs fail can only be retried from the start. Asking for a retry a run doesn't offer raises `ConflictError` with `code` `"run_not_retryable"`.
|
|
147
|
+
|
|
148
|
+
## Follow runs
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
for run in lodol.runs.list(workflow="665f...", limit=20):
|
|
152
|
+
print(run.created_at, run.status)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Runs come newest first. Loop over the result and pages are fetched as you go, so stopping early costs nothing extra. Leave out `limit` to go through all of them, and `workflow` to see every workflow's runs. Listed runs leave out `outputs` and `steps`; `runs.get` has them. A failed run says why in `run.error`, listed or not.
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from datetime import date
|
|
159
|
+
|
|
160
|
+
for run in lodol.runs.list(status="failed", started_from=date(2026, 10, 1)):
|
|
161
|
+
print(run.started_at, run.workflow_name, run.error)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`status` takes one status or several (`status=["failed", "stopped"]`). `started_from` and `started_to` bound when runs started, `started_to` itself left out: a `date` is the start of that day, UTC, and a `datetime` without a timezone is taken as UTC. `search` finds runs by their workflow's name or a value in their data, such as a client's name in their inputs. A status runs can't be listed by, such as a typo, raises `ValueError` before anything is sent, rather than quietly listing every run.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
run = lodol.runs.get("683b...", include_steps=True)
|
|
168
|
+
lodol.runs.stop(run)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`run.workflow_id` is the workflow's `id`. `run.version_id` is the version of the workflow that ran, which changes as the workflow is edited. Times are timezone-aware `datetime`s.
|
|
172
|
+
|
|
173
|
+
Stopping a run doesn't undo the steps it already finished. `stop` returns the run, as `runs.get` would, with status `"stopping"` while it finishes the step it's on, or `"stopped"`. If the run had already finished, `stop` returns it as it finished rather than raising.
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
run = lodol.runs.get("683b...")
|
|
177
|
+
lodol.runs.stop(run)
|
|
178
|
+
lodol.runs.delete(run)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Deleting a run removes it from the history, as deleting it in Lodol does, along with the runs it started: its trigger's, or the workflows it called. It can't be undone. A run still going has to be stopped first, or deleting it raises `ConflictError` with `code` `"run_not_finished"`. It needs a key with the **Delete runs** scope, which isn't ticked by default, made by a member who's allowed to delete runs. Like a workflow delete, it's never sent twice.
|
|
182
|
+
|
|
183
|
+
## Answer approvals
|
|
184
|
+
|
|
185
|
+
A workflow's **Request approval** step pauses its run until someone answers. Your own system can answer instead of a person in Lodol:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
run = lodol.workflows.run("4e8d...")
|
|
189
|
+
|
|
190
|
+
approval = lodol.approvals.wait_for(run) # until the run asks
|
|
191
|
+
if approval is not None:
|
|
192
|
+
print(approval.title) # "Pay invoice #4471?"
|
|
193
|
+
lodol.approvals.approve(approval, comment="Matches the purchase order")
|
|
194
|
+
|
|
195
|
+
run = lodol.runs.wait(run)
|
|
196
|
+
print(run.outputs) # {"paid": True}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`wait_for` waits until the run asks for an answer and returns the request it's waiting on, or `None` if the run succeeds without asking. It paces itself as `runs.wait` does, raises `RunFailedError` if the run fails or is stopped first, and takes a `timeout` too. A run can ask more than once: call `wait_for` again after each answer. A workflow that starts from its own trigger never asks itself, since the runs its events start do, so `wait_for` returns `None` for it at once, with a warning.
|
|
200
|
+
|
|
201
|
+
To answer whatever is waiting, from any run:
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
for approval in lodol.approvals.list(): # pending ones, newest first
|
|
205
|
+
print(approval.title, approval.details, approval.workflow_name)
|
|
206
|
+
|
|
207
|
+
if approval.kind == "reply":
|
|
208
|
+
lodol.approvals.reply(approval, "The PO number is 4471")
|
|
209
|
+
else:
|
|
210
|
+
lodol.approvals.approve(approval, comment="Matches the purchase order")
|
|
211
|
+
# or lodol.approvals.reject(approval, comment="Wrong amount")
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The run carries on as soon as it's answered. An answer sent with an API key is recorded as that key's. `approvals.list(status="all")` includes answered requests, with `approval.decision` saying who answered, when and how. `approvals.list(run=...)` lists one run's requests, and `workflow=` one workflow's.
|
|
215
|
+
|
|
216
|
+
A request that's no longer pending can't be answered: you get `ConflictError`, whose `code` says what happened to it. `"approval_already_answered"`: someone answered it first. `"approval_expired"`: nobody answered in time, so it closed, or the step's time limit decided it automatically. `"approval_withdrawn"`: the run was stopped, or its workflow deleted, before anyone answered.
|
|
217
|
+
|
|
218
|
+
## Workflows
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
workflows = lodol.workflows.list()
|
|
222
|
+
drafts = [workflow for workflow in workflows if workflow.is_draft]
|
|
223
|
+
|
|
224
|
+
deleted = lodol.workflows.delete("665f...")
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Deleting removes the workflow and every version of it, as deleting it in Lodol does: runs in progress stop, and its trigger or schedule stops listening. It can't be undone. Its past runs stay readable. It needs a key with the **Delete workflows** scope, which isn't ticked by default, made by a member who's allowed to delete workflows.
|
|
228
|
+
|
|
229
|
+
A delete is never sent twice. If it times out, check with `lodol.workflows.get(...)` before trying again, since it may have happened.
|
|
230
|
+
|
|
231
|
+
## Create and change workflows
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
program = {
|
|
235
|
+
"kind": "Program",
|
|
236
|
+
"version": 1,
|
|
237
|
+
"body": [
|
|
238
|
+
{
|
|
239
|
+
"kind": "ExprStmt",
|
|
240
|
+
"expr": {
|
|
241
|
+
"kind": "DisplayBuiltIn",
|
|
242
|
+
"value": {"kind": "Literal", "value": "Hello from Lodol"},
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
check = lodol.workflows.validate(program) # saves nothing
|
|
249
|
+
if not check.valid:
|
|
250
|
+
raise ValueError(check.error)
|
|
251
|
+
|
|
252
|
+
workflow = lodol.workflows.create("Say hello", program, description="A first workflow")
|
|
253
|
+
print(workflow.url) # where to open it in Lodol
|
|
254
|
+
|
|
255
|
+
workflow = lodol.workflows.edit(
|
|
256
|
+
workflow,
|
|
257
|
+
[{"op": "replace", "path": "/body/0/expr/value/value", "value": "Hello again"}],
|
|
258
|
+
)
|
|
259
|
+
workflow = lodol.workflows.update(workflow, name="Say hello again")
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
A workflow's steps are its `program`, the same structure `workflows.get(...).program` gives you. `validate` checks one without saving it: `check.error` says what to fix, and for a valid program, `check.warnings` names likely mistakes. It checks the program, not what the key's member may use (connections, and the workflows it calls), so a program that passes can still be refused when saved. `create` saves a new workflow, published, and private to the key's member until they share it in Lodol. It doesn't run it. A program Lodol can't save raises `BadRequestError` with `code` `"invalid_workflow"`, and nothing is saved.
|
|
263
|
+
|
|
264
|
+
`update` replaces a workflow's `program`, `name` or `description`, leaving the rest as it is, and `edit` changes its program with JSON Patch operations (`"replace"`, `"add"` or `"remove"`), applied in order to the program as it is now. Each is saved as an edit in Lodol is: as a new version, or, when the workflow was saved within the last few minutes, over that version; `workflow.version_id` names the version saved. Saving pauses the trigger of a workflow that was listening, and `workflow.trigger_paused` says so: run it again to set it listening on the new version. After any save, `workflow.apps_to_connect` names the apps it uses that the key's member has yet to connect in Lodol, which it can't run without.
|
|
265
|
+
|
|
266
|
+
These need a key with the **Create and edit workflows** scope (`workflows:write`), which is ticked by default, made by a member allowed to create or edit workflows. A save is never made twice: a retried request gets back the workflow the first one saved.
|
|
267
|
+
|
|
268
|
+
## Errors
|
|
269
|
+
|
|
270
|
+
When Lodol or the network says no, the SDK raises a `LodolError`. Errors from the API carry Lodol's own explanation as their message, and `code`, a stable name for what went wrong (such as `"run_already_finished"`): decide what to do from `code`, since the wording may change. `request_id` names the request; it's also in the error's text, so quote that when you contact Lodol's support. A call that can't be made as written (inputs that aren't a dict, a timeout that isn't a number) raises `TypeError` or `ValueError` before anything is sent.
|
|
271
|
+
|
|
272
|
+
| Exception | When | What to do |
|
|
273
|
+
| --- | --- | --- |
|
|
274
|
+
| `RunFailedError` | A run you waited for failed or was stopped | Look at `error.run` |
|
|
275
|
+
| `WaitTimeoutError` | A run wasn't finished within `timeout` | Wait again, or stop it. The run carries on |
|
|
276
|
+
| `BadRequestError` | The request was refused, such as inputs that don't fit | Read the message: it names the problem |
|
|
277
|
+
| `AuthenticationError` | The key is missing, wrong, revoked or expired | Check `LODOL_API_KEY` |
|
|
278
|
+
| `PermissionDeniedError` | The key's scopes, or its creator's role, don't allow this | Give the key the scope, or use another key |
|
|
279
|
+
| `NotFoundError` | No such workflow, run or approval in this workspace | Check the id |
|
|
280
|
+
| `ConflictError` | An approval was already answered, expired or was withdrawn (`code` says which); or Lodol was still working on an earlier attempt of a request after five minutes | For an approval, nothing. Otherwise check before trying again: the message has the key to retry with |
|
|
281
|
+
| `RunLimitError` | This month's runs are used up | Runs come back at `error.resets_at` |
|
|
282
|
+
| `RateLimitError` | Too many requests, for longer than the SDK waits on its own | Wait `error.retry_after` seconds |
|
|
283
|
+
| `APIConnectionError`, `APITimeoutError` | Lodol couldn't be reached, didn't answer, or had a certificate that couldn't be verified | Try again later; for a certificate, see the message |
|
|
284
|
+
|
|
285
|
+
## What happens automatically
|
|
286
|
+
|
|
287
|
+
- **Retries.** A request that fails for a passing reason (a dropped connection, a server error, a rate limit that clears within a minute) is sent again, up to `max_retries` times, with growing pauses. Only requests that can't take effect twice are retried: reads, and checking a workflow; runs, run retries, stops, approval answers and workflow saves, which carry an idempotency key; and anything a rate limit refused, since Lodol refuses before doing anything. A delete that fails otherwise, and limits that last longer, are reported at once.
|
|
288
|
+
- **Idempotency keys.** Every run, run retry, stop, approval answer and workflow save gets one, so a retry can never start a second run, answer twice or save twice. If a retry finds Lodol still working on the first attempt, it waits for that, up to five minutes, rather than starting again.
|
|
289
|
+
- **Timeouts.** Each request may take 30 seconds (`timeout`). Waiting for a run has its own `timeout`.
|
|
290
|
+
- **Security.** Your key is only sent over HTTPS, except to Lodol Local on your own machine, which is reached directly, never through a proxy. It never appears in logs or reprs.
|
|
291
|
+
|
|
292
|
+
## Async
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
from lodol import AsyncLodol
|
|
296
|
+
|
|
297
|
+
async with AsyncLodol() as lodol:
|
|
298
|
+
run = await lodol.workflows.run("665f...", inputs={"client": "Acme"}, wait=True)
|
|
299
|
+
|
|
300
|
+
async for approval in lodol.approvals.list():
|
|
301
|
+
print(approval.title)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`AsyncLodol` does everything `Lodol` does, with every call awaited and lists looped over with `async for`. It works with asyncio and trio.
|
|
305
|
+
|
|
306
|
+
## Lodol Local
|
|
307
|
+
|
|
308
|
+
Point the client at your own installation:
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
from lodol import Lodol
|
|
312
|
+
|
|
313
|
+
lodol = Lodol(base_url="http://localhost:3901")
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
or set `LODOL_BASE_URL`. Plain `http://` is allowed only for this machine (`localhost`, or `host.docker.internal` from a container); anything else must be `https://`.
|
|
317
|
+
|
|
318
|
+
## Settings
|
|
319
|
+
|
|
320
|
+
| Argument | Environment variable | Default |
|
|
321
|
+
| --- | --- | --- |
|
|
322
|
+
| `api_key` | `LODOL_API_KEY` | (required) |
|
|
323
|
+
| `base_url` | `LODOL_BASE_URL` | `https://api-prod.lodol.com/api/v1` |
|
|
324
|
+
| `timeout` | | `30` seconds per request |
|
|
325
|
+
| `max_retries` | | `2` |
|
|
326
|
+
| `http_client` | | Your own `httpx2.Client` (or `httpx2.AsyncClient`) for proxies, custom certificates or tests |
|
|
327
|
+
|
|
328
|
+
Proxies and custom certificates are also picked up from the usual environment variables (`HTTPS_PROXY`, `SSL_CERT_FILE`).
|
|
329
|
+
|
|
330
|
+
## Logging
|
|
331
|
+
|
|
332
|
+
Set `LODOL_LOG=info` to see retries and what runs you're waiting for are doing, or `LODOL_LOG=debug` to see every request too. Or configure the `lodol` logger with Python's `logging`. Requests are logged by method, path and status only, never with your key or the inputs you send.
|
|
333
|
+
|
|
334
|
+
## Testing your code
|
|
335
|
+
|
|
336
|
+
Give the client an `httpx2` mock transport and answer as Lodol would:
|
|
337
|
+
|
|
338
|
+
```python
|
|
339
|
+
import httpx2
|
|
340
|
+
from lodol import Lodol
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def fake_lodol(request: httpx2.Request) -> httpx2.Response:
|
|
344
|
+
if request.url.path.endswith("/run-async"):
|
|
345
|
+
return httpx2.Response(202, json={"execution_id": "683b", "status": "queued"})
|
|
346
|
+
return httpx2.Response(404, json={"error": "Workflow not found"})
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
lodol = Lodol(
|
|
350
|
+
api_key="sk_live_test",
|
|
351
|
+
http_client=httpx2.Client(transport=httpx2.MockTransport(fake_lodol)),
|
|
352
|
+
)
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Models are plain data, so you can also build them directly: `Run(id="683b", status="success")`. They can be compared, hashed and pickled, and so can the SDK's errors.
|
|
356
|
+
|
|
357
|
+
## Endpoints the SDK doesn't cover yet
|
|
358
|
+
|
|
359
|
+
```python
|
|
360
|
+
body = lodol.request("GET", "/approvals", params={"status": "all"})
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
`request` reaches any Developer API endpoint and returns its JSON. Errors raise as they do everywhere else. A write is retried only when you give it an `idempotency_key`.
|
|
364
|
+
|
|
365
|
+
## Upgrading from 0.1
|
|
366
|
+
|
|
367
|
+
0.2 renames executions to runs, to match Lodol, and changes a few behaviors. See [CHANGELOG.md](CHANGELOG.md) for the full list.
|