2. Give and Take
Components provide services and require services from other components.
The Problem
You want to build a spell checker. It needs a dictionary. Without a component system, you’d instantiate the dictionary yourself, pass it in, manage its lifecycle. With the kernel: the dictionary provides a service, the spell checker requires it, the kernel wires them together.
Providing a Service
from signalpy.kernel import component, provides, lifecycle
@component("dict-en")
@provides("IDictionary")
class EnglishDict:
@lifecycle.activate
def activate(self):
self.words = {"hello", "world", "welcome", "to", "the", "tutorial"}
def check_word(self, word: str) -> bool:
return word.lower() in self.words@provides("IDictionary") registers this component in the service registry under the contract name "IDictionary". Any other component can now say “I need an IDictionary” and get this one.
@provides(IDictionary) is enough. Don’t add a @requires(checker=ISpellChecker) “for symmetry” — the kernel wires it for you. Consumers point at providers; providers don’t point back. (More in Decorators reference.)
Requiring a Service
from signalpy.kernel import component, requires, lifecycle
@component("checker")
@requires(dictionary="IDictionary")
class SpellChecker:
@lifecycle.activate
def activate(self):
print("Spell checker ready")
def check(self, text: str) -> list[str]:
return [w for w in text.split() if not self.rt.dictionary.check_word(w)]@requires(dictionary="IDictionary") tells the kernel: “before activating me, find a component that provides IDictionary and make it available as self.rt.dictionary.”
Typed Contracts
Strings work, but types are better. Define a Protocol and use it everywhere:
from typing import Protocol, runtime_checkable
@runtime_checkable
class IDictionary(Protocol):
def check_word(self, word: str) -> bool: ...@provides(IDictionary) # type, not string
class EnglishDict: ...
@requires(dictionary=IDictionary) # type, not string
class SpellChecker: ...No magic strings to typo. IDE “find all references” works. Import errors catch mismatches at load time. Both forms work — strings for backward compat, types for new code.
Unified @requires — but the shape changes the meaning
One decorator, four shapes. The type hint tells the kernel what you need — and also whether you can boot before it exists.
| Declaration | What self.rt.X is |
Boot semantics | Use case |
|---|---|---|---|
@requires(x=IContract) |
Single highest-ranked service | Boot-blocking | The one config, the one logger |
@requires(x=list[IContract]) |
Live list of all matching | Non-blocking (reactive) | All dictionaries, all plugins |
@requires(x=IContract, key="lang") |
Live dict keyed by property | Non-blocking (reactive) | {"EN": en, "FR": fr} |
@requires(x=IContract, optional=True) |
Single or None |
Non-blocking | Optional cache |
The bare scalar form is the only one that means “I cannot start until this exists.” The three other shapes look like dependencies in code but behave like subscriptions on the registry: your component boots regardless of who’s there, and the runtime updates self.rt.x reactively as providers come and go.
Practical consequence: if you wrote @requires(plugins=list[IPlugin]), your component activates with self.rt.plugins == [] and the list fills as providers boot. Don’t put logic in @lifecycle.activate that assumes the list is populated — put it in an @effect (Tutorial 3) so it re-runs as providers arrive.
self.rt — The Reactive Runtime
Every injected service lives under self.rt. This is the component’s window into the kernel:
self.rt.dictionary # injected service (from @requires) — reactive!
self.rt.config # IConfig (if required)
self.rt.logger # ILogger (scoped to this component)
self.rt.publish(...) # emit an event (covered later, when we introduce events)
self.rt.spawn(...) # create a child component (covered later)
self.rt.peek("config") # read without reactive tracking (covered in tutorial 3)IConfig, ILogger, and a handful of other contracts are defined in signalpy.kernel.contracts. The kernel ships components (ConfigProvider, LoggingProvider, …) that implement them, so you can @requires(config=IConfig) without writing the implementation yourself. Tutorial 7 shows how to write your own provider for a new contract.
Every read through self.rt is a reactive read. Inside reactive methods (introduced in tutorial 3 as @effect and @computed), the kernel tracks what you accessed and re-runs the method automatically when those values change. Outside reactive methods, it just behaves like normal attribute access.