sarj-python-lint 1.1.1__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/PKG-INFO +1 -1
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/pyproject.toml +1 -1
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/__main__.py +28 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_registry.py +2 -0
- sarj_python_lint-1.2.0/src/sarj_python_lint/rules/_test_composition.py +286 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/async_mock_call_without_await_assertion.py +2 -2
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/mock_without_spec.py +2 -2
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +2 -2
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_fixed_sleep_before_assert.py +1 -1
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +157 -33
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +2 -2
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +4 -4
- sarj_python_lint-1.2.0/src/sarj_python_lint/rules/repeated_test_composition.py +286 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/.gitignore +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/LICENSE +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/README.md +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_file_context.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/benchmark.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/json_boundary.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_annotation_semantics.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_ast_position.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/fastapi_class_router_contract.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/named_record_at_boundaries.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_any_mapping_types.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_delete_statement.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_invalid_argument_name_suppression.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_unused_value_marker.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_exception_dispatch.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_pydantic_json_value.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_pydantic_for_structured_payload.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
|
|
6
6
|
Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
|
|
@@ -160,6 +160,7 @@ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
|
|
|
160
160
|
# still owns every fixed dictionary return when selected alone, while
|
|
161
161
|
# an all-rules run emits one actionable diagnostic for this overlap.
|
|
162
162
|
"SARJ447": frozenset({"SARJ008"}),
|
|
163
|
+
"SARJ457": frozenset({"SARJ045"}),
|
|
163
164
|
}
|
|
164
165
|
)
|
|
165
166
|
|
|
@@ -174,6 +175,7 @@ class _OwnerLocation(NamedTuple):
|
|
|
174
175
|
|
|
175
176
|
|
|
176
177
|
def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
|
|
178
|
+
diags = _deduplicate_test_composition(diags, source)
|
|
177
179
|
codes = frozenset(diagnostic.code for diagnostic in diags)
|
|
178
180
|
needs_docstring_owners = ("SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])) or (
|
|
179
181
|
"SARJ420" in codes and not codes.isdisjoint(_DOCSTRING_PRECEDENCE_CODES)
|
|
@@ -221,6 +223,32 @@ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = Non
|
|
|
221
223
|
]
|
|
222
224
|
|
|
223
225
|
|
|
226
|
+
def _deduplicate_test_composition(diags: list[Diagnostic], source: str | None) -> list[Diagnostic]:
|
|
227
|
+
codes = {diagnostic.code for diagnostic in diags}
|
|
228
|
+
if source is None or not {"SARJ066", "SARJ457"} <= codes:
|
|
229
|
+
return diags
|
|
230
|
+
try:
|
|
231
|
+
tree = ast.parse(source)
|
|
232
|
+
except SyntaxError:
|
|
233
|
+
return diags
|
|
234
|
+
whole_tests = {
|
|
235
|
+
(diagnostic.path, diagnostic.line): diagnostic.severity for diagnostic in diags if diagnostic.code == "SARJ066"
|
|
236
|
+
}
|
|
237
|
+
functions = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))]
|
|
238
|
+
|
|
239
|
+
def redundant(diagnostic: Diagnostic) -> bool:
|
|
240
|
+
if diagnostic.code != "SARJ457":
|
|
241
|
+
return False
|
|
242
|
+
owners = [node for node in functions if node.lineno <= diagnostic.line <= (node.end_lineno or node.lineno)]
|
|
243
|
+
if not owners:
|
|
244
|
+
return False
|
|
245
|
+
owner = max(owners, key=lambda node: node.lineno)
|
|
246
|
+
severity = whole_tests.get((diagnostic.path, owner.lineno))
|
|
247
|
+
return severity is not None and (severity is Severity.ERROR or diagnostic.severity is Severity.WARNING)
|
|
248
|
+
|
|
249
|
+
return [diagnostic for diagnostic in diags if not redundant(diagnostic)]
|
|
250
|
+
|
|
251
|
+
|
|
224
252
|
def _suppressed_diagnostics(
|
|
225
253
|
present: dict[tuple[Path, int, int], dict[str, set[Severity]]],
|
|
226
254
|
) -> set[tuple[tuple[Path, int, int], str, Severity]]:
|
|
@@ -145,6 +145,7 @@ from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
|
|
|
145
145
|
from sarj_python_lint.rules.redundant_module_docstring import RedundantModuleDocstring
|
|
146
146
|
from sarj_python_lint.rules.repeated_kwarg_heavy_call_in_test import RepeatedKwargHeavyCallInTest
|
|
147
147
|
from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCallCases
|
|
148
|
+
from sarj_python_lint.rules.repeated_test_composition import RepeatedTestComposition
|
|
148
149
|
from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
149
150
|
RequireKeywordOnlySwapProneParams,
|
|
150
151
|
)
|
|
@@ -225,6 +226,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
225
226
|
PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
|
|
226
227
|
StoreGetDelegatesToBulkRead.id: StoreGetDelegatesToBulkRead,
|
|
227
228
|
RepeatedKwargHeavyCallInTest.id: RepeatedKwargHeavyCallInTest,
|
|
229
|
+
RepeatedTestComposition.id: RepeatedTestComposition,
|
|
228
230
|
DefectXfailRequiresExplicitStrict.id: DefectXfailRequiresExplicitStrict,
|
|
229
231
|
NoFirstPartyPrivateImport.id: NoFirstPartyPrivateImport,
|
|
230
232
|
NoRestatedComment.id: NoRestatedComment,
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from sarj_python_lint.rules._project_index import ProjectIndexSet, SourceUnit, SymbolRef
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
|
|
14
|
+
from sarj_python_lint._file_context import PythonFileContext
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
type Function = ast.FunctionDef | ast.AsyncFunctionDef
|
|
18
|
+
_PORT_BASES = frozenset({"abc.ABC", "typing.Protocol", "typing_extensions.Protocol"})
|
|
19
|
+
_MIN_COLLABORATORS = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Constructor:
|
|
24
|
+
symbol: SymbolRef
|
|
25
|
+
positional: tuple[str, ...]
|
|
26
|
+
collaborators: frozenset[str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class WiringScope:
|
|
31
|
+
parameters: frozenset[str]
|
|
32
|
+
aliases: dict[str, ast.expr]
|
|
33
|
+
blocked: frozenset[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def direct_nodes(node: ast.AST) -> Iterator[ast.AST]:
|
|
37
|
+
for child in ast.iter_child_nodes(node):
|
|
38
|
+
yield child
|
|
39
|
+
if not isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
40
|
+
yield from direct_nodes(child)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _class_owner(
|
|
44
|
+
context: PythonFileContext, expression: ast.expr, project: ProjectIndexSet
|
|
45
|
+
) -> tuple[SourceUnit, ast.ClassDef] | None:
|
|
46
|
+
qualified = context.module_imports.resolved_qualified_name(expression)
|
|
47
|
+
unit = project.unit(context.path)
|
|
48
|
+
if qualified is not None:
|
|
49
|
+
module, _, name = qualified.rpartition(".")
|
|
50
|
+
unit = project.source_unit(module)
|
|
51
|
+
elif isinstance(expression, ast.Name):
|
|
52
|
+
name = expression.id
|
|
53
|
+
else:
|
|
54
|
+
return None
|
|
55
|
+
if unit is None or unit.tree is None or unit.module is None:
|
|
56
|
+
return None
|
|
57
|
+
owner = next((node for node in unit.tree.body if isinstance(node, ast.ClassDef) and node.name == name), None)
|
|
58
|
+
if owner is None:
|
|
59
|
+
return None
|
|
60
|
+
if any(
|
|
61
|
+
isinstance(node, ast.Name) and node.id == name and isinstance(node.ctx, (ast.Store, ast.Del))
|
|
62
|
+
for node in direct_nodes(unit.tree)
|
|
63
|
+
):
|
|
64
|
+
return None
|
|
65
|
+
return unit, owner
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def constructor(context: PythonFileContext, expression: ast.expr, project: ProjectIndexSet) -> Constructor | None:
|
|
69
|
+
resolved = _class_owner(context, expression, project)
|
|
70
|
+
if resolved is None:
|
|
71
|
+
return None
|
|
72
|
+
unit, owner = resolved
|
|
73
|
+
if unit.module is None:
|
|
74
|
+
return None
|
|
75
|
+
initializer = next(
|
|
76
|
+
(node for node in owner.body if isinstance(node, ast.FunctionDef) and node.name == "__init__"), None
|
|
77
|
+
)
|
|
78
|
+
if initializer is None:
|
|
79
|
+
return None
|
|
80
|
+
parameters = (*initializer.args.posonlyargs, *initializer.args.args, *initializer.args.kwonlyargs)
|
|
81
|
+
invoked = _invoked_fields(owner)
|
|
82
|
+
stored = _stored_parameters(initializer)
|
|
83
|
+
collaborators = frozenset(
|
|
84
|
+
parameter.arg
|
|
85
|
+
for parameter in parameters
|
|
86
|
+
if parameter.annotation is not None
|
|
87
|
+
and _is_port(project, unit, parameter.annotation)
|
|
88
|
+
and stored.get(parameter.arg) in invoked
|
|
89
|
+
)
|
|
90
|
+
if len(collaborators) < _MIN_COLLABORATORS:
|
|
91
|
+
return None
|
|
92
|
+
return Constructor(
|
|
93
|
+
SymbolRef(unit.module, owner.name),
|
|
94
|
+
tuple(arg.arg for arg in (*initializer.args.posonlyargs, *initializer.args.args)[1:]),
|
|
95
|
+
collaborators,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_port(project: ProjectIndexSet, unit: SourceUnit, annotation: ast.expr) -> bool:
|
|
100
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
101
|
+
try:
|
|
102
|
+
annotation = ast.parse(annotation.value, mode="eval").body
|
|
103
|
+
except SyntaxError:
|
|
104
|
+
return False
|
|
105
|
+
summary = project.class_for(unit, annotation)
|
|
106
|
+
if summary is None:
|
|
107
|
+
return False
|
|
108
|
+
owner = project.source_unit(summary.symbol.module)
|
|
109
|
+
return owner is not None and project.class_inherits_from(owner, summary.symbol.name, _PORT_BASES)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _stored_parameters(initializer: Function) -> dict[str, str]:
|
|
113
|
+
stored: dict[str, str] = {}
|
|
114
|
+
writes = Counter(
|
|
115
|
+
node.attr
|
|
116
|
+
for node in direct_nodes(initializer)
|
|
117
|
+
if isinstance(node, ast.Attribute) and _is_self(node.value) and isinstance(node.ctx, (ast.Store, ast.Del))
|
|
118
|
+
)
|
|
119
|
+
for statement in initializer.body:
|
|
120
|
+
if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
|
|
121
|
+
target, value = statement.targets[0], statement.value
|
|
122
|
+
elif isinstance(statement, ast.AnnAssign):
|
|
123
|
+
target, value = statement.target, statement.value
|
|
124
|
+
else:
|
|
125
|
+
continue
|
|
126
|
+
if (
|
|
127
|
+
isinstance(value, ast.Name)
|
|
128
|
+
and isinstance(target, ast.Attribute)
|
|
129
|
+
and _is_self(target.value)
|
|
130
|
+
and writes[target.attr] == 1
|
|
131
|
+
):
|
|
132
|
+
stored[value.id] = target.attr
|
|
133
|
+
return stored
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _is_self(node: ast.expr) -> bool:
|
|
137
|
+
return isinstance(node, ast.Name) and node.id == "self"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _invoked_fields(owner: ast.ClassDef) -> frozenset[str]:
|
|
141
|
+
invoked: set[str] = set()
|
|
142
|
+
for method in owner.body:
|
|
143
|
+
if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)) and method.name != "__init__":
|
|
144
|
+
invoked.update(_method_fields(method))
|
|
145
|
+
return frozenset(invoked)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _method_fields(method: Function) -> Iterator[str]:
|
|
149
|
+
for node in direct_nodes(method):
|
|
150
|
+
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
|
151
|
+
continue
|
|
152
|
+
target = node.func.value
|
|
153
|
+
if isinstance(target, ast.Attribute) and _is_self(target.value):
|
|
154
|
+
yield target.attr
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def bindings(function: Function) -> dict[str, ast.expr]:
|
|
158
|
+
candidates: dict[str, list[ast.expr]] = {}
|
|
159
|
+
for statement in function.body:
|
|
160
|
+
if (
|
|
161
|
+
isinstance(statement, ast.Assign)
|
|
162
|
+
and len(statement.targets) == 1
|
|
163
|
+
and isinstance(statement.targets[0], ast.Name)
|
|
164
|
+
):
|
|
165
|
+
candidates.setdefault(statement.targets[0].id, []).append(statement.value)
|
|
166
|
+
elif (
|
|
167
|
+
isinstance(statement, ast.AnnAssign)
|
|
168
|
+
and isinstance(statement.target, ast.Name)
|
|
169
|
+
and statement.value is not None
|
|
170
|
+
):
|
|
171
|
+
candidates.setdefault(statement.target.id, []).append(statement.value)
|
|
172
|
+
writes = [
|
|
173
|
+
node.id
|
|
174
|
+
for node in direct_nodes(function)
|
|
175
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del))
|
|
176
|
+
]
|
|
177
|
+
return {name: values[0] for name, values in candidates.items() if len(values) == 1 and writes.count(name) == 1}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def local_names(function: Function) -> set[str]:
|
|
181
|
+
names: set[str] = set()
|
|
182
|
+
for node in direct_nodes(function):
|
|
183
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
184
|
+
names.add(node.id)
|
|
185
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
186
|
+
names.add(node.name)
|
|
187
|
+
elif isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
188
|
+
names.update(alias.asname or alias.name.partition(".")[0] for alias in node.names)
|
|
189
|
+
return names
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def changed_before(function: Function, call: ast.Call) -> set[str]:
|
|
193
|
+
changed: set[str] = set()
|
|
194
|
+
for node in direct_nodes(function):
|
|
195
|
+
if not isinstance(node, (ast.Call, ast.Attribute)) or node.lineno >= call.lineno:
|
|
196
|
+
continue
|
|
197
|
+
target = node.func.value if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) else None
|
|
198
|
+
if isinstance(node, ast.Attribute) and isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
199
|
+
target = node.value
|
|
200
|
+
if isinstance(target, ast.Name):
|
|
201
|
+
changed.add(target.id)
|
|
202
|
+
return changed
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def unalias(node: ast.expr, aliases: dict[str, ast.expr]) -> ast.expr:
|
|
206
|
+
seen: set[str] = set()
|
|
207
|
+
while isinstance(node, ast.Name) and node.id in aliases and node.id not in seen:
|
|
208
|
+
seen.add(node.id)
|
|
209
|
+
node = aliases[node.id]
|
|
210
|
+
return node
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def shadowed(node: ast.expr, names: frozenset[str]) -> bool:
|
|
214
|
+
while isinstance(node, ast.Attribute):
|
|
215
|
+
node = node.value
|
|
216
|
+
return isinstance(node, ast.Name) and node.id in names
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def wiring(
|
|
220
|
+
context: PythonFileContext,
|
|
221
|
+
project: ProjectIndexSet,
|
|
222
|
+
call: ast.Call,
|
|
223
|
+
spec: Constructor,
|
|
224
|
+
scope: WiringScope,
|
|
225
|
+
) -> tuple[tuple[str, str], ...] | None:
|
|
226
|
+
if any(isinstance(arg, ast.Starred) for arg in call.args) or any(keyword.arg is None for keyword in call.keywords):
|
|
227
|
+
return None
|
|
228
|
+
if len(call.args) > len(spec.positional):
|
|
229
|
+
return None
|
|
230
|
+
values = dict(zip(spec.positional, call.args, strict=False))
|
|
231
|
+
for keyword in call.keywords:
|
|
232
|
+
if keyword.arg is None or keyword.arg in values:
|
|
233
|
+
return None
|
|
234
|
+
values[keyword.arg] = keyword.value
|
|
235
|
+
result: list[tuple[str, str]] = []
|
|
236
|
+
for name in sorted(spec.collaborators):
|
|
237
|
+
value = values.get(name)
|
|
238
|
+
if value is None or not isinstance(unalias(value, scope.aliases), (ast.Name, ast.Call)):
|
|
239
|
+
return None
|
|
240
|
+
identity = _value_key(context, project, value, scope)
|
|
241
|
+
if identity is None:
|
|
242
|
+
return None
|
|
243
|
+
result.append((name, identity))
|
|
244
|
+
return tuple(result)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _value_key(
|
|
248
|
+
context: PythonFileContext,
|
|
249
|
+
project: ProjectIndexSet,
|
|
250
|
+
value: ast.expr,
|
|
251
|
+
scope: WiringScope,
|
|
252
|
+
seen: frozenset[str] = frozenset(),
|
|
253
|
+
) -> str | None:
|
|
254
|
+
if isinstance(value, ast.Name):
|
|
255
|
+
if value.id in seen or value.id in scope.blocked:
|
|
256
|
+
return None
|
|
257
|
+
if value.id in scope.aliases:
|
|
258
|
+
return _value_key(
|
|
259
|
+
context,
|
|
260
|
+
project,
|
|
261
|
+
scope.aliases[value.id],
|
|
262
|
+
scope,
|
|
263
|
+
seen=seen | {value.id},
|
|
264
|
+
)
|
|
265
|
+
return f"fixture:{value.id}" if value.id in scope.parameters else None
|
|
266
|
+
if isinstance(value, ast.Constant):
|
|
267
|
+
return ast.dump(value)
|
|
268
|
+
if not isinstance(value, ast.Call):
|
|
269
|
+
return None
|
|
270
|
+
callee = unalias(value.func, scope.aliases)
|
|
271
|
+
if shadowed(callee, scope.parameters | scope.blocked):
|
|
272
|
+
return None
|
|
273
|
+
owner = _class_owner(context, callee, project)
|
|
274
|
+
if owner is None or any(keyword.arg is None for keyword in value.keywords):
|
|
275
|
+
return None
|
|
276
|
+
positional = [_value_key(context, project, argument, scope, seen=seen) for argument in value.args]
|
|
277
|
+
keywords = [
|
|
278
|
+
(
|
|
279
|
+
keyword.arg,
|
|
280
|
+
_value_key(context, project, keyword.value, scope, seen=seen),
|
|
281
|
+
)
|
|
282
|
+
for keyword in value.keywords
|
|
283
|
+
]
|
|
284
|
+
if any(key is None for key in positional) or any(key is None for _, key in keywords):
|
|
285
|
+
return None
|
|
286
|
+
return f"{owner[0].module}.{owner[1].name}:{positional!r}:{sorted(keywords)!r}"
|
|
@@ -69,7 +69,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
69
69
|
id = "async-mock-call-without-await-assertion"
|
|
70
70
|
code = "SARJ456"
|
|
71
71
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
72
|
-
default_level=Severity.
|
|
72
|
+
default_level=Severity.ERROR,
|
|
73
73
|
summary="Pair positive AsyncMock call assertions with evidence that the mock was awaited.",
|
|
74
74
|
rationale=(
|
|
75
75
|
"Calling an AsyncMock records a call before its coroutine is awaited. A positive call assertion can "
|
|
@@ -150,7 +150,7 @@ class AsyncMockCallWithoutAwaitAssertion(Rule):
|
|
|
150
150
|
line=call.lineno,
|
|
151
151
|
col=call.col_offset + 1,
|
|
152
152
|
code=self.code,
|
|
153
|
-
severity=Severity.
|
|
153
|
+
severity=Severity.ERROR,
|
|
154
154
|
message="AsyncMock call assertions do not prove awaiting; keep this assertion and add an await assertion",
|
|
155
155
|
)
|
|
156
156
|
for test in _collected_tests(tree.body)
|
{sarj_python_lint-1.1.1 → sarj_python_lint-1.2.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
@@ -121,7 +121,7 @@ class MockWithoutSpec(Rule):
|
|
|
121
121
|
id: str = "mock-without-spec"
|
|
122
122
|
code: str = "SARJ040"
|
|
123
123
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
124
|
-
default_level=Severity.
|
|
124
|
+
default_level=Severity.ERROR,
|
|
125
125
|
summary="Unrestricted mock permits attributes outside the collaborator contract.",
|
|
126
126
|
rationale=(
|
|
127
127
|
"A mock without a spec permits removed or misspelled attributes; a callable mock without autospec may "
|
|
@@ -234,7 +234,7 @@ class MockWithoutSpec(Rule):
|
|
|
234
234
|
col=node.col_offset + 1,
|
|
235
235
|
code=self.code,
|
|
236
236
|
message=_diagnostic_message(label),
|
|
237
|
-
severity=Severity.
|
|
237
|
+
severity=Severity.ERROR,
|
|
238
238
|
)
|
|
239
239
|
for node, label in _unspecced_calls(
|
|
240
240
|
tree, names, pytest_mocker, _FileFacts.from_tree(tree), node_index=context.node_index
|
|
@@ -204,7 +204,7 @@ class NegativeOnlyHttpStatusAssertion(Rule):
|
|
|
204
204
|
id = "negative-only-http-status-assertion"
|
|
205
205
|
code = "SARJ408"
|
|
206
206
|
documentation = RuleDocumentation(
|
|
207
|
-
default_level=Severity.
|
|
207
|
+
default_level=Severity.ERROR,
|
|
208
208
|
summary="HTTP test assertion only excludes a server error instead of identifying the intended response.",
|
|
209
209
|
rationale=(
|
|
210
210
|
"Authentication, routing, validation, and domain failures can all replace the intended response while "
|
|
@@ -276,7 +276,7 @@ class NegativeOnlyHttpStatusAssertion(Rule):
|
|
|
276
276
|
line=node.lineno,
|
|
277
277
|
col=node.col_offset + 1,
|
|
278
278
|
code=self.code,
|
|
279
|
-
severity=Severity.
|
|
279
|
+
severity=Severity.ERROR,
|
|
280
280
|
message=(
|
|
281
281
|
"this assertion proves only that the response avoided a server-error outcome. Assert the "
|
|
282
282
|
"intended status and domain effect, or suppress SARJ408 with a reason when avoiding a server "
|
|
@@ -43,7 +43,7 @@ class NoFixedSleepBeforeAssert(Rule):
|
|
|
43
43
|
id = "no-fixed-sleep-before-assert"
|
|
44
44
|
code = "SARJ455"
|
|
45
45
|
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
46
|
-
default_level=Severity.
|
|
46
|
+
default_level=Severity.ERROR,
|
|
47
47
|
summary="Do not sleep for a fixed interval and then assert in a test.",
|
|
48
48
|
rationale=(
|
|
49
49
|
"A fixed sleep followed by an assertion guesses how long the code under test needs. On a loaded CI "
|