labcode 0.3.0__tar.gz → 0.3.1__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 (89) hide show
  1. {labcode-0.3.0 → labcode-0.3.1}/PKG-INFO +3 -3
  2. {labcode-0.3.0 → labcode-0.3.1}/SPECIFICATIONS.md +36 -6
  3. {labcode-0.3.0 → labcode-0.3.1}/labcode/backend.py +2 -2
  4. {labcode-0.3.0 → labcode-0.3.1}/labcode/dialect.py +66 -20
  5. {labcode-0.3.0 → labcode-0.3.1}/labcode/extension.py +32 -0
  6. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/PKG-INFO +3 -3
  7. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/requires.txt +2 -2
  8. labcode-0.3.1/labcode.egg-info/scm_version.json +8 -0
  9. {labcode-0.3.0 → labcode-0.3.1}/pyproject.toml +11 -5
  10. {labcode-0.3.0 → labcode-0.3.1}/tests/test_backend.py +34 -0
  11. {labcode-0.3.0 → labcode-0.3.1}/tests/test_dialect.py +82 -0
  12. labcode-0.3.0/labcode.egg-info/scm_version.json +0 -8
  13. {labcode-0.3.0 → labcode-0.3.1}/.github/workflows/ci.yml +0 -0
  14. {labcode-0.3.0 → labcode-0.3.1}/.github/workflows/publish.yml +0 -0
  15. {labcode-0.3.0 → labcode-0.3.1}/.gitignore +0 -0
  16. {labcode-0.3.0 → labcode-0.3.1}/LICENSE +0 -0
  17. {labcode-0.3.0 → labcode-0.3.1}/MANIFEST.in +0 -0
  18. {labcode-0.3.0 → labcode-0.3.1}/README.md +0 -0
  19. {labcode-0.3.0 → labcode-0.3.1}/examples/README.md +0 -0
  20. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/plate_line.boundary.yaml +0 -0
  21. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/plate_line.observation.yaml +0 -0
  22. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/plate_line.plan.yaml +0 -0
  23. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/plate_line.svg +0 -0
  24. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
  25. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
  26. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
  27. {labcode-0.3.0 → labcode-0.3.1}/examples/outputs/sila2_plate_cycle.svg +0 -0
  28. {labcode-0.3.0 → labcode-0.3.1}/examples/plate_line.boundary.yaml +0 -0
  29. {labcode-0.3.0 → labcode-0.3.1}/examples/plate_line.env.yaml +0 -0
  30. {labcode-0.3.0 → labcode-0.3.1}/examples/plate_line.workflow.yaml +0 -0
  31. {labcode-0.3.0 → labcode-0.3.1}/examples/preflight_sila2_env.py +0 -0
  32. {labcode-0.3.0 → labcode-0.3.1}/examples/render_plate_line.py +0 -0
  33. {labcode-0.3.0 → labcode-0.3.1}/examples/render_sila2_plate_cycle.py +0 -0
  34. {labcode-0.3.0 → labcode-0.3.1}/examples/run_all_sila2_examples.py +0 -0
  35. {labcode-0.3.0 → labcode-0.3.1}/examples/run_sila2_plate_cycle.py +0 -0
  36. {labcode-0.3.0 → labcode-0.3.1}/examples/run_sila2_plate_cycle_no_atc.py +0 -0
  37. {labcode-0.3.0 → labcode-0.3.1}/examples/run_sila2_seal.py +0 -0
  38. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_plate_cycle.boundary.yaml +0 -0
  39. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_plate_cycle.workflow.yaml +0 -0
  40. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
  41. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_plate_cycle_no_atc.boundary.yaml +0 -0
  42. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_plate_cycle_no_atc.workflow.yaml +0 -0
  43. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_plate_cycle_no_atc.wrapped.env.yaml +0 -0
  44. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_seal.boundary.yaml +0 -0
  45. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_seal.env.yaml +0 -0
  46. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_seal.workflow.yaml +0 -0
  47. {labcode-0.3.0 → labcode-0.3.1}/examples/sila2_seal.wrapped.env.yaml +0 -0
  48. {labcode-0.3.0 → labcode-0.3.1}/labcode/__init__.py +0 -0
  49. {labcode-0.3.0 → labcode-0.3.1}/labcode/__main__.py +0 -0
  50. {labcode-0.3.0 → labcode-0.3.1}/labcode/_child.py +0 -0
  51. {labcode-0.3.0 → labcode-0.3.1}/labcode/cli.py +0 -0
  52. {labcode-0.3.0 → labcode-0.3.1}/labcode/idgen.py +0 -0
  53. {labcode-0.3.0 → labcode-0.3.1}/labcode/objectid.py +0 -0
  54. {labcode-0.3.0 → labcode-0.3.1}/labcode/otel.py +0 -0
  55. {labcode-0.3.0 → labcode-0.3.1}/labcode/otel_sila2.py +0 -0
  56. {labcode-0.3.0 → labcode-0.3.1}/labcode/probe.py +0 -0
  57. {labcode-0.3.0 → labcode-0.3.1}/labcode/py.typed +0 -0
  58. {labcode-0.3.0 → labcode-0.3.1}/labcode/record.py +0 -0
  59. {labcode-0.3.0 → labcode-0.3.1}/labcode/run_cli.py +0 -0
  60. {labcode-0.3.0 → labcode-0.3.1}/labcode/runner.py +0 -0
  61. {labcode-0.3.0 → labcode-0.3.1}/labcode/sila2.py +0 -0
  62. {labcode-0.3.0 → labcode-0.3.1}/labcode/sila2_commands.py +0 -0
  63. {labcode-0.3.0 → labcode-0.3.1}/labcode/sila2_instrument.py +0 -0
  64. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/SOURCES.txt +0 -0
  65. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/dependency_links.txt +0 -0
  66. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/entry_points.txt +0 -0
  67. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/scm_file_list.json +0 -0
  68. {labcode-0.3.0 → labcode-0.3.1}/labcode.egg-info/top_level.txt +0 -0
  69. {labcode-0.3.0 → labcode-0.3.1}/setup.cfg +0 -0
  70. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/device_script.env.yaml +0 -0
  71. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/device_script.workflow.yaml +0 -0
  72. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/replenishment.env.yaml +0 -0
  73. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/reroute_device.env.yaml +0 -0
  74. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/reroute_transporter.env.yaml +0 -0
  75. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/transport.env.yaml +0 -0
  76. {labcode-0.3.0 → labcode-0.3.1}/tests/fixtures/transport.workflow.yaml +0 -0
  77. {labcode-0.3.0 → labcode-0.3.1}/tests/test_cli.py +0 -0
  78. {labcode-0.3.0 → labcode-0.3.1}/tests/test_objectid.py +0 -0
  79. {labcode-0.3.0 → labcode-0.3.1}/tests/test_otel.py +0 -0
  80. {labcode-0.3.0 → labcode-0.3.1}/tests/test_otel_child.py +0 -0
  81. {labcode-0.3.0 → labcode-0.3.1}/tests/test_otel_grpc.py +0 -0
  82. {labcode-0.3.0 → labcode-0.3.1}/tests/test_otel_sila2.py +0 -0
  83. {labcode-0.3.0 → labcode-0.3.1}/tests/test_probe.py +0 -0
  84. {labcode-0.3.0 → labcode-0.3.1}/tests/test_record.py +0 -0
  85. {labcode-0.3.0 → labcode-0.3.1}/tests/test_recording.py +0 -0
  86. {labcode-0.3.0 → labcode-0.3.1}/tests/test_run_cli.py +0 -0
  87. {labcode-0.3.0 → labcode-0.3.1}/tests/test_sila2.py +0 -0
  88. {labcode-0.3.0 → labcode-0.3.1}/tests/test_sila2_commands.py +0 -0
  89. {labcode-0.3.0 → labcode-0.3.1}/tests/test_sila2_instrument.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -21,8 +21,8 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate<0.3,>=0.2
