diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..434f8cf2f --- /dev/null +++ b/conftest.py @@ -0,0 +1,19 @@ +"""Give each test an empty TimeNet home and no other ``TIMENET_*`` setting.""" + +import os + +import pytest + +from timenet.config import TimeNetSettings + + +@pytest.fixture(autouse=True) +def _timenet_state(tmp_path_factory, monkeypatch): + """Set an empty temporary TimeNet home and clear other ``TIMENET_*`` variables. + + Ignore settings from ``.env`` files. + """ + for name in [name for name in os.environ if name.startswith("TIMENET_")]: + monkeypatch.delenv(name) + monkeypatch.setitem(TimeNetSettings.model_config, "env_file", None) + monkeypatch.setenv("TIMENET_HOME", str(tmp_path_factory.mktemp("timenet-home"))) diff --git a/packages/timenet-connectors/tests/test_build_backend.py b/packages/timenet-connectors/tests/test_build_backend.py index ab179a74c..e0e6dd95f 100644 --- a/packages/timenet-connectors/tests/test_build_backend.py +++ b/packages/timenet-connectors/tests/test_build_backend.py @@ -129,7 +129,6 @@ def test_build_in_process_round_trips_the_connector_default_zarr_backend(tmp_pat # Only BuildOptions.quiet silences the child. An SDK build without a progress callback still prints. @pytest.mark.parametrize("quiet", [False, True]) def test_build_runs_an_isolated_child_with_the_release_and_the_pins(tmp_path, monkeypatch, quiet): - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) monkeypatch.setattr(backend_module, "run_pipeline", _unexpected_pipeline) calls = [] monkeypatch.setattr( @@ -148,7 +147,6 @@ def test_build_runs_an_isolated_child_with_the_release_and_the_pins(tmp_path, mo def test_build_rejects_another_version(tmp_path, monkeypatch): - monkeypatch.setenv("TIMENET_ISOLATION", "on") monkeypatch.setattr(backend_module, "run_isolated_build", _unexpected_isolation) monkeypatch.setattr(backend_module, "run_pipeline", _unexpected_pipeline) release = DatasetRef.parse("timenet/hello-world@9.9.9") diff --git a/packages/timenet-connectors/tests/test_build_cli.py b/packages/timenet-connectors/tests/test_build_cli.py index 308f05928..0b81d818e 100644 --- a/packages/timenet-connectors/tests/test_build_cli.py +++ b/packages/timenet-connectors/tests/test_build_cli.py @@ -51,7 +51,6 @@ def _unexpected(*args, **kwargs): def test_build_runs_isolated_by_default(monkeypatch, tmp_path): - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) calls = [] monkeypatch.setattr(backend_module, "run_isolated_build", _isolated_child(calls)) @@ -67,9 +66,7 @@ def test_build_runs_in_process_without_isolation(monkeypatch, tmp_path, how): calls = [] monkeypatch.setattr(backend_module, "run_isolated_build", _unexpected) monkeypatch.setattr(backend_module, "run_pipeline", _in_process(calls)) - if how == "flag": - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) - else: + if how == "setting": monkeypatch.setenv("TIMENET_ISOLATION", "off") result = runner.invoke( @@ -104,7 +101,6 @@ def test_build_without_isolation_shows_each_build_line_once(monkeypatch, tmp_pat def test_a_build_that_reuses_the_release_says_so_on_stderr(monkeypatch, tmp_path): - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) # The line names a long path, so the console must not wrap it. monkeypatch.setattr(cli_module.console, "rich", Console(stderr=True, width=1000)) calls = [] @@ -122,7 +118,6 @@ def test_a_build_that_reuses_the_release_says_so_on_stderr(monkeypatch, tmp_path def test_build_takes_an_exact_release_and_refuses_another_version(monkeypatch, tmp_path): - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) calls = [] monkeypatch.setattr(backend_module, "run_isolated_build", _isolated_child(calls)) result = runner.invoke(app, ["build", "timenet/hello-world@1.0.0", "--out", str(tmp_path)]) @@ -162,7 +157,6 @@ def test_build_rejects_an_id_that_no_card_declares_and_names_the_known_ids(tmp_p @pytest.mark.parametrize("quiet", [False, True]) def test_build_forwards_quiet_to_the_isolated_child(monkeypatch, tmp_path, quiet): - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) # The console is a process-wide singleton; monkeypatch restores it for the tests that follow. monkeypatch.setattr(cli_module.console, "quiet", False) calls = [] diff --git a/packages/timenet-connectors/tests/test_build_plan.py b/packages/timenet-connectors/tests/test_build_plan.py index 89111b6a4..57fef1214 100644 --- a/packages/timenet-connectors/tests/test_build_plan.py +++ b/packages/timenet-connectors/tests/test_build_plan.py @@ -299,7 +299,6 @@ def child(release, store_root, *, pinned_by, **_): else: _store_from_card(release.dataset_id, store_root, force=False) - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) monkeypatch.setattr(backend_module, "run_isolated_build", child) if backend == ValuesBackend.PARQUET: @@ -510,7 +509,6 @@ def child_builds(monkeypatch) -> list: Returns: The list that receives one ``(dataset_id, force)`` entry for each build. """ - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) calls = [] monkeypatch.setattr(backend_module, "run_isolated_build", _prepared_builds(calls)) return calls @@ -519,7 +517,6 @@ def child_builds(monkeypatch) -> list: def test_build_prepares_parents_before_the_isolated_child(tmp_path, monkeypatch): cards = _install_cards(monkeypatch, child={"parent": PARENT, "grand": GRAND}, parent={}) monkeypatch.setattr(cli_module, "known_ids", lambda: list(cards)) - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) origin = LocalRegistry(tmp_path / "origin") origin.put_dataset(make_dataset()) out = tmp_path / "out" @@ -541,7 +538,6 @@ def test_build_prepares_parents_before_the_isolated_child(tmp_path, monkeypatch) @pytest.mark.parametrize("obtain", ["fetch_from", "build_parents"]) def test_load_obtains_a_missing_parent_before_it_builds_and_reads(tmp_path, monkeypatch, obtain): cards = _install_cards(monkeypatch, child={"parent": PARENT}, parent={}) - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) monkeypatch.setenv("TIMENET_HOME", str(tmp_path / "home")) monkeypatch.setenv("TIMENET_REGISTRY", str(tmp_path / "out")) calls = [] @@ -601,7 +597,6 @@ def child(command, env): result = runner.invoke(app, argv) return "", result.exit_code - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) monkeypatch.setattr(env_module, "_run_build", child) monkeypatch.setattr(cli_module, "known_ids", lambda: list(cards)) monkeypatch.setattr(backend_module, "run_pipeline", run_pipeline) @@ -666,7 +661,6 @@ def _remote_out(tmp_path, monkeypatch, cards) -> tuple[_Hosted, LocalRegistry, l monkeypatch.setattr(cli_module, "_resolve_target", lambda target: remote) monkeypatch.setattr(cli_module, "known_ids", lambda: list(cards)) monkeypatch.setenv("TIMENET_HOME", str(tmp_path / "home")) - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) calls = [] monkeypatch.setattr(backend_module, "run_isolated_build", _prepared_builds(calls)) return remote, LocalRegistry(tmp_path / "home/registry"), calls diff --git a/packages/timenet-connectors/tests/test_package_api.py b/packages/timenet-connectors/tests/test_package_api.py index c9f53d388..74166be0e 100644 --- a/packages/timenet-connectors/tests/test_package_api.py +++ b/packages/timenet-connectors/tests/test_package_api.py @@ -40,7 +40,6 @@ def test_build_then_load_round_trip(monkeypatch, tmp_path): def test_build_runs_in_an_isolated_environment_by_default(monkeypatch, tmp_path): # The parent reads only the card. It does not import the connector, which needs dependencies # that only the environment of the child has. - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) monkeypatch.setattr(backend_module, "resolve", _unexpected_resolve) calls = [] @@ -58,7 +57,6 @@ def fake_isolated(release, store_root, *, force=False, keep_cache=False, quiet=F def test_build_rejects_a_version_the_connector_does_not_declare(monkeypatch, tmp_path): - monkeypatch.delenv("TIMENET_ISOLATION", raising=False) monkeypatch.setattr(backend_module, "resolve", _unexpected_resolve) monkeypatch.setattr(backend_module, "run_isolated_build", _unexpected_isolation) diff --git a/packages/timenet/tests/conftest.py b/packages/timenet/tests/conftest.py index cc6be6f89..b7f785f6f 100644 --- a/packages/timenet/tests/conftest.py +++ b/packages/timenet/tests/conftest.py @@ -9,6 +9,9 @@ import sys from hypothesis import HealthCheck, settings +import pytest + +from timenet.testing import install_backends # Make the test helpers of this directory (e.g. ``_demo_child``) importable by name under pytest's @@ -24,3 +27,9 @@ suppress_health_check=[HealthCheck.too_slow], ) settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "ci")) + + +@pytest.fixture(autouse=True) +def _no_installed_connectors(monkeypatch): + """Install no builder backends, so that no test reads the connector packages of the environment.""" + install_backends(monkeypatch) diff --git a/packages/timenet/tests/test_cli.py b/packages/timenet/tests/test_cli.py index 991fcbede..e03bf46c6 100644 --- a/packages/timenet/tests/test_cli.py +++ b/packages/timenet/tests/test_cli.py @@ -8,14 +8,13 @@ import typer from typer.testing import CliRunner -from timenet import builders from timenet.cache import cached_datasets, raw_cache_size from timenet.cli.app import _enum_list, app from timenet.dataset import TimeFDataset from timenet.errors import TimeNetDatasetNotFoundError from timenet.reader import TimeFReader from timenet.registry import open_local_version -from timenet.testing import install_backends, make_dataset +from timenet.testing import make_dataset from timenet.types import Access, Domain from timenet.types.reference_keys import ParentAlias from timenet.types.references import DatasetRef @@ -154,8 +153,6 @@ def test_info_lists_the_access_page_of_each_release_that_sets_one(tmp_path): def test_download_command_copies_into_the_store_and_then_has_nothing_to_do(registry_root, tmp_path, monkeypatch): - # The default sources end with the installed connectors. Keep the real ones out of the resolution. - monkeypatch.setattr(builders, "entry_points", lambda group: []) # The last line names a long path, so the console must not wrap it. monkeypatch.setattr(_cli_module.ui, "rich", Console(stderr=True, width=1000)) monkeypatch.setenv("TIMENET_HOME", str(tmp_path / "home")) @@ -180,7 +177,6 @@ def test_download_command_copies_into_the_store_and_then_has_nothing_to_do(regis def test_download_command_with_force_copies_a_held_release_again(registry_root, tmp_path, monkeypatch): - install_backends(monkeypatch) monkeypatch.setenv("TIMENET_HOME", str(tmp_path / "home")) monkeypatch.setenv("TIMENET_REGISTRY", str(tmp_path / "store")) command = ["download", "timenet/hello-world", "--registry", str(registry_root)] diff --git a/packages/timenet/tests/test_client.py b/packages/timenet/tests/test_client.py index 7f696b85d..5fa13c006 100644 --- a/packages/timenet/tests/test_client.py +++ b/packages/timenet/tests/test_client.py @@ -8,7 +8,6 @@ from pydantic import ValidationError import pytest -from timenet import builders as builders_module from timenet.client import TimeNet, default_sources from timenet.connectors import BaseConnector from timenet.dataset import Record, RegularAxis, Signal, Source, TimeFDataset @@ -65,8 +64,7 @@ def build(self, release, store, *, force, pinned_by, options): store.put_dataset(dataset, force=force, values_backend=self.BACKEND, pinned_by=pinned_by or None) -def test_a_child_reads_its_missing_payload_source_from_a_later_registry(tmp_path, monkeypatch): - monkeypatch.setattr(builders_module, "entry_points", lambda group: []) +def test_a_child_reads_its_missing_payload_source_from_a_later_registry(tmp_path): origin, reference = _child_of_the_demo(tmp_path / "origin") cache = tmp_path / "cache" shutil.copytree(origin.root / "test/child/1.0.0", cache / "test/child/1.0.0") @@ -221,7 +219,6 @@ def test_the_default_list_follows_timenet_registry(tmp_path, monkeypatch, regist def test_timenet_registry_env_selects_a_local_registry(tmp_path, monkeypatch): - monkeypatch.setattr(builders_module, "entry_points", lambda group: []) monkeypatch.setenv("TIMENET_HOME", str(tmp_path / "home")) monkeypatch.setenv("TIMENET_REGISTRY", str(tmp_path / "home" / "registry")) dataset = make_dataset() @@ -363,8 +360,7 @@ def test_download_of_latest_fails_when_a_connector_declares_a_newer_version(regi assert client.download("timenet/hello-world") == store.root / "timenet/hello-world/1.0.0" -def test_download_of_a_release_that_no_source_provides_names_each_source(tmp_path, monkeypatch): - monkeypatch.setattr(builders_module, "entry_points", lambda group: []) +def test_download_of_a_release_that_no_source_provides_names_each_source(tmp_path): sources = [LocalRegistry(tmp_path / "store"), LocalRegistry(tmp_path / "empty"), InstalledConnectors()] with pytest.raises(TimeNetDatasetNotFoundError) as caught: @@ -438,7 +434,6 @@ def test_download_names_the_store_when_no_other_registry_provides_its_copy(tmp_p @pytest.mark.parametrize("case", ["force", "damaged"]) def test_download_counts_a_registry_over_the_directory_of_the_store_as_the_store(tmp_path, monkeypatch, case): - monkeypatch.setattr(builders_module, "entry_points", lambda group: []) root = tmp_path / "reg" LocalRegistry(root).put_dataset(make_dataset()) directory = root / "timenet/hello-world/1.0.0" @@ -773,8 +768,7 @@ def test_load_names_the_buildable_version_when_a_pin_cannot_be_built(registry_ro @pytest.mark.parametrize("connectors", [True, False]) -def test_a_release_that_no_source_provides_names_how_to_get_it(tmp_path, monkeypatch, connectors): - monkeypatch.setattr(builders_module, "entry_points", lambda group: []) +def test_a_release_that_no_source_provides_names_how_to_get_it(tmp_path, connectors): sources = [ LocalRegistry(tmp_path / "reg"), LocalRegistry(tmp_path / "empty"), @@ -858,8 +852,7 @@ def test_load_rejects_a_credentialed_dataset_from_a_hosted_registry(tmp_path): assert not store.root.exists() -def test_the_access_hint_says_that_no_installed_connector_builds_the_release(tmp_path, monkeypatch): - monkeypatch.setattr(builders_module, "entry_points", lambda group: []) +def test_the_access_hint_says_that_no_installed_connector_builds_the_release(tmp_path): card = _card("org/gated", Access.CREDENTIALED, "https://physionet.example/dua") client = TimeNet(sources=[LocalRegistry(tmp_path / "store"), _hosted(tmp_path, card), InstalledConnectors()]) diff --git a/packages/timenet/tests/test_conftest.py b/packages/timenet/tests/test_conftest.py new file mode 100644 index 000000000..a7aa29752 --- /dev/null +++ b/packages/timenet/tests/test_conftest.py @@ -0,0 +1,55 @@ +"""Tests of the root ``conftest.py``: each test gets its own state.""" + +import os +from pathlib import Path +import subprocess +import sys + +from timenet.config import settings + + +pytest_plugins = ["pytester"] + +ROOT_CONFTEST = Path(__file__).resolve().parents[3] / "conftest.py" + +PATHS = ("registry_path", "storage_dir", "cache_dir", "control_cache_dir") +"""The settings properties that point at local state.""" + + +def test_each_test_starts_without_the_timenet_variables_of_the_shell(pytester, monkeypatch): + for name, value in { + "TIMENET_REGISTRY": "timenet://", + "TIMENET_STORAGE": "/shell/storage", + "TIMENET_CACHE": "/shell/cache", + "TIMENET_TOKEN": "secret", + "TIMENET_ISOLATION": "off", + }.items(): + monkeypatch.setenv(name, value) + pytester.makeconftest(ROOT_CONFTEST.read_text()) + pytester.makepyfile( + """ + import os + + def test_only_the_home_is_set(): + names = sorted(name for name in os.environ if name.startswith("TIMENET_")) + assert names == ["TIMENET_HOME"] + """ + ) + pytester.runpytest().assert_outcomes(passed=1) + + +def test_every_settings_path_lies_in_the_home_of_the_test_also_in_a_child_process(tmp_path): + home = Path(os.environ["TIMENET_HOME"]) + in_process = [getattr(settings(), name) for name in PATHS] + code = f"from timenet.config import settings; print(*(getattr(settings(), name) for name in {PATHS}), sep='\\n')" + child = subprocess.run([sys.executable, "-c", code], cwd=tmp_path, capture_output=True, text=True, check=True) + in_child = [Path(line) for line in child.stdout.splitlines()] + assert len(in_child) == len(in_process) == 4 + assert all(path.is_relative_to(home) for path in [*in_process, *in_child]) + + +def test_a_dotenv_file_in_the_working_directory_does_not_change_the_settings(monkeypatch, tmp_path): + (tmp_path / ".env").write_text("TIMENET_REGISTRY=timenet://\nTIMENET_TOKEN=secret\nTIMENET_ISOLATION=off\n") + monkeypatch.chdir(tmp_path) + cfg = settings() + assert (cfg.registry, cfg.token, cfg.isolation) == (None, None, "on") diff --git a/packages/timenet/tests/test_settings_remote.py b/packages/timenet/tests/test_settings_remote.py index 6624e3daa..8724b6046 100644 --- a/packages/timenet/tests/test_settings_remote.py +++ b/packages/timenet/tests/test_settings_remote.py @@ -1,15 +1,6 @@ from timenet.config import settings -def test_token_defaults_to_none(monkeypatch, tmp_path): - # A .env in the working directory supplies a token of its own, and pydantic-settings reads it - # when the environment variable is absent. Run from an empty directory so the assertion tests - # the default rather than whatever the checkout happens to sit next to. - monkeypatch.chdir(tmp_path) - monkeypatch.delenv("TIMENET_TOKEN", raising=False) - assert settings().token is None - - def test_token_read_from_env(monkeypatch): monkeypatch.setenv("TIMENET_TOKEN", "tok_abc") assert settings().token == "tok_abc" diff --git a/packages/timenet/tests/test_sources.py b/packages/timenet/tests/test_sources.py index 904d3863a..8a0c7e518 100644 --- a/packages/timenet/tests/test_sources.py +++ b/packages/timenet/tests/test_sources.py @@ -2,7 +2,6 @@ import pytest -from timenet import builders from timenet.errors import ( TimeFValidationError, TimeNetRegistryError, @@ -132,8 +131,7 @@ def test_installed_connectors_answer_not_found_for_another_version(monkeypatch): assert InstalledConnectors().resolve("x/y", None) == NotFound() -def test_installed_connectors_without_a_package_answer_not_found(monkeypatch): - monkeypatch.setattr(builders, "entry_points", lambda group: []) +def test_installed_connectors_without_a_package_answer_not_found(): assert InstalledConnectors().resolve("timenet/hello-world", None) == NotFound() assert "no connector package is installed" in str(InstalledConnectors())