Skip to content

Commit 92f5abe

Browse files
authored
service: Include TS code module dir in .env search (#485)
* service: Include TS code module dir in .env search * examples: Update teststand_nivisa_dmm.py to search for `.env` starting with the code module's parent directory * tests: Fix mypy errors and last-minute rename errors * service: Work around lack of PurePath.is_relative_to() in Python 3.8 * service: Make _get_nims_path return package dir, not __init__.py path * service: Fix _get_caller_path() with Python >= 3.11 In Python 3.11 and later, `traceback.walk_stack(None)` skips more stack frames than in the past: python/cpython#96092 * tests: Make pytest path check less specific
1 parent 8fb5454 commit 92f5abe

5 files changed

Lines changed: 109 additions & 7 deletions

File tree

examples/nivisa_dmm_measurement/teststand_nivisa_dmm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
)
1313
from ni_measurementlink_service.session_management._types import SessionInformation
1414

15-
# Search for the `.env` file starting with the current directory.
16-
_config = AutoConfig(str(pathlib.Path.cwd()))
15+
# Search for the `.env` file starting with this code module's parent directory.
16+
_config = AutoConfig(str(pathlib.Path(__file__).resolve().parent))
1717
_VISA_DMM_SIMULATE: bool = _config("MEASUREMENTLINK_VISA_DMM_SIMULATE", default=False, cast=bool)
1818

1919

examples/output_voltage_measurement/teststand_nivisa_dmm.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
)
1313
from ni_measurementlink_service.session_management._types import SessionInformation
1414

15-
# Search for the `.env` file starting with the current directory.
16-
_config = AutoConfig(str(pathlib.Path.cwd()))
15+
# Search for the `.env` file starting with this code module's parent directory.
16+
_config = AutoConfig(str(pathlib.Path(__file__).resolve().parent))
1717
_VISA_DMM_SIMULATE: bool = _config("MEASUREMENTLINK_VISA_DMM_SIMULATE", default=False, cast=bool)
1818

1919

ni_measurementlink_service/_configuration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""MeasurementLink configuration options."""
22
from __future__ import annotations
33

4-
import pathlib
54
import sys
65
from typing import TYPE_CHECKING, Any, Callable, Dict, NamedTuple, TypeVar, Union
76

87
from decouple import AutoConfig, Undefined, undefined
98

9+
from ni_measurementlink_service._dotenvpath import get_dotenv_search_path
10+
1011
if TYPE_CHECKING:
1112
if sys.version_info >= (3, 11):
1213
from typing import Self
@@ -16,8 +17,7 @@
1617

1718
_PREFIX = "MEASUREMENTLINK"
1819

19-
# Search for the `.env` file starting with the current directory.
20-
_config = AutoConfig(str(pathlib.Path.cwd()))
20+
_config = AutoConfig(str(get_dotenv_search_path()))
2121

2222
if TYPE_CHECKING:
2323
# Work around decouple's lack of type hints.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import inspect
2+
import sys
3+
import traceback
4+
from pathlib import Path, PurePath
5+
from typing import Optional
6+
7+
8+
def get_dotenv_search_path() -> Path:
9+
"""Get the search path for loading the `.env` file."""
10+
# Prefer to load the `.env` file from the current directory or its parents.
11+
# If the current directory doesn't have a `.env` file, fall back to the
12+
# script/EXE path or the TestStand code module path.
13+
cwd = Path.cwd()
14+
if not _has_dotenv_file(cwd):
15+
if script_or_exe_path := _get_script_or_exe_path():
16+
return script_or_exe_path.resolve().parent
17+
if caller_path := _get_caller_path():
18+
return caller_path.resolve().parent
19+
return cwd
20+
21+
22+
def _has_dotenv_file(dir: Path) -> bool:
23+
"""Check whether the dir or its parents contains a `.env` file."""
24+
return (dir / ".env").exists() or any((p / ".env").exists() for p in dir.parents)
25+
26+
27+
def _get_script_or_exe_path() -> Optional[Path]:
28+
"""Get the path of the top-level script or PyInstaller EXE, if possible."""
29+
if getattr(sys, "frozen", False):
30+
return Path(sys.executable)
31+
32+
main_module = sys.modules.get("__main__")
33+
if main_module:
34+
script_path = getattr(main_module, "__file__", "")
35+
if script_path:
36+
return Path(script_path)
37+
38+
return None
39+
40+
41+
def _get_caller_path() -> Optional[Path]:
42+
"""Get the path of the module calling into ni_measurementlink_service, if possible."""
43+
nims_path = _get_nims_path()
44+
for frame, _ in traceback.walk_stack(inspect.currentframe()):
45+
if frame.f_code.co_filename:
46+
module_path = Path(frame.f_code.co_filename)
47+
if module_path.exists() and not _is_relative_to(module_path, nims_path):
48+
return module_path
49+
50+
return None
51+
52+
53+
def _is_relative_to(path: PurePath, other: PurePath) -> bool:
54+
if sys.version_info >= (3, 9):
55+
return path.is_relative_to(other)
56+
else:
57+
try:
58+
_ = path.relative_to(other)
59+
return True
60+
except ValueError:
61+
return False
62+
63+
64+
def _get_nims_path() -> Path:
65+
"""Get the path of the ni_measurementlink_service package."""
66+
nims_module = sys.modules["ni_measurementlink_service"]
67+
assert nims_module.__file__ and nims_module.__file__.endswith("__init__.py")
68+
return Path(nims_module.__file__).parent

tests/unit/test_dotenvpath.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
5+
from ni_measurementlink_service import _dotenvpath
6+
7+
8+
@pytest.mark.parametrize("dotenv_exists", [False, True])
9+
def test___dotenv_exists_varies___has_dotenv_file___matches_dotenv_exists(
10+
dotenv_exists: bool, tmp_path: Path
11+
) -> None:
12+
if dotenv_exists:
13+
(tmp_path / ".env").write_text("")
14+
subdirs = [tmp_path / "a", tmp_path / "a" / "b", tmp_path / "a" / "b" / "c"]
15+
for dir in subdirs:
16+
dir.mkdir()
17+
18+
assert _dotenvpath._has_dotenv_file(tmp_path) == dotenv_exists
19+
assert all([_dotenvpath._has_dotenv_file(p) == dotenv_exists for p in subdirs])
20+
21+
22+
def test___get_caller_path___returns_this_modules_path() -> None:
23+
assert _dotenvpath._get_caller_path() == Path(__file__)
24+
25+
26+
def test___get_nims_path___returns_package_dir() -> None:
27+
assert _dotenvpath._get_nims_path() == Path(_dotenvpath.__file__).parent
28+
29+
30+
def test___get_script_or_exe_path___returns_pytest_path() -> None:
31+
path = _dotenvpath._get_script_or_exe_path()
32+
33+
assert path is not None
34+
assert "pytest" in path.parts or "pytest.exe" in path.parts

0 commit comments

Comments
 (0)