aeth-ext 2.0.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,491 @@
1
+ Metadata-Version: 2.3
2
+ Name: aeth-ext
3
+ Version: 2.0.0
4
+ Summary: Add your description here
5
+ Author: Jacob Ogden
6
+ Author-email: Jacob Ogden <jacob.ogden@yahoo.com>
7
+ Requires-Dist: aiologic>=0.16.0
8
+ Requires-Dist: pydantic-settings>=2.14.1
9
+ Requires-Dist: python-dateutil>=2.9.0.post0
10
+ Requires-Dist: rich>=15.0.0
11
+ Requires-Dist: tzdata>=2026.2
12
+ Requires-Dist: uvloop>=0.22.1 ; sys_platform == 'linux' and extra == 'async'
13
+ Requires-Dist: winloop>=0.6.0 ; sys_platform == 'win32' and extra == 'async'
14
+ Requires-Dist: paramiko>=5.0.0 ; extra == 'sftp'
15
+ Requires-Python: >=3.14
16
+ Provides-Extra: async
17
+ Provides-Extra: sftp
18
+ Description-Content-Type: text/markdown
19
+
20
+ # aeth-ext
21
+
22
+ > This markdown file and _search_for_subclasses.py was AI generated by Claude. All other code was written by me.
23
+ > Sweet Fire Tobacco shared library — a batteries-included foundation for building
24
+ > Python services, batch jobs, and CLI tools.
25
+
26
+ `aeth-ext` bundles the cross-cutting infrastructure that the Sweet Fire Tobacco
27
+ projects rely on: a one-call application bootstrap, an opinionated logging stack
28
+ built on [Rich](https://github.com/Textualize/rich), pydantic-based settings,
29
+ FTP/SFTP transfer adapters, a static (import-free) subclass discovery engine, a
30
+ monkey-patching framework, alert emails, and assorted utilities.
31
+
32
+ - **Python:** `>=3.14`
33
+ - **Package name (PyPI):** `aeth-ext`
34
+ - **Import name:** `aeth_ext`
35
+ - **Author:** Jacob Ogden
36
+
37
+ ---
38
+
39
+ ## Table of contents
40
+
41
+ - [aeth-ext](#aeth-ext)
42
+ - [Table of contents](#table-of-contents)
43
+ - [Installation](#installation)
44
+ - [Quick start](#quick-start)
45
+ - [Architecture overview](#architecture-overview)
46
+ - [Modules](#modules)
47
+ - [`aeth_ext` — application bootstrap](#aeth_ext--application-bootstrap)
48
+ - [`settings` — configuration](#settings--configuration)
49
+ - [Helpers](#helpers)
50
+ - [`logging` — logging stack](#logging--logging-stack)
51
+ - [Public API](#public-api)
52
+ - [`errors` — fatal-exception handling \& alerts](#errors--fatal-exception-handling--alerts)
53
+ - [`ftp` — FTP / SFTP adapters](#ftp--ftp--sftp-adapters)
54
+ - [FTP API](#ftp-api)
55
+ - [`monkey_patcher` — patch framework](#monkey_patcher--patch-framework)
56
+ - [`_search_for_subclasses` — static class discovery](#_search_for_subclasses--static-class-discovery)
57
+ - [`const_parsing` — constant extraction](#const_parsing--constant-extraction)
58
+ - [`utils` — email \& datetime helpers](#utils--email--datetime-helpers)
59
+ - [`types` — shared types \& mixins](#types--shared-types--mixins)
60
+ - [`rich` — enhanced progress bars](#rich--enhanced-progress-bars)
61
+ - [Configuration reference](#configuration-reference)
62
+ - [Development](#development)
63
+ - [Releasing](#releasing)
64
+
65
+ ---
66
+
67
+ ## Installation
68
+
69
+ The library is published to the internal SFTPyPI index.
70
+
71
+ ```bash
72
+ # base install
73
+ uv add aeth-ext
74
+
75
+ # with the high-performance async event loop (uvloop on Linux, winloop on Windows)
76
+ uv add "aeth-ext[async]"
77
+
78
+ # with SFTP support (paramiko)
79
+ uv add "aeth-ext[sftp]"
80
+
81
+ # everything
82
+ uv add "aeth-ext[async,sftp]"
83
+ ```
84
+
85
+ | Extra | Pulls in | Use when |
86
+ | ------- | -------------------------------------- | ------------------------------------ |
87
+ | `async` | `uvloop` (Linux) / `winloop` (Windows) | You call `initialize(asyncio=True)` |
88
+ | `sftp` | `paramiko` | You use `AdaptedSFTP` |
89
+
90
+ Core runtime dependencies: `aiologic`, `pydantic-settings`, `python-dateutil`,
91
+ `rich`, `tzdata`.
92
+
93
+ ---
94
+
95
+ ## Quick start
96
+
97
+ ```python
98
+ from aeth_ext import initialize
99
+
100
+ # Bootstraps logging, applies registered monkey patches, and (optionally)
101
+ # installs a high-performance asyncio event loop.
102
+ initialize(asyncio=True)
103
+ ```
104
+
105
+ A more complete service entry point:
106
+
107
+ ```python
108
+ # main.py
109
+ from aeth_ext import initialize
110
+ from aeth_ext.settings import BaseSettings
111
+
112
+
113
+ # 1. Define your settings by subclassing BaseSettings.
114
+ class Settings(BaseSettings):
115
+ api_token: str # read from env var API_TOKEN (or .env in debug)
116
+
117
+
118
+ def main() -> None:
119
+ initialize(asyncio=False) # logging + monkey patches
120
+ settings = Settings.get_settings() # resolved singleton
121
+ ...
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Architecture overview
131
+
132
+ A central theme of the library is **static, import-free discovery**: rather than
133
+ forcing you to register components in a central place, `aeth_ext` scans your
134
+ source tree, finds the most-derived subclass of a given base, and wires it up
135
+ automatically. This powers settings (`BaseSettings`), logging
136
+ (`BaseLoggingConfig`), and patches (`MonkeyPatcher`).
137
+
138
+ ```mermaid
139
+ flowchart TD
140
+ A["initialize()"] --> B["init_logging() / init_logging_worker()"]
141
+ A --> C["MonkeyPatcher.apply_monkey_patches()"]
142
+ A --> D["install uvloop / winloop (asyncio=True)"]
143
+ B --> E["discover deepest BaseLoggingConfig subclass"]
144
+ C --> F["discover MonkeyPatcher subclasses"]
145
+ E --> G["_search_for_subclasses"]
146
+ F --> G
147
+ H["BaseSettings.get_settings()"] --> I["CapturesSubclasses mixin"]
148
+ ```
149
+
150
+ ---
151
+
152
+ ## Modules
153
+
154
+ ### `aeth_ext` — application bootstrap
155
+
156
+ The package root exposes a single orchestration function.
157
+
158
+ ```python
159
+ def initialize(
160
+ *queues: QueueCatchall,
161
+ asyncio: bool = False,
162
+ worker: bool = False,
163
+ run_monkey_patches: bool = True,
164
+ return_wrapped: bool = False,
165
+ ) -> None | Callable[[], None]: ...
166
+ ```
167
+
168
+ | Parameter | Default | Description |
169
+ | -------------------- | ------- | --------------------------------------------------------------------------------------------- |
170
+ | `*queues` | none | Logging queues (`QueueCatchall`) to attach for multi-process / multi-thread log fan-in. |
171
+ | `asyncio` | `False` | Install `uvloop` (POSIX) or `winloop` (Windows) as the active event loop. Requires `[async]`. |
172
+ | `worker` | `False` | Use worker-process logging config (`init_logging_worker`) instead of main-process config. |
173
+ | `run_monkey_patches` | `True` | Discover and apply every `MonkeyPatcher` subclass before the app starts. |
174
+ | `return_wrapped` | `False` | Return the initializer as a callable instead of running it immediately (useful for deferral). |
175
+
176
+ ```python
177
+ # Run immediately
178
+ initialize()
179
+
180
+ # Defer execution (e.g. to pass into a process pool initializer)
181
+ init = initialize(asyncio=True, return_wrapped=True)
182
+ init()
183
+ ```
184
+
185
+ ---
186
+
187
+ ### `settings` — configuration
188
+
189
+ `BaseSettings` extends `pydantic_settings.BaseSettings` and the
190
+ `CapturesSubclasses` mixin, so the *most-derived* subclass is resolved
191
+ automatically. In debug builds it reads a `.env` file; in release builds it
192
+ relies purely on environment variables.
193
+
194
+ ```python
195
+ from aeth_ext.settings import BaseSettings
196
+
197
+
198
+ class Settings(BaseSettings):
199
+ api_token: str # required, from API_TOKEN
200
+
201
+
202
+ settings = Settings.get_settings() # singleton; same instance every call
203
+ creds = settings.creds_file_reusable("Missing creds", "ftp", "creds.json")
204
+ ```
205
+
206
+ Key built-in fields (all overridable via env vars / `.env`):
207
+
208
+ | Field | Env var | Default |
209
+ | -------------------- | -------------------- | -------------------------------------------------- |
210
+ | `persisted_dir_loc` | `PERSISTED_DIR_LOC` | `./persisted_data` (debug) / `/app/persisted_data` |
211
+ | `alerts_smtp_server` | `ALERTS_SMTP_SERVER` | `smtppro.zoho.com` |
212
+ | `alerts_smtp_port` | `ALERTS_SMTP_PORT` | `587` |
213
+ | `alerts_email` | `ALERTS_EMAIL` | `info@sweetfiretobacco.com` |
214
+ | `alerts_email_pwd` | `ALERTS_EMAIL_PWD` | *(required)* |
215
+ | `alerts_recipients` | `ALERTS_RECIPIENTS` | `{jacob.ogden@sweetfiretobacco.com}` |
216
+ | `log_loc_folder` | `LOG_LOC_FOLDER` | `<persisted_dir_loc>/logs` |
217
+ | `tz` | `TZ` | `US/Eastern` |
218
+
219
+ #### Helpers
220
+
221
+ - `get_settings()` / `get_final_model()` — resolve the singleton.
222
+ - `creds_file_reusable(err_msg, *path_parts)` — validate and return a file path
223
+ under `persisted_dir_loc`, raising `FileNotFoundError` with `err_msg` if absent.
224
+
225
+ ---
226
+
227
+ ### `logging` — logging stack
228
+
229
+ A Rich-powered logging system with daily/per-run file rotation, abbreviated
230
+ library paths, and queue-based fan-in for multi-process apps. Usually you don't
231
+ call these directly — `initialize()` does — but you can customize behavior by
232
+ subclassing `BaseLoggingConfig`.
233
+
234
+ ```python
235
+ from aeth_ext.logging.config import BaseLoggingConfig
236
+ from rich.console import Console
237
+
238
+
239
+ class LoggingConfig(BaseLoggingConfig):
240
+ def configure_logging_main(rich_console: Console, project_name: str, **kw) -> None:
241
+ # override to customize handlers, formats, rotation, etc.
242
+ ...
243
+ ```
244
+
245
+ #### Public API
246
+
247
+ - `init_logging(*queues)` — main-process setup; discovers the deepest
248
+ `BaseLoggingConfig` subclass and the `configure_logging_main` constants from your
249
+ `__main__`.
250
+ - `init_logging_worker(queue)` — worker-process setup; routes logs to the parent
251
+ via a `QueueHandler`.
252
+ - `BaseLoggingConfig` — override point for `configure_logging_main`,
253
+ `configure_logging_worker`, `configure_base_per_runner`, and `configure_base_once`.
254
+ - `QueueCatchall` — union type of the supported queue backends
255
+ (`InterpreterQueue | ProcessQueue | ThreadQueue`).
256
+ - `FixedRichHandler`, `FixedLogRecord`, `CustomTimedRotatingFileHandler` —
257
+ the handler/record building blocks that abbreviate `site-packages`/`src`/`Lib`
258
+ paths in output.
259
+
260
+ ---
261
+
262
+ ### `errors` — fatal-exception handling & alerts
263
+
264
+ Decorators that wrap a callable, log + email on any unhandled exception, set a
265
+ shared `FATAL_EVENT`, and swallow the error (returning `None`).
266
+
267
+ ```python
268
+ from aeth_ext.errors.err_handling import (
269
+ handle_fatal_exc_sync,
270
+ handle_fatal_exc_async,
271
+ FATAL_EVENT,
272
+ )
273
+
274
+
275
+ @handle_fatal_exc_sync
276
+ def risky() -> int:
277
+ return 1 / 0 # logs, emails an alert, sets FATAL_EVENT, returns None
278
+
279
+
280
+ @handle_fatal_exc_async
281
+ async def risky_async() -> None:
282
+ ...
283
+ ```
284
+
285
+ **`send_alert_email(subject, content)`** composes and batch-sends an alert email
286
+ to `settings.alerts_recipients`, attaching `content` as a UTF-8 file. It logs (and
287
+ no-ops) if no recipients are configured.
288
+
289
+ ---
290
+
291
+ ### `ftp` — FTP / SFTP adapters
292
+
293
+ A unified, context-managed interface over plain FTP and Paramiko SFTP, with
294
+ optional Rich progress bars and server-to-server transfers.
295
+
296
+ ```python
297
+ from aeth_ext.ftp.adapter import AdaptedSFTP # requires the [sftp] extra
298
+ from aeth_ext.rich.progress import Progress
299
+
300
+ with Progress() as pbar, AdaptedSFTP(sftp_protocol, "my-container", pbar=pbar) as ftp:
301
+ ftp.download_file(remote_path, callback, task_msg="Downloading")
302
+ ftp.upload_file(remote_path, callback, file_size, task_msg="Uploading")
303
+ ok = ftp.transfer_file(source, dest, other, task_msg="Relaying", ...)
304
+ ```
305
+
306
+ #### FTP API
307
+
308
+ - `AdaptedFTP` / `AdaptedSFTP` — context managers exposing `upload_file`,
309
+ `download_file`, and `transfer_file` (SFTP additionally provides `rename`,
310
+ `makedir`, `get_size`, `test_connection`).
311
+ - `AdapterProtocol`, `FTPProtocol`, `SFTPProtocol`, `ProtocolEnum`,
312
+ `ListDirResult` — the protocol/types layer (`ftp/types.py`).
313
+ - `ServerNotAvailableError(ConnectionError)` — raised when a server is unreachable.
314
+
315
+ ---
316
+
317
+ ### `monkey_patcher` — patch framework
318
+
319
+ Organize monkey patches as subclasses. Each plain method you define is forced
320
+ into a `staticmethod` by the metaclass and is invoked once when patches are
321
+ applied. The class is **not instantiable** — call its classmethods directly.
322
+
323
+ ```python
324
+ from aeth_ext.monkey_patcher import MonkeyPatcher
325
+
326
+
327
+ class MyPatches(MonkeyPatcher):
328
+ def patch_some_library():
329
+ import some_library
330
+ some_library.thing = replacement
331
+
332
+
333
+ MonkeyPatcher.apply_monkey_patches() # discovers + runs every subclass's patches
334
+ ```
335
+
336
+ `initialize(run_monkey_patches=True)` calls `apply_monkey_patches()` for you.
337
+
338
+ ---
339
+
340
+ ### `_search_for_subclasses` — static class discovery
341
+
342
+ The engine behind the auto-wiring. It scans `.py` files with the `ast` module —
343
+ **without importing them** — to find subclasses, then loads only the ones you ask
344
+ for.
345
+
346
+ - `find_subclasses(base, roots, *, ignored_dirs=..., include_name_fallback=False, recursive=True)`
347
+ → `tuple[SubclassInfo, ...]`
348
+ - `get_entrypoint_root()` → topmost package dir of the running entrypoint.
349
+ - `SubclassInfo` — `NamedTuple` with `qualname`, `name`, `module`, `file`,
350
+ `lineno`, `depth`; call `.load()` to import the live class.
351
+ - `iter_python_files`, `build_subclass_index`, `load_subclasses`,
352
+ `reset_subclass_caches` — supporting helpers.
353
+
354
+ ---
355
+
356
+ ### `const_parsing` — constant extraction
357
+
358
+ Read uppercase constant assignments out of a source file via AST and safely
359
+ evaluate them against a restricted namespace.
360
+
361
+ ```python
362
+ from pathlib import Path
363
+ from aeth_ext.const_parsing import parse_and_grab_constants
364
+
365
+ values = parse_and_grab_constants(
366
+ Path("config.py"),
367
+ expected_constants={"PROJECT_NAME": "project_name"},
368
+ eval_locals={},
369
+ )
370
+ # -> {"project_name": "<value of PROJECT_NAME>"}
371
+ ```
372
+
373
+ It scans both module-level statements and the `if __name__ == "__main__":` block.
374
+
375
+ ---
376
+
377
+ ### `utils` — email & datetime helpers
378
+
379
+ Email composition / batch sending plus offset-aware datetime helpers.
380
+
381
+ ```python
382
+ from aeth_ext.utils import prepare_email_message, batch_send_emails, get_now, today
383
+
384
+ msg = prepare_email_message({
385
+ "subject": "Report",
386
+ "body": "See attached.",
387
+ "from_addr": "info@sweetfiretobacco.com",
388
+ "to_addrs": ["jacob.ogden@sweetfiretobacco.com"],
389
+ "attachments": Path("report.csv"),
390
+ })
391
+ batch_send_emails(msg) # SMTP config defaults to the alerts.* settings
392
+ ```
393
+
394
+ | Function | Purpose |
395
+ | ------------------------------------- | ------------------------------------------------------------- |
396
+ | `prepare_email_message(parts)` | Build an `EmailMessage` from an `EmailMessageParts` dict. |
397
+ | `batch_send_emails(msgs, ...)` | Send one or many messages over SMTP (defaults to alerts cfg). |
398
+ | `handle_addrlike` / `..._sequence` | Normalize flexible `AddressLike` values. |
399
+ | `handle_attachment(path)` | Read a file and return `(bytes, mime-info)`. |
400
+ | `get_now(tz=None)` / `today(tz=None)` | Current datetime / midnight with configurable offset. |
401
+ | `get_last_sat(...)` / `get_next_sat` | Previous / next Saturday. |
402
+
403
+ ---
404
+
405
+ ### `types` — shared types & mixins
406
+
407
+ - `AddressLike` — `str | Address | tuple[str, str | None, str | None, str | None]`.
408
+ - `EmailMessageParts` — `TypedDict` for `prepare_email_message` (`subject`,
409
+ `body`, `from_addr`, `to_addrs` required; `cc_addrs`, `bcc_addrs`,
410
+ `attachments` optional).
411
+ - `StrEnum` — string enum whose value mirrors the member name.
412
+ - `CapturesSubclasses` (`types/abc.py`) — mixin that registers instances and can
413
+ resolve the deepest subclass / final model; the backbone of the auto-wiring used
414
+ by `BaseSettings` and `BaseLoggingConfig`.
415
+ - `SingletonType`, `SingletonTypeABC`, `SingletonTypeBaseModel` — singleton
416
+ metaclasses.
417
+
418
+ ---
419
+
420
+ ### `rich` — enhanced progress bars
421
+
422
+ `Progress` is a `rich.progress.Progress` subclass preconfigured with a sensible
423
+ column layout (bar, M-of-N, percentage, time remaining). Its `TaskID` supports
424
+ use as a context manager so a task is auto-removed on exit.
425
+
426
+ ```python
427
+ from aeth_ext.rich.progress import Progress
428
+
429
+ with Progress() as progress:
430
+ with progress.add_task("Working", total=100) as task_id:
431
+ progress.update(task_id, advance=50)
432
+ # task is removed automatically here
433
+ ```
434
+
435
+ ---
436
+
437
+ ## Configuration reference
438
+
439
+ Settings are read (in priority order) from explicit constructor args →
440
+ environment variables → a `.env` file (debug builds only) → field defaults.
441
+ Empty env values are ignored, and unknown keys are dropped (`extra="ignore"`).
442
+
443
+ Example `.env`:
444
+
445
+ ```dotenv
446
+ PERSISTED_DIR_LOC=./persisted_data
447
+ ALERTS_EMAIL_PWD=super-secret
448
+ ALERTS_RECIPIENTS=["ops@sweetfiretobacco.com","jacob.ogden@sweetfiretobacco.com"]
449
+ TZ=US/Eastern
450
+ ```
451
+
452
+ > Never commit real secrets. Provide `ALERTS_EMAIL_PWD` and any credentials via
453
+ > the environment or your secret manager.
454
+
455
+ ---
456
+
457
+ ## Development
458
+
459
+ This project uses [uv](https://github.com/astral-sh/uv).
460
+
461
+ ```bash
462
+ # install dependencies (including the dev group)
463
+ uv sync
464
+
465
+ # run the type checker
466
+ uv run pyright
467
+
468
+ # lint
469
+ uv run ruff check
470
+
471
+ # import smoke test
472
+ uv run python -c "import aeth_ext"
473
+ ```
474
+
475
+ The dev group includes `paramiko`, `pyright`, `types-python-dateutil`, and the
476
+ async event loop backends.
477
+
478
+ ---
479
+
480
+ ## Releasing
481
+
482
+ A [Poe the Poet](https://poethepoet.natn.io/) task automates version bump, tag,
483
+ build, and publish to GitHub + SFTPyPI:
484
+
485
+ ```bash
486
+ uv run poe release patch # or: minor | major
487
+ ```
488
+
489
+ It bumps the version in `pyproject.toml`, commits, tags `vX.Y.Z`, pushes with
490
+ tags, builds, publishes to the `SFTPyPI` index, and creates a GitHub release with
491
+ generated notes.