c2cwsgiutils 1.0.0.dev1__tar.gz → 5.2.1.dev197__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 (67) hide show
  1. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/PKG-INFO +60 -30
  2. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/README.md +33 -7
  3. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/__init__.py +12 -12
  4. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/acceptance/connection.py +5 -2
  5. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/acceptance/image.py +94 -2
  6. c2cwsgiutils-5.2.1.dev197/c2cwsgiutils/acceptance/package-lock.json +1933 -0
  7. c2cwsgiutils-5.2.1.dev197/c2cwsgiutils/acceptance/package.json +7 -0
  8. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/acceptance/print.py +3 -3
  9. c2cwsgiutils-5.2.1.dev197/c2cwsgiutils/acceptance/screenshot.js +62 -0
  10. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/acceptance/utils.py +12 -20
  11. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/auth.py +4 -3
  12. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/broadcast/__init__.py +6 -6
  13. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/broadcast/interface.py +3 -2
  14. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/broadcast/local.py +3 -2
  15. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/broadcast/redis.py +4 -3
  16. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/client_info.py +5 -5
  17. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/config_utils.py +2 -1
  18. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/db.py +4 -2
  19. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/db_maintenance_view.py +2 -1
  20. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/debug/_listeners.py +5 -4
  21. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/debug/_views.py +11 -10
  22. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/debug/utils.py +5 -5
  23. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/health_check.py +10 -22
  24. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/index.py +90 -105
  25. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/loader.py +3 -3
  26. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/logging_view.py +3 -2
  27. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/models_graph.py +4 -4
  28. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/prometheus.py +16 -17
  29. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/pyramid_logging.py +2 -1
  30. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/redis_utils.py +3 -5
  31. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/request_tracking/__init__.py +5 -4
  32. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/scripts/genversion.py +4 -4
  33. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/scripts/stats_db.py +13 -14
  34. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sentry.py +2 -1
  35. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/setup_process.py +3 -3
  36. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sql_profiler/_impl.py +3 -2
  37. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sqlalchemylogger/_models.py +2 -2
  38. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sqlalchemylogger/handlers.py +3 -3
  39. c2cwsgiutils-5.2.1.dev197/c2cwsgiutils/static/favicon-16x16.png +0 -0
  40. c2cwsgiutils-5.2.1.dev197/c2cwsgiutils/static/favicon-32x32.png +0 -0
  41. c2cwsgiutils-5.2.1.dev197/c2cwsgiutils/templates/index.html.mako +50 -0
  42. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/version.py +5 -5
  43. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/pyproject.toml +68 -67
  44. c2cwsgiutils-1.0.0.dev1/c2cwsgiutils/acceptance/composition.py +0 -148
  45. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/LICENSE +0 -0
  46. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/acceptance/__init__.py +0 -0
  47. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/broadcast/utils.py +0 -0
  48. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/coverage_setup.py +0 -0
  49. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/debug/__init__.py +0 -0
  50. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/errors.py +0 -0
  51. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/pretty_json.py +0 -0
  52. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/profiler.py +0 -0
  53. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/py.typed +0 -0
  54. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/pyramid.py +0 -0
  55. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/redis_stats.py +0 -0
  56. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/request_tracking/_sql.py +0 -0
  57. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/scripts/__init__.py +0 -0
  58. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/scripts/test_print.py +0 -0
  59. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/services.py +0 -0
  60. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sql_profiler/__init__.py +0 -0
  61. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sqlalchemylogger/README.md +0 -0
  62. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sqlalchemylogger/__init__.py +0 -0
  63. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sqlalchemylogger/_filters.py +0 -0
  64. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/sqlalchemylogger/examples/example.py +0 -0
  65. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/stats_pyramid/__init__.py +0 -0
  66. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/stats_pyramid/_db_spy.py +0 -0
  67. {c2cwsgiutils-1.0.0.dev1 → c2cwsgiutils-5.2.1.dev197}/c2cwsgiutils/stats_pyramid/_pyramid_spy.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: c2cwsgiutils
3
- Version: 1.0.0.dev1
3
+ Version: 5.2.1.dev197
4
4
  Summary: Common utilities for Camptocamp WSGI applications
