Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ on 401/429) for the REST API, and a socket.io *client* for status updates and lo
- **Annotator** — thin: it forwards the loop frontend's `handle_user_input` events into
`AnnotatorLogic` and keeps a per-frontend history.

`helpers/entrypoint.py` holds what every node's `main.py` repeats: `node_parser` builds a
configargparse parser with `--host`/`--port`, `run_node` starts uvicorn. A setting is a flag
*and* an environment variable from one declaration — `--conf-threshold` reads
`CONF_THRESHOLD`. The exception is `--host`/`--port`, which read `NODE_HOST`/`NODE_PORT`: the
bare `HOST` already means the loop's address, and a node adopting it would hand it to uvicorn
and fail to bind. A node that used to require a prefix passes `legacy_env_prefix`, and the
prefixed names keep working with a warning.

`detector/postprocess.py` and `detector/geometry.py` hold the parts of a detector that do *not*
depend on the model: confidence filtering, per-class NMS, box/point clipping, and turning
predictions into the loop's dataclasses. A node should import them rather than write its own —
Expand Down
75 changes: 75 additions & 0 deletions learning_loop_node/helpers/entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""The boilerplate every node's ``main.py`` repeats.

A node entry point always does the same four things: read a handful of settings, build the
logic object, construct the node, and hand it to uvicorn. Only the middle two are the node's
own, so :func:`node_parser` and :func:`run_node` cover the rest.

Settings come from a flag *or* an environment variable, because a node is configured on the
command line while developing and through the container environment in deployment. One
declaration gives both: ``--conf-threshold`` reads ``CONF_THRESHOLD``.

``--host`` and ``--port`` are the exception — they read ``NODE_HOST`` and ``NODE_PORT`` rather
than the names their flags imply, because the bare ``HOST`` already means *the loop's address*
and a node adopting it would hand it to uvicorn and fail to bind.
"""

import logging
import os
from argparse import Action, Namespace

import configargparse
import uvicorn


def node_parser(*, description: str, legacy_env_prefix: str = '') -> configargparse.ArgumentParser:
"""Build the parser for a node, pre-loaded with the settings every node has.

:param legacy_env_prefix: A prefix an earlier version of this node required, e.g.
``'DFINE_DETECTOR_'``. Prefixed names are still honoured, with a warning, so a
deployment keeps working until it is updated. Leave empty for a node that has always
read unprefixed names.
"""
parser = _NodeArgumentParser(description=description, legacy_env_prefix=legacy_env_prefix)
parser.add_argument('--host', default='0.0.0.0', env_var='NODE_HOST',
help='Host interface to bind to')
parser.add_argument('--port', type=int, default=80, env_var='NODE_PORT', help='Port to bind to')
return parser


def run_node(app: str, args: Namespace) -> None:
"""Serve the node.

:param app: Import string of the node object, conventionally ``'main:node'``.
"""
reload = os.getenv('UVICORN_RELOAD', 'FALSE').lower() in ('true', '1')
logging.info('Uvicorn reload is set to: %s', reload)
uvicorn.run(app, host=args.host, port=args.port, lifespan='on', reload=reload)


class _NodeArgumentParser(configargparse.ArgumentParser):
"""Parser whose every setting is also an environment variable named after its flag."""

def __init__(self, *, description: str, legacy_env_prefix: str) -> None:
super().__init__(description=description)
self.legacy_env_prefix = legacy_env_prefix

def add_argument(self, *args, **kwargs) -> Action: # type: ignore[override]
action = super().add_argument(*args, **kwargs)
if getattr(action, 'env_var', None) is None and action.dest != 'help':
action.env_var = action.dest.upper()
return action

def parse_args(self, *args, **kwargs) -> Namespace: # type: ignore[override]
self._adopt_legacy_env_vars()
return super().parse_args(*args, **kwargs)

def _adopt_legacy_env_vars(self) -> None:
if not self.legacy_env_prefix:
return
for action in self._actions:
name = getattr(action, 'env_var', None)
legacy = self.legacy_env_prefix + name if name else None
if not legacy or name in os.environ or legacy not in os.environ:
continue
os.environ[name] = os.environ[legacy]
logging.warning('%s is deprecated and will stop being read; set %s instead', legacy, name)
71 changes: 71 additions & 0 deletions learning_loop_node/tests/unit/test_entrypoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import pytest

from ...helpers.entrypoint import node_parser

MANAGED = ('WEIGHT_TYPE', 'DFINE_DETECTOR_WEIGHT_TYPE', 'HOST', 'NODE_HOST', 'NODE_PORT', 'PORT')


@pytest.fixture(autouse=True)
def clean_env(monkeypatch: pytest.MonkeyPatch):
"""Every test starts without the variables it is about to set."""
for name in MANAGED:
monkeypatch.delenv(name, raising=False)


def _parser(**kwargs):
parser = node_parser(description='a node', **kwargs)
parser.add_argument('--weight-type', default='FP16')
return parser


def test_every_node_gets_a_host_and_a_port():
args = _parser().parse_args([])
assert (args.host, args.port) == ('0.0.0.0', 80)


def test_a_flag_beats_everything():
args = _parser().parse_args(['--weight-type', 'FP32'])
assert args.weight_type == 'FP32'


def test_a_setting_is_read_from_the_variable_named_after_its_flag(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('WEIGHT_TYPE', 'FP32')
assert _parser().parse_args([]).weight_type == 'FP32'


def test_the_loop_own_host_is_never_mistaken_for_the_bind_address(monkeypatch: pytest.MonkeyPatch):
"""HOST is the loop's address. Binding uvicorn to it would leave the node unreachable."""
monkeypatch.setenv('HOST', 'preview.learning-loop.ai')
assert _parser().parse_args([]).host == '0.0.0.0'


def test_the_bind_address_has_a_name_of_its_own(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('NODE_HOST', '127.0.0.1')
monkeypatch.setenv('NODE_PORT', '8080')
args = _parser().parse_args([])
assert (args.host, args.port) == ('127.0.0.1', 8080)


def test_a_node_that_used_a_prefix_still_reads_it(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('DFINE_DETECTOR_WEIGHT_TYPE', 'FP32')
parser = _parser(legacy_env_prefix='DFINE_DETECTOR_')
assert parser.parse_args([]).weight_type == 'FP32'


def test_the_prefixed_name_warns_which_one_to_use_instead(monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture):
monkeypatch.setenv('DFINE_DETECTOR_WEIGHT_TYPE', 'FP32')
_parser(legacy_env_prefix='DFINE_DETECTOR_').parse_args([])
assert 'DFINE_DETECTOR_WEIGHT_TYPE' in caplog.text
assert 'WEIGHT_TYPE' in caplog.text


def test_the_current_name_wins_over_the_prefixed_one(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('DFINE_DETECTOR_WEIGHT_TYPE', 'FP32')
monkeypatch.setenv('WEIGHT_TYPE', 'FP16')
assert _parser(legacy_env_prefix='DFINE_DETECTOR_').parse_args([]).weight_type == 'FP16'


def test_a_node_without_a_legacy_prefix_ignores_prefixed_names(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv('DFINE_DETECTOR_WEIGHT_TYPE', 'FP32')
assert _parser().parse_args([]).weight_type == 'FP16'
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [
"python-socketio>=5.16.2,<6.0.0",
"aiofiles>=0.7.0",
"python-multipart>=0.0.31",
"configargparse>=1.7.1",
"psutil>=5.9.0,<8.0.0",
"numpy>=2.0,<3.0",
"Pillow>=12.3.0,<13.0.0",
Expand Down
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading