| orphan: |
|---|
This page records the ergonomics audit required by
the Adoption & Hardening Roadmap (docs/planning/adoption_hardening.rst §2). The goal is to count the steps a developer
must take to produce a useful component, identify any steps that exist only for
library bookkeeping, and designate a canonical "shortest path" example.
Audit date: 2026-04-24.
---
The steps below correspond to a blank file: nothing imported, no base classes
available. Optional overrides are marked (optional).
- Import
LifecyclePublisherComponentand the ROS message type. - Subclass
LifecyclePublisherComponent[MsgT]; implement__init__with named argumentsnameandtopic_name;qos_profileremains optional (default:10).msg_typeis optional when the subclass is parameterized with a concrete generic argument. - Call
self.publish(msg)from any callback or override.
Total: 3 steps. No override is mandatory.
Additional steps beyond the publish-on-demand case:
- Import
LifecycleTimerComponent. - Subclass
LifecycleTimerComponent; pass the publisher instance at construction. - Implement
on_tick(): call the publisher's emit method orpublish(msg).
Total: 6 steps. No _on_activate, _on_deactivate, create_timer, or
destroy_timer call required. Both components are gated by the library
automatically. See examples/minimal_publisher.py for the canonical form.
Note
BeforeLifecycleTimerComponentwas used as a sibling component, timer-driven publication required overriding_on_activateand_on_deactivatewith manualcreate_timer/destroy_timercalls — 7 steps total. That path remains valid
for resources without a library equivalent but should not be used for timers.
- Import
LifecycleSubscriberComponentand the ROS message type. - Subclass
LifecycleSubscriberComponent[MsgT]; implement__init__with named argumentsnameandtopic_name;qos_profileremains optional (default:10).msg_typeis optional when the subclass is parameterized with a concrete generic argument. - Implement
on_message(self, msg: MsgT) -> None.
Total: 3 steps. on_message is the only mandatory user override.
After both components are defined:
- Subclass
LifecycleComponentNode. - In
__init__: callsuper().__init__(node_name). - Call
self.add_component(comp_a)andself.add_component(comp_b).
Total: 3 steps. No library-only overhead.
---
One potential friction item was identified during the audit.
Before PR #4, every
TopicComponent subclass required the message type to be stated twice: once
as the generic parameter [String] and once as the constructor argument
msg_type=String.
class EchoSub(LifecycleSubscriberComponent[String]): # String stated here …
def __init__(self) -> None:
super().__init__(
name="echo",
topic_name="/chatter",
msg_type=String, # … and again here
qos_profile=10,
)The duplication came from Python's runtime type erasure: the library had to
recover the concrete generic argument at __init__ time before handing the
resolved type to rclpy.
Initial decision (2026-04-24): the current duplication is a Python
limitation, not a library design choice. Making msg_type optional
(via __orig_bases__ introspection) is feasible but was deferred to a
dedicated investigation. See issue
#1.
Verdict: IMPLEMENT as a transverse utility usable by
ServiceComponent (shipped) and future ActionComponent as well as
today's topic components.
Evidence: the POC scripts/investigate_iface_type_inference.py exercised
nine scenarios on CPython 3.12 against the exact PEP 695 generic shape used
by TopicComponent[MsgT]. All nine matched the oracle:
| # | Scenario | Oracle |
|---|---|---|
| 1 | Direct subclass parameterized as Sub(Base[MsgT]) |
infer MsgT |
| 2 | Indirect subclass without re-parameterization (resolution via MRO) | infer MsgT |
| 3 | Multi-level concrete chain Leaf < Mid < Base[MsgT] |
infer MsgT |
| 4 | Unparameterized subclass and no explicit argument | TypeError at __init__ |
| 5 | Ancestor forwards an unresolved TypeVar |
TypeError at __init__ (no false positive) |
| 6 | Generic and explicit argument disagree | TypeError at __init__ (no silent surprise) |
| 7 | Generic and explicit argument agree | resolved to that type |
| 8 | Explicit only, generic unparameterized (fallback) | resolved to the explicit type |
| 9 | __orig_bases__ under from __future__ import annotations |
contains real type objects, not strings |
Result on Python 3.12.3: failures: 0/9.
These apply uniformly to msg_type, srv_type, and action_type
across current and future components.
ADR — R-IfaceTypeInference
Decision. Any component parameterized over a ROS interface type accepts
that type either via the generic parameter of the class
(Component[InterfaceT]) or via an explicit constructor argument. The
two sources are reconciled by a single transverse utility. When neither is
available, or when both are available and disagree, the library raises a
typed boundary exception at __init__ time.
Rationale. Avoids the "stated twice" friction documented above without adding magic: explicit argument remains supported, generic parameterization becomes sufficient, and divergence is surfaced loudly instead of silently.
Consequences.
- A single resolver lives at
src/lifecore_ros2/core/_iface_type.py— transverse, reused byServiceComponent(shipped) and reusable byActionComponentwhen introduced (seeTODO.md §2). Placing it incore/rather thancomponents/is deliberate: the rule is not topic-specific. - Boundary failure is a typed exception
_InterfaceTypeNotResolvedError(LifecoreError, TypeError)— internal (Rule A: typed boundary errors), prefixed with_, and not re-exported fromlifecore_ros2. SubclassingTypeErrorkeeps user-facing tracebacks idiomatic. - Conflict policy is error, never "explicit wins". Silent disagreement is forbidden.
- Failure happens at
__init__. No component can ever reach_on_configurewith an unresolved interface type, which keeps the Rule A / Rule B split intact (boundary errors stay outside lifecycle hooks). - Pedagogical examples under
examples/keepmsg_type=…explicit; the generic-only short form is documented indocs/patterns.rstonly.
Implementation landed. The rule is enforced directly in
TopicComponent.__init__ through the transverse resolver in
src/lifecore_ros2/core/_iface_type.py.
- Investigation: closed.
- Implementation: shipped in PR #4 for issue #1.
- Tracker: Adoption & Hardening §2 is closed and
docs/patterns.rstdocuments the generic-only form.
No other library-bookkeeping-only steps were found. All remaining steps either carry clear functional justification or belong to application logic (timer management, message construction).
---
Designated example: examples/minimal_subscriber.py
Rationale: the subscriber requires only a three-step setup (import, subclass +
__init__, on_message) and imposes zero timer lifecycle overhead. It
demonstrates activation gating — the primary differentiator of the library —
without incidental complexity.
Regression snapshot (2026-04-24):
| Scope | Line count |
|---|---|
| Total file (including module docstring) | 90 |
| Code only (lines 36–90, excluding module docstring) | 55 |
| Component + node definition (lines 43–66) | 24 |
Any PR that grows the Component + node definition scope beyond 24 lines
without explicit justification should be treated as a regression in API
ergonomics.
The example fits on one screen and does not require the reader to know any
_on_* hook beyond on_message.
---
- Publisher path (3 steps / 7 with timer): clean. Timer management is application logic, not library overhead.
- Subscriber path (3 steps): minimal.
on_messageis the sole mandatory override. - Composition path (3 steps): straightforward.
- One friction item shipped:
msg_typeno longer needs to be duplicated when the component class is parameterized with a concrete generic argument. - Canonical shortest path:
examples/minimal_subscriber.py— 24 lines for component + node definition.