5
5
  Home-page: https://github.com/camptocamp/c2cwsgiutils
6
6
  License: BSD-2-Clause
7
7
  Keywords: geo,gis,sqlalchemy,orm,wsgi
8
8
  Author: Camptocamp
9
9
  Author-email: info@camptocamp.com
10
- Requires-Python: >=3.8,<4.0
10
+ Requires-Python: >=3.9,<4.0
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Environment :: Plugins
13
13
  Classifier: Framework :: Pyramid
@@ -17,44 +17,48 @@ Classifier: License :: OSI Approved :: BSD License
17
17
  Classifier: Operating System :: OS Independent
18
18
  Classifier: Programming Language :: Python
19
19
  Classifier: Programming Language :: Python :: 3
20
- Classifier: Programming Language :: Python :: 3.8
21
20
  Classifier: Programming Language :: Python :: 3.9
22
21
  Classifier: Programming Language :: Python :: 3.10
23
22
  Classifier: Programming Language :: Python :: 3.11
24
23
  Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application
25
24
  Classifier: Typing :: Typed
25
+ Provides-Extra: alembic
26
26
  Provides-Extra: all
27
27
  Provides-Extra: broadcast
28
+ Provides-Extra: debug
28
29
  Provides-Extra: dev
29
30
  Provides-Extra: oauth2
31
+ Provides-Extra: sentry
30
32
  Provides-Extra: standard
31
33
  Provides-Extra: test-images
32
- Requires-Dist: SQLAlchemy (>=1.4.0,<3.0.0) ; extra == "standard" or extra == "all"
33
- Requires-Dist: SQLAlchemy-Utils ; extra == "standard" or extra == "all"
34
- Requires-Dist: alembic ; extra == "standard" or extra == "all"
35
- Requires-Dist: boltons ; extra == "standard" or extra == "all"
36
- Requires-Dist: cee_syslog_handler ; extra == "standard" or extra == "all"
34
+ Provides-Extra: tests
35
+ Provides-Extra: webserver
36
+ Requires-Dist: SQLAlchemy (>=1.4.0,<3.0.0)
37
+ Requires-Dist: SQLAlchemy-Utils
38
+ Requires-Dist: alembic ; extra == "standard" or extra == "alembic" or extra == "all"
39
+ Requires-Dist: boltons ; extra == "tests" or extra == "all"
40
+ Requires-Dist: cee_syslog_handler
37
41
  Requires-Dist: certifi
38
- Requires-Dist: cornice ; extra == "standard" or extra == "all"
39
- Requires-Dist: gunicorn ; extra == "standard" or extra == "all"
40
- Requires-Dist: lxml ; extra == "standard" or extra == "all"
41
- Requires-Dist: netifaces ; extra == "standard" or extra == "all"
42
- Requires-Dist: objgraph ; extra == "standard" or extra == "all"
42
+ Requires-Dist: cornice
43
+ Requires-Dist: gunicorn ; extra == "standard" or extra == "webserver" or extra == "all"
44
+ Requires-Dist: lxml ; extra == "tests" or extra == "all"
45
+ Requires-Dist: objgraph ; extra == "debug" or extra == "all"
43
46
  Requires-Dist: prometheus-client
44
- Requires-Dist: psycopg2 ; extra == "standard" or extra == "all"
45
- Requires-Dist: pyjwt ; extra == "oauth2" or extra == "all"
46
- Requires-Dist: pyramid ; extra == "standard" or extra == "all"
47
- Requires-Dist: pyramid-tm ; extra == "standard" or extra == "all"
47
+ Requires-Dist: psycopg2
48
+ Requires-Dist: pyjwt ; extra == "standard" or extra == "oauth2" or extra == "all"
49
+ Requires-Dist: pyramid
50
+ Requires-Dist: pyramid-tm
51
+ Requires-Dist: pyramid_mako
48
52
  Requires-Dist: pyyaml
49
- Requires-Dist: redis ; extra == "standard" or extra == "broadcast" or extra == "all" or extra == "all"
53
+ Requires-Dist: redis ; extra == "standard" or extra == "broadcast" or extra == "all"
50
54
  Requires-Dist: requests
