persistence-kit 3.13.0__tar.gz → 3.14.1__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.
Files changed (118) hide show
  1. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/PKG-INFO +24 -9
  2. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/README.md +21 -7
  3. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/templates.py +45 -15
  4. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/pyproject.toml +2 -2
  5. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/LICENSE +0 -0
  6. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/__init__.py +0 -0
  7. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/__init__.py +0 -0
  8. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/common.py +0 -0
  9. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/encrypted_middleware.py +0 -0
  10. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/encrypted_routes.py +0 -0
  11. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/error_handlers.py +0 -0
  12. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/exceptions.py +0 -0
  13. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/rate_limit.py +0 -0
  14. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/api/route_loader.py +0 -0
  15. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/authenticated_user.py +0 -0
  16. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/bootstrap/__init__.py +0 -0
  17. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/bootstrap/configuration.py +0 -0
  18. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/bootstrap/seeders.py +0 -0
  19. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/bootstrap/startup.py +0 -0
  20. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/__init__.py +0 -0
  21. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/contracts.py +0 -0
  22. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/dynamodb.py +0 -0
  23. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/factory.py +0 -0
  24. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/memory.py +0 -0
  25. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/mongo.py +0 -0
  26. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/cache/namespaced.py +0 -0
  27. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/contracts/__init__.py +0 -0
  28. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/contracts/repository.py +0 -0
  29. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/contracts/view_repository.py +0 -0
  30. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/__init__.py +0 -0
  31. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/contracts.py +0 -0
  32. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/errors.py +0 -0
  33. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/factory.py +0 -0
  34. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/mailer.py +0 -0
  35. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/memory.py +0 -0
  36. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/message.py +0 -0
  37. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/mime.py +0 -0
  38. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/preview.py +0 -0
  39. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/mail/smtp.py +0 -0
  40. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/py.typed +0 -0
  41. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/__init__.py +0 -0
  42. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/dynamodb_repo/dynamodb_mapper.py +0 -0
  43. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/dynamodb_repo/dynamodb_repo.py +0 -0
  44. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/filter_ops.py +0 -0
  45. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/memory_repo/__init__.py +0 -0
  46. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/memory_repo/memory_repo.py +0 -0
  47. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/mongo_repo/__init__.py +0 -0
  48. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/mongo_repo/mongo_mapper.py +0 -0
  49. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/mongo_repo/mongo_repo.py +0 -0
  50. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/sqlalchemy_repo/__init__.py +0 -0
  51. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/sqlalchemy_repo/schema_evolve.py +0 -0
  52. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_dataclass_mapper.py +0 -0
  53. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_engine.py +0 -0
  54. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/sqlalchemy_repo/sqlalchemy_repo.py +0 -0
  55. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository/sqlalchemy_repo/table_factory.py +0 -0
  56. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/__init__.py +0 -0
  57. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/factory/__init__.py +0 -0
  58. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/factory/repository_factory.py +0 -0
  59. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/registry/__init__.py +0 -0
  60. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/registry/entity_registry.py +0 -0
  61. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/view/__init__.py +0 -0
  62. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/repository_factory/view/populating_repository.py +0 -0
  63. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/resilience/__init__.py +0 -0
  64. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/resilience/circuit.py +0 -0
  65. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/__init__.py +0 -0
  66. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/aggregate.py +0 -0
  67. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/__init__.py +0 -0
  68. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/api_key.py +0 -0
  69. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/base.py +0 -0
  70. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/basic.py +0 -0
  71. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/bearer.py +0 -0
  72. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/login.py +0 -0
  73. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/auth/oauth2.py +0 -0
  74. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/caching.py +0 -0
  75. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/client.py +0 -0
  76. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/config.py +0 -0
  77. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/contracts.py +0 -0
  78. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/errors.py +0 -0
  79. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/factory.py +0 -0
  80. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/mapping.py +0 -0
  81. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/memory.py +0 -0
  82. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/payload.py +0 -0
  83. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/populate.py +0 -0
  84. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/provider.py +0 -0
  85. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/registry.py +0 -0
  86. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/resolver.py +0 -0
  87. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/restclient/retry.py +0 -0
  88. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/__init__.py +0 -0
  89. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/encrypted/__init__.py +0 -0
  90. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/encrypted/envelope.py +0 -0
  91. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/encrypted/errors.py +0 -0
  92. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/factory.py +0 -0
  93. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/ports.py +0 -0
  94. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/providers/__init__.py +0 -0
  95. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/providers/cognito_identity_provider.py +0 -0
  96. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/providers/encryption_provider.py +0 -0
  97. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/providers/memory_security_provider.py +0 -0
  98. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/registration.py +0 -0
  99. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/token_verifiers/__init__.py +0 -0
  100. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/token_verifiers/cognito_jwt_verifier.py +0 -0
  101. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/security/token_verifiers/memory_jwt_verifier.py +0 -0
  102. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/__init__.py +0 -0
  103. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/app_settings.py +0 -0
  104. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/cache_settings.py +0 -0
  105. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/constants.py +0 -0
  106. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/mail_settings.py +0 -0
  107. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/parsers.py +0 -0
  108. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/settings/repo_settings.py +0 -0
  109. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/__init__.py +0 -0
  110. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/contracts.py +0 -0
  111. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/errors.py +0 -0
  112. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/factory.py +0 -0
  113. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/local.py +0 -0
  114. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/media.py +0 -0
  115. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/routes.py +0 -0
  116. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/storage/s3.py +0 -0
  117. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/utils/__init__.py +0 -0
  118. {persistence_kit-3.13.0 → persistence_kit-3.14.1}/persistence_kit/utils/upsert.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: persistence-kit
3
- Version: 3.13.0
3
+ Version: 3.14.1
4
4
  Summary: Reusable persistence and repository toolkit
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
19
20
  Classifier: Topic :: Database
20
21
  Classifier: Typing :: Typed
21
22
  Provides-Extra: all
@@ -31,7 +32,7 @@ Provides-Extra: storage-s3
31
32
  Provides-Extra: testing
32
33
  Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
33
34
  Requires-Dist: boto3 (>=1.35.0,<2.0.0) ; extra == "storage-s3" or extra == "security-cognito" or extra == "dynamodb" or extra == "all"
34
- Requires-Dist: cryptography (>=42.0.0,<43.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
35
+ Requires-Dist: cryptography (>=42.0.0) ; extra == "encrypted" or extra == "testing" or extra == "all"
35
36
  Requires-Dist: fastapi (>=0.115.0,<1.0.0) ; extra == "api" or extra == "storage-routes" or extra == "security" or extra == "security-cognito" or extra == "encrypted" or extra == "testing" or extra == "all"
36
37
  Requires-Dist: httpx (>=0.28.0,<1.0.0) ; extra == "restclient" or extra == "testing" or extra == "all"
37
38
  Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "mail" or extra == "testing" or extra == "all"
@@ -358,10 +359,24 @@ message = mailer.compose(
358
359
  mailer.dispatch(message)
359
360
  ```
360
361
 
361
- A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
362
- (required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
363
- They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
364
- and a variable missing from the context fails instead of rendering blank.
362
+ A template is a folder in the app's directory, `user_roles_changed/`, with
363
+ `subject.txt` (required) and `body.html` or `body.txt` (at least one). The same
364
+ files can also live loose in the directory as `user_roles_changed.subject.txt`,
365
+ `user_roles_changed.html` and `user_roles_changed.txt`; the folder wins when both
366
+ exist. They are Jinja2, so a layout can be shared with `{% extends "shared/layout.html" %}`,
367
+ resolved from the directory root. HTML is autoescaped, and a variable missing from
368
+ the context fails instead of rendering blank.
369
+
370
+ ```text
371
+ templates/mail/
372
+ shared/layout.html
373
+ images/logo.png
374
+ user_roles_changed/
375
+ subject.txt
376
+ body.html
377
+ body.txt
378
+ sample.json
379
+ ```
365
380
 
366
381
  `to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
367
382
  Blind copies travel in the SMTP envelope only and never appear in the headers.
@@ -379,8 +394,8 @@ remote images are blocked. `compose` adds it on its own; a missing file or one t
379
394
  is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
380
395
  does the same for images the caller builds.
381
396
 
382
- A template can carry a fourth file, `user_roles_changed.sample.json`, with an
383
- example context. `MailTemplates.names()` lists the templates and
397
+ A template can carry a fourth file, `sample.json` (`user_roles_changed.sample.json`
398
+ when loose), with an example context. `MailTemplates.names()` lists the templates and
384
399
  `sample_context(name)` reads that file, so one parametrized test covers them all.
385
400
  The same sample drives the preview, which renders without sending:
386
401
 
@@ -571,7 +586,7 @@ poetry install --with dev --all-extras
571
586
  poetry run pytest -q
572
587
  ```
573
588
 
574
- Current baseline: **621 tests passing** (version 3.13.0). Async tests use
589
+ Current baseline: **627 tests passing** (version 3.14.0). Async tests use
575
590
  `pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
576
591
 
577
592
  `tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
@@ -310,10 +310,24 @@ message = mailer.compose(
310
310
  mailer.dispatch(message)
311
311
  ```
312
312
 
313
- A template is up to three files in the app's directory: `user_roles_changed.subject.txt`
314
- (required), and `user_roles_changed.html` or `user_roles_changed.txt` (at least one).
315
- They are Jinja2, so a layout can be shared with `{% extends %}`. HTML is autoescaped,
316
- and a variable missing from the context fails instead of rendering blank.
313
+ A template is a folder in the app's directory, `user_roles_changed/`, with
314
+ `subject.txt` (required) and `body.html` or `body.txt` (at least one). The same
315
+ files can also live loose in the directory as `user_roles_changed.subject.txt`,
316
+ `user_roles_changed.html` and `user_roles_changed.txt`; the folder wins when both
317
+ exist. They are Jinja2, so a layout can be shared with `{% extends "shared/layout.html" %}`,
318
+ resolved from the directory root. HTML is autoescaped, and a variable missing from
319
+ the context fails instead of rendering blank.
320
+
321
+ ```text
322
+ templates/mail/
323
+ shared/layout.html
324
+ images/logo.png
325
+ user_roles_changed/
326
+ subject.txt
327
+ body.html
328
+ body.txt
329
+ sample.json
330
+ ```
317
331
 
318
332
  `to`, `cc`, `bcc` and `reply_to` take a string, a `MailAddress` or a list of them.
319
333
  Blind copies travel in the SMTP envelope only and never appear in the headers.
@@ -331,8 +345,8 @@ remote images are blocked. `compose` adds it on its own; a missing file or one t
331
345
  is not an image fails like a missing variable. `MailAttachment(..., content_id=...)`
332
346
  does the same for images the caller builds.
333
347
 
334
- A template can carry a fourth file, `user_roles_changed.sample.json`, with an
335
- example context. `MailTemplates.names()` lists the templates and
348
+ A template can carry a fourth file, `sample.json` (`user_roles_changed.sample.json`
349
+ when loose), with an example context. `MailTemplates.names()` lists the templates and
336
350
  `sample_context(name)` reads that file, so one parametrized test covers them all.
337
351
  The same sample drives the preview, which renders without sending:
338
352
 
@@ -523,7 +537,7 @@ poetry install --with dev --all-extras
523
537
  poetry run pytest -q
524
538
  ```
525
539
 
526
- Current baseline: **621 tests passing** (version 3.13.0). Async tests use
540
+ Current baseline: **627 tests passing** (version 3.14.0). Async tests use
527
541
  `pytest-asyncio` in strict mode, so each one carries `@pytest.mark.asyncio`.
528
542
 
529
543
  `tests/test_capabilities.py` guards the lazy-import promise: it fails if merely
@@ -24,6 +24,10 @@ SUBJECT_SUFFIX = ".subject.txt"
24
24
  HTML_SUFFIX = ".html"
25
25
  TEXT_SUFFIX = ".txt"
26
26
  SAMPLE_SUFFIX = ".sample.json"
27
+ FOLDER_SUBJECT = "subject.txt"
28
+ FOLDER_HTML = "body.html"
29
+ FOLDER_TEXT = "body.txt"
30
+ FOLDER_SAMPLE = "sample.json"
27
31
  IMAGES_DIRECTORY = "images"
28
32
  CID_SOURCE = re.compile(r"""\bsrc\s*=\s*["']cid:([^"']+)["']""", re.IGNORECASE)
29
33
 
@@ -36,12 +40,21 @@ class RenderedMail:
36
40
  inline_images: tuple[MailAttachment, ...] = ()
37
41
 
38
42
 
43
+ @dataclass(frozen=True, kw_only=True)
44
+ class _TemplateFiles:
45
+ subject: str
46
+ html: str
47
+ text: str
48
+ sample: str
49
+
50
+
39
51
  class MailTemplates:
40
52
  """Renders the templates of the application that sends; the kit ships none of its own.
41
53
 
42
- A template `name` is up to three files in `directory`: `name.subject.txt` (required) and
43
- `name.html` or `name.txt` (at least one). HTML is autoescaped, subject and text are not.
44
- An optional `name.sample.json` holds an example context for previews and tests.
54
+ A template `name` is a folder `name/` with `subject.txt` (required) and `body.html` or
55
+ `body.txt` (at least one), or the same files loose in `directory` as `name.subject.txt`,
56
+ `name.html` and `name.txt`. HTML is autoescaped, subject and text are not. An optional
57
+ `sample.json` (`name.sample.json` when loose) holds an example context for previews and tests.
45
58
  Every `src="cid:file.png"` in the HTML embeds `images/file.png` in the message.
46
59
  """
47
60
 
@@ -58,15 +71,14 @@ class MailTemplates:
58
71
 
59
72
  def render(self, name: str, context: Mapping[str, Any] | None = None) -> RenderedMail:
60
73
  values = dict(context or {})
61
- subject = self._render_file(f"{name}{SUBJECT_SUFFIX}", values)
74
+ files = self._files(name)
75
+ subject = self._render_file(files.subject, values)
62
76
  if subject is None:
63
- raise MailTemplateError(f"Template '{name}' has no {name}{SUBJECT_SUFFIX}.")
64
- html = self._render_file(f"{name}{HTML_SUFFIX}", values)
65
- text = self._render_file(f"{name}{TEXT_SUFFIX}", values)
77
+ raise MailTemplateError(f"Template '{name}' has no {files.subject}.")
78
+ html = self._render_file(files.html, values)
79
+ text = self._render_file(files.text, values)
66
80
  if html is None and text is None:
67
- raise MailTemplateError(
68
- f"Template '{name}' needs {name}{HTML_SUFFIX} or {name}{TEXT_SUFFIX}."
69
- )
81
+ raise MailTemplateError(f"Template '{name}' needs {files.html} or {files.text}.")
70
82
  return RenderedMail(
71
83
  subject=" ".join(subject.split()),
72
84
  html=html,
@@ -76,23 +88,41 @@ class MailTemplates:
76
88
 
77
89
  def names(self) -> list[str]:
78
90
  """Every template in the directory, identified by its subject file."""
79
- return sorted(
91
+ loose = (
80
92
  path.name.removesuffix(SUBJECT_SUFFIX)
81
93
  for path in self._directory.glob(f"*{SUBJECT_SUFFIX}")
82
94
  )
95
+ folders = (path.parent.name for path in self._directory.glob(f"*/{FOLDER_SUBJECT}"))
96
+ return sorted({*loose, *folders})
83
97
 
84
98
  def sample_context(self, name: str) -> dict[str, Any]:
85
- path = self._directory / f"{name}{SAMPLE_SUFFIX}"
99
+ relative = self._files(name).sample
100
+ path = self._directory / relative
86
101
  if not path.is_file():
87
- raise MailTemplateError(f"Template '{name}' has no {name}{SAMPLE_SUFFIX}.")
102
+ raise MailTemplateError(f"Template '{name}' has no {relative}.")
88
103
  try:
89
104
  sample = json.loads(path.read_text(encoding="utf-8"))
90
105
  except json.JSONDecodeError as exc:
91
- raise MailTemplateError(f"{path.name} is not valid JSON: {exc}") from exc
106
+ raise MailTemplateError(f"{relative} is not valid JSON: {exc}") from exc
92
107
  if not isinstance(sample, dict):
93
- raise MailTemplateError(f"{path.name} must hold a JSON object.")
108
+ raise MailTemplateError(f"{relative} must hold a JSON object.")
94
109
  return sample
95
110
 
111
+ def _files(self, name: str) -> _TemplateFiles:
112
+ if (self._directory / name).is_dir():
113
+ return _TemplateFiles(
114
+ subject=f"{name}/{FOLDER_SUBJECT}",
115
+ html=f"{name}/{FOLDER_HTML}",
116
+ text=f"{name}/{FOLDER_TEXT}",
117
+ sample=f"{name}/{FOLDER_SAMPLE}",
118
+ )
119
+ return _TemplateFiles(
120
+ subject=f"{name}{SUBJECT_SUFFIX}",
121
+ html=f"{name}{HTML_SUFFIX}",
122
+ text=f"{name}{TEXT_SUFFIX}",
123
+ sample=f"{name}{SAMPLE_SUFFIX}",
124
+ )
125
+
96
126
  def _inline_images(self, name: str, html: str | None) -> tuple[MailAttachment, ...]:
97
127
  if html is None:
98
128
  return ()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "persistence-kit"
3
- version = "3.13.0"
3
+ version = "3.14.1"
4
4
  description = "Reusable persistence and repository toolkit"
5
5
  authors = ["Andres Felipe Serrano Barrios <andresfserrano1@gmail.com>"]
6
6
  readme = "README.md"
@@ -35,7 +35,7 @@ boto3 = {version = "^1.35.0", optional = true}
35
35
  pyjwt = {version = "^2.10.1", extras = ["crypto"], optional = true}
36
36
  python-multipart = {version = ">=0.0.31,<1.0.0", optional = true}
37
37
  httpx = {version = ">=0.28.0,<1.0.0", optional = true}
38
- cryptography = {version = "^42.0.0", optional = true}
38
+ cryptography = {version = ">=42.0.0", optional = true}
39
39
  jinja2 = {version = "^3.1.4", optional = true}
40
40
 
41
41
  [tool.poetry.extras]