OpenFisca-Core-test 46.0.0__py3-none-any.whl

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 (237) hide show
  1. openfisca_core/__init__.py +0 -0
  2. openfisca_core/commons/__init__.py +18 -0
  3. openfisca_core/commons/dummy.py +25 -0
  4. openfisca_core/commons/formulas.py +121 -0
  5. openfisca_core/commons/misc.py +109 -0
  6. openfisca_core/commons/py.typed +0 -0
  7. openfisca_core/commons/rates.py +114 -0
  8. openfisca_core/commons/tests/__init__.py +0 -0
  9. openfisca_core/commons/tests/test_dummy.py +9 -0
  10. openfisca_core/commons/tests/test_formulas.py +75 -0
  11. openfisca_core/commons/tests/test_rates.py +26 -0
  12. openfisca_core/data_storage/__init__.py +7 -0
  13. openfisca_core/data_storage/in_memory_storage.py +198 -0
  14. openfisca_core/data_storage/on_disk_storage.py +307 -0
  15. openfisca_core/data_storage/types.py +14 -0
  16. openfisca_core/entities/__init__.py +23 -0
  17. openfisca_core/entities/_core_entity.py +217 -0
  18. openfisca_core/entities/_description.py +55 -0
  19. openfisca_core/entities/entity.py +60 -0
  20. openfisca_core/entities/group_entity.py +123 -0
  21. openfisca_core/entities/helpers.py +165 -0
  22. openfisca_core/entities/py.typed +0 -0
  23. openfisca_core/entities/role.py +92 -0
  24. openfisca_core/entities/tests/__init__.py +0 -0
  25. openfisca_core/entities/tests/test_entity.py +10 -0
  26. openfisca_core/entities/tests/test_group_entity.py +70 -0
  27. openfisca_core/entities/tests/test_role.py +11 -0
  28. openfisca_core/entities/types.py +44 -0
  29. openfisca_core/errors/__init__.py +64 -0
  30. openfisca_core/errors/cycle_error.py +2 -0
  31. openfisca_core/errors/empty_argument_error.py +39 -0
  32. openfisca_core/errors/nan_creation_error.py +2 -0
  33. openfisca_core/errors/parameter_not_found_error.py +16 -0
  34. openfisca_core/errors/parameter_parsing_error.py +18 -0
  35. openfisca_core/errors/period_mismatch_error.py +9 -0
  36. openfisca_core/errors/situation_parsing_error.py +26 -0
  37. openfisca_core/errors/spiral_error.py +2 -0
  38. openfisca_core/errors/variable_name_config_error.py +2 -0
  39. openfisca_core/errors/variable_not_found_error.py +29 -0
  40. openfisca_core/experimental/__init__.py +9 -0
  41. openfisca_core/experimental/_errors.py +5 -0
  42. openfisca_core/experimental/_memory_config.py +41 -0
  43. openfisca_core/formula_helpers.py +9 -0
  44. openfisca_core/holders/__init__.py +33 -0
  45. openfisca_core/holders/helpers.py +93 -0
  46. openfisca_core/holders/holder.py +328 -0
  47. openfisca_core/holders/tests/__init__.py +0 -0
  48. openfisca_core/holders/tests/test_helpers.py +134 -0
  49. openfisca_core/holders/types.py +3 -0
  50. openfisca_core/indexed_enums/__init__.py +18 -0
  51. openfisca_core/indexed_enums/_enum_type.py +79 -0
  52. openfisca_core/indexed_enums/_errors.py +35 -0
  53. openfisca_core/indexed_enums/_guards.py +209 -0
  54. openfisca_core/indexed_enums/_utils.py +180 -0
  55. openfisca_core/indexed_enums/config.py +6 -0
  56. openfisca_core/indexed_enums/enum.py +218 -0
  57. openfisca_core/indexed_enums/enum_array.py +316 -0
  58. openfisca_core/indexed_enums/py.typed +0 -0
  59. openfisca_core/indexed_enums/tests/__init__.py +0 -0
  60. openfisca_core/indexed_enums/tests/test_enum.py +135 -0
  61. openfisca_core/indexed_enums/tests/test_enum_array.py +30 -0
  62. openfisca_core/indexed_enums/types.py +49 -0
  63. openfisca_core/memory_config.py +9 -0
  64. openfisca_core/model_api.py +71 -0
  65. openfisca_core/parameters/__init__.py +71 -0
  66. openfisca_core/parameters/at_instant_like.py +17 -0
  67. openfisca_core/parameters/config.py +55 -0
  68. openfisca_core/parameters/helpers.py +106 -0
  69. openfisca_core/parameters/parameter.py +217 -0
  70. openfisca_core/parameters/parameter_at_instant.py +75 -0
  71. openfisca_core/parameters/parameter_node.py +176 -0
  72. openfisca_core/parameters/parameter_node_at_instant.py +57 -0
  73. openfisca_core/parameters/parameter_scale.py +121 -0
  74. openfisca_core/parameters/parameter_scale_bracket.py +7 -0
  75. openfisca_core/parameters/values_history.py +5 -0
  76. openfisca_core/parameters/vectorial_parameter_node_at_instant.py +179 -0
  77. openfisca_core/periods/__init__.py +77 -0
  78. openfisca_core/periods/_errors.py +28 -0
  79. openfisca_core/periods/_parsers.py +122 -0
  80. openfisca_core/periods/config.py +20 -0
  81. openfisca_core/periods/date_unit.py +110 -0
  82. openfisca_core/periods/helpers.py +314 -0
  83. openfisca_core/periods/instant_.py +226 -0
  84. openfisca_core/periods/period_.py +919 -0
  85. openfisca_core/periods/py.typed +0 -0
  86. openfisca_core/periods/tests/__init__.py +0 -0
  87. openfisca_core/periods/tests/helpers/__init__.py +0 -0
  88. openfisca_core/periods/tests/helpers/test_helpers.py +65 -0
  89. openfisca_core/periods/tests/helpers/test_instant.py +73 -0
  90. openfisca_core/periods/tests/helpers/test_period.py +134 -0
  91. openfisca_core/periods/tests/test_instant.py +32 -0
  92. openfisca_core/periods/tests/test_parsers.py +129 -0
  93. openfisca_core/periods/tests/test_period.py +283 -0
  94. openfisca_core/populations/__init__.py +64 -0
  95. openfisca_core/populations/_core_population.py +454 -0
  96. openfisca_core/populations/_errors.py +65 -0
  97. openfisca_core/populations/group_population.py +326 -0
  98. openfisca_core/populations/population.py +142 -0
  99. openfisca_core/populations/tests/__init__.py +0 -0
  100. openfisca_core/populations/tests/test_members_position.py +116 -0
  101. openfisca_core/populations/types.py +110 -0
  102. openfisca_core/projectors/__init__.py +39 -0
  103. openfisca_core/projectors/entity_to_person_projector.py +12 -0
  104. openfisca_core/projectors/first_person_to_entity_projector.py +13 -0
  105. openfisca_core/projectors/helpers.py +139 -0
  106. openfisca_core/projectors/projector.py +38 -0
  107. openfisca_core/projectors/typing.py +27 -0
  108. openfisca_core/projectors/unique_role_to_entity_projector.py +14 -0
  109. openfisca_core/rates.py +9 -0
  110. openfisca_core/reforms/__init__.py +24 -0
  111. openfisca_core/reforms/in_yaml_test_reform.py +28 -0
  112. openfisca_core/reforms/reform.py +86 -0
  113. openfisca_core/scripts/__init__.py +106 -0
  114. openfisca_core/scripts/__pycache__/__init__.cpython-310-pytest-8.3.3.pyc +0 -0
  115. openfisca_core/scripts/__pycache__/__init__.cpython-310-pytest-8.4.2.pyc +0 -0
  116. openfisca_core/scripts/__pycache__/__init__.cpython-310.pyc +0 -0
  117. openfisca_core/scripts/__pycache__/__init__.cpython-311-pytest-8.3.3.pyc +0 -0
  118. openfisca_core/scripts/__pycache__/__init__.cpython-311-pytest-8.4.2.pyc +0 -0
  119. openfisca_core/scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  120. openfisca_core/scripts/__pycache__/__init__.cpython-312-pytest-8.4.2.pyc +0 -0
  121. openfisca_core/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  122. openfisca_core/scripts/__pycache__/__init__.cpython-313-pytest-8.3.3.pyc +0 -0
  123. openfisca_core/scripts/__pycache__/__init__.cpython-313-pytest-8.4.2.pyc +0 -0
  124. openfisca_core/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  125. openfisca_core/scripts/__pycache__/find_placeholders.cpython-311.pyc +0 -0
  126. openfisca_core/scripts/__pycache__/find_placeholders.cpython-313.pyc +0 -0
  127. openfisca_core/scripts/__pycache__/measure_numpy_condition_notations.cpython-311.pyc +0 -0
  128. openfisca_core/scripts/__pycache__/measure_numpy_condition_notations.cpython-313.pyc +0 -0
  129. openfisca_core/scripts/__pycache__/measure_performances.cpython-311.pyc +0 -0
  130. openfisca_core/scripts/__pycache__/measure_performances.cpython-313.pyc +0 -0
  131. openfisca_core/scripts/__pycache__/measure_performances_fancy_indexing.cpython-311.pyc +0 -0
  132. openfisca_core/scripts/__pycache__/measure_performances_fancy_indexing.cpython-313.pyc +0 -0
  133. openfisca_core/scripts/__pycache__/openfisca_command.cpython-310.pyc +0 -0
  134. openfisca_core/scripts/__pycache__/openfisca_command.cpython-311.pyc +0 -0
  135. openfisca_core/scripts/__pycache__/openfisca_command.cpython-312.pyc +0 -0
  136. openfisca_core/scripts/__pycache__/openfisca_command.cpython-313.pyc +0 -0
  137. openfisca_core/scripts/__pycache__/remove_fuzzy.cpython-311.pyc +0 -0
  138. openfisca_core/scripts/__pycache__/remove_fuzzy.cpython-313.pyc +0 -0
  139. openfisca_core/scripts/__pycache__/run_test.cpython-310.pyc +0 -0
  140. openfisca_core/scripts/__pycache__/run_test.cpython-311.pyc +0 -0
  141. openfisca_core/scripts/__pycache__/run_test.cpython-312.pyc +0 -0
  142. openfisca_core/scripts/__pycache__/run_test.cpython-313.pyc +0 -0
  143. openfisca_core/scripts/__pycache__/simulation_generator.cpython-311.pyc +0 -0
  144. openfisca_core/scripts/__pycache__/simulation_generator.cpython-313.pyc +0 -0
  145. openfisca_core/scripts/assets/__init__.py +0 -0
  146. openfisca_core/scripts/assets/__pycache__/__init__.cpython-311.pyc +0 -0
  147. openfisca_core/scripts/assets/__pycache__/__init__.cpython-313.pyc +0 -0
  148. openfisca_core/scripts/assets/index.html +140 -0
  149. openfisca_core/scripts/find_placeholders.py +51 -0
  150. openfisca_core/scripts/measure_numpy_condition_notations.py +137 -0
  151. openfisca_core/scripts/measure_performances.py +291 -0
  152. openfisca_core/scripts/measure_performances_fancy_indexing.py +89 -0
  153. openfisca_core/scripts/migrations/__init__.py +0 -0
  154. openfisca_core/scripts/migrations/__pycache__/__init__.cpython-311.pyc +0 -0
  155. openfisca_core/scripts/migrations/__pycache__/__init__.cpython-313.pyc +0 -0
  156. openfisca_core/scripts/migrations/__pycache__/v24_to_25.cpython-311.pyc +0 -0
  157. openfisca_core/scripts/migrations/__pycache__/v24_to_25.cpython-313.pyc +0 -0
  158. openfisca_core/scripts/migrations/v16_2_to_v17/__init__.py +0 -0
  159. openfisca_core/scripts/migrations/v16_2_to_v17/__pycache__/__init__.cpython-311.pyc +0 -0
  160. openfisca_core/scripts/migrations/v16_2_to_v17/__pycache__/__init__.cpython-313.pyc +0 -0
  161. openfisca_core/scripts/migrations/v16_2_to_v17/__pycache__/xml_to_yaml_country_template.cpython-311.pyc +0 -0
  162. openfisca_core/scripts/migrations/v16_2_to_v17/__pycache__/xml_to_yaml_country_template.cpython-313.pyc +0 -0
  163. openfisca_core/scripts/migrations/v16_2_to_v17/__pycache__/xml_to_yaml_extension_template.cpython-311.pyc +0 -0
  164. openfisca_core/scripts/migrations/v16_2_to_v17/__pycache__/xml_to_yaml_extension_template.cpython-313.pyc +0 -0
  165. openfisca_core/scripts/migrations/v16_2_to_v17/legislation.xsd +102 -0
  166. openfisca_core/scripts/migrations/v16_2_to_v17/xml_to_yaml_country_template.py +30 -0
  167. openfisca_core/scripts/migrations/v16_2_to_v17/xml_to_yaml_extension_template.py +26 -0
  168. openfisca_core/scripts/migrations/v24_to_25.py +156 -0
  169. openfisca_core/scripts/openfisca_command.py +192 -0
  170. openfisca_core/scripts/remove_fuzzy.py +213 -0
  171. openfisca_core/scripts/run_test.py +51 -0
  172. openfisca_core/scripts/simulation_generator.py +90 -0
  173. openfisca_core/simulation_builder.py +16 -0
  174. openfisca_core/simulations/__init__.py +45 -0
  175. openfisca_core/simulations/_build_default_simulation.py +158 -0
  176. openfisca_core/simulations/_build_from_variables.py +230 -0
  177. openfisca_core/simulations/_type_guards.py +298 -0
  178. openfisca_core/simulations/helpers.py +111 -0
  179. openfisca_core/simulations/simulation.py +618 -0
  180. openfisca_core/simulations/simulation_builder.py +855 -0
  181. openfisca_core/simulations/typing.py +203 -0
  182. openfisca_core/taxbenefitsystems/__init__.py +26 -0
  183. openfisca_core/taxbenefitsystems/tax_benefit_system.py +584 -0
  184. openfisca_core/taxscales/__init__.py +35 -0
  185. openfisca_core/taxscales/abstract_rate_tax_scale.py +41 -0
  186. openfisca_core/taxscales/abstract_tax_scale.py +53 -0
  187. openfisca_core/taxscales/amount_tax_scale_like.py +55 -0
  188. openfisca_core/taxscales/helpers.py +44 -0
  189. openfisca_core/taxscales/linear_average_rate_tax_scale.py +76 -0
  190. openfisca_core/taxscales/marginal_amount_tax_scale.py +34 -0
  191. openfisca_core/taxscales/marginal_rate_tax_scale.py +286 -0
  192. openfisca_core/taxscales/rate_tax_scale_like.py +214 -0
  193. openfisca_core/taxscales/single_amount_tax_scale.py +32 -0
  194. openfisca_core/taxscales/tax_scale_like.py +65 -0
  195. openfisca_core/tools/__init__.py +94 -0
  196. openfisca_core/tools/parallel_plugin.py +55 -0
  197. openfisca_core/tools/simulation_dumper.py +137 -0
  198. openfisca_core/tools/test_runner.py +802 -0
  199. openfisca_core/tracers/__init__.py +40 -0
  200. openfisca_core/tracers/computation_log.py +196 -0
  201. openfisca_core/tracers/flat_trace.py +87 -0
  202. openfisca_core/tracers/full_tracer.py +172 -0
  203. openfisca_core/tracers/performance_log.py +133 -0
  204. openfisca_core/tracers/simple_tracer.py +64 -0
  205. openfisca_core/tracers/trace_node.py +121 -0
  206. openfisca_core/tracers/tracing_parameter_node_at_instant.py +81 -0
  207. openfisca_core/types.py +548 -0
  208. openfisca_core/variables/__init__.py +26 -0
  209. openfisca_core/variables/config.py +53 -0
  210. openfisca_core/variables/helpers.py +67 -0
  211. openfisca_core/variables/tests/__init__.py +0 -0
  212. openfisca_core/variables/tests/test_definition_period.py +43 -0
  213. openfisca_core/variables/variable.py +474 -0
  214. openfisca_core/warnings/__init__.py +25 -0
  215. openfisca_core/warnings/libyaml_warning.py +2 -0
  216. openfisca_core/warnings/tempfile_warning.py +2 -0
  217. openfisca_core_test-46.0.0.data/data/share/openfisca/openfisca-core/CHANGELOG.md +4209 -0
  218. openfisca_core_test-46.0.0.data/data/share/openfisca/openfisca-core/README.md +152 -0
  219. openfisca_core_test-46.0.0.dist-info/METADATA +194 -0
  220. openfisca_core_test-46.0.0.dist-info/RECORD +237 -0
  221. openfisca_core_test-46.0.0.dist-info/WHEEL +5 -0
  222. openfisca_core_test-46.0.0.dist-info/entry_points.txt +3 -0
  223. openfisca_core_test-46.0.0.dist-info/licenses/LICENSE +661 -0
  224. openfisca_core_test-46.0.0.dist-info/top_level.txt +2 -0
  225. openfisca_web_api/__init__.py +0 -0
  226. openfisca_web_api/app.py +226 -0
  227. openfisca_web_api/errors.py +11 -0
  228. openfisca_web_api/handlers.py +90 -0
  229. openfisca_web_api/loader/__init__.py +22 -0
  230. openfisca_web_api/loader/entities.py +28 -0
  231. openfisca_web_api/loader/parameters.py +122 -0
  232. openfisca_web_api/loader/spec.py +168 -0
  233. openfisca_web_api/loader/tax_benefit_system.py +27 -0
  234. openfisca_web_api/loader/variables.py +121 -0
  235. openfisca_web_api/openAPI.yml +434 -0
  236. openfisca_web_api/scripts/__init__.py +0 -0
  237. openfisca_web_api/scripts/serve.py +99 -0
@@ -0,0 +1,802 @@
1
+ # Avoid T201 warning for print statements in this test runner
2
+ from __future__ import annotations
3
+
4
+ import dataclasses
5
+ import json
6
+ import os
7
+ import pathlib
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import textwrap
12
+ import time
13
+ import traceback
14
+ import warnings
15
+ from collections.abc import Sequence
16
+ from typing import Any, Literal
17
+
18
+ from typing_extensions import TypedDict
19
+
20
+ from openfisca_core.types import TaxBenefitSystem
21
+
22
+ # Unix-specific modules for parallel testing (not available on Windows)
23
+ try:
24
+ import pty
25
+ import select
26
+
27
+ PARALLEL_AVAILABLE = True
28
+ except ImportError:
29
+ PARALLEL_AVAILABLE = False
30
+
31
+ import pytest
32
+
33
+ from openfisca_core.errors import SituationParsingError, VariableNotFound
34
+ from openfisca_core.reforms.in_yaml_test_reform import InYamlTestReform
35
+ from openfisca_core.simulations import SimulationBuilder
36
+ from openfisca_core.tools import assert_near
37
+ from openfisca_core.warnings import LibYAMLWarning
38
+
39
+
40
+ class Options(TypedDict, total=False):
41
+ aggregate: bool
42
+ ignore_default: bool
43
+ ignore_variables: Sequence[str] | None
44
+ max_depth: int
45
+ name_filter: str | None
46
+ only_variables: Sequence[str] | None
47
+ pdb: bool
48
+ performance_graph: bool
49
+ performance_tables: bool
50
+ verbose: bool
51
+
52
+
53
+ @dataclasses.dataclass(frozen=True)
54
+ class ErrorMargin:
55
+ __root__: dict[str | Literal["default"], float | None]
56
+
57
+ def __getitem__(self, key: str) -> float | None:
58
+ if key in self.__root__:
59
+ return self.__root__[key]
60
+
61
+ return self.__root__["default"]
62
+
63
+
64
+ @dataclasses.dataclass
65
+ class Test:
66
+ absolute_error_margin: ErrorMargin
67
+ relative_error_margin: ErrorMargin
68
+ name: str = ""
69
+ input: dict[str, float | dict[str, float]] = dataclasses.field(default_factory=dict)
70
+ output: dict[str, float | dict[str, float]] | None = None
71
+ period: str | None = None
72
+ reforms: Sequence[str] = dataclasses.field(default_factory=list)
73
+ keywords: Sequence[str] | None = None
74
+ extensions: Sequence[str] = dataclasses.field(default_factory=list)
75
+ description: str | None = None
76
+ parameters: dict[str, float | dict[str, float]] | None = None
77
+ max_spiral_loops: int | None = None
78
+
79
+
80
+ def build_test(params: dict[str, Any]) -> Test:
81
+ for key in ["absolute_error_margin", "relative_error_margin"]:
82
+ value = params.get(key)
83
+
84
+ if value is None:
85
+ value = {"default": None}
86
+
87
+ elif isinstance(value, (float, int, str)):
88
+ value = {"default": float(value)}
89
+
90
+ params[key] = ErrorMargin(value)
91
+
92
+ return Test(**params)
93
+
94
+
95
+ def import_yaml():
96
+ import yaml
97
+
98
+ try:
99
+ from yaml import CLoader as Loader
100
+ except ImportError:
101
+ message = [
102
+ "libyaml is not installed in your environment.",
103
+ "This can make your test suite slower to run. Once you have installed libyaml, ",
104
+ "run 'pip uninstall pyyaml && pip install pyyaml --no-cache-dir'",
105
+ "so that it is used in your Python environment.",
106
+ ]
107
+ warnings.warn(" ".join(message), LibYAMLWarning, stacklevel=2)
108
+ from yaml import SafeLoader as Loader
109
+ return yaml, Loader
110
+
111
+
112
+ TEST_KEYWORDS = {
113
+ "absolute_error_margin",
114
+ "description",
115
+ "extensions",
116
+ "ignore_variables",
117
+ "input",
118
+ "keywords",
119
+ "max_spiral_loops",
120
+ "name",
121
+ "only_variables",
122
+ "output",
123
+ "period",
124
+ "parameters",
125
+ "reforms",
126
+ "relative_error_margin",
127
+ }
128
+
129
+ yaml, Loader = import_yaml()
130
+
131
+ _tax_benefit_system_cache: dict = {}
132
+
133
+ options: Options = Options()
134
+
135
+
136
+ def _create_worker_environment(options: Options) -> dict:
137
+ """Create environment variables for worker processes.
138
+
139
+ Args:
140
+ options: Test options to pass to workers
141
+
142
+ Returns:
143
+ Environment dictionary with OpenFisca configuration
144
+ """
145
+ env = os.environ.copy()
146
+ env["PYTHONUNBUFFERED"] = "1" # Ensure output is not buffered
147
+ if options.get("country_package"):
148
+ env["OPENFISCA_COUNTRY_PACKAGE"] = options.get("country_package")
149
+ env["OPENFISCA_EXTENSIONS"] = json.dumps(options.get("extensions") or [])
150
+ env["OPENFISCA_REFORMS"] = json.dumps(options.get("reforms") or [])
151
+ env["OPENFISCA_OPTIONS"] = json.dumps(options)
152
+ return env
153
+
154
+
155
+ def _spawn_worker(
156
+ batch: list[str], python_bin: str, env: dict, verbose: bool
157
+ ) -> tuple[subprocess.Popen, int]:
158
+ """Spawn a single worker process with PTY.
159
+
160
+ Args:
161
+ batch: List of test files for this worker
162
+ python_bin: Path to Python executable
163
+ env: Environment variables
164
+ verbose: Whether to enable verbose output
165
+
166
+ Returns:
167
+ Tuple of (subprocess.Popen, master_fd)
168
+ """
169
+ # Create PTY (pseudo-terminal) for real-time output capture
170
+ # PTY ensures output is line-buffered and flushed immediately
171
+ master_fd, slave_fd = pty.openpty()
172
+
173
+ # Build pytest command for this worker
174
+ cmd = [
175
+ python_bin,
176
+ "-m",
177
+ "pytest",
178
+ "-p",
179
+ "openfisca_core.tools.parallel_plugin", # Load our custom plugin
180
+ "--maxfail=1", # Stop on first failure within this worker
181
+ "--disable-warnings", # Reduce noise in output
182
+ ]
183
+ if verbose:
184
+ cmd.append("-vv")
185
+ cmd.extend(batch) # Add test files for this worker
186
+
187
+ # Spawn worker process with PTY output
188
+ p = subprocess.Popen(
189
+ cmd, stdout=slave_fd, stderr=subprocess.STDOUT, env=env, close_fds=True
190
+ )
191
+ os.close(slave_fd) # Close slave end in parent process
192
+ return p, master_fd
193
+
194
+
195
+ def _format_file_list(file_names: list[str], max_display: int = 3) -> str:
196
+ """Format list of files for display.
197
+
198
+ Args:
199
+ file_names: List of file names
200
+ max_display: Maximum number of files to show before truncating
201
+
202
+ Returns:
203
+ Formatted string of file names
204
+ """
205
+ if len(file_names) <= max_display:
206
+ return ", ".join(file_names)
207
+ return (
208
+ f"{', '.join(file_names[:max_display])} (+{len(file_names) - max_display} more)"
209
+ )
210
+
211
+
212
+ def _read_worker_output(
213
+ readable: list, fd_to_idx: dict, outputs: list[str], verbose: bool
214
+ ) -> None:
215
+ """Read output from workers that have data available.
216
+
217
+ Args:
218
+ readable: List of file descriptors with data available
219
+ fd_to_idx: Mapping from file descriptor to worker index
220
+ outputs: List of accumulated outputs per worker
221
+ verbose: Whether to print output in real-time
222
+ """
223
+ for fd in readable:
224
+ idx = fd_to_idx.get(fd)
225
+ if idx is not None:
226
+ try:
227
+ chunk = os.read(fd, 4096).decode("utf-8", "replace")
228
+ outputs[idx] += chunk
229
+ if verbose:
230
+ print(f"[Worker {idx}] {chunk}", end="", flush=True)
231
+ except OSError:
232
+ pass # FD closed or error, ignore
233
+
234
+
235
+ def _terminate_workers(procs: list, running: set) -> None:
236
+ """Terminate all running workers.
237
+
238
+ Args:
239
+ procs: List of (worker_id, subprocess.Popen) tuples
240
+ running: Set of worker IDs still running
241
+ """
242
+ for idx, p in procs:
243
+ if idx in running:
244
+ try:
245
+ p.terminate()
246
+ except (ProcessLookupError, PermissionError):
247
+ pass # Process already dead or permission denied
248
+
249
+
250
+ def discover_test_files(
251
+ paths: Sequence[str], name_filter: str | None = None
252
+ ) -> list[str]:
253
+ """Discover all test files (YAML and Python) in the given paths.
254
+
255
+ Args:
256
+ paths: List of file or directory paths to search for test files
257
+ name_filter: Optional string filter to match against file names
258
+
259
+ Returns:
260
+ Sorted list of unique absolute paths to test files
261
+
262
+ Notes:
263
+ - Accepts .yaml and .yml files
264
+ - Only accepts Python files starting with 'test_'
265
+ - Recursively explores directories
266
+ """
267
+ files = []
268
+ yaml_exts = {".yaml", ".yml"}
269
+ for p in paths:
270
+ p = pathlib.Path(p)
271
+ if p.is_file():
272
+ if (p.suffix in yaml_exts) or (
273
+ p.name.startswith("test_") and p.suffix == ".py"
274
+ ):
275
+ files.append(str(p.resolve()))
276
+ elif p.is_dir():
277
+ # collect yaml files
278
+ for ext in yaml_exts:
279
+ for f in p.rglob(f"*{ext}"):
280
+ files.append(str(f.resolve()))
281
+ # collect only python test files named test_*.py
282
+ for f in p.rglob("test_*.py"):
283
+ files.append(str(f.resolve()))
284
+ if name_filter:
285
+ files = [f for f in files if name_filter in pathlib.Path(f).name]
286
+ return sorted(set(files))
287
+
288
+
289
+ def run_tests_in_parallel(tax_benefit_system, paths, options, num_workers, verbose):
290
+ """Run OpenFisca tests in parallel across multiple worker processes.
291
+
292
+ This function implements parallel test execution by:
293
+ 1. Discovering all test files in the given paths
294
+ 2. Splitting test files into batches for each worker
295
+ 3. Spawning pytest worker processes with the parallel_plugin
296
+ 4. Monitoring worker progress and collecting output
297
+ 5. Stopping all workers on first failure (fail-fast behavior)
298
+ 6. Reporting results with colored output and timing
299
+
300
+ Architecture:
301
+ - Main process: orchestrates workers via subprocess.Popen
302
+ - Each worker: independent pytest process with parallel_plugin loaded
303
+ - Communication: PTY for real-time output capture
304
+ - Configuration: passed via environment variables
305
+
306
+ Args:
307
+ tax_benefit_system: The tax-benefit system to test
308
+ paths: List of paths containing test files
309
+ options: Test options dict (name_filter, verbose, etc.)
310
+ num_workers: Number of parallel workers (0 = auto-detect from CPU count)
311
+ verbose: If True, print detailed output from each worker
312
+
313
+ Returns:
314
+ Exit code: 0 for success, 1 for failure
315
+
316
+ Notes:
317
+ - Uses fail-fast: stops all workers on first failure
318
+ - Provides progress updates every 2 seconds
319
+ - Captures and displays output from failed workers
320
+ - Uses PTY to ensure proper output flushing
321
+ - On Windows, falls back to single-threaded testing
322
+ """
323
+ if not PARALLEL_AVAILABLE:
324
+ print(
325
+ "Parallel testing not available on this platform (requires Unix PTY support)."
326
+ )
327
+ print("Falling back to single-threaded testing...")
328
+ return run_tests(tax_benefit_system, paths, options)
329
+
330
+ test_files = discover_test_files(paths, options.get("name_filter"))
331
+
332
+ if not test_files:
333
+ print("No test files found")
334
+ return 0
335
+
336
+ # Auto-detect number of workers based on CPU count
337
+ if num_workers <= 0:
338
+ try:
339
+ import multiprocessing
340
+
341
+ # Use N-1 CPUs to leave one for the system
342
+ num_workers = max(1, multiprocessing.cpu_count() - 1)
343
+ except (ImportError, NotImplementedError):
344
+ num_workers = 1
345
+
346
+ # Limit workers to number of test files (no point having idle workers)
347
+ num_workers = min(num_workers, len(test_files))
348
+
349
+ # Split test files evenly across workers using round-robin distribution
350
+ # This ensures balanced workload even with uneven file counts
351
+ batches = [[] for _ in range(num_workers)]
352
+ for i, f in enumerate(test_files):
353
+ batches[i % num_workers].append(f)
354
+
355
+ # Remove empty batches and adjust worker count
356
+ batches = [b for b in batches if b]
357
+ num_workers = len(batches)
358
+
359
+ print(f"Running {len(test_files)} test files across {num_workers} workers...")
360
+
361
+ # Prepare environment variables for pytest workers
362
+ env = _create_worker_environment(options)
363
+
364
+ # Get python executable with fallback
365
+ python_bin = sys.executable or shutil.which("python3") or shutil.which("python")
366
+ if not python_bin:
367
+ print("Error: Could not find Python executable")
368
+ return 1
369
+
370
+ # Initialize data structures for worker management
371
+ procs = [] # List of (worker_id, subprocess.Popen) tuples
372
+ fds = [] # List of (worker_id, master_fd) tuples for PTY communication
373
+ outputs = ["" for _ in range(num_workers)] # Accumulated output from each worker
374
+ worker_info = {} # Metadata about each worker (files, timing, status)
375
+ start_time = time.time()
376
+
377
+ # Launch worker processes
378
+ for idx, batch in enumerate(batches):
379
+ if not batch:
380
+ continue
381
+
382
+ # Spawn worker process
383
+ p, master_fd = _spawn_worker(batch, python_bin, env, verbose)
384
+ procs.append((idx, p))
385
+ fds.append((idx, master_fd))
386
+
387
+ # Store worker metadata for progress reporting
388
+ file_names = [os.path.basename(f) for f in batch]
389
+ file_str = _format_file_list(file_names)
390
+
391
+ worker_info[idx] = {
392
+ "files": file_str,
393
+ "batch": batch,
394
+ "start_time": time.time(),
395
+ "status": "running",
396
+ }
397
+ print(f" Worker {idx}: {file_str}")
398
+
399
+ print()
400
+
401
+ running = {i for (i, _) in procs} # Set of worker IDs still running
402
+ exit_codes = {} # Map of worker_id -> exit_code
403
+ last_update = time.time() # For throttling progress updates
404
+
405
+ # Create fd -> worker_idx mapping for O(1) lookup
406
+ fd_to_idx = {fd: idx for idx, fd in fds}
407
+
408
+ # Monitor workers until all complete or one fails
409
+ while running:
410
+ # Use select() to wait for output from any worker
411
+ # Timeout of 0.1s allows periodic checking of process status
412
+ rlist = [fd for (_, fd) in fds]
413
+ readable, _, _ = select.select(rlist, [], [], 0.1)
414
+
415
+ # Read output from any workers that have data available
416
+ _read_worker_output(readable, fd_to_idx, outputs, verbose)
417
+
418
+ # Print progress update every 2 seconds (avoid spamming)
419
+ current_time = time.time()
420
+ if current_time - last_update >= 2.0:
421
+ completed = len(exit_codes)
422
+ total = num_workers
423
+ elapsed = current_time - start_time
424
+ print(
425
+ f"\rProgress: {completed}/{total} workers completed ({elapsed:.1f}s elapsed)",
426
+ end="",
427
+ flush=True,
428
+ )
429
+ last_update = current_time
430
+
431
+ # Check each worker for completion
432
+ for idx2, p in procs:
433
+ if idx2 in running:
434
+ ret = p.poll() # Non-blocking check if process finished
435
+ if ret is not None:
436
+ # Worker completed - record exit code and timing
437
+ exit_codes[idx2] = ret
438
+ duration = time.time() - worker_info[idx2]["start_time"]
439
+ worker_info[idx2]["status"] = "passed" if ret == 0 else "failed"
440
+ worker_info[idx2]["duration"] = duration
441
+
442
+ # Print completion with colored status indicator
443
+ status_symbol = "✓" if ret == 0 else "✗"
444
+ status_color = "\033[32m" if ret == 0 else "\033[31m"
445
+ reset_color = "\033[0m"
446
+ print(
447
+ f"\r{status_color}{status_symbol}{reset_color} Worker {idx2}: {worker_info[idx2]['files']} ({duration:.1f}s)"
448
+ )
449
+
450
+ running.remove(idx2)
451
+
452
+ # Fail-fast: if any worker fails, terminate all others
453
+ if ret != 0:
454
+ _terminate_workers(procs, running)
455
+ running.clear()
456
+ break
457
+
458
+ # Close file descriptors
459
+ for _, fd in fds:
460
+ try:
461
+ os.close(fd)
462
+ except OSError:
463
+ pass
464
+
465
+ total_duration = time.time() - start_time
466
+ print()
467
+
468
+ # Report failures
469
+ for idx, code in exit_codes.items():
470
+ if code != 0:
471
+ print(f"\n{'=' * 80}")
472
+ print(f"Worker {idx} FAILED")
473
+ print(f"{'=' * 80}")
474
+ print(f"Files: {', '.join(worker_info[idx]['batch'])}")
475
+ print(f"{'=' * 80}")
476
+ print(outputs[idx])
477
+ return 1
478
+
479
+ # Success summary
480
+ print(f"{'=' * 80}")
481
+ print("✓ All tests passed!")
482
+ print(
483
+ f" {len(test_files)} test files across {num_workers} workers in {total_duration:.2f}s"
484
+ )
485
+ print(f"{'=' * 80}")
486
+ return 0
487
+
488
+
489
+ def run_tests(
490
+ tax_benefit_system: TaxBenefitSystem,
491
+ paths: str | Sequence[str],
492
+ options: Options = options,
493
+ ) -> int:
494
+ """Runs all the YAML tests contained in a file or a directory.
495
+
496
+ If ``path`` is a directory, subdirectories will be recursively explored.
497
+
498
+ Args:
499
+ tax_benefit_system: the tax-benefit system to use to run the tests.
500
+ paths: A path, or a list of paths, towards the files or directories containing the tests to run. If a path is a directory, subdirectories will be recursively explored.
501
+ options: See more details below.
502
+
503
+ Returns:
504
+ The number of successful tests executed.
505
+
506
+ Raises:
507
+ :exc:`AssertionError`: if a test does not pass.
508
+
509
+ **Testing options**:
510
+
511
+ +-------------------------------+-----------+-------------------------------------------+
512
+ | Key | Type | Role |
513
+ +===============================+===========+===========================================+
514
+ | verbose | ``bool`` | |
515
+ +-------------------------------+-----------+ See :any:`openfisca_test` options doc |
516
+ | name_filter | ``str`` | |
517
+ +-------------------------------+-----------+-------------------------------------------+
518
+
519
+ """
520
+ argv = []
521
+ plugins = [OpenFiscaPlugin(tax_benefit_system, options)]
522
+
523
+ if options.get("pdb"):
524
+ argv.append("--pdb")
525
+
526
+ if options.get("verbose"):
527
+ argv.append("--verbose")
528
+
529
+ if isinstance(paths, str):
530
+ paths = [paths]
531
+
532
+ return pytest.main([*argv, *paths], plugins=plugins)
533
+
534
+
535
+ class YamlFile(pytest.File):
536
+ def __init__(self, *, tax_benefit_system, options, **kwargs) -> None:
537
+ super().__init__(**kwargs)
538
+ self.tax_benefit_system = tax_benefit_system
539
+ self.options = options
540
+
541
+ def collect(self):
542
+ try:
543
+ with open(self.path) as file:
544
+ tests = yaml.load(file, Loader=Loader)
545
+ except (yaml.scanner.ScannerError, yaml.parser.ParserError, TypeError):
546
+ message = os.linesep.join(
547
+ [
548
+ traceback.format_exc(),
549
+ f"'{self.path}' is not a valid YAML file. Check the stack trace above for more details.",
550
+ ],
551
+ )
552
+ raise ValueError(message)
553
+
554
+ if not isinstance(tests, list):
555
+ tests: Sequence[dict] = [tests]
556
+
557
+ for test in tests:
558
+ if not self.should_ignore(test):
559
+ yield YamlItem.from_parent(
560
+ self,
561
+ name="",
562
+ baseline_tax_benefit_system=self.tax_benefit_system,
563
+ test=test,
564
+ options=self.options,
565
+ )
566
+
567
+ def should_ignore(self, test):
568
+ name_filter = self.options.get("name_filter")
569
+ return (
570
+ name_filter is not None
571
+ and name_filter not in os.path.splitext(os.path.basename(self.path))[0]
572
+ and name_filter not in test.get("name", "")
573
+ and name_filter not in test.get("keywords", [])
574
+ )
575
+
576
+
577
+ class YamlItem(pytest.Item):
578
+ """Terminal nodes of the test collection tree."""
579
+
580
+ def __init__(self, *, baseline_tax_benefit_system, test, options, **kwargs) -> None:
581
+ super().__init__(**kwargs)
582
+ self.baseline_tax_benefit_system = baseline_tax_benefit_system
583
+ self.options = options
584
+ self.test = build_test(test)
585
+ self.simulation = None
586
+ self.tax_benefit_system = None
587
+
588
+ def runtest(self) -> None:
589
+ self.name = self.test.name
590
+
591
+ if self.test.output is None:
592
+ msg = f"Missing key 'output' in test '{self.name}' in file '{self.path}'"
593
+ raise ValueError(msg)
594
+
595
+ self.tax_benefit_system = _get_tax_benefit_system(
596
+ self.baseline_tax_benefit_system,
597
+ self.test.reforms,
598
+ self.test.extensions,
599
+ )
600
+
601
+ if self.test.parameters:
602
+ self.tax_benefit_system = InYamlTestReform(
603
+ self.tax_benefit_system, self.test.parameters
604
+ )
605
+
606
+ builder = SimulationBuilder()
607
+ input = self.test.input
608
+ period = self.test.period
609
+ max_spiral_loops = self.test.max_spiral_loops
610
+ verbose = self.options.get("verbose")
611
+ aggregate = self.options.get("aggregate")
612
+ max_depth = self.options.get("max_depth")
613
+ performance_graph = self.options.get("performance_graph")
614
+ performance_tables = self.options.get("performance_tables")
615
+
616
+ try:
617
+ builder.set_default_period(period)
618
+ self.simulation = builder.build_from_dict(self.tax_benefit_system, input)
619
+ assert self.simulation is not None, (
620
+ "Simulation should be properly initialized, Check your test input"
621
+ )
622
+
623
+ except (VariableNotFound, SituationParsingError, AssertionError):
624
+ raise
625
+ except Exception as e:
626
+ error_message = os.linesep.join(
627
+ [str(e), "", f"Unexpected error raised while parsing '{self.path}'"],
628
+ )
629
+ raise ValueError(error_message).with_traceback(
630
+ sys.exc_info()[2],
631
+ ) from e # Keep the stack trace from the root error
632
+
633
+ if max_spiral_loops:
634
+ self.simulation.max_spiral_loops = max_spiral_loops
635
+
636
+ try:
637
+ self.simulation.trace = verbose or performance_graph or performance_tables
638
+ self.check_output()
639
+ finally:
640
+ tracer = self.simulation.tracer
641
+ if verbose:
642
+ ignore_default = self.options.get("ignore_default", False)
643
+ self.print_computation_log(
644
+ tracer,
645
+ aggregate,
646
+ max_depth,
647
+ ignore_default,
648
+ self.tax_benefit_system,
649
+ )
650
+ if performance_graph:
651
+ self.generate_performance_graph(tracer)
652
+ if performance_tables:
653
+ self.generate_performance_tables(tracer)
654
+
655
+ def print_computation_log(
656
+ self, tracer, aggregate, max_depth, ignore_default, tax_benefit_system
657
+ ) -> None:
658
+ tracer.print_computation_log(
659
+ aggregate, max_depth, ignore_default, tax_benefit_system
660
+ )
661
+
662
+ def generate_performance_graph(self, tracer) -> None:
663
+ tracer.generate_performance_graph(".")
664
+
665
+ def generate_performance_tables(self, tracer) -> None:
666
+ tracer.generate_performance_tables(".")
667
+
668
+ def check_output(self) -> None:
669
+ output = self.test.output
670
+
671
+ if output is None:
672
+ return
673
+ for key, expected_value in output.items():
674
+ if self.tax_benefit_system.get_variable(key): # If key is a variable
675
+ self.check_variable(key, expected_value, self.test.period)
676
+ elif self.simulation.populations.get(key): # If key is an entity singular
677
+ for variable_name, value in expected_value.items():
678
+ self.check_variable(variable_name, value, self.test.period)
679
+ else:
680
+ population = self.simulation.get_population(plural=key)
681
+ if population is not None: # If key is an entity plural
682
+ for instance_id, instance_values in expected_value.items():
683
+ for variable_name, value in instance_values.items():
684
+ entity_index = population.get_index(instance_id)
685
+ self.check_variable(
686
+ variable_name,
687
+ value,
688
+ self.test.period,
689
+ entity_index,
690
+ )
691
+ else:
692
+ raise VariableNotFound(key, self.tax_benefit_system)
693
+
694
+ def check_variable(
695
+ self,
696
+ variable_name: str,
697
+ expected_value,
698
+ period,
699
+ entity_index=None,
700
+ ):
701
+ if self.should_ignore_variable(variable_name):
702
+ return None
703
+
704
+ if isinstance(expected_value, dict):
705
+ for requested_period, expected_value_at_period in expected_value.items():
706
+ self.check_variable(
707
+ variable_name,
708
+ expected_value_at_period,
709
+ requested_period,
710
+ entity_index,
711
+ )
712
+
713
+ return None
714
+
715
+ actual_value = self.simulation.calculate(variable_name, period)
716
+
717
+ if entity_index is not None:
718
+ actual_value = actual_value[entity_index]
719
+
720
+ return assert_near(
721
+ actual_value,
722
+ expected_value,
723
+ self.test.absolute_error_margin[variable_name],
724
+ f"{variable_name}@{period}: ",
725
+ self.test.relative_error_margin[variable_name],
726
+ )
727
+
728
+ def should_ignore_variable(self, variable_name: str):
729
+ only_variables = self.options.get("only_variables")
730
+ ignore_variables = self.options.get("ignore_variables")
731
+ variable_ignored = (
732
+ ignore_variables is not None and variable_name in ignore_variables
733
+ )
734
+ variable_not_tested = (
735
+ only_variables is not None and variable_name not in only_variables
736
+ )
737
+
738
+ return variable_ignored or variable_not_tested
739
+
740
+ def repr_failure(self, excinfo):
741
+ if not isinstance(
742
+ excinfo.value,
743
+ (AssertionError, VariableNotFound, SituationParsingError),
744
+ ):
745
+ return super().repr_failure(excinfo)
746
+
747
+ message = excinfo.value.args[0]
748
+ if isinstance(excinfo.value, SituationParsingError):
749
+ message = f"Could not parse situation described: {message}"
750
+
751
+ return os.linesep.join(
752
+ [
753
+ f"{self.path!s}:",
754
+ f" Test '{self.name!s}':",
755
+ textwrap.indent(message, " "),
756
+ ],
757
+ )
758
+
759
+
760
+ class OpenFiscaPlugin:
761
+ def __init__(self, tax_benefit_system, options) -> None:
762
+ self.tax_benefit_system = tax_benefit_system
763
+ self.options = options
764
+
765
+ def pytest_collect_file(self, parent, path):
766
+ """Called by pytest for all plugins.
767
+ :return: The collector for test methods.
768
+ """
769
+ if path.ext in [".yaml", ".yml"]:
770
+ return YamlFile.from_parent(
771
+ parent,
772
+ path=pathlib.Path(path),
773
+ tax_benefit_system=self.tax_benefit_system,
774
+ options=self.options,
775
+ )
776
+ return None
777
+
778
+
779
+ def _get_tax_benefit_system(baseline, reforms, extensions):
780
+ if not isinstance(reforms, list):
781
+ reforms = [reforms]
782
+ if not isinstance(extensions, list):
783
+ extensions = [extensions]
784
+
785
+ # keep reforms order in cache, ignore extensions order
786
+ key = hash((id(baseline), ":".join(reforms), frozenset(extensions)))
787
+ if _tax_benefit_system_cache.get(key):
788
+ return _tax_benefit_system_cache.get(key)
789
+
790
+ current_tax_benefit_system = baseline.clone()
791
+
792
+ for reform_path in reforms:
793
+ current_tax_benefit_system = current_tax_benefit_system.apply_reform(
794
+ reform_path,
795
+ )
796
+
797
+ for extension in extensions:
798
+ current_tax_benefit_system.load_extension(extension)
799
+
800
+ _tax_benefit_system_cache[key] = current_tax_benefit_system
801
+
802
+ return current_tax_benefit_system