51
- Requires-Dist: requests-oauthlib ; extra == "oauth2" or extra == "all"
55
+ Requires-Dist: requests-oauthlib ; extra == "standard" or extra == "oauth2" or extra == "all"
52
56
  Requires-Dist: scikit-image ; extra == "test-images"
53
- Requires-Dist: sentry-sdk ; extra == "standard" or extra == "all"
54
- Requires-Dist: ujson ; extra == "standard" or extra == "all"
57
+ Requires-Dist: sentry-sdk ; extra == "standard" or extra == "sentry" or extra == "all"
58
+ Requires-Dist: ujson
55
59
  Requires-Dist: waitress ; extra == "dev" or extra == "all"
56
- Requires-Dist: zope.interface ; extra == "standard" or extra == "all"
57
- Requires-Dist: zope.sqlalchemy ; extra == "standard" or extra == "all"
60
+ Requires-Dist: zope.interface
61
+ Requires-Dist: zope.sqlalchemy
58
62
  Project-URL: Repository, https://github.com/camptocamp/c2cwsgiutils
59
63
  Description-Content-Type: text/markdown
60
64
 
@@ -120,7 +124,6 @@ ENV \
120
124
  LOG_TYPE=console \
121
125
  OTHER_LOG_LEVEL=WARNING \
122
126
  GUNICORN_LOG_LEVEL=WARNING \
123
- GUNICORN_ACCESS_LOG_LEVEL=INFO \
124
127
  SQL_LOG_LEVEL=WARNING \
125
128
  C2CWSGIUTILS_LOG_LEVEL=WARNING \
126
129
  LOG_LEVEL=INFO
@@ -159,8 +162,6 @@ The related environment variables:
159
162
  - `SQL_LOG_LEVEL`: The SQL query log level, `WARNING`: no logs, `INFO`: logs the queries,
160
163
  `DEBUG` also logs the results, default is `WARNING`.
161
164
  - `GUNICORN_ERROR_LOG_LEVEL`: The Gunicorn error log level, default is `WARNING`.
162
- - `GUNICORN_ACCESS_LOG_LEVEL`: The Gunicorn access log level, the logs have the level `INFO`,
163
- default is `WARNING`.
164
165
  - `C2CWSGIUTILS_CONFIG`: The fallback ini file to use by gunicorn, default is `production.ini`.
165
166
  - `C2CWSGIUTILS_LOG_LEVEL`: The c2c WSGI utils log level, default is `WARNING`.
166
167
  - `OTHER_LOG_LEVEL`: The log level for all the other logger, default is `WARNING`.
@@ -483,14 +484,14 @@ command line. Usually done in the [Dockerfile](acceptance_tests/app/Dockerfile)
483
484
  It will work in multi process mode with the limitation listed in the
