diff --git a/.env.example b/.env.example index 5e09988..1bf3807 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,9 @@ OPENSYNDROME_PROVIDER=ollama OPENSYNDROME_MODEL=mistral +# optional: directory with your own definitions, read alongside the community ones +# OPENSYNDROME_DEFINITIONS_DIR=./my-definitions +# optional: ignore the community definitions and use OPENSYNDROME_DEFINITIONS_DIR only +# OPENSYNDROME_LOCAL_DEFINITIONS_ONLY=1 # mandatory only if using Ollama OLLAMA_BASE_URL=http://localhost:11434 # optional: add the API KEY of your desired provider in your .env file diff --git a/README.md b/README.md index df45708..c687fd5 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,33 @@ opensyndrome download definitions The files will be placed in the folder `.open_syndrome` in `$HOME`. +### Using your own local definitions + +The definitions published by the Open Syndrome Initiative (the community definitions) always live in +`~/.open_syndrome/v1/definitions`. To add definitions of your own that are not published on GitHub, point +`OPENSYNDROME_DEFINITIONS_DIR` to a directory you maintain; it is never written to by the CLI. + +```bash +OPENSYNDROME_DEFINITIONS_DIR=./my-definitions +``` + +To ignore the community definitions altogether and use only yours, also set +`OPENSYNDROME_LOCAL_DEFINITIONS_ONLY=1` (`true`, `yes` and `on` also work). Nothing is downloaded in that +mode, not even with `--force`. + +From Python, `opensyndrome.artifacts.get_definition_dirs()` returns the directories to read, in order: + +| `OPENSYNDROME_DEFINITIONS_DIR` | `OPENSYNDROME_LOCAL_DEFINITIONS_ONLY` | Result | +|---|---|---| +| unset | unset | `[community]` | +| set | unset | `[community, local]` | +| set | set | `[local]` | +| unset | set | error | + +It also accepts `local_dir` and `local_only` keyword arguments that override the environment variables. +The former `get_definition_dir()` still returns the community directory only, ignoring these variables; +it emits a `DeprecationWarning` and will be removed in a future version. + ### Providers and configuration The provider and model can be set via environment variables so you don't have to pass them on every command: diff --git a/opensyndrome/artifacts.py b/opensyndrome/artifacts.py index c898be2..d1eab8f 100644 --- a/opensyndrome/artifacts.py +++ b/opensyndrome/artifacts.py @@ -1,5 +1,9 @@ import json +import os +import warnings from pathlib import Path + +from dotenv import load_dotenv import requests @@ -9,6 +13,35 @@ SCHEMA_DIR = OPEN_SYNDROME_DIR / "schema.json" DEFINITIONS_DIR = OPEN_SYNDROME_DIR / "definitions" DEFINITIONS_DIR.mkdir(parents=True, exist_ok=True) +DEFINITIONS_DIR_ENV_VAR = "OPENSYNDROME_DEFINITIONS_DIR" +LOCAL_DEFINITIONS_ONLY_ENV_VAR = "OPENSYNDROME_LOCAL_DEFINITIONS_ONLY" +_TRUTHY_VALUES = {"1", "true", "yes", "on"} + +load_dotenv() + + +def local_definitions_dir() -> Path | None: + """Directory with the user's own definitions, from ``OPENSYNDROME_DEFINITIONS_DIR``. + + Returns ``None`` when the variable is unset or empty. The directory is owned + by the user and never written to; it must already exist. + """ + value = os.environ.get(DEFINITIONS_DIR_ENV_VAR, "").strip() + if not value: + return None + directory = Path(value).expanduser().resolve() + if not directory.is_dir(): + raise FileNotFoundError( + f"{DEFINITIONS_DIR_ENV_VAR} points to a directory that does not exist: " + f"{directory}" + ) + return directory + + +def local_definitions_only() -> bool: + """Whether ``OPENSYNDROME_LOCAL_DEFINITIONS_ONLY`` asks to ignore the community definitions.""" + value = os.environ.get(LOCAL_DEFINITIONS_ONLY_ENV_VAR, "") + return value.strip().lower() in _TRUTHY_VALUES def download_schema(): @@ -20,10 +53,14 @@ def download_schema(): return SCHEMA_DIR -def download_definitions(url=None, current_path=None): - """Download definitions from GitHub repos.""" +def download_definitions( + url: str | None = None, current_path: Path | None = None +) -> None: + """Download the community definitions from GitHub into ``DEFINITIONS_DIR``.""" if url is None: url = "https://api.github.com/repos/OpenSyndrome/definitions/contents/definitions/v1?ref=main" + if current_path is None: + current_path = DEFINITIONS_DIR response = requests.get(url) response.raise_for_status() @@ -37,15 +74,69 @@ def download_definitions(url=None, current_path=None): print(item["path"]) local_dir = DEFINITIONS_DIR / Path(item["path"]).parts[-1] local_dir.mkdir(parents=True, exist_ok=True) - download_definitions(item["url"], local_dir) + download_definitions(url=item["url"], current_path=local_dir) + + +def get_definition_dirs( + force: bool = False, + *, + local_dir: str | Path | None = None, + local_only: bool | None = None, +) -> list[Path]: + """Return the directories holding definitions, in lookup order. + - Neither environment variable set: ``[community]``, downloading the + community definitions when the directory is empty or ``force`` is set. + - ``OPENSYNDROME_DEFINITIONS_DIR`` set: ``[community, local]``. + - ``OPENSYNDROME_LOCAL_DEFINITIONS_ONLY`` set: ``[local]``, never downloading. -def get_definition_dir(force=False): + ``local_dir`` and ``local_only`` override the respective environment + variables when given. + """ + if local_dir is None: + local_dir = local_definitions_dir() + else: + local_dir = Path(local_dir).expanduser().resolve() + if local_only is None: + local_only = local_definitions_only() + + if local_only: + if local_dir is None: + raise ValueError( + f"{LOCAL_DEFINITIONS_ONLY_ENV_VAR} is set, so " + f"{DEFINITIONS_DIR_ENV_VAR} must be set to a directory with your " + "local definitions." + ) + return [local_dir] + + community_dir = _community_definitions_dir(force) + if local_dir is None: + return [community_dir] + return [community_dir, local_dir] + + +def _community_definitions_dir(force: bool) -> Path: + """Return the community directory, downloading into it when empty or forced.""" if force or not any(DEFINITIONS_DIR.iterdir()): download_definitions() return DEFINITIONS_DIR +def get_definition_dir(force: bool = False) -> Path: + """Deprecated: return the community definitions directory only. + + Kept so existing callers keep working. It ignores the local definitions + environment variables; use :func:`get_definition_dirs` to honour them. + """ + warnings.warn( + "get_definition_dir is deprecated and will be removed in a future version; " + "use get_definition_dirs, which also returns your local definitions.", + DeprecationWarning, + stacklevel=2, + ) + return _community_definitions_dir(force) + + def get_schema_filepath(force=False): if force or not SCHEMA_DIR.exists(): download_schema() diff --git a/opensyndrome/cli.py b/opensyndrome/cli.py index 176a407..260b8fb 100644 --- a/opensyndrome/cli.py +++ b/opensyndrome/cli.py @@ -12,7 +12,12 @@ generate_human_readable_format, ) from opensyndrome.ontology import enrich_definition, MAPPERS -from opensyndrome.artifacts import get_schema_filepath, get_definition_dir +from opensyndrome.artifacts import ( + LOCAL_DEFINITIONS_ONLY_ENV_VAR, + get_schema_filepath, + get_definition_dirs, + local_definitions_only, +) from opensyndrome.validators import validate_machine_readable_format from opensyndrome.providers import ( build_model_string, @@ -284,21 +289,37 @@ def convert_to_text(json_file, model, language, provider): def download_entity(entity, force): match entity: case "schema": - result = get_schema_filepath(force=force) + click.echo( + click.style( + f"{entity} available at: {get_schema_filepath(force=force)}", + fg="green", + ) + ) case "definitions": - result = get_definition_dir(force=force) + if local_definitions_only(): + click.echo( + click.style( + f"{LOCAL_DEFINITIONS_ONLY_ENV_VAR} is set: skipping download, " + "using local definitions only.", + fg="yellow", + ) + ) + try: + dirs = get_definition_dirs(force=force) + except (ValueError, FileNotFoundError) as e: + click.echo(click.style(f"❌ {e}", fg="red"), err=True) + raise SystemExit(1) + for directory in dirs: + click.echo( + click.style(f"{entity} available at: {directory}", fg="green") + ) case _: - result = None - - if not result: - click.echo( - click.style( - f"Invalid entity: {entity}. Expected: `schema` or `definitions`.", - fg="red", + click.echo( + click.style( + f"Invalid entity: {entity}. Expected: `schema` or `definitions`.", + fg="red", + ) ) - ) - else: - click.echo(click.style(f"{entity} available at: {result}", fg="green")) def main(): diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 69a7d16..fff3cdc 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,34 +1,39 @@ +from pathlib import Path from unittest import mock from unittest.mock import Mock, call +import pytest + from opensyndrome.artifacts import ( + DEFINITIONS_DIR_ENV_VAR, + LOCAL_DEFINITIONS_ONLY_ENV_VAR, download_definitions, - get_definition_dir, download_schema, + get_definition_dir, + get_definition_dirs, get_schema_filepath, + local_definitions_dir, + local_definitions_only, ) -@mock.patch("opensyndrome.artifacts.DEFINITIONS_DIR") -@mock.patch("opensyndrome.artifacts.download_definitions") -class TestGetDefinitionsDir: - def test_return_definitions_dir_if_not_empty(self, mock_download, mock_dir): - mock_dir.iterdir.return_value = ["schema.json", "v1/"] - - get_definition_dir() +def _add_definition(directory: Path) -> None: + directory.mkdir(parents=True, exist_ok=True) + (directory / "dengue.json").write_text("{}", encoding="utf-8") - assert mock_dir.iterdir.called - assert mock_download.called is False - def test_download_definitions_from_repo_if_dir_is_empty( - self, mock_download, mock_dir - ): - mock_dir.iterdir.return_value = [] +@pytest.fixture +def community_dir(monkeypatch, tmp_path) -> Path: + directory = tmp_path / "community" + directory.mkdir() + monkeypatch.setattr("opensyndrome.artifacts.DEFINITIONS_DIR", directory) + return directory - get_definition_dir() - assert mock_dir.iterdir.called - assert mock_download.called is True +@pytest.fixture +def clean_env(monkeypatch) -> None: + monkeypatch.delenv(DEFINITIONS_DIR_ENV_VAR, raising=False) + monkeypatch.delenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, raising=False) class TestDownloadSchema: @@ -67,13 +72,283 @@ def test_download_schema_from_repo_if_dir_does_not_exist( assert mock_download.called is True +class TestLocalDefinitionsDir: + def test_returns_none_when_env_var_is_unset(self, clean_env): + assert local_definitions_dir() is None + + def test_returns_none_when_env_var_is_empty(self, clean_env, monkeypatch): + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, "") + + assert local_definitions_dir() is None + + def test_returns_existing_directory_from_env_var( + self, clean_env, monkeypatch, tmp_path + ): + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + + assert local_definitions_dir() == tmp_path / "local" + + def test_expands_user_in_env_var(self, clean_env, monkeypatch, tmp_path): + _add_definition(tmp_path / "local") + # expanduser reads HOME on POSIX and USERPROFILE on Windows + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, "~/local") + + assert local_definitions_dir() == (tmp_path / "local").resolve() + + def test_relative_env_var_is_returned_as_absolute_path( + self, clean_env, monkeypatch, tmp_path + ): + _add_definition(tmp_path / "my-definitions") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, "./my-definitions") + + result = local_definitions_dir() + + assert result.is_absolute() + assert result == tmp_path / "my-definitions" + + def test_raises_when_directory_does_not_exist( + self, clean_env, monkeypatch, tmp_path + ): + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "missing")) + + with pytest.raises(FileNotFoundError, match=DEFINITIONS_DIR_ENV_VAR): + local_definitions_dir() + + +class TestLocalDefinitionsOnly: + def test_defaults_to_false_when_env_var_is_unset(self, clean_env): + assert local_definitions_only() is False + + @pytest.mark.parametrize("value", ["1", "true", "True", "TRUE", "yes", "on", " 1 "]) + def test_truthy_env_var_values(self, clean_env, monkeypatch, value): + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, value) + + assert local_definitions_only() is True + + @pytest.mark.parametrize("value", ["0", "false", "no", "off", "", "anything"]) + def test_falsy_env_var_values(self, clean_env, monkeypatch, value): + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, value) + + assert local_definitions_only() is False + + +@mock.patch("opensyndrome.artifacts.download_definitions") +class TestGetDefinitionDirs: + """Every combination of the two environment variables, the community + directory state and ``force``.""" + + @pytest.mark.parametrize("force", [False, True]) + @pytest.mark.parametrize("community_is_empty", [False, True]) + def test_without_env_vars_returns_community_only( + self, mock_download, clean_env, community_dir, community_is_empty, force + ): + if not community_is_empty: + _add_definition(community_dir) + + result = get_definition_dirs(force=force) + + assert result == [community_dir] + assert mock_download.called is (community_is_empty or force) + + @pytest.mark.parametrize("force", [False, True]) + @pytest.mark.parametrize("community_is_empty", [False, True]) + def test_with_local_dir_returns_community_then_local( + self, + mock_download, + clean_env, + monkeypatch, + tmp_path, + community_dir, + community_is_empty, + force, + ): + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + if not community_is_empty: + _add_definition(community_dir) + + result = get_definition_dirs(force=force) + + assert result == [community_dir, tmp_path / "local"] + assert mock_download.called is (community_is_empty or force) + + @pytest.mark.parametrize("force", [False, True]) + @pytest.mark.parametrize("community_is_empty", [False, True]) + def test_with_local_only_returns_local_and_never_downloads( + self, + mock_download, + clean_env, + monkeypatch, + tmp_path, + community_dir, + community_is_empty, + force, + ): + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, "1") + if not community_is_empty: + _add_definition(community_dir) + + result = get_definition_dirs(force=force) + + assert result == [tmp_path / "local"] + assert mock_download.called is False + + @pytest.mark.parametrize("force", [False, True]) + def test_local_only_without_local_dir_raises( + self, mock_download, clean_env, monkeypatch, community_dir, force + ): + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, "1") + + with pytest.raises(ValueError, match=DEFINITIONS_DIR_ENV_VAR): + get_definition_dirs(force=force) + + assert mock_download.called is False + + def test_local_only_falsy_value_behaves_as_unset( + self, mock_download, clean_env, monkeypatch, tmp_path, community_dir + ): + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, "0") + + assert get_definition_dirs() == [community_dir, tmp_path / "local"] + + @pytest.mark.parametrize( + ("local_only", "env_value", "expected_local_only"), + [ + (None, None, False), + (None, "1", True), + (None, "0", False), + (True, None, True), + (True, "1", True), + (True, "0", True), + (False, None, False), + (False, "1", False), + (False, "0", False), + ], + ) + def test_local_only_argument_takes_precedence_over_env_var( + self, + mock_download, + clean_env, + monkeypatch, + tmp_path, + community_dir, + local_only, + env_value, + expected_local_only, + ): + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + if env_value is not None: + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, env_value) + + result = get_definition_dirs(local_only=local_only) + + if expected_local_only: + assert result == [tmp_path / "local"] + else: + assert result == [community_dir, tmp_path / "local"] + + @pytest.mark.parametrize( + ("pass_argument", "set_env_var", "expected"), + [ + (True, True, "from-arg"), + (True, False, "from-arg"), + (False, True, "from-env"), + (False, False, None), + ], + ) + def test_local_dir_argument_takes_precedence_over_env_var( + self, + mock_download, + clean_env, + monkeypatch, + tmp_path, + community_dir, + pass_argument, + set_env_var, + expected, + ): + _add_definition(tmp_path / "from-arg") + _add_definition(tmp_path / "from-env") + if set_env_var: + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "from-env")) + argument = tmp_path / "from-arg" if pass_argument else None + + result = get_definition_dirs(local_dir=argument) + + expected_dirs = [community_dir] + if expected: + expected_dirs.append(tmp_path / expected) + assert result == expected_dirs + + def test_local_dir_argument_accepts_a_string( + self, mock_download, clean_env, tmp_path, community_dir + ): + _add_definition(tmp_path / "local") + + result = get_definition_dirs(local_dir=str(tmp_path / "local")) + + assert result == [community_dir, tmp_path / "local"] + + def test_relative_local_dir_argument_is_returned_as_absolute_path( + self, mock_download, clean_env, monkeypatch, tmp_path, community_dir + ): + _add_definition(tmp_path / "my-definitions") + monkeypatch.chdir(tmp_path) + + result = get_definition_dirs(local_dir="my-definitions") + + assert result == [community_dir, tmp_path / "my-definitions"] + assert result[1].is_absolute() + + +@mock.patch("opensyndrome.artifacts.download_definitions") +class TestGetDefinitionDirDeprecated: + """Kept for callers that expect a single community path; scheduled for removal.""" + + @pytest.mark.parametrize("force", [False, True]) + @pytest.mark.parametrize("community_is_empty", [False, True]) + def test_returns_community_path_and_downloads_as_before( + self, mock_download, clean_env, community_dir, community_is_empty, force + ): + if not community_is_empty: + _add_definition(community_dir) + + with pytest.warns(DeprecationWarning, match="get_definition_dirs"): + result = get_definition_dir(force=force) + + assert result == community_dir + assert mock_download.called is (community_is_empty or force) + + def test_ignores_local_definitions_env_vars( + self, mock_download, clean_env, monkeypatch, tmp_path, community_dir + ): + _add_definition(community_dir) + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + monkeypatch.setenv(LOCAL_DEFINITIONS_ONLY_ENV_VAR, "1") + + with pytest.warns(DeprecationWarning, match="removed"): + result = get_definition_dir() + + assert result == community_dir + assert mock_download.called is False + + @mock.patch("opensyndrome.artifacts.requests") -@mock.patch("opensyndrome.artifacts.DEFINITIONS_DIR") class TestDownloadDefinitions: - def test_download_definitions_recursively(self, mock_path, mock_requests): + @staticmethod + def _github_responses() -> list[Mock]: mock_response_v1 = Mock() mock_response_v1.json.return_value = [ - # some keys were omitted from the response due to its size { "name": "a", "path": "definitions/v1/a", @@ -93,19 +368,15 @@ def test_download_definitions_recursively(self, mock_path, mock_requests): ] mock_response_file = Mock() mock_response_file.content = b'{"key": "value"}' + return [mock_response_v1, mock_response_a, mock_response_file] - mock_requests.get.side_effect = [ - mock_response_v1, - mock_response_a, - mock_response_file, - ] - - mock_path.return_value.write_bytes.return_value = None - mock_path.return_value.mkdir.return_value = None + def test_download_definitions_recursively_into_community_dir( + self, mock_requests, community_dir + ): + mock_requests.get.side_effect = self._github_responses() download_definitions() - assert mock_requests.get.call_count == 3 calls = [ call( "https://api.github.com/repos/OpenSyndrome/definitions/contents/definitions/v1?ref=main" @@ -117,13 +388,18 @@ def test_download_definitions_recursively(self, mock_path, mock_requests): "https://raw.githubusercontent.com/OpenSyndrome/definitions/main/definitions/v1/a/acuteflaccidparalysis_kenya.json" ), ] - mock_requests.get.assert_has_calls(calls, any_order=True) - - assert ( - call.__truediv__().mkdir(parents=True, exist_ok=True) - in mock_path.mock_calls - ) - assert ( - call.__truediv__().__truediv__().write_bytes(b'{"key": "value"}') - in mock_path.mock_calls - ) + mock_requests.get.assert_has_calls(calls) + downloaded = community_dir / "a" / "acuteflaccidparalysis_kenya.json" + assert downloaded.read_bytes() == b'{"key": "value"}' + + def test_download_ignores_local_definitions_dir( + self, mock_requests, clean_env, monkeypatch, tmp_path, community_dir + ): + _add_definition(tmp_path / "local") + monkeypatch.setenv(DEFINITIONS_DIR_ENV_VAR, str(tmp_path / "local")) + mock_requests.get.side_effect = self._github_responses() + + download_definitions() + + assert (community_dir / "a" / "acuteflaccidparalysis_kenya.json").exists() + assert not (tmp_path / "local" / "a").exists() diff --git a/tests/test_cli.py b/tests/test_cli.py index a4e6c55..fcdd87f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -327,3 +327,54 @@ def test_humanize_shows_friendly_error_on_auth_failure( ) result = runner.invoke(cli, ["humanize", str(json_file)]) assert result.exit_code == 1 + + +class TestDownloadDefinitions: + @pytest.fixture + def dirs(self, tmp_path): + return [tmp_path / "community", tmp_path / "local"] + + @pytest.fixture + def mock_get_dirs(self, mocker, dirs): + return mocker.patch("opensyndrome.cli.get_definition_dirs", return_value=dirs) + + @pytest.mark.parametrize("force", [True, False]) + @pytest.mark.parametrize("local_only", [True, False]) + def test_definitions_prints_every_dir_and_warns_when_local_only( + self, mock_get_dirs, dirs, force, local_only + ): + env = {"OPENSYNDROME_LOCAL_DEFINITIONS_ONLY": "1" if local_only else None} + args = ["download", "definitions"] + if force: + args.append("--force") + + result = CliRunner(env=env).invoke(cli, args) + + assert result.exit_code == 0 + mock_get_dirs.assert_called_once_with(force=force) + assert ("OPENSYNDROME_LOCAL_DEFINITIONS_ONLY" in result.output) is local_only + for directory in dirs: + assert str(directory) in result.output + + def test_definitions_reports_missing_local_dir_as_error(self, mocker): + mocker.patch( + "opensyndrome.cli.get_definition_dirs", + side_effect=ValueError("OPENSYNDROME_DEFINITIONS_DIR must be set"), + ) + + result = CliRunner().invoke(cli, ["download", "definitions"]) + + assert result.exit_code == 1 + assert "OPENSYNDROME_DEFINITIONS_DIR must be set" in result.output + + def test_schema_ignores_local_only(self, mocker, tmp_path): + mock_get_schema = mocker.patch( + "opensyndrome.cli.get_schema_filepath", return_value=tmp_path + ) + env = {"OPENSYNDROME_LOCAL_DEFINITIONS_ONLY": "1"} + + result = CliRunner(env=env).invoke(cli, ["download", "schema"]) + + assert result.exit_code == 0 + mock_get_schema.assert_called_once_with(force=False) + assert "OPENSYNDROME_LOCAL_DEFINITIONS_ONLY" not in result.output