Marco Ricci commited on 2026-07-03 22:21:41
Zeige 10 geänderte Dateien mit 318 Einfügungen und 144 Löschungen.
Introduce a converter function for `pytest.mark.parametrize` calls to `hypothesis.example` calls. Further use this converter function to turn some parametrized unit tests into nominal property-based hypothesis tests. We use the same call signature as `pytest.mark.parametrize` for our converter function so that transition to hypothesis-managed test functions is very easy to write (just change the decorator name). We also stick to the obvious cases only—things that should be property-based tests but are currently parametrized tests, likely because we haven't yet made the effort or don't yet know how to write suitable hypothesis strategies. (Our implementation explicitly avoids a caveat with the hypothesis strategy `st.nothing`. Background at https://github.com/HypothesisWorks/hypothesis/issues/4774 . Because we rely on certain machinery from hypothesis, we adjust our minimum hypothesis version. Since we're here, we also adjust the pytest version, reflecting our actual use.) We also add TODOs for non-trivial future work, such as consolidating work across modules or adding new testing machinery.
| ... | ... |
@@ -70,11 +70,15 @@ dev = [ |
| 70 | 70 |
# the extra, and accept that for PyPy environments, `hatch test -p` |
| 71 | 71 |
# will not be able to distinguish logical and physical CPU core |
| 72 | 72 |
# counts. |
| 73 |
+ # |
|
| 74 |
+ # We use `hypothesis.is_hypothesis_test`, which was introduced in |
|
| 75 |
+ # 6.131.0, and `exc_type` for `pytest.importorskip`, which was |
|
| 76 |
+ # introduced in 8.2.0. |
|
| 73 | 77 |
"coverage[toml] >= 7.4", |
| 74 | 78 |
"coverage-enable-subprocess >= 1.0", |
| 75 |
- "hypothesis >= 6.0", |
|
| 79 |
+ "hypothesis >= 6.131.0", |
|
| 76 | 80 |
"packaging", |
| 77 |
- "pytest >= 8.1", |
|
| 81 |
+ "pytest >= 8.2", |
|
| 78 | 82 |
"pytest-randomly >= 3.15", |
| 79 | 83 |
"pytest-xdist >= 3.6.0", |
| 80 | 84 |
'pytest-xdist[psutil] >= 3.6.0; platform_python_implementation != "PyPy"', |
| ... | ... |
@@ -378,9 +382,9 @@ default-args = ['src', 'tests'] |
| 378 | 382 |
dependencies = [ |
| 379 | 383 |
"coverage[toml] >= 7.4", |
| 380 | 384 |
"coverage-enable-subprocess >= 1.0", |
| 381 |
- "hypothesis >= 6.0", |
|
| 385 |
+ "hypothesis >= 6.131.0", |
|
| 382 | 386 |
"packaging", |
| 383 |
- "pytest >= 8.1", |
|
| 387 |
+ "pytest >= 8.2", |
|
| 384 | 388 |
"pytest-randomly >= 3.15", |
| 385 | 389 |
"pytest-xdist >= 3.6.0", |
| 386 | 390 |
'pytest-xdist[psutil] >= 3.6.0; platform_python_implementation != "PyPy"', |
| ... | ... |
@@ -21,11 +21,14 @@ import importlib |
| 21 | 21 |
import importlib.util |
| 22 | 22 |
import math |
| 23 | 23 |
import sys |
| 24 |
-from typing import TYPE_CHECKING |
|
| 24 |
+from collections.abc import Sequence |
|
| 25 |
+from typing import TYPE_CHECKING, Callable |
|
| 25 | 26 |
|
| 26 | 27 |
import hypothesis |
| 27 | 28 |
import hypothesis.errors |
| 29 |
+from _pytest import mark as pytest_mark |
|
| 28 | 30 |
from hypothesis import strategies |
| 31 |
+from typing_extensions import assert_type |
|
| 29 | 32 |
|
| 30 | 33 |
from derivepassphrase import _types |
| 31 | 34 |
from tests import data, machinery |
| ... | ... |
@@ -33,7 +36,7 @@ from tests import data, machinery |
| 33 | 36 |
__all__ = () |
| 34 | 37 |
|
| 35 | 38 |
if TYPE_CHECKING: |
| 36 |
- from typing_extensions import Any |
|
| 39 |
+ from typing_extensions import Any, TypeIs |
|
| 37 | 40 |
|
| 38 | 41 |
|
| 39 | 42 |
# Hypothesis settings management |
| ... | ... |
@@ -325,3 +328,191 @@ def smudged_vault_test_config( |
| 325 | 328 |
service[key] = draw(strategies.sampled_from(falsy_no_zero)) |
| 326 | 329 |
hypothesis.assume(obj != conf.config) |
| 327 | 330 |
return data.VaultTestConfig(obj, conf.comment, conf.validation_settings) |
| 331 |
+ |
|
| 332 |
+ |
|
| 333 |
+# Hypothesis decorators |
|
| 334 |
+# ===================== |
|
| 335 |
+ |
|
| 336 |
+ |
|
| 337 |
+def _is_paramset( |
|
| 338 |
+ seq: Sequence[pytest_mark.ParameterSet] | Sequence[object], / |
|
| 339 |
+) -> TypeIs[Sequence[pytest_mark.ParameterSet]]: |
|
| 340 |
+ return all( |
|
| 341 |
+ isinstance(argvalue, pytest_mark.ParameterSet) for argvalue in seq |
|
| 342 |
+ ) |
|
| 343 |
+ |
|
| 344 |
+ |
|
| 345 |
+def _get_id( |
|
| 346 |
+ argvalue: object, |
|
| 347 |
+ ids: Sequence[str | None] | Callable[[Any], str | None] | None, |
|
| 348 |
+ i: int, |
|
| 349 |
+) -> str | None: |
|
| 350 |
+ if ( |
|
| 351 |
+ isinstance(argvalue, pytest_mark.ParameterSet) |
|
| 352 |
+ and argvalue.id is not None |
|
| 353 |
+ ): |
|
| 354 |
+ return argvalue.id |
|
| 355 |
+ if callable(ids): |
|
| 356 |
+ result = ids(argvalue) |
|
| 357 |
+ return result if result is not None else None |
|
| 358 |
+ if isinstance(ids, Sequence): |
|
| 359 |
+ result = ids[i] |
|
| 360 |
+ return result if result is not None else None |
|
| 361 |
+ assert_type(ids, None) |
|
| 362 |
+ return None |
|
| 363 |
+ |
|
| 364 |
+ |
|
| 365 |
+def _get_examples_data_and_ids( |
|
| 366 |
+ *, |
|
| 367 |
+ argnames: str | Sequence[str], |
|
| 368 |
+ argvalues: Sequence[pytest_mark.ParameterSet] | Sequence[object], |
|
| 369 |
+ ids: Sequence[str | None] | Callable[[Any], str | None] | None, |
|
| 370 |
+) -> tuple[list[dict[str, Any]], list[str | None]]: |
|
| 371 |
+ names = ( |
|
| 372 |
+ tuple(argnames.split(","))
|
|
| 373 |
+ if isinstance(argnames, str) |
|
| 374 |
+ else tuple(argnames) |
|
| 375 |
+ ) |
|
| 376 |
+ examples_data: list[dict[str, Any]] = [] |
|
| 377 |
+ final_ids: list[str | None] = [] |
|
| 378 |
+ k = len(names) |
|
| 379 |
+ |
|
| 380 |
+ if _is_paramset(argvalues): |
|
| 381 |
+ for i, argvalue in enumerate(argvalues): |
|
| 382 |
+ if len(argvalue.values) != k: # pragma: no cover [failsafe] |
|
| 383 |
+ msg = f"not a {k}-tuple: {argvalue!r}"
|
|
| 384 |
+ raise ValueError(msg) |
|
| 385 |
+ examples_data.append(dict(zip(names, argvalue.values))) |
|
| 386 |
+ final_ids.append(_get_id(argvalue, ids, i)) |
|
| 387 |
+ else: |
|
| 388 |
+ for i, argvalue in enumerate(argvalues): |
|
| 389 |
+ if k > 1: |
|
| 390 |
+ if ( |
|
| 391 |
+ not isinstance(argvalue, Sequence) or len(argvalue) != k |
|
| 392 |
+ ): # pragma: no cover [failsafe] |
|
| 393 |
+ msg = f"not a {k}-tuple: {argvalue!r}"
|
|
| 394 |
+ raise ValueError(msg) |
|
| 395 |
+ examples_data.append(dict(zip(names, argvalue))) |
|
| 396 |
+ else: |
|
| 397 |
+ examples_data.append({names[0]: argvalue})
|
|
| 398 |
+ final_ids.append(_get_id(argvalue, ids, i)) |
|
| 399 |
+ |
|
| 400 |
+ return examples_data, final_ids |
|
| 401 |
+ |
|
| 402 |
+ |
|
| 403 |
+def explicit_examples( |
|
| 404 |
+ argnames: str | Sequence[str], |
|
| 405 |
+ argvalues: Sequence[pytest_mark.ParameterSet] | Sequence[object], |
|
| 406 |
+ ids: Sequence[str | None] | Callable[[Any], str | None] | None = None, |
|
| 407 |
+ *, |
|
| 408 |
+ settings: hypothesis.settings | None = None, |
|
| 409 |
+) -> Callable[[Callable[..., None]], Callable[..., None]]: |
|
| 410 |
+ """Decorate a function to take explicit (hypothesis) examples. |
|
| 411 |
+ |
|
| 412 |
+ Using the same signature as [`pytest.mark.parametrize`][], we |
|
| 413 |
+ replicate the effect of decorating the function with multiple |
|
| 414 |
+ [`hypothesis.example`][] calls. This is useful in particular if the |
|
| 415 |
+ exact number of examples isn't known statically. If the decorated |
|
| 416 |
+ function is not already a hypothesis test, we also decorate the |
|
| 417 |
+ function with [`hypothesis.given`][] and [`hypothesis.settings`][], |
|
| 418 |
+ in a manner that only ever runs the explicit examples. One obvious |
|
| 419 |
+ way of writing the (sentinel) strategy to be passed to |
|
| 420 |
+ [`hypothesis.given`][] actually triggers |
|
| 421 |
+ [`hypothesis.errors.Unsatisfiable`][], so we are careful to avoid |
|
| 422 |
+ that ([Hypothesis bug #4774][H4774]). |
|
| 423 |
+ |
|
| 424 |
+ [H4774]: https://github.com/HypothesisWorks/hypothesis/issues/4774 |
|
| 425 |
+ |
|
| 426 |
+ Args: |
|
| 427 |
+ argnames: |
|
| 428 |
+ A comma-separated list of argument names, or a sequence of |
|
| 429 |
+ argument name strings. |
|
| 430 |
+ argvalues: |
|
| 431 |
+ A sequence of realizations for the named arguments. If |
|
| 432 |
+ only one argument name is given, then each realization must |
|
| 433 |
+ be a single value. Otherwise, each realization must be a |
|
| 434 |
+ (correctly sized) tuple of argument values. |
|
| 435 |
+ ids: |
|
| 436 |
+ An optional sequence of id strings to use for the |
|
| 437 |
+ respective realization, or a callable that takes the |
|
| 438 |
+ realization and returns an id string or `None`, or `None`. |
|
| 439 |
+ If `None` or if the callable returns `None`, and if |
|
| 440 |
+ additionally the argvalue is a pytest parameter set that |
|
| 441 |
+ has its `id` set, then use that `id`; else fall back to |
|
| 442 |
+ auto-generated IDs. |
|
| 443 |
+ |
|
| 444 |
+ Returns: |
|
| 445 |
+ A decorator that does the equivalent of |
|
| 446 |
+ |
|
| 447 |
+ @hypothesis.example( |
|
| 448 |
+ argnames[0]=argvalues[0][0], |
|
| 449 |
+ argnames[1]=argvalues[0][1], |
|
| 450 |
+ ... |
|
| 451 |
+ ) |
|
| 452 |
+ @hypothesis.example( |
|
| 453 |
+ argnames[1]=argvalues[1][0], |
|
| 454 |
+ argnames[1]=argvalues[1][1], |
|
| 455 |
+ ... |
|
| 456 |
+ ) |
|
| 457 |
+ ... |
|
| 458 |
+ @hypothesis.example( |
|
| 459 |
+ argnames[0]=argvalues[-1][0], |
|
| 460 |
+ argnames[1]=argvalues[-1][1], |
|
| 461 |
+ ... |
|
| 462 |
+ ) |
|
| 463 |
+ def f(...) -> ...: ... |
|
| 464 |
+ |
|
| 465 |
+ and, if not already a hypothesis test, prepends |
|
| 466 |
+ |
|
| 467 |
+ @hypothesis.given( |
|
| 468 |
+ argnames[0]=strategies.just(argvalues[0][0]), |
|
| 469 |
+ argnames[1]=strategies.just(argvalues[0][1]), |
|
| 470 |
+ ... |
|
| 471 |
+ ) |
|
| 472 |
+ |
|
| 473 |
+ Warning: |
|
| 474 |
+ Because of the way [`pytest.mark.parametrize`][] and |
|
| 475 |
+ [`hypothesis.example`][] work, having multiple |
|
| 476 |
+ `explicit_examples` decorators behaves differently from |
|
| 477 |
+ multiple [`pytest.mark.parametrize`][] decorators, and |
|
| 478 |
+ is not drop-in compatible. `explicit_examples` adds |
|
| 479 |
+ *more examples on the same argument names*, whereas |
|
| 480 |
+ [`pytest.mark.parametrize`][] adds *more argument names |
|
| 481 |
+ and values to the same set of existing examples*! |
|
| 482 |
+ |
|
| 483 |
+ """ |
|
| 484 |
+ if not argnames or not argvalues: # pragma: no cover [failsafe] |
|
| 485 |
+ msg = "no explicit examples given" |
|
| 486 |
+ raise ValueError(msg) |
|
| 487 |
+ |
|
| 488 |
+ examples_data, final_ids = _get_examples_data_and_ids( |
|
| 489 |
+ argnames=argnames, argvalues=argvalues, ids=ids |
|
| 490 |
+ ) |
|
| 491 |
+ |
|
| 492 |
+ def decorator(f: Callable[..., None], /) -> Callable[..., None]: |
|
| 493 |
+ ret = f |
|
| 494 |
+ for i, arg in reversed(list(enumerate(examples_data))): |
|
| 495 |
+ ex_id = final_ids[i] |
|
| 496 |
+ ex = hypothesis.example(**arg) |
|
| 497 |
+ ex = ( |
|
| 498 |
+ ex.via("id={!r}".format(str(ex_id))) # noqa: UP032
|
|
| 499 |
+ if ex_id is not None |
|
| 500 |
+ else ex |
|
| 501 |
+ ) |
|
| 502 |
+ ret = ex(ret) |
|
| 503 |
+ if not hypothesis.is_hypothesis_test( |
|
| 504 |
+ f |
|
| 505 |
+ ): # pragma: no branch [external] |
|
| 506 |
+ kwargs = {
|
|
| 507 |
+ k: strategies.just(v) for k, v in examples_data[0].items() |
|
| 508 |
+ } |
|
| 509 |
+ given = hypothesis.given(**kwargs) |
|
| 510 |
+ ret = given(ret) |
|
| 511 |
+ new_settings = hypothesis.settings( |
|
| 512 |
+ parent=settings, |
|
| 513 |
+ phases=[hypothesis.Phase.explicit, hypothesis.Phase.reuse], |
|
| 514 |
+ ) |
|
| 515 |
+ ret = new_settings(ret) |
|
| 516 |
+ return ret |
|
| 517 |
+ |
|
| 518 |
+ return decorator |
| ... | ... |
@@ -389,6 +389,9 @@ class TestUserConfigurationFileOther: |
| 389 | 389 |
class TestSSHAgentAvailability: |
| 390 | 390 |
"""Tests concerning the availability of the SSH agent.""" |
| 391 | 391 |
|
| 392 |
+ # TODO(the-13th-letter): Decouple tests from exact knowledge of how |
|
| 393 |
+ # to unsupport a certain socket provider. Coordinate with |
|
| 394 |
+ # tests.test_derivepassphrase_ssh_agent.test_000_basic.TestConstructorFailures. |
|
| 392 | 395 |
def _unsupport_provider_and_force_use( |
| 393 | 396 |
self, |
| 394 | 397 |
provider: _types.BuiltinSSHAgentSocketProvider, |
| ... | ... |
@@ -12,6 +12,7 @@ import types |
| 12 | 12 |
from typing import TYPE_CHECKING |
| 13 | 13 |
|
| 14 | 14 |
import click.shell_completion |
| 15 |
+import hypothesis |
|
| 15 | 16 |
import pytest |
| 16 | 17 |
from typing_extensions import Any |
| 17 | 18 |
|
| ... | ... |
@@ -21,6 +22,7 @@ from derivepassphrase._internals import ( |
| 21 | 22 |
cli_machinery as cli_machinery, # noqa: PLC0414 |
| 22 | 23 |
) |
| 23 | 24 |
from tests import data, machinery |
| 25 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
| 24 | 26 |
from tests.machinery import pytest as pytest_machinery |
| 25 | 27 |
|
| 26 | 28 |
if TYPE_CHECKING: |
| ... | ... |
@@ -232,7 +234,7 @@ class Parametrize(types.SimpleNamespace): |
| 232 | 234 |
), |
| 233 | 235 |
], |
| 234 | 236 |
) |
| 235 |
- COMPLETABLE_SUBCOMMANDS = pytest.mark.parametrize( |
|
| 237 |
+ COMPLETABLE_SUBCOMMANDS = hypothesis_machinery.explicit_examples( |
|
| 236 | 238 |
["command_prefix", "incomplete", "completions"], |
| 237 | 239 |
[ |
| 238 | 240 |
pytest.param( |
| ... | ... |
@@ -249,7 +251,7 @@ class Parametrize(types.SimpleNamespace): |
| 249 | 251 |
), |
| 250 | 252 |
], |
| 251 | 253 |
) |
| 252 |
- COMPLETION_FUNCTION_INPUTS = pytest.mark.parametrize( |
|
| 254 |
+ COMPLETION_FUNCTION_INPUTS = hypothesis_machinery.explicit_examples( |
|
| 253 | 255 |
["config", "comp_func", "args", "incomplete", "results"], |
| 254 | 256 |
[ |
| 255 | 257 |
pytest.param( |
| ... | ... |
@@ -377,7 +379,7 @@ class Parametrize(types.SimpleNamespace): |
| 377 | 379 |
), |
| 378 | 380 |
], |
| 379 | 381 |
) |
| 380 |
- SERVICE_NAME_COMPLETION_INPUTS = pytest.mark.parametrize( |
|
| 382 |
+ SERVICE_NAME_COMPLETION_INPUTS = hypothesis_machinery.explicit_examples( |
|
| 381 | 383 |
["config", "key", "incomplete", "completions"], |
| 382 | 384 |
[ |
| 383 | 385 |
pytest.param( |
| ... | ... |
@@ -525,13 +527,19 @@ class Parametrize(types.SimpleNamespace): |
| 525 | 527 |
id="del_partial_specific", |
| 526 | 528 |
), |
| 527 | 529 |
], |
| 530 |
+ settings=hypothesis.settings( |
|
| 531 |
+ parent=hypothesis.settings(), |
|
| 532 |
+ suppress_health_check=[ |
|
| 533 |
+ hypothesis.HealthCheck.function_scoped_fixture |
|
| 534 |
+ ], |
|
| 535 |
+ ), |
|
| 528 | 536 |
) |
| 529 | 537 |
SERVICE_NAME_EXCEPTIONS = pytest.mark.parametrize( |
| 530 | 538 |
"exc_type", [RuntimeError, KeyError, ValueError] |
| 531 | 539 |
) |
| 532 | 540 |
INCOMPLETE = pytest.mark.parametrize("incomplete", ["", "partial"])
|
| 533 | 541 |
CONFIG_SETTING_MODE = pytest.mark.parametrize("mode", ["config", "import"])
|
| 534 |
- COMPLETABLE_ITEMS = pytest.mark.parametrize( |
|
| 542 |
+ COMPLETABLE_ITEMS = hypothesis_machinery.explicit_examples( |
|
| 535 | 543 |
["partial", "is_completable"], |
| 536 | 544 |
[ |
| 537 | 545 |
("", True),
|
| ... | ... |
@@ -756,6 +764,7 @@ class TestCLICompletabilityHandling(TestShellCompletion): |
| 756 | 764 |
completions: AbstractSet[str], |
| 757 | 765 |
) -> None: |
| 758 | 766 |
"""Completion skips incompletable items.""" |
| 767 |
+ caplog.clear() |
|
| 759 | 768 |
vault_config = config if mode == "config" else {"services": {}}
|
| 760 | 769 |
runner = machinery.CliRunner(mix_stderr=False) |
| 761 | 770 |
# TODO(the-13th-letter): Rewrite using parenthesized |
| ... | ... |
@@ -822,6 +831,8 @@ class TestCLICompletabilityHandling(TestShellCompletion): |
| 822 | 831 |
"", |
| 823 | 832 |
) |
| 824 | 833 |
|
| 834 |
+ # TODO(the-13th-letter): Reevaluate whether to keep this as a |
|
| 835 |
+ # non-hypothesis test. |
|
| 825 | 836 |
@Parametrize.SERVICE_NAME_EXCEPTIONS |
| 826 | 837 |
def test_handling_unexpected_exceptions( |
| 827 | 838 |
self, |
| ... | ... |
@@ -706,6 +706,8 @@ def provider_registry() -> dict[ |
| 706 | 706 |
} |
| 707 | 707 |
|
| 708 | 708 |
|
| 709 |
+# TODO(the-13th-letter): Rewrite the parametrization to be somewhat more |
|
| 710 |
+# transparent, but keep it this high-level if possible. |
|
| 709 | 711 |
class TestKeyExplicitSSHAgentSocketProvider(TestKeyBasic): |
| 710 | 712 |
"""Tests for SSH key configuration: explicit SSH agent socket providers.""" |
| 711 | 713 |
|
| ... | ... |
@@ -178,7 +178,7 @@ class Parametrize(types.SimpleNamespace): |
| 178 | 178 |
), |
| 179 | 179 |
], |
| 180 | 180 |
) |
| 181 |
- CONFIG_EDITING_VIA_CONFIG_FLAG = pytest.mark.parametrize( |
|
| 181 |
+ CONFIG_EDITING_VIA_CONFIG_FLAG = hypothesis_machinery.explicit_examples( |
|
| 182 | 182 |
["command_line", "input", "starting_config", "result_config"], |
| 183 | 183 |
[ |
| 184 | 184 |
pytest.param( |
| ... | ... |
@@ -19,6 +19,7 @@ import pytest |
| 19 | 19 |
from hypothesis import strategies |
| 20 | 20 |
|
| 21 | 21 |
from derivepassphrase import sequin |
| 22 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
| 22 | 23 |
|
| 23 | 24 |
if TYPE_CHECKING: |
| 24 | 25 |
from collections.abc import Sequence |
| ... | ... |
@@ -56,7 +57,7 @@ def bitseq(string: str) -> list[int]: |
| 56 | 57 |
|
| 57 | 58 |
|
| 58 | 59 |
class Parametrize(types.SimpleNamespace): |
| 59 |
- BIG_ENDIAN_NUMBER_EXCEPTIONS = pytest.mark.parametrize( |
|
| 60 |
+ BIG_ENDIAN_NUMBER_EXCEPTIONS = hypothesis_machinery.explicit_examples( |
|
| 60 | 61 |
["exc_type", "exc_pattern", "sequence", "base"], |
| 61 | 62 |
[ |
| 62 | 63 |
(ValueError, "invalid base 3 digit:", [-1], 3), |
| ... | ... |
@@ -64,7 +65,7 @@ class Parametrize(types.SimpleNamespace): |
| 64 | 65 |
(TypeError, "not an integer:", [0.0, 1.0, 0.0, 1.0], 2), |
| 65 | 66 |
], |
| 66 | 67 |
) |
| 67 |
- INVALID_SEQUIN_INPUTS = pytest.mark.parametrize( |
|
| 68 |
+ INVALID_SEQUIN_INPUTS = hypothesis_machinery.explicit_examples( |
|
| 68 | 69 |
["sequence", "is_bitstring", "exc_type", "exc_pattern"], |
| 69 | 70 |
[ |
| 70 | 71 |
( |
| ... | ... |
@@ -27,6 +27,7 @@ from derivepassphrase._internals import cli_helpers, cli_messages |
| 27 | 27 |
from derivepassphrase.ssh_agent import socketprovider |
| 28 | 28 |
from tests import data, machinery |
| 29 | 29 |
from tests.data import callables |
| 30 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
| 30 | 31 |
from tests.machinery import pytest as pytest_machinery |
| 31 | 32 |
|
| 32 | 33 |
if TYPE_CHECKING: |
| ... | ... |
@@ -135,7 +136,7 @@ class Parametrize(types.SimpleNamespace): |
| 135 | 136 |
], |
| 136 | 137 |
ids=str, |
| 137 | 138 |
) |
| 138 |
- SSH_STRING_EXCEPTIONS = pytest.mark.parametrize( |
|
| 139 |
+ SSH_STRING_EXCEPTIONS = hypothesis_machinery.explicit_examples( |
|
| 139 | 140 |
["input", "exc_type", "exc_pattern"], |
| 140 | 141 |
[ |
| 141 | 142 |
pytest.param( |
| ... | ... |
@@ -143,7 +144,7 @@ class Parametrize(types.SimpleNamespace): |
| 143 | 144 |
), |
| 144 | 145 |
], |
| 145 | 146 |
) |
| 146 |
- UINT32_EXCEPTIONS = pytest.mark.parametrize( |
|
| 147 |
+ UINT32_EXCEPTIONS = hypothesis_machinery.explicit_examples( |
|
| 147 | 148 |
["input", "exc_type", "exc_pattern"], |
| 148 | 149 |
[ |
| 149 | 150 |
pytest.param( |
| ... | ... |
@@ -160,7 +161,7 @@ class Parametrize(types.SimpleNamespace): |
| 160 | 161 |
), |
| 161 | 162 |
], |
| 162 | 163 |
) |
| 163 |
- SSH_UNSTRING_EXCEPTIONS = pytest.mark.parametrize( |
|
| 164 |
+ SSH_UNSTRING_EXCEPTIONS = hypothesis_machinery.explicit_examples( |
|
| 164 | 165 |
["input", "exc_type", "exc_pattern", "parts"], |
| 165 | 166 |
[ |
| 166 | 167 |
pytest.param( |
| ... | ... |
@@ -186,7 +187,7 @@ class Parametrize(types.SimpleNamespace): |
| 186 | 187 |
), |
| 187 | 188 |
], |
| 188 | 189 |
) |
| 189 |
- SSH_STRING_INPUT = pytest.mark.parametrize( |
|
| 190 |
+ SSH_STRING_INPUT = hypothesis_machinery.explicit_examples( |
|
| 190 | 191 |
["input", "expected"], |
| 191 | 192 |
[ |
| 192 | 193 |
pytest.param( |
| ... | ... |
@@ -206,7 +207,7 @@ class Parametrize(types.SimpleNamespace): |
| 206 | 207 |
), |
| 207 | 208 |
], |
| 208 | 209 |
) |
| 209 |
- SSH_UNSTRING_INPUT = pytest.mark.parametrize( |
|
| 210 |
+ SSH_UNSTRING_INPUT = hypothesis_machinery.explicit_examples( |
|
| 210 | 211 |
["input", "expected"], |
| 211 | 212 |
[ |
| 212 | 213 |
pytest.param( |
| ... | ... |
@@ -221,21 +222,13 @@ class Parametrize(types.SimpleNamespace): |
| 221 | 222 |
), |
| 222 | 223 |
], |
| 223 | 224 |
) |
| 224 |
- UINT32_INPUT = pytest.mark.parametrize( |
|
| 225 |
+ UINT32_INPUT = hypothesis_machinery.explicit_examples( |
|
| 225 | 226 |
["input", "expected"], |
| 226 | 227 |
[ |
| 227 | 228 |
pytest.param(16777216, b"\x01\x00\x00\x00", id="16777216"), |
| 228 | 229 |
], |
| 229 | 230 |
) |
| 230 |
- IO_OPERATIONS_ON_HANDLES = pytest.mark.parametrize( |
|
| 231 |
- "io_operation", |
|
| 232 |
- [ |
|
| 233 |
- lambda handle: handle.recv(1), |
|
| 234 |
- lambda handle: handle.sendall(b"Hello, world!"), |
|
| 235 |
- ], |
|
| 236 |
- ids=["recv", "sendall"], |
|
| 237 |
- ) |
|
| 238 |
- SIGN_ERROR_RESPONSES = pytest.mark.parametrize( |
|
| 231 |
+ SIGN_ERROR_RESPONSES = hypothesis_machinery.explicit_examples( |
|
| 239 | 232 |
[ |
| 240 | 233 |
"key", |
| 241 | 234 |
"check", |
| ... | ... |
@@ -274,7 +267,7 @@ class Parametrize(types.SimpleNamespace): |
| 274 | 267 |
+ [(callables.list_keys_singleton()[0].key, True)], |
| 275 | 268 |
ids=[*data.SUPPORTED_KEYS.keys(), "singleton"], |
| 276 | 269 |
) |
| 277 |
- SH_EXPORT_LINES = pytest.mark.parametrize( |
|
| 270 |
+ SH_EXPORT_LINES = hypothesis_machinery.explicit_examples( |
|
| 278 | 271 |
["line", "env_name", "value"], |
| 279 | 272 |
[ |
| 280 | 273 |
pytest.param( |
| ... | ... |
@@ -375,7 +368,7 @@ class Parametrize(types.SimpleNamespace): |
| 375 | 368 |
list(data.SUPPORTED_KEYS.values()), |
| 376 | 369 |
ids=list(data.SUPPORTED_KEYS.keys()), |
| 377 | 370 |
) |
| 378 |
- REQUEST_ERROR_RESPONSES = pytest.mark.parametrize( |
|
| 371 |
+ REQUEST_ERROR_RESPONSES = hypothesis_machinery.explicit_examples( |
|
| 379 | 372 |
["request_code", "response_code", "exc_type", "exc_pattern"], |
| 380 | 373 |
[ |
| 381 | 374 |
pytest.param( |
| ... | ... |
@@ -391,7 +384,7 @@ class Parametrize(types.SimpleNamespace): |
| 391 | 384 |
), |
| 392 | 385 |
], |
| 393 | 386 |
) |
| 394 |
- TRUNCATED_AGENT_RESPONSES = pytest.mark.parametrize( |
|
| 387 |
+ TRUNCATED_AGENT_RESPONSES = hypothesis_machinery.explicit_examples( |
|
| 395 | 388 |
"response", |
| 396 | 389 |
[ |
| 397 | 390 |
b"\x00\x00", |
| ... | ... |
@@ -399,7 +392,7 @@ class Parametrize(types.SimpleNamespace): |
| 399 | 392 |
], |
| 400 | 393 |
ids=["in-header", "in-body"], |
| 401 | 394 |
) |
| 402 |
- LIST_KEYS_ERROR_RESPONSES = pytest.mark.parametrize( |
|
| 395 |
+ LIST_KEYS_ERROR_RESPONSES = hypothesis_machinery.explicit_examples( |
|
| 403 | 396 |
["response_code", "response", "exc_type", "exc_pattern"], |
| 404 | 397 |
[ |
| 405 | 398 |
pytest.param( |
| ... | ... |
@@ -425,7 +418,7 @@ class Parametrize(types.SimpleNamespace): |
| 425 | 418 |
), |
| 426 | 419 |
], |
| 427 | 420 |
) |
| 428 |
- QUERY_EXTENSIONS_MALFORMED_RESPONSES = pytest.mark.parametrize( |
|
| 421 |
+ QUERY_EXTENSIONS_MALFORMED_RESPONSES = hypothesis_machinery.explicit_examples( |
|
| 429 | 422 |
"response_data", |
| 430 | 423 |
[ |
| 431 | 424 |
pytest.param(b"\xde\xad\xbe\xef", id="truncated"), |
| ... | ... |
@@ -446,7 +439,7 @@ class Parametrize(types.SimpleNamespace): |
| 446 | 439 |
list(data.ALL_KEYS.items()), |
| 447 | 440 |
ids=data.ALL_KEYS.keys(), |
| 448 | 441 |
) |
| 449 |
- RESOLVE_CHAINS = pytest.mark.parametrize( |
|
| 442 |
+ RESOLVE_CHAINS = hypothesis_machinery.explicit_examples( |
|
| 450 | 443 |
["terminal", "chain"], |
| 451 | 444 |
[ |
| 452 | 445 |
pytest.param("callable", ["a"], id="callable-1"),
|
| ... | ... |
@@ -483,10 +476,29 @@ class Strategies: |
| 483 | 476 |
alphabet=strategies.characters(min_codepoint=1), min_size=1 |
| 484 | 477 |
).filter(cls.select_invalid_pipe_names) |
| 485 | 478 |
|
| 479 |
+ @staticmethod |
|
| 480 |
+ def io_operations_on_handles() -> strategies.SearchStrategy[ |
|
| 481 |
+ Callable[[_types.SSHAgentSocket], Any] |
|
| 482 |
+ ]: |
|
| 483 |
+ """Return an I/O operation on an SSH agent socket.""" |
|
| 484 |
+ return strategies.one_of( |
|
| 485 |
+ strategies.integers(min_value=0).map( |
|
| 486 |
+ lambda i: lambda handle: handle.recv(i) |
|
| 487 |
+ ), |
|
| 488 |
+ strategies.binary().map( |
|
| 489 |
+ lambda bs: lambda handle: handle.sendall(bs) |
|
| 490 |
+ ), |
|
| 491 |
+ ) |
|
| 492 |
+ |
|
| 486 | 493 |
|
| 487 | 494 |
class TestStaticFunctionality: |
| 488 | 495 |
"""Test the static functionality of the `ssh_agent` module.""" |
| 489 | 496 |
|
| 497 |
+ # TODO(the-13th-letter): Put this functionality into the |
|
| 498 |
+ # tests.data.callables module. It is nice to have for other tests as |
|
| 499 |
+ # well: `vault` tests, stubbed SSH agent tests, agent protocol |
|
| 500 |
+ # response queue tests, etc. Also add an unstring and a u32 |
|
| 501 |
+ # function. |
|
| 490 | 502 |
@staticmethod |
| 491 | 503 |
def as_ssh_string(bytestring: bytes) -> bytes: |
| 492 | 504 |
"""Return an encoded SSH string from a bytestring. |
| ... | ... |
@@ -667,6 +679,7 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality): |
| 667 | 679 |
for canon2 in canonical_functions: |
| 668 | 680 |
assert canon1(canon2(encoded)) == canon1(encoded) |
| 669 | 681 |
|
| 682 |
+ # TODO(the-13th-letter): Write a hypothesis strategy. |
|
| 670 | 683 |
@Parametrize.UINT32_EXCEPTIONS |
| 671 | 684 |
def test_uint32_exceptions( |
| 672 | 685 |
self, input: int, exc_type: type[Exception], exc_pattern: str |
| ... | ... |
@@ -676,6 +689,7 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality): |
| 676 | 689 |
with pytest.raises(exc_type, match=exc_pattern): |
| 677 | 690 |
uint32(input) |
| 678 | 691 |
|
| 692 |
+ # TODO(the-13th-letter): Write a hypothesis strategy. |
|
| 679 | 693 |
@Parametrize.SSH_STRING_EXCEPTIONS |
| 680 | 694 |
def test_string_exceptions( |
| 681 | 695 |
self, input: Any, exc_type: type[Exception], exc_pattern: str |
| ... | ... |
@@ -685,6 +699,7 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality): |
| 685 | 699 |
with pytest.raises(exc_type, match=exc_pattern): |
| 686 | 700 |
string(input) |
| 687 | 701 |
|
| 702 |
+ # TODO(the-13th-letter): Write a hypothesis strategy. |
|
| 688 | 703 |
@Parametrize.SSH_UNSTRING_EXCEPTIONS |
| 689 | 704 |
def test_unstring_exceptions( |
| 690 | 705 |
self, |
| ... | ... |
@@ -705,6 +720,10 @@ class TestSSHProtocolDatatypes(TestStaticFunctionality): |
| 705 | 720 |
unstring_prefix(input) |
| 706 | 721 |
|
| 707 | 722 |
|
| 723 |
+# TODO(the-13th-letter): Introduce a registry analyzer that decomposes |
|
| 724 |
+# the registry into chains. On this decomposed representation, provide |
|
| 725 |
+# strategies to draw chains, existing entries, non-existing entries, |
|
| 726 |
+# leaves, aliases, etc. |
|
| 708 | 727 |
class TestSSHAgentSocketProviderRegistry: |
| 709 | 728 |
"""Tests for the SSH agent socket provider registry.""" |
| 710 | 729 |
|
| ... | ... |
@@ -732,44 +751,6 @@ class TestSSHAgentSocketProviderRegistry: |
| 732 | 751 |
resolve(_types.BuiltinSSHAgentSocketProvider.STUB_AGENT) |
| 733 | 752 |
|
| 734 | 753 |
@Parametrize.RESOLVE_CHAINS |
| 735 |
- def test_resolve_chains( |
|
| 736 |
- self, |
|
| 737 |
- terminal: Literal["unimplemented", "alias", "callable"], |
|
| 738 |
- chain: list[str], |
|
| 739 |
- ) -> None: |
|
| 740 |
- """Resolving a chain of providers works.""" |
|
| 741 |
- registry = socketprovider.SocketProvider.registry |
|
| 742 |
- resolve = socketprovider.SocketProvider.resolve |
|
| 743 |
- lookup = socketprovider.SocketProvider.lookup |
|
| 744 |
- try: |
|
| 745 |
- implementation = resolve( |
|
| 746 |
- _types.BuiltinSSHAgentSocketProvider.NATIVE |
|
| 747 |
- ) |
|
| 748 |
- except NotImplementedError: # pragma: no cover |
|
| 749 |
- pytest.fail("Native SSH agent socket provider is unavailable?!")
|
|
| 750 |
- # TODO(the-13th-letter): Rewrite using structural pattern matching. |
|
| 751 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
| 752 |
- target: _types.SSHAgentSocketProvider | str | None = ( |
|
| 753 |
- None |
|
| 754 |
- if terminal == "unimplemented" |
|
| 755 |
- else _types.BuiltinSSHAgentSocketProvider.NATIVE |
|
| 756 |
- if terminal == "alias" |
|
| 757 |
- else implementation |
|
| 758 |
- ) |
|
| 759 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
| 760 |
- for link in chain: |
|
| 761 |
- monkeypatch.setitem(registry, link, target) |
|
| 762 |
- target = link |
|
| 763 |
- for link in chain: |
|
| 764 |
- assert lookup(link) == ( |
|
| 765 |
- implementation if terminal != "unimplemented" else None |
|
| 766 |
- ) |
|
| 767 |
- if terminal == "unimplemented": |
|
| 768 |
- with pytest.raises(NotImplementedError): |
|
| 769 |
- resolve(link) |
|
| 770 |
- else: |
|
| 771 |
- assert resolve(link) == implementation |
|
| 772 |
- |
|
| 773 | 754 |
@hypothesis.given( |
| 774 | 755 |
terminal=strategies.sampled_from([ |
| 775 | 756 |
"unimplemented", |
| ... | ... |
@@ -793,7 +774,7 @@ class TestSSHAgentSocketProviderRegistry: |
| 793 | 774 |
unique=True, |
| 794 | 775 |
), |
| 795 | 776 |
) |
| 796 |
- def test_resolve_chains2( |
|
| 777 |
+ def test_resolve_chains( |
|
| 797 | 778 |
self, |
| 798 | 779 |
terminal: Literal["unimplemented", "alias", "callable"], |
| 799 | 780 |
chain: list[str], |
| ... | ... |
@@ -832,6 +813,8 @@ class TestSSHAgentSocketProviderRegistry: |
| 832 | 813 |
else: |
| 833 | 814 |
assert resolve(link) == implementation |
| 834 | 815 |
|
| 816 |
+ # TODO(the-13th-letter): Reevaluate whether this makes sense as a |
|
| 817 |
+ # property-based test. |
|
| 835 | 818 |
@Parametrize.GOOD_ENTRY_POINTS |
| 836 | 819 |
def test_find_all_socket_providers( |
| 837 | 820 |
self, |
| ... | ... |
@@ -851,6 +834,8 @@ class TestSSHAgentSocketProviderRegistry: |
| 851 | 834 |
*old_registry.values(), |
| 852 | 835 |
} |
| 853 | 836 |
|
| 837 |
+ # TODO(the-13th-letter): Reevaluate whether this makes sense as a |
|
| 838 |
+ # property-based test. |
|
| 854 | 839 |
@Parametrize.BAD_ENTRY_POINTS |
| 855 | 840 |
def test_find_all_socket_providers_errors( |
| 856 | 841 |
self, |
| ... | ... |
@@ -866,6 +851,8 @@ class TestSSHAgentSocketProviderRegistry: |
| 866 | 851 |
stack.enter_context(pytest.raises(AssertionError)) |
| 867 | 852 |
socketprovider.SocketProvider._find_all_ssh_agent_socket_providers() |
| 868 | 853 |
|
| 854 |
+ # TODO(the-13th-letter): Convert this to a property-based test once |
|
| 855 |
+ # the registry analyzer is ready. |
|
| 869 | 856 |
def test_overwriting_existing_entries( |
| 870 | 857 |
self, |
| 871 | 858 |
) -> None: |
| ... | ... |
@@ -911,6 +898,8 @@ class TestSSHAgentSocketProviderRegistry: |
| 911 | 898 |
with pytest.raises(ValueError, match="already registered"): |
| 912 | 899 |
register(names[1][0], names[0][1])(bases[1]) |
| 913 | 900 |
|
| 901 |
+ # TODO(the-13th-letter): Convert this to a property-based test once |
|
| 902 |
+ # the registry analyzer is ready. |
|
| 914 | 903 |
def test_resolve_non_existant_entries( |
| 915 | 904 |
self, |
| 916 | 905 |
) -> None: |
| ... | ... |
@@ -932,6 +921,8 @@ class TestSSHAgentSocketProviderRegistry: |
| 932 | 921 |
_types.BuiltinSSHAgentSocketProvider.NATIVE |
| 933 | 922 |
) |
| 934 | 923 |
|
| 924 |
+ # TODO(the-13th-letter): Convert this to a property-based test once |
|
| 925 |
+ # the registry analyzer is ready. |
|
| 935 | 926 |
def test_register_new_entry( |
| 936 | 927 |
self, |
| 937 | 928 |
) -> None: |
| ... | ... |
@@ -968,6 +959,8 @@ class TestSSHAgentSocketProviderRegistry: |
| 968 | 959 |
for n in names |
| 969 | 960 |
]) |
| 970 | 961 |
|
| 962 |
+ # TODO(the-13th-letter): Convert this to a property-based test once |
|
| 963 |
+ # the registry analyzer is ready. |
|
| 971 | 964 |
@Parametrize.EXISTING_REGISTRY_ENTRIES |
| 972 | 965 |
def test_register_old_entry( |
| 973 | 966 |
self, |
| ... | ... |
@@ -1163,6 +1156,9 @@ class TestSuitableKeys: |
| 1163 | 1156 |
class TestConstructorFailures: |
| 1164 | 1157 |
"""Test actually talking to the SSH agent: constructor failures.""" |
| 1165 | 1158 |
|
| 1159 |
+ # TODO(the-13th-letter): Decouple tests from exact knowledge of how |
|
| 1160 |
+ # to unsupport a certain socket provider. Coordinate with |
|
| 1161 |
+ # tests.test_derivepassphrase_cli.test_000_basic.TestSSHAgentAvailability. |
|
| 1166 | 1162 |
def _unsupport_provider_and_force_use( |
| 1167 | 1163 |
self, |
| 1168 | 1164 |
provider: _types.BuiltinSSHAgentSocketProvider, |
| ... | ... |
@@ -1450,6 +1446,8 @@ class TestAgentErrorResponses: |
| 1450 | 1446 |
_types.SSH_AGENT.SUCCESS, b"" |
| 1451 | 1447 |
) |
| 1452 | 1448 |
|
| 1449 |
+ # TODO(the-13th-letter): Reconsider whether to parametrize or |
|
| 1450 |
+ # hypothesize this test. |
|
| 1453 | 1451 |
@pytest.mark.parametrize( |
| 1454 | 1452 |
["success_after_query", "extensions"], |
| 1455 | 1453 |
[ |
| ... | ... |
@@ -1620,7 +1618,7 @@ class TestWindowsNamedPipeHandle: |
| 1620 | 1618 |
monkeypatch.setattr(socketprovider, "CloseHandle", close_handle) |
| 1621 | 1619 |
yield |
| 1622 | 1620 |
|
| 1623 |
- @Parametrize.IO_OPERATIONS_ON_HANDLES |
|
| 1621 |
+ @hypothesis.given(io_operation=Strategies.io_operations_on_handles()) |
|
| 1624 | 1622 |
def test_closed_handle_cannot_be_operated_on( |
| 1625 | 1623 |
self, |
| 1626 | 1624 |
io_operation: Callable[[Any], Any], |
| ... | ... |
@@ -1635,7 +1633,7 @@ class TestWindowsNamedPipeHandle: |
| 1635 | 1633 |
): |
| 1636 | 1634 |
io_operation(handle) |
| 1637 | 1635 |
|
| 1638 |
- @Parametrize.IO_OPERATIONS_ON_HANDLES |
|
| 1636 |
+ @hypothesis.given(io_operation=Strategies.io_operations_on_handles()) |
|
| 1639 | 1637 |
def test_handle_is_non_reentrant( |
| 1640 | 1638 |
self, |
| 1641 | 1639 |
io_operation: Callable[[Any], Any], |
| ... | ... |
@@ -83,12 +83,7 @@ class Parametrize(types.SimpleNamespace): |
| 83 | 83 |
(1, {"upper": 0, "lower": 0, "number": 0, "symbol": 0}, 0.0),
|
| 84 | 84 |
], |
| 85 | 85 |
) |
| 86 |
- MASTER_PASSPHRASE_TYPES = pytest.mark.parametrize( |
|
| 87 |
- ["phrase1", "phrase2"], |
|
| 88 |
- [(PHRASE.decode("UTF-8"), f(PHRASE)) for f in buffer_types.values()],
|
|
| 89 |
- ids=buffer_types.keys(), |
|
| 90 |
- ) |
|
| 91 |
- BINARY_STRINGS = pytest.mark.parametrize( |
|
| 86 |
+ BINARY_STRINGS = hypothesis_machinery.explicit_examples( |
|
| 92 | 87 |
"s", |
| 93 | 88 |
[ |
| 94 | 89 |
"ñ", |
| ... | ... |
@@ -108,11 +103,6 @@ class Parametrize(types.SimpleNamespace): |
| 108 | 103 |
], |
| 109 | 104 |
ids=["google", "twitter"], |
| 110 | 105 |
) |
| 111 |
- SERVICE_NAME_TYPES = pytest.mark.parametrize( |
|
| 112 |
- ["sv1", "sv2"], |
|
| 113 |
- [("email", f(b"email")) for f in buffer_types.values()],
|
|
| 114 |
- ids=buffer_types.keys(), |
|
| 115 |
- ) |
|
| 116 | 106 |
|
| 117 | 107 |
|
| 118 | 108 |
def phrases_are_interchangable( |
| ... | ... |
@@ -458,27 +448,14 @@ class TestStringAndBinaryExchangability(TestVault): |
| 458 | 448 |
|
| 459 | 449 |
""" |
| 460 | 450 |
|
| 461 |
- @Parametrize.SAMPLE_SERVICES_AND_PHRASES |
|
| 462 |
- @Parametrize.MASTER_PASSPHRASE_TYPES |
|
| 463 |
- def test_binary_phrases( |
|
| 464 |
- self, |
|
| 465 |
- phrase1: str, |
|
| 466 |
- phrase2: Buffer, |
|
| 467 |
- service: bytes | str, |
|
| 468 |
- expected: bytes, |
|
| 469 |
- ) -> None: |
|
| 470 |
- """Binary and text master passphrases generate the same passphrases.""" |
|
| 471 |
- v1 = vault.Vault(phrase=phrase1) |
|
| 472 |
- v2 = vault.Vault(phrase=phrase2) |
|
| 473 |
- assert v1.generate(service) == expected |
|
| 474 |
- assert v2.generate(service) == expected |
|
| 475 |
- |
|
| 476 |
- @Parametrize.SERVICE_NAME_TYPES |
|
| 477 |
- def test_binary_service_name(self, sv1: str, sv2: Buffer) -> None: |
|
| 478 |
- """Binary and text service names generate the same passphrases.""" |
|
| 479 |
- v = vault.Vault(phrase=self.phrase) |
|
| 480 |
- assert v.generate(sv1) == v.generate(sv2) |
|
| 481 |
- |
|
| 451 |
+ @hypothesis_machinery.explicit_examples( |
|
| 452 |
+ ["phrase", "service"], |
|
| 453 |
+ [ |
|
| 454 |
+ pytest.param(PHRASE.decode("UTF-8"), "google", id="google"),
|
|
| 455 |
+ pytest.param(PHRASE.decode("UTF-8"), "twitter", id="twitter"),
|
|
| 456 |
+ pytest.param(PHRASE.decode("UTF-8"), "email", id="email"),
|
|
| 457 |
+ ], |
|
| 458 |
+ ) |
|
| 482 | 459 |
@hypothesis.given( |
| 483 | 460 |
phrase=Strategies.text_strategy(), |
| 484 | 461 |
service=Strategies.text_strategy(), |
| ... | ... |
@@ -807,6 +784,18 @@ class TestUtilities(TestVault): |
| 807 | 784 |
"""Removing allowed characters internally works.""" |
| 808 | 785 |
assert vault.Vault._subtract(b"be", b"abcdef") == bytearray(b"acdf") |
| 809 | 786 |
|
| 787 |
+ # TODO(the-13th-letter): Strongly consider removing this test, and |
|
| 788 |
+ # the underlying functionality `Vault._entropy` and |
|
| 789 |
+ # `Vault._estimate_sufficient_hash_length`, in favor of a heuristic |
|
| 790 |
+ # estimate that works in "most cases". This test strongly suggests |
|
| 791 |
+ # that we know how to correctly calculate both the entropy of |
|
| 792 |
+ # a passphrase derivation template and a bound on how many bits of |
|
| 793 |
+ # entropy the template will actually need. We don't, neither the |
|
| 794 |
+ # template entropy (because the passphrase characters are not |
|
| 795 |
+ # stochastically independent of each other, thanks to mandatory |
|
| 796 |
+ # characters and repetition constraints) nor the actual used entropy |
|
| 797 |
+ # (because we still use some form of rejection sampling with |
|
| 798 |
+ # worst-case infinite retry chains). |
|
| 810 | 799 |
@Parametrize.ENTROPY_RESULTS |
| 811 | 800 |
def test_entropy( |
| 812 | 801 |
self, length: int, settings: dict[str, int], entropy: int |
| ... | ... |
@@ -821,44 +810,16 @@ class TestUtilities(TestVault): |
| 821 | 810 |
) |
| 822 | 811 |
assert v._estimate_sufficient_hash_length(8.0) >= entropy |
| 823 | 812 |
|
| 824 |
- # TODO(the-13th-letter): Either fold this version into test_entropy, |
|
| 825 |
- # or remove degenerate cases from Parametrize.ENTROPY_RESULTS. |
|
| 826 |
- def test_hash_length_estimation(self) -> None: |
|
| 827 |
- """ |
|
| 828 |
- Estimating the entropy and hash length for degenerate cases works. |
|
| 829 |
- """ |
|
| 830 |
- v = vault.Vault( |
|
| 831 |
- phrase=self.phrase, |
|
| 832 |
- lower=0, |
|
| 833 |
- upper=0, |
|
| 834 |
- number=0, |
|
| 835 |
- symbol=0, |
|
| 836 |
- space=1, |
|
| 837 |
- length=1, |
|
| 813 |
+ @Parametrize.BINARY_STRINGS |
|
| 814 |
+ @hypothesis.given( |
|
| 815 |
+ s=strategies.text().flatmap( |
|
| 816 |
+ lambda s: strategies.sampled_from([ |
|
| 817 |
+ s, |
|
| 818 |
+ s.encode("UTF-8"),
|
|
| 819 |
+ bytearray(s.encode("UTF-8")),
|
|
| 820 |
+ ]) |
|
| 838 | 821 |
) |
| 839 |
- assert v._entropy() == 0.0 # noqa: RUF069 |
|
| 840 |
- assert v._estimate_sufficient_hash_length() > 0 |
|
| 841 |
- |
|
| 842 |
- @Parametrize.SAMPLE_SERVICES_AND_PHRASES |
|
| 843 |
- def test_hash_length_expansion( |
|
| 844 |
- self, |
|
| 845 |
- monkeypatch: pytest.MonkeyPatch, |
|
| 846 |
- service: str | bytes, |
|
| 847 |
- expected: bytes, |
|
| 848 |
- ) -> None: |
|
| 849 |
- """ |
|
| 850 |
- Estimating the entropy and hash length for the degenerate case works. |
|
| 851 |
- """ |
|
| 852 |
- v = vault.Vault(phrase=self.phrase) |
|
| 853 |
- monkeypatch.setattr( |
|
| 854 |
- v, |
|
| 855 |
- "_estimate_sufficient_hash_length", |
|
| 856 |
- lambda *_args, **_kwargs: 1, |
|
| 857 | 822 |
) |
| 858 |
- assert v._estimate_sufficient_hash_length() < len(self.phrase) |
|
| 859 |
- assert v.generate(service) == expected |
|
| 860 |
- |
|
| 861 |
- @Parametrize.BINARY_STRINGS |
|
| 862 | 823 |
def test_binary_strings(self, s: str | bytes | bytearray) -> None: |
| 863 | 824 |
"""Byte string conversion is idempotent.""" |
| 864 | 825 |
binstr = vault.Vault._get_binary_string |
| ... | ... |
@@ -19,6 +19,7 @@ import pytest |
| 19 | 19 |
from hypothesis import strategies |
| 20 | 20 |
|
| 21 | 21 |
from derivepassphrase._internals import cli_messages as msg |
| 22 |
+from tests.machinery import hypothesis as hypothesis_machinery |
|
| 22 | 23 |
|
| 23 | 24 |
if TYPE_CHECKING: |
| 24 | 25 |
from collections.abc import Generator |
| ... | ... |
@@ -26,7 +27,7 @@ if TYPE_CHECKING: |
| 26 | 27 |
|
| 27 | 28 |
|
| 28 | 29 |
class Parametrize(types.SimpleNamespace): |
| 29 |
- MAYBE_FORMAT_STRINGS = pytest.mark.parametrize( |
|
| 30 |
+ MAYBE_FORMAT_STRINGS = hypothesis_machinery.explicit_examples( |
|
| 30 | 31 |
"s", ["{spam}", "{spam}abc", "{", "}", "{{{"]
|
| 31 | 32 |
) |
| 32 | 33 |
|
| ... | ... |
@@ -281,6 +282,8 @@ class TestTranslatedStrings: |
| 281 | 282 |
class TestSuppressedInterpolations: |
| 282 | 283 |
"""Test the interpolation suppression behavior of translated strings.""" |
| 283 | 284 |
|
| 285 |
+ # TODO(the-13th-letter): Revise this test, in particular, the error |
|
| 286 |
+ # assertions. |
|
| 284 | 287 |
@Parametrize.MAYBE_FORMAT_STRINGS |
| 285 | 288 |
def test_missing_fields(self, s: str) -> None: |
| 286 | 289 |
"""TranslatableStrings require fixed replacement fields. |
| 287 | 290 |