usagerail 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.
@@ -0,0 +1,40 @@
1
+ # 密钥和本地环境:永远不提交
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+ *.pem
6
+ *.key
7
+ secrets/
8
+
9
+ # 依赖和构建产物
10
+ node_modules/
11
+ .pnpm-store/
12
+ .next/
13
+ out/
14
+ dist/
15
+ build/
16
+ coverage/
17
+ *.tsbuildinfo
18
+ .turbo/
19
+ .vercel/
20
+
21
+ # Python
22
+ __pycache__/
23
+ *.py[cod]
24
+ .venv/
25
+ .pytest_cache/
26
+ .ruff_cache/
27
+ .mypy_cache/
28
+ *.egg-info/
29
+
30
+ # 交叉审查的原始输出(本地留档)
31
+ .review/
32
+
33
+ # 系统和编辑器
34
+ .DS_Store
35
+ *.log
36
+ .idea/
37
+ .vscode/
38
+
39
+ # Stripe 沙盒每次运行建的对象 ID(清理用)
40
+ .stripe-runs/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UsageRail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.5
2
+ Name: usagerail
3
+ Version: 0.1.0
4
+ Summary: UsageRail SDK for Python: API keys, rate limits, quotas and usage metering inside your API server
5
+ Project-URL: Homepage, https://usagerail.com
6
+ Project-URL: Documentation, https://usagerail.com/docs
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Framework :: Flask
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+
17
+ # usagerail
18
+
19
+ The UsageRail SDK for Python. It checks API keys, applies rate limits and quotas, and counts usage per customer inside your own process, then reports the counts to UsageRail in the background. Requests never pass through us.
20
+
21
+ It needs Python 3.10 or newer and has no dependencies of its own. Versions start at 0.x, so names may still change between minor versions.
22
+
23
+ ```bash
24
+ pip install usagerail
25
+ ```
26
+
27
+ Get the app key from the console at https://app.usagerail.com. Setup for each framework, options and known limitations are in the docs at https://usagerail.com/docs.
28
+
29
+ ## FastAPI and Starlette
30
+
31
+ ```python
32
+ import os
33
+
34
+ from fastapi import FastAPI, Request
35
+ from usagerail.fastapi import UsageRailMiddleware
36
+
37
+ app = FastAPI()
38
+ app.add_middleware(UsageRailMiddleware, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
39
+
40
+
41
+ @app.post("/v1/generate")
42
+ async def generate(request: Request):
43
+ result = await run_model(await request.json())
44
+ request.state.usagerail.track(result.tokens) # optional: count tokens instead of requests
45
+ return result.body
46
+ ```
47
+
48
+ ## Flask
49
+
50
+ ```python
51
+ import os
52
+
53
+ from flask import Flask, g, request
54
+ from usagerail.flask import UsageRail
55
+
56
+ app = Flask(__name__)
57
+ UsageRail(app, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
58
+
59
+
60
+ @app.post("/v1/generate")
61
+ def generate():
62
+ result = run_model(request.json)
63
+ g.usagerail.track(result.tokens)
64
+ return result.body
65
+ ```
66
+
67
+ ## Django
68
+
69
+ ```python
70
+ # settings.py
71
+ MIDDLEWARE = [
72
+ # ...
73
+ "usagerail.django.UsageRailMiddleware",
74
+ ]
75
+ USAGERAIL = {"app_key": os.environ["USAGERAIL_APP_KEY"], "auth": "api-key"}
76
+
77
+ # views.py
78
+ def generate(request):
79
+ result = run_model(request.body)
80
+ request.usagerail.track(result.tokens)
81
+ return JsonResponse(result.body)
82
+ ```
83
+
84
+ ## Your own authentication
85
+
86
+ If your API already has its own keys or logins, pass `auth="custom"` and a `consumer` function. It gets the framework's request and returns your customer's ID, or `None` for anonymous traffic. Return an identity your own code has verified, never a value the caller can set, such as a plain header:
87
+
88
+ ```python
89
+ app.add_middleware(
90
+ UsageRailMiddleware,
91
+ app_key=os.environ["USAGERAIL_APP_KEY"],
92
+ auth="custom",
93
+ consumer=lambda request: request.user.identity if request.user.is_authenticated else None,
94
+ )
95
+ ```
96
+
97
+ The function runs before your route handlers. In FastAPI that is before `Depends(...)`, so the identity has to come from an authentication middleware that runs first. FastAPI runs the middleware added last first, so call `app.add_middleware()` for your authentication after the line that adds UsageRail. In Django, list UsageRail after `AuthenticationMiddleware` if you want `request.user`. In Flask, register your own `before_request` hook first.
98
+
99
+ ## Good to know before you deploy
100
+
101
+ - Rejected requests get 401 (unknown or revoked key), 403 (suspended customer), 429 (rate limit or quota), 402 (prepaid credit used up) or 503 (UsageRail unreachable and you chose to refuse). The body is `{"error": "...", "retry_after": 30}`. Pass `on_reject` to send your own response instead.
102
+ - Counts are sent every 60 seconds and once more when the app shuts down. Under uvicorn, FastAPI's lifespan shutdown sends them. Flask and Django send them when the process exits normally, which gunicorn does on SIGTERM; uvicorn re-raises SIGTERM after shutting down, so Django under uvicorn loses the counts it hadn't sent. If a process is killed hard (`kill -9`, out of memory), up to about 10 minutes of unsent counts are lost and never billed.
103
+ - With `gunicorn --preload`, each worker becomes a separate SDK instance after the fork and keeps the key list it inherited.
104
+ - The key list is cached in a private folder in the system temp directory, so a restart doesn't wait for the first download. Pass `snapshot=False` to turn this off.
105
+ - WebSocket connections are not checked or counted.
106
+ - Memory: around 15 MB with 10,000 keys and customers, and at most about 85 MB at the limits: 100,000 keys, 100,000 customers' settings, and the 40,000 customers used most recently. Customers beyond that are fetched again when they come back, which adds one round trip to their next request.
107
+ - Other frameworks can use `usagerail.create_engine()` directly: call `begin()` when a request arrives and `end()` when it finishes.
@@ -0,0 +1,91 @@
1
+ # usagerail
2
+
3
+ The UsageRail SDK for Python. It checks API keys, applies rate limits and quotas, and counts usage per customer inside your own process, then reports the counts to UsageRail in the background. Requests never pass through us.
4
+
5
+ It needs Python 3.10 or newer and has no dependencies of its own. Versions start at 0.x, so names may still change between minor versions.
6
+
7
+ ```bash
8
+ pip install usagerail
9
+ ```
10
+
11
+ Get the app key from the console at https://app.usagerail.com. Setup for each framework, options and known limitations are in the docs at https://usagerail.com/docs.
12
+
13
+ ## FastAPI and Starlette
14
+
15
+ ```python
16
+ import os
17
+
18
+ from fastapi import FastAPI, Request
19
+ from usagerail.fastapi import UsageRailMiddleware
20
+
21
+ app = FastAPI()
22
+ app.add_middleware(UsageRailMiddleware, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
23
+
24
+
25
+ @app.post("/v1/generate")
26
+ async def generate(request: Request):
27
+ result = await run_model(await request.json())
28
+ request.state.usagerail.track(result.tokens) # optional: count tokens instead of requests
29
+ return result.body
30
+ ```
31
+
32
+ ## Flask
33
+
34
+ ```python
35
+ import os
36
+
37
+ from flask import Flask, g, request
38
+ from usagerail.flask import UsageRail
39
+
40
+ app = Flask(__name__)
41
+ UsageRail(app, app_key=os.environ["USAGERAIL_APP_KEY"], auth="api-key")
42
+
43
+
44
+ @app.post("/v1/generate")
45
+ def generate():
46
+ result = run_model(request.json)
47
+ g.usagerail.track(result.tokens)
48
+ return result.body
49
+ ```
50
+
51
+ ## Django
52
+
53
+ ```python
54
+ # settings.py
55
+ MIDDLEWARE = [
56
+ # ...
57
+ "usagerail.django.UsageRailMiddleware",
58
+ ]
59
+ USAGERAIL = {"app_key": os.environ["USAGERAIL_APP_KEY"], "auth": "api-key"}
60
+
61
+ # views.py
62
+ def generate(request):
63
+ result = run_model(request.body)
64
+ request.usagerail.track(result.tokens)
65
+ return JsonResponse(result.body)
66
+ ```
67
+
68
+ ## Your own authentication
69
+
70
+ If your API already has its own keys or logins, pass `auth="custom"` and a `consumer` function. It gets the framework's request and returns your customer's ID, or `None` for anonymous traffic. Return an identity your own code has verified, never a value the caller can set, such as a plain header:
71
+
72
+ ```python
73
+ app.add_middleware(
74
+ UsageRailMiddleware,
75
+ app_key=os.environ["USAGERAIL_APP_KEY"],
76
+ auth="custom",
77
+ consumer=lambda request: request.user.identity if request.user.is_authenticated else None,
78
+ )
79
+ ```
80
+
81
+ The function runs before your route handlers. In FastAPI that is before `Depends(...)`, so the identity has to come from an authentication middleware that runs first. FastAPI runs the middleware added last first, so call `app.add_middleware()` for your authentication after the line that adds UsageRail. In Django, list UsageRail after `AuthenticationMiddleware` if you want `request.user`. In Flask, register your own `before_request` hook first.
82
+
83
+ ## Good to know before you deploy
84
+
85
+ - Rejected requests get 401 (unknown or revoked key), 403 (suspended customer), 429 (rate limit or quota), 402 (prepaid credit used up) or 503 (UsageRail unreachable and you chose to refuse). The body is `{"error": "...", "retry_after": 30}`. Pass `on_reject` to send your own response instead.
86
+ - Counts are sent every 60 seconds and once more when the app shuts down. Under uvicorn, FastAPI's lifespan shutdown sends them. Flask and Django send them when the process exits normally, which gunicorn does on SIGTERM; uvicorn re-raises SIGTERM after shutting down, so Django under uvicorn loses the counts it hadn't sent. If a process is killed hard (`kill -9`, out of memory), up to about 10 minutes of unsent counts are lost and never billed.
87
+ - With `gunicorn --preload`, each worker becomes a separate SDK instance after the fork and keeps the key list it inherited.
88
+ - The key list is cached in a private folder in the system temp directory, so a restart doesn't wait for the first download. Pass `snapshot=False` to turn this off.
89
+ - WebSocket connections are not checked or counted.
90
+ - Memory: around 15 MB with 10,000 keys and customers, and at most about 85 MB at the limits: 100,000 keys, 100,000 customers' settings, and the 40,000 customers used most recently. Customers beyond that are fetched again when they come back, which adds one round trip to their next request.
91
+ - Other frameworks can use `usagerail.create_engine()` directly: call `begin()` when a request arrives and `end()` when it finishes.
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.32.4"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "usagerail"
7
+ version = "0.1.0"
8
+ description = "UsageRail SDK for Python: API keys, rate limits, quotas and usage metering inside your API server"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ # 运行时不依赖任何第三方包:适配器只在用到时才导入对应的框架
13
+ dependencies = []
14
+ classifiers = [
15
+ "Framework :: Django",
16
+ "Framework :: FastAPI",
17
+ "Framework :: Flask",
18
+ "Programming Language :: Python :: 3",
19
+ "Typing :: Typed",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://usagerail.com"
24
+ Documentation = "https://usagerail.com/docs"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest==9.1.1",
29
+ "mypy==2.4.0",
30
+ "fastapi==0.142.2",
31
+ "starlette==1.7.0",
32
+ # Starlette 1.2 起 TestClient 用 httpx2,用 httpx 会警告
33
+ "httpx2==2.13.1",
34
+ "flask==3.1.3",
35
+ "werkzeug==3.1.9",
36
+ "django==6.1.1; python_version >= '3.12'",
37
+ ]
38
+
39
+ [tool.uv]
40
+ # 只装发布满两周的版本(和 pnpm 的 minimumReleaseAge 同一条规矩),间接依赖也算
41
+ exclude-newer = "14 days"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/usagerail"]
45
+
46
+ # sdist 和 Node 包一样只放装包用得到的:实现笔记(NOTES.md)、测试、基准不发出去(D47)。
47
+ # pyproject.toml、README、LICENSE hatchling 会自己带上
48
+ [tool.hatch.build.targets.sdist]
49
+ include = ["src/usagerail"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+
54
+ [tool.mypy]
55
+ files = ["src"]
56
+ python_version = "3.10"
57
+ strict = true
58
+ # Django 不带类型标注(要另装 django-stubs),只查我们自己的代码
59
+ [[tool.mypy.overrides]]
60
+ module = ["django.*"]
61
+ ignore_missing_imports = true
@@ -0,0 +1,43 @@
1
+ """UsageRail 的 Python SDK:在你自己的 API 服务里发 key、限流、管额度、计量(PRD 5.1)。
2
+
3
+ FastAPI / Starlette 用 usagerail.asgi,Flask 用 usagerail.flask,Django 用 usagerail.django。
4
+ 自己接别的框架时用 create_engine:begin() 拿到结论,请求结束调 end()。
5
+ """
6
+
7
+ from .engine import (
8
+ MAX_TRACK_UNITS,
9
+ Admission,
10
+ ConsumerIdentity,
11
+ Decision,
12
+ Engine,
13
+ Pending,
14
+ Rejection,
15
+ RejectionCode,
16
+ RequestHandle,
17
+ create_engine,
18
+ read_api_key,
19
+ rejection_body,
20
+ )
21
+ from .key_store import SnapshotStore
22
+ from .snapshot import FileSnapshotStore
23
+ from .transport import HttpTransport, Transport, TransportError
24
+
25
+ __all__ = [
26
+ "MAX_TRACK_UNITS",
27
+ "Admission",
28
+ "ConsumerIdentity",
29
+ "Decision",
30
+ "Engine",
31
+ "FileSnapshotStore",
32
+ "HttpTransport",
33
+ "Pending",
34
+ "Rejection",
35
+ "RejectionCode",
36
+ "RequestHandle",
37
+ "SnapshotStore",
38
+ "Transport",
39
+ "TransportError",
40
+ "create_engine",
41
+ "read_api_key",
42
+ "rejection_body",
43
+ ]
@@ -0,0 +1,14 @@
1
+ """三个框架适配器共用的一点东西。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .engine import Engine, create_engine
8
+ from .snapshot import FileSnapshotStore
9
+
10
+
11
+ def engine_for(app_key: str, auth: str, snapshot: bool, options: dict[str, Any]) -> Engine[Any]:
12
+ """key 快照只对用我们 key 的应用有意义,默认落盘到本地临时目录(PRD 5.3)"""
13
+ store = FileSnapshotStore(app_key) if snapshot and auth == "api-key" and app_key else None
14
+ return create_engine(app_key=app_key, auth=auth, snapshot_store=store, **options)
@@ -0,0 +1,100 @@
1
+ """把 concurrent.futures.Future 当成 Node 的 Promise 用的几个小工具。
2
+
3
+ 引擎里走网络的地方(远程校验、领租约、同步、上报)都返回 Future:Flask、Django 的线程里阻塞等它,
4
+ FastAPI 里 await 它。等的时候不占线程,几百个请求同时等租约也不会把线程池耗光(PRD「最多多等 300 毫秒」)。
5
+ 回调在完成它的那个线程里跑,已经完成的 Future 加回调会当场跑,所以引擎的状态一律在锁里改。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from concurrent.futures import Future, InvalidStateError
12
+ from typing import Any, Callable, Generator, Generic, TypeVar
13
+
14
+ T = TypeVar("T")
15
+ U = TypeVar("U")
16
+
17
+
18
+ def resolved(value: T) -> Future[T]:
19
+ future: Future[T] = Future()
20
+ future.set_result(value)
21
+ return future
22
+
23
+
24
+ def settle(future: Future[T], value: T) -> None:
25
+ """已经有结果的不再改(超时和完成同时到时,先到的算)"""
26
+ try:
27
+ future.set_result(value)
28
+ except InvalidStateError:
29
+ pass
30
+
31
+
32
+ def fail(future: Future[Any], error: BaseException) -> None:
33
+ try:
34
+ future.set_exception(error)
35
+ except InvalidStateError:
36
+ pass
37
+
38
+
39
+ def error_of(future: Future[Any]) -> BaseException | None:
40
+ """已经完成的 Future 的异常;取消的当作出错"""
41
+ if future.cancelled():
42
+ return RuntimeError("cancelled")
43
+ return future.exception()
44
+
45
+
46
+ def then(
47
+ future: Future[T],
48
+ on_value: Callable[[T], U | Future[U]],
49
+ on_error: Callable[[BaseException], U | Future[U]] | None = None,
50
+ ) -> Future[U]:
51
+ """Promise.then:回调可以返回值,也可以返回 Future(会展开)"""
52
+ out: Future[U] = Future()
53
+
54
+ def run(callback: Callable[[Any], U | Future[U]], argument: Any) -> None:
55
+ try:
56
+ result = callback(argument)
57
+ except BaseException as error: # noqa: BLE001 - 原样交给下一环
58
+ fail(out, error)
59
+ return
60
+ if isinstance(result, Future):
61
+ result.add_done_callback(lambda inner: _copy(inner, out))
62
+ else:
63
+ settle(out, result)
64
+
65
+ def done(completed: Future[T]) -> None:
66
+ error = error_of(completed)
67
+ if error is None:
68
+ run(on_value, completed.result())
69
+ elif on_error is not None:
70
+ run(on_error, error)
71
+ else:
72
+ fail(out, error)
73
+
74
+ future.add_done_callback(done)
75
+ return out
76
+
77
+
78
+ def _copy(source: Future[Any], target: Future[Any]) -> None:
79
+ error = error_of(source)
80
+ if error is None:
81
+ settle(target, source.result())
82
+ else:
83
+ fail(target, error)
84
+
85
+
86
+ class Pending(Generic[T]):
87
+ """还要等服务端的结论。WSGI 里 .result() 阻塞等;ASGI 里直接 await"""
88
+
89
+ __slots__ = ("future",)
90
+
91
+ def __init__(self, future: Future[T]) -> None:
92
+ self.future = future
93
+
94
+ def result(self, timeout: float | None = None) -> T:
95
+ return self.future.result(timeout)
96
+
97
+ def __await__(self) -> Generator[Any, None, T]:
98
+ # shield:等的一方被取消(客户端走了)不能连带取消底下的 Future。引擎还会给它结论(比如领到的租约),
99
+ # 取消了结论就没处放,预留一直挂着;调用方自己决定被取消以后怎么收尾
100
+ return asyncio.shield(asyncio.wrap_future(self.future)).__await__()
@@ -0,0 +1,24 @@
1
+ """ISO 8601 时间和毫秒时间戳之间的换算,格式和 JavaScript 的 Date 一致(服务端和 Node SDK 用的就是它)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+
8
+ def parse_iso_ms(value: object) -> float | None:
9
+ """读不懂的返回 None。没带时区的按 UTC 算(服务端总是带 Z)"""
10
+ if not isinstance(value, str) or not value:
11
+ return None
12
+ text = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
13
+ try:
14
+ parsed = datetime.fromisoformat(text)
15
+ except ValueError:
16
+ return None
17
+ if parsed.tzinfo is None:
18
+ parsed = parsed.replace(tzinfo=timezone.utc)
19
+ return parsed.timestamp() * 1000
20
+
21
+
22
+ def iso_from_ms(ms: float) -> str:
23
+ """2026-10-20T12:00:10.000Z,和 new Date(ms).toISOString() 一样"""
24
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
@@ -0,0 +1,95 @@
1
+ """时钟。SDK 里所有「现在」都从这里取,测试注入假时钟,不用真的等(docs/decisions.md D4)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import heapq
6
+ import itertools
7
+ import os
8
+ import threading
9
+ import time
10
+ from typing import Callable, Protocol
11
+
12
+
13
+ class Clock(Protocol):
14
+ def now(self) -> float:
15
+ """墙上时间(毫秒):分钟归属、key 过期、周期边界。这些要和服务端、Stripe 的时间对得上"""
16
+ ...
17
+
18
+ def monotonic(self) -> float:
19
+ """单调时间(毫秒):租约本地有效期、令牌桶、退避、各种缓存时长。机器改时间、NTP 校时都不影响(PRD 5.5)"""
20
+ ...
21
+
22
+ def set_timer(self, callback: Callable[[], None], delay_ms: float) -> Callable[[], None]:
23
+ """定时器,返回取消函数。后台的同步、上报、续租都走它,测试里由假时钟推进"""
24
+ ...
25
+
26
+
27
+ class _Timer:
28
+ __slots__ = ("callback", "cancelled")
29
+
30
+ def __init__(self, callback: Callable[[], None]) -> None:
31
+ self.callback = callback
32
+ self.cancelled = False
33
+
34
+ def cancel(self) -> None:
35
+ self.cancelled = True
36
+
37
+
38
+ class SystemClock:
39
+ """所有定时器共用一个后台线程,按到期时间排队跑,不是一个定时器一个线程:等租约的请求每个都有一个定时器。
40
+ 线程是 daemon 的,不拖住进程退出(同 Node 的 unref)。回调要快,走网络的事交给 transport 的线程。"""
41
+
42
+ def __init__(self) -> None:
43
+ self._reset()
44
+ # gunicorn --preload 这类先建好应用再 fork 的:子进程里没有父进程的线程,锁也可能停在被持有的状态。
45
+ # 子进程重新来过;父进程排着的定时器属于父进程的引擎,引擎自己会在子进程里重新排(engine.py)
46
+ # Windows 没有 fork,也没有这个函数
47
+ if hasattr(os, "register_at_fork"):
48
+ os.register_at_fork(after_in_child=self._reset)
49
+
50
+ def _reset(self) -> None:
51
+ self._condition = threading.Condition()
52
+ self._queue: list[tuple[float, int, _Timer]] = []
53
+ self._order = itertools.count()
54
+ self._thread: threading.Thread | None = None
55
+
56
+ def now(self) -> float:
57
+ return time.time() * 1000
58
+
59
+ def monotonic(self) -> float:
60
+ return time.monotonic() * 1000
61
+
62
+ def set_timer(self, callback: Callable[[], None], delay_ms: float) -> Callable[[], None]:
63
+ timer = _Timer(callback)
64
+ due = time.monotonic() + max(0.0, delay_ms) / 1000
65
+ with self._condition:
66
+ heapq.heappush(self._queue, (due, next(self._order), timer))
67
+ if self._thread is None or not self._thread.is_alive():
68
+ self._thread = threading.Thread(target=self._run, name="usagerail-timers", daemon=True)
69
+ self._thread.start()
70
+ self._condition.notify()
71
+ return timer.cancel
72
+
73
+ def _run(self) -> None:
74
+ while True:
75
+ with self._condition:
76
+ while True:
77
+ while self._queue and self._queue[0][2].cancelled:
78
+ heapq.heappop(self._queue)
79
+ if not self._queue:
80
+ self._condition.wait()
81
+ continue
82
+ wait = self._queue[0][0] - time.monotonic()
83
+ if wait <= 0:
84
+ timer = heapq.heappop(self._queue)[2]
85
+ break
86
+ self._condition.wait(wait)
87
+ if timer.cancelled:
88
+ continue
89
+ try:
90
+ timer.callback()
91
+ except Exception: # noqa: BLE001 - 回调出错不能让定时器线程死掉,引擎自己会打警告
92
+ pass
93
+
94
+
95
+ system_clock = SystemClock()