one-ring-loop 0.1.0__tar.gz
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.
- one_ring_loop-0.1.0/.gitignore +43 -0
- one_ring_loop-0.1.0/.python-version +1 -0
- one_ring_loop-0.1.0/CLAUDE.md +28 -0
- one_ring_loop-0.1.0/PKG-INFO +83 -0
- one_ring_loop-0.1.0/README.md +60 -0
- one_ring_loop-0.1.0/justfile +20 -0
- one_ring_loop-0.1.0/pyproject.toml +44 -0
- one_ring_loop-0.1.0/src/one_ring_loop/__init__.py +12 -0
- one_ring_loop-0.1.0/src/one_ring_loop/_utils.py +63 -0
- one_ring_loop-0.1.0/src/one_ring_loop/cancellation.py +49 -0
- one_ring_loop-0.1.0/src/one_ring_loop/exceptions.py +2 -0
- one_ring_loop-0.1.0/src/one_ring_loop/fileio/__init__.py +40 -0
- one_ring_loop-0.1.0/src/one_ring_loop/log.py +56 -0
- one_ring_loop-0.1.0/src/one_ring_loop/loop.py +246 -0
- one_ring_loop-0.1.0/src/one_ring_loop/lowlevel.py +36 -0
- one_ring_loop-0.1.0/src/one_ring_loop/operations.py +29 -0
- one_ring_loop-0.1.0/src/one_ring_loop/py.typed +0 -0
- one_ring_loop-0.1.0/src/one_ring_loop/socketio/__init__.py +108 -0
- one_ring_loop-0.1.0/src/one_ring_loop/streams/__init__.py +0 -0
- one_ring_loop-0.1.0/src/one_ring_loop/streams/buffered.py +97 -0
- one_ring_loop-0.1.0/src/one_ring_loop/streams/exceptions.py +14 -0
- one_ring_loop-0.1.0/src/one_ring_loop/streams/memory.py +199 -0
- one_ring_loop-0.1.0/src/one_ring_loop/streams/protocols.py +29 -0
- one_ring_loop-0.1.0/src/one_ring_loop/streams/tls.py +126 -0
- one_ring_loop-0.1.0/src/one_ring_loop/sync_primitives.py +139 -0
- one_ring_loop-0.1.0/src/one_ring_loop/task/__init__.py +383 -0
- one_ring_loop-0.1.0/src/one_ring_loop/task/state.py +35 -0
- one_ring_loop-0.1.0/src/one_ring_loop/timerio/__init__.py +17 -0
- one_ring_loop-0.1.0/src/one_ring_loop/typedefs.py +18 -0
- one_ring_loop-0.1.0/tests/conftest.py +17 -0
- one_ring_loop-0.1.0/tests/streams/__init__.py +0 -0
- one_ring_loop-0.1.0/tests/streams/conftest.py +1 -0
- one_ring_loop-0.1.0/tests/streams/test_buffered_byte_stream.py +92 -0
- one_ring_loop-0.1.0/tests/streams/test_memory_object_stream.py +168 -0
- one_ring_loop-0.1.0/tests/streams/test_tls_stream.py +91 -0
- one_ring_loop-0.1.0/tests/test_cancellation.py +94 -0
- one_ring_loop-0.1.0/tests/test_fileio.py +27 -0
- one_ring_loop-0.1.0/tests/test_log.py +37 -0
- one_ring_loop-0.1.0/tests/test_loop.py +10 -0
- one_ring_loop-0.1.0/tests/test_socketio.py +54 -0
- one_ring_loop-0.1.0/tests/test_sync_primitives.py +253 -0
- one_ring_loop-0.1.0/tests/test_taskgroup.py +117 -0
- one_ring_loop-0.1.0/tests/test_timerio.py +13 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
|
|
8
|
+
# Virtual environments
|
|
9
|
+
.venv/
|
|
10
|
+
|
|
11
|
+
# Testing
|
|
12
|
+
.coverage
|
|
13
|
+
htmlcov/
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
|
|
16
|
+
# Tools
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# IDE
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/*
|
|
22
|
+
!.vscode/settings.json
|
|
23
|
+
!.vscode/extensions.json
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# Docs
|
|
28
|
+
site/
|
|
29
|
+
|
|
30
|
+
# Secrets
|
|
31
|
+
.env
|
|
32
|
+
.env.*
|
|
33
|
+
*.pem
|
|
34
|
+
|
|
35
|
+
# OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
Thumbs.db
|
|
38
|
+
|
|
39
|
+
# Personal testing.
|
|
40
|
+
tmp/
|
|
41
|
+
|
|
42
|
+
# Agents
|
|
43
|
+
.claude
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# one-ring-loop — Package Context
|
|
2
|
+
|
|
3
|
+
Part of the **one-ring** monorepo. See the root `CLAUDE.md` for shared conventions.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
<!-- Describe what this package does -->
|
|
8
|
+
|
|
9
|
+
## Package Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# From this directory:
|
|
13
|
+
just test # Run tests for this package
|
|
14
|
+
just test-cov # Tests with coverage
|
|
15
|
+
just typecheck # Type check this package
|
|
16
|
+
|
|
17
|
+
# From monorepo root:
|
|
18
|
+
just test-pkg one-ring-loop # Test this package
|
|
19
|
+
just check # Run all checks (all packages)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Layout
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
src/one_ring_loop/ # Source code
|
|
26
|
+
tests/ # Tests
|
|
27
|
+
pyproject.toml # Package metadata (tool config inherited from root)
|
|
28
|
+
```
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: one-ring-loop
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Custom async event loop built on io_uring with structured concurrency, file/socket IO, and timers
|
|
5
|
+
Project-URL: Homepage, https://github.com/otto-sellerstam/one-ring
|
|
6
|
+
Project-URL: Repository, https://github.com/otto-sellerstam/one-ring
|
|
7
|
+
Project-URL: Issues, https://github.com/otto-sellerstam/one-ring/issues
|
|
8
|
+
Author-email: Otto Sellerstam <ottosellerstam@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: async,coroutines,event-loop,io_uring,linux
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Requires-Dist: one-ring-core>=0.1.0
|
|
21
|
+
Requires-Dist: structlog>=24.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# one-ring-loop
|
|
25
|
+
|
|
26
|
+
Custom async event loop built on [one-ring-core](https://pypi.org/project/one-ring-core/) and Linux io_uring.
|
|
27
|
+
|
|
28
|
+
Part of the [one-ring](https://github.com/otto-sellerstam/one-ring) project.
|
|
29
|
+
|
|
30
|
+
## What it provides
|
|
31
|
+
|
|
32
|
+
- **Task scheduling** with `Task` and `TaskGroup` (structured concurrency)
|
|
33
|
+
- **File IO** - async file operations via io_uring
|
|
34
|
+
- **Socket IO** - TCP server/client with `create_server`, `Connection.receive`/`send`
|
|
35
|
+
- **Timers** - async `sleep` backed by io_uring timeouts
|
|
36
|
+
- **Streams** - buffered byte streams, TLS wrapping, memory streams
|
|
37
|
+
- **Cancellation** - `move_on_after`/`fail_after` scoped timeout/cancellation
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from one_ring_loop import TaskGroup, run
|
|
43
|
+
from one_ring_loop.socketio import Connection, create_server
|
|
44
|
+
|
|
45
|
+
def echo_handler(conn):
|
|
46
|
+
try:
|
|
47
|
+
while True:
|
|
48
|
+
data = yield from conn.receive(1024)
|
|
49
|
+
if not data:
|
|
50
|
+
break
|
|
51
|
+
yield from conn.send(b"Echo: " + data)
|
|
52
|
+
finally:
|
|
53
|
+
yield from conn.close()
|
|
54
|
+
|
|
55
|
+
def main():
|
|
56
|
+
server = yield from create_server(b"0.0.0.0", 9999)
|
|
57
|
+
tg = TaskGroup()
|
|
58
|
+
tg.enter()
|
|
59
|
+
try:
|
|
60
|
+
while True:
|
|
61
|
+
conn = yield from server.accept()
|
|
62
|
+
tg.create_task(echo_handler(conn))
|
|
63
|
+
finally:
|
|
64
|
+
yield from tg.exit()
|
|
65
|
+
yield from server.close()
|
|
66
|
+
|
|
67
|
+
run(main())
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Requirements
|
|
71
|
+
|
|
72
|
+
- **Linux** with io_uring support (kernel 6.7+)
|
|
73
|
+
- **Python 3.14+**
|
|
74
|
+
|
|
75
|
+
## Installation
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uv add one-ring-loop
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## License
|
|
82
|
+
|
|
83
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# one-ring-loop
|
|
2
|
+
|
|
3
|
+
Custom async event loop built on [one-ring-core](https://pypi.org/project/one-ring-core/) and Linux io_uring.
|
|
4
|
+
|
|
5
|
+
Part of the [one-ring](https://github.com/otto-sellerstam/one-ring) project.
|
|
6
|
+
|
|
7
|
+
## What it provides
|
|
8
|
+
|
|
9
|
+
- **Task scheduling** with `Task` and `TaskGroup` (structured concurrency)
|
|
10
|
+
- **File IO** - async file operations via io_uring
|
|
11
|
+
- **Socket IO** - TCP server/client with `create_server`, `Connection.receive`/`send`
|
|
12
|
+
- **Timers** - async `sleep` backed by io_uring timeouts
|
|
13
|
+
- **Streams** - buffered byte streams, TLS wrapping, memory streams
|
|
14
|
+
- **Cancellation** - `move_on_after`/`fail_after` scoped timeout/cancellation
|
|
15
|
+
|
|
16
|
+
## Example
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from one_ring_loop import TaskGroup, run
|
|
20
|
+
from one_ring_loop.socketio import Connection, create_server
|
|
21
|
+
|
|
22
|
+
def echo_handler(conn):
|
|
23
|
+
try:
|
|
24
|
+
while True:
|
|
25
|
+
data = yield from conn.receive(1024)
|
|
26
|
+
if not data:
|
|
27
|
+
break
|
|
28
|
+
yield from conn.send(b"Echo: " + data)
|
|
29
|
+
finally:
|
|
30
|
+
yield from conn.close()
|
|
31
|
+
|
|
32
|
+
def main():
|
|
33
|
+
server = yield from create_server(b"0.0.0.0", 9999)
|
|
34
|
+
tg = TaskGroup()
|
|
35
|
+
tg.enter()
|
|
36
|
+
try:
|
|
37
|
+
while True:
|
|
38
|
+
conn = yield from server.accept()
|
|
39
|
+
tg.create_task(echo_handler(conn))
|
|
40
|
+
finally:
|
|
41
|
+
yield from tg.exit()
|
|
42
|
+
yield from server.close()
|
|
43
|
+
|
|
44
|
+
run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- **Linux** with io_uring support (kernel 6.7+)
|
|
50
|
+
- **Python 3.14+**
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv add one-ring-loop
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# List available recipes
|
|
2
|
+
default:
|
|
3
|
+
@just --list
|
|
4
|
+
|
|
5
|
+
pkg := "one_ring_loop"
|
|
6
|
+
|
|
7
|
+
# Run tests for this package
|
|
8
|
+
test *args:
|
|
9
|
+
uv run pytest tests/ {{args}}
|
|
10
|
+
|
|
11
|
+
# Run tests with coverage
|
|
12
|
+
test-cov:
|
|
13
|
+
uv run pytest tests/ --cov={{pkg}} --cov-report=term-missing
|
|
14
|
+
|
|
15
|
+
# Run type checker for this package
|
|
16
|
+
typecheck:
|
|
17
|
+
uv run pyrefly check src/ tests/
|
|
18
|
+
|
|
19
|
+
# Run all checks for this package
|
|
20
|
+
check: typecheck test
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "one-ring-loop"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Custom async event loop built on io_uring with structured concurrency, file/socket IO, and timers"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Otto Sellerstam", email = "ottosellerstam@gmail.com" },
|
|
9
|
+
]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
keywords = ["io_uring", "async", "event-loop", "coroutines", "linux"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Operating System :: POSIX :: Linux",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.14",
|
|
19
|
+
"Topic :: System :: Networking",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"one-ring-core>=0.1.0",
|
|
24
|
+
"structlog>=24.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/otto-sellerstam/one-ring"
|
|
29
|
+
Repository = "https://github.com/otto-sellerstam/one-ring"
|
|
30
|
+
Issues = "https://github.com/otto-sellerstam/one-ring/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/one_ring_loop"]
|
|
38
|
+
|
|
39
|
+
# ── Coverage (per-package source) ───────────────────────────────────────
|
|
40
|
+
# Other coverage settings (fail_under, exclude_lines) are in root pyproject.toml.
|
|
41
|
+
|
|
42
|
+
[tool.coverage.run]
|
|
43
|
+
source = ["one_ring_loop"]
|
|
44
|
+
branch = true
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections import deque
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
|
+
|
|
8
|
+
from one_ring_core.results import IOResult
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from one_ring_core.operations import IOOperation
|
|
12
|
+
from one_ring_loop.loop import Loop
|
|
13
|
+
from one_ring_loop.typedefs import Coro, TaskID
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_new_operation_id() -> TaskID:
|
|
17
|
+
"""Gets an unused ID to submit to the IO worker."""
|
|
18
|
+
ret = _local.free_operation_id
|
|
19
|
+
_local.free_operation_id += 1
|
|
20
|
+
|
|
21
|
+
return ret
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _execute[T: IOResult](op: IOOperation[T]) -> Coro[T]:
|
|
25
|
+
"""Unwrap an IO completion into the expected result type."""
|
|
26
|
+
expected = op.result_type
|
|
27
|
+
completion = yield cast("IOOperation[IOResult]", op)
|
|
28
|
+
if completion is not None and isinstance(result := completion.unwrap(), expected):
|
|
29
|
+
return result
|
|
30
|
+
elif completion is None:
|
|
31
|
+
raise RuntimeError("Low level coroutine was sent None")
|
|
32
|
+
|
|
33
|
+
msg = f"Expected {expected.__name__}, got {type(completion)}. Expected {expected}"
|
|
34
|
+
raise TypeError(msg)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(kw_only=True)
|
|
38
|
+
class _Local(threading.local):
|
|
39
|
+
"""Wrapper around threading.local for proper type annotations."""
|
|
40
|
+
|
|
41
|
+
loop: Loop | None = None
|
|
42
|
+
free_operation_id: int = 1
|
|
43
|
+
|
|
44
|
+
# TODO: Move the below two to be attributes on Loop.
|
|
45
|
+
cancel_queue: deque[TaskID] = field(default_factory=deque)
|
|
46
|
+
unpark_queue: deque[TaskID] = field(default_factory=deque)
|
|
47
|
+
|
|
48
|
+
def cleanup(self) -> None:
|
|
49
|
+
"""Resets all attributes."""
|
|
50
|
+
self.loop = None
|
|
51
|
+
self.free_operation_id = 1
|
|
52
|
+
|
|
53
|
+
self.cancel_queue = deque()
|
|
54
|
+
self.unpark_queue = deque()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_local = _Local()
|
|
58
|
+
|
|
59
|
+
__all__ = [
|
|
60
|
+
"_execute",
|
|
61
|
+
"_get_new_operation_id",
|
|
62
|
+
"_local",
|
|
63
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from contextlib import contextmanager, suppress
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from one_ring_loop.exceptions import Cancelled
|
|
5
|
+
from one_ring_loop.log import get_logger
|
|
6
|
+
from one_ring_loop.task import CancelScope, _create_standalone_task
|
|
7
|
+
from one_ring_loop.timerio import sleep
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Generator
|
|
11
|
+
|
|
12
|
+
from one_ring_loop.typedefs import Coro
|
|
13
|
+
|
|
14
|
+
logger = get_logger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@contextmanager
|
|
18
|
+
def fail_after(delay: float, *, shield: bool = False) -> Generator[CancelScope]:
|
|
19
|
+
"""Cancels cancel scope and throws Cancelled after delay."""
|
|
20
|
+
|
|
21
|
+
def cancellation_task(cancel_scope: CancelScope) -> Coro[None]:
|
|
22
|
+
"""Background task that sleeps for delay.
|
|
23
|
+
|
|
24
|
+
Cancels the cancel scope if not finished after sleep.
|
|
25
|
+
"""
|
|
26
|
+
with suppress(Cancelled):
|
|
27
|
+
yield from sleep(delay)
|
|
28
|
+
|
|
29
|
+
if not finished:
|
|
30
|
+
cancel_scope.cancel()
|
|
31
|
+
|
|
32
|
+
finished = False
|
|
33
|
+
with CancelScope(shielded=shield) as scope:
|
|
34
|
+
task = _create_standalone_task(cancellation_task(scope), None, None)
|
|
35
|
+
yield scope
|
|
36
|
+
task.current_cancel_scope().cancel()
|
|
37
|
+
finished = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def move_on_after(delay: float, *, shield: bool = False) -> Generator[CancelScope]:
|
|
42
|
+
"""Moves on after delay by catching Cancelled."""
|
|
43
|
+
with fail_after(delay, shield=shield) as cancel_scope:
|
|
44
|
+
try:
|
|
45
|
+
yield cancel_scope
|
|
46
|
+
except Cancelled:
|
|
47
|
+
if not cancel_scope.cancelled:
|
|
48
|
+
# Another scope cancelled from above. Re-raise.
|
|
49
|
+
raise
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from one_ring_core.operations import Close, FileOpen, FileRead, FileWrite
|
|
6
|
+
from one_ring_loop._utils import _execute
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from one_ring_loop.typedefs import Coro
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True, kw_only=True)
|
|
13
|
+
class File:
|
|
14
|
+
"""Utility wrapper for file operations. Represents a single regular file."""
|
|
15
|
+
|
|
16
|
+
fd: int
|
|
17
|
+
|
|
18
|
+
def read(self) -> Coro[str]:
|
|
19
|
+
"""Read file low-level coroutine."""
|
|
20
|
+
result = yield from _execute(FileRead(fd=self.fd))
|
|
21
|
+
return result.content.decode()
|
|
22
|
+
|
|
23
|
+
def write(self, data: bytes | str) -> Coro[int]:
|
|
24
|
+
"""Write file low-level coroutine."""
|
|
25
|
+
_data = data.encode() if isinstance(data, str) else data
|
|
26
|
+
result = yield from _execute(FileWrite(fd=self.fd, data=_data))
|
|
27
|
+
return result.size
|
|
28
|
+
|
|
29
|
+
def close(self) -> Coro[None]:
|
|
30
|
+
"""Close file low-level coroutine."""
|
|
31
|
+
yield from _execute(Close(fd=self.fd))
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def open_file(path: str | Path, mode: str = "r") -> Coro[File]:
|
|
36
|
+
"""Open file coroutine."""
|
|
37
|
+
_path = str(path) if isinstance(path, Path) else path
|
|
38
|
+
|
|
39
|
+
result = yield from _execute(FileOpen(path=_path.encode(), mode=mode))
|
|
40
|
+
return File(fd=result.fd)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Structured logging configuration using structlog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def setup_logging() -> None:
|
|
12
|
+
"""Configure structlog with sensible defaults.
|
|
13
|
+
|
|
14
|
+
Uses JSON output when ``LOG_FORMAT=json`` (e.g. production),
|
|
15
|
+
otherwise uses colored console output for development.
|
|
16
|
+
"""
|
|
17
|
+
shared_processors: list[structlog.types.Processor] = [
|
|
18
|
+
structlog.contextvars.merge_contextvars,
|
|
19
|
+
structlog.stdlib.add_log_level,
|
|
20
|
+
# structlog.stdlib.add_logger_name,
|
|
21
|
+
structlog.processors.TimeStamper(fmt="iso"),
|
|
22
|
+
structlog.processors.StackInfoRenderer(),
|
|
23
|
+
structlog.processors.UnicodeDecoder(),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
if os.environ.get("LOG_FORMAT") == "json":
|
|
27
|
+
renderer: structlog.types.Processor = structlog.processors.JSONRenderer()
|
|
28
|
+
else:
|
|
29
|
+
renderer = structlog.dev.ConsoleRenderer()
|
|
30
|
+
|
|
31
|
+
structlog.configure(
|
|
32
|
+
processors=[
|
|
33
|
+
*shared_processors,
|
|
34
|
+
renderer,
|
|
35
|
+
],
|
|
36
|
+
wrapper_class=structlog.make_filtering_bound_logger(0),
|
|
37
|
+
context_class=dict,
|
|
38
|
+
logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
|
|
39
|
+
cache_logger_on_first_use=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_configured = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_logger(*args: object, **kwargs: object) -> structlog.stdlib.BoundLogger:
|
|
47
|
+
"""Return a structlog logger, configuring on first call.
|
|
48
|
+
|
|
49
|
+
This avoids running ``setup_logging()`` at import time, which would
|
|
50
|
+
interfere with tests and multi-process setups.
|
|
51
|
+
"""
|
|
52
|
+
global _configured # noqa: PLW0603
|
|
53
|
+
if not _configured:
|
|
54
|
+
setup_logging()
|
|
55
|
+
_configured = True
|
|
56
|
+
return structlog.get_logger(*args, **kwargs)
|