open-api-framework 0.13.2__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.2 → open_api_framework-0.13.3}/CHANGELOG.rst +11 -0
  2. {open_api_framework-0.13.2/open_api_framework.egg-info → open_api_framework-0.13.3}/PKG-INFO +2 -2
  3. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/README.rst +1 -1
  4. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/conf/base.py +176 -136
  5. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/conf/utils.py +55 -27
  6. {open_api_framework-0.13.2 → open_api_framework-0.13.3/open_api_framework.egg-info}/PKG-INFO +2 -2
  7. {open_api_framework-0.13.2 → 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.2 → open_api_framework-0.13.3}/tests/test_csp.py +7 -0
  10. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/tests/test_generate_envvar_docs.py +8 -1
  11. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/tests/test_settings.py +4 -0
  12. open_api_framework-0.13.2/tests/test_config_helpers.py +0 -13
  13. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/LICENSE +0 -0
  14. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/MANIFEST.in +0 -0
  15. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/__init__.py +0 -0
  16. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/admin.py +0 -0
  17. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/apps.py +0 -0
  18. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/conf/__init__.py +0 -0
  19. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/context_processors.py +0 -0
  20. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/management/__init__.py +0 -0
  21. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/management/commands/__init__.py +0 -0
  22. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/management/commands/generate_envvar_docs.py +0 -0
  23. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/py.typed +0 -0
  24. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/static/open_api_framework/css/admin.css +0 -0
  25. {open_api_framework-0.13.2 → 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.2 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/components/environment.html +0 -0
  27. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/components/version.html +0 -0
  28. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/templates/open_api_framework/env_config.rst +0 -0
  29. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/templatetags/__init__.py +0 -0
  30. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/templatetags/doc_tags.py +0 -0
  31. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/templatetags/open_api_framework.py +0 -0
  32. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework/utils.py +0 -0
  33. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework.egg-info/SOURCES.txt +0 -0
  34. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework.egg-info/dependency_links.txt +0 -0
  35. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework.egg-info/requires.txt +0 -0
  36. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/open_api_framework.egg-info/top_level.txt +0 -0
  37. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/setup.cfg +0 -0
  38. {open_api_framework-0.13.2 → open_api_framework-0.13.3}/tests/test_admin.py +0 -0
@@ -1,6 +1,17 @@
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
+
4
15
  0.13.2 (2025-11-13)
5
16
  -------------------
6
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-api-framework
3
- Version: 0.13.2
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.2
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.2
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
  #
@@ -307,53 +317,55 @@ if DB_POOL_ENABLED:
307
317
  # https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
308
318
  DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
309
319
 
310
- CACHE_DEFAULT = config(
311
- "CACHE_DEFAULT",
312
- "localhost:6379/0",
313
- help_text="redis cache address for the default cache (this **MUST** be set when using Docker)",
314
- group="Required",
315
- )
316
- CACHE_AXES = config(
317
- "CACHE_AXES",
318
- "localhost:6379/0",
319
- help_text=(
320
- "redis cache address for the brute force login protection cache "
321
- "(this **MUST** be set when using Docker)"
322
- ),
323
- group="Required",
324
- )
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
+ )
325
336
 
326
- CACHES = {
327
- "default": {
328
- "BACKEND": "django_redis.cache.RedisCache",
329
- "LOCATION": f"redis://{CACHE_DEFAULT}",
330
- "OPTIONS": {
331
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
332
- "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
+ },
333
345
  },
334
- },
335
- "axes": {
336
- "BACKEND": "django_redis.cache.RedisCache",
337
- "LOCATION": f"redis://{CACHE_AXES}",
338
- "OPTIONS": {
339
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
340
- "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
+ },
341
353
  },
342
- },
343
- "oidc": {
344
- "BACKEND": "django_redis.cache.RedisCache",
345
- "LOCATION": f"redis://{CACHE_DEFAULT}",
346
- "OPTIONS": {
347
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
348
- "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
+ },
349
361
  },
350
- },
351
- }
362
+ }
363
+
352
364
 
353
365
  #
354
366
  # APPLICATIONS enabled for this project
355
367
  #
