OpenGeodeWeb-Back 6.15.1__tar.gz → 6.15.2__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 (119) hide show
  1. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/PKG-INFO +1 -1
  2. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/pyproject.toml +1 -1
  3. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/OpenGeodeWeb_Back.egg-info/PKG-INFO +1 -1
  4. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/OpenGeodeWeb_Back.egg-info/SOURCES.txt +1 -0
  5. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/app.py +53 -12
  6. opengeodeweb_back-6.15.2/tests/test_events.py +51 -0
  7. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/LICENSE +0 -0
  8. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/README.md +0 -0
  9. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/requirements.txt +0 -0
  10. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/setup.cfg +0 -0
  11. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/OpenGeodeWeb_Back.egg-info/dependency_links.txt +0 -0
  12. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/OpenGeodeWeb_Back.egg-info/entry_points.txt +0 -0
  13. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/OpenGeodeWeb_Back.egg-info/requires.txt +0 -0
  14. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/OpenGeodeWeb_Back.egg-info/top_level.txt +0 -0
  15. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/__init__.py +0 -0
  16. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/app_config.py +0 -0
  17. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_functions.py +0 -0
  18. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/__init__.py +0 -0
  19. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_brep.py +0 -0
  20. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_cross_section.py +0 -0
  21. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_edged_curve2d.py +0 -0
  22. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_edged_curve3d.py +0 -0
  23. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_graph.py +0 -0
  24. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_grid2d.py +0 -0
  25. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_grid3d.py +0 -0
  26. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_horizon_stack3d.py +0 -0
  27. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_hybrid_solid3d.py +0 -0
  28. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_implicit_cross_section.py +0 -0
  29. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_implicit_structural_model.py +0 -0
  30. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_light_regular_grid2d.py +0 -0
  31. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_light_regular_grid3d.py +0 -0
  32. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_mesh.py +0 -0
  33. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_model.py +0 -0
  34. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_object.py +0 -0
  35. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_point_set2d.py +0 -0
  36. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_point_set3d.py +0 -0
  37. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_polygonal_surface2d.py +0 -0
  38. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_polygonal_surface3d.py +0 -0
  39. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_polyhedral_solid3d.py +0 -0
  40. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_raster_image2d.py +0 -0
  41. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_raster_image3d.py +0 -0
  42. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_regular_grid2d.py +0 -0
  43. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_regular_grid3d.py +0 -0
  44. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_section.py +0 -0
  45. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_solid_mesh3d.py +0 -0
  46. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_structural_model.py +0 -0
  47. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_surface_mesh2d.py +0 -0
  48. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_surface_mesh3d.py +0 -0
  49. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_tetrahedral_solid3d.py +0 -0
  50. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_triangulated_surface2d.py +0 -0
  51. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_triangulated_surface3d.py +0 -0
  52. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/geode_objects/geode_vertex_set.py +0 -0
  53. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/py.typed +0 -0
  54. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/__init__.py +0 -0
  55. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/blueprint_routes.py +0 -0
  56. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/__init__.py +0 -0
  57. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/blueprint_create.py +0 -0
  58. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/__init__.py +0 -0
  59. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/edged_curve.json +0 -0
  60. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/edged_curve.py +0 -0
  61. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/point_set.json +0 -0
  62. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/point_set.py +0 -0
  63. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.json +0 -0
  64. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/create/schemas/polygonal_surface.py +0 -0
  65. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/__init__.py +0 -0
  66. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/allowed_files.json +0 -0
  67. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/allowed_files.py +0 -0
  68. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/allowed_objects.json +0 -0
  69. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/allowed_objects.py +0 -0
  70. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/cell_attribute_names.json +0 -0
  71. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/cell_attribute_names.py +0 -0
  72. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/edge_attribute_names.json +0 -0
  73. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/edge_attribute_names.py +0 -0
  74. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/export_project.json +0 -0
  75. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/export_project.py +0 -0
  76. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/geode_object_inheritance.json +0 -0
  77. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/geode_object_inheritance.py +0 -0
  78. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/geode_objects_and_output_extensions.json +0 -0
  79. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/geode_objects_and_output_extensions.py +0 -0
  80. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.json +0 -0
  81. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/geographic_coordinate_systems.py +0 -0
  82. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/import_project.json +0 -0
  83. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/import_project.py +0 -0
  84. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/kill.json +0 -0
  85. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/kill.py +0 -0
  86. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/missing_files.json +0 -0
  87. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/missing_files.py +0 -0
  88. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_edge_attribute_names.json +0 -0
  89. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_edge_attribute_names.py +0 -0
  90. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_polygon_attribute_names.json +0 -0
  91. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_polygon_attribute_names.py +0 -0
  92. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_polyhedron_attribute_names.json +0 -0
  93. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_polyhedron_attribute_names.py +0 -0
  94. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_vertex_attribute_names.json +0 -0
  95. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/model_component_vertex_attribute_names.py +0 -0
  96. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/ping.json +0 -0
  97. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/ping.py +0 -0
  98. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/polygon_attribute_names.json +0 -0
  99. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/polygon_attribute_names.py +0 -0
  100. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/polyhedron_attribute_names.json +0 -0
  101. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/polyhedron_attribute_names.py +0 -0
  102. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/save_viewable_file.json +0 -0
  103. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/save_viewable_file.py +0 -0
  104. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/texture_coordinates.json +0 -0
  105. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/texture_coordinates.py +0 -0
  106. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/upload_file.json +0 -0
  107. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/upload_file.py +0 -0
  108. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/validate.json +0 -0
  109. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/validate.py +0 -0
  110. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/vertex_attribute_names.json +0 -0
  111. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/routes/schemas/vertex_attribute_names.py +0 -0
  112. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/test_utils.py +0 -0
  113. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/typed_route.py +0 -0
  114. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/src/opengeodeweb_back/utils_functions.py +0 -0
  115. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/tests/test_create_routes.py +0 -0
  116. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/tests/test_geode_functions.py +0 -0
  117. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/tests/test_routes.py +0 -0
  118. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/tests/test_typed_routes.py +0 -0
  119. {opengeodeweb_back-6.15.1 → opengeodeweb_back-6.15.2}/tests/test_utils_functions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenGeodeWeb-Back