24
- Requires-Dist: ofplang-schedule<0.4,>=0.3
25
- Requires-Dist: ofplang-run<0.5,>=0.4
24
+ Requires-Dist: ofplang-schedule<0.6,>=0.5
25
+ Requires-Dist: ofplang-run<0.7,>=0.6
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
@@ -55,7 +55,9 @@ still validates and schedules as plain v0. Only labcode interprets it.
55
55
  - `endpoints` (**transport routes only**, optional, default `false`): MUST be a boolean —
56
56
  whether this move is also given clients for the devices at either **end** of its route,
57
57
  not only its `transporter` (§1.7). A process mode may not declare it: a mode's machines
58
- are the ones it lists.
58
+ are the ones it lists. A `sila2` script on a route with **no transporter** (§1.3) MUST
59
+ declare it `true`: the ends are then the only machines there are, and a script is never
60
+ given a machine it did not ask for.
59
61
 
60
62
  **Unknown keys are an error** — in `x-labcode` at every position, and in the mappings it
61
63
  holds. A key this version does not know is either a typo or a feature it does not have;
@@ -124,6 +126,24 @@ output is verified. Success is "it ran without raising"; an exception is a grace
124
126
  A route with no `x-labcode.script` runs as a plain timed move — the runner's material
125
127
  bookkeeping only, with no device command (a warned no-op for a real move, from != to).
