py-app-runner 0.5.49.dev0__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.
- py_app_runner-0.5.49.dev0/.version +1 -0
- py_app_runner-0.5.49.dev0/LICENSE +21 -0
- py_app_runner-0.5.49.dev0/MANIFEST.in +1 -0
- py_app_runner-0.5.49.dev0/PKG-INFO +134 -0
- py_app_runner-0.5.49.dev0/README.md +87 -0
- py_app_runner-0.5.49.dev0/pyproject.toml +117 -0
- py_app_runner-0.5.49.dev0/setup.cfg +4 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/__init__.py +11 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/__init__.py +29 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/_service.py +91 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/_service_args.py +44 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/audit.py +319 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/commands.py +151 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/diff.py +202 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/errors.py +8 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/event.py +130 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/audit/store.py +134 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/__init__.py +0 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/_service.py +265 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/_service_args.py +24 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/api.py +138 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/encoders/__init__.py +5 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/encoders/base.py +24 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/encoders/json_encoder.py +26 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/encoders/msgpack_encoder.py +58 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/web_app.py +31 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/bridge/websocket.py +313 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/colors.py +73 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/config.py +132 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/__init__.py +14 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/_service.py +75 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/_service_args.py +54 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/commands.py +164 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/envelope.py +144 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/errors.py +8 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/fields.py +300 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/crypto/passwords.py +66 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/db_pools.py +20 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/http_exception.py +31 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/logger_handlers.py +167 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/__init__.py +5 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/_service.py +296 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/_service_args.py +91 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/commands.py +386 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/discovery.py +108 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/states.py +63 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/migrations/tracker.py +141 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/py.typed +0 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/pybridge.py +64 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/__init__.py +25 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/_service.py +231 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/_service_args.py +67 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/commands.py +180 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/driver_pg.py +464 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/driver_redis.py +613 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/handler.py +90 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/interface.py +63 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/job.py +46 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/queue/worker.py +221 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/registry.py +54 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/request_handler/__init__.py +0 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/request_handler/auth_service.py +123 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/request_handler/decorators.py +304 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/request_handler/handlers.py +604 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/request_handler/pagination.py +24 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/return_model.py +78 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/runner.py +182 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/throttle/__init__.py +5 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/throttle/throttle.py +217 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/tick_service.py +308 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/timer.py +289 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/utils.py +346 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/wbcm/__init__.py +0 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/wbcm/device_connections.py +89 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/wbcm/factory.py +113 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/wbcm/wb_connection_manager.py +333 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner/wbcm/ws_interface.py +56 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner.egg-info/PKG-INFO +134 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner.egg-info/SOURCES.txt +105 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner.egg-info/dependency_links.txt +1 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner.egg-info/requires.txt +19 -0
- py_app_runner-0.5.49.dev0/src/py_app_runner.egg-info/top_level.txt +1 -0
- py_app_runner-0.5.49.dev0/tests/test_api_keys.py +27 -0
- py_app_runner-0.5.49.dev0/tests/test_audit_diff.py +127 -0
- py_app_runner-0.5.49.dev0/tests/test_audit_store.py +362 -0
- py_app_runner-0.5.49.dev0/tests/test_config.py +63 -0
- py_app_runner-0.5.49.dev0/tests/test_crypto_envelope.py +128 -0
- py_app_runner-0.5.49.dev0/tests/test_crypto_fields.py +222 -0
- py_app_runner-0.5.49.dev0/tests/test_crypto_passwords.py +61 -0
- py_app_runner-0.5.49.dev0/tests/test_decorators.py +200 -0
- py_app_runner-0.5.49.dev0/tests/test_error_envelope.py +132 -0
- py_app_runner-0.5.49.dev0/tests/test_example.py +5 -0
- py_app_runner-0.5.49.dev0/tests/test_logger_handlers.py +36 -0
- py_app_runner-0.5.49.dev0/tests/test_migrations_commands.py +919 -0
- py_app_runner-0.5.49.dev0/tests/test_migrations_discovery.py +166 -0
- py_app_runner-0.5.49.dev0/tests/test_migrations_pg_harness.py +35 -0
- py_app_runner-0.5.49.dev0/tests/test_migrations_service.py +801 -0
- py_app_runner-0.5.49.dev0/tests/test_migrations_states.py +87 -0
- py_app_runner-0.5.49.dev0/tests/test_migrations_tracker.py +188 -0
- py_app_runner-0.5.49.dev0/tests/test_pybridge.py +76 -0
- py_app_runner-0.5.49.dev0/tests/test_queue_contract.py +299 -0
- py_app_runner-0.5.49.dev0/tests/test_queue_driver.py +318 -0
- py_app_runner-0.5.49.dev0/tests/test_queue_driver_redis.py +250 -0
- py_app_runner-0.5.49.dev0/tests/test_queue_worker.py +271 -0
- py_app_runner-0.5.49.dev0/tests/test_throttle.py +217 -0
- py_app_runner-0.5.49.dev0/tests/test_utils.py +31 -0
- py_app_runner-0.5.49.dev0/tests/test_wbcm.py +312 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.5.49.dev0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gints Murāns
|
|
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 @@
|
|
|
1
|
+
include .version
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py_app_runner
|
|
3
|
+
Version: 0.5.49.dev0
|
|
4
|
+
Summary: Async service framework: Tornado HTTP/WS bridge, service loader, Redis WS connection manager
|
|
5
|
+
Author-email: Gints Murans <gm@gm.lv>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/4Apps/py_app_runner
|
|
8
|
+
Project-URL: Documentation, https://github.com/4Apps/py_app_runner/blob/master/AGENTS.md
|
|
9
|
+
Project-URL: Code, https://github.com/4Apps/py_app_runner
|
|
10
|
+
Project-URL: Issue Tracker, https://github.com/4Apps/py_app_runner/issues
|
|
11
|
+
Project-URL: Download, https://pypi.org/project/py-app-runner/
|
|
12
|
+
Keywords: async,tornado,websocket,framework,service,redis,postgresql,migrations,queue
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.11
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Requires-Dist: sentry-sdk
|
|
29
|
+
Requires-Dist: python-dotenv
|
|
30
|
+
Requires-Dist: uvloop
|
|
31
|
+
Requires-Dist: msgspec
|
|
32
|
+
Requires-Dist: msgpack
|
|
33
|
+
Requires-Dist: tornado
|
|
34
|
+
Requires-Dist: database_wrapper[pgsql,redis]==0.3.146
|
|
35
|
+
Requires-Dist: psycopg>=3.2.0
|
|
36
|
+
Requires-Dist: psycopg[pool]>=3.2.0
|
|
37
|
+
Requires-Dist: redis
|
|
38
|
+
Requires-Dist: pyjwt
|
|
39
|
+
Requires-Dist: cryptography>=44
|
|
40
|
+
Requires-Dist: bcrypt
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pyrefly; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff; extra == "dev"
|
|
44
|
+
Requires-Dist: pytest; extra == "dev"
|
|
45
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# py_app_runner
|
|
49
|
+
|
|
50
|
+
Async Python service framework: Tornado HTTP/WS bridge, PyBridge service loader, Redis WebSocket connection manager.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install py_app_runner
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Every push to `develop` publishes a pre-release, `X.Y.<commit count>.dev0`. pip hides those
|
|
59
|
+
unless you ask for them:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install --pre py_app_runner # newest, including dev builds
|
|
63
|
+
pip install py_app_runner==0.4.50.dev0 # a specific dev build
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
docker-compose up develop
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Migrations
|
|
73
|
+
|
|
74
|
+
Built-in service that applies tracked SQL files to one or more configured databases. Add
|
|
75
|
+
`migrations` to `SERVICES` to enable it.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 src/app.py migrations status [--check] [--target NAME]
|
|
79
|
+
python3 src/app.py migrations apply [--dry-run] [--to PREFIX] [--target NAME]
|
|
80
|
+
python3 src/app.py migrations baseline [--to PREFIX] [--yes] [--target NAME]
|
|
81
|
+
python3 src/app.py migrations new <name> [--target NAME]
|
|
82
|
+
python3 src/app.py migrations repair <filename> [--target NAME]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
By default there is a single target, `main`, against `config["db"]["main"]`: files live in
|
|
86
|
+
`config["migrations"]["dir"]` (default `data/migrations`) and are tracked in
|
|
87
|
+
`config["migrations"]["table"]` (default `migrations`). **No project needs to change
|
|
88
|
+
anything to keep this working** - an absent `migrations` key, an empty one, and this flat
|
|
89
|
+
shape all resolve to that same single target.
|
|
90
|
+
|
|
91
|
+
To migrate more than one database, opt in with `config["migrations"]["targets"]`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
"migrations": {
|
|
95
|
+
"targets": {
|
|
96
|
+
"main": {"db": "main", "dir": "data/migrations", "table": "migrations"},
|
|
97
|
+
"gis": {"db": "gis", "dir": "data/migrations_gis", "table": "gis_migrations"},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Each target's `db` names a key under `config["db"]` and defaults to the target's own name;
|
|
103
|
+
`dir` and `table` default as above. `status` and `apply` with no `--target` run every
|
|
104
|
+
target in declared order (`apply` stops at the first one that fails; `status --check`
|
|
105
|
+
reports each target rather than stopping at the first with pending work, and exits 1 if any
|
|
106
|
+
is pending or blocked). `new`, `repair` and `baseline` require `--target` once more than one
|
|
107
|
+
target is configured, and an unknown `--target` exits 1 - both name the configured targets.
|
|
108
|
+
Output gets a `[name] ` prefix only when more than one target is processed, so single-target
|
|
109
|
+
output is unchanged.
|
|
110
|
+
|
|
111
|
+
Targets may share a database, but not a database *and* a tracking table - each would then
|
|
112
|
+
report the other's migrations as missing, so that config is refused up front, naming both
|
|
113
|
+
targets. `table` defaults to `migrations` for every target, so two targets on one database
|
|
114
|
+
need an explicit `table` on at least one of them.
|
|
115
|
+
|
|
116
|
+
- Migrations no longer need to be idempotent - each file runs in its own transaction with
|
|
117
|
+
its tracking row written inside it, so what already ran is always known.
|
|
118
|
+
- `-- migrations:no-transaction` on line 1 runs a file outside a transaction (for
|
|
119
|
+
`CREATE INDEX CONCURRENTLY` and friends). Such a file must contain **exactly one
|
|
120
|
+
statement**: Postgres wraps a multi-statement send in an implicit transaction, which would
|
|
121
|
+
defeat the directive, so `apply` refuses it up front. A no-transaction file that fails
|
|
122
|
+
cannot roll back and is not recorded - `apply` says so and tells you to inspect the
|
|
123
|
+
database before re-running.
|
|
124
|
+
- Editing an applied file is detected as drift, and a tracked file that has since been
|
|
125
|
+
deleted shows as missing; both block `apply` until resolved. `repair` fixes drift only;
|
|
126
|
+
a missing file is fixed by restoring it, or by deleting its tracking row by hand
|
|
127
|
+
(`apply` prints the exact `DELETE` when it blocks).
|
|
128
|
+
- Files must not contain psql meta-commands (`\restrict` / `\unrestrict`, as emitted by
|
|
129
|
+
`pg_dump`) - psycopg cannot execute them, and `apply` refuses such a file up front.
|
|
130
|
+
- `baseline` adopts an existing database into the system: it writes tracking rows without
|
|
131
|
+
executing anything.
|
|
132
|
+
- Every subcommand exits non-zero on failure, including a misconfigured `targets` block.
|
|
133
|
+
`status --check` exits 1 if anything is pending, drifted or missing, so a deploy script
|
|
134
|
+
can halt before restarting services against a half-migrated database.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# py_app_runner
|
|
2
|
+
|
|
3
|
+
Async Python service framework: Tornado HTTP/WS bridge, PyBridge service loader, Redis WebSocket connection manager.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install py_app_runner
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Every push to `develop` publishes a pre-release, `X.Y.<commit count>.dev0`. pip hides those
|
|
12
|
+
unless you ask for them:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install --pre py_app_runner # newest, including dev builds
|
|
16
|
+
pip install py_app_runner==0.4.50.dev0 # a specific dev build
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Development
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
docker-compose up develop
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Migrations
|
|
26
|
+
|
|
27
|
+
Built-in service that applies tracked SQL files to one or more configured databases. Add
|
|
28
|
+
`migrations` to `SERVICES` to enable it.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python3 src/app.py migrations status [--check] [--target NAME]
|
|
32
|
+
python3 src/app.py migrations apply [--dry-run] [--to PREFIX] [--target NAME]
|
|
33
|
+
python3 src/app.py migrations baseline [--to PREFIX] [--yes] [--target NAME]
|
|
34
|
+
python3 src/app.py migrations new <name> [--target NAME]
|
|
35
|
+
python3 src/app.py migrations repair <filename> [--target NAME]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
By default there is a single target, `main`, against `config["db"]["main"]`: files live in
|
|
39
|
+
`config["migrations"]["dir"]` (default `data/migrations`) and are tracked in
|
|
40
|
+
`config["migrations"]["table"]` (default `migrations`). **No project needs to change
|
|
41
|
+
anything to keep this working** - an absent `migrations` key, an empty one, and this flat
|
|
42
|
+
shape all resolve to that same single target.
|
|
43
|
+
|
|
44
|
+
To migrate more than one database, opt in with `config["migrations"]["targets"]`:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
"migrations": {
|
|
48
|
+
"targets": {
|
|
49
|
+
"main": {"db": "main", "dir": "data/migrations", "table": "migrations"},
|
|
50
|
+
"gis": {"db": "gis", "dir": "data/migrations_gis", "table": "gis_migrations"},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each target's `db` names a key under `config["db"]` and defaults to the target's own name;
|
|
56
|
+
`dir` and `table` default as above. `status` and `apply` with no `--target` run every
|
|
57
|
+
target in declared order (`apply` stops at the first one that fails; `status --check`
|
|
58
|
+
reports each target rather than stopping at the first with pending work, and exits 1 if any
|
|
59
|
+
is pending or blocked). `new`, `repair` and `baseline` require `--target` once more than one
|
|
60
|
+
target is configured, and an unknown `--target` exits 1 - both name the configured targets.
|
|
61
|
+
Output gets a `[name] ` prefix only when more than one target is processed, so single-target
|
|
62
|
+
output is unchanged.
|
|
63
|
+
|
|
64
|
+
Targets may share a database, but not a database *and* a tracking table - each would then
|
|
65
|
+
report the other's migrations as missing, so that config is refused up front, naming both
|
|
66
|
+
targets. `table` defaults to `migrations` for every target, so two targets on one database
|
|
67
|
+
need an explicit `table` on at least one of them.
|
|
68
|
+
|
|
69
|
+
- Migrations no longer need to be idempotent - each file runs in its own transaction with
|
|
70
|
+
its tracking row written inside it, so what already ran is always known.
|
|
71
|
+
- `-- migrations:no-transaction` on line 1 runs a file outside a transaction (for
|
|
72
|
+
`CREATE INDEX CONCURRENTLY` and friends). Such a file must contain **exactly one
|
|
73
|
+
statement**: Postgres wraps a multi-statement send in an implicit transaction, which would
|
|
74
|
+
defeat the directive, so `apply` refuses it up front. A no-transaction file that fails
|
|
75
|
+
cannot roll back and is not recorded - `apply` says so and tells you to inspect the
|
|
76
|
+
database before re-running.
|
|
77
|
+
- Editing an applied file is detected as drift, and a tracked file that has since been
|
|
78
|
+
deleted shows as missing; both block `apply` until resolved. `repair` fixes drift only;
|
|
79
|
+
a missing file is fixed by restoring it, or by deleting its tracking row by hand
|
|
80
|
+
(`apply` prints the exact `DELETE` when it blocks).
|
|
81
|
+
- Files must not contain psql meta-commands (`\restrict` / `\unrestrict`, as emitted by
|
|
82
|
+
`pg_dump`) - psycopg cannot execute them, and `apply` refuses such a file up front.
|
|
83
|
+
- `baseline` adopts an existing database into the system: it writes tracking rows without
|
|
84
|
+
executing anything.
|
|
85
|
+
- Every subcommand exits non-zero on failure, including a misconfigured `targets` block.
|
|
86
|
+
`status --check` exits 1 if anything is pending, drifted or missing, so a deploy script
|
|
87
|
+
can halt before restarting services against a half-migrated database.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# PEP 639 license expressions (the "license"/"license-files" keys below) need 77+
|
|
3
|
+
requires = ["setuptools >= 77.0.0"]
|
|
4
|
+
build-backend = "setuptools.build_meta"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "py_app_runner"
|
|
8
|
+
dynamic = ["version"]
|
|
9
|
+
description = "Async service framework: Tornado HTTP/WS bridge, service loader, Redis WS connection manager"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
authors = [{ name = "Gints Murans", email = "gm@gm.lv" }]
|
|
15
|
+
keywords = [
|
|
16
|
+
"async",
|
|
17
|
+
"tornado",
|
|
18
|
+
"websocket",
|
|
19
|
+
"framework",
|
|
20
|
+
"service",
|
|
21
|
+
"redis",
|
|
22
|
+
"postgresql",
|
|
23
|
+
"migrations",
|
|
24
|
+
"queue",
|
|
25
|
+
]
|
|
26
|
+
# No "License ::" classifier: it is deprecated alongside Metadata 2.4, and setuptools
|
|
27
|
+
# refuses one next to the license expression above
|
|
28
|
+
classifiers = [
|
|
29
|
+
"Development Status :: 4 - Beta",
|
|
30
|
+
"Framework :: AsyncIO",
|
|
31
|
+
"Intended Audience :: Developers",
|
|
32
|
+
# uvloop is a hard dependency and does not build on Windows
|
|
33
|
+
"Operating System :: POSIX",
|
|
34
|
+
"Programming Language :: Python :: 3",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Programming Language :: Python :: Implementation :: CPython",
|
|
39
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
40
|
+
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
41
|
+
"Typing :: Typed",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
dependencies = [
|
|
45
|
+
"sentry-sdk",
|
|
46
|
+
"python-dotenv",
|
|
47
|
+
"uvloop",
|
|
48
|
+
"msgspec",
|
|
49
|
+
"msgpack",
|
|
50
|
+
"tornado",
|
|
51
|
+
# The extras pull the base package in, so one pin rather than three that must be kept
|
|
52
|
+
# in lockstep. 0.3.146 is the first MIT release - 0.2.38 and earlier are GPL-3.0.
|
|
53
|
+
"database_wrapper[pgsql,redis] == 0.3.146",
|
|
54
|
+
# Used directly by py_app_runner.migrations, not only through database_wrapper, so the
|
|
55
|
+
# floor is declared here rather than inherited. Matches database_wrapper_pgsql 0.3.146's
|
|
56
|
+
# own psycopg[binary]/psycopg[pool] >= 3.2.0.
|
|
57
|
+
"psycopg >= 3.2.0",
|
|
58
|
+
"psycopg[pool] >= 3.2.0",
|
|
59
|
+
"redis",
|
|
60
|
+
"pyjwt",
|
|
61
|
+
# Used directly by py_app_runner.crypto for AES-256-GCM. The floor is 44 because that
|
|
62
|
+
# is where XChaCha20Poly1305 appears, which is the obvious alternative if AES-GCM's
|
|
63
|
+
# 96-bit nonce ever becomes uncomfortable for a high-volume column.
|
|
64
|
+
"cryptography >= 44",
|
|
65
|
+
"bcrypt",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[project.optional-dependencies]
|
|
69
|
+
dev = [
|
|
70
|
+
"pyrefly",
|
|
71
|
+
"ruff",
|
|
72
|
+
"pytest",
|
|
73
|
+
"pytest-asyncio",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
[project.urls]
|
|
77
|
+
Homepage = "https://github.com/4Apps/py_app_runner"
|
|
78
|
+
Documentation = "https://github.com/4Apps/py_app_runner/blob/master/AGENTS.md"
|
|
79
|
+
Code = "https://github.com/4Apps/py_app_runner"
|
|
80
|
+
"Issue Tracker" = "https://github.com/4Apps/py_app_runner/issues"
|
|
81
|
+
Download = "https://pypi.org/project/py-app-runner/"
|
|
82
|
+
|
|
83
|
+
[tool.setuptools.packages.find]
|
|
84
|
+
where = ["src"]
|
|
85
|
+
|
|
86
|
+
# Without the marker file in the wheel, a downstream type checker ignores every
|
|
87
|
+
# annotation in this package (PEP 561)
|
|
88
|
+
[tool.setuptools.package-data]
|
|
89
|
+
"py_app_runner" = ["py.typed"]
|
|
90
|
+
|
|
91
|
+
# Manual major.minor in .version; CI appends the git commit count as the patch part.
|
|
92
|
+
# A local build therefore reports a bare "0.4" - scripts/version.bash prints what a
|
|
93
|
+
# release of this commit would actually publish as.
|
|
94
|
+
[tool.setuptools.dynamic]
|
|
95
|
+
version = { file = ".version" }
|
|
96
|
+
|
|
97
|
+
[tool.pytest.ini_options]
|
|
98
|
+
asyncio_mode = "auto"
|
|
99
|
+
testpaths = ["tests"]
|
|
100
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
101
|
+
|
|
102
|
+
[tool.ruff]
|
|
103
|
+
line-length = 120
|
|
104
|
+
target-version = "py311"
|
|
105
|
+
src = ["src"]
|
|
106
|
+
|
|
107
|
+
[tool.ruff.lint]
|
|
108
|
+
select = ["E", "F", "I", "B", "UP"]
|
|
109
|
+
fixable = ["ALL"]
|
|
110
|
+
|
|
111
|
+
[tool.ruff.format]
|
|
112
|
+
quote-style = "double"
|
|
113
|
+
indent-style = "space"
|
|
114
|
+
|
|
115
|
+
# For some reason this needs to be here to show errors in VSCode
|
|
116
|
+
[tool.pyrefly]
|
|
117
|
+
site-package-path = ["/srv/venv/lib64/python3.13/site-packages"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
2
|
+
|
|
3
|
+
from py_app_runner.registry import AppRegistry
|
|
4
|
+
|
|
5
|
+
__all__ = ["AppRegistry"]
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("py_app_runner")
|
|
9
|
+
except PackageNotFoundError:
|
|
10
|
+
# Running straight from a source tree that was never installed
|
|
11
|
+
__version__ = "0.0.dev0"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""An append-only trail of who changed what, written explicitly at the call site."""
|
|
2
|
+
|
|
3
|
+
from py_app_runner.audit.audit import Audit
|
|
4
|
+
from py_app_runner.audit.errors import AuditError
|
|
5
|
+
from py_app_runner.audit.event import (
|
|
6
|
+
CREATED,
|
|
7
|
+
DELETED,
|
|
8
|
+
UPDATED,
|
|
9
|
+
Actor,
|
|
10
|
+
AuditEvent,
|
|
11
|
+
RequestContext,
|
|
12
|
+
current_context,
|
|
13
|
+
new_request_id,
|
|
14
|
+
request_context,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"CREATED",
|
|
19
|
+
"DELETED",
|
|
20
|
+
"UPDATED",
|
|
21
|
+
"Actor",
|
|
22
|
+
"Audit",
|
|
23
|
+
"AuditError",
|
|
24
|
+
"AuditEvent",
|
|
25
|
+
"RequestContext",
|
|
26
|
+
"current_context",
|
|
27
|
+
"new_request_id",
|
|
28
|
+
"request_context",
|
|
29
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import pathlib
|
|
4
|
+
from argparse import Namespace
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
|
|
7
|
+
import psycopg
|
|
8
|
+
|
|
9
|
+
from py_app_runner.audit.commands import Out, cmd_install, cmd_prune
|
|
10
|
+
from py_app_runner.audit.errors import AuditError
|
|
11
|
+
from py_app_runner.migrations._service import connect_kwargs, resolve_targets
|
|
12
|
+
from py_app_runner.pybridge import PyBridge
|
|
13
|
+
from py_app_runner.registry import AppRegistry
|
|
14
|
+
|
|
15
|
+
_DEFAULT_DB = "main"
|
|
16
|
+
_DEFAULT_TABLE = "audit_log"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _migrations_dir(args: Namespace, config: dict) -> pathlib.Path:
|
|
20
|
+
"""Where `install` writes.
|
|
21
|
+
|
|
22
|
+
The file it produces is a migration and belongs wherever the rest of them are, so the
|
|
23
|
+
default comes from the migrations config rather than from an audit key of its own.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
raw = getattr(args, "dir", None)
|
|
27
|
+
if raw:
|
|
28
|
+
directory = pathlib.Path(raw)
|
|
29
|
+
if directory.is_absolute():
|
|
30
|
+
return directory
|
|
31
|
+
|
|
32
|
+
return pathlib.Path(config.get("current_path") or ".") / directory
|
|
33
|
+
|
|
34
|
+
targets = resolve_targets(config)
|
|
35
|
+
return next(iter(targets.values())).directory
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def init_service(args: Namespace, _pybridge: PyBridge, logger: logging.Logger) -> None:
|
|
39
|
+
out: Out = print
|
|
40
|
+
|
|
41
|
+
# Same reasoning as migrations: runner.py catches Exception around init_service and
|
|
42
|
+
# returns normally, which exits 0. A prune that could not reach the database must not
|
|
43
|
+
# report success to a retention job.
|
|
44
|
+
code = 1
|
|
45
|
+
try:
|
|
46
|
+
config = AppRegistry.config()
|
|
47
|
+
settings = config.get("audit") or {}
|
|
48
|
+
table = getattr(args, "table", None) or settings.get("table") or _DEFAULT_TABLE
|
|
49
|
+
|
|
50
|
+
if args.step == "install":
|
|
51
|
+
raise SystemExit(cmd_install(_migrations_dir(args, config), table, datetime.now(UTC), out))
|
|
52
|
+
|
|
53
|
+
db_name = getattr(args, "db", None) or settings.get("db") or _DEFAULT_DB
|
|
54
|
+
db_config = config.get("db") or {}
|
|
55
|
+
if db_name not in db_config:
|
|
56
|
+
out(
|
|
57
|
+
f'error: no database {db_name!r} in config["db"]; '
|
|
58
|
+
f"configured are: {', '.join(sorted(db_config)) or 'none'}."
|
|
59
|
+
)
|
|
60
|
+
raise SystemExit(2)
|
|
61
|
+
|
|
62
|
+
if args.step == "prune":
|
|
63
|
+
async with await psycopg.AsyncConnection.connect(**connect_kwargs(db_config[db_name])) as conn:
|
|
64
|
+
code = await cmd_prune(
|
|
65
|
+
conn,
|
|
66
|
+
table,
|
|
67
|
+
args.before,
|
|
68
|
+
args.batch,
|
|
69
|
+
getattr(args, "dry_run", False),
|
|
70
|
+
out,
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
out(f"error: unknown audit command {args.step!r}")
|
|
74
|
+
code = 1
|
|
75
|
+
|
|
76
|
+
except AuditError as e:
|
|
77
|
+
# Configuration and refusal messages already say what to do; a stack trace above
|
|
78
|
+
# them would bury it.
|
|
79
|
+
out(f"error: {e}")
|
|
80
|
+
raise SystemExit(1) from None
|
|
81
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
82
|
+
# Above `except Exception` because both derive from BaseException. An interrupted
|
|
83
|
+
# prune has deleted some batches and not others - safe to resume, but it must not
|
|
84
|
+
# be reported as complete.
|
|
85
|
+
logger.error("audit: interrupted; the prune is partially done")
|
|
86
|
+
raise SystemExit(1) from None
|
|
87
|
+
except Exception:
|
|
88
|
+
logger.exception("audit: unhandled failure")
|
|
89
|
+
raise SystemExit(1) from None
|
|
90
|
+
|
|
91
|
+
raise SystemExit(code)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""CLI subparsers for the built-in audit service.
|
|
2
|
+
|
|
3
|
+
python3 src/app.py audit install [--dir PATH] [--table NAME]
|
|
4
|
+
python3 src/app.py audit prune --before YYYY-MM-DD [--batch N] [--dry-run] [--db NAME]
|
|
5
|
+
|
|
6
|
+
`--dry-run` shadows a real top-level flag on runner.py's parser and needs
|
|
7
|
+
`default=SUPPRESS`, the same collision migrations' `apply` has.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from argparse import SUPPRESS, ArgumentParser, _SubParsersAction # type: ignore
|
|
12
|
+
|
|
13
|
+
from py_app_runner.pybridge import PyBridge
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def reg_subparsers(
|
|
17
|
+
subparsers: "_SubParsersAction[ArgumentParser]",
|
|
18
|
+
_pybridge: PyBridge,
|
|
19
|
+
_base_logger: logging.Logger,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Command line subparsers"""
|
|
22
|
+
|
|
23
|
+
parser = subparsers.add_parser(
|
|
24
|
+
"audit",
|
|
25
|
+
description="Install the audit trail schema and prune old rows",
|
|
26
|
+
help="Audit trail",
|
|
27
|
+
)
|
|
28
|
+
group = parser.add_subparsers(title="command", dest="step", required=True)
|
|
29
|
+
|
|
30
|
+
install_parser = group.add_parser("install", help="Write the audit schema into the migrations directory")
|
|
31
|
+
install_parser.add_argument("--dir", default=None, help="Migrations directory to write into")
|
|
32
|
+
install_parser.add_argument("--table", default=None, help="Audit table (default: from config)")
|
|
33
|
+
|
|
34
|
+
prune_parser = group.add_parser("prune", help="Delete trail rows older than a date")
|
|
35
|
+
prune_parser.add_argument("--before", required=True, help="Delete rows older than this, YYYY-MM-DD")
|
|
36
|
+
prune_parser.add_argument("--batch", type=int, default=10000, help="Rows per statement (default: 10000)")
|
|
37
|
+
prune_parser.add_argument("--table", default=None, help="Audit table (default: from config)")
|
|
38
|
+
prune_parser.add_argument("--db", default=None, help='Entry of config["db"] to prune in (default: main)')
|
|
39
|
+
prune_parser.add_argument(
|
|
40
|
+
"--dry-run",
|
|
41
|
+
action="store_true",
|
|
42
|
+
default=SUPPRESS,
|
|
43
|
+
help="Count what would go, delete nothing",
|
|
44
|
+
)
|