484
485
  [`prometheus_client` documentation](https://github.com/prometheus/client_python#multiprocess-mode-eg-gunicorn).
485
486
 
486
- To enable it you should provide the `PROMETHEUS_PORT` environment variable.
487
+ To enable it you should provide the `C2CWSGIUTILS_PROMETHEUS_PORT` environment variable.
487
488
  For security reason, this port should not be exposed.
488
489
 
489
490
  We can customize it with the following environment variables:
490
491
 
491
- - `PROMETHEUS_PREFIX`: to customize the prefix, default is `c2cwsggiutils-`.
492
+ - `C2C_PROMETHEUS_PREFIX`: to customize the prefix, default is `c2cwsggiutils-`.
492
493
  - `C2C_PROMETHEUS_PACKAGES` the packages that will be present in the version information, default is `c2cwsgiutils,pyramid,gunicorn,sqlalchemy`.
493
- - `C2C_PROMETHEUS_APPLICATION_PACKAGES` the packages that will be present in the version information as application.
494
+ - `C2C_PROMETHEUS_APPLICATION_PACKAGE` the packages that will be present in the version information as application.
494
495
 
495
496
  And you should add in your `gunicorn.conf.py`:
496
497
 
@@ -524,7 +525,7 @@ In your `Dockerfile` you should add:
524
525
 
525
526
  ```dockerfile
526
527
  RUN mkdir -p /prometheus-metrics \
527
- chmod a+rwx /prometheus-metrics
528
+ && chmod a+rwx /prometheus-metrics
528
529
  ENV PROMETHEUS_MULTIPROC_DIR=/prometheus-metrics
529
530
  ```
530
531
 
@@ -757,6 +758,35 @@ To make a release:
757
758
  - Add the new branch name in the `.github/workflows/rebuild.yaml` and
758
759
  `.github/workflows/audit.yaml` files.
759
760
 
761
+ ## Testing
762
+
763
+ ### Screenshots
764
+
765
+ To test the screenshots, you need to install `node` with `npm`, to do that add the following lines in your `Dockerfile`:
766
+
767
+ ```dockerfile
768
+ RUN --mount=type=cache,target=/var/lib/apt/lists \
769
+ --mount=type=cache,target=/var/cache,sharing=locked \
770
+ . /etc/os-release \
771
+ && echo "deb https://deb.nodesource.com/node_18.x ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/nodesource.list \
772
+ && curl --silent https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \
773
+ && apt-get update \
774
+ && apt-get install --assume-yes --no-install-recommends 'nodejs=18.*'
775
+ ```
776
+
777
+ To do the image test call `check_screenshot` e.g.:
778
+
779
+ ```python
780
+ def test_screenshot(app_connection):
781
+ image.check_screenshot(
782
+ app_connection.base_url + "my-path",
783
+ width=800,
784
+ height=600,
785
+ result_folder="results",
786
+ expected_filename=os.path.join(os.path.dirname(__file__), "my-check.expected.png"),
787
+ )
788
+ ```
789
+
760
790
  ## Contributing
761
791
 
762
792
  Install the pre-commit hooks:
@@ -60,7 +60,6 @@ ENV \
60
60
  LOG_TYPE=console \
61
61
  OTHER_LOG_LEVEL=WARNING \
62
62
  GUNICORN_LOG_LEVEL=WARNING \
63
- GUNICORN_ACCESS_LOG_LEVEL=INFO \
64
63
  SQL_LOG_LEVEL=WARNING \
65
64
  C2CWSGIUTILS_LOG_LEVEL=WARNING \
66
65
  LOG_LEVEL=INFO
@@ -99,8 +98,6 @@ The related environment variables:
99
98
  - `SQL_LOG_LEVEL`: The SQL query log level, `WARNING`: no logs, `INFO`: logs the queries,
100
99
  `DEBUG` also logs the results, default is `WARNING`.
101
100
  - `GUNICORN_ERROR_LOG_LEVEL`: The Gunicorn error log level, default is `WARNING`.
102
- - `GUNICORN_ACCESS_LOG_LEVEL`: The Gunicorn access log level, the logs have the level `INFO`,
103
- default is `WARNING`.
104
101
  - `C2CWSGIUTILS_CONFIG`: The fallback ini file to use by gunicorn, default is `production.ini`.
105
102
  - `C2CWSGIUTILS_LOG_LEVEL`: The c2c WSGI utils log level, default is `WARNING`.
106
103
  - `OTHER_LOG_LEVEL`: The log level for all the other logger, default is `WARNING`.
@@ -423,14 +420,14 @@ command line. Usually done in the [Dockerfile](acceptance_tests/app/Dockerfile)
423
420
  It will work in multi process mode with the limitation listed in the
424
421
  [`prometheus_client` documentation](https://github.com/prometheus/client_python#multiprocess-mode-eg-gunicorn).
425
422
 
426
- To enable it you should provide the `PROMETHEUS_PORT` environment variable.
423
+ To enable it you should provide the `C2CWSGIUTILS_PROMETHEUS_PORT` environment variable.
427
424
  For security reason, this port should not be exposed.
428
425
 
429
426
  We can customize it with the following environment variables:
430
427
 
431
- - `PROMETHEUS_PREFIX`: to customize the prefix, default is `c2cwsggiutils-`.
428
+ - `C2C_PROMETHEUS_PREFIX`: to customize the prefix, default is `c2cwsggiutils-`.
432
429
  - `C2C_PROMETHEUS_PACKAGES` the packages that will be present in the version information, default is `c2cwsgiutils,pyramid,gunicorn,sqlalchemy`.
433
- - `C2C_PROMETHEUS_APPLICATION_PACKAGES` the packages that will be present in the version information as application.
430
+ - `C2C_PROMETHEUS_APPLICATION_PACKAGE` the packages that will be present in the version information as application.
434
431
 
435
432
  And you should add in your `gunicorn.conf.py`:
436
433
 
@@ -464,7 +461,7 @@ In your `Dockerfile` you should add:
464
461
 
465
462
  ```dockerfile
466
463
  RUN mkdir -p /prometheus-metrics \
467
- chmod a+rwx /prometheus-metrics
464
+ && chmod a+rwx /prometheus-metrics
468
465
  ENV PROMETHEUS_MULTIPROC_DIR=/prometheus-metrics
469
466
  ```
470
467
 
@@ -697,6 +694,35 @@ To make a release:
697
694
  - Add the new branch name in the `.github/workflows/rebuild.yaml` and
698
695
  `.github/workflows/audit.yaml` files.
699
696
 
697
+ ## Testing
698
+
699
+ ### Screenshots
700
+
701
+ To test the screenshots, you need to install `node` with `npm`, to do that add the following lines in your `Dockerfile`:
702
+
703
+ ```dockerfile
704
+ RUN --mount=type=cache,target=/var/lib/apt/lists \
705
+ --mount=type=cache,target=/var/cache,sharing=locked \
706
+ . /etc/os-release \
707
+ && echo "deb https://deb.nodesource.com/node_18.x ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/nodesource.list \
708
+ && curl --silent https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - \
709
+ && apt-get update \
710
+ && apt-get install --assume-yes --no-install-recommends 'nodejs=18.*'
711
+ ```
712
+
713
+ To do the image test call `check_screenshot` e.g.:
714
+
715
+ ```python
716
+ def test_screenshot(app_connection):
717
+ image.check_screenshot(
718
+ app_connection.base_url + "my-path",
719
+ width=800,
720
+ height=600,
721
+ result_folder="results",
722
+ expected_filename=os.path.join(os.path.dirname(__file__), "my-check.expected.png"),
723
+ )
724
+ ```
725
+
700
726
  ## Contributing
701
727
 
702
728
  Install the pre-commit hooks:
@@ -4,12 +4,12 @@ import os
4
4
  import re
5
5
  import sys
6
6
  from configparser import SectionProxy
7
- from typing import Any, Dict, Set
7
+ from typing import Any
8
8
 
9
9
  LOG = logging.getLogger(__name__)
10
10
 
11
11
 
12
- def get_config_defaults() -> Dict[str, str]:
12
+ def get_config_defaults() -> dict[str, str]:
13
13
  """
14
14
  Get the environment variables as defaults for configparser.
15
15
 
@@ -18,8 +18,8 @@ def get_config_defaults() -> Dict[str, str]:
18
18
 
19
19
  configparser interpretate the % then we need to escape them
20
20
  """
21
- result: Dict[str, str] = {}
22
- lowercase_keys: Set[str] = set()
21
+ result: dict[str, str] = {}
22
+ lowercase_keys: set[str] = set()
23
23
  for key, value in os.environ.items():
24
24
  if key.lower() in lowercase_keys:
25
25
  LOG.warning("The environment variable '%s' is duplicated with different case, ignoring", key)
@@ -29,9 +29,9 @@ def get_config_defaults() -> Dict[str, str]:
29
29
  return result
30
30
 
31
31
 
32
- def _create_handlers(config: configparser.ConfigParser) -> Dict[str, Any]:
32
+ def _create_handlers(config: configparser.ConfigParser) -> dict[str, Any]:
33
33
  handlers = [k.strip() for k in config["handlers"]["keys"].split(",")]
34
- d_handlers: Dict[str, Any] = {}
34
+ d_handlers: dict[str, Any] = {}
35
35
  stream_re = re.compile(r"\((.*?),\)")
36
36
  for hh in handlers:
37
37
  block = config[f"handler_{hh}"]
@@ -53,8 +53,8 @@ def _create_handlers(config: configparser.ConfigParser) -> Dict[str, Any]:
53
53
  return d_handlers
54
54
 
55
55
 
56
- def _filter_logger(block: SectionProxy) -> Dict[str, Any]:
57
- out: Dict[str, Any] = {"level": block["level"]}
56
+ def _filter_logger(block: SectionProxy) -> dict[str, Any]:
57
+ out: dict[str, Any] = {"level": block["level"]}
58
58
  handlers = block.get("handlers", "")
59
59
  if handlers != "":
60
60
  out["handlers"] = [block["handlers"]]
@@ -65,7 +65,7 @@ def _filter_logger(block: SectionProxy) -> Dict[str, Any]:
65
65
  # logging configuration
66
66
  # https://docs.python.org/3/library/logging.config.html#logging-config-dictschema
67
67
  ###
68
- def get_logconfig_dict(filename: str) -> Dict[str, Any]:
68
+ def get_logconfig_dict(filename: str) -> dict[str, Any]:
69
69
  """
70
70
  Create a logconfig dictionary based on the provided ini file.
71
71
 
@@ -76,8 +76,8 @@ def get_logconfig_dict(filename: str) -> Dict[str, Any]:
76
76
  loggers = [k.strip() for k in config["loggers"]["keys"].split(",")]
77
77
  formatters = [k.strip() for k in config["formatters"]["keys"].split(",")]
78
78
 
79
- d_loggers: Dict[str, Any] = {}
80
- root: Dict[str, Any] = {}
79
+ d_loggers: dict[str, Any] = {}
80
+ root: dict[str, Any] = {}
81
81
  for ll in loggers:
82
82
  block = config[f"logger_{ll}"]
83
83
  if ll == "root":
@@ -86,7 +86,7 @@ def get_logconfig_dict(filename: str) -> Dict[str, Any]:
86
86
  qualname = block["qualname"]
87
87
  d_loggers[qualname] = _filter_logger(block)
88
88
 
89
- d_formatters: Dict[str, Any] = {}
89
+ d_formatters: dict[str, Any] = {}
90
90
  for ff in formatters:
91
91
  block = config[f"formatter_{ff}"]
92
92
  d_formatters[ff] = {
@@ -1,9 +1,9 @@
1
1
  import re
2
+ from collections.abc import Mapping, MutableMapping
2
3
  from enum import Enum
3
- from typing import Any, Mapping, MutableMapping, Optional, Union
4
+ from typing import Any, Optional, Union
4
5
 
5
6
  import requests
6
- from lxml import etree # nosec
7
7
 
8
8
  COLON_SPLIT_RE = re.compile(r"\s*,\s*")
9
9
 
@@ -82,6 +82,9 @@ class Connection:
82
82
  **kwargs: Any,
83
83
  ) -> Any:
84
84
  """Get the given URL (relative to the root of API)."""
85
+
86
+ from lxml import etree # nosec
87
+
85
88
  with self.session.get(
86
89
  self.base_url + url,
87
90
  headers=self._merge_headers(headers, cors),
@@ -1,4 +1,6 @@
1
+ import json
1
2
  import os
3
+ import subprocess # nosec
2
4
  from typing import TYPE_CHECKING, Any, Optional
3
5
 
4
6
  import numpy as np
@@ -42,6 +44,20 @@ def check_image_file(
42
44
  check_image(result_folder, result, expected_filename, level, generate_expected_image, use_mask)
43
45
 
44
46
 
47
+ def normalize_image(image: NpNdarrayInt) -> NpNdarrayInt:
48
+ """
49
+ Normalize the image to be comparable.
50
+
51
+ - Remove the alpha channel
52
+ - Convert to uint8
53
+ """
54
+ if len(image.shape) == 3 and image.shape[2] == 4:
55
+ image = skimage.color.rgba2rgb(image)
56
+ if np.issubdtype(image.dtype, np.floating):
57
+ image = (image * 255).astype("uint8")
58
+ return image
59
+
60
+
45
61
  def check_image(
46
62
  result_folder: str,
47
63
  image_to_check: NpNdarrayInt,
@@ -80,11 +96,30 @@ def check_image(
80
96
  result_filename = os.path.join(result_folder, f"{image_file_basename}.result.png")
81
97
  diff_filename = os.path.join(result_folder, f"{image_file_basename}.diff.png")
82
98
 
99
+ image_to_check = normalize_image(image_to_check)
100
+
83
101
  mask = None
84
102
  if mask_filename is not None:
85
103
  mask = skimage.io.imread(mask_filename)
104
+
86
105
  assert mask is not None, "Wrong mask: " + mask_filename
87
- image_to_check[mask == 0] = [255, 255, 255]
106
+
107
+ # Normalize the mask
108
+ if len(mask.shape) == 3 and mask.shape[2] == 3:
109
+ mask = skimage.color.rgb2gray(mask)
110
+
111
+ if len(mask.shape) == 3 and mask.shape[2] == 4:
112
+ mask = skimage.color.rgba2gray(mask)
113
+
114
+ if np.issubdtype(mask.dtype, np.floating):
115
+ mask = (mask * 255).astype("uint8")
116
+
117
+ assert ((0 < mask) & (mask < 255)).sum() == 0, "Mask should be only black and white image"
118
+
119
+ # Convert to boolean
120
+ mask = mask == 0
121
+
122
+ image_to_check[mask] = [255, 255, 255]
88
123
 
89
124
  if not os.path.exists(result_folder):
90
125
  os.makedirs(result_folder)
@@ -97,9 +132,10 @@ def check_image(
97
132
  assert False, "Expected image not found: " + expected_filename
98
133
  expected = skimage.io.imread(expected_filename)
99
134
  assert expected is not None, "Wrong image: " + expected_filename
135
+ expected = normalize_image(expected)
100
136
 
101
137
  if mask is not None:
102
- expected[mask == 0] = [255, 255, 255]
138
+ expected[mask] = [255, 255, 255]
103
139
 
104
140
  score, diff = skimage.metrics.structural_similarity(
105
141
  expected, image_to_check, multichannel=True, full=True, channel_axis=2
@@ -115,3 +151,59 @@ def check_image(
115
151
  assert (
116
152
  score >= level
117
153
  ), f"{result_filename} != {expected_filename} => {diff_filename} ({score} < {level})"
154
+
155
+
156
+ def check_screenshot(
157
+ url: str,
158
+ result_folder: str,
159
+ expected_filename: str,
160
+ width: int = 800,
161
+ height: int = 600,
162
+ headers: Optional[dict[str, str]] = None,
163
+ media: Optional[list[dict[str, str]]] = None,
164
+ level: float = 1.0,
165
+ generate_expected_image: bool = False,
166
+ use_mask: bool = True,
167
+ ) -> None:
168
+ """
169
+ Test that the screenshot of the `url` corresponds to the image `expected_filename`.
170
+
171
+ Requires nodejs with puppeteer and commander to be installed.
172
+ """
173
+
174
+ if headers is None:
175
+ headers = {}
176
+ if media is None:
177
+ media = []
178
+
179
+ if not os.path.exists(os.path.join(os.path.dirname(__file__), "node_modules")):
180
+ subprocess.run(["npm", "install"], cwd=os.path.dirname(__file__), check=True) # nosec
181
+
182
+ image_file_basename = os.path.splitext(os.path.basename(expected_filename))[0]
183
+ if image_file_basename.endswith(".expected"):
184
+ image_file_basename = os.path.splitext(image_file_basename)[0]
185
+
186
+ result_folder = os.path.abspath(result_folder)
187
+ actual_filename = os.path.join(result_folder, f"{image_file_basename}.actual.png")
188
+ subprocess.run( # nosec
189
+ [
190
+ "node",
191
+ "screenshot.js",
192
+ f"--url={url}",
193
+ f"--width={width}",
194
+ f"--height={height}",
195
+ f"--headers={json.dumps(headers)}",
196
+ f"--media={json.dumps(media)}",
197
+ f"--output={actual_filename}",
198
+ ],
199
+ cwd=os.path.dirname(__file__),
200
+ check=True,
201
+ )
202
+ check_image(
203
+ result_folder,
204
+ skimage.io.imread(actual_filename)[:, :, :3],
205
+ expected_filename,
206
+ level,
207
+ generate_expected_image,
208
+ use_mask,
209
+ )