readerboard 0.1.1__py3-none-any.whl

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.
@@ -0,0 +1,143 @@
1
+ """A smaller surface for clients that would rather not read status codes.
2
+
3
+ Fixed paths, one message, and **every response is HTTP 200** with the outcome in
4
+ the body. That suits a Home Assistant ``rest_command`` or a shell one-liner in a
5
+ cron job, neither of which branches gracefully on a status code.
6
+
7
+ There is one exception to always-200, and it is deliberate: a request without a
8
+ valid API key gets a 401 like any other, because a caller the service will not
9
+ talk to is not the same as a request that failed.
10
+
11
+ ``POST /Write/Message`` writes to a reserved slot rather than to the sign's
12
+ priority file. That distinction matters more than it looks. By protocol a
13
+ priority message suppresses every other file on the sign, so writing one here
14
+ would quietly turn a service that shares the sign between several sources into a
15
+ service that can only ever show one thing. Written to an ordinary slot it looks
16
+ identical while it is the only message registered, and it shares the sign the
17
+ moment anything else registers.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+
24
+ from fastapi import APIRouter
25
+
26
+ from readerboard.api.deps import ControllerDep, RegistryDep, RequireApiKey, SettingsDep
27
+ from readerboard.api.models import (
28
+ SimpleCommandRequest,
29
+ SimpleControlCommand,
30
+ SimpleDisplayMode,
31
+ SimpleMessageRequest,
32
+ SimpleResult,
33
+ SimpleToken,
34
+ )
35
+ from readerboard.protocol.tokens import CONTROL_COMMANDS, DISPLAY_MODES, MARKUP_TOKENS
36
+ from readerboard.services import commands
37
+ from readerboard.services.registry import DEFAULT_SLOT_KEY
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ router = APIRouter()
42
+
43
+ write = APIRouter(prefix="/Write", tags=["Write (simple)"])
44
+ enumerations = APIRouter(prefix="/Enumerations", tags=["Enumerations (simple)"])
45
+
46
+
47
+ @write.post("/Message", summary="Write a message to the sign", dependencies=[RequireApiKey])
48
+ async def write_message(
49
+ body: SimpleMessageRequest, registry: RegistryDep, settings: SettingsDep
50
+ ) -> SimpleResult:
51
+ """Display a message on the sign.
52
+
53
+ The message goes into a reserved slot rather than the sign's priority file,
54
+ so other sources can share the sign with it.
55
+ """
56
+ try:
57
+ await registry.upsert(
58
+ DEFAULT_SLOT_KEY,
59
+ body.message,
60
+ mode=body.display_mode.strip().upper(),
61
+ position="MIDDLE",
62
+ source="simple",
63
+ # Unset by default, which is exactly how this endpoint has always
64
+ # behaved: the message stays until something replaces it. Set it and
65
+ # an automation that stops calling leaves an empty sign rather than
66
+ # a stale temperature that still looks current.
67
+ ttl_seconds=settings.default_slot_ttl_seconds,
68
+ # Unknown tokens are passed through as literal text, exactly as the
69
+ # old parser did, so a payload that worked before cannot start
70
+ # failing now.
71
+ strict=False,
72
+ )
73
+ except KeyError:
74
+ return SimpleResult.error(
75
+ "The display mode '%s' is not valid" % body.display_mode
76
+ )
77
+ except Exception as err:
78
+ logger.warning("simple write failed: %s", err)
79
+ return SimpleResult.error(str(err))
80
+
81
+ return SimpleResult.ok("Message displayed on sign")
82
+
83
+
84
+ @write.post(
85
+ "/ControlCommand",
86
+ summary="Send a control command to the sign",
87
+ dependencies=[RequireApiKey],
88
+ )
89
+ async def write_control_command(
90
+ body: SimpleCommandRequest, controller: ControllerDep
91
+ ) -> SimpleResult:
92
+ """Send one of the sign's control commands."""
93
+ try:
94
+ payload = commands.build(body.command, body.parameter)
95
+ except commands.UnknownCommand:
96
+ # The old wording, kept because something may be matching on it.
97
+ return SimpleResult.error("Unrecognized control command '%s'" % body.command)
98
+ except commands.BadParameter as err:
99
+ return SimpleResult.error(str(err))
100
+
101
+ try:
102
+ await controller.send_special(payload)
103
+ except Exception as err:
104
+ logger.warning("simple control command failed: %s", err)
105
+ return SimpleResult.error(str(err))
106
+
107
+ return SimpleResult.ok("Control command sent to sign")
108
+
109
+
110
+ @enumerations.get("/DisplayModes", summary="Available display modes")
111
+ async def display_modes() -> list[SimpleDisplayMode]:
112
+ """List the display modes, in the old shape."""
113
+ return [
114
+ SimpleDisplayMode(display_mode=token.text, description=token.description)
115
+ for token in DISPLAY_MODES
116
+ ]
117
+
118
+
119
+ @enumerations.get("/ControlCommands", summary="Available control commands")
120
+ async def control_commands() -> list[SimpleControlCommand]:
121
+ """List the control commands, in the old shape."""
122
+ return [
123
+ SimpleControlCommand(control_command=token.text, description=token.description)
124
+ for token in CONTROL_COMMANDS
125
+ ]
126
+
127
+
128
+ @enumerations.get("/MarkupTokens", summary="Available markup tokens")
129
+ async def markup_tokens() -> list[SimpleToken]:
130
+ """List the markup tokens, in the old shape.
131
+
132
+ ``token_text`` is the token to write in a message, such as ``<red>``, and
133
+ ``description`` says what it does. Easy to describe the wrong way round,
134
+ which is why they are spelled out here.
135
+ """
136
+ return [
137
+ SimpleToken(token_text=token.text, description=token.description)
138
+ for token in MARKUP_TOKENS
139
+ ]
140
+
141
+
142
+ router.include_router(write)
143
+ router.include_router(enumerations)
@@ -0,0 +1,207 @@
1
+ """The current API.
2
+
3
+ Status codes mean what they say here: 400 for a message the sign cannot render,
4
+ 401 for a missing key, 404 for a slot that does not exist, 409 when the pool is
5
+ full, and 503 when the sign is unreachable. The routes in ``routes_simple``
6
+ deliberately do none of that, and answer 200 to everything instead.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from fastapi import APIRouter, Response, status
12
+
13
+ from readerboard.api.deps import AlertsDep, ClockDep, ControllerDep, RegistryDep, RequireApiKey
14
+ from readerboard.api.models import (
15
+ AlertRequest,
16
+ AlertResponse,
17
+ ClockResponse,
18
+ ControlCommandRequest,
19
+ MessageRequest,
20
+ SlotKey,
21
+ SlotResponse,
22
+ TokenInfo,
23
+ )
24
+ from readerboard.protocol.tokens import (
25
+ CONTROL_COMMANDS,
26
+ DISPLAY_MODES,
27
+ MARKUP_TOKENS,
28
+ TEXT_POSITIONS,
29
+ Token,
30
+ )
31
+ from readerboard.services import commands
32
+
33
+ router = APIRouter(prefix="/v2")
34
+
35
+ messages = APIRouter(prefix="/messages", tags=["Messages"])
36
+ alerts_routes = APIRouter(prefix="/alerts", tags=["Alerts"])
37
+ sign_routes = APIRouter(prefix="/sign", tags=["Sign"])
38
+ enumerations = APIRouter(prefix="/enumerations", tags=["Enumerations"])
39
+
40
+
41
+ # ===========================================================================
42
+ # Messages
43
+ # ===========================================================================
44
+
45
+
46
+ @messages.get("", summary="List the messages sharing the sign")
47
+ async def list_messages(registry: RegistryDep) -> list[SlotResponse]:
48
+ """Return every registered slot, in the order the sign plays them."""
49
+ return [SlotResponse.of(slot) for slot in registry.list_slots()]
50
+
51
+
52
+ @messages.get("/{key}", summary="Read one message")
53
+ async def get_message(key: SlotKey, registry: RegistryDep) -> SlotResponse:
54
+ """Return one slot by name."""
55
+ return SlotResponse.of(registry.get(key))
56
+
57
+
58
+ @messages.put("/{key}", summary="Register or replace a message", dependencies=[RequireApiKey])
59
+ async def put_message(
60
+ key: SlotKey, body: MessageRequest, registry: RegistryDep
61
+ ) -> SlotResponse:
62
+ """Put a message in a slot, replacing whatever was there.
63
+
64
+ The sign rotates through every registered slot on its own, so registering a
65
+ second message does not displace the first.
66
+ """
67
+ slot = await registry.upsert(
68
+ key,
69
+ body.message,
70
+ mode=body.display_mode,
71
+ position=body.position,
72
+ order=body.order,
73
+ ttl_seconds=body.ttl_seconds,
74
+ source=body.source,
75
+ )
76
+ return SlotResponse.of(slot)
77
+
78
+
79
+ @messages.delete(
80
+ "/{key}",
81
+ summary="Take a message off the sign",
82
+ status_code=status.HTTP_204_NO_CONTENT,
83
+ dependencies=[RequireApiKey],
84
+ )
85
+ async def delete_message(key: SlotKey, registry: RegistryDep) -> Response:
86
+ """Remove one slot and free the sign file it held."""
87
+ await registry.remove(key)
88
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
89
+
90
+
91
+ @messages.delete(
92
+ "",
93
+ summary="Take every message off the sign",
94
+ status_code=status.HTTP_204_NO_CONTENT,
95
+ dependencies=[RequireApiKey],
96
+ )
97
+ async def clear_messages(registry: RegistryDep) -> Response:
98
+ """Remove every slot, leaving the sign showing nothing."""
99
+ await registry.clear()
100
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
101
+
102
+
103
+ # ===========================================================================
104
+ # Alerts
105
+ # ===========================================================================
106
+
107
+
108
+ @alerts_routes.get("", summary="Read the alert holding the sign")
109
+ async def get_alert(alerts: AlertsDep) -> AlertResponse | None:
110
+ """Return the active alert, or null if the sign is rotating normally."""
111
+ alert = alerts.active
112
+ return AlertResponse.of(alert) if alert else None
113
+
114
+
115
+ @alerts_routes.post("", summary="Take the sign over with an alert", dependencies=[RequireApiKey])
116
+ async def post_alert(body: AlertRequest, alerts: AlertsDep) -> AlertResponse:
117
+ """Take the whole sign over until the alert is released.
118
+
119
+ This uses the sign's priority file, which suppresses every other message. If
120
+ a ttl is given, the sign is released automatically and the rotation resumes
121
+ by itself.
122
+ """
123
+ alert = await alerts.raise_alert(
124
+ body.message,
125
+ mode=body.display_mode,
126
+ position=body.position,
127
+ ttl_seconds=body.ttl_seconds,
128
+ )
129
+ return AlertResponse.of(alert)
130
+
131
+
132
+ @alerts_routes.delete(
133
+ "",
134
+ summary="Give the sign back",
135
+ status_code=status.HTTP_204_NO_CONTENT,
136
+ dependencies=[RequireApiKey],
137
+ )
138
+ async def delete_alert(alerts: AlertsDep) -> Response:
139
+ """Release an alert so the rotation resumes."""
140
+ await alerts.release()
141
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
142
+
143
+
144
+ # ===========================================================================
145
+ # The sign itself
146
+ # ===========================================================================
147
+
148
+
149
+ @sign_routes.post("/sync-clock", summary="Set the sign's clock now", dependencies=[RequireApiKey])
150
+ async def sync_clock(clock: ClockDep) -> ClockResponse:
151
+ """Set the sign's clock and day of week immediately.
152
+
153
+ The service already does this at startup, hourly, and whenever the link to
154
+ the sign comes back. This is for when you would rather not wait.
155
+ """
156
+ return ClockResponse(synced_at=await clock.sync())
157
+
158
+
159
+ @sign_routes.post(
160
+ "/command",
161
+ summary="Send a control command to the sign",
162
+ status_code=status.HTTP_204_NO_CONTENT,
163
+ dependencies=[RequireApiKey],
164
+ )
165
+ async def send_command(body: ControlCommandRequest, controller: ControllerDep) -> Response:
166
+ """Send one of the sign's own control commands."""
167
+ await controller.send_special(commands.build(body.command, body.parameter))
168
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
169
+
170
+
171
+ # ===========================================================================
172
+ # Enumerations
173
+ # ===========================================================================
174
+
175
+
176
+ def _as_info(tokens: tuple[Token, ...]) -> list[TokenInfo]:
177
+ return [TokenInfo(name=token.text, description=token.description) for token in tokens]
178
+
179
+
180
+ @enumerations.get("/markup-tokens", summary="Markup tokens a message may contain")
181
+ async def markup_tokens() -> list[TokenInfo]:
182
+ """List every token that can be written inline in a message."""
183
+ return _as_info(MARKUP_TOKENS)
184
+
185
+
186
+ @enumerations.get("/display-modes", summary="Ways the sign can present a message")
187
+ async def display_modes() -> list[TokenInfo]:
188
+ """List every display mode."""
189
+ return _as_info(DISPLAY_MODES)
190
+
191
+
192
+ @enumerations.get("/text-positions", summary="Where text sits vertically")
193
+ async def text_positions() -> list[TokenInfo]:
194
+ """List every vertical text position."""
195
+ return _as_info(TEXT_POSITIONS)
196
+
197
+
198
+ @enumerations.get("/control-commands", summary="Commands aimed at the sign itself")
199
+ async def control_commands() -> list[TokenInfo]:
200
+ """List every control command."""
201
+ return _as_info(CONTROL_COMMANDS)
202
+
203
+
204
+ router.include_router(messages)
205
+ router.include_router(alerts_routes)
206
+ router.include_router(sign_routes)
207
+ router.include_router(enumerations)
readerboard/config.py ADDED
@@ -0,0 +1,222 @@
1
+ """Everything the service reads from its environment, in one place.
2
+
3
+ Settings come from three places, later ones winning: the defaults below, a TOML
4
+ file (``/etc/readerboard/config.toml`` unless ``READERBOARD_CONFIG_FILE`` says
5
+ otherwise), and environment variables prefixed ``READERBOARD_``.
6
+
7
+ One change here is worth calling out because it will bite on upgrade. The old
8
+ ``config.json`` held ``com_port``, a bare device name that the server prefixed
9
+ with ``/dev/`` before opening. This service takes a full pyserial URL in
10
+ ``serial_url`` instead, so a sign on an Ethernet adapter is
11
+ ``socket://192.168.2.51:4001`` and a sign on a cable is ``/dev/ttyUSB0``. There
12
+ is no prefixing, and no way to express a network sign in the old key.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pydantic import Field, SecretStr, field_validator, model_validator
22
+ from pydantic_settings import (
23
+ BaseSettings,
24
+ PydanticBaseSettingsSource,
25
+ SettingsConfigDict,
26
+ TomlConfigSettingsSource,
27
+ )
28
+
29
+ DEFAULT_CONFIG_FILE = Path("/etc/readerboard/config.toml")
30
+
31
+ # A BetaBrite Classic holds roughly 30000 bytes of messages and graphics all
32
+ # told. Allocating the whole of it leaves the sign no room for anything else, so
33
+ # the service refuses a pool that claims more than this much of it. The protocol
34
+ # charges eleven bytes of directory overhead per configured file on top of each
35
+ # file's own size, and that is counted too.
36
+ SIGN_MEMORY_BUDGET = 26000
37
+
38
+
39
+ def _config_file() -> Path:
40
+ override = os.environ.get("READERBOARD_CONFIG_FILE")
41
+ return Path(override) if override else DEFAULT_CONFIG_FILE
42
+
43
+
44
+ class Settings(BaseSettings):
45
+ """The service's configuration."""
46
+
47
+ model_config = SettingsConfigDict(
48
+ env_prefix="READERBOARD_",
49
+ toml_file=_config_file(),
50
+ extra="forbid",
51
+ )
52
+
53
+ # == the link to the sign ==============================================
54
+
55
+ serial_url: str = Field(
56
+ default="loop://",
57
+ description=(
58
+ "pyserial URL for the sign: socket://host:port for an Ethernet to RS-232 "
59
+ "adapter, /dev/ttyUSB0 for a direct cable, or loop:// to run without one"
60
+ ),
61
+ )
62
+ baud_rate: int = Field(default=9600, ge=110, le=921600)
63
+ serial_timeout: float = Field(default=10.0, gt=0)
64
+ inter_packet_delay: float = Field(
65
+ default=0.5,
66
+ ge=0,
67
+ le=10,
68
+ description=(
69
+ "seconds to wait after each transmission before sending another. The old "
70
+ "server always slept 2 seconds; run scripts/protocol_spike.py to find what "
71
+ "this sign actually needs"
72
+ ),
73
+ )
74
+ backoff_initial: float = Field(default=1.0, gt=0)
75
+ backoff_max: float = Field(default=60.0, gt=0)
76
+
77
+ # == the pool of sign files the rotation uses ==========================
78
+
79
+ slot_count: int = Field(
80
+ default=8,
81
+ ge=1,
82
+ le=26,
83
+ description="how many messages can share the sign at once, one sign file each",
84
+ )
85
+ slot_capacity: int = Field(
86
+ default=256,
87
+ ge=16,
88
+ le=4096,
89
+ description="bytes allocated to each message, after markup has been rendered",
90
+ )
91
+
92
+ # == behaviour =========================================================
93
+
94
+ default_display_mode: str = "HOLD"
95
+ default_text_position: str = "MIDDLE"
96
+ registry_sweep_seconds: float = Field(default=15.0, gt=0)
97
+ refresh_interval_seconds: float = Field(
98
+ default=900.0,
99
+ gt=0,
100
+ description=(
101
+ "how often to push every message to the sign again whether or not it looks "
102
+ "necessary. This is what repairs a sign that was power cycled behind a "
103
+ "still-connected Ethernet adapter, which nothing else can detect"
104
+ ),
105
+ )
106
+ default_slot_ttl_seconds: float | None = Field(
107
+ default=None,
108
+ gt=0,
109
+ description=(
110
+ "expire the slot POST /Write/Message writes to, after this long. Unset, "
111
+ "which is the default, reproduces the old behaviour exactly: the message "
112
+ "stays until something replaces it. Setting it means a Home Assistant "
113
+ "automation that dies leaves a visibly empty sign rather than a quietly "
114
+ "wrong temperature"
115
+ ),
116
+ )
117
+ clock_sync_enabled: bool = True
118
+ clock_sync_interval_seconds: float = Field(default=3600.0, gt=0)
119
+ timezone: str | None = Field(
120
+ default=None,
121
+ description=(
122
+ "IANA name such as America/New_York, used when setting the sign's clock. "
123
+ "Unset means the machine's own local time"
124
+ ),
125
+ )
126
+
127
+ # == the HTTP surface ==================================================
128
+
129
+ host: str = "0.0.0.0"
130
+ port: int = Field(default=5001, ge=1, le=65535)
131
+ api_key: SecretStr = Field(
132
+ default=SecretStr(""),
133
+ description="required on every write. Generated by scripts/install.sh if absent",
134
+ )
135
+
136
+ # == where state and logs go ===========================================
137
+
138
+ state_path: Path = Path("/var/lib/readerboard/state.json")
139
+ log_level: str = "INFO"
140
+ log_file: Path | None = None
141
+
142
+ @field_validator("log_level")
143
+ @classmethod
144
+ def _check_log_level(cls, value: str) -> str:
145
+ allowed = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"}
146
+ upper = value.upper()
147
+ if upper not in allowed:
148
+ raise ValueError("log_level must be one of %s" % ", ".join(sorted(allowed)))
149
+ return upper
150
+
151
+ @field_validator("default_display_mode")
152
+ @classmethod
153
+ def _check_mode(cls, value: str) -> str:
154
+ from readerboard.protocol.tokens import MODE_BY_NAME
155
+
156
+ upper = value.upper()
157
+ if upper not in MODE_BY_NAME:
158
+ raise ValueError("unknown display mode %r" % value)
159
+ return upper
160
+
161
+ @field_validator("default_text_position")
162
+ @classmethod
163
+ def _check_position(cls, value: str) -> str:
164
+ from readerboard.protocol.tokens import POSITION_BY_NAME
165
+
166
+ upper = value.upper()
167
+ if upper not in POSITION_BY_NAME:
168
+ raise ValueError("unknown text position %r" % value)
169
+ return upper
170
+
171
+ @field_validator("timezone")
172
+ @classmethod
173
+ def _check_timezone(cls, value: str | None) -> str | None:
174
+ if value is None:
175
+ return None
176
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
177
+
178
+ try:
179
+ ZoneInfo(value)
180
+ except (ZoneInfoNotFoundError, ValueError) as err:
181
+ raise ValueError("unknown timezone %r: %s" % (value, err)) from err
182
+ return value
183
+
184
+ @model_validator(mode="after")
185
+ def _check_pool_fits(self) -> Settings:
186
+ from readerboard.protocol.constants import FILE_OVERHEAD_BYTES
187
+
188
+ claimed = self.slot_count * (self.slot_capacity + FILE_OVERHEAD_BYTES)
189
+ if claimed > SIGN_MEMORY_BUDGET:
190
+ raise ValueError(
191
+ "slot_count %d at slot_capacity %d claims %d bytes of the sign's "
192
+ "memory pool, more than the %d this service is willing to take. "
193
+ "Lower one of them."
194
+ % (self.slot_count, self.slot_capacity, claimed, SIGN_MEMORY_BUDGET)
195
+ )
196
+ if self.backoff_max < self.backoff_initial:
197
+ raise ValueError("backoff_max must not be smaller than backoff_initial")
198
+ return self
199
+
200
+ @classmethod
201
+ def settings_customise_sources(
202
+ cls,
203
+ settings_cls: type[BaseSettings],
204
+ init_settings: PydanticBaseSettingsSource,
205
+ env_settings: PydanticBaseSettingsSource,
206
+ dotenv_settings: PydanticBaseSettingsSource,
207
+ file_secret_settings: PydanticBaseSettingsSource,
208
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
209
+ """Put the TOML file below the environment but above the defaults."""
210
+ return (
211
+ init_settings,
212
+ env_settings,
213
+ dotenv_settings,
214
+ TomlConfigSettingsSource(settings_cls),
215
+ file_secret_settings,
216
+ )
217
+
218
+ def redacted(self) -> dict[str, Any]:
219
+ """Return the settings as a dict with the API key removed, safe to log."""
220
+ data = self.model_dump(mode="json")
221
+ data["api_key"] = "set" if self.api_key.get_secret_value() else "unset"
222
+ return data
@@ -0,0 +1,53 @@
1
+ """Logging configuration.
2
+
3
+ Output goes to stderr, which is what journald reads when the service runs under
4
+ systemd, with an optional rotating file as well for anyone not running it that
5
+ way.
6
+
7
+ Log messages are documentation. They are the only account of what the service
8
+ did that anyone will read at three in the morning, so they say what happened and
9
+ what it means rather than dumping state. The one thing they never contain is the
10
+ API key.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import logging.handlers
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
21
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
22
+
23
+ MAX_FILE_BYTES = 1_000_000
24
+ BACKUP_COUNT = 3
25
+
26
+
27
+ def configure(level: str = "INFO", log_file: Path | None = None) -> None:
28
+ """Set up logging for the service. Safe to call more than once."""
29
+ root = logging.getLogger()
30
+ root.setLevel(level)
31
+
32
+ for handler in list(root.handlers):
33
+ root.removeHandler(handler)
34
+ handler.close()
35
+
36
+ formatter = logging.Formatter(FORMAT, datefmt=DATE_FORMAT)
37
+
38
+ stream = logging.StreamHandler(sys.stderr)
39
+ stream.setFormatter(formatter)
40
+ root.addHandler(stream)
41
+
42
+ if log_file is not None:
43
+ log_file.parent.mkdir(parents=True, exist_ok=True)
44
+ rotating = logging.handlers.RotatingFileHandler(
45
+ log_file, maxBytes=MAX_FILE_BYTES, backupCount=BACKUP_COUNT, encoding="utf-8"
46
+ )
47
+ rotating.setFormatter(formatter)
48
+ root.addHandler(rotating)
49
+
50
+ # uvicorn installs its own handlers; letting them propagate to ours would
51
+ # print every access line twice.
52
+ for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
53
+ logging.getLogger(name).propagate = False
@@ -0,0 +1 @@
1
+ """The Alpha sign protocol: constants, markup, and frame builders."""