diff --git a/pyproject.toml b/pyproject.toml index e42cf18..479ed47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ dependencies = [ "pyserial>=3.5", "Werkzeug>=3.1.5", "flask-limiter>=2.5.4", + "bleak>=0.21.0", + "flask-sock", + "requests>=2.28.0", ] [project.urls] diff --git a/requirements.txt b/requirements.txt index 791f646..0fe5775 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ flask>=2.0.0 flask-limiter>=2.5.4 requests>=2.28.0 +Werkzeug>=3.1.5 # BLE scanning with manufacturer data detection (optional - for TSCM) bleak>=0.21.0 diff --git a/tests/test_requirements.py b/tests/test_requirements.py new file mode 100644 index 0000000..27e4922 --- /dev/null +++ b/tests/test_requirements.py @@ -0,0 +1,109 @@ +import pytest +from pathlib import Path +import importlib.metadata +import tomllib # Standard in Python 3.11+ + +def get_root_path(): + return Path(__file__).parent.parent + +def _clean_string(req): + """Normalizes a requirement string (lowercase and removes spaces).""" + return req.strip().lower().replace(" ", "") + +def parse_txt_requirements(file_path): + """Extracts full requirement strings (name + version) from a .txt file.""" + if not file_path.exists(): + return set() + packages = set() + with open(file_path, "r") as f: + for line in f: + line = line.strip() + # Ignore empty lines, comments, and recursive/local flags + if not line or line.startswith(("#", "-e", "git+", "-r")): + continue + packages.add(_clean_string(line)) + return packages + +def parse_toml_section(data, section_type="main"): + """Extracts full requirement strings from pyproject.toml.""" + packages = set() + if section_type == "main": + deps = data.get("project", {}).get("dependencies", []) + else: + # Check optional-dependencies or dependency-groups + deps = data.get("project", {}).get("optional-dependencies", {}).get("dev", []) + if not deps: + deps = data.get("dependency-groups", {}).get("dev", []) + + for req in deps: + packages.add(_clean_string(req)) + return packages + +def test_dependency_files_integrity(): + """1. Verifies that .txt files and pyproject.toml have identical names AND versions.""" + root = get_root_path() + toml_path = root / "pyproject.toml" + assert toml_path.exists(), "Missing pyproject.toml" + + with open(toml_path, "rb") as f: + toml_data = tomllib.load(f) + + # Validate Production Sync + txt_main = parse_txt_requirements(root / "requirements.txt") + toml_main = parse_toml_section(toml_data, "main") + assert txt_main == toml_main, ( + f"Production version mismatch!\n" + f"Only in TXT: {txt_main - toml_main}\n" + f"Only in TOML: {toml_main - txt_main}" + ) + + # Validate Development Sync + txt_dev = parse_txt_requirements(root / "requirements-dev.txt") + toml_dev = parse_toml_section(toml_data, "dev") + assert txt_dev == toml_dev, ( + f"Development version mismatch!\n" + f"Only in TXT: {txt_dev - toml_dev}\n" + f"Only in TOML: {toml_dev - txt_dev}" + ) + +def test_environment_vs_toml(): + """2. Verifies that installed packages satisfy TOML requirements.""" + root = get_root_path() + with open(root / "pyproject.toml", "rb") as f: + data = tomllib.load(f) + + all_declared = parse_toml_section(data, "main") | parse_toml_section(data, "dev") + _verify_installation(all_declared, "TOML") + +def test_environment_vs_requirements(): + """3. Verifies that installed packages satisfy .txt requirements.""" + root = get_root_path() + all_txt_deps = ( + parse_txt_requirements(root / "requirements.txt") | + parse_txt_requirements(root / "requirements-dev.txt") + ) + _verify_installation(all_txt_deps, "requirements.txt") + +def _verify_installation(package_set, source_name): + """Helper to check if declared versions match installed versions.""" + missing_or_wrong = [] + + for req in package_set: + # Split name from version to check installation status + # handles ==, >=, ~=, <=, > , < + import re + parts = re.split(r'==|>=|~=|<=|>|<', req) + name = parts[0].strip() + + try: + installed_ver = importlib.metadata.version(name) + # If the config uses exact versioning '==', we can do a strict check + if "==" in req: + expected_ver = req.split("==")[1].strip() + if installed_ver != expected_ver: + missing_or_wrong.append(f"{name} (Installed: {installed_ver}, Expected: {expected_ver})") + except importlib.metadata.PackageNotFoundError: + missing_or_wrong.append(f"{name} (Not installed)") + + if missing_or_wrong: + pytest.fail(f"Environment out of sync with {source_name}:\n" + "\n".join(missing_or_wrong)) \ No newline at end of file