126
128
 
129
+ **A route with no transporter.** An environment may declare a route that needs no
130
+ transporter at all, by writing `transporter: null` (ofplang-schedule §4.6 / §5.4) — a
131
+ device shifting material between its own spots, a chute. The plan then reports the move
132
+ with `transporter: null` too, and this dialect matches it like any other: routes are keyed
133
+ by `(transporter, from, to)`, so a null one matches a null one, and the script's
134
+ `transporter` local is `None`.
135
+
136
+ Such a move is performed by the **devices at either end of the route** — for a move within
137
+ one device, that device. So a `flavor: sila2` script on such a route must declare
138
+ **`endpoints: true`** (§1.6), and at least one of those devices must declare a
139
+ `connection`; either missing is a front-door error. The `endpoints` request is *required*
140
+ rather than inferred: it is the author's statement of which machines the script drives, and
141
+ reading it for them would decide that on exactly the routes where it matters most. A `raw`
142
+ script, or no script at all, is unaffected — neither is handed clients.
143
+
144
+ `sila2_client` is then the first machine connected to, which is the **source device**: the
145
+ one performing the move, exactly as it is the transporter on a carried route (§1.7).
146
+
127
147
  ### 1.4 `x-labcode` on a replenishment route
128
148
 
129
149
  An environment `replenishments[]` route may carry an `x-labcode` with a `script`: the
@@ -211,14 +231,18 @@ a script uses it.
211
231
  - a mode script with `flavor: sila2` requires **at least one** of that mode's `devices[]`
212
232
  to declare a `connection`;
213
233
  - a transport script with `flavor: sila2` requires that route's `transporter` to declare
214
- one.
234
+ one — or, on a route with **no transporter** (§1.3), requires `endpoints: true` and at
235
+ least one of the devices at its ends to declare one, those being the machines that
236
+ perform such a move.
215
237
 
216
238
  A transport that declares `endpoints: true` is also handed the clients of the devices at
217
239
  either **end** of its route (§1.7), but those are *not* required to declare a `connection`:
218
240
  a route through a plain holding location is ordinary, and the end without an address is
219
241
  simply not connected to (a **warning** when *neither* end has one, since then the request
220
- does nothing). The transporter is the one that must be reachable, because it is the machine
221
- that does the moving — and the one `sila2_client` names. Asking a `raw` script for endpoint
242
+ does nothing an **error** on a route with no transporter, which has nothing else to
243
+ drive). The transporter is the one that must be reachable, because it is the machine that
244
+ does the moving — and the one `sila2_client` names; where there is none, that is the source
245
+ device, for the same reason. Asking a `raw` script for endpoint
222
246
  clients is an **error**: a raw script is handed no clients at all, so the request cannot be
223
247
  honoured.
224
248
 
@@ -320,8 +344,8 @@ of the input ports of §1.2 (or the transport locals of §1.3), the code sees:
320
344
 