3
- Version: 6.15.1
3
+ Version: 6.15.2
4
4
  Summary: OpenGeodeWeb-Back is an open source framework that proposes handy python functions and wrappers for the OpenGeode ecosystem
5
5
  Author-email: Geode-solutions <team-web@geode-solutions.com>
6
6
  Project-URL: Homepage, https://github.com/Geode-solutions/OpenGeodeWeb-Back
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "OpenGeodeWeb-Back"
7
- version = "6.15.1"
7
+ version = "6.15.2"
8
8
  dynamic = ["dependencies"]
9
9
  authors = [{ name = "Geode-solutions", email = "team-web@geode-solutions.com" }]
10
10
  description = "OpenGeodeWeb-Back is an open source framework that proposes handy python functions and wrappers for the OpenGeode ecosystem"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: OpenGeodeWeb-Back
3
- Version: 6.15.1
3
+ Version: 6.15.2
4
4
  Summary: OpenGeodeWeb-Back is an open source framework that proposes handy python functions and wrappers for the OpenGeode ecosystem
5
5
  Author-email: Geode-solutions <team-web@geode-solutions.com>
6
6
  Project-URL: Homepage, https://github.com/Geode-solutions/OpenGeodeWeb-Back
@@ -110,6 +110,7 @@ src/opengeodeweb_back/routes/schemas/validate.py
110
110
  src/opengeodeweb_back/routes/schemas/vertex_attribute_names.json
111
111
  src/opengeodeweb_back/routes/schemas/vertex_attribute_names.py
112
112
  tests/test_create_routes.py
113
+ tests/test_events.py
113
114
  tests/test_geode_functions.py
114
115
  tests/test_routes.py
115
116
  tests/test_typed_routes.py
@@ -6,6 +6,7 @@ import argparse
6
6
  import json
7
7
  import logging
8
8
  import queue
9
+ import threading
9
10
  from pathlib import Path
10
11
  from typing import TYPE_CHECKING, Any
11
12
 
@@ -25,6 +26,9 @@ if TYPE_CHECKING:
25
26
 
26
27
  logger = logging.getLogger(__name__)
27
28
 
29
+ # Below nginx's default 60 s proxy_read_timeout, which closes idle streams
30
+ EVENTS_KEEP_ALIVE_SECONDS = 15
31
+
28
32
 
29
33
  def _register_request_hooks(app: flask.Flask) -> None:
30
34
  @app.before_request
@@ -41,25 +45,60 @@ def _register_request_hooks(app: flask.Flask) -> None:
41
45
  utils_functions.teardown_request(flask.current_app, exception)
42
46
 
43
47
 
