Skip to content

Invocation context variables#187

Merged
rwb27 merged 48 commits into
thing-connectionfrom
invocation-context
Nov 13, 2025
Merged

Invocation context variables#187
rwb27 merged 48 commits into
thing-connectionfrom
invocation-context

Conversation

@rwb27
Copy link
Copy Markdown
Collaborator

@rwb27 rwb27 commented Oct 9, 2025

This pull request introduces two new top-level functions:

  • lt.cancellable_sleep(interval) is equivalent to time.sleep but may raise InvocationCancelledError to stop the action.
  • lt.get_invocation_logger() supplies a logging.Logger object that captures logs specific to the current invocation.

Together, these two functions remove the need for the InvocationLogger and CancelHook dependencies, completing the third phase of #182 .

import labthings_fastapi as lt

class Counter(lt.Thing):
    """An example counter Thing."""

    index: int = lt.property(default=0)

    @lt.thing_action
    def count(self, n: int, dt: float) -> None:
        "Count slowly and log about it."
        logger = lt.get_invocation_logger()
        for i in range(n):
            lt.cancellable_sleep(dt)  # If the action is cancelled, an exception is raised here to terminate it
            self.index += 1
            logger.info(f"Counted to {self.index}")  # This appears if the action is polled

I have also added lt.ThreadWithInvocationID (name might want to change) which is a subclass of threading.Thread that supplies an invocation ID. This allows an action to be run in a background thread without errors because it's missing an invocation ID context. It also allows the thread to be cancelled.

This is mostly implemented with one new module, invocation_contexts. I've moved CancelEvent from the invocation dependencies module into the new module, and the dependencies now use invocation_contexts so they still work.

The one place where dependencies are still used is in generating invocation IDs for actions that are invoked over HTTP. Because of the way FastAPI's dependency mechanism works, I can't get rid of this without breaking both of the dependencies mentioned above. I intend to leave them in for now to facilitate migration, but the whole module will be deleted before too long.

I have written new, bottom-up unit tests in test_invocation_contexts (with 100% coverage of the new module) and have also migrated test_action_cancellation and test_action_logging to use the new API. The old tests are preserved in a submodule, along with other dependency-related tests.

I've deliberately not exposed all the functions at top-level in the module: I think the two functions and one class I've exposed are the API we want people to use. I should make fake_action_context visible, most likely in a test module, so one could say from labthings_fastapi.testing import fake_action_context. That would be a natural home for create_thing_without_server and maybe a few other things.

rwb27 added 6 commits October 9, 2025 23:15
This new module uses context variables to provide cancellation
and logging to action invocations.
It will replace the various dependencies `InvocationID`, `InvocationLogger`,
and `CancelHook`.
The actions module now uses the new cancellation/logging code.
Said code is available via top-level imports.

I'm currently still generating invocation IDs using the
dependency. This will need to stay until we remove the
dependency, as without it
the other dependencies
(`CancelHook` and `InvocationLogger`) will break.
I've not made a dedicated page (yet) but have added this to the
conceptual docs on actions and concurrency.

Test code achieves 100% coverage of the new module
from `test_invocation_contexts`.
Code testing dependencies is now moved into a submodule,
pending their removal.

The tests for cancellation and logging are duplicated: the old
copies are in the submodule, but I have also migrated them to the new API in the main tests folder.
@rwb27
Copy link
Copy Markdown
Collaborator Author

rwb27 commented Oct 22, 2025