321
345
  | name | meaning |
322
346
  |---|---|
323
- | `sila2_clients` | the clients by **machine id**, in the order the operation names its machines: a mode's `devices[]` order, or — for a transport — its `transporter`, followed by the devices at either **end of the route** when it declares `endpoints: true`. Named, not held: a mode declaring `device_access: false` (ofplang-schedule §4.4.2) rests on its devices rather than occupying them, and its script is still handed their clients |
324
- | `sila2_client` | the first of them — for a transport always its `transporter`; the one name a single-machine operation needs |
347
+ | `sila2_clients` | the clients by **machine id**, in the order the operation names its machines: a mode's `devices[]` order, or — for a transport — its `transporter` (absent on a route that has none, §1.3), followed by the devices at either **end of the route** when it declares `endpoints: true`. Named, not held: a mode declaring `device_access: false` (ofplang-schedule §4.4.2) rests on its devices rather than occupying them, and its script is still handed their clients |
348
+ | `sila2_client` | the first of them — for a transport its `transporter`, or the **source device** on a route with none (§1.3); the one name a single-machine operation needs |
325
349
 
326
350
  ```yaml
327
351
  x-labcode:
@@ -350,6 +374,12 @@ x-labcode:
350
374
  property of the move, not of the lab. A route that does not ask still *holds* both ends, so
351
375
  reaching for one is answered with what to add rather than with silence.
352
376
 
377
+ On a route with **no transporter** (§1.3) a `sila2` script must ask: the ends are the only
378
+ machines there are, so not asking leaves nothing to open, and the front door says so rather
379
+ than letting the move fail when it runs. It is still asked for, not assumed — which
380
+ machines a script drives is the author's to state, and nowhere more so than where the
381
+ machine doing the moving is also the one holding the material.
382
+
353
383
  ```yaml
354
384
  transports:
355
385
  - transporter: arm
@@ -57,6 +57,7 @@ from labcode.extension import (
57
57
  script_endpoints,
58
58
  script_flavor,
59
59
  spot_device,
60
+ transport_label,
60
61
  transporter_connections,
61
62
  )
62
63
  from labcode.idgen import DEFAULT_ID_GENERATOR, IdGenerator
@@ -245,8 +246,7 @@ def make_transport_resolver(environment: dict) -> Callable:
245
246
  code = _flavored(
246
247
  code, script,
247
248
  _transport_machines(transport, transporters, devices, script),
248
- f"transport {transporter!r} "
249
- f"{transport.get('from')} -> {transport.get('to')}",
249
+ transport_label(transport),
250
250
  )
251
251
  routes[key] = code
252
252
 
@@ -59,8 +59,11 @@ from labcode.extension import (
59
59
  parse_connection,
60
60
  parse_op_timeout,
61
61
  parse_probe,
62
+ route_transporter,
63
+ script_endpoints,
62
64
  script_flavor,
63
65
  spot_device,
66
+ transport_label,
64
67
  unknown_key_messages,
65
68
  )
66
69
  from labcode.objectid import RESERVED_ID, reserved_collisions
@@ -464,11 +467,8 @@ def _validate_replenishments(environment: dict, errors: list, warnings: list) ->
464
467
  )
465
468
 
466
469
 
467
- def _transport_label(transport: dict) -> str:
468
- return (
469
- f"transport {transport.get('transporter')!r} "
470
- f"{transport.get('from')} -> {transport.get('to')}"
471
- )
470
+ # `transport_label` (extension.py) is shared with the backend, so the same route is named
471
+ # the same way in a front-door diagnostic and in a failing operation.
472
472
 
473
473
 
