open-api-framework 0.13.1__tar.gz → 0.13.3__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 (38) hide show
  1. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/CHANGELOG.rst +19 -0
  2. {open_api_framework-0.13.1/open_api_framework.egg-info → open_api_framework-0.13.3}/PKG-INFO +2 -2
  3. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/README.rst +1 -1
  4. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/conf/base.py +182 -137
  5. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/conf/utils.py +55 -27
  6. {open_api_framework-0.13.1 → open_api_framework-0.13.3/open_api_framework.egg-info}/PKG-INFO +2 -2
  7. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/pyproject.toml +2 -2
  8. open_api_framework-0.13.3/tests/test_config_helpers.py +50 -0
  9. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/tests/test_csp.py +7 -0
  10. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/tests/test_generate_envvar_docs.py +9 -2
  11. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/tests/test_settings.py +4 -0
  12. open_api_framework-0.13.1/tests/test_config_helpers.py +0 -13
  13. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/LICENSE +0 -0
  14. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/MANIFEST.in +0 -0
  15. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/__init__.py +0 -0
  16. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/admin.py +0 -0
  17. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/apps.py +0 -0
  18. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/conf/__init__.py +0 -0
  19. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/context_processors.py +0 -0
  20. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/management/__init__.py +0 -0
  21. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/management/commands/__init__.py +0 -0
  22. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/management/commands/generate_envvar_docs.py +0 -0
  23. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/py.typed +0 -0
  24. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/static/open_api_framework/css/admin.css +0 -0
  25. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/admin/base_site.html +0 -0
  26. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/components/environment.html +0 -0
  27. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/components/version.html +0 -0
  28. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/env_config.rst +0 -0
  29. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templatetags/__init__.py +0 -0
  30. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templatetags/doc_tags.py +0 -0
  31. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/templatetags/open_api_framework.py +0 -0
  32. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework/utils.py +0 -0
  33. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework.egg-info/SOURCES.txt +0 -0
  34. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework.egg-info/dependency_links.txt +0 -0
  35. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework.egg-info/requires.txt +0 -0
  36. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/open_api_framework.egg-info/top_level.txt +0 -0
  37. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/setup.cfg +0 -0
  38. {open_api_framework-0.13.1 → open_api_framework-0.13.3}/tests/test_admin.py +0 -0