356
- INSTALLED_APPS = [
368
+ INSTALLED_APPS = importable(
357
369
  # Note: contenttypes should be first, see Django ticket #10827
358
370
  "django.contrib.contenttypes",
359
371
  "django.contrib.auth",
@@ -396,9 +408,9 @@ INSTALLED_APPS = [
396
408
  PROJECT_DIRNAME,
397
409
  # Django libraries
398
410
  "upgrade_check",
399
- ]
411
+ )
400
412
 
401
- MIDDLEWARE = [
413
+ MIDDLEWARE = importable(
402
414
  "django.middleware.security.SecurityMiddleware",
403
415
  "sessionprofile.middleware.SessionProfileMiddleware",
404
416
  "django.contrib.sessions.middleware.SessionMiddleware",
@@ -411,7 +423,7 @@ MIDDLEWARE = [
411
423
  "django.middleware.clickjacking.XFrameOptionsMiddleware",
412
424
  "axes.middleware.AxesMiddleware",
413
425
  "csp.contrib.rate_limiting.RateLimitedCSPMiddleware",
414
- ]
426
+ )
415
427
 
416
428
  ROOT_URLCONF = f"{PROJECT_DIRNAME}.urls"
417
429
 
@@ -424,7 +436,7 @@ TEMPLATE_LOADERS = (
424
436
  TEMPLATES = [
425
437
  {
426
438
  "BACKEND": "django.template.backends.django.DjangoTemplates",
427
- "DIRS": [Path(DJANGO_PROJECT_DIR) / "templates"],
439
+ "DIRS": [DJANGO_PROJECT_DIR / "templates"],
428
440
  "APP_DIRS": False, # conflicts with explicity specifying the loaders
429
441
  "OPTIONS": {
430
442
  "context_processors": [
@@ -443,7 +455,7 @@ TEMPLATES = [
443
455
  WSGI_APPLICATION = f"{PROJECT_DIRNAME}.wsgi.application"
444
456
 
445
457
  # Translations
446
- LOCALE_PATHS = (Path(DJANGO_PROJECT_DIR) / "conf" / "locale",)
458
+ LOCALE_PATHS = (DJANGO_PROJECT_DIR / "conf" / "locale",)
447
459
 
448
460
  #
449
461
  # SERVING of static and media files
@@ -451,10 +463,10 @@ LOCALE_PATHS = (Path(DJANGO_PROJECT_DIR) / "conf" / "locale",)
451
463
 
452
464
  STATIC_URL = "/static/"
453
465
 
454
- STATIC_ROOT = Path(BASE_DIR) / "static"
466
+ STATIC_ROOT = BASE_DIR / "static"
455
467
 
456
468
  # Additional locations of static files
457
- STATICFILES_DIRS = [Path(DJANGO_PROJECT_DIR) / "static"]
469
+ STATICFILES_DIRS = [DJANGO_PROJECT_DIR / "static"]
458
470
 
459
471
  # List of finder classes that know how to find static files in
460
472
  # various locations.
@@ -463,7 +475,7 @@ STATICFILES_FINDERS = [
463
475
  "django.contrib.staticfiles.finders.AppDirectoriesFinder",
464
476
  ]
465
477
 
466
- MEDIA_ROOT = Path(BASE_DIR) / "media"
478
+ MEDIA_ROOT = BASE_DIR / "media"
467
479
 
468
480
  MEDIA_URL = "/media/"
469
481
 
@@ -568,6 +580,7 @@ CELERY_LOGLEVEL = config(
568
580
  help_text="control the verbosity of logging output for celery, independent of ``LOG_LEVEL``."
569
581
  " Available values are ``CRITICAL``, ``ERROR``, ``WARNING``, ``INFO`` and ``DEBUG``",
570
582
  group="Celery",
583
+ add_to_docs="celery",
571
584
  )
572
585
 
573
586
  _USE_STRUCTLOG = config("_USE_STRUCTLOG", default=False, add_to_docs=False)
@@ -578,10 +591,11 @@ ENABLE_STRUCTLOG_REQUESTS = config(
578
591
  default=True,
579
592
  help_text=("enable structured logging of requests"),
580
593
  group="Logging",
594
+ add_to_docs="django_structlog",
581
595
  )
582
596
 
583
597
 
584
- LOGGING_DIR = Path(BASE_DIR) / "log"
598
+ LOGGING_DIR = BASE_DIR / "log"
585
599
 
586
600
  if _USE_STRUCTLOG:
587
601
  import structlog
@@ -668,7 +682,7 @@ if _USE_STRUCTLOG:
668
682
  "json_file": {
669
683
  "level": LOG_LEVEL, # always debug might be better?
670
684
  "class": "logging.handlers.RotatingFileHandler",
671
- "filename": Path(LOGGING_DIR) / "application.jsonl",
685
+ "filename": LOGGING_DIR / "application.jsonl",
672
686
  "formatter": "json",
673
687
  "maxBytes": 1024 * 1024 * 10, # 10 MB
674
688
  "backupCount": 10,
@@ -676,7 +690,7 @@ if _USE_STRUCTLOG:
676
690
  "performance": {
677
691
  "level": "INFO",
678
692
  "class": "logging.handlers.RotatingFileHandler",
679
- "filename": Path(LOGGING_DIR) / "performance.log",
693
+ "filename": LOGGING_DIR / "performance.log",
680
694
  "formatter": "performance",
681
695
  "maxBytes": 1024 * 1024 * 10, # 10 MB
682
696
  "backupCount": 10,
@@ -684,7 +698,7 @@ if _USE_STRUCTLOG:
684
698
  "requests": {
685
699
  "level": "DEBUG",
686
700
  "class": "logging.handlers.RotatingFileHandler",
687
- "filename": Path(LOGGING_DIR) / "requests.log",
701
+ "filename": LOGGING_DIR / "requests.log",
688
702
  "formatter": "timestamped",
689
703
  "maxBytes": 1024 * 1024 * 10, # 10 MB
690
704
  "backupCount": 10,
@@ -825,23 +839,10 @@ else:
825
839
  "class": "logging.StreamHandler",
826
840
  "formatter": "db",
827
841
  },
828
- "celery_console": {
829
- "level": CELERY_LOGLEVEL,
830
- "class": "logging.StreamHandler",
831
- "formatter": "timestamped",
832
- },
833
- "celery_file": {
834
- "level": CELERY_LOGLEVEL,
835
- "class": "logging.handlers.RotatingFileHandler",
836
- "filename": Path(LOGGING_DIR) / "celery.log",
837
- "formatter": "verbose",
838
- "maxBytes": 1024 * 1024 * 10, # 10 MB
839
- "backupCount": 10,
840
- },
841
842
  "django": {
842
843
  "level": LOG_LEVEL,
843
844
  "class": "logging.handlers.RotatingFileHandler",
844
- "filename": Path(LOGGING_DIR) / "django.log",
845
+ "filename": LOGGING_DIR / "django.log",
845
846
  "formatter": "verbose",
846
847
  "maxBytes": 1024 * 1024 * 10, # 10 MB
847
848
  "backupCount": 10,
@@ -849,7 +850,7 @@ else:
849
850
  "project": {
850
851
  "level": LOG_LEVEL,
851
852
  "class": "logging.handlers.RotatingFileHandler",
852
- "filename": Path(LOGGING_DIR) / f"{PROJECT_DIRNAME}.log",
853
+ "filename": LOGGING_DIR / f"{PROJECT_DIRNAME}.log",
853
854
  "formatter": "verbose",
854
855
  "maxBytes": 1024 * 1024 * 10, # 10 MB
855
856
  "backupCount": 10,
@@ -857,7 +858,7 @@ else:
857
858
  "performance": {
858
859
  "level": "INFO",
859
860
  "class": "logging.handlers.RotatingFileHandler",
860
- "filename": Path(LOGGING_DIR) / "performance.log",
861
+ "filename": LOGGING_DIR / "performance.log",
861
862
  "formatter": "performance",
862
863
  "maxBytes": 1024 * 1024 * 10, # 10 MB
863
864
  "backupCount": 10,
@@ -865,7 +866,7 @@ else:
865
866
  "requests": {
866
867
  "level": "DEBUG",
867
868
  "class": "logging.handlers.RotatingFileHandler",
868
- "filename": Path(LOGGING_DIR) / "requests.log",
869
+ "filename": LOGGING_DIR / "requests.log",
869
870
  "formatter": "timestamped",
870
871
  "maxBytes": 1024 * 1024 * 10, # 10 MB
871
872
  "backupCount": 10,
@@ -880,7 +881,26 @@ else:
880
881
  # enabling saving to database
881
882
  "class": "log_outgoing_requests.handlers.DatabaseOutgoingRequestsHandler",
882
883
  },
883
- },
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
+ ),
884
904
  "loggers": {
885
905
  "": {
886
906
  "handlers": logging_root_handlers,
@@ -930,18 +950,25 @@ else:
930
950
  "level": "DEBUG",
931
951
  "propagate": True,
932
952
  },
933
- "celery": {
934
- "handlers": ["celery_console"] if LOG_STDOUT else ["celery_file"],
935
- "level": CELERY_LOGLEVEL,
936
- "propagate": True,
937
- },
938
- },
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
+ ),
939
965
  }
940
966
 
941
967
  #
942
968
  # AUTH settings - user accounts, passwords, backends...
943
969
  #
944
- AUTH_USER_MODEL = "accounts.User"
970
+ if find_spec(f"{PROJECT_DIRNAME}.accounts"):
971
+ AUTH_USER_MODEL = "accounts.User"
945
972
 
946
973
  # Password validation
947
974
  # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
@@ -1053,7 +1080,7 @@ if subpath:
1053
1080
  if "GIT_SHA" in os.environ:
1054
1081
  GIT_SHA = config("GIT_SHA", "", add_to_docs=False)
1055
1082
  # in docker (build) context, there is no .git directory
1056
- elif (Path(BASE_DIR) / ".git").exists():
1083
+ elif (BASE_DIR / ".git").exists():
1057
1084
  try:
1058
1085
  import git
1059
1086
  except ImportError:
@@ -1133,6 +1160,7 @@ CORS_ALLOW_ALL_ORIGINS = config(
1133
1160
  default=False,
1134
1161
  group="Cross-Origin-Resource-Sharing",
1135
1162
  help_text="allow cross-domain access from any client",
1163
+ add_to_docs="corsheaders",
1136
1164
  )
1137
1165
  CORS_ALLOWED_ORIGINS = config(
1138
1166
  "CORS_ALLOWED_ORIGINS",
@@ -1143,6 +1171,7 @@ CORS_ALLOWED_ORIGINS = config(
1143
1171
  "explicitly list the allowed origins for cross-domain requests. "
1144
1172
  "Example: http://localhost:3000,https://some-app.gemeente.nl"
1145
1173
  ),
1174
+ add_to_docs="corsheaders",
1146
1175
  )
1147
1176
  CORS_ALLOWED_ORIGIN_REGEXES = config(
1148
1177
  "CORS_ALLOWED_ORIGIN_REGEXES",
@@ -1150,7 +1179,9 @@ CORS_ALLOWED_ORIGIN_REGEXES = config(
1150
1179
  default=[],
1151
1180
  group="Cross-Origin-Resource-Sharing",
1152
1181
  help_text="same as ``CORS_ALLOWED_ORIGINS``, but supports regular expressions",
1182
+ add_to_docs="corsheaders",
1153
1183
  )
1184
+
1154
1185
  # Authorization is included in default_cors_headers
1155
1186
  CORS_ALLOW_HEADERS = (
1156
1187
  list(default_cors_headers)
@@ -1168,8 +1199,10 @@ CORS_ALLOW_HEADERS = (
1168
1199
  "By default, Authorization, Accept-Crs and Content-Crs are already included. "
1169
1200
  "The value of this variable is added to these already included headers."
1170
1201
  ),
1202
+ add_to_docs="corsheaders",
1171
1203
  )
1172
1204
  )
1205
+
1173
1206
  CORS_EXPOSE_HEADERS = [
1174
1207
  "content-crs",
1175
1208
  ]
@@ -1190,7 +1223,7 @@ CSRF_TRUSTED_ORIGINS = config(
1190
1223
  #
1191
1224
  # DJANGO-PRIVATES -- safely serve files after authorization
1192
1225
  #
1193
- PRIVATE_MEDIA_ROOT = Path(BASE_DIR) / "private-media"
1226
+ PRIVATE_MEDIA_ROOT = BASE_DIR / "private-media"
1194
1227
  PRIVATE_MEDIA_URL = "/private-media/"
1195
1228
 
1196
1229
 
@@ -1392,54 +1425,72 @@ LOG_OUTGOING_REQUESTS_MAX_AGE = config(
1392
1425
 
1393
1426
  def get_content_security_policy():
1394
1427
  # ideally we'd use BASE_URI but it'd have to be lazy or cause issues
1395
- csp_default_src = [SELF] + config(
1428
+ extra_default_src = config(
1396
1429
  "CSP_EXTRA_DEFAULT_SRC",
1397
1430
  default=[],
1398
1431
  split=True,
1399
1432
  group="Content Security Policy",
1400
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",
1401
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",
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
1402
1487
  return {
1403
1488
  "DIRECTIVES": {
1404
- "default-src": [SELF]
1405
- + config(
1406
- "CSP_EXTRA_DEFAULT_SRC",
1407
- default=[],
1408
- split=True,
1409
- group="Content Security Policy",
1410
- help_text="Extra default source URLs for CSP other than ``self``. Used for ``img-src``, ``style-src`` and ``script-src``.",
1411
- ),
1412
- "form-action": config(
1413
- "CSP_FORM_ACTION",
1414
- default=["\"'self'\""]
1415
- + config(
1416
- "CSP_EXTRA_FORM_ACTION",
1417
- default=[],
1418
- split=True,
1419
- group="Content Security Policy",
1420
- help_text="Additional `form-action` sources.",
1421
- ),
1422
- split=True,
1423
- group="Content Security Policy",
1424
- help_text="Override the default `form-action` sources.",
1425
- )
1426
- + CORS_ALLOWED_ORIGINS,
1427
- "img-src": csp_default_src
1428
- + ["data:", "cdn.redoc.ly"]
1429
- + config(
1430
- "CSP_EXTRA_IMG_SRC",
1431
- default=[],
1432
- split=True,
1433
- group="Content Security Policy",
1434
- help_text="Extra `img-src` sources.",
1435
- ),
1436
- "object-src": config(
1437
- "CSP_OBJECT_SRC",
1438
- default=["\"'none'\""],
1439
- split=True,
1440
- group="Content Security Policy",
1441
- help_text="`object-src` sources.",
1442
- ),
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,
1443
1494
  "style-src": csp_default_src
1444
1495
  + [NONCE, "'unsafe-inline'", "fonts.googleapis.com"],
1445
1496
  "script-src": csp_default_src + [NONCE, "'unsafe-inline'"],
@@ -1449,22 +1500,11 @@ def get_content_security_policy():
1449
1500
  "frame-ancestors": [NONE],
1450
1501
  "frame-src": [SELF],
1451
1502
  "upgrade-insecure-requests": False, # Enable only in production
1452
- "report-uri": config(
1453
- "CSP_REPORT_URI",
1454
- None,
1455
- group="Content Security Policy",
1456
- help_text="URI for CSP report-uri directive.",
1457
- ),
1503
+ "report-uri": report_uri,
1458
1504
  },
1459
1505
  # Envvar used for django-csp==3.8 was a float between 0 and 1, while django-csp==4.0
1460
1506
  # expects a percentage (between 0 and 100)
1461
- "REPORT_PERCENTAGE": config(
1462
- "CSP_REPORT_PERCENTAGE",
1463
- 0.0,
1464
- group="Content Security Policy",
1465
- help_text="Fraction (between 0 and 1) of requests to include report-uri directive.",
1466
- )
1467
- * 100,
1507
+ "REPORT_PERCENTAGE": report_percentage * 100,
1468
1508
  }
1469
1509
 
1470
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.2
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.2
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.2"
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.2"
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
@@ -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"]