474
474
  def _validate_transports(
@@ -485,7 +485,7 @@ def _validate_transports(
485
485
  for transport in environment.get("transports") or []:
486
486
  if not isinstance(transport, dict):
487
487
  continue
488
- label = _transport_label(transport)
488
+ label = transport_label(transport)
489
489
  extension = transport.get(EXTENSION_KEY)
490
490
  if extension is None:
491
491
  if transport.get("from") != transport.get("to"): # a real move
@@ -505,7 +505,9 @@ def _validate_transports(
505
505
  continue
506
506
  _check_transport_endpoints(transport, script, label, devices, errors, warnings)
507
507
  if script_flavor(script) == FLAVOR_SILA2:
508
- _check_transport_connection(transport, label, transporters, errors)
508
+ _check_transport_connection(
509
+ transport, script, label, transporters, devices, errors
510
+ )
509
511
 
510
512
 
511
513
  def _check_transport_endpoints(
@@ -519,7 +521,12 @@ def _check_transport_endpoints(
519
521
  clients at all (§1.6), so the request cannot be honoured and the author expects
520
522
  something that will not happen. Asking when neither end has an address is a **warning**
521
523
  -- the route still works through its transporter, and an environment written before its
522
- instruments have addresses is a legitimate intermediate state (as with `probe`)."""
524
+ instruments have addresses is a legitimate intermediate state (as with `probe`).
525
+
526
+ That last one holds only for a route that *has* a transporter to fall back on. A route
527
+ with no transporter (§4.6/§5.4) has nothing else to drive, so the same situation is an
528
+ error there -- reported by `_check_transport_connection`, which is where the rest of
529
+ that route's connection requirement lives, so that one problem yields one message."""
523
530
  endpoints = script.get("endpoints")
524
531
  prefix = f"{label}: x-labcode.script.endpoints"
525
532
  if endpoints is None:
@@ -535,8 +542,9 @@ def _check_transport_endpoints(
535
542
  f"{script_flavor(script)!r}: only a 'sila2' script is handed clients (§1.6)"
536
543
  )
537
544
  return
538
- ends = [spot_device(transport.get(end)) for end in ("from", "to")]
539
- named = [device for device in ends if device is not None]
545
+ if route_transporter(transport) is None:
546
+ return # nothing to fall back on; _check_transport_connection reports it as an error
547
+ named = _route_ends(transport)
540
548
  if not any(device in devices.with_connection for device in named):
541
549
  warnings.append(
542
550
  f"{prefix} is true, but neither end of the route ({', '.join(named)}) declares "
@@ -544,16 +552,54 @@ def _check_transport_endpoints(
544
552
  )
545
553
 
546
554
 
555
+ def _route_ends(transport: dict) -> list[str]:
556
+ """The devices at either end of a route, named and deduplicated in route order.
557
+
558
+ A move within one device names it once: it is one machine, and one client."""
559
+ ends = [spot_device(transport.get(end)) for end in ("from", "to")]
560
+ return list(dict.fromkeys(device for device in ends if device is not None))
561
+
562
+
547
563
  def _check_transport_connection(
548
- transport: dict, label: str, transporters: _NodeIndex, errors: list
564
+ transport: dict,
565
+ script: dict,
566
+ label: str,
567
+ transporters: _NodeIndex,
568
+ devices: _NodeIndex,
569
+ errors: list,
549
570
  ) -> None:
550
- """A `sila2` transport script drives one machine -- the transporter that carries the
551
- move -- so that is where its connection has to be."""
552
- identifier = transport.get("transporter")
571
+ """Where a `sila2` transport script's connection has to be.
572
+
573
+ Normally the transporter: it is the machine that carries the move, and the one
574
+ `sila2_client` names.
575
+
576
+ A route with **no transporter** (`transporter: null`, ofplang-schedule §4.6/§5.4) is
577
+ performed by the devices at its ends -- a cycler loading its own block -- so those are
578
+ what the script must be able to reach. It receives them only if it asks (`endpoints`,
579
+ §1.6), and it is made to ask rather than being read as asking: `endpoints` is the
580
+ author's statement of which machines the script drives, and inferring it here would take
581
+ that statement away from them for exactly the routes where it matters most. The cost of
582
+ requiring it is one line in the environment; the cost of assuming it is a script handed
583
+ clients its author never asked for."""
553
584
  prefix = f"{label}: x-labcode.script.flavor 'sila2' but"
554
- if not isinstance(identifier, str) or not identifier:
555
- errors.append(f"{prefix} the route names no transporter")
556
- elif identifier not in transporters.declared:
557
- errors.append(f"{prefix} transporter {identifier!r} is not declared in transporters[]")
558
- elif identifier not in transporters.with_connection:
559
- errors.append(f"{prefix} transporter {identifier!r} declares no x-labcode.connection")
585
+ identifier = route_transporter(transport)
586
+ if identifier is not None:
587
+ if identifier not in transporters.declared:
588
+ errors.append(f"{prefix} transporter {identifier!r} is not declared in transporters[]")
589
+ elif identifier not in transporters.with_connection:
590
+ errors.append(f"{prefix} transporter {identifier!r} declares no x-labcode.connection")
591
+ return
592
+ if not script_endpoints(script):
593
+ errors.append(
594
+ f"{prefix} the route names no transporter and the script does not ask for its "
595
+ f"endpoint clients; add `endpoints: true` so it is handed the devices at either "
596
+ f"end of the route, which are what performs a move nothing carries"
597
+ )
598
+ return
599
+ named = _route_ends(transport)
600
+ if not any(device in devices.with_connection for device in named):
601
+ errors.append(
602
+ f"{prefix} the route names no transporter and neither end "
603
+ f"({', '.join(named)}) declares an x-labcode.connection; there is no machine "
604
+ f"left for the script to drive"
605
+ )
@@ -315,6 +315,38 @@ def declared_probe(extension: Any) -> dict[str, Any]:
315
315
  return declared
316
316
 
317
317
 
318
+ def route_transporter(transport: Any) -> str | None:
319
+ """The transporter carrying a transport route, or None when the route names none.
320
+
321
+ A route may declare ``transporter: null`` (ofplang-schedule SPECIFICATIONS §4.6/§5.4):
322
+ the move needs no transporter at all -- a device shifting material between its own
323
+ spots, a chute -- and is performed by the devices at its ends instead.
324
+
325
+ Anything that is not a non-empty string reads as "none" here. A malformed value is the
326
+ schedule environment validator's to reject, and this must not hold a second opinion
327
+ about it; what matters at this layer is only whether there is a transporter to drive.
328
+ """
329
+ if not isinstance(transport, dict):
330
+ return None
331
+ identifier = transport.get("transporter")
332
+ return identifier if isinstance(identifier, str) and identifier else None
333
+
334
+
335
+ def transport_label(transport: Any) -> str:
336
+ """How a transport route is named in a message: its carrier, then its ends.
337
+
338
+ A route with no transporter says so in words. Rendering the absent id would print
339
+ ``transport None a -> b``, which reads as a defect in the message rather than as a fact
340
+ about the route -- and this label reaches the user in both a front-door diagnostic and a
341
+ failing operation, so it is written once and shared.
342
+ """
343
+ if not isinstance(transport, dict):
344
+ transport = {}
345
+ identifier = route_transporter(transport)
346
+ who = repr(identifier) if identifier is not None else "(no transporter)"
347
+ return f"transport {who} {transport.get('from')} -> {transport.get('to')}"
348
+
349
+
318
350
  def spot_device(spot: Any) -> str | None:
319
351
  """The device a qualified spot (``<device>.<spot>``, schedule SPECIFICATIONS §8.2)
320
352
  belongs to, or None when there is no name to take.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -21,8 +21,8 @@ Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate<0.3,>=0.2
24
- Requires-Dist: ofplang-schedule<0.4,>=0.3
25
- Requires-Dist: ofplang-run<0.5,>=0.4
24
+ Requires-Dist: ofplang-schedule<0.6,>=0.5
25
+ Requires-Dist: ofplang-run<0.7,>=0.6
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
@@ -1,6 +1,6 @@
1
1
  ofplang-validate<0.3,>=0.2
2
- ofplang-schedule<0.4,>=0.3
3
- ofplang-run<0.5,>=0.4
2
+ ofplang-schedule<0.6,>=0.5
3
+ ofplang-run<0.7,>=0.6
4
4
 
5
5
  [dev]
6
6
  pytest>=7.0
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.3.1",
3
+ "distance": 0,
4
+ "node": "g8ed9be19f0560168d506f98a6e9f54b3e7085aa0",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-09-06"
8
+ }
@@ -41,9 +41,15 @@ classifiers = [
41
41
  # `dispatch_replenishment` seam and the `kind: replenishment` the child harness has to
42
42
  # recognise as side-effect only.
43
43
  #
44
- # `ofplang-schedule>=0.2.3` says what this package is tested against rather than what its
45
- # own code calls -- nothing here calls a scheduler API newer than the in-memory workflow
46
- # acceptance of 0.1.3. CI resolves the siblings from PyPI, so every run of this suite and
44
+ # `ofplang-schedule>=0.5` is required, not merely tested against: an environment may
45
+ # declare a route that needs no transporter (`transporter: null`, schedule §4.6/§5.4) and
46
+ # this dialect accepts a `sila2` script on one, but an older scheduler refuses the
47
+ # environment with `wrong_type` before there is a plan to run -- and `ofplang-run>=0.6` is
48
+ # where such a move is dispatched rather than rejected.
49
+ #
50
+ # The `>=0.2.3` floor it replaces said what this package was tested against rather than what
51
+ # its own code called -- nothing then called a scheduler API newer than the in-memory
52
+ # workflow acceptance of 0.1.3. CI resolves the siblings from PyPI, so every run of this suite and
47
53
  # every real-lab example already happens on it, and run's own floor requires it anyway
48
54
  # (an older scheduler reports a running refill free before it is).
49
55
  dependencies = [
@@ -55,8 +61,8 @@ dependencies = [
55
61
  # bounds cannot protect what is already on PyPI without one, which is why they go
56
62
  # in now rather than at the first breakage.
57
63
  "ofplang-validate>=0.2,<0.3",
58
- "ofplang-schedule>=0.3,<0.4",
59
- "ofplang-run>=0.4,<0.5",
64
+ "ofplang-schedule>=0.5,<0.6",
65
+ "ofplang-run>=0.6,<0.7",
60
66
  ]
61
67
 
62
68
  [project.urls]
@@ -288,6 +288,40 @@ def test_a_route_within_one_device_connects_to_it_once():
288
288
  assert code.count("('cycler',") == 1
289
289
 
290
290
 
291
+ def _internal_transport_env(**script) -> dict:
292
+ """A route with no transporter: the cycler shifts the plate between its own spots."""
293
+ env = _sila2_transport_env("cycler.door", "cycler.block", **script)
294
+ for entry in env["devices"]:
295
+ if entry["id"] == "cycler":
296
+ entry["spots"] = ["door", "block"]
297
+ env["transports"][0]["transporter"] = None
298
+ return env
299
+
300
+
301
+ def test_a_transporter_less_route_drives_the_devices_at_its_ends():
302
+ # Nothing carries this move, so `sila2_client` -- "the first of them" -- is the source
303
+ # device, which is the machine that performs it. Both ends are the same device here, so
304
+ # it is connected to once.
305
+ code = make_transport_resolver(_internal_transport_env(endpoints=True))(
306
+ None, "cycler.door", "cycler.block"
307
+ )
308
+ assert code is not None
309
+ assert "('cycler', '127.0.0.1', 50055, True)" in code
310
+ assert "('arm'," not in code # the arm is not on this route and is left alone
311
+ assert code.count("('cycler',") == 1
312
+
313
+
314
+ def test_a_transporter_less_route_is_named_in_words_when_it_fails():
315
+ # The label reaches the user in a failing operation as well as a diagnostic;
316
+ # `transporter!r` would render it `transport None a -> b`.
317
+ env = _internal_transport_env(endpoints=True)
318
+ for entry in env["devices"]: # nothing left with an address
319
+ entry.pop("x-labcode", None)
320
+ code = make_transport_resolver(env)(None, "cycler.door", "cycler.block")
321
+ assert code is not None
322
+ assert "transport (no transporter) cycler.door -> cycler.block" in code
323
+
324
+
291
325
  TRANSPORT_ENV = {
292
326
  "time": {"unit": "second"},
293
327
  "devices": [{"id": "s0", "spots": ["core"]}, {"id": "s1", "spots": ["core"]}],
@@ -700,3 +700,85 @@ def test_an_x_labcode_somewhere_nothing_reads_still_says_where_it_belongs():
700
700
  result = validate_dialect({"processes": {}}, env)
701
701
  assert not result.ok
702
702
  assert any("replenishments[]" in e for e in result.errors)
703
+
704
+
705
+ # -- a route with no transporter (ofplang-schedule §4.6/§5.4) -------------------------
706
+
707
+
708
+ def _internal_env(script: dict | None = None, *, connected: bool = True) -> dict:
709
+ """A cycler that shifts the plate between its own two spots: `transporter: null`."""
710
+ route = {"transporter": None, "from": "cycler.door", "to": "cycler.block"}
711
+ if script is not None:
712
+ route["x-labcode"] = {"script": script}
713
+ env = _transport_env(route)
714
+ cycler = {"x-labcode": {"connection": CONNECTION}} if connected else {}
715
+ env["devices"] = [{"id": "cycler", "spots": ["door", "block"], **cycler}]
716
+ return env
717
+
718
+
719
+ def test_a_transporter_less_route_needs_no_script():
720
+ result = validate_dialect({}, _internal_env())
721
+ assert result.ok, result.errors
722
+ assert any("no-op move" in w for w in result.warnings)
723
+
724
+
725
+ def test_a_transporter_less_route_takes_a_raw_script():
726
+ result = validate_dialect(
727
+ {}, _internal_env({"language": "python", "code": "load_block()"})
728
+ )
729
+ assert result.ok, result.errors
730
+ assert not result.warnings
731
+
732
+
733
+ def test_a_transporter_less_sila2_script_must_ask_for_its_endpoints():
734
+ """There is no transporter to connect to, and `endpoints` is what asks for the ends.
735
+
736
+ Read as implicitly asked, the author would be handed clients they never requested for
737
+ exactly the routes where which machine is driven matters most.
738
+ """
739
+ for endpoints in (None, False):
740
+ script = {"language": "python", "code": "load_block()", "flavor": "sila2"}
741
+ if endpoints is not None:
742
+ script["endpoints"] = endpoints
743
+ result = validate_dialect({}, _internal_env(script))
744
+ assert not result.ok
745
+ assert any("add `endpoints: true`" in e for e in result.errors), result.errors
746
+
747
+
748
+ def test_a_transporter_less_sila2_script_passes_when_it_asks():
749
+ result = validate_dialect(
750
+ {},
751
+ _internal_env(
752
+ {"language": "python", "code": "load_block()", "flavor": "sila2",
753
+ "endpoints": True}
754
+ ),
755
+ )
756
+ assert result.ok, result.errors
757
+ assert not result.warnings
758
+
759
+
760
+ def test_a_transporter_less_route_with_no_reachable_end_is_an_error():
761
+ """A carried route only *warns* here -- it still works through its transporter.
762
+
763
+ This one has no transporter to fall back on, so there is no machine left to drive, and
764
+ exactly one message says so (not the warning as well).
765
+ """
766
+ result = validate_dialect(
767
+ {},
768
+ _internal_env(
769
+ {"language": "python", "code": "load_block()", "flavor": "sila2",
770
+ "endpoints": True},
771
+ connected=False,
772
+ ),
773
+ )
774
+ assert not result.ok
775
+ assert len(result.errors) == 1
776
+ assert "no machine left for the script to drive" in result.errors[0]
777
+ assert not result.warnings
778
+
779
+
780
+ def test_a_transporter_less_route_is_named_in_words():
781
+ """`transporter!r` would print `transport None a -> b`, which reads as a broken message."""
782
+ result = validate_dialect({}, _internal_env())
783
+ assert any("transport (no transporter) cycler.door -> cycler.block" in w
784
+ for w in result.warnings), result.warnings
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "0.3.0",
3
- "distance": 0,
4
- "node": "g0952ede2cf679e2dcbd548d43ed8bd75e6acba0e",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-09-01"
8
- }
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