labcode 0.1.5__tar.gz → 0.1.6__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.
- {labcode-0.1.5 → labcode-0.1.6}/PKG-INFO +2 -2
- {labcode-0.1.5 → labcode-0.1.6}/SPECIFICATIONS.md +50 -5
- {labcode-0.1.5 → labcode-0.1.6}/examples/README.md +12 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/backend.py +31 -1
- {labcode-0.1.5 → labcode-0.1.6}/labcode/dialect.py +8 -2
- {labcode-0.1.5 → labcode-0.1.6}/labcode/extension.py +59 -1
- {labcode-0.1.5 → labcode-0.1.6}/labcode/run_cli.py +44 -2
- {labcode-0.1.5 → labcode-0.1.6}/labcode/runner.py +13 -4
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/PKG-INFO +2 -2
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/requires.txt +1 -1
- labcode-0.1.6/labcode.egg-info/scm_version.json +8 -0
- {labcode-0.1.5 → labcode-0.1.6}/pyproject.toml +8 -7
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_backend.py +52 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_dialect.py +29 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_run_cli.py +76 -0
- labcode-0.1.5/labcode.egg-info/scm_version.json +0 -8
- {labcode-0.1.5 → labcode-0.1.6}/.github/workflows/ci.yml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/.github/workflows/publish.yml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/.gitignore +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/LICENSE +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/MANIFEST.in +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/README.md +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.boundary.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.observation.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.plan.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/plate_line.svg +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/outputs/sila2_plate_cycle.svg +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/plate_line.boundary.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/plate_line.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/plate_line.workflow.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/render_plate_line.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/render_sila2_plate_cycle.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/run_all_sila2_examples.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/run_sila2_plate_cycle.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/run_sila2_seal.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_plate_cycle.boundary.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_plate_cycle.workflow.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.boundary.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.workflow.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/examples/sila2_seal.wrapped.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/__init__.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/__main__.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/_child.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/cli.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/idgen.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/objectid.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/probe.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/py.typed +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/sila2.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode/sila2_commands.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/SOURCES.txt +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/dependency_links.txt +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/entry_points.txt +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/scm_file_list.json +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/labcode.egg-info/top_level.txt +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/setup.cfg +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/device_script.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/device_script.workflow.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/reroute_device.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/reroute_transporter.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/transport.env.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/fixtures/transport.workflow.yaml +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_cli.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_objectid.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_probe.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_sila2.py +0 -0
- {labcode-0.1.5 → labcode-0.1.6}/tests/test_sila2_commands.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
|
|
5
5
|
Author-email: Kazunari Kaizu <kwaizu@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: ofplang-validate>=0.1.3
|
|
24
24
|
Requires-Dist: ofplang-schedule>=0.1.3
|
|
25
|
-
Requires-Dist: ofplang-run>=0.1.
|
|
25
|
+
Requires-Dist: ofplang-run>=0.1.12
|
|
26
26
|
Provides-Extra: test
|
|
27
27
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
28
|
Provides-Extra: dev
|
|
@@ -367,8 +367,11 @@ equally available to a `raw` script, and stays visible in the code that depends
|
|
|
367
367
|
`settle` that times out fails the *operation* while the instrument carries on. Whatever
|
|
368
368
|
state that leaves the lab in is the operator's to restore, as for any operation that
|
|
369
369
|
failed part way. The default timeout is therefore generous rather than tight: its purpose
|
|
370
|
-
is to turn a hang into a diagnosable failure
|
|
371
|
-
|
|
370
|
+
is to turn a hang into a diagnosable failure.
|
|
371
|
+
- **It is the inner of two limits.** This one is per command, chosen by the script that
|
|
372
|
+
knows what it is waiting for, and its failure can name the command that hung. The outer
|
|
373
|
+
one (§1.8) is per operation and lab-wide, and catches the hangs no script is watching
|
|
374
|
+
for. The outer default is looser than this one, so where both apply this is what fires.
|
|
372
375
|
- **Its timeout is in real seconds**, and is unrelated to the mode's `duration` — which is
|
|
373
376
|
an *estimate*, in environment time, for scheduling. A schedule's estimate is not a
|
|
374
377
|
deadline, and `--seconds-per-tick` does not rescale the timeout.
|
|
@@ -379,8 +382,9 @@ equally available to a `raw` script, and stays visible in the code that depends
|
|
|
379
382
|
|
|
380
383
|
### 1.7 Where an `x-labcode` may appear
|
|
381
384
|
|
|
382
|
-
The positions of §1 are the only ones: the environment **root** (`probe` defaults
|
|
383
|
-
`processes.<p>.modes[]`, `transports[]`, `devices[]` and
|
|
385
|
+
The positions of §1 are the only ones: the environment **root** (`probe` defaults and
|
|
386
|
+
`op_timeout`), `processes.<p>.modes[]`, `transports[]`, `devices[]` and
|
|
387
|
+
`transporters[]`. An `x-labcode`
|
|
384
388
|
anywhere else in the environment — on a process, beside `time` — is an **error**, as is a
|
|
385
389
|
key at a position that does not define it (a `connection` at the root, a `probe` on a mode).
|
|
386
390
|
Nothing would read it, and `ofplang-schedule` tolerates an `x-` key at *every* position
|
|
@@ -390,6 +394,45 @@ This rule covers the environment only. An `x-labcode` in the **workflow** is not
|
|
|
390
394
|
that document is portable v0, read by other implementations, and what extension keys it
|
|
391
395
|
carries is not labcode's business.
|
|
392
396
|
|
|
397
|
+
### 1.8 Operation timeout — `op_timeout`
|
|
398
|
+
|
|
399
|
+
How long **one operation** may run before labcode stops waiting for it, in **real
|
|
400
|
+
seconds**. It lives at the environment root and nowhere else:
|
|
401
|
+
|
|
402
|
+
```yaml
|
|
403
|
+
x-labcode:
|
|
404
|
+
op_timeout: 7200 # a positive number of real seconds (the default)
|
|
405
|
+
# op_timeout: null # or: wait as long as it takes
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
- `op_timeout` MUST be a positive, finite number, or `null` for **no limit**. `0` is not a
|
|
409
|
+
way to say "no limit" and is an error; a machine may not declare one (a per-machine key
|
|
410
|
+
is an unknown key, §1.1).
|
|
411
|
+
- **One value for the whole lab.** The fine-grained waits belong to the scripts, which know
|
|
412
|
+
what they are waiting for (`settle`, §1.6.1); this value only has to clear the longest
|
|
413
|
+
operation the lab legitimately runs. Its default (7200 s) is twice the `settle` default,
|
|
414
|
+
so where both apply the inner one — which can name the command — fires first.
|
|
415
|
+
- **The clock is real seconds**, from the moment the operation starts, covering everything
|
|
416
|
+
it does: connecting, every command it issues, and its own waiting. It is unrelated to the
|
|
417
|
+
mode's `duration` (an *estimate*, in environment time, for scheduling) and is not
|
|
418
|
+
rescaled by `--seconds-per-tick`.
|
|
419
|
+
- **What happens when it expires**: the operation's child process is stopped and the
|
|
420
|
+
operation **fails** with the reason code `op_timeout`. That is an ordinary graceful
|
|
421
|
+
failure — the run stops, the status document is written, the reason is reported, the exit
|
|
422
|
+
code is 1 — which is the point: without a limit, an instrument that stops answering
|
|
423
|
+
leaves a run polling with *no* status document and no reason at all.
|
|
424
|
+
- **A timeout is not a cancel**, exactly as in §1.6.1: nothing here can stop a command the
|
|
425
|
+
instrument has already accepted. It keeps running, and the state that leaves behind —
|
|
426
|
+
including material a transport was part way through moving — is the operator's to
|
|
427
|
+
restore. The run stops there, so labcode's own picture of the lab is not relied on
|
|
428
|
+
afterwards.
|
|
429
|
+
- The machine that hung is **not** treated as unavailable: `op_timeout` does not add it to
|
|
430
|
+
the down machines (§1.5), because "not answering" is not "not there", and re-routing work
|
|
431
|
+
onto other machines while this one is still physically running its command would make the
|
|
432
|
+
lab less consistent, not more.
|
|
433
|
+
- `lc run` overrides it for one run: `--op-timeout SECONDS`, or `--no-op-timeout` for no
|
|
434
|
+
limit at all. The order is flag, then document, then default.
|
|
435
|
+
|
|
393
436
|
## 2. Code source resolution and exclusivity
|
|
394
437
|
|
|
395
438
|
For a dispatched `(process, mode)`, labcode resolves the code to run in this order:
|
|
@@ -412,7 +455,9 @@ Each dispatched operation runs in its own child process (real, wall-clock-paced)
|
|
|
412
455
|
runner discovers completion by polling, so a multi-minute computation never blocks it.
|
|
413
456
|
The advisory `duration` is the scheduler's estimate; the real duration is the script's.
|
|
414
457
|
A script error (an exception, a wrong/ missing output name, a non-conformant value) is a
|
|
415
|
-
graceful runtime failure (§22.2): the operation ends `failed` and the run stops.
|
|
458
|
+
graceful runtime failure (§22.2): the operation ends `failed` and the run stops. An
|
|
459
|
+
operation that never finishes at all ends the same way once it passes `op_timeout` (§1.8) —
|
|
460
|
+
polling for completion is not the same as waiting forever for it.
|
|
416
461
|
|
|
417
462
|
Cadence: the nominal poll period is `poll_interval × seconds_per_tick`. labcode defaults
|
|
418
463
|
`seconds_per_tick` to ~20 s (so a real op is polled at an observable cadence, not
|
|
@@ -168,6 +168,18 @@ arm, so there is nothing to route around, and what a stopped lab should produce
|
|
|
168
168
|
instrument command failing where it was issued. `lc run --no-probe` turns probing off for a
|
|
169
169
|
run without editing the environment.
|
|
170
170
|
|
|
171
|
+
### If a machine stops answering *mid-operation*
|
|
172
|
+
|
|
173
|
+
Probing catches a machine that is not there; it does not catch one that accepted a command
|
|
174
|
+
and never came back. That is what the **operation timeout** is for
|
|
175
|
+
([`../SPECIFICATIONS.md`](../SPECIFICATIONS.md) §1.8): every operation has a real-seconds
|
|
176
|
+
deadline (7200 s by default, declared lab-wide at the environment root as
|
|
177
|
+
`x-labcode.op_timeout`), and one that passes it is stopped and failed with the reason
|
|
178
|
+
`op_timeout` — a run that ends with a status document and a reason instead of one that
|
|
179
|
+
polls forever. A script that knows its own commands should still bound them itself with
|
|
180
|
+
`settle(..., timeout=...)`: it fires first and can say *which command* hung. `lc run
|
|
181
|
+
--op-timeout SECONDS` / `--no-op-timeout` change the outer limit for a single run.
|
|
182
|
+
|
|
171
183
|
### Prerequisites
|
|
172
184
|
|
|
173
185
|
**Verified against [ofplang-sila2-backend](https://github.com/kaizu/sila2-demo) v0.3.0 (commit
|
|
@@ -33,6 +33,7 @@ import subprocess
|
|
|
33
33
|
import sys
|
|
34
34
|
import time
|
|
35
35
|
from collections.abc import Callable
|
|
36
|
+
from typing import Literal
|
|
36
37
|
|
|
37
38
|
from ofplang.run.simulator import (
|
|
38
39
|
DeviceComputationError,
|
|
@@ -41,7 +42,9 @@ from ofplang.run.simulator import (
|
|
|
41
42
|
)
|
|
42
43
|
|
|
43
44
|
from labcode.extension import (
|
|
45
|
+
DEFAULT_OP_TIMEOUT,
|
|
44
46
|
FLAVOR_SILA2,
|
|
47
|
+
declared_op_timeout,
|
|
45
48
|
device_connections,
|
|
46
49
|
python_code,
|
|
47
50
|
script_endpoints,
|
|
@@ -65,6 +68,11 @@ from labcode.sila2 import failing_code, plan_clients, wrap
|
|
|
65
68
|
# reads as discrete, observable steps rather than a burst of sub-second replans.
|
|
66
69
|
DEFAULT_SECONDS_PER_TICK = 20.0
|
|
67
70
|
|
|
71
|
+
#: `op_timeout="environment"` -- take the operation timeout from the environment document
|
|
72
|
+
#: (or the default). It is a distinct value from `None`, which says the opposite: run with
|
|
73
|
+
#: no deadline at all, whatever the document declares.
|
|
74
|
+
FROM_ENVIRONMENT: Literal["environment"] = "environment"
|
|
75
|
+
|
|
68
76
|
|
|
69
77
|
def _flavored(code: str, script, machines, label: str) -> str:
|
|
70
78
|
"""`code` as its flavor asks for it: a `sila2` script wrapped so its clients are open
|
|
@@ -333,6 +341,21 @@ class LabcodeBackend(SubprocessBackend):
|
|
|
333
341
|
return uuid
|
|
334
342
|
|
|
335
343
|
|
|
344
|
+
def _effective_op_timeout(
|
|
345
|
+
environment: dict, requested: float | None | Literal["environment"]
|
|
346
|
+
) -> float | None:
|
|
347
|
+
"""How long an operation may run in this environment, in real seconds (None = no
|
|
348
|
+
limit): what the caller asked for, else what the document declares, else the default.
|
|
349
|
+
|
|
350
|
+
The three layers are `lc run --op-timeout` / `--no-op-timeout`, the environment root's
|
|
351
|
+
``x-labcode.op_timeout``, and `DEFAULT_OP_TIMEOUT` -- a lab that says nothing still
|
|
352
|
+
gets a limit, because a hang that nobody bounded is the thing this is here to stop."""
|
|
353
|
+
if isinstance(requested, str): # the sentinel: the document decides
|
|
354
|
+
declared, value = declared_op_timeout(environment)
|
|
355
|
+
return value if declared else DEFAULT_OP_TIMEOUT
|
|
356
|
+
return requested
|
|
357
|
+
|
|
358
|
+
|
|
336
359
|
def labcode_backend_factory(
|
|
337
360
|
*,
|
|
338
361
|
seconds_per_tick: float = DEFAULT_SECONDS_PER_TICK,
|
|
@@ -345,6 +368,7 @@ def labcode_backend_factory(
|
|
|
345
368
|
prober: Prober | None = None,
|
|
346
369
|
on_availability_change: ChangeReporter | None = None,
|
|
347
370
|
on_cadence_slip: CadenceReporter | None = None,
|
|
371
|
+
op_timeout: float | None | Literal["environment"] = FROM_ENVIRONMENT,
|
|
348
372
|
) -> Callable[[dict], SubprocessBackend]:
|
|
349
373
|
"""Build a ``backend_factory(environment) -> SubprocessBackend`` for the runner,
|
|
350
374
|
wired with the labcode env-script resolver (`make_code_resolver`).
|
|
@@ -361,7 +385,12 @@ def labcode_backend_factory(
|
|
|
361
385
|
``lc run --no-probe`` does). `prober` decides how a machine is checked (default: a TCP
|
|
362
386
|
connection), and `on_availability_change(id, reachable)` is told when one changes, so a
|
|
363
387
|
caller can report it. `on_cadence_slip(skipped, budget, spent)` is told once if a poll
|
|
364
|
-
cycle outgrows its poll period (see `LabcodeBackend.advance`).
|
|
388
|
+
cycle outgrows its poll period (see `LabcodeBackend.advance`).
|
|
389
|
+
|
|
390
|
+
`op_timeout` bounds how long one operation may run, in **real seconds**: by default the
|
|
391
|
+
environment's ``x-labcode.op_timeout`` (or `DEFAULT_OP_TIMEOUT` if it declares none),
|
|
392
|
+
which a caller overrides with a number of its own or `None` for no limit at all -- what
|
|
393
|
+
``lc run --op-timeout`` / ``--no-op-timeout`` pass."""
|
|
365
394
|
|
|
366
395
|
def factory(environment: dict) -> LabcodeBackend:
|
|
367
396
|
kwargs: dict = {
|
|
@@ -373,6 +402,7 @@ def labcode_backend_factory(
|
|
|
373
402
|
"monotonic": monotonic,
|
|
374
403
|
"sleep": sleep,
|
|
375
404
|
"on_cadence_slip": on_cadence_slip,
|
|
405
|
+
"op_timeout": _effective_op_timeout(environment, op_timeout),
|
|
376
406
|
}
|
|
377
407
|
if probe:
|
|
378
408
|
kwargs["availability"] = build_availability(
|
|
@@ -57,6 +57,7 @@ from labcode.extension import (
|
|
|
57
57
|
is_supported_position,
|
|
58
58
|
merge_probe,
|
|
59
59
|
parse_connection,
|
|
60
|
+
parse_op_timeout,
|
|
60
61
|
parse_probe,
|
|
61
62
|
script_flavor,
|
|
62
63
|
spot_device,
|
|
@@ -143,8 +144,8 @@ def _validate_positions(environment: dict, errors: list) -> None:
|
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
def _validate_root(environment: dict, errors: list) -> dict:
|
|
146
|
-
"""Shape-check the environment root's ``x-labcode`` -- document-wide
|
|
147
|
-
|
|
147
|
+
"""Shape-check the environment root's ``x-labcode`` -- document-wide defaults, and
|
|
148
|
+
nothing else (an address belongs to the machine that has it). Returns the probe
|
|
148
149
|
fields it declares, for the effective policies below."""
|
|
149
150
|
extension = environment.get(EXTENSION_KEY)
|
|
150
151
|
if extension is None:
|
|
@@ -156,6 +157,11 @@ def _validate_root(environment: dict, errors: list) -> dict:
|
|
|
156
157
|
f"environment root: {message}"
|
|
157
158
|
for message in unknown_key_messages(extension, "x-labcode", ROOT_KEYS)
|
|
158
159
|
)
|
|
160
|
+
# The operation timeout is one value for the whole lab, so it lives here and nowhere
|
|
161
|
+
# else -- a mode that needs a different wait says so inside its own script.
|
|
162
|
+
if "op_timeout" in extension:
|
|
163
|
+
_value, messages = parse_op_timeout(extension["op_timeout"])
|
|
164
|
+
errors.extend(f"environment root: x-labcode.{message}" for message in messages)
|
|
159
165
|
raw = extension.get("probe")
|
|
160
166
|
if raw is None:
|
|
161
167
|
return {}
|
|
@@ -43,7 +43,7 @@ SCRIPT_SITE_KEYS: tuple[str, ...] = ("script",)
|
|
|
43
43
|
#: `x-labcode` on a device or a transporter -- how to reach it, and whether to check.
|
|
44
44
|
NODE_KEYS: tuple[str, ...] = ("connection", "probe")
|
|
45
45
|
#: `x-labcode` at the environment root -- document-wide defaults, and nothing else.
|
|
46
|
-
ROOT_KEYS: tuple[str, ...] = ("probe",)
|
|
46
|
+
ROOT_KEYS: tuple[str, ...] = ("probe", "op_timeout")
|
|
47
47
|
CONNECTION_KEYS: tuple[str, ...] = ("kind", "host", "port", "insecure")
|
|
48
48
|
PROBE_KEYS: tuple[str, ...] = ("enabled", "timeout", "interval")
|
|
49
49
|
|
|
@@ -168,6 +168,64 @@ def parse_connection(raw: Any) -> tuple[Connection | None, list[str]]:
|
|
|
168
168
|
return connection, []
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
# -- operation timeout ----------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
#: How long one operation may run before labcode gives up on it, in **real seconds**.
|
|
174
|
+
#:
|
|
175
|
+
#: Deliberately generous, and deliberately finite. Its job is not to notice a slow
|
|
176
|
+
#: instrument -- a script that knows what it is waiting for should say so with its own
|
|
177
|
+
#: `labcode.sila2_commands.settle(timeout=...)`, which can name the command that hung.
|
|
178
|
+
#: This is the outer net: it catches the hangs no script is watching for (a `raw` script,
|
|
179
|
+
#: a forgotten `settle`, a stall while connecting), and it exists at all because without
|
|
180
|
+
#: it a silent instrument stops a run with no status document and no reason written.
|
|
181
|
+
#:
|
|
182
|
+
#: It is **one value for the whole lab**, not a per-machine one: the fine-grained waits
|
|
183
|
+
#: belong to the scripts, so the value here only has to clear the longest operation the
|
|
184
|
+
#: lab legitimately runs. It is twice the `settle` default so that when both apply, the
|
|
185
|
+
#: inner wait -- which can say *which command* did not answer -- is the one that fires.
|
|
186
|
+
DEFAULT_OP_TIMEOUT = 7200.0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_op_timeout(raw: Any) -> tuple[float | None, list[str]]:
|
|
190
|
+
"""Parse a **declared** ``x-labcode.op_timeout``: seconds, or None for `null` (wait
|
|
191
|
+
forever). Returns ``(value, errors)``.
|
|
192
|
+
|
|
193
|
+
Only call this when the key is present: a declared `null` and no declaration at all
|
|
194
|
+
mean different things (wait forever / use the default), and a mapping cannot tell
|
|
195
|
+
them apart once the value is out."""
|
|
196
|
+
if raw is None:
|
|
197
|
+
return None, []
|
|
198
|
+
if (
|
|
199
|
+
isinstance(raw, bool)
|
|
200
|
+
or not isinstance(raw, (int, float))
|
|
201
|
+
or raw <= 0
|
|
202
|
+
or raw in (float("inf"), float("-inf"))
|
|
203
|
+
or raw != raw # NaN
|
|
204
|
+
):
|
|
205
|
+
return None, [
|
|
206
|
+
"op_timeout must be a positive, finite number of seconds, or null (no limit)"
|
|
207
|
+
]
|
|
208
|
+
return float(raw), []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def declared_op_timeout(environment: Any) -> tuple[bool, float | None]:
|
|
212
|
+
"""The operation timeout the environment root declares: ``(declared?, seconds)``,
|
|
213
|
+
where `seconds` is None for "wait forever".
|
|
214
|
+
|
|
215
|
+
Best-effort, like the other readers here: a malformed value reads as *not declared*
|
|
216
|
+
(the dialect validator is what reports it), so execution falls back to the default
|
|
217
|
+
rather than silently to no limit at all."""
|
|
218
|
+
if not isinstance(environment, dict):
|
|
219
|
+
return False, None
|
|
220
|
+
extension = environment.get(EXTENSION_KEY)
|
|
221
|
+
if not isinstance(extension, dict) or "op_timeout" not in extension:
|
|
222
|
+
return False, None
|
|
223
|
+
value, errors = parse_op_timeout(extension["op_timeout"])
|
|
224
|
+
if errors:
|
|
225
|
+
return False, None
|
|
226
|
+
return True, value
|
|
227
|
+
|
|
228
|
+
|
|
171
229
|
# -- probe ---------------------------------------------------------------------
|
|
172
230
|
|
|
173
231
|
#: Probing is **off unless asked for**: writing a policy says *how* to probe, never
|
|
@@ -31,8 +31,9 @@ from ofplang.run import FrontDoorResult, front_door_check
|
|
|
31
31
|
from ofplang.run.runner import ContractSyntaxError, RunnerError, load_document, serialize_document
|
|
32
32
|
from ofplang.run.simulator import SimulatorError
|
|
33
33
|
|
|
34
|
-
from labcode.backend import DEFAULT_SECONDS_PER_TICK
|
|
34
|
+
from labcode.backend import DEFAULT_SECONDS_PER_TICK, FROM_ENVIRONMENT
|
|
35
35
|
from labcode.dialect import validate_dialect
|
|
36
|
+
from labcode.extension import DEFAULT_OP_TIMEOUT
|
|
36
37
|
from labcode.runner import run_labcode
|
|
37
38
|
|
|
38
39
|
EXIT_OK = 0
|
|
@@ -84,6 +85,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
84
85
|
help="ignore the environment's x-labcode.probe policies and treat every machine as"
|
|
85
86
|
" reachable (the document is still validated)",
|
|
86
87
|
)
|
|
88
|
+
# One operation timeout for the whole lab, in real seconds. The two forms exclude each
|
|
89
|
+
# other: asking for a limit and for no limit in the same breath is a mistake, not a
|
|
90
|
+
# precedence puzzle to resolve quietly.
|
|
91
|
+
timeout = p.add_mutually_exclusive_group()
|
|
92
|
+
timeout.add_argument(
|
|
93
|
+
"--op-timeout", type=float, default=None, metavar="S",
|
|
94
|
+
help="stop and fail an operation that has run for S real seconds (default: the"
|
|
95
|
+
f" environment's x-labcode.op_timeout, else {DEFAULT_OP_TIMEOUT:g})",
|
|
96
|
+
)
|
|
97
|
+
timeout.add_argument(
|
|
98
|
+
"--no-op-timeout", action="store_true",
|
|
99
|
+
help="let an operation run for as long as it takes, whatever the environment says",
|
|
100
|
+
)
|
|
87
101
|
return p
|
|
88
102
|
|
|
89
103
|
|
|
@@ -131,6 +145,16 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
131
145
|
print(f"lc run: {label} not found: {path!r}", file=sys.stderr)
|
|
132
146
|
return EXIT_USAGE
|
|
133
147
|
|
|
148
|
+
# A limit of zero (or less) is not "no limit" -- that is what --no-op-timeout says --
|
|
149
|
+
# so it is a usage error rather than something to interpret.
|
|
150
|
+
if args.op_timeout is not None and not args.op_timeout > 0:
|
|
151
|
+
print(
|
|
152
|
+
f"lc run: --op-timeout must be a positive number of seconds "
|
|
153
|
+
f"(got {args.op_timeout:g}); use --no-op-timeout for no limit",
|
|
154
|
+
file=sys.stderr,
|
|
155
|
+
)
|
|
156
|
+
return EXIT_USAGE
|
|
157
|
+
|
|
134
158
|
# Shared workflow front door (ofplang-validate + capability gate; validate skippable).
|
|
135
159
|
fd = front_door_check(args.workflow, validate=not args.no_validate)
|
|
136
160
|
if not fd.ok:
|
|
@@ -193,6 +217,16 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
193
217
|
else ""
|
|
194
218
|
)
|
|
195
219
|
|
|
220
|
+
def timeout_note(kind: str) -> str:
|
|
221
|
+
# The upstream failure says what happened and that nothing cancelled the
|
|
222
|
+
# instrument; what it cannot say is how *this* CLI's user changes the limit.
|
|
223
|
+
if kind != "op_timeout":
|
|
224
|
+
return ""
|
|
225
|
+
return (
|
|
226
|
+
" (raise the limit with --op-timeout SECONDS or the environment root's"
|
|
227
|
+
" x-labcode.op_timeout, or drop it entirely with --no-op-timeout)"
|
|
228
|
+
)
|
|
229
|
+
|
|
196
230
|
try:
|
|
197
231
|
# Validation already ran at the front doors above, so run trusting. `run_labcode`
|
|
198
232
|
# owns the labcode Object-identity setup (rewrite the workflow's Object types to
|
|
@@ -214,6 +248,13 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
214
248
|
probe=not args.no_probe,
|
|
215
249
|
on_availability_change=report_availability,
|
|
216
250
|
on_cadence_slip=report_cadence_slip,
|
|
251
|
+
# Three layers, outermost first: this flag, the environment, the default.
|
|
252
|
+
# `FROM_ENVIRONMENT` is "the flag says nothing"; None is "no limit at all".
|
|
253
|
+
op_timeout=(
|
|
254
|
+
None if args.no_op_timeout
|
|
255
|
+
else FROM_ENVIRONMENT if args.op_timeout is None
|
|
256
|
+
else args.op_timeout
|
|
257
|
+
),
|
|
217
258
|
)
|
|
218
259
|
except (yaml.YAMLError, ContractSyntaxError) as exc:
|
|
219
260
|
print(f"lc run: invalid input: {exc}", file=sys.stderr)
|
|
@@ -232,7 +273,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
232
273
|
failure = result.failure
|
|
233
274
|
if failure is not None:
|
|
234
275
|
print(
|
|
235
|
-
f"lc run: execution failed: {failure.kind}: {failure.detail}
|
|
276
|
+
f"lc run: execution failed: {failure.kind}: {failure.detail}"
|
|
277
|
+
f"{probe_note()}{timeout_note(failure.kind)}",
|
|
236
278
|
file=sys.stderr,
|
|
237
279
|
)
|
|
238
280
|
else:
|
|
@@ -18,11 +18,16 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import time
|
|
20
20
|
from collections.abc import Callable
|
|
21
|
+
from typing import Literal
|
|
21
22
|
|
|
22
23
|
from ofplang.run.app import RunResult
|
|
23
24
|
from ofplang.run.runner import RollingRunner, RunnerError, load_document
|
|
24
25
|
|
|
25
|
-
from labcode.backend import
|
|
26
|
+
from labcode.backend import (
|
|
27
|
+
DEFAULT_SECONDS_PER_TICK,
|
|
28
|
+
FROM_ENVIRONMENT,
|
|
29
|
+
labcode_backend_factory,
|
|
30
|
+
)
|
|
26
31
|
from labcode.idgen import IdGenerator, SeededUuid4Generator
|
|
27
32
|
from labcode.objectid import inject_boundary_ids, inject_id_field, reserved_collisions
|
|
28
33
|
from labcode.probe import CadenceReporter, ChangeReporter, Prober
|
|
@@ -35,9 +40,11 @@ class LabcodeRunner(RollingRunner):
|
|
|
35
40
|
runner reads it itself). Object types are rewritten to declare ``_id``, the boundary's
|
|
36
41
|
Object inputs are minted, and the labcode backend is wired with a shared `IdGenerator`
|
|
37
42
|
(default: reproducible, seeded). `seconds_per_tick` / `speed` / `spawn` / `monotonic` /
|
|
38
|
-
`sleep` configure the wall-clock backend
|
|
39
|
-
`on_availability_change` its availability probing (`labcode.probe`)
|
|
40
|
-
is
|
|
43
|
+
`sleep` configure the wall-clock backend, `probe` / `prober` /
|
|
44
|
+
`on_availability_change` its availability probing (`labcode.probe`), and `op_timeout`
|
|
45
|
+
how long one operation may run before it is stopped and failed (default: whatever the
|
|
46
|
+
environment's ``x-labcode.op_timeout`` says); any other keyword is forwarded to
|
|
47
|
+
`RollingRunner` (e.g. `random_seed`, `down_scope`, `observation_out`).
|
|
41
48
|
|
|
42
49
|
**`running_task_margin` defaults to the poll interval here**, not to the upstream 0.
|
|
43
50
|
The margin is how far ahead of *now* a still-running operation is assumed to finish
|
|
@@ -64,6 +71,7 @@ class LabcodeRunner(RollingRunner):
|
|
|
64
71
|
prober: Prober | None = None,
|
|
65
72
|
on_availability_change: ChangeReporter | None = None,
|
|
66
73
|
on_cadence_slip: CadenceReporter | None = None,
|
|
74
|
+
op_timeout: float | None | Literal["environment"] = FROM_ENVIRONMENT,
|
|
67
75
|
poll_interval: int | None = 1,
|
|
68
76
|
running_task_margin: int | None = None,
|
|
69
77
|
**rolling_kwargs,
|
|
@@ -91,6 +99,7 @@ class LabcodeRunner(RollingRunner):
|
|
|
91
99
|
prober=prober,
|
|
92
100
|
on_availability_change=on_availability_change,
|
|
93
101
|
on_cadence_slip=on_cadence_slip,
|
|
102
|
+
op_timeout=op_timeout,
|
|
94
103
|
)
|
|
95
104
|
# A margin of at least one tick, defaulting to the poll interval (see the class
|
|
96
105
|
# docstring). An explicit value is honoured as given -- including 0, for a caller
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: labcode
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
|
|
5
5
|
Author-email: Kazunari Kaizu <kwaizu@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: ofplang-validate>=0.1.3
|
|
24
24
|
Requires-Dist: ofplang-schedule>=0.1.3
|
|
25
|
-
Requires-Dist: ofplang-run>=0.1.
|
|
25
|
+
Requires-Dist: ofplang-run>=0.1.12
|
|
26
26
|
Provides-Extra: test
|
|
27
27
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
28
|
Provides-Extra: dev
|
|
@@ -25,16 +25,17 @@ classifiers = [
|
|
|
25
25
|
"Typing :: Typed",
|
|
26
26
|
]
|
|
27
27
|
# labcode bundles the toolchain by depending on the ofplang sibling packages. `lc run`
|
|
28
|
-
# is the labcode dialect backend, so the floors reflect what it needs: run must
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
28
|
+
# is the labcode dialect backend, so the floors reflect what it needs: run must bound how
|
|
29
|
+
# long one operation may run (`SubprocessBackend(op_timeout=...)`), which is what the
|
|
30
|
+
# lab-wide `x-labcode.op_timeout` is carried by (>=0.1.12, and which also takes a
|
|
31
|
+
# *transporter* out of the plan when `down_devices` reports one, from 0.1.11); schedule's
|
|
32
|
+
# in-memory workflow acceptance (>=0.1.3); validate's `$import` expansion API so the front
|
|
33
|
+
# door hands the expanded document on (>=0.1.3). `validate` and `schedule` subcommands
|
|
34
|
+
# still forward unchanged.
|
|
34
35
|
dependencies = [
|
|
35
36
|
"ofplang-validate>=0.1.3",
|
|
36
37
|
"ofplang-schedule>=0.1.3",
|
|
37
|
-
"ofplang-run>=0.1.
|
|
38
|
+
"ofplang-run>=0.1.12",
|
|
38
39
|
]
|
|
39
40
|
|
|
40
41
|
[project.urls]
|
|
@@ -19,6 +19,7 @@ from labcode.backend import (
|
|
|
19
19
|
make_code_resolver,
|
|
20
20
|
make_transport_resolver,
|
|
21
21
|
)
|
|
22
|
+
from labcode.extension import DEFAULT_OP_TIMEOUT
|
|
22
23
|
|
|
23
24
|
WF_DEF = {"script": {"language": "python", "code": "WF"}}
|
|
24
25
|
|
|
@@ -84,6 +85,57 @@ def test_factory_builds_labcode_backend():
|
|
|
84
85
|
backend.close()
|
|
85
86
|
|
|
86
87
|
|
|
88
|
+
# -- the operation timeout: three layers, outermost first ----------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _timeout_env(**extension) -> dict:
|
|
92
|
+
env = {
|
|
93
|
+
"time": {"unit": "second"},
|
|
94
|
+
"devices": [{"id": "rack", "spots": ["slot"]}],
|
|
95
|
+
"transporters": [{"id": "arm"}],
|
|
96
|
+
"transports": [],
|
|
97
|
+
"processes": {},
|
|
98
|
+
"objective": {"kind": "makespan"},
|
|
99
|
+
}
|
|
100
|
+
if extension:
|
|
101
|
+
env["x-labcode"] = extension
|
|
102
|
+
return env
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _built(env: dict, **kw) -> float | None:
|
|
106
|
+
backend = labcode_backend_factory(seconds_per_tick=0.001, **kw)(env)
|
|
107
|
+
try:
|
|
108
|
+
return backend._op_timeout
|
|
109
|
+
finally:
|
|
110
|
+
backend.close()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_a_lab_that_says_nothing_still_gets_a_limit():
|
|
114
|
+
# The point of the default: the hang that stops a run silently is exactly the one
|
|
115
|
+
# nobody thought to bound.
|
|
116
|
+
assert _built(_timeout_env()) == DEFAULT_OP_TIMEOUT
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_the_environment_sets_the_limit():
|
|
120
|
+
assert _built(_timeout_env(op_timeout=120)) == 120.0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_the_environment_can_ask_to_wait_forever():
|
|
124
|
+
# A declared null is a decision on the record, unlike saying nothing.
|
|
125
|
+
assert _built(_timeout_env(op_timeout=None)) is None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_the_caller_overrides_the_environment():
|
|
129
|
+
assert _built(_timeout_env(op_timeout=120), op_timeout=30.0) == 30.0
|
|
130
|
+
assert _built(_timeout_env(op_timeout=120), op_timeout=None) is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_a_malformed_limit_falls_back_to_the_default():
|
|
134
|
+
# The dialect front door rejects it; if one reaches execution anyway, falling back to
|
|
135
|
+
# the default is the safe reading -- silently meaning "no limit" is not.
|
|
136
|
+
assert _built(_timeout_env(op_timeout="soon")) == DEFAULT_OP_TIMEOUT
|
|
137
|
+
|
|
138
|
+
|
|
87
139
|
# -- transport resolution / dispatch -------------------------------------------
|
|
88
140
|
|
|
89
141
|
|
|
@@ -145,6 +145,35 @@ def test_a_connection_at_the_environment_root_is_rejected():
|
|
|
145
145
|
assert any("unknown key 'connection'" in e for e in result.errors)
|
|
146
146
|
|
|
147
147
|
|
|
148
|
+
def test_the_root_holds_the_operation_timeout():
|
|
149
|
+
# One value for the whole lab: how long any operation may run before labcode stops
|
|
150
|
+
# waiting for it. A positive number of real seconds, or null for no limit at all.
|
|
151
|
+
for value in (7200, 0.5, None):
|
|
152
|
+
env = _device_env(_device())
|
|
153
|
+
env["x-labcode"] = {"op_timeout": value}
|
|
154
|
+
result = validate_dialect({}, env)
|
|
155
|
+
assert result.ok, result.errors
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_a_nonsensical_operation_timeout_is_rejected():
|
|
159
|
+
# Zero is not "no limit" (null is), and neither a string nor an infinity is a wait.
|
|
160
|
+
for value in (0, -1, "3600", True, float("inf"), float("nan")):
|
|
161
|
+
env = _device_env(_device())
|
|
162
|
+
env["x-labcode"] = {"op_timeout": value}
|
|
163
|
+
result = validate_dialect({}, env)
|
|
164
|
+
assert not result.ok, value
|
|
165
|
+
assert any("op_timeout" in e for e in result.errors)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_an_operation_timeout_on_a_machine_is_rejected():
|
|
169
|
+
# Per-machine limits are deliberately not a thing: a script that knows what it waits
|
|
170
|
+
# for says so itself (`settle`), and the outer net is one number for the lab.
|
|
171
|
+
env = _device_env(_device(**{"x-labcode": {"op_timeout": 60}}))
|
|
172
|
+
result = validate_dialect({}, env)
|
|
173
|
+
assert not result.ok
|
|
174
|
+
assert any("unknown key 'op_timeout'" in e for e in result.errors)
|
|
175
|
+
|
|
176
|
+
|
|
148
177
|
def test_x_labcode_on_a_process_is_rejected():
|
|
149
178
|
env = _env({"id": "v0"})
|
|
150
179
|
env["processes"]["m"]["x-labcode"] = {"connection": CONNECTION}
|
|
@@ -275,6 +275,82 @@ def test_cli_no_probe_ignores_the_policy(tmp_path, capsys):
|
|
|
275
275
|
assert "unreachable" not in capsys.readouterr().err
|
|
276
276
|
|
|
277
277
|
|
|
278
|
+
# -- the operation timeout -----------------------------------------------------
|
|
279
|
+
|
|
280
|
+
#: `device_script.env.yaml`, but the measurement never returns. This is what an
|
|
281
|
+
#: instrument that has stopped answering looks like from labcode's side: the child is
|
|
282
|
+
#: alive, so no poll will ever complete the op.
|
|
283
|
+
HANGING_ENV = """\
|
|
284
|
+
time: {unit: second}
|
|
285
|
+
devices: [{id: rack, spots: [slot]}]
|
|
286
|
+
transporters: [{id: arm}]
|
|
287
|
+
transports: []
|
|
288
|
+
processes:
|
|
289
|
+
measure:
|
|
290
|
+
modes:
|
|
291
|
+
- id: v0
|
|
292
|
+
duration: 3
|
|
293
|
+
x-labcode:
|
|
294
|
+
script:
|
|
295
|
+
language: python
|
|
296
|
+
code: |
|
|
297
|
+
import time
|
|
298
|
+
time.sleep(600)
|
|
299
|
+
return {"od": 0.42}
|
|
300
|
+
objective: {kind: makespan}
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_cli_stops_a_hung_operation_and_says_how_to_change_the_limit(tmp_path, capsys):
|
|
305
|
+
# End to end with a real child process: the script hangs, the deadline stops it, and
|
|
306
|
+
# the run ends the way any failure does -- a status document, a reason, exit 1 --
|
|
307
|
+
# instead of polling for as long as anyone is willing to wait.
|
|
308
|
+
pytest.importorskip("ofplang.schedule", reason="ofplang-schedule not installed")
|
|
309
|
+
env = tmp_path / "hanging.env.yaml"
|
|
310
|
+
env.write_text(HANGING_ENV, encoding="utf-8")
|
|
311
|
+
status = tmp_path / "status.yaml"
|
|
312
|
+
code = run_cli.main([
|
|
313
|
+
WF, "--env", str(env), "--seconds-per-tick", "0.01",
|
|
314
|
+
"--op-timeout", "1", "-o", str(status),
|
|
315
|
+
])
|
|
316
|
+
assert code == 1
|
|
317
|
+
err = capsys.readouterr().err
|
|
318
|
+
assert "op_timeout" in err
|
|
319
|
+
assert "did not finish within 1s" in err
|
|
320
|
+
assert "--no-op-timeout" in err # the way out is in the message
|
|
321
|
+
assert status.exists() # the failure is on the record, not just on stderr
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_cli_rejects_a_zero_timeout(tmp_path, capsys):
|
|
325
|
+
# Zero is not a way to say "no limit"; --no-op-timeout is.
|
|
326
|
+
code = run_cli.main([WF, "--env", ENV, "--op-timeout", "0"])
|
|
327
|
+
assert code == 2
|
|
328
|
+
assert "--no-op-timeout" in capsys.readouterr().err
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def test_cli_rejects_asking_for_both_a_limit_and_no_limit():
|
|
332
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
333
|
+
run_cli.main([WF, "--env", ENV, "--op-timeout", "10", "--no-op-timeout"])
|
|
334
|
+
assert excinfo.value.code == 2
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def test_cli_no_op_timeout_asks_for_no_deadline(monkeypatch):
|
|
338
|
+
# The flag has to reach the backend, since nothing else can express "wait forever".
|
|
339
|
+
seen = {}
|
|
340
|
+
|
|
341
|
+
def fake_run(*args, **kwargs):
|
|
342
|
+
from ofplang.run.app import RunResult
|
|
343
|
+
|
|
344
|
+
seen["op_timeout"] = kwargs.get("op_timeout")
|
|
345
|
+
return RunResult(
|
|
346
|
+
status={"now": 0, "activities": []}, result_boundary={}, failed=False, failure=None
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
monkeypatch.setattr(run_cli, "run_labcode", fake_run)
|
|
350
|
+
assert run_cli.main([WF, "--env", ENV, "--no-op-timeout"]) == 0
|
|
351
|
+
assert seen["op_timeout"] is None
|
|
352
|
+
|
|
353
|
+
|
|
278
354
|
def test_cli_warns_on_typed_default(tmp_path, capsys, monkeypatch):
|
|
279
355
|
# A process with no script warns (typed-default no-op) but still runs. Stub the run so
|
|
280
356
|
# the test does not spend real wall-clock time.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|