Skip to content

Commit 17ca920

Browse files
mikeprosserniMike Prosser
andauthored
Add WAVEFORM_SUPPORT Feature Toggle, and Feature Toggle Support (#799)
* first draft - based on measurement-plugin-python * cleanup - remove _configuration.py * update changelog * address feedback * make _dotenvpath.py generic, and add tests for it --------- Co-authored-by: Mike Prosser <[email protected]>
1 parent ddb23e1 commit 17ca920

File tree

15 files changed

+682
-6
lines changed

15 files changed

+682
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ All notable changes to this project will be documented in this file.
3232
* ...
3333

3434
* ### Major Changes
35-
* (IN PROGRESS) Added support for reading and writing Waveform data.
35+
* (IN PROGRESS behind "WAVEFORM_SUPPORT" feature toggle) Added support for reading and writing Waveform data.
3636

3737
* ### Known Issues
3838
* ...

examples/analog_in/voltage_acq_int_clk_wfm.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@
44
of data using the DAQ device's internal clock.
55
"""
66

7-
import nidaqmx
8-
from nidaqmx.constants import AcquisitionType, READ_ALL_AVAILABLE
9-
from nidaqmx.stream_readers import AnalogSingleChannelReader
7+
import os
8+
9+
os.environ["NIDAQMX_ENABLE_WAVEFORM_SUPPORT"] = "1"
10+
11+
import nidaqmx # noqa: E402 # Must import after setting environment variable
12+
from nidaqmx.constants import AcquisitionType, READ_ALL_AVAILABLE # noqa: E402
13+
from nidaqmx.stream_readers import AnalogSingleChannelReader # noqa: E402
1014

1115
with nidaqmx.Task() as task:
1216
task.ai_channels.add_ai_voltage_chan("Dev1/ai0")

generated/nidaqmx/_dotenvpath.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import sys
5+
import traceback
6+
from pathlib import Path
7+
8+
9+
def get_dotenv_search_path() -> Path:
10+
"""Get the search path for loading the `.env` file."""
11+
# Prefer to load the `.env` file from the current directory or its parents.
12+
# If the current directory doesn't have a `.env` file, fall back to the
13+
# script/EXE path or the TestStand code module path.
14+
cwd = Path.cwd()
15+
if not _has_dotenv_file(cwd):
16+
if script_or_exe_path := _get_script_or_exe_path():
17+
return script_or_exe_path.resolve().parent
18+
if caller_path := _get_caller_path():
19+
return caller_path.resolve().parent
20+
return cwd
21+
22+
23+
def _has_dotenv_file(dir: Path) -> bool:
24+
"""Check whether the dir or its parents contains a `.env` file."""
25+
return (dir / ".env").exists() or any((p / ".env").exists() for p in dir.parents)
26+
27+
28+
def _get_script_or_exe_path() -> Path | None:
29+
"""Get the path of the top-level script or PyInstaller EXE, if possible."""
30+
if getattr(sys, "frozen", False):
31+
return Path(sys.executable)
32+
33+
main_module = sys.modules.get("__main__")
34+
if main_module:
35+
script_path = getattr(main_module, "__file__", "")
36+
if script_path:
37+
return Path(script_path)
38+
39+
return None
40+
41+
42+
def _get_caller_path() -> Path | None:
43+
"""Get the path of the module calling into this package, if possible."""
44+
package_path = _get_package_path()
45+
for frame, _ in traceback.walk_stack(inspect.currentframe()):
46+
if frame.f_code.co_filename:
47+
module_path = Path(frame.f_code.co_filename)
48+
if _exists(module_path) and not module_path.is_relative_to(package_path):
49+
return module_path
50+
51+
return None
52+
53+
54+
# Path.exists() throws OSError when the path has invalid file characters.
55+
# https://github.com/python/cpython/issues/79487
56+
if sys.version_info >= (3, 10):
57+
58+
def _exists(path: Path) -> bool:
59+
return path.exists()
60+
61+
else:
62+
63+
def _exists(path: Path) -> bool:
64+
import os
65+
66+
return os.path.exists(path)
67+
68+
69+
def _get_package_path() -> Path:
70+
"""Get the path of this package."""
71+
module = sys.modules[__package__]
72+
assert module.__file__ and module.__file__.endswith("__init__.py")
73+
return Path(module.__file__).parent
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""nidaqmx feature toggles."""
2+
3+
from __future__ import annotations
4+
5+
import functools
6+
import sys
7+
from decouple import AutoConfig, Undefined, undefined
8+
from enum import Enum
9+
from typing import TYPE_CHECKING, Callable, TypeVar
10+
from nidaqmx._dotenvpath import get_dotenv_search_path
11+
from nidaqmx.errors import FeatureNotSupportedError
12+
13+
if TYPE_CHECKING:
14+
if sys.version_info >= (3, 10):
15+
from typing import ParamSpec
16+
else:
17+
from typing_extensions import ParamSpec
18+
19+
if sys.version_info >= (3, 11):
20+
from typing import Self
21+
else:
22+
from typing_extensions import Self
23+
24+
_P = ParamSpec("_P")
25+
_T = TypeVar("_T")
26+
27+
_PREFIX = "NIDAQMX"
28+
29+
_config = AutoConfig(str(get_dotenv_search_path()))
30+
31+
if TYPE_CHECKING:
32+
# Work around decouple's lack of type hints.
33+
def _config(
34+
option: str,
35+
default: _T | Undefined = undefined,
36+
cast: Callable[[str], _T] | Undefined = undefined,
37+
) -> _T: ...
38+
39+
# Based on the recipe at https://docs.python.org/3/howto/enum.html
40+
class _OrderedEnum(Enum):
41+
def __ge__(self, other: Self) -> bool:
42+
if self.__class__ is other.__class__:
43+
return self.value >= other.value
44+
return NotImplemented
45+
46+
def __gt__(self, other: Self) -> bool:
47+
if self.__class__ is other.__class__:
48+
return self.value > other.value
49+
return NotImplemented
50+
51+
def __le__(self, other: Self) -> bool:
52+
if self.__class__ is other.__class__:
53+
return self.value <= other.value
54+
return NotImplemented
55+
56+
def __lt__(self, other: Self) -> bool:
57+
if self.__class__ is other.__class__:
58+
return self.value < other.value
59+
return NotImplemented
60+
61+
62+
class CodeReadiness(_OrderedEnum):
63+
"""Indicates whether code is ready to be supported."""
64+
65+
RELEASE = 0
66+
NEXT_RELEASE = 1
67+
INCOMPLETE = 2
68+
PROTOTYPE = 3
69+
70+
71+
def _init_code_readiness_level() -> CodeReadiness:
72+
if _config(f"{_PREFIX}_ALLOW_INCOMPLETE", default=False, cast=bool):
73+
return CodeReadiness.INCOMPLETE
74+
elif _config(f"{_PREFIX}_ALLOW_NEXT_RELEASE", default=False, cast=bool):
75+
return CodeReadiness.NEXT_RELEASE
76+
else:
77+
return CodeReadiness.RELEASE
78+
79+
80+
# This is not public because `from _feature_toggles import CODE_READINESS_LEVEL`
81+
# is incompatible with the patching performed by the use_code_readiness mark.
82+
_CODE_READINESS_LEVEL = _init_code_readiness_level()
83+
84+
85+
def get_code_readiness_level() -> CodeReadiness:
86+
"""Get the current code readiness level.
87+
88+
You can override this in tests by specifying the ``use_code_readiness``
89+
mark.
90+
"""
91+
return _CODE_READINESS_LEVEL
92+
93+
94+
class FeatureToggle:
95+
"""A run-time feature toggle."""
96+
97+
name: str
98+
"""The name of the feature."""
99+
100+
readiness: CodeReadiness
101+
"""The code readiness at which this feature is enabled."""
102+
103+
def __init__(self, name: str, readiness: CodeReadiness) -> None:
104+
"""Initialize the feature toggle."""
105+
assert name == name.upper()
106+
self.name = name
107+
self.readiness = readiness
108+
self._is_enabled_override = None
109+
# Only read the env var at initialization time.
110+
if _config(f"{_PREFIX}_ENABLE_{name}", default=False, cast=bool):
111+
self._is_enabled_override = True
112+
113+
@property
114+
def is_enabled(self) -> bool:
115+
"""Indicates whether the feature is currently enabled.
116+
117+
You can enable/disable features in tests by specifying the
118+
``enable_feature_toggle`` or ``disable_feature_toggle`` marks.
119+
"""
120+
if self._is_enabled_override is not None:
121+
return self._is_enabled_override
122+
return self.readiness <= get_code_readiness_level()
123+
124+
def _raise_if_disabled(self) -> None:
125+
if self.is_enabled:
126+
return
127+
128+
env_vars = f"{_PREFIX}_ENABLE_{self.name}"
129+
if self.readiness in [CodeReadiness.NEXT_RELEASE, CodeReadiness.INCOMPLETE]:
130+
env_vars += f" or {_PREFIX}_ALLOW_{self.readiness.name}"
131+
message = (
132+
f"The {self.name} feature is not supported at the current code readiness level. "
133+
f" To enable it, set {env_vars}."
134+
)
135+
raise FeatureNotSupportedError(message)
136+
137+
138+
def requires_feature(
139+
feature_toggle: FeatureToggle,
140+
) -> Callable[[Callable[_P, _T]], Callable[_P, _T]]:
141+
"""Decorator specifying that the function requires the specified feature toggle."""
142+
143+
def decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
144+
@functools.wraps(func)
145+
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
146+
feature_toggle._raise_if_disabled()
147+
return func(*args, **kwargs)
148+
149+
return wrapper
150+
151+
return decorator
152+
153+
154+
# --------------------------------------
155+
# Define feature toggle constants here:
156+
# --------------------------------------
157+
158+
WAVEFORM_SUPPORT = FeatureToggle("WAVEFORM_SUPPORT", CodeReadiness.INCOMPLETE)

generated/nidaqmx/errors.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,3 +200,7 @@ def __init__(self, rpc_code, description):
200200
except Exception:
201201
rpc_error = str(self.rpc_code)
202202
super().__init__(rpc_error + ": " + self.description)
203+
204+
205+
class FeatureNotSupportedError(Exception):
206+
"""The feature is not supported at the current code readiness level."""

generated/nidaqmx/stream_readers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import numpy
44
from nidaqmx import DaqError
55

6+
from nidaqmx._feature_toggles import WAVEFORM_SUPPORT, requires_feature
67
from nidaqmx.constants import FillMode, READ_ALL_AVAILABLE
78
from nidaqmx.error_codes import DAQmxErrors
89
from nidaqmx.types import PowerMeasurement, CtrFreq, CtrTick, CtrTime
@@ -238,6 +239,7 @@ def read_one_sample(self, timeout=10):
238239
"""
239240
return self._interpreter.read_analog_scalar_f64(self._handle, timeout)
240241

242+
@requires_feature(WAVEFORM_SUPPORT)
241243
def read_waveform(
242244
self,
243245
number_of_samples_per_channel: int = READ_ALL_AVAILABLE,

pyproject.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ markers = [
134134
"grpc_session_name: specifies GrpcSessionOptions.session_name.",
135135
"grpc_session_initialization_behavior: specifies GrpcSessionOptions.initialization_behavior.",
136136
"temporary_grpc_channel(options=...): specifies that the test uses a separate gRPC channel.",
137+
"disable_feature_toggle: specifies a feature toggle to disable for the test function/module.",
138+
"enable_feature_toggle: specifies a feature toggle to enable for the test function/module.",
139+
"use_code_readiness: specifies a code readiness level to use for the test function/module.",
137140
]
138141

139142
[build-system]
@@ -172,10 +175,12 @@ warn_unused_ignores = false
172175

173176
[tool.pyright]
174177
typeCheckingMode = "basic"
175-
reportOptionalMemberAccess = false
176178
reportArgumentType = false
177-
reportOperatorIssue = false
178179
reportAttributeAccessIssue = false
180+
reportInvalidTypeForm = false
181+
reportOperatorIssue = false
182+
reportOptionalMemberAccess = false
183+
reportReturnType = false
179184

180185
[tool.bandit]
181186
skips = [

src/handwritten/_dotenvpath.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import sys
5+
import traceback
6+
from pathlib import Path
7+
8+
9+
def get_dotenv_search_path() -> Path:
10+
"""Get the search path for loading the `.env` file."""
11+
# Prefer to load the `.env` file from the current directory or its parents.
12+
# If the current directory doesn't have a `.env` file, fall back to the
13+
# script/EXE path or the TestStand code module path.
14+
cwd = Path.cwd()
15+
if not _has_dotenv_file(cwd):
16+
if script_or_exe_path := _get_script_or_exe_path():
17+
return script_or_exe_path.resolve().parent
18+
if caller_path := _get_caller_path():
19+
return caller_path.resolve().parent
20+
return cwd
21+
22+
23+
def _has_dotenv_file(dir: Path) -> bool:
24+
"""Check whether the dir or its parents contains a `.env` file."""
25+
return (dir / ".env").exists() or any((p / ".env").exists() for p in dir.parents)
26+
27+
28+
def _get_script_or_exe_path() -> Path | None:
29+
"""Get the path of the top-level script or PyInstaller EXE, if possible."""
30+
if getattr(sys, "frozen", False):
31+
return Path(sys.executable)
32+
33+
main_module = sys.modules.get("__main__")
34+
if main_module:
35+
script_path = getattr(main_module, "__file__", "")
36+
if script_path:
37+
return Path(script_path)
38+
39+
return None
40+
41+
42+
def _get_caller_path() -> Path | None:
43+
"""Get the path of the module calling into this package, if possible."""
44+
package_path = _get_package_path()
45+
for frame, _ in traceback.walk_stack(inspect.currentframe()):
46+
if frame.f_code.co_filename:
47+
module_path = Path(frame.f_code.co_filename)
48+
if _exists(module_path) and not module_path.is_relative_to(package_path):
49+
return module_path
50+
51+
return None
52+
53+
54+
# Path.exists() throws OSError when the path has invalid file characters.
55+
# https://github.com/python/cpython/issues/79487
56+
if sys.version_info >= (3, 10):
57+
58+
def _exists(path: Path) -> bool:
59+
return path.exists()
60+
61+
else:
62+
63+
def _exists(path: Path) -> bool:
64+
import os
65+
66+
return os.path.exists(path)
67+
68+
69+
def _get_package_path() -> Path:
70+
"""Get the path of this package."""
71+
module = sys.modules[__package__]
72+
assert module.__file__ and module.__file__.endswith("__init__.py")
73+
return Path(module.__file__).parent

0 commit comments

Comments
 (0)