48
+ type _Event = tuple[str, dict[str, Any]]
49
+
50
+
51
+ # One queue per connected stream: with a single shared queue, a stream left over from a
52
+ # Dropped connection would take events meant for the others.
53
+ class _EventListeners:
54
+ def __init__(self) -> None:
55
+ self._queues: set[queue.Queue[_Event]] = set()
56
+ self._lock = threading.Lock()
57
+
58
+ def publish(self, event: str, data: dict[str, Any]) -> None:
59
+ with self._lock:
60
+ for listener in self._queues:
61
+ listener.put((event, data))
62
+
63
+ def stream(self, keep_alive_seconds: float) -> flask.Response:
64
+ listener: queue.Queue[_Event] = queue.Queue()
65
+ with self._lock:
66
+ self._queues.add(listener)
67
+
68
+ def unregister() -> None:
69
+ with self._lock:
70
+ self._queues.discard(listener)
71
+
72
+ response = flask.Response(
73
+ _stream_events(listener, keep_alive_seconds), mimetype="text/event-stream"
74
+ )
75
+ response.call_on_close(unregister)
76
+ return response
77
+
78
+
79
+ def _stream_events(listener: queue.Queue[_Event], keep_alive_seconds: float) -> Generator[str]:
80
+ while True:
81
+ try:
82
+ event, data = listener.get(timeout=keep_alive_seconds)
83
+ except queue.Empty:
84
+ # Comment line, ignored by EventSource: keeps proxies from closing the idle
85
+ # Stream and lets a dropped connection be detected
86
+ yield ": keep-alive\n\n"
87
+ continue
88
+ yield f"event: {event}\ndata: {json.dumps(data)}\n\n"
89
+
90
+
44
91
  def _register_event_stream(app: flask.Flask) -> None:
45
92
  def wants_event_stream() -> bool:
46
93
  accept = flask.request.headers.get("Accept", "")
47
94
  return "text/event-stream" in accept
48
95
 
49
- _event_queue: queue.Queue[tuple[str, dict[str, Any]]] = queue.Queue()
50
-
51
- def publish_event(event: str, data: dict[str, Any]) -> None:
52
- _event_queue.put((event, data))
53
-
54
- def stream_events() -> Generator[str]:
55
- while True:
56
- event, data = _event_queue.get()
57
- yield f"event: {event}\ndata: {json.dumps(data)}\n\n"
96
+ listeners = _EventListeners()
58
97
 
59
98
  @app.after_request
60
99
  def after_request(response: flask.Response) -> flask.Response:
61
100
  endpoint = flask.request.endpoint.replace(".", "/") if flask.request.endpoint else None
62
- if endpoint in {"events", None}:
101
+ if endpoint is None or endpoint == "events":
63
102
  return response
64
103
 
65
104
  if wants_event_stream():
@@ -68,12 +107,14 @@ def _register_event_stream(app: flask.Flask) -> None:
68
107
  payload = response.get_json()
69
108
  except ValueError:
70
109
  payload = {"status": response.status_code}
71
- publish_event(endpoint, payload)
110
+ listeners.publish(endpoint, payload)
72
111
  return response
73
112
 
74
113
  @app.route("/events")
75
114
  def events() -> flask.Response:
76
- return flask.Response(stream_events(), mimetype="text/event-stream")
115
+ return listeners.stream(
116
+ flask.current_app.config.get("EVENTS_KEEP_ALIVE_SECONDS", EVENTS_KEEP_ALIVE_SECONDS)
117
+ )
77
118
 
78
119
 
79
120
  def _register_error_handlers(app: flask.Flask) -> None:
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ # Standard library imports
4
+ import queue
5
+ import threading
6
+ import time
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from flask.testing import FlaskClient
11
+
12
+ CHUNK_TIMEOUT_SECONDS = 2.0
13
+ LISTENERS_CONNECT_SECONDS = 0.5
14
+
15
+
16
+ # Opens a new event stream and reads its first chunk in a daemon thread: the test client
17
+ # Already reads that chunk inside client.get, which blocks until something is published.
18
+ def _read_next_chunk(client: FlaskClient) -> queue.Queue[bytes]:
19
+ result: queue.Queue[bytes] = queue.Queue()
20
+
21
+ def read() -> None:
22
+ result.put(next(client.get("/events").iter_encoded()))
23
+
24
+ threading.Thread(target=read, daemon=True).start()
25
+ return result
26
+
27
+
28
+ def _chunk(result: queue.Queue[bytes]) -> str | None:
29
+ try:
30
+ return result.get(timeout=CHUNK_TIMEOUT_SECONDS).decode()
31
+ except queue.Empty:
32
+ return None
33
+
34
+
35
+ def test_every_listener_receives_each_event(client: FlaskClient) -> None:
36
+ # A listener left over from a dropped connection must not take events meant for the others
37
+ first = _read_next_chunk(client)
38
+ second = _read_next_chunk(client)
39
+ time.sleep(LISTENERS_CONNECT_SECONDS)
40
+ client.get("/health", headers={"Accept": "text/event-stream"})
41
+ assert _chunk(first) == "event: health\ndata: {}\n\n"
42
+ assert _chunk(second) == "event: health\ndata: {}\n\n"
43
+
44
+
45
+ def test_idle_stream_sends_keep_alive(client: FlaskClient) -> None:
46
+ app = client.application
47
+ app.config["EVENTS_KEEP_ALIVE_SECONDS"] = 0.1
48
+ try:
49
+ assert _chunk(_read_next_chunk(client)) == ": keep-alive\n\n"
50
+ finally:
51
+ app.config.pop("EVENTS_KEEP_ALIVE_SECONDS")