After reading the logging docs I think our current approach of creating a new logger name for every invocation is explicitly discouraged (because loggers don't get garbage collected). I think a good solution would be to use a filter to add context information to the LogRecord and a custom handler to append logs to the right invocation object, if needed.

This would result in a property Thing.logger that returns a logger named for a particular Thing instance, so instead of needing an invocation logger that's specific to one invocation, we'd simply call self.logger.info("message").

A side effect of this approach is that I no longer think it's reasonable to raise an exception if the logger is used without the invocation context variable being set: exceptions in logging code are usually unhelpful. If the logger is used from outside an action, the log message should still be logged, but without the invocation context data.

In the future, it would be nice to think more about how to relay progress to a user/client. I think our current use of a logger for this has some advantages, but also some drawbacks.

@rwb27
Copy link
Copy Markdown
Collaborator Author

rwb27 commented Oct 22, 2025

After discussing with @julianstirling and @bprobert97 I think logs should go via Thing.logger as described above, and the jury's out on whether we should make cancellable_sleep be a method of Thing or a module-level method. If it's a module-level method, the conclusion was that it shouldn't raise an error if it was called outside of an invocation thread, so that code can be tested without overriding things.

@julianstirling was also strongly in favour of adding a second function with a descriptive name, rather than cancellable_sleep(None) to check for cancellation without waiting.

rwb27 added 20 commits October 22, 2025 22:26
In response to feedback, I've added `raise_if_cancelled()` to replace `cancellable_sleep(None)`.

In response to the same feedback, I now handle the error if no invocation ID is available, so we simply perform a regular time.sleep.

I've also deleted a couple of defunct print statements.
This commit adds a logger for every Thing instance, and a
custom log handler and filter that inject the invocation ID
into LogRecord objects.

This means we can still filter out invocation logs as we did before, but we no longer need to make
a new logger for each invocation.

This more or less follows the example given in the Logging Cookbook for adding context.
For ease of migration, I've fixed the old InvocationLogger dependency.

This uses a hard-coded Thing name, but will work well enough to enable a smooth migration to
the new syntax.
The test for ThreadWithInvocationID occasionally failed, because
the thread was cancelled before it started running, and the CancelEvent was destroyed (and reset).

I now hold a reference to the CancelEvent for the lifetime of the Thread, which means this is
no longer possible.
This adds full unit test coverage, in addition to the more functional testing in
`test_action_logging`.
This marks a few more methods of ThingServer as private, and accepts a dict of Things to be created during initialisation.

In order to do this cleanly, I have now formalised a schema for config files, using a Pydantic model.

Tests (and no doubt fixes) will come in the next commit.
The config modules now do all the required validation, including Thing names,
ensuring we expand classes into full `ThingConfig` objects, and supplying
default values for args, kwargs, etc.

I've exposed ThingConfig and ThingServerConfig at module level as they are
likely useful in a number of scenarios, particularly writing tests.

It is possible to supply a dict whenever a ThingConfig is required - I'll include
this in the tests, but I don't want to recommend it - it's better to use the model
as it makes it harder to miss fields.
There was duplicated validation code in ThingServer, which I've removed, in favour of using the ThingServerConfig model.

I've also split up the code adding Things, so now we:
1. Create Things (and supply a ThingServerInterface)
2. Make connections between Things.
3. Add Things to the API.

Step (3) may move out of __init__at some point in the future.
If the server is started from the command line, we print out any validation errors, then exit: a stack
trace is not helpful. This also fixes an issue where ValidationError would not serialise properly,
which stopped the test code working (it used multiprocessing).
I've removed ThingServer.add_thing in favour of passing a dictionary to ThingServer.__init__
and the test code is updated to reflect that.

As a rule, when thing instances were required, I would:
1. Create the server
2. Assign `my_thing = server.things["my_thing"]`
3. Assert `assert isinstance(my_thing, MyThing)`
That was enough for `pyright` (and I guess mypy) to correctly infer the type in my test code. It's slightly more verbose, but means we can use the same code in test and production, rather than
needing a different way to create/add things in test code.
The name `thing_connection` confused people. `thing_slot` suggests we are marking the attribute and its value will be supplied later, which is exactly right. This commit makes that swap.

I've searched/replaced every instance of ThingConnection and thing_connection: we should check the docs build OK, this is perhaps most easily done in CI.
This has been removed as its functionality is provided by `pydantic.ImportString`.

It is no longer used for the fallback server: we gain nothing from the dynamic import.
In the future, if we want to make it configurable, we could use `ImportString` there too.
Now that it's imported statically, `mypy` spots typing errors with the fallback server.
These are now annotated correctly.
I've reworked core concepts and removed DirectThingClient, replacing it with a new "structure" page that I think preserves most of the still-useful content.
I've added an explanation comment with a link to the open pydantic issue.
Copy link
Copy Markdown
Contributor

@julianstirling julianstirling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand this a bit more than the last two.

I think the upshot of this reviewing is that it is very hard to keep 4 complex PRs that interact in my head. I think it would be good once we have merged the 3 later PRs into the base one, to do a final review of #183

Comment thread src/labthings_fastapi/dependencies/invocation.py
Comment thread tests/test_invocation_contexts.py Outdated
@rwb27 rwb27 mentioned this pull request Oct 29, 2025
rwb27 and others added 20 commits November 12, 2025 23:22
I've expanded this docstring as suggested.
I've also fixed the import of `os` - I was previously importing `os.path` but using
`os`, now I import `os` directly.
I've imported symbols directly and eliminated `from labthings_fastapi import <module> as <shortname>`.
This ought to make the tests a bit more readable.
I've deleted a chunk of text that was in a documentation file, but didn't show (it was deliberately hidden).

This may or may not get added back in somewhere else in due course, but I think it's confusing to leave it in its current state.
This was a utility function for testing, but it wasn't clear or helpful.
I've replaced the use of this function with more direct tests.
The module under test was renamed, so I'm renaming the test module to match.
Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com>
Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com>
Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com>
I've removed a test that mixed old and new syntax (the code it tests is tested elsewhere), and added a README
to explain why we have a folder of almost-duplicated tests.
@rwb27 rwb27 merged commit 27ecdda into thing-connection Nov 13, 2025
12 of 13 checks passed
@rwb27 rwb27 deleted the invocation-context branch November 13, 2025 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants