ripples-sci 0.1.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.
- ripples/__init__.py +267 -0
- ripples/_test.py +1504 -0
- ripples/differentiation/__init__.py +114 -0
- ripples/differentiation/_numerical_differentiation.py +3790 -0
- ripples/differentiation/_numerical_differentiation_utils.py +2332 -0
- ripples/differentiation/_test.py +3075 -0
- ripples/optimization/__init__.py +136 -0
- ripples/optimization/_constrained_optimization.py +957 -0
- ripples/optimization/_global_optimization.py +1277 -0
- ripples/optimization/_global_optimization_utils.py +253 -0
- ripples/optimization/_line_search_optimization.py +2525 -0
- ripples/optimization/_line_search_utils.py +378 -0
- ripples/optimization/_minimizer.py +1891 -0
- ripples/optimization/_steepest_descent_optimization.py +478 -0
- ripples/optimization/_test.py +2570 -0
- ripples/optimization/_trust_region_optimization.py +1499 -0
- ripples/optimization/_trust_region_utils.py +344 -0
- ripples/optimization/_utils.py +1366 -0
- ripples_sci-0.1.0.dist-info/METADATA +557 -0
- ripples_sci-0.1.0.dist-info/RECORD +23 -0
- ripples_sci-0.1.0.dist-info/WHEEL +5 -0
- ripples_sci-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
- ripples_sci-0.1.0.dist-info/top_level.txt +1 -0
ripples/_test.py
ADDED
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Installation-integrity tests for `ripples`, and the unified test dispatcher.
|
|
3
|
+
|
|
4
|
+
This script does not re-test the numerical machinery - the differentiation and
|
|
5
|
+
optimization suites own that. Its job is the layer below: to prove that the
|
|
6
|
+
package the user actually installed is whole and wired correctly. A wheel can
|
|
7
|
+
import cleanly and still be broken - a re-export pointing at the wrong object, a
|
|
8
|
+
metadata field dropped during packaging, a NumPy too old for the code that
|
|
9
|
+
relies on it, a submodule whose test script never made it into the
|
|
10
|
+
distribution. Every section here checks one such property, so that a green run
|
|
11
|
+
means "what landed on this machine is the library, intact" before any feature
|
|
12
|
+
test is trusted.
|
|
13
|
+
|
|
14
|
+
It exercises, in reading order:
|
|
15
|
+
|
|
16
|
+
- Package import and the public metadata (`__version__`, author, licence).
|
|
17
|
+
- The runtime environment against the documented floors (Python, NumPy).
|
|
18
|
+
- The public API surface, the `__all__` declaration, and the curated `__dir__`.
|
|
19
|
+
- Submodule reachability and the identity of every top-level re-export.
|
|
20
|
+
- The bundled per-submodule test scripts and the unified `test()` entry point.
|
|
21
|
+
- A minimal end-to-end smoke call of each public function on the installed
|
|
22
|
+
package - loose tolerances, since the point is "it runs and lands in the
|
|
23
|
+
right place", not "it is accurate to the last digit".
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
The dispatcher
|
|
27
|
+
--------------
|
|
28
|
+
`test()` is the single front door named in the README and in both submodule
|
|
29
|
+
suites. It routes to the installation checks here, to one submodule suite, or
|
|
30
|
+
to everything at once, folding the per-suite tallies into one combined summary
|
|
31
|
+
in the last case.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Output convention
|
|
35
|
+
-----------------
|
|
36
|
+
For each test the script prints, on its own block:
|
|
37
|
+
|
|
38
|
+
- a header line of the form:
|
|
39
|
+
"Section <n>, Test <m>: s<n>.<name> - <description>", where the description
|
|
40
|
+
states in one sentence what the test verifies and why it matters, with the
|
|
41
|
+
checking procedure folded into the same sentence.
|
|
42
|
+
- RESULT : the measured value(s).
|
|
43
|
+
- VERDICT : one of PASS / FAIL / INFO / SKIP, followed by an
|
|
44
|
+
interpretation of what the result means in practice.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
How to run
|
|
48
|
+
----------
|
|
49
|
+
Through the library's unified entry point::
|
|
50
|
+
|
|
51
|
+
>>> import ripples
|
|
52
|
+
>>> ripples.test("installation") # just these checks
|
|
53
|
+
>>> ripples.test("differentiation")
|
|
54
|
+
>>> ripples.test("optimization")
|
|
55
|
+
>>> ripples.test() # "all" - every suite, one summary
|
|
56
|
+
|
|
57
|
+
from the command line::
|
|
58
|
+
|
|
59
|
+
python -m ripples._test
|
|
60
|
+
# append --all to chain the submodule suites after the installation checks
|
|
61
|
+
|
|
62
|
+
or programmatically, when a caller wants the tallies back::
|
|
63
|
+
|
|
64
|
+
>>> from ripples._test import run
|
|
65
|
+
>>> reporter = run()
|
|
66
|
+
>>> reporter.failed
|
|
67
|
+
0
|
|
68
|
+
|
|
69
|
+
Under continuous integration the suite is reached through pytest, which
|
|
70
|
+
collects the bridge test at the foot of this file. However it is launched,
|
|
71
|
+
every path funnels through the same `run()`, so the output and the verdicts are
|
|
72
|
+
identical no matter how the suite is started.
|
|
73
|
+
|
|
74
|
+
The exit code of the command-line form is 0 when every test passes and the
|
|
75
|
+
number of failures otherwise. The summary block at the end lists every
|
|
76
|
+
failure together with its test name, the expected value, and the actual value.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Copyright (c) Álvaro Cátedra Sánchez <alvaro.catedra.sanchez@gmail.com>,
|
|
80
|
+
# unique author and maintainer.
|
|
81
|
+
|
|
82
|
+
# This module is licensed under the Apache License 2.0. You may use, modify,
|
|
83
|
+
# and distribute this software under the terms of the license, provided that
|
|
84
|
+
# proper attribution is given to the original author. See LICENSE.txt for
|
|
85
|
+
# full details.
|
|
86
|
+
|
|
87
|
+
import numpy as np
|
|
88
|
+
|
|
89
|
+
import re
|
|
90
|
+
import sys
|
|
91
|
+
import importlib
|
|
92
|
+
from typing import Callable, Dict, List, Tuple, Optional, Any, Literal
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
FLOAT_EPSILON = float(np.finfo(float).eps)
|
|
97
|
+
|
|
98
|
+
SEPARATOR_WIDTH = 79
|
|
99
|
+
|
|
100
|
+
_CURRENT_SECTION_TEST_NUMBER = 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Kept here as the one place the environment checks read so a bump in the
|
|
104
|
+
# requirements is a one-line edit rather than a hunt through the assertions.
|
|
105
|
+
MINIMUM_PYTHON_VERSION = (3, 10)
|
|
106
|
+
MINIMUM_NUMPY_VERSION = (1, 24)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# The public surface. Every name here must be reachable from `import ripples`,
|
|
110
|
+
# and nothing private should leak alongside it. It mirrors the `__all__`
|
|
111
|
+
# assembled in ripples/__init__.py, on purpose - the test exists precisely to
|
|
112
|
+
# catch the day the two drift apart.
|
|
113
|
+
EXPECTED_SUBMODULES = (
|
|
114
|
+
"differentiation",
|
|
115
|
+
"optimization",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
EXPECTED_PUBLIC_FUNCTIONS = (
|
|
119
|
+
"nth_numerical_derivative",
|
|
120
|
+
"numerical_hessian_vector_product",
|
|
121
|
+
"DifferentiationResult",
|
|
122
|
+
"minimizer",
|
|
123
|
+
"OptimizationResult",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# The unified test entry point, re-exported at the top level from this module.
|
|
127
|
+
# It is part of the public surface - the README documents `ripples.test(...)` -
|
|
128
|
+
# so the import checks hold the package to its presence the same as any other
|
|
129
|
+
# advertised name.
|
|
130
|
+
EXPECTED_DISPATCHER = (
|
|
131
|
+
"test",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
EXPECTED_METADATA = (
|
|
135
|
+
"__version__",
|
|
136
|
+
"__author__",
|
|
137
|
+
"__email__",
|
|
138
|
+
"__license__",
|
|
139
|
+
"__copyright__",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# REPORTING UTILITIES
|
|
145
|
+
#
|
|
146
|
+
# Every test routes through `check_*` helpers that record a pass / fail entry
|
|
147
|
+
# on the module-level `REPORT` object. The helpers print the verdict line in
|
|
148
|
+
# place, so that the caller can read the output top-to-bottom as the suite
|
|
149
|
+
# progresses, and the summary at the end of `run()` re-prints every failure.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Reporter:
|
|
154
|
+
"""
|
|
155
|
+
Aggregator for pass / fail / skip / info events.
|
|
156
|
+
|
|
157
|
+
A single instance is kept at module level (`REPORT`) so every test
|
|
158
|
+
can record into the same accumulator without explicit wiring.
|
|
159
|
+
|
|
160
|
+
Attributes
|
|
161
|
+
----------
|
|
162
|
+
passed : int
|
|
163
|
+
Number of `check_*` calls that returned a passing verdict.
|
|
164
|
+
failed : int
|
|
165
|
+
Number of `check_*` calls that returned a failing verdict.
|
|
166
|
+
skipped : int
|
|
167
|
+
Number of tests that decided to short-circuit (for example,
|
|
168
|
+
because an optional part of the environment is absent).
|
|
169
|
+
info : int
|
|
170
|
+
Number of pure-information blocks.
|
|
171
|
+
failure_records : list of tuple
|
|
172
|
+
Tuples (test_name, message, expected, actual) recorded for
|
|
173
|
+
every failure, replayed verbatim in the final summary.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
def __init__(self) -> None:
|
|
177
|
+
self.passed: int = 0
|
|
178
|
+
self.failed: int = 0
|
|
179
|
+
self.skipped: int = 0
|
|
180
|
+
self.info: int = 0
|
|
181
|
+
self.failure_records: List[Tuple[str, str, Any, Any]] = []
|
|
182
|
+
|
|
183
|
+
def reset(self) -> None:
|
|
184
|
+
"""
|
|
185
|
+
Clear every counter and the recorded failure list.
|
|
186
|
+
|
|
187
|
+
Called at the top of `run()` so the same module-level reporter
|
|
188
|
+
can be reused across repeated invocations - for instance when
|
|
189
|
+
`ripples.test()` drives this suite on its own and then again as
|
|
190
|
+
part of an "all" run - without the second run inheriting the
|
|
191
|
+
tallies of the first.
|
|
192
|
+
"""
|
|
193
|
+
self.passed = 0
|
|
194
|
+
self.failed = 0
|
|
195
|
+
self.skipped = 0
|
|
196
|
+
self.info = 0
|
|
197
|
+
self.failure_records.clear()
|
|
198
|
+
|
|
199
|
+
def record_pass(self, test_name: str, message: str) -> None:
|
|
200
|
+
self.passed += 1
|
|
201
|
+
print(f" VERDICT : PASS - {message}")
|
|
202
|
+
|
|
203
|
+
def record_fail(
|
|
204
|
+
self, test_name: str, message: str,
|
|
205
|
+
expected: Any = None, actual: Any = None,
|
|
206
|
+
) -> None:
|
|
207
|
+
self.failed += 1
|
|
208
|
+
self.failure_records.append((test_name, message, expected, actual))
|
|
209
|
+
print(f" VERDICT : FAIL - {message}")
|
|
210
|
+
if expected is not None or actual is not None:
|
|
211
|
+
print(f" expected = {expected!r}")
|
|
212
|
+
print(f" actual = {actual!r}")
|
|
213
|
+
|
|
214
|
+
def record_skip(self, test_name: str, reason: str) -> None:
|
|
215
|
+
self.skipped += 1
|
|
216
|
+
print(f" VERDICT : SKIP - {reason}")
|
|
217
|
+
|
|
218
|
+
def record_info(self, message: str) -> None:
|
|
219
|
+
self.info += 1
|
|
220
|
+
print(f" VERDICT : INFO - {message}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def print_summary(self) -> None:
|
|
224
|
+
"""Print the end-of-run accounting and replay each failure."""
|
|
225
|
+
|
|
226
|
+
total_tests = self.passed + self.failed + self.skipped
|
|
227
|
+
print()
|
|
228
|
+
print("=" * SEPARATOR_WIDTH)
|
|
229
|
+
print(" FINAL SUMMARY ".center(SEPARATOR_WIDTH, "="))
|
|
230
|
+
print("=" * SEPARATOR_WIDTH)
|
|
231
|
+
print(f" Total tests : {total_tests}")
|
|
232
|
+
print(f" Passed : {self.passed}")
|
|
233
|
+
print(f" Failed : {self.failed}")
|
|
234
|
+
print(f" Skipped : {self.skipped}")
|
|
235
|
+
print(f" Info / benchmark : {self.info}")
|
|
236
|
+
|
|
237
|
+
if self.failure_records:
|
|
238
|
+
print()
|
|
239
|
+
print(" FAILURES ".center(SEPARATOR_WIDTH, "-"))
|
|
240
|
+
for test_name, message, expected, actual in self.failure_records:
|
|
241
|
+
print()
|
|
242
|
+
print(f" [{test_name}]")
|
|
243
|
+
print(f" {message}")
|
|
244
|
+
if expected is not None or actual is not None:
|
|
245
|
+
print(f" expected = {expected!r}")
|
|
246
|
+
print(f" actual = {actual!r}")
|
|
247
|
+
print("=" * SEPARATOR_WIDTH)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
REPORT = Reporter()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def section_banner(section_title: str) -> None:
|
|
256
|
+
"""
|
|
257
|
+
Open a new test section.
|
|
258
|
+
|
|
259
|
+
Prints a blank line, the section title as a plain comment line, and a
|
|
260
|
+
full-width rule of "#" beneath it, then resets the per-section test
|
|
261
|
+
counter so the next `test_block` starts again at "Test 1".
|
|
262
|
+
"""
|
|
263
|
+
global _CURRENT_SECTION_TEST_NUMBER
|
|
264
|
+
_CURRENT_SECTION_TEST_NUMBER = 0
|
|
265
|
+
print()
|
|
266
|
+
print(f"# {section_title} ")
|
|
267
|
+
print("#" * SEPARATOR_WIDTH)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_block(test_name: str, description: str) -> None:
|
|
272
|
+
"""
|
|
273
|
+
Print the one-line header that opens a single test.
|
|
274
|
+
|
|
275
|
+
The header reads::
|
|
276
|
+
|
|
277
|
+
Section <n>, Test <m>: s<n>.<rest> - <description>
|
|
278
|
+
|
|
279
|
+
where <n> is the section number (the section letter prefixing
|
|
280
|
+
`test_name`, mapped A -> 1 ... F -> 6), <m> is the position of this
|
|
281
|
+
test within the current section (reset by `section_banner`), and <rest>
|
|
282
|
+
is whatever follows the section letter. The VERDICT line is appended
|
|
283
|
+
afterwards by whichever `check_*` helper the test calls.
|
|
284
|
+
|
|
285
|
+
Parameters
|
|
286
|
+
----------
|
|
287
|
+
test_name : str
|
|
288
|
+
The dotted identifier, beginning with its section letter, e.g.
|
|
289
|
+
"C.surface.public_functions_present". The same string is handed to
|
|
290
|
+
the `check_*` helpers, so a failure in the summary traces back here.
|
|
291
|
+
description : str
|
|
292
|
+
A single sentence stating what the test verifies and why it
|
|
293
|
+
matters, with the checking procedure folded in.
|
|
294
|
+
"""
|
|
295
|
+
global _CURRENT_SECTION_TEST_NUMBER
|
|
296
|
+
|
|
297
|
+
section_letter, _, name_remainder = test_name.partition(".")
|
|
298
|
+
section_number = ord(section_letter.upper()) - ord("A") + 1
|
|
299
|
+
_CURRENT_SECTION_TEST_NUMBER += 1
|
|
300
|
+
numbered_name = f"s{section_number}.{name_remainder}"
|
|
301
|
+
|
|
302
|
+
print()
|
|
303
|
+
print("-" * SEPARATOR_WIDTH)
|
|
304
|
+
print(
|
|
305
|
+
f"Section {section_number}, Test {_CURRENT_SECTION_TEST_NUMBER}: "
|
|
306
|
+
f"{numbered_name} - {description}"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _format_for_result_line(value: Any) -> str:
|
|
312
|
+
"""Compact textual representation tailored for the RESULT line."""
|
|
313
|
+
if isinstance(value, np.ndarray):
|
|
314
|
+
with np.printoptions(precision=6, suppress=False, linewidth=200):
|
|
315
|
+
return np.array2string(value)
|
|
316
|
+
if isinstance(value, float):
|
|
317
|
+
return f"{value:.6e}"
|
|
318
|
+
return repr(value)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def check_allclose(
|
|
323
|
+
test_name: str,
|
|
324
|
+
actual_value: Any,
|
|
325
|
+
expected_value: Any,
|
|
326
|
+
relative_tolerance: float,
|
|
327
|
+
absolute_tolerance: float = 0.0,
|
|
328
|
+
interpretation: str = "",
|
|
329
|
+
) -> bool:
|
|
330
|
+
"""
|
|
331
|
+
Floating-point equality check.
|
|
332
|
+
|
|
333
|
+
`relative_tolerance` and `absolute_tolerance` are the same parameters
|
|
334
|
+
that numpy.allclose accepts. The pair is reported on the RESULT line
|
|
335
|
+
so the caller can re-run the test with a different tolerance band
|
|
336
|
+
without inspecting the source.
|
|
337
|
+
|
|
338
|
+
Returns True on pass, False on fail (so the caller may use the
|
|
339
|
+
return value to gate follow-up assertions).
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
actual_array = np.asarray(actual_value, dtype=float)
|
|
343
|
+
expected_array = np.asarray(expected_value, dtype=float)
|
|
344
|
+
|
|
345
|
+
# Element-wise absolute difference normalised by max(|expected|, eps).
|
|
346
|
+
denominator = np.maximum(np.abs(expected_array), FLOAT_EPSILON)
|
|
347
|
+
relative_error_per_entry = (
|
|
348
|
+
np.abs(actual_array - expected_array) / denominator
|
|
349
|
+
)
|
|
350
|
+
max_relative_error = float(np.max(relative_error_per_entry))
|
|
351
|
+
|
|
352
|
+
print(f" RESULT : actual = {_format_for_result_line(actual_value)}")
|
|
353
|
+
print(f" expected = "
|
|
354
|
+
f"{_format_for_result_line(expected_value)}")
|
|
355
|
+
print(f" max relative error = {max_relative_error:.6e}")
|
|
356
|
+
print(f" tolerance band "
|
|
357
|
+
f"= rtol={relative_tolerance:.1e}, "
|
|
358
|
+
f"atol={absolute_tolerance:.1e}")
|
|
359
|
+
|
|
360
|
+
is_close = bool(np.allclose(
|
|
361
|
+
actual_array, expected_array,
|
|
362
|
+
rtol=relative_tolerance, atol=absolute_tolerance,
|
|
363
|
+
equal_nan=False,
|
|
364
|
+
))
|
|
365
|
+
|
|
366
|
+
if is_close:
|
|
367
|
+
message = interpretation or (
|
|
368
|
+
"actual value matches the expected one within the tolerance band."
|
|
369
|
+
)
|
|
370
|
+
REPORT.record_pass(test_name, message)
|
|
371
|
+
else:
|
|
372
|
+
REPORT.record_fail(
|
|
373
|
+
test_name,
|
|
374
|
+
interpretation or "value disagrees beyond the tolerance band.",
|
|
375
|
+
expected=expected_value, actual=actual_value,
|
|
376
|
+
)
|
|
377
|
+
return is_close
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def check_truth(
|
|
382
|
+
test_name: str,
|
|
383
|
+
condition: bool,
|
|
384
|
+
message_on_pass: str,
|
|
385
|
+
message_on_fail: str,
|
|
386
|
+
) -> bool:
|
|
387
|
+
"""Plain boolean assertion. Used for presence / identity / flag checks."""
|
|
388
|
+
|
|
389
|
+
print(f" RESULT : condition evaluated to {bool(condition)}")
|
|
390
|
+
if condition:
|
|
391
|
+
REPORT.record_pass(test_name, message_on_pass)
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
REPORT.record_fail(
|
|
395
|
+
test_name, message_on_fail, expected=True, actual=bool(condition)
|
|
396
|
+
)
|
|
397
|
+
return False
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def check_raises(
|
|
402
|
+
test_name: str,
|
|
403
|
+
callable_object: Callable[[], Any],
|
|
404
|
+
expected_exception_type: type,
|
|
405
|
+
interpretation: str = "",
|
|
406
|
+
) -> bool:
|
|
407
|
+
"""
|
|
408
|
+
Assertion that calling `callable_object()` raises the given
|
|
409
|
+
exception type. Both "no exception" and "wrong exception type" count
|
|
410
|
+
as failures.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
try:
|
|
414
|
+
callable_object()
|
|
415
|
+
|
|
416
|
+
except expected_exception_type as raised_exception:
|
|
417
|
+
print(f" RESULT : raised {type(raised_exception).__name__}: "
|
|
418
|
+
f"{raised_exception}")
|
|
419
|
+
REPORT.record_pass(
|
|
420
|
+
test_name,
|
|
421
|
+
interpretation
|
|
422
|
+
or f"call raised {expected_exception_type.__name__} as expected.",
|
|
423
|
+
)
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
except BaseException as wrong_exception:
|
|
427
|
+
REPORT.record_fail(
|
|
428
|
+
test_name,
|
|
429
|
+
f"call raised {type(wrong_exception).__name__} instead of "
|
|
430
|
+
f"{expected_exception_type.__name__}.",
|
|
431
|
+
expected=expected_exception_type.__name__,
|
|
432
|
+
actual=type(wrong_exception).__name__,
|
|
433
|
+
)
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
REPORT.record_fail(
|
|
437
|
+
test_name,
|
|
438
|
+
f"call returned normally; expected {expected_exception_type.__name__}.",
|
|
439
|
+
expected=expected_exception_type.__name__,
|
|
440
|
+
actual="<no exception>",
|
|
441
|
+
)
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ENVIRONMENT PROBES
|
|
447
|
+
#
|
|
448
|
+
# Two tiny parsers shared by the environment section. Both read the leading
|
|
449
|
+
# numeric release segment of a version - "1.24.3rc1" -> (1, 24, 3) - and
|
|
450
|
+
# ignore any pre-release or local suffix, since the requirement floors are
|
|
451
|
+
# expressed only in terms of the release numbers.
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _release_tuple(version_string: str) -> Tuple[int, ...]:
|
|
456
|
+
"""Parse the leading numeric release segment of a version into ints."""
|
|
457
|
+
leading_match = re.match(r"\d+(?:\.\d+)*", version_string.strip())
|
|
458
|
+
if leading_match is None:
|
|
459
|
+
return ()
|
|
460
|
+
return tuple(int(part) for part in leading_match.group(0).split("."))
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _format_version(version_tuple: Tuple[int, ...]) -> str:
|
|
464
|
+
"""Render a release tuple back into a dotted string for the RESULT line."""
|
|
465
|
+
return ".".join(str(component) for component in version_tuple)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# SECTION A - PACKAGE IMPORT AND METADATA
|
|
470
|
+
#
|
|
471
|
+
# The first thing any install has to do is import. The second is identify
|
|
472
|
+
# itself: the version and the authorship/licence fields are what PyPI, pip, and
|
|
473
|
+
# any downstream tooling read, and a packaging slip that drops one of them is
|
|
474
|
+
# invisible until something asks for it. This section imports the package and
|
|
475
|
+
# confirms every metadata field is present, a non-empty string, and - for the
|
|
476
|
+
# version - shaped like a release number.
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def section_a_import_and_metadata() -> None:
|
|
481
|
+
"""
|
|
482
|
+
Import `ripples`, then confirm `__version__` parses as a dotted release
|
|
483
|
+
number and that the author, e-mail, licence, and copyright fields are all
|
|
484
|
+
present non-empty strings.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
section_banner("SECTION A - Package import and metadata")
|
|
488
|
+
|
|
489
|
+
# the import itself
|
|
490
|
+
test_block(
|
|
491
|
+
test_name="A.import.package",
|
|
492
|
+
description=(
|
|
493
|
+
"verify `import ripples` succeeds, importing the top-level package "
|
|
494
|
+
"and checking the module object comes back; nothing else in the "
|
|
495
|
+
"suite can run if the package will not load, so this is the gate "
|
|
496
|
+
"every later test depends on."
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
import ripples
|
|
500
|
+
check_truth(
|
|
501
|
+
test_name="A.import.package",
|
|
502
|
+
condition=ripples is not None,
|
|
503
|
+
message_on_pass="the top-level package imported.",
|
|
504
|
+
message_on_fail="the import produced no module object.",
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# version is a release-shaped string
|
|
508
|
+
test_block(
|
|
509
|
+
test_name="A.metadata.version_is_release_shaped",
|
|
510
|
+
description=(
|
|
511
|
+
"verify `__version__` is a non-empty string whose leading segment "
|
|
512
|
+
"parses as a dotted release number, reading the attribute and "
|
|
513
|
+
"matching it against the release pattern; pip and PyPI reject or "
|
|
514
|
+
"mis-sort a version that is not PEP 440 shaped, so a malformed one "
|
|
515
|
+
"is a release blocker."
|
|
516
|
+
),
|
|
517
|
+
)
|
|
518
|
+
version_string = getattr(ripples, "__version__", None)
|
|
519
|
+
version_is_release_shaped = (
|
|
520
|
+
isinstance(version_string, str)
|
|
521
|
+
and re.match(r"^\d+\.\d+", version_string.strip()) is not None
|
|
522
|
+
)
|
|
523
|
+
check_truth(
|
|
524
|
+
test_name="A.metadata.version_is_release_shaped",
|
|
525
|
+
condition=version_is_release_shaped,
|
|
526
|
+
message_on_pass=f"__version__ is {version_string!r}.",
|
|
527
|
+
message_on_fail=(
|
|
528
|
+
f"__version__ is missing or not release-shaped: "
|
|
529
|
+
f"{version_string!r}."
|
|
530
|
+
),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
# the remaining metadata fields
|
|
534
|
+
# Derived from EXPECTED_METADATA minus __version__ (handled separately
|
|
535
|
+
# above with its own release-shape check), so adding a new metadata field
|
|
536
|
+
# to EXPECTED_METADATA is enough; the loop below picks it up on its own.
|
|
537
|
+
descriptive_metadata = tuple(
|
|
538
|
+
metadata_name for metadata_name in EXPECTED_METADATA
|
|
539
|
+
if metadata_name != "__version__"
|
|
540
|
+
)
|
|
541
|
+
for metadata_name in descriptive_metadata:
|
|
542
|
+
test_block(
|
|
543
|
+
test_name=f"A.metadata.{metadata_name.strip('_')}",
|
|
544
|
+
description=(
|
|
545
|
+
f"verify `{metadata_name}` is present as a non-empty string, "
|
|
546
|
+
f"reading the attribute off the package and checking its type "
|
|
547
|
+
f"and length; this field is surfaced on PyPI and in tooling, "
|
|
548
|
+
f"and an empty or missing one is a packaging defect that "
|
|
549
|
+
f"import alone would never reveal."
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
metadata_value = getattr(ripples, metadata_name, None)
|
|
553
|
+
check_truth(
|
|
554
|
+
test_name=f"A.metadata.{metadata_name.strip('_')}",
|
|
555
|
+
condition=(
|
|
556
|
+
isinstance(metadata_value, str)
|
|
557
|
+
and len(metadata_value) > 0
|
|
558
|
+
),
|
|
559
|
+
message_on_pass=f"{metadata_name} = {metadata_value!r}",
|
|
560
|
+
message_on_fail=f"{metadata_name} is missing or empty.",
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# SECTION B - RUNTIME ENVIRONMENT
|
|
566
|
+
#
|
|
567
|
+
# The code is written against documented floors - Python 3.10 for the syntax
|
|
568
|
+
# it uses, NumPy 1.24 for the array API it relies on. pip enforces these at
|
|
569
|
+
# install time from the metadata, but an editable install, a hand-built
|
|
570
|
+
# environment, or a stale wheel can sidestep that. Checking the live
|
|
571
|
+
# interpreter and the imported NumPy here turns a confusing downstream failure
|
|
572
|
+
# ("some attribute does not exist") into a clear up-front verdict.
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def section_b_runtime_environment() -> None:
|
|
577
|
+
"""
|
|
578
|
+
Compare the running Python and the imported NumPy against the documented
|
|
579
|
+
minimums, reporting both the found and the required version on each block.
|
|
580
|
+
"""
|
|
581
|
+
|
|
582
|
+
section_banner("SECTION B - Runtime environment")
|
|
583
|
+
|
|
584
|
+
# Python
|
|
585
|
+
test_block(
|
|
586
|
+
test_name="B.environment.python_version",
|
|
587
|
+
description=(
|
|
588
|
+
"verify the running interpreter meets the documented Python floor, "
|
|
589
|
+
"comparing sys.version_info against the required minimum; the code "
|
|
590
|
+
"uses syntax introduced in that release, so an older interpreter "
|
|
591
|
+
"fails in ways that have nothing to do with the library's logic."
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
running_python_version = sys.version_info[: len(MINIMUM_PYTHON_VERSION)]
|
|
595
|
+
print(f" RESULT : found Python "
|
|
596
|
+
f"{_format_version(tuple(running_python_version))}, "
|
|
597
|
+
f"require >= {_format_version(MINIMUM_PYTHON_VERSION)}")
|
|
598
|
+
check_truth(
|
|
599
|
+
test_name="B.environment.python_version",
|
|
600
|
+
condition=tuple(running_python_version) >= MINIMUM_PYTHON_VERSION,
|
|
601
|
+
message_on_pass="interpreter satisfies the documented floor.",
|
|
602
|
+
message_on_fail="interpreter is older than the documented floor.",
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# NumPy
|
|
606
|
+
test_block(
|
|
607
|
+
test_name="B.environment.numpy_version",
|
|
608
|
+
description=(
|
|
609
|
+
"verify the imported NumPy meets the documented floor, parsing "
|
|
610
|
+
"numpy.__version__ and comparing against the required minimum; the "
|
|
611
|
+
"whole library is built on NumPy, so a version below the floor is "
|
|
612
|
+
"the most likely source of a subtle array-API mismatch."
|
|
613
|
+
),
|
|
614
|
+
)
|
|
615
|
+
installed_numpy_version = _release_tuple(np.__version__)
|
|
616
|
+
comparable_numpy_version = installed_numpy_version[
|
|
617
|
+
: len(MINIMUM_NUMPY_VERSION)
|
|
618
|
+
]
|
|
619
|
+
print(f" RESULT : found NumPy {np.__version__}, "
|
|
620
|
+
f"require >= {_format_version(MINIMUM_NUMPY_VERSION)}")
|
|
621
|
+
check_truth(
|
|
622
|
+
test_name="B.environment.numpy_version",
|
|
623
|
+
condition=comparable_numpy_version >= MINIMUM_NUMPY_VERSION,
|
|
624
|
+
message_on_pass="NumPy satisfies the documented floor.",
|
|
625
|
+
message_on_fail="NumPy is older than the documented floor.",
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# SECTION C - PUBLIC API SURFACE
|
|
631
|
+
#
|
|
632
|
+
# The promise of the package is that `import ripples` is enough to reach every
|
|
633
|
+
# feature. That promise is held together by three things: the names being
|
|
634
|
+
# present, the `__all__` declaration agreeing with what is actually exported,
|
|
635
|
+
# and the curated `__dir__` showing the user the public surface and nothing
|
|
636
|
+
# else. A break in any of them is silent - the code still works, but the
|
|
637
|
+
# advertised interface no longer matches reality.
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def section_c_public_api_surface() -> None:
|
|
642
|
+
"""
|
|
643
|
+
Confirm every documented submodule, public function, and metadata name is
|
|
644
|
+
reachable from the package; that `__all__` is the union of the three and
|
|
645
|
+
contains no dangling name; and that `dir(ripples)` returns exactly the
|
|
646
|
+
sorted `__all__`.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
section_banner("SECTION C - Public API surface")
|
|
650
|
+
|
|
651
|
+
import ripples
|
|
652
|
+
|
|
653
|
+
# every advertised name is present
|
|
654
|
+
expected_public_names = (
|
|
655
|
+
EXPECTED_SUBMODULES + EXPECTED_PUBLIC_FUNCTIONS
|
|
656
|
+
+ EXPECTED_DISPATCHER + EXPECTED_METADATA
|
|
657
|
+
)
|
|
658
|
+
for public_name in expected_public_names:
|
|
659
|
+
test_block(
|
|
660
|
+
test_name=f"C.present.{public_name.strip('_')}",
|
|
661
|
+
description=(
|
|
662
|
+
f"verify `ripples.{public_name}` resolves, looking the name up "
|
|
663
|
+
f"on the package; this is one entry of the advertised surface, "
|
|
664
|
+
f"and a missing one means the documented one-import workflow "
|
|
665
|
+
f"is broken for that feature."
|
|
666
|
+
),
|
|
667
|
+
)
|
|
668
|
+
check_truth(
|
|
669
|
+
test_name=f"C.present.{public_name.strip('_')}",
|
|
670
|
+
condition=hasattr(ripples, public_name),
|
|
671
|
+
message_on_pass=f"ripples.{public_name} is reachable.",
|
|
672
|
+
message_on_fail=f"ripples.{public_name} is absent.",
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# the documented callables / classes are actually callable
|
|
676
|
+
for callable_name in EXPECTED_PUBLIC_FUNCTIONS + EXPECTED_DISPATCHER:
|
|
677
|
+
test_block(
|
|
678
|
+
test_name=f"C.callable.{callable_name}",
|
|
679
|
+
description=(
|
|
680
|
+
f"verify `ripples.{callable_name}` is callable, fetching the "
|
|
681
|
+
f"object and checking callable(); a name that resolves to a "
|
|
682
|
+
f"non-callable (a stray constant, a half-finished re-export) "
|
|
683
|
+
f"would pass the presence check yet fail the moment a user "
|
|
684
|
+
f"tried to use it."
|
|
685
|
+
),
|
|
686
|
+
)
|
|
687
|
+
candidate = getattr(ripples, callable_name, None)
|
|
688
|
+
check_truth(
|
|
689
|
+
test_name=f"C.callable.{callable_name}",
|
|
690
|
+
condition=callable(candidate),
|
|
691
|
+
message_on_pass=f"{callable_name} is callable.",
|
|
692
|
+
message_on_fail=f"{callable_name} resolved to a non-callable.",
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
# __all__ is well-formed and complete
|
|
696
|
+
test_block(
|
|
697
|
+
test_name="C.dunder_all.union",
|
|
698
|
+
description=(
|
|
699
|
+
"verify `__all__` is a list holding exactly the union of the "
|
|
700
|
+
"documented submodules, public functions, and metadata, comparing "
|
|
701
|
+
"the two as sets; `__all__` is what `from ripples import *` "
|
|
702
|
+
"honours, so a name absent here is invisible to that idiom even "
|
|
703
|
+
"when it exists on the package."
|
|
704
|
+
),
|
|
705
|
+
)
|
|
706
|
+
package_all = getattr(ripples, "__all__", None)
|
|
707
|
+
all_matches_expected = (
|
|
708
|
+
isinstance(package_all, list)
|
|
709
|
+
and set(package_all) == set(expected_public_names)
|
|
710
|
+
)
|
|
711
|
+
print(f" RESULT : __all__ has {len(package_all or [])} entries; "
|
|
712
|
+
f"expected {len(expected_public_names)}")
|
|
713
|
+
check_truth(
|
|
714
|
+
test_name="C.dunder_all.union",
|
|
715
|
+
condition=all_matches_expected,
|
|
716
|
+
message_on_pass="__all__ is exactly the documented public surface.",
|
|
717
|
+
message_on_fail="__all__ diverges from the documented public surface.",
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# no name in __all__ dangles
|
|
721
|
+
test_block(
|
|
722
|
+
test_name="C.dunder_all.no_dangling_name",
|
|
723
|
+
description=(
|
|
724
|
+
"verify every name listed in `__all__` actually resolves on the "
|
|
725
|
+
"package, looking each one up; a name advertised but not bound "
|
|
726
|
+
"raises ImportError under `from ripples import *`, the kind of "
|
|
727
|
+
"fault that survives a clean import and only bites the star-import "
|
|
728
|
+
"user."
|
|
729
|
+
),
|
|
730
|
+
)
|
|
731
|
+
dangling_names = [
|
|
732
|
+
name for name in (package_all or []) if not hasattr(ripples, name)
|
|
733
|
+
]
|
|
734
|
+
check_truth(
|
|
735
|
+
test_name="C.dunder_all.no_dangling_name",
|
|
736
|
+
condition=len(dangling_names) == 0,
|
|
737
|
+
message_on_pass="every __all__ entry is bound on the package.",
|
|
738
|
+
message_on_fail=f"__all__ lists unbound names: {dangling_names}.",
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# __dir__ is the sorted __all__
|
|
742
|
+
test_block(
|
|
743
|
+
test_name="C.dunder_dir.equals_sorted_all",
|
|
744
|
+
description=(
|
|
745
|
+
"verify `dir(ripples)` equals the sorted `__all__`, calling dir() "
|
|
746
|
+
"and comparing; the package defines a custom __dir__ so that tab-"
|
|
747
|
+
"completion shows the public surface and nothing private, and a "
|
|
748
|
+
"regression there quietly re-clutters the listing the user sees."
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
directory_listing = dir(ripples)
|
|
752
|
+
check_truth(
|
|
753
|
+
test_name="C.dunder_dir.equals_sorted_all",
|
|
754
|
+
condition=directory_listing == sorted(package_all or []),
|
|
755
|
+
message_on_pass="dir(ripples) is the curated, sorted public surface.",
|
|
756
|
+
message_on_fail="dir(ripples) no longer matches sorted(__all__).",
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# SECTION D - SUBMODULE REACHABILITY AND RE-EXPORT IDENTITY
|
|
762
|
+
#
|
|
763
|
+
# Each public function is defined deep inside a submodule and re-exported at
|
|
764
|
+
# the top level. The re-export is plumbing, and plumbing leaks: a refactor can
|
|
765
|
+
# leave `ripples.minimizer` pointing at a stale object while
|
|
766
|
+
# `ripples.optimization.minimizer` points at the live one, and both still
|
|
767
|
+
# import. The identity check (`is`) is the only thing that catches that - two
|
|
768
|
+
# different objects with the same name behave identically right up until they
|
|
769
|
+
# don't.
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def section_d_submodule_reexport_identity() -> None:
|
|
774
|
+
"""
|
|
775
|
+
Import each submodule directly, confirm it carries its own `__all__`, and
|
|
776
|
+
confirm every top-level public function is the very same object as the
|
|
777
|
+
attribute it was re-exported from.
|
|
778
|
+
"""
|
|
779
|
+
|
|
780
|
+
section_banner("SECTION D - Submodule reachability and re-export identity")
|
|
781
|
+
|
|
782
|
+
import ripples
|
|
783
|
+
|
|
784
|
+
# each submodule imports on its own-
|
|
785
|
+
# The submodule objects are stashed in a dict here so the identity loop
|
|
786
|
+
# below can iterate them without re-importing - one import per submodule
|
|
787
|
+
# is enough.
|
|
788
|
+
imported_submodule_objects: Dict[str, Any] = {}
|
|
789
|
+
for submodule_name in EXPECTED_SUBMODULES:
|
|
790
|
+
test_block(
|
|
791
|
+
test_name=f"D.import.{submodule_name}",
|
|
792
|
+
description=(
|
|
793
|
+
f"verify `ripples.{submodule_name}` imports as a submodule, "
|
|
794
|
+
f"importing it directly and checking it exposes its own "
|
|
795
|
+
f"`__all__`; the top-level re-exports lean on the submodule "
|
|
796
|
+
f"loading cleanly, so a fault here explains a whole column of "
|
|
797
|
+
f"later failures at once."
|
|
798
|
+
),
|
|
799
|
+
)
|
|
800
|
+
imported_submodule_objects[submodule_name] = importlib.import_module(
|
|
801
|
+
f"ripples.{submodule_name}"
|
|
802
|
+
)
|
|
803
|
+
check_truth(
|
|
804
|
+
test_name=f"D.import.{submodule_name}",
|
|
805
|
+
condition=hasattr(
|
|
806
|
+
imported_submodule_objects[submodule_name], "__all__"
|
|
807
|
+
),
|
|
808
|
+
message_on_pass=(
|
|
809
|
+
f"ripples.{submodule_name} imported and declares __all__."
|
|
810
|
+
),
|
|
811
|
+
message_on_fail=(
|
|
812
|
+
f"ripples.{submodule_name} imported without an __all__."
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
# top-level re-exports are the same objects
|
|
817
|
+
#
|
|
818
|
+
# The library's stated invariant is that every public entry point reaches
|
|
819
|
+
# the top level, so what is in a submodule's `__all__` must be re-exported
|
|
820
|
+
# at `ripples.<name>` and must be the same object. The loop discovers the
|
|
821
|
+
# names by reading each submodule's `__all__` rather than hardcoding a
|
|
822
|
+
# function-to-submodule map; a new public name added to a submodule and
|
|
823
|
+
# re-exported needs no edit here, and a name added to the submodule but
|
|
824
|
+
# forgotten at the top level fails this check rather than slipping through.
|
|
825
|
+
for submodule_name, submodule_object in imported_submodule_objects.items():
|
|
826
|
+
submodule_public_names = getattr(submodule_object, "__all__", None)
|
|
827
|
+
if not submodule_public_names:
|
|
828
|
+
# Already reported as a failure by the import block above; nothing
|
|
829
|
+
# useful to iterate on, so skip the identity checks for this one.
|
|
830
|
+
continue
|
|
831
|
+
|
|
832
|
+
for public_name in submodule_public_names:
|
|
833
|
+
test_block(
|
|
834
|
+
test_name=f"D.identity.{public_name}",
|
|
835
|
+
description=(
|
|
836
|
+
f"verify `ripples.{public_name}` is the very object "
|
|
837
|
+
f"exported by ripples.{submodule_name}, comparing the two "
|
|
838
|
+
f"with `is`; a presence check passes even when the top "
|
|
839
|
+
f"level points at a stale copy, so identity is what "
|
|
840
|
+
f"actually proves the re-export wiring is live."
|
|
841
|
+
),
|
|
842
|
+
)
|
|
843
|
+
top_level_object = getattr(ripples, public_name, None)
|
|
844
|
+
origin_object = getattr(submodule_object, public_name, None)
|
|
845
|
+
check_truth(
|
|
846
|
+
test_name=f"D.identity.{public_name}",
|
|
847
|
+
condition=top_level_object is origin_object
|
|
848
|
+
and top_level_object is not None,
|
|
849
|
+
message_on_pass=(
|
|
850
|
+
f"ripples.{public_name} is ripples.{submodule_name}."
|
|
851
|
+
f"{public_name}."
|
|
852
|
+
),
|
|
853
|
+
message_on_fail=(
|
|
854
|
+
f"ripples.{public_name} is a different object from "
|
|
855
|
+
f"ripples.{submodule_name}.{public_name} (or one of "
|
|
856
|
+
f"them is missing)."
|
|
857
|
+
),
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
# a phantom submodule does not resolve
|
|
861
|
+
test_block(
|
|
862
|
+
test_name="D.no_phantom_submodule",
|
|
863
|
+
description=(
|
|
864
|
+
"verify importing a submodule that does not exist raises "
|
|
865
|
+
"ModuleNotFoundError, attempting `ripples.does_not_exist` and "
|
|
866
|
+
"catching; this guards against an over-eager package hook that "
|
|
867
|
+
"would answer for names the library never defined and so mask a "
|
|
868
|
+
"caller's typo."
|
|
869
|
+
),
|
|
870
|
+
)
|
|
871
|
+
check_raises(
|
|
872
|
+
test_name="D.no_phantom_submodule",
|
|
873
|
+
callable_object=lambda: importlib.import_module(
|
|
874
|
+
"ripples.does_not_exist"
|
|
875
|
+
),
|
|
876
|
+
expected_exception_type=ModuleNotFoundError,
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# SECTION E - BUNDLED TEST SUITES AND THE UNIFIED ENTRY POINT
|
|
882
|
+
#
|
|
883
|
+
# The per-submodule test scripts are shipped inside the wheel (the MANIFEST and
|
|
884
|
+
# the setuptools config pull every .py under the package in), and the unified
|
|
885
|
+
# `test()` dispatcher in this module is the front door the README points the
|
|
886
|
+
# user at. This section proves both are reachable and correctly shaped, without
|
|
887
|
+
# running them - executing the full submodule suites is what `test("all")`
|
|
888
|
+
# does, and duplicating that here would only slow the installation gate down.
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def section_e_test_suite_wiring() -> None:
|
|
893
|
+
"""
|
|
894
|
+
Confirm each submodule ships a `_test` module exposing the `run` callable
|
|
895
|
+
the dispatcher drives, that this module's own `test` dispatcher is callable,
|
|
896
|
+
rejects an unknown target with ValueError and a non-string target with
|
|
897
|
+
TypeError, and is the very object exposed as `ripples.test`.
|
|
898
|
+
"""
|
|
899
|
+
|
|
900
|
+
section_banner(
|
|
901
|
+
"SECTION E - Bundled test suites and the unified entry point"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
import ripples
|
|
905
|
+
|
|
906
|
+
# each submodule's _test ships and exposes run()
|
|
907
|
+
for submodule_name in EXPECTED_SUBMODULES:
|
|
908
|
+
test_block(
|
|
909
|
+
test_name=f"E.suite.{submodule_name}_run_callable",
|
|
910
|
+
description=(
|
|
911
|
+
f"verify ripples.{submodule_name}._test ships and exposes a "
|
|
912
|
+
f"callable `run`, importing the test module and checking the "
|
|
913
|
+
f"attribute; the unified dispatcher drives each suite through "
|
|
914
|
+
f"this exact entry point, so a suite missing from the wheel "
|
|
915
|
+
f"breaks `test(\"all\")` even though the library itself works."
|
|
916
|
+
),
|
|
917
|
+
)
|
|
918
|
+
submodule_test = importlib.import_module(
|
|
919
|
+
f"ripples.{submodule_name}._test"
|
|
920
|
+
)
|
|
921
|
+
check_truth(
|
|
922
|
+
test_name=f"E.suite.{submodule_name}_run_callable",
|
|
923
|
+
condition=callable(getattr(submodule_test, "run", None)),
|
|
924
|
+
message_on_pass=(
|
|
925
|
+
f"ripples.{submodule_name}._test.run is present and callable."
|
|
926
|
+
),
|
|
927
|
+
message_on_fail=(
|
|
928
|
+
f"ripples.{submodule_name}._test.run is missing or "
|
|
929
|
+
f"not callable."
|
|
930
|
+
),
|
|
931
|
+
)
|
|
932
|
+
|
|
933
|
+
# the dispatcher in this module is callable
|
|
934
|
+
test_block(
|
|
935
|
+
test_name="E.dispatcher.callable",
|
|
936
|
+
description=(
|
|
937
|
+
"verify the `test` dispatcher defined in this module is callable, "
|
|
938
|
+
"checking the local object; it is the single front door every "
|
|
939
|
+
"other launch path funnels through, so it has to be a working "
|
|
940
|
+
"callable before any of the documented `ripples.test(...)` forms "
|
|
941
|
+
"can mean anything."
|
|
942
|
+
),
|
|
943
|
+
)
|
|
944
|
+
check_truth(
|
|
945
|
+
test_name="E.dispatcher.callable",
|
|
946
|
+
condition=callable(test),
|
|
947
|
+
message_on_pass="the unified test() dispatcher is callable.",
|
|
948
|
+
message_on_fail="the unified test() dispatcher is not callable.",
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
# the dispatcher rejects an unknown target
|
|
952
|
+
test_block(
|
|
953
|
+
test_name="E.dispatcher.rejects_unknown_target",
|
|
954
|
+
description=(
|
|
955
|
+
"verify `test(\"non-existant\")` raises ValueError, calling the "
|
|
956
|
+
"dispatcher with a target it does not recognise and catching; a "
|
|
957
|
+
"silent no-op on a mistyped target would let a caller believe a "
|
|
958
|
+
"suite ran when nothing did, the worst failure mode a test runner "
|
|
959
|
+
"can have."
|
|
960
|
+
),
|
|
961
|
+
)
|
|
962
|
+
check_raises(
|
|
963
|
+
test_name="E.dispatcher.rejects_unknown_target",
|
|
964
|
+
callable_object=lambda: test("non-existant"),
|
|
965
|
+
expected_exception_type=ValueError,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
# the dispatcher rejects a non-string target
|
|
969
|
+
test_block(
|
|
970
|
+
test_name="E.dispatcher.rejects_non_string_target",
|
|
971
|
+
description=(
|
|
972
|
+
"verify `test(None)` raises TypeError, calling the dispatcher with "
|
|
973
|
+
"a non-string target and catching; the library raises TypeError "
|
|
974
|
+
"for type errors elsewhere, and a clean TypeError here points at "
|
|
975
|
+
"the real fault rather than the AttributeError that would surface "
|
|
976
|
+
"if the dispatcher tried to `.strip()` a None."
|
|
977
|
+
),
|
|
978
|
+
)
|
|
979
|
+
check_raises(
|
|
980
|
+
test_name="E.dispatcher.rejects_non_string_target",
|
|
981
|
+
callable_object=lambda: test(None), # type: ignore[arg-type]
|
|
982
|
+
expected_exception_type=TypeError,
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
# test() is exposed at the top level and is this dispatcher
|
|
986
|
+
test_block(
|
|
987
|
+
test_name="E.dispatcher.exposed_at_top_level",
|
|
988
|
+
description=(
|
|
989
|
+
"verify `ripples.test` is exposed and is this module's dispatcher, "
|
|
990
|
+
"comparing ripples.test against the local `test` by identity; the "
|
|
991
|
+
"README documents `ripples.test(...)` as the entry point, so the "
|
|
992
|
+
"re-export has to be present and has to resolve to the real "
|
|
993
|
+
"dispatcher rather than to some object shadowing the name."
|
|
994
|
+
),
|
|
995
|
+
)
|
|
996
|
+
check_truth(
|
|
997
|
+
test_name="E.dispatcher.exposed_at_top_level",
|
|
998
|
+
condition=getattr(ripples, "test", None) is test,
|
|
999
|
+
message_on_pass="ripples.test is wired and is the unified dispatcher.",
|
|
1000
|
+
message_on_fail=(
|
|
1001
|
+
"ripples.test is missing or is not this dispatcher; expose it with "
|
|
1002
|
+
"`from ._test import test` in __init__.py and list 'test' in the "
|
|
1003
|
+
"package's public surface."
|
|
1004
|
+
),
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# SECTION F - END-TO-END SMOKE TEST
|
|
1010
|
+
#
|
|
1011
|
+
# Import succeeding and names resolving still does not prove the installed code
|
|
1012
|
+
# runs. A broken NumPy build, a corrupted file, an ABI mismatch - these surface
|
|
1013
|
+
# only when arithmetic actually happens. So each public entry point is called
|
|
1014
|
+
# once on a trivial problem with a known answer, judged on a loose band. This
|
|
1015
|
+
# is deliberately not an accuracy test - the submodule suites own that, with
|
|
1016
|
+
# tolerances down to machine epsilon. Here the only question is whether the
|
|
1017
|
+
# installed package computes and lands in the right place at all.
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _smoke_sine_of_first_coordinate(point: Any) -> float:
|
|
1022
|
+
"""
|
|
1023
|
+
1-D sine, taking the point as a one-element array - the convention used
|
|
1024
|
+
throughout the differentiation suite - so the smoke call exercises the
|
|
1025
|
+
same input shape the rest of the tests do.
|
|
1026
|
+
"""
|
|
1027
|
+
coordinates = np.asarray(point, dtype=float)
|
|
1028
|
+
return float(np.sin(coordinates[0]))
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _smoke_objective(point: Any) -> float:
|
|
1032
|
+
"""A convex bowl with its minimum at (3, -1), for the optimizer smoke."""
|
|
1033
|
+
coordinates = np.asarray(point, dtype=float)
|
|
1034
|
+
return float((coordinates[0] - 3.0) ** 2 + (coordinates[1] + 1.0) ** 2)
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def _smoke_objective_gradient(point: Any) -> np.ndarray:
|
|
1038
|
+
"""Analytical gradient of `_smoke_objective`."""
|
|
1039
|
+
coordinates = np.asarray(point, dtype=float)
|
|
1040
|
+
return np.array([
|
|
1041
|
+
2.0 * (coordinates[0] - 3.0),
|
|
1042
|
+
2.0 * (coordinates[1] + 1.0),
|
|
1043
|
+
])
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _smoke_identity_gradient(point: Any) -> np.ndarray:
|
|
1047
|
+
"""
|
|
1048
|
+
The gradient of f(x) = 0.5 * x^T * x, which is simply x itself - the
|
|
1049
|
+
identity map.
|
|
1050
|
+
|
|
1051
|
+
`numerical_hessian_vector_product` differentiates a gradient, not a scalar
|
|
1052
|
+
function, so the first argument it expects is grad_f, not f. This helper
|
|
1053
|
+
is that grad_f for the trivial bowl above; its Jacobian (the Hessian of f)
|
|
1054
|
+
is the identity, so for any direction v the product H @ v equals v.
|
|
1055
|
+
"""
|
|
1056
|
+
return np.asarray(point, dtype=float)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def section_f_end_to_end_smoke() -> None:
|
|
1060
|
+
"""
|
|
1061
|
+
Call each public entry point once on a trivial known-answer problem -
|
|
1062
|
+
the first derivative of sin at 0, the minimiser of a convex bowl, and a
|
|
1063
|
+
Hessian-vector product whose Hessian is the identity - and confirm each
|
|
1064
|
+
lands on the right value within a loose smoke band and returns the
|
|
1065
|
+
documented wrapper type.
|
|
1066
|
+
"""
|
|
1067
|
+
|
|
1068
|
+
section_banner("SECTION F - End-to-end smoke test")
|
|
1069
|
+
|
|
1070
|
+
import ripples
|
|
1071
|
+
|
|
1072
|
+
# differentiation: d/dx sin(x) at 0 is 1
|
|
1073
|
+
test_block(
|
|
1074
|
+
test_name="F.smoke.first_derivative",
|
|
1075
|
+
description=(
|
|
1076
|
+
"verify nth_numerical_derivative computes d/dx sin(x) at 0 as 1, "
|
|
1077
|
+
"passing the 1-D sine taking a one-element point array and "
|
|
1078
|
+
"evaluating the first-derivative callable; this is the smallest "
|
|
1079
|
+
"end-to-end run of the differentiation path, and a wrong or "
|
|
1080
|
+
"erroring answer means the installed code does not actually "
|
|
1081
|
+
"compute, however cleanly it imported."
|
|
1082
|
+
),
|
|
1083
|
+
)
|
|
1084
|
+
first_derivative_callable = ripples.nth_numerical_derivative(
|
|
1085
|
+
_smoke_sine_of_first_coordinate, derivative_order=1,
|
|
1086
|
+
)
|
|
1087
|
+
first_derivative_result = first_derivative_callable(np.array([0.0]))
|
|
1088
|
+
check_allclose(
|
|
1089
|
+
test_name="F.smoke.first_derivative",
|
|
1090
|
+
actual_value=float(first_derivative_result.as_float()),
|
|
1091
|
+
expected_value=1.0,
|
|
1092
|
+
relative_tolerance=1e-4, absolute_tolerance=1e-6,
|
|
1093
|
+
interpretation="the differentiation path runs and is correct here.",
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
test_block(
|
|
1097
|
+
test_name="F.smoke.differentiation_result_type",
|
|
1098
|
+
description=(
|
|
1099
|
+
"verify the differentiation call returns a DifferentiationResult, "
|
|
1100
|
+
"checking the wrapper type of the value just produced; the "
|
|
1101
|
+
"documented return type is the contract downstream code is written "
|
|
1102
|
+
"against, so a bare ndarray slipping through would break callers "
|
|
1103
|
+
"that expect the wrapper's metadata."
|
|
1104
|
+
),
|
|
1105
|
+
)
|
|
1106
|
+
check_truth(
|
|
1107
|
+
test_name="F.smoke.differentiation_result_type",
|
|
1108
|
+
condition=isinstance(
|
|
1109
|
+
first_derivative_result, ripples.DifferentiationResult
|
|
1110
|
+
),
|
|
1111
|
+
message_on_pass="the result is a DifferentiationResult as documented.",
|
|
1112
|
+
message_on_fail="the result is not a DifferentiationResult.",
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
# optimization: minimiser of a convex bowl
|
|
1116
|
+
test_block(
|
|
1117
|
+
test_name="F.smoke.minimise_bowl",
|
|
1118
|
+
description=(
|
|
1119
|
+
"verify minimizer locates the minimum (3, -1) of a convex bowl "
|
|
1120
|
+
"from the origin with BFGS and the analytical gradient, then check "
|
|
1121
|
+
"the minimiser within a loose band; this is the smallest "
|
|
1122
|
+
"end-to-end run of the optimization path and confirms the "
|
|
1123
|
+
"installed solver converges rather than merely imports."
|
|
1124
|
+
),
|
|
1125
|
+
)
|
|
1126
|
+
optimization_result = ripples.minimizer(
|
|
1127
|
+
function=_smoke_objective,
|
|
1128
|
+
method="bfgs",
|
|
1129
|
+
initial_params=[0.0, 0.0],
|
|
1130
|
+
gradient_function=_smoke_objective_gradient,
|
|
1131
|
+
)
|
|
1132
|
+
check_allclose(
|
|
1133
|
+
test_name="F.smoke.minimise_bowl",
|
|
1134
|
+
actual_value=np.asarray(optimization_result.final_params),
|
|
1135
|
+
expected_value=np.array([3.0, -1.0]),
|
|
1136
|
+
relative_tolerance=1e-3, absolute_tolerance=1e-3,
|
|
1137
|
+
interpretation="the optimization path runs and converges here.",
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
test_block(
|
|
1141
|
+
test_name="F.smoke.optimization_result_type",
|
|
1142
|
+
description=(
|
|
1143
|
+
"verify the minimiser call returns an OptimizationResult, checking "
|
|
1144
|
+
"the wrapper type of the value just produced; the documented "
|
|
1145
|
+
"return type carries the cost, counts, and termination reason "
|
|
1146
|
+
"callers rely on, so the contract has to hold on the installed "
|
|
1147
|
+
"package."
|
|
1148
|
+
),
|
|
1149
|
+
)
|
|
1150
|
+
check_truth(
|
|
1151
|
+
test_name="F.smoke.optimization_result_type",
|
|
1152
|
+
condition=isinstance(
|
|
1153
|
+
optimization_result, ripples.OptimizationResult
|
|
1154
|
+
),
|
|
1155
|
+
message_on_pass="the result is an OptimizationResult as documented.",
|
|
1156
|
+
message_on_fail="the result is not an OptimizationResult.",
|
|
1157
|
+
)
|
|
1158
|
+
|
|
1159
|
+
# Hessian-vector product: the gradient is the identity, so H @ v = v
|
|
1160
|
+
test_block(
|
|
1161
|
+
test_name="F.smoke.hessian_vector_product",
|
|
1162
|
+
description=(
|
|
1163
|
+
"verify numerical_hessian_vector_product returns v unchanged when "
|
|
1164
|
+
"given the identity-map gradient (the gradient of "
|
|
1165
|
+
"f(x) = 0.5 * x^T * x, whose Hessian is therefore I), evaluating "
|
|
1166
|
+
"H @ v at a fixed point and direction; the entry point "
|
|
1167
|
+
"differentiates a gradient rather than a scalar function, and the "
|
|
1168
|
+
"identity Hessian gives an exact expected answer for a clean "
|
|
1169
|
+
"end-to-end check of the matrix-free path that touches no full "
|
|
1170
|
+
"Hessian."
|
|
1171
|
+
),
|
|
1172
|
+
)
|
|
1173
|
+
probe_point = np.array([1.0, 2.0, 3.0])
|
|
1174
|
+
probe_direction = np.array([1.0, 0.0, -2.0])
|
|
1175
|
+
hessian_vector_result = ripples.numerical_hessian_vector_product(
|
|
1176
|
+
_smoke_identity_gradient,
|
|
1177
|
+
point=probe_point,
|
|
1178
|
+
vector=probe_direction,
|
|
1179
|
+
)
|
|
1180
|
+
check_allclose(
|
|
1181
|
+
test_name="F.smoke.hessian_vector_product",
|
|
1182
|
+
actual_value=np.asarray(hessian_vector_result),
|
|
1183
|
+
expected_value=probe_direction,
|
|
1184
|
+
relative_tolerance=1e-4, absolute_tolerance=1e-6,
|
|
1185
|
+
interpretation="the matrix-free HVP path runs and is correct here.",
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
# MAIN ENTRY POINT
|
|
1191
|
+
#
|
|
1192
|
+
# `run()` is the single orchestration point for the installation suite, shared
|
|
1193
|
+
# by every way of launching it: the `ripples.test("installation")` dispatcher,
|
|
1194
|
+
# the `python -m ripples._test` command line, and the pytest bridge below all
|
|
1195
|
+
# come through here, so the output and the verdicts are identical no matter how
|
|
1196
|
+
# the suite is started.
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def run(include_benchmarks: bool = True) -> Reporter:
|
|
1201
|
+
"""
|
|
1202
|
+
Execute the installation-integrity suite end to end.
|
|
1203
|
+
|
|
1204
|
+
Parameters
|
|
1205
|
+
----------
|
|
1206
|
+
include_benchmarks : bool, default True
|
|
1207
|
+
Accepted for signature parity with the submodule suites so the unified
|
|
1208
|
+
dispatcher can drive every `run()` the same way. The installation suite
|
|
1209
|
+
has no timing-only sections, so the flag has no effect here.
|
|
1210
|
+
|
|
1211
|
+
Returns
|
|
1212
|
+
-------
|
|
1213
|
+
Reporter
|
|
1214
|
+
The reporter holding the run's tallies (`passed`, `failed`,
|
|
1215
|
+
`skipped`, `info`) and its failure records. It is returned rather
|
|
1216
|
+
than a bare count so a caller aggregating several suites - as
|
|
1217
|
+
`test("all")` does - can fold these numbers into one combined summary.
|
|
1218
|
+
"""
|
|
1219
|
+
|
|
1220
|
+
# A fresh slate on every call: the module-level reporter is reused, so
|
|
1221
|
+
# without this a second run would keep adding to the first run's counts.
|
|
1222
|
+
REPORT.reset()
|
|
1223
|
+
|
|
1224
|
+
ordered_sections = [
|
|
1225
|
+
section_a_import_and_metadata,
|
|
1226
|
+
section_b_runtime_environment,
|
|
1227
|
+
section_c_public_api_surface,
|
|
1228
|
+
section_d_submodule_reexport_identity,
|
|
1229
|
+
section_e_test_suite_wiring,
|
|
1230
|
+
section_f_end_to_end_smoke,
|
|
1231
|
+
]
|
|
1232
|
+
|
|
1233
|
+
print("=" * SEPARATOR_WIDTH)
|
|
1234
|
+
print(
|
|
1235
|
+
" ripples - installation-integrity suite ".center(SEPARATOR_WIDTH, "=")
|
|
1236
|
+
)
|
|
1237
|
+
print("=" * SEPARATOR_WIDTH)
|
|
1238
|
+
print(
|
|
1239
|
+
"Reading order: each block states what it checks and why it matters, "
|
|
1240
|
+
"then\nprints the RESULT and a VERDICT (pass / fail / info)."
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
for section_runner in ordered_sections:
|
|
1244
|
+
try:
|
|
1245
|
+
section_runner()
|
|
1246
|
+
except Exception as section_exception:
|
|
1247
|
+
# An exception escaping a section is itself a failure; record it,
|
|
1248
|
+
# print the traceback, and carry on with the next section so a
|
|
1249
|
+
# single fault cannot abort the whole suite.
|
|
1250
|
+
import traceback
|
|
1251
|
+
REPORT.record_fail(
|
|
1252
|
+
test_name=section_runner.__name__,
|
|
1253
|
+
message=(
|
|
1254
|
+
f"section raised an unhandled "
|
|
1255
|
+
f"{type(section_exception).__name__}: "
|
|
1256
|
+
f"{section_exception}"
|
|
1257
|
+
),
|
|
1258
|
+
)
|
|
1259
|
+
print()
|
|
1260
|
+
print(" UNCAUGHT EXCEPTION ".center(SEPARATOR_WIDTH, "!"))
|
|
1261
|
+
traceback.print_exc()
|
|
1262
|
+
print("!" * SEPARATOR_WIDTH)
|
|
1263
|
+
|
|
1264
|
+
REPORT.print_summary()
|
|
1265
|
+
return REPORT
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# UNIFIED DISPATCHER
|
|
1270
|
+
#
|
|
1271
|
+
# `test()` is the one front door named in the README and in both submodule
|
|
1272
|
+
# suites. It routes to the installation checks above, to a single submodule
|
|
1273
|
+
# suite, or to everything at once. In the "all" case it runs each suite in
|
|
1274
|
+
# turn - each prints its own report - and then folds the per-suite tallies into
|
|
1275
|
+
# one combined summary, so a caller sees both the detail and the grand total.
|
|
1276
|
+
#
|
|
1277
|
+
# It is re-exported at the top level from ripples/__init__.py (`from ._test
|
|
1278
|
+
# import test`, with 'test' listed in the package's public surface), which is
|
|
1279
|
+
# what makes `ripples.test(...)` resolve to the function defined here.
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
# The targets `test()` understands, each mapped to a short list of accepted
|
|
1284
|
+
# spellings, so a caller can write the obvious word and have it resolve.
|
|
1285
|
+
_TARGET_ALIASES = {
|
|
1286
|
+
"installation": ("installation", "install", "root", "integrity"),
|
|
1287
|
+
"differentiation": ("differentiation", "diff"),
|
|
1288
|
+
"optimization": ("optimization", "optimisation", "opt"),
|
|
1289
|
+
"all": ("all", "everything", "full"),
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _canonical_target(requested_target: str) -> str:
|
|
1294
|
+
"""
|
|
1295
|
+
Map a requested target spelling to its canonical key.
|
|
1296
|
+
|
|
1297
|
+
Raises
|
|
1298
|
+
------
|
|
1299
|
+
TypeError
|
|
1300
|
+
If `requested_target` is not a string.
|
|
1301
|
+
ValueError
|
|
1302
|
+
If the string does not match any of the recognised target aliases.
|
|
1303
|
+
"""
|
|
1304
|
+
if not isinstance(requested_target, str):
|
|
1305
|
+
raise TypeError(
|
|
1306
|
+
f"test target must be a string, got "
|
|
1307
|
+
f"{type(requested_target).__name__}: {requested_target!r}."
|
|
1308
|
+
)
|
|
1309
|
+
requested_lower = requested_target.strip().lower()
|
|
1310
|
+
for canonical_name, accepted_spellings in _TARGET_ALIASES.items():
|
|
1311
|
+
if requested_lower in accepted_spellings:
|
|
1312
|
+
return canonical_name
|
|
1313
|
+
raise ValueError(
|
|
1314
|
+
f"unknown test target {requested_target!r}; choose one of "
|
|
1315
|
+
f"'installation', 'differentiation', 'optimization', or 'all'."
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _print_combined_summary(
|
|
1320
|
+
named_reporters: List[Tuple[str, Reporter]],
|
|
1321
|
+
) -> None:
|
|
1322
|
+
"""Print one grand-total table across several suites' reporters."""
|
|
1323
|
+
|
|
1324
|
+
print()
|
|
1325
|
+
print("=" * SEPARATOR_WIDTH)
|
|
1326
|
+
print(" COMBINED SUMMARY (ALL SUITES) ".center(SEPARATOR_WIDTH, "="))
|
|
1327
|
+
print("=" * SEPARATOR_WIDTH)
|
|
1328
|
+
print(f" {'suite':<20}{'passed':>9}{'failed':>9}"
|
|
1329
|
+
f"{'skipped':>9}{'info':>9}")
|
|
1330
|
+
print(" " + "-" * (SEPARATOR_WIDTH - 4))
|
|
1331
|
+
|
|
1332
|
+
total_passed = total_failed = total_skipped = total_info = 0
|
|
1333
|
+
for suite_name, suite_reporter in named_reporters:
|
|
1334
|
+
total_passed += suite_reporter.passed
|
|
1335
|
+
total_failed += suite_reporter.failed
|
|
1336
|
+
total_skipped += suite_reporter.skipped
|
|
1337
|
+
total_info += suite_reporter.info
|
|
1338
|
+
print(f" {suite_name:<20}{suite_reporter.passed:>9}"
|
|
1339
|
+
f"{suite_reporter.failed:>9}{suite_reporter.skipped:>9}"
|
|
1340
|
+
f"{suite_reporter.info:>9}")
|
|
1341
|
+
|
|
1342
|
+
print(" " + "-" * (SEPARATOR_WIDTH - 4))
|
|
1343
|
+
print(f" {'TOTAL':<20}{total_passed:>9}{total_failed:>9}"
|
|
1344
|
+
f"{total_skipped:>9}{total_info:>9}")
|
|
1345
|
+
print("=" * SEPARATOR_WIDTH)
|
|
1346
|
+
if total_failed == 0:
|
|
1347
|
+
print(" All suites passed.")
|
|
1348
|
+
else:
|
|
1349
|
+
print(f" {total_failed} check(s) failed across all suites; "
|
|
1350
|
+
f"see each suite's own FAILURES block above.")
|
|
1351
|
+
print("=" * SEPARATOR_WIDTH)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
def test(
|
|
1356
|
+
which: Literal[
|
|
1357
|
+
'installation', 'differentiation', 'optimization', 'all'
|
|
1358
|
+
] = "all",
|
|
1359
|
+
include_benchmarks: bool = True
|
|
1360
|
+
) -> int:
|
|
1361
|
+
"""
|
|
1362
|
+
Run the Ripples test suites and return the number of failing checks.
|
|
1363
|
+
|
|
1364
|
+
This is the unified entry point referenced throughout the documentation.
|
|
1365
|
+
It runs the installation-integrity checks defined in this module, a single
|
|
1366
|
+
submodule suite, or every suite at once, and returns the total failure
|
|
1367
|
+
count so the result reads as a truthiness gate: ``0`` means everything
|
|
1368
|
+
passed.
|
|
1369
|
+
|
|
1370
|
+
Parameters
|
|
1371
|
+
----------
|
|
1372
|
+
which : 'installation', 'differentiation', 'optimization' or 'all', \
|
|
1373
|
+
default 'all'
|
|
1374
|
+
Which suite to run. Accepted (case-insensitive) targets:
|
|
1375
|
+
|
|
1376
|
+
- ``'installation'`` - the integrity checks in this module only.
|
|
1377
|
+
- ``'differentiation'`` - the differentiation suite only.
|
|
1378
|
+
- ``'optimization'`` - the optimization suite only.
|
|
1379
|
+
- ``'all'`` - the installation checks followed by both submodule
|
|
1380
|
+
suites, with a combined summary printed at the end.
|
|
1381
|
+
|
|
1382
|
+
Common alternative spellings (``'install'``, ``'diff'``, ``'opt'``,
|
|
1383
|
+
``'everything'``, ...) are accepted as well.
|
|
1384
|
+
include_benchmarks : bool, default True
|
|
1385
|
+
Forwarded unchanged to each submodule suite, where it toggles the
|
|
1386
|
+
timing-only sections. The installation suite has no benchmarks, so the
|
|
1387
|
+
flag does not affect that part of an ``'all'`` run.
|
|
1388
|
+
|
|
1389
|
+
Returns
|
|
1390
|
+
-------
|
|
1391
|
+
int
|
|
1392
|
+
The number of failing checks across whatever was run; ``0`` on a fully
|
|
1393
|
+
passing run, which is also the convention continuous-integration
|
|
1394
|
+
systems read from the process exit code.
|
|
1395
|
+
|
|
1396
|
+
Raises
|
|
1397
|
+
------
|
|
1398
|
+
TypeError
|
|
1399
|
+
If `which` is not a string.
|
|
1400
|
+
ValueError
|
|
1401
|
+
If `which` is not one of the recognised targets.
|
|
1402
|
+
|
|
1403
|
+
Examples
|
|
1404
|
+
--------
|
|
1405
|
+
>>> import ripples
|
|
1406
|
+
>>> ripples.test("installation") # doctest: +SKIP
|
|
1407
|
+
>>> ripples.test() # every suite, one summary # doctest: +SKIP
|
|
1408
|
+
"""
|
|
1409
|
+
|
|
1410
|
+
canonical_target = _canonical_target(which)
|
|
1411
|
+
|
|
1412
|
+
if canonical_target == "installation":
|
|
1413
|
+
return run(include_benchmarks=include_benchmarks).failed
|
|
1414
|
+
|
|
1415
|
+
if canonical_target == "differentiation":
|
|
1416
|
+
differentiation_tests = importlib.import_module(
|
|
1417
|
+
"ripples.differentiation._test"
|
|
1418
|
+
)
|
|
1419
|
+
return differentiation_tests.run(
|
|
1420
|
+
include_benchmarks=include_benchmarks
|
|
1421
|
+
).failed
|
|
1422
|
+
|
|
1423
|
+
if canonical_target == "optimization":
|
|
1424
|
+
optimization_tests = importlib.import_module(
|
|
1425
|
+
"ripples.optimization._test"
|
|
1426
|
+
)
|
|
1427
|
+
return optimization_tests.run(
|
|
1428
|
+
include_benchmarks=include_benchmarks
|
|
1429
|
+
).failed
|
|
1430
|
+
|
|
1431
|
+
# canonical_target == "all": run each suite in turn, then combine.
|
|
1432
|
+
differentiation_tests = importlib.import_module(
|
|
1433
|
+
"ripples.differentiation._test"
|
|
1434
|
+
)
|
|
1435
|
+
optimization_tests = importlib.import_module(
|
|
1436
|
+
"ripples.optimization._test"
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
named_reporters: List[Tuple[str, Reporter]] = [
|
|
1440
|
+
("installation", run(include_benchmarks=include_benchmarks)),
|
|
1441
|
+
(
|
|
1442
|
+
"differentiation",
|
|
1443
|
+
differentiation_tests.run(include_benchmarks=include_benchmarks),
|
|
1444
|
+
),
|
|
1445
|
+
(
|
|
1446
|
+
"optimization",
|
|
1447
|
+
optimization_tests.run(include_benchmarks=include_benchmarks),
|
|
1448
|
+
),
|
|
1449
|
+
]
|
|
1450
|
+
|
|
1451
|
+
_print_combined_summary(named_reporters)
|
|
1452
|
+
return sum(suite_reporter.failed for _, suite_reporter in named_reporters)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
|
|
1456
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
1457
|
+
"""
|
|
1458
|
+
Command-line entry point for `python -m ripples._test`.
|
|
1459
|
+
|
|
1460
|
+
By default it runs the installation-integrity suite alone. With ``--all``
|
|
1461
|
+
it chains the two submodule suites after it and prints a combined summary;
|
|
1462
|
+
with ``--no-benchmarks`` it forwards `include_benchmarks=False` to whatever
|
|
1463
|
+
it runs. Returns the number of failures so the process exit code is 0 on a
|
|
1464
|
+
clean run and non-zero otherwise - the convention continuous-integration
|
|
1465
|
+
systems read.
|
|
1466
|
+
|
|
1467
|
+
The CLI dispatches through `ripples.test` rather than the local `test`
|
|
1468
|
+
defined below, because `python -m ripples._test` loads this source file
|
|
1469
|
+
twice (once as `ripples._test` during the package import, once again as
|
|
1470
|
+
`__main__` for the CLI run) and the two copies hold separate function
|
|
1471
|
+
objects; going through the package attribute ensures the identity check
|
|
1472
|
+
in Section E resolves to the same `test` object regardless of how the
|
|
1473
|
+
suite was launched.
|
|
1474
|
+
"""
|
|
1475
|
+
arguments = sys.argv[1:] if argv is None else argv
|
|
1476
|
+
include_benchmarks = "--no-benchmarks" not in arguments
|
|
1477
|
+
target = "all" if "--all" in arguments else "installation"
|
|
1478
|
+
import ripples
|
|
1479
|
+
return ripples.test(which=target, include_benchmarks=include_benchmarks)
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
# PYTEST BRIDGE
|
|
1484
|
+
#
|
|
1485
|
+
# A single function so that pytest - which continuous integration runs on every
|
|
1486
|
+
# push - actually gates on this suite. It drives the same `run()` as every
|
|
1487
|
+
# other entry point (benchmarks off, since pytest is a correctness gate, not a
|
|
1488
|
+
# stopwatch) and turns a non-zero failure count into a failed test. The per-
|
|
1489
|
+
# test report is printed by `run()`; pytest shows it with `-s` or on failure.
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def test_installation_suite() -> None:
|
|
1494
|
+
"""Fail under pytest if any installation-integrity check fails."""
|
|
1495
|
+
reporter = run(include_benchmarks=False)
|
|
1496
|
+
assert reporter.failed == 0, (
|
|
1497
|
+
f"{reporter.failed} installation check(s) failed; "
|
|
1498
|
+
f"see the printed report for the what / why of each."
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
if __name__ == "__main__":
|
|
1504
|
+
sys.exit(main())
|