@@ -1,6 +1,25 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
+ 0.13.3 (2026-01-28)
5
+ -------------------
6
+
7
+ **Bugfixes**
8
+
9
+ * [#196] Fix errors raised when using ``open-api-framework`` without optional dependencies
10
+
11
+ **Project maintenance**
12
+
13
+ * Remove Django 4.2 from CI (pyproject.toml enforces >=5.2)
14
+
15
+ 0.13.2 (2025-11-13)
16
+ -------------------
17
+
18
+ **Documentation**
19
+
20
+ * Updated the help text of ``DB_POOL_ENABLED`` to indicate that connection pooling is experimental and not recommended for production use.
21
+ * Added a reference to the connection pooling documentation.
22
+
4
23
  0.13.1 (2025-10-03)
5
24
  -------------------
6
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-api-framework
3
- Version: 0.13.1
3
+ Version: 0.13.3
4
4
  Summary: A metapackage for registration components, that bundles the dependencies shared between these components and provides generic settings
5
5
  Author-email: Maykin Media <support@maykinmedia.nl>
6
6
  License: Copyright © Maykin 2024
@@ -96,7 +96,7 @@ Dynamic: license-file
96
96
  Open API Framework
97
97
  ==================
98
98
 
99
- :Version: 0.13.1
99
+ :Version: 0.13.3
100
100
  :Source: https://github.com/maykinmedia/open-api-framework
101
101
  :Keywords: metapackage, dependencies
102
102
 
@@ -1,7 +1,7 @@
1
1
  Open API Framework
2
2
  ==================
3
3
 
4
- :Version: 0.13.1
4
+ :Version: 0.13.3
5
5
  :Source: https://github.com/maykinmedia/open-api-framework
6
6
  :Keywords: metapackage, dependencies
7
7
 
@@ -1,30 +1,40 @@
1
1
  import datetime
2
2
  import os
3
3
  import warnings
4
- from pathlib import Path
4
+ from contextlib import suppress
5
+ from importlib.util import find_spec
5
6
 
6
7
  from django.urls import reverse_lazy
7
8
 
8
9
  import sentry_sdk
9
- from corsheaders.defaults import default_headers as default_cors_headers
10
- from csp.constants import NONCE, NONE, SELF
11
10
  from log_outgoing_requests.formatters import HttpFormatter
12
- from notifications_api_common.settings import * # noqa
13
11
 
14
12
  from .utils import (
15
13
  config,
16
14
  get_django_project_dir,
17
15
  get_project_dirname,
18
16
  get_sentry_integrations,
17
+ importable,
19
18
  strip_protocol_from_origin,
20
19
  )
21
20
 
21
+ # optional requirements
22
+ default_cors_headers = []
23
+ with suppress(ImportError):
24
+ from corsheaders.defaults import default_headers as default_cors_headers
25
+
26
+ csp_installed = False
27
+ with suppress(ImportError):
28
+ from csp.constants import NONCE, NONE, SELF
29
+
30
+ csp_installed = True
31
+
22
32
  PROJECT_DIRNAME = get_project_dirname()
23
33
 
24
34
  # Build paths inside the project, so further paths can be defined relative to
25
35
  # the code root.
26
36
  DJANGO_PROJECT_DIR = get_django_project_dir()
27
- BASE_DIR = Path(DJANGO_PROJECT_DIR).resolve().parents[1]
37
+ BASE_DIR = DJANGO_PROJECT_DIR.resolve().parents[1]
28
38
 
29
39
 
30
40
  #
@@ -164,7 +174,12 @@ DATABASES["default"]["CONN_MAX_AGE"] = config(
164
174
  DB_POOL_ENABLED = config(
165
175
  "DB_POOL_ENABLED",
166
176
  default=False,
167
- help_text=("Whether to use connection pooling."),
177
+ help_text=(
178
+ "**Experimental:** Whether to use connection pooling. "
179
+ "This feature is not yet recommended for production use. "
180
+ "See the documentation for details: "
181
+ "https://open-api-framework.readthedocs.io/en/latest/connection_pooling.html"
182
+ ),
168
183
  group="Database",
169
184
  )
170
185
 
@@ -302,53 +317,55 @@ if DB_POOL_ENABLED:
302
317
  # https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
303
318
  DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
304
319
 
305
- CACHE_DEFAULT = config(
306
- "CACHE_DEFAULT",
307
- "localhost:6379/0",
308
- help_text="redis cache address for the default cache (this **MUST** be set when using Docker)",
309
- group="Required",
310
- )
311
- CACHE_AXES = config(
312
- "CACHE_AXES",
313
- "localhost:6379/0",
314
- help_text=(
315
- "redis cache address for the brute force login protection cache "
316
- "(this **MUST** be set when using Docker)"
317
- ),
318
- group="Required",
319
- )
320
+ if find_spec("django_redis"):
321
+ CACHE_DEFAULT = config(
322
+ "CACHE_DEFAULT",
323
+ "localhost:6379/0",
324
+ help_text="redis cache address for the default cache (this **MUST** be set when using Docker)",
325
+ group="Required",
326
+ )
327
+ CACHE_AXES = config(
328
+ "CACHE_AXES",
329
+ "localhost:6379/0",
330
+ help_text=(
331
+ "redis cache address for the brute force login protection cache "
332
+ "(this **MUST** be set when using Docker)"
333
+ ),
334
+ group="Required",
335
+ )
320
336
 
321
- CACHES = {
322
- "default": {
323
- "BACKEND": "django_redis.cache.RedisCache",
324
- "LOCATION": f"redis://{CACHE_DEFAULT}",
325
- "OPTIONS": {
326
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
327
- "IGNORE_EXCEPTIONS": True,
337
+ CACHES = {
338
+ "default": {
339
+ "BACKEND": "django_redis.cache.RedisCache",
340
+ "LOCATION": f"redis://{CACHE_DEFAULT}",
341
+ "OPTIONS": {
342
+ "CLIENT_CLASS": "django_redis.client.DefaultClient",
343
+ "IGNORE_EXCEPTIONS": True,
344
+ },
328
345
  },
329
- },
330
- "axes": {
331
- "BACKEND": "django_redis.cache.RedisCache",
332
- "LOCATION": f"redis://{CACHE_AXES}",
333
- "OPTIONS": {
334
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
335
- "IGNORE_EXCEPTIONS": True,
346
+ "axes": {
347
+ "BACKEND": "django_redis.cache.RedisCache",
348
+ "LOCATION": f"redis://{CACHE_AXES}",
349
+ "OPTIONS": {
350
+ "CLIENT_CLASS": "django_redis.client.DefaultClient",
351
+ "IGNORE_EXCEPTIONS": True,
352
+ },
336
353
  },
337
- },
338
- "oidc": {
339
- "BACKEND": "django_redis.cache.RedisCache",
340
- "LOCATION": f"redis://{CACHE_DEFAULT}",
341
- "OPTIONS": {
342
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
343
- "IGNORE_EXCEPTIONS": True,
354
+ "oidc": {
355
+ "BACKEND": "django_redis.cache.RedisCache",
356
+ "LOCATION": f"redis://{CACHE_DEFAULT}",
357
+ "OPTIONS": {
358
+ "CLIENT_CLASS": "django_redis.client.DefaultClient",
359
+ "IGNORE_EXCEPTIONS": True,
360
+ },
344
361
  },
345
- },
346
- }
362
+ }
363
+
347
364
 
348
365
  #
349
366
  # APPLICATIONS enabled for this project
350
367
  #
351
- INSTALLED_APPS = [
368
+ INSTALLED_APPS = importable(
352
369
  # Note: contenttypes should be first, see Django ticket #10827
353
370
  "django.contrib.contenttypes",
354
371
  "django.contrib.auth",
@@ -391,9 +408,9 @@ INSTALLED_APPS = [
391
408
  PROJECT_DIRNAME,
392
409
  # Django libraries
393
410
  "upgrade_check",
394
- ]
411
+ )
395
412
 
396
- MIDDLEWARE = [
413
+ MIDDLEWARE = importable(
397
414
  "django.middleware.security.SecurityMiddleware",
398
415
  "sessionprofile.middleware.SessionProfileMiddleware",
399
416
  "django.contrib.sessions.middleware.SessionMiddleware",
@@ -406,7 +423,7 @@ MIDDLEWARE = [
406
423
  "django.middleware.clickjacking.XFrameOptionsMiddleware",
407
424
  "axes.middleware.AxesMiddleware",
408
425
  "csp.contrib.rate_limiting.RateLimitedCSPMiddleware",
409
- ]
426
+ )
410
427
 
411
428
  ROOT_URLCONF = f"{PROJECT_DIRNAME}.urls"
412
429
 
@@ -419,7 +436,7 @@ TEMPLATE_LOADERS = (
419
436
  TEMPLATES = [
420
437
  {
421
438
  "BACKEND": "django.template.backends.django.DjangoTemplates",
422
- "DIRS": [Path(DJANGO_PROJECT_DIR) / "templates"],
439
+ "DIRS": [DJANGO_PROJECT_DIR / "templates"],
423
440
  "APP_DIRS": False, # conflicts with explicity specifying the loaders
424
441
  "OPTIONS": {
425
442
  "context_processors": [
@@ -438,7 +455,7 @@ TEMPLATES = [
438
455
  WSGI_APPLICATION = f"{PROJECT_DIRNAME}.wsgi.application"
439
456
 
440
457
  # Translations
441
- LOCALE_PATHS = (Path(DJANGO_PROJECT_DIR) / "conf" / "locale",)
458
+ LOCALE_PATHS = (DJANGO_PROJECT_DIR / "conf" / "locale",)
442
459
 
443
460
  #
444
461
  # SERVING of static and media files
@@ -446,10 +463,10 @@ LOCALE_PATHS = (Path(DJANGO_PROJECT_DIR) / "conf" / "locale",)
446
463
 
447
464
  STATIC_URL = "/static/"
448
465
 
449
- STATIC_ROOT = Path(BASE_DIR) / "static"
466
+ STATIC_ROOT = BASE_DIR / "static"
450
467
 
451
468
  # Additional locations of static files
452
- STATICFILES_DIRS = [Path(DJANGO_PROJECT_DIR) / "static"]
469
+ STATICFILES_DIRS = [DJANGO_PROJECT_DIR / "static"]
453
470
 
454
471
  # List of finder classes that know how to find static files in
455
472
  # various locations.
@@ -458,7 +475,7 @@ STATICFILES_FINDERS = [
458
475
  "django.contrib.staticfiles.finders.AppDirectoriesFinder",
459
476
  ]
460
477
 
461
- MEDIA_ROOT = Path(BASE_DIR) / "media"
478
+ MEDIA_ROOT = BASE_DIR / "media"
462
479
 
463
480
  MEDIA_URL = "/media/"
464
481
 
@@ -563,6 +580,7 @@ CELERY_LOGLEVEL = config(
563
580
  help_text="control the verbosity of logging output for celery, independent of ``LOG_LEVEL``."
564
581
  " Available values are ``CRITICAL``, ``ERROR``, ``WARNING``, ``INFO`` and ``DEBUG``",
565
582
  group="Celery",
583
+ add_to_docs="celery",
566
584
  )
567
585
 
568
586
  _USE_STRUCTLOG = config("_USE_STRUCTLOG", default=False, add_to_docs=False)
@@ -573,10 +591,11 @@ ENABLE_STRUCTLOG_REQUESTS = config(
573
591
  default=True,
574
592
  help_text=("enable structured logging of requests"),
575
593
  group="Logging",
594
+ add_to_docs="django_structlog",
576
595
  )
577
596
 
578
597
 
579
- LOGGING_DIR = Path(BASE_DIR) / "log"
598
+ LOGGING_DIR = BASE_DIR / "log"
580
599
 
581
600
  if _USE_STRUCTLOG:
582
601
  import structlog
@@ -663,7 +682,7 @@ if _USE_STRUCTLOG:
663
682
  "json_file": {
664
683
  "level": LOG_LEVEL, # always debug might be better?
665
684
  "class": "logging.handlers.RotatingFileHandler",
666
- "filename": Path(LOGGING_DIR) / "application.jsonl",
685
+ "filename": LOGGING_DIR / "application.jsonl",
667
686
  "formatter": "json",
668
687
  "maxBytes": 1024 * 1024 * 10, # 10 MB
669
688
  "backupCount": 10,
@@ -671,7 +690,7 @@ if _USE_STRUCTLOG:
671
690
  "performance": {
672
691
  "level": "INFO",
673
692
  "class": "logging.handlers.RotatingFileHandler",
674
- "filename": Path(LOGGING_DIR) / "performance.log",
693
+ "filename": LOGGING_DIR / "performance.log",
675
694
  "formatter": "performance",
676
695
  "maxBytes": 1024 * 1024 * 10, # 10 MB
677
696
  "backupCount": 10,
@@ -679,7 +698,7 @@ if _USE_STRUCTLOG:
679
698
  "requests": {
680
699
  "level": "DEBUG",
681
700
  "class": "logging.handlers.RotatingFileHandler",
682
- "filename": Path(LOGGING_DIR) / "requests.log",
701
+ "filename": LOGGING_DIR / "requests.log",
683
702
  "formatter": "timestamped",
684
703
  "maxBytes": 1024 * 1024 * 10, # 10 MB
685
704
  "backupCount": 10,
@@ -820,23 +839,10 @@ else:
820
839
  "class": "logging.StreamHandler",
821
840
  "formatter": "db",
822
841
  },
823
- "celery_console": {
824
- "level": CELERY_LOGLEVEL,
825
- "class": "logging.StreamHandler",
826
- "formatter": "timestamped",
827
- },
828
- "celery_file": {
829
- "level": CELERY_LOGLEVEL,
830
- "class": "logging.handlers.RotatingFileHandler",
831
- "filename": Path(LOGGING_DIR) / "celery.log",
832
- "formatter": "verbose",
833
- "maxBytes": 1024 * 1024 * 10, # 10 MB
834
- "backupCount": 10,
835
- },
836
842
  "django": {
837
843
  "level": LOG_LEVEL,
838
844
  "class": "logging.handlers.RotatingFileHandler",
839
- "filename": Path(LOGGING_DIR) / "django.log",
845
+ "filename": LOGGING_DIR / "django.log",
840
846
  "formatter": "verbose",
841
847
  "maxBytes": 1024 * 1024 * 10, # 10 MB
842
848
  "backupCount": 10,
@@ -844,7 +850,7 @@ else:
844
850
  "project": {
845
851
  "level": LOG_LEVEL,
846
852
  "class": "logging.handlers.RotatingFileHandler",
847
- "filename": Path(LOGGING_DIR) / f"{PROJECT_DIRNAME}.log",
853
+ "filename": LOGGING_DIR / f"{PROJECT_DIRNAME}.log",
848
854
  "formatter": "verbose",
849
855
  "maxBytes": 1024 * 1024 * 10, # 10 MB
850
856
  "backupCount": 10,
@@ -852,7 +858,7 @@ else:
852
858
  "performance": {
853
859
  "level": "INFO",
854
860
  "class": "logging.handlers.RotatingFileHandler",
855
- "filename": Path(LOGGING_DIR) / "performance.log",
861
+ "filename": LOGGING_DIR / "performance.log",
856
862
  "formatter": "performance",
857
863
  "maxBytes": 1024 * 1024 * 10, # 10 MB
858
864
  "backupCount": 10,
@@ -860,7 +866,7 @@ else:
860
866
  "requests": {
861
867
  "level": "DEBUG",
862
868
  "class": "logging.handlers.RotatingFileHandler",
863
- "filename": Path(LOGGING_DIR) / "requests.log",
869
+ "filename": LOGGING_DIR / "requests.log",
864
870
  "formatter": "timestamped",
865
871
  "maxBytes": 1024 * 1024 * 10, # 10 MB
866
872
  "backupCount": 10,
@@ -875,7 +881,26 @@ else:
875
881
  # enabling saving to database
876
882
  "class": "log_outgoing_requests.handlers.DatabaseOutgoingRequestsHandler",
877
883
  },
878
- },
884
+ }
885
+ | ( # celery dependant handlers
886
+ {
887
+ "celery_console": {
888
+ "level": CELERY_LOGLEVEL,
889
+ "class": "logging.StreamHandler",
890
+ "formatter": "timestamped",
891
+ },
892
+ "celery_file": {
893
+ "level": CELERY_LOGLEVEL,
894
+ "class": "logging.handlers.RotatingFileHandler",
895
+ "filename": LOGGING_DIR / "celery.log",
896
+ "formatter": "verbose",
897
+ "maxBytes": 1024 * 1024 * 10, # 10 MB
898
+ "backupCount": 10,
899
+ },
900
+ }
901
+ if find_spec("celery")
902
+ else {}
903
+ ),
879
904
  "loggers": {
880
905
  "": {
881
906
  "handlers": logging_root_handlers,
@@ -925,18 +950,25 @@ else:
925
950
  "level": "DEBUG",
926
951
  "propagate": True,
927
952
  },
928
- "celery": {
929
- "handlers": ["celery_console"] if LOG_STDOUT else ["celery_file"],
930
- "level": CELERY_LOGLEVEL,
931
- "propagate": True,
932
- },
933
- },
953
+ }
954
+ | (
955
+ {
956
+ "celery": {
957
+ "handlers": ["celery_console"] if LOG_STDOUT else ["celery_file"],
958
+ "level": CELERY_LOGLEVEL,
959
+ "propagate": True,
960
+ },
961
+ }
962
+ if find_spec("celery")
963
+ else {}
964
+ ),
934
965
  }
935
966
 
936
967
  #
937
968
  # AUTH settings - user accounts, passwords, backends...
938
969
  #
939
- AUTH_USER_MODEL = "accounts.User"
970
+ if find_spec(f"{PROJECT_DIRNAME}.accounts"):
971
+ AUTH_USER_MODEL = "accounts.User"
940
972
 
941
973
  # Password validation
942
974
  # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
@@ -1048,7 +1080,7 @@ if subpath:
1048
1080
  if "GIT_SHA" in os.environ:
1049
1081
  GIT_SHA = config("GIT_SHA", "", add_to_docs=False)
1050
1082
  # in docker (build) context, there is no .git directory
1051
- elif (Path(BASE_DIR) / ".git").exists():
1083
+ elif (BASE_DIR / ".git").exists():
1052
1084
  try:
1053
1085
  import git
1054
1086
  except ImportError:
@@ -1128,6 +1160,7 @@ CORS_ALLOW_ALL_ORIGINS = config(
1128
1160
  default=False,
1129
1161
  group="Cross-Origin-Resource-Sharing",
1130
1162
  help_text="allow cross-domain access from any client",
1163
+ add_to_docs="corsheaders",
1131
1164
  )
1132
1165
  CORS_ALLOWED_ORIGINS = config(
1133
1166
  "CORS_ALLOWED_ORIGINS",
@@ -1138,6 +1171,7 @@ CORS_ALLOWED_ORIGINS = config(
1138
1171
  "explicitly list the allowed origins for cross-domain requests. "
1139
1172
  "Example: http://localhost:3000,https://some-app.gemeente.nl"
1140
1173
  ),
1174
+ add_to_docs="corsheaders",
1141
1175
  )
1142
1176
  CORS_ALLOWED_ORIGIN_REGEXES = config(
1143
1177
  "CORS_ALLOWED_ORIGIN_REGEXES",
@@ -1145,7 +1179,9 @@ CORS_ALLOWED_ORIGIN_REGEXES = config(
1145
1179
  default=[],
1146
1180
  group="Cross-Origin-Resource-Sharing",
1147
1181
  help_text="same as ``CORS_ALLOWED_ORIGINS``, but supports regular expressions",
1182
+ add_to_docs="corsheaders",
1148
1183
  )
1184
+
1149
1185
  # Authorization is included in default_cors_headers
1150
1186
  CORS_ALLOW_HEADERS = (
1151
1187
  list(default_cors_headers)
@@ -1163,8 +1199,10 @@ CORS_ALLOW_HEADERS = (
1163
1199
  "By default, Authorization, Accept-Crs and Content-Crs are already included. "
1164
1200
  "The value of this variable is added to these already included headers."
1165
1201
  ),
1202
+ add_to_docs="corsheaders",
1166
1203
  )
1167
1204
  )
1205
+
1168
1206
  CORS_EXPOSE_HEADERS = [
1169
1207
  "content-crs",
1170
1208
  ]
@@ -1185,7 +1223,7 @@ CSRF_TRUSTED_ORIGINS = config(
1185
1223
  #
1186
1224
  # DJANGO-PRIVATES -- safely serve files after authorization
1187
1225
  #
1188
- PRIVATE_MEDIA_ROOT = Path(BASE_DIR) / "private-media"
1226
+ PRIVATE_MEDIA_ROOT = BASE_DIR / "private-media"
1189
1227
  PRIVATE_MEDIA_URL = "/private-media/"
1190
1228
 
1191
1229
 
@@ -1387,54 +1425,72 @@ LOG_OUTGOING_REQUESTS_MAX_AGE = config(
1387
1425
 
1388
1426
  def get_content_security_policy():
1389
1427
  # ideally we'd use BASE_URI but it'd have to be lazy or cause issues
1390
- csp_default_src = [SELF] + config(
1428
+ extra_default_src = config(
1391
1429
  "CSP_EXTRA_DEFAULT_SRC",
1392
1430
  default=[],
1393
1431
  split=True,
1394
1432
  group="Content Security Policy",
1395
1433
  help_text="Extra default source URLs for CSP other than ``self``. Used for ``img-src``, ``style-src`` and ``script-src``.",
1434
+ add_to_docs="csp",
1435
+ )
1436
+ extra_form_action = config(
1437
+ "CSP_EXTRA_FORM_ACTION",
1438
+ default=[],
1439
+ split=True,
1440
+ group="Content Security Policy",
1441
+ help_text="Additional `form-action` sources.",
1442
+ add_to_docs="csp",
1443
+ )
1444
+ form_action = config(
1445
+ "CSP_FORM_ACTION",
1446
+ default=["\"'self'\""] + extra_form_action,
1447
+ split=True,
1448
+ group="Content Security Policy",
1449
+ help_text="Override the default `form-action` sources.",
1450
+ add_to_docs="csp",
1396
1451
  )
1452
+ extra_img_src = config(
1453
+ "CSP_EXTRA_IMG_SRC",
1454
+ default=[],
1455
+ split=True,
1456
+ group="Content Security Policy",
1457
+ help_text="Extra `img-src` sources.",
1458
+ add_to_docs="csp",
1459
+ )
1460
+ object_src = config(
1461
+ "CSP_OBJECT_SRC",
1462
+ default=["\"'none'\""],
1463
+ split=True,
1464
+ group="Content Security Policy",
1465
+ help_text="`object-src` sources.",
1466
+ add_to_docs="csp",
1467
+ )
1468
+ report_uri = config(
1469
+ "CSP_REPORT_URI",
1470
+ None,
1471
+ group="Content Security Policy",
1472
+ help_text="URI for CSP report-uri directive.",
1473
+ add_to_docs="csp",
1474
+ )
1475
+ report_percentage = config(
1476
+ "CSP_REPORT_PERCENTAGE",
1477
+ 0.0,
1478
+ group="Content Security Policy",
1479
+ help_text="Fraction (between 0 and 1) of requests to include report-uri directive.",
1480
+ add_to_docs="csp",
1481
+ )
1482
+
1483
+ if not csp_installed:
1484
+ return {}
1485
+
1486
+ csp_default_src = [SELF] + extra_default_src
1397
1487
  return {
1398
1488
  "DIRECTIVES": {
1399
- "default-src": [SELF]
1400
- + config(
1401
- "CSP_EXTRA_DEFAULT_SRC",
1402
- default=[],
1403
- split=True,
1404
- group="Content Security Policy",
1405
- help_text="Extra default source URLs for CSP other than ``self``. Used for ``img-src``, ``style-src`` and ``script-src``.",
1406
- ),
1407
- "form-action": config(
1408
- "CSP_FORM_ACTION",
1409
- default=["\"'self'\""]
1410
- + config(
1411
- "CSP_EXTRA_FORM_ACTION",
1412
- default=[],
1413
- split=True,
1414
- group="Content Security Policy",
1415
- help_text="Additional `form-action` sources.",
1416
- ),
1417
- split=True,
1418
- group="Content Security Policy",
1419
- help_text="Override the default `form-action` sources.",
1420
- )
1421
- + CORS_ALLOWED_ORIGINS,
1422
- "img-src": csp_default_src
1423
- + ["data:", "cdn.redoc.ly"]
1424
- + config(
1425
- "CSP_EXTRA_IMG_SRC",
1426
- default=[],
1427
- split=True,
1428
- group="Content Security Policy",
1429
- help_text="Extra `img-src` sources.",
1430
- ),
1431
- "object-src": config(
1432
- "CSP_OBJECT_SRC",
1433
- default=["\"'none'\""],
1434
- split=True,
1435
- group="Content Security Policy",
1436
- help_text="`object-src` sources.",
1437
- ),
1489
+ "default-src": csp_default_src,
1490
+ "form-action": form_action
1491
+ + CORS_ALLOWED_ORIGINS, # XXX: not passed as default to prevent misconfig??
1492
+ "img-src": csp_default_src + ["data:", "cdn.redoc.ly"] + extra_img_src,
1493
+ "object-src": object_src,
1438
1494
  "style-src": csp_default_src
1439
1495
  + [NONCE, "'unsafe-inline'", "fonts.googleapis.com"],
1440
1496
  "script-src": csp_default_src + [NONCE, "'unsafe-inline'"],
@@ -1444,22 +1500,11 @@ def get_content_security_policy():
1444
1500
  "frame-ancestors": [NONE],
1445
1501
  "frame-src": [SELF],
1446
1502
  "upgrade-insecure-requests": False, # Enable only in production
1447
- "report-uri": config(
1448
- "CSP_REPORT_URI",
1449
- None,
1450
- group="Content Security Policy",
1451
- help_text="URI for CSP report-uri directive.",
1452
- ),
1503
+ "report-uri": report_uri,
1453
1504
  },
1454
1505
  # Envvar used for django-csp==3.8 was a float between 0 and 1, while django-csp==4.0
1455
1506
  # expects a percentage (between 0 and 100)
1456
- "REPORT_PERCENTAGE": config(
1457
- "CSP_REPORT_PERCENTAGE",
1458
- 0.0,
1459
- group="Content Security Policy",
1460
- help_text="Fraction (between 0 and 1) of requests to include report-uri directive.",
1461
- )
1462
- * 100,
1507
+ "REPORT_PERCENTAGE": report_percentage * 100,
1463
1508
  }
1464
1509
 
1465
1510
 
@@ -1,12 +1,14 @@
1
1
  import logging # noqa: TID251
2
2
  import sys
3
3
  from dataclasses import dataclass
4
+ from importlib.util import find_spec
4
5
  from pathlib import Path
5
- from typing import Any, Optional
6
+ from typing import Any, Optional, TypeVar, assert_never
6
7
  from urllib.parse import urlparse
8
+ from warnings import warn
7
9
 
8
10
  from decouple import Csv, Undefined, config as _config, undefined
9
- from sentry_sdk.integrations import DidNotEnable, django, redis
11
+ from sentry_sdk.integrations import DidNotEnable, Integration, django, redis
10
12
  from sentry_sdk.integrations.logging import LoggingIntegration
11
13
 
12
14
 
@@ -30,17 +32,19 @@ class EnvironmentVariable:
30
32
 
31
33
  ENVVAR_REGISTRY = []
32
34
 
35
+ _T = TypeVar("_T")
36
+
33
37
 
34
38
  def config(
35
39
  option: str,
36
- default: Any = undefined,
40
+ default: _T = undefined,
37
41
  help_text="",
38
42
  group=None,
39
- add_to_docs=True,
43
+ add_to_docs: str | bool = True,
40
44
  auto_display_default=True,
41
45
  *args,
42
46
  **kwargs,
43
- ):
47
+ ) -> _T:
44
48
  """
45
49
  An override of ``decouple.config``, with custom options to construct documentation
46
50
  for environment variables.
@@ -59,18 +63,21 @@ def config(
59
63
  :param help_text: The help text to be displayed for this variable in the documentation. Default `""`
60
64
  :param group: The name of the section under which this variable will be grouped. Default ``None``
61
65
  :param add_to_docs: Whether or not this variable will be displayed in the documentation. Default ``True``
66
+ If a string is passed, it will only be displayed if it is importable as a module,
67
+ and will raise a Warning when it is still passed in from the environment.
62
68
  :param auto_display_default: Whether or not the passed ``default`` value is displayed in the docs, this can be
63
69
  set to ``False`` in case a default needs more explanation that can be added to the ``help_text``
64
70
  (e.g. if it is computed or based on another variable). Default ``True``
65
71
  """
66
- if add_to_docs:
67
- variable = EnvironmentVariable(
68
- name=option,
69
- default=default,
70
- help_text=help_text,
71
- group=group,
72
- auto_display_default=auto_display_default,
73
- )
72
+ variable = EnvironmentVariable(
73
+ name=option,
74
+ default=default,
75
+ help_text=help_text,
76
+ group=group,
77
+ auto_display_default=auto_display_default,
78
+ )
79
+
80
+ def document():
74
81
  if variable not in ENVVAR_REGISTRY:
75
82
  ENVVAR_REGISTRY.append(variable)
76
83
  else:
@@ -85,19 +92,44 @@ def config(
85
92
 
86
93
  if default is not undefined and default is not None:
87
94
  kwargs.setdefault("cast", type(default))
88
- return _config(option, default=default, *args, **kwargs)
95
+
96
+ value = _config(option, default=default, *args, **kwargs)
97
+
98
+ match add_to_docs:
99
+ case str(module) if find_spec(module):
100
+ document()
101
+ case str(module):
102
+ if value is not default:
103
+ warn(
104
+ f"{variable.name} found, but required {add_to_docs} is not installed",
105
+ RuntimeWarning,
106
+ )
107
+ case True:
108
+ document()
109
+ case False:
110
+ pass
111
+ case _:
112
+ assert_never(add_to_docs)
113
+
114
+ return value # type: ignore
89
115
 
90
116
 
91
- def get_sentry_integrations() -> list:
117
+ def importable(*items: str) -> list[str]:
118
+ "Return the dotted paths that start from an installed package"
119
+
120
+ split_items = (item.split(".") for item in items)
121
+ return [".".join(item) for item in split_items if find_spec(item[0])]
122
+
123
+
124
+ def get_sentry_integrations() -> list[Integration]:
92
125
  """
93
126
  Determine which Sentry SDK integrations to enable.
94
127
  """
95
- default = [
96
- django.DjangoIntegration(),
97
- redis.RedisIntegration(),
98
- ]
99
128
  extra = []
100
129
 
130
+ if find_spec("redis"): # does not raise DidNotEnable if redis is not installed
131
+ extra.append(redis.RedisIntegration())
132
+
101
133
  try:
102
134
  from sentry_sdk.integrations import celery
103
135
  except DidNotEnable: # happens if the celery import fails by the integration
@@ -105,11 +137,7 @@ def get_sentry_integrations() -> list:
105
137
  else:
106
138
  extra.append(celery.CeleryIntegration())
107
139
 
108
- try:
109
- import structlog # type: ignore # noqa
110
- except ImportError:
111
- pass
112
- else:
140
+ if find_spec("structlog"):
113
141
  extra.append(
114
142
  LoggingIntegration(
115
143
  level=logging.INFO, # breadcrumbs
@@ -119,7 +147,7 @@ def get_sentry_integrations() -> list:
119
147
  ),
120
148
  )
121
149
 
122
- return [*default, *extra]
150
+ return [django.DjangoIntegration(), *extra]
123
151
 
124
152
 
125
153
  def strip_protocol_from_origin(origin: str) -> str:
@@ -131,10 +159,10 @@ def get_project_dirname() -> str:
131
159
  return config("DJANGO_SETTINGS_MODULE", add_to_docs=False).split(".")[0]
132
160
 
133
161
 
134
- def get_django_project_dir() -> str:
162
+ def get_django_project_dir() -> Path:
135
163
  # Get the path of the importing module
136
164
  base_dirname = get_project_dirname()
137
- return Path(sys.modules[base_dirname].__file__).parent
165
+ return Path(sys.modules[base_dirname].__file__).parent # pyright: ignore[reportArgumentType]
138
166
 
139
167
 
140
168
  def mute_logging(config: dict) -> None: # pragma: no cover
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-api-framework
3
- Version: 0.13.1
3
+ Version: 0.13.3
4
4
  Summary: A metapackage for registration components, that bundles the dependencies shared between these components and provides generic settings
5
5
  Author-email: Maykin Media <support@maykinmedia.nl>
6
6
  License: Copyright © Maykin 2024
@@ -96,7 +96,7 @@ Dynamic: license-file
96
96
  Open API Framework
97
97
  ==================
98
98
 
99
- :Version: 0.13.1
99
+ :Version: 0.13.3
100
100
  :Source: https://github.com/maykinmedia/open-api-framework
101
101
  :Keywords: metapackage, dependencies
102
102
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "open-api-framework"
7
- version = "0.13.1"
7
+ version = "0.13.3"
8
8
  description = "A metapackage for registration components, that bundles the dependencies shared between these components and provides generic settings"
9
9
  authors = [
10
10
  {name = "Maykin Media", email = "support@maykinmedia.nl"}
@@ -129,7 +129,7 @@ env = [
129
129
  ]
130
130
 
131
131
  [tool.bumpversion]
132
- current_version = "0.13.1"
132
+ current_version = "0.13.3"
133
133
  files = [
134
134
  {filename = "pyproject.toml"},
135
135
  {filename = "README.rst"},
@@ -0,0 +1,50 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from open_api_framework.conf.utils import (
6
+ ENVVAR_REGISTRY,
7
+ config,
8
+ get_django_project_dir,
9
+ )
10
+
11
+
12
+ def test_empty_list_as_default():
13
+ value = config("SOME_TEST_ENVVAR", split=True, default=[], add_to_docs=False)
14
+
15
+ assert value == []
16
+
17
+
18
+ def test_non_empty_list_as_default():
19
+ value = config("SOME_TEST_ENVVAR", split=True, default=["foo"], add_to_docs=False)
20
+
21
+ assert value == ["foo"]
22
+
23
+
24
+ def test_string_list_from_env(monkeypatch):
25
+ monkeypatch.setenv("SOME_TEST_ENVVAR", "foo,bar")
26
+
27
+ value = config("SOME_TEST_ENVVAR", split=True, default=["foo"], add_to_docs=False)
28
+
29
+ assert value == ["foo", "bar"]
30
+
31
+
32
+ def test_it_raises_warning_if_add_to_docs_module_is_not_present(monkeypatch):
33
+ monkeypatch.setenv("FOO_TEST_ENVVAR", "value")
34
+ with pytest.warns() as warnings:
35
+ value = config("FOO_TEST_ENVVAR", default="value", add_to_docs="foo_module")
36
+ assert value == "value"
37
+ assert not any(var.name == "FOO_TEST_VAR" for var in ENVVAR_REGISTRY)
38
+
39
+ # warning mentions key actionable info
40
+ assert "FOO_TEST_ENVVAR" in str(warnings[0])
41
+ assert "foo_module" in str(warnings[0])
42
+
43
+
44
+ def test_get_django_project_dir():
45
+ project_path = get_django_project_dir()
46
+ assert project_path.parts[-1] == "testapp"
47
+
48
+ # still compatible with os.path.join
49
+ settings = os.path.join(project_path, "settings.py")
50
+ assert os.path.exists(settings)
@@ -1,5 +1,12 @@
1
+ from importlib.util import find_spec
2
+
3
+ import pytest
4
+
1
5
  import testapp.settings
2
6
 
7
+ if not find_spec("csp"):
8
+ pytest.skip("no csp installed", allow_module_level=True)
9
+
3
10
 
4
11
  def test_csp_default(client):
5
12
  response = client.get("/dummy/")
@@ -1,3 +1,4 @@
1
+ from importlib.util import find_spec
1
2
  from unittest.mock import mock_open, patch
2
3
 
3
4
  from django.core.management import call_command
@@ -34,7 +35,7 @@ Database
34
35
  * ``DB_HOST``: hostname of the PostgreSQL database. Defaults to ``db`` for the docker environment, otherwise defaults to ``localhost``.
35
36
  * ``DB_PORT``: port number of the database. Defaults to: ``5432``.
36
37
  * ``DB_CONN_MAX_AGE``: The lifetime of a database connection, as an integer of seconds. Use 0 to close database connections at the end of each request — Django’s historical behavior. This setting is ignored if connection pooling is used. Defaults to: ``60``.
37
- * ``DB_POOL_ENABLED``: Whether to use connection pooling. Defaults to: ``False``.
38
+ * ``DB_POOL_ENABLED``: **Experimental:** Whether to use connection pooling. This feature is not yet recommended for production use. See the documentation for details: https://open-api-framework.readthedocs.io/en/latest/connection_pooling.html. Defaults to: ``False``.
38
39
  * ``DB_POOL_MIN_SIZE``: The minimum number of connection the pool will hold. The pool will actively try to create new connections if some are lost (closed, broken) and will try to never go below min_size. Defaults to: ``4``.
39
40
  * ``DB_POOL_MAX_SIZE``: The maximum number of connections the pool will hold. If None, or equal to min_size, the pool will not grow or shrink. If larger than min_size, the pool can grow if more than min_size connections are requested at the same time and will shrink back after the extra connections have been unused for more than max_idle seconds. Defaults to: ``None``.
40
41
  * ``DB_POOL_TIMEOUT``: The default maximum time in seconds that a client can wait to receive a connection from the pool (using connection() or getconn()). Note that these methods allow to override the timeout default. Defaults to: ``30``.
@@ -160,6 +161,8 @@ to define the envvars. The component will pick them up out of the box.
160
161
 
161
162
  def test_generate_envvar_docs():
162
163
  mock_file = mock_open()
164
+ extras_installed = bool(find_spec("csp"))
165
+
163
166
  with patch(
164
167
  "open_api_framework.management.commands.generate_envvar_docs.open", mock_file
165
168
  ):
@@ -174,4 +177,8 @@ def test_generate_envvar_docs():
174
177
  # Check the entire content written to the mock file
175
178
  written_content = "".join(call.args[0] for call in handle.write.call_args_list)
176
179
 
177
- assert written_content == EXPECTED_OUTPUT
180
+ if extras_installed:
181
+ assert written_content == EXPECTED_OUTPUT
182
+ else:
183
+ assert "Cross-Origin-Resource-Sharing" not in written_content
184
+ assert "Content Security Policy" not in written_content
@@ -1,9 +1,13 @@
1
+ from importlib.util import find_spec
2
+
1
3
  from django.conf import settings
2
4
  from django.urls import reverse
3
5
 
6
+ import pytest
4
7
  from django_webtest import WebTest
5
8
 
6
9
 
10
+ @pytest.mark.skipif(not find_spec("sentry_sdk"), reason="No sentry installed")
7
11
  def test_sentry_settings():
8
12
  """
9
13
  test that sentry settings are initialized
@@ -1,13 +0,0 @@
1
- from open_api_framework.conf.utils import config
2
-
3
-
4
- def test_empty_list_as_default():
5
- value = config("SOME_TEST_ENVVAR", split=True, default=[], add_to_docs=False)
6
-
7
- assert value == []
8
-
9
-
10
- def test_non_empty_list_as_default():
11
- value = config("SOME_TEST_ENVVAR", split=True, default=["foo"], add_to_docs=False)
12
-
13
- assert value == ["foo"]