Compare commits

..
6 Commits
Author SHA1 Message Date
devx-ci-bot 888cc4e3b2 release: v0.43.0 [skip ci] 2026-07-13 05:02:47 +00:00
emil f08ff0e7a3 DEVX-135: feat: add get_customer_vm_ip and get_observability_vm_ip to I/O check
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 1m1s
2026-07-13 05:02:03 +00:00
gitea-actions-bot 772e1b1c6d chore: update badge URLs to commit 29e3ef9c [skip ci] 2026-07-13 02:59:23 +00:00
devx-ci-bot bdfe2c561b release: v0.42.0 [skip ci] 2026-07-13 02:58:46 +00:00
emil 02b27dd343 DEVX-134: feat: add I/O function isolation check and skip integration tests
Post-merge / detect-and-configure (push) Successful in 16s
Post-merge / release-and-maintain (push) Successful in 1m7s
2026-07-13 02:57:54 +00:00
gitea-actions-bot e5488fcfbd chore: update badge URLs to commit ae591a0c [skip ci] 2026-07-13 02:27:15 +00:00
7 changed files with 250 additions and 22 deletions
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## [0.43.0] - 2026-07-13
### Features
- Add get_customer_vm_ip and get_observability_vm_ip to I/O check
## [0.42.0] - 2026-07-13
### Features
- Add I/O function isolation check and skip integration tests
## [0.41.2] - 2026-07-13
### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.41.2",
"devx>=0.43.0",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.41.2"`) or use a version constraint
> (for example, `"devx>=0.41.2,<0.42"`).
> `dependencies` (for example, `"devx==0.43.0"`) or use a version constraint
> (for example, `"devx>=0.43.0,<0.44"`).
### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/9175bdc9ea121021ef145c0233905f2ce7ce3e5c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/29e3ef9cfd8e7d5605c7189cf26f1fc0b0cff756/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.41.2",
"devx>=0.43.0",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.41.2"` or `"devx>=0.41.2,<0.42"`.
Pin a specific version if needed: `"devx==0.43.0"` or `"devx>=0.43.0,<0.44"`.
### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.41.2",
"devx>=0.43.0",
]
[project.optional-dependencies]
dev = [
"devx>=0.41.2",
"devx>=0.43.0",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.41.2"
__version__ = "0.43.0"
+77 -2
View File
@@ -30,7 +30,10 @@ Patterns detected:
without patching it.
3. **Unpatched known-subprocess-helpers** — functions known to spawn
subprocesses (e.g. ``update_doc_versions``) called without patching.
4. **Excessive iteration loops** — ``for _ in range(N)`` where N > 100.
4. **Unpatched I/O functions** — functions known to do filesystem or
network I/O (e.g. ``get_pat``, ``load_secrets``, ``requests.get``)
called without patching.
5. **Excessive iteration loops** — ``for _ in range(N)`` where N > 100.
"""
from __future__ import annotations
@@ -57,6 +60,25 @@ KNOWN_SUBPROCESS_HELPERS: dict[str, str] = {
"run_cmd": "calls subprocess.run for shell commands",
}
# Functions known to do filesystem or network I/O that should be mocked in tests.
# Maps function name → description of what I/O it does.
# If a test calls one of these without a corresponding @patch, it's a violation.
KNOWN_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passwords
"get_pat": "reads ZITADEL PAT from filesystem/env (ZitadelAuth._iter_sources)",
"load_secrets": "reads YAML config file from disk",
"get_customer_secret": "reads customer-specific config from disk",
"get_customer_vm_ip": "queries Hetzner Cloud API for VM IP (network I/O)",
"get_observability_vm_ip": "queries Hetzner Cloud API for observability VM IP (network I/O)",
"requests.get": "performs HTTP GET to a real server",
"requests.post": "performs HTTP POST to a real server",
"requests.put": "performs HTTP PUT to a real server",
"requests.patch": "performs HTTP PATCH to a real server",
"requests.delete": "performs HTTP DELETE to a real server",
"urlopen": "performs HTTP request to a real server",
"httpx.get": "performs HTTP GET to a real server",
"httpx.post": "performs HTTP POST to a real server",
}
# Transitive dependencies: if a helper calls another helper that is patched,
# the call is safe. Maps helper → set of function names it internally calls.
# If ANY of these are in the test's patches, the helper call is safe.
@@ -126,6 +148,20 @@ def _is_test_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
return node.name.startswith("test_")
def _has_integration_marker(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
"""Check if a test function has @pytest.mark.integration decorator."""
for decorator in node.decorator_list:
# @pytest.mark.integration → ast.Attribute(attr='integration')
if isinstance(decorator, ast.Attribute) and decorator.attr == "integration":
return True
# @pytest.mark.integration(...) → ast.Call(func=ast.Attribute(attr='integration'))
if isinstance(decorator, ast.Call):
func = decorator.func
if isinstance(func, ast.Attribute) and func.attr == "integration":
return True
return False
def _get_called_name(node: ast.Call) -> str | None:
func = node.func
if isinstance(func, ast.Name):
@@ -208,6 +244,11 @@ class TestIsolationVisitor(ast.NodeVisitor):
self.generic_visit(node)
return
# Skip integration tests — they intentionally do real I/O
if _has_integration_marker(node):
self.generic_visit(node)
return
patches = _extract_patch_targets(node)
info = TestFunctionInfo(
name=node.name,
@@ -299,6 +340,34 @@ class TestIsolationVisitor(ast.NodeVisitor):
)
)
# Check 4: Known I/O functions (filesystem/network)
# Match by short name (e.g. "get_pat") or full name (e.g. "requests.get")
sn = short_name or ""
io_key = sn if sn in KNOWN_IO_FUNCTIONS else None
if io_key is None and full_name and full_name in KNOWN_IO_FUNCTIONS:
io_key = full_name
if io_key and not (
io_key in all_patches
or sn in all_patches
or any(io_key in p or sn in p for p in all_patches)
or any(p.endswith(f".{sn}") for p in all_patches)
):
self.violations.append(
Violation(
file=self.file_path,
line=node.lineno,
col=node.col_offset,
category="unpatched-io",
message=_(
"{func} called in test '{test}' without @patch — "
'this function {desc}. Add @patch("<module>.{func}").',
func=io_key,
test=self._current_function.name,
desc=KNOWN_IO_FUNCTIONS[io_key],
),
)
)
self.generic_visit(node)
def visit_For(self, node: ast.For) -> None:
@@ -334,7 +403,13 @@ def find_test_files(test_path: Path) -> list[Path]:
def analyze_file(file_path: Path, max_loop_iterations: int = DEFAULT_MAX_LOOP_ITERATIONS) -> list[Violation]:
"""Analyze a single test file for isolation violations."""
"""Analyze a single test file for isolation violations.
Files in ``integration/`` directories are skipped — integration tests
intentionally do real I/O (subprocess, network, filesystem).
"""
if "integration" in file_path.parts:
return []
try:
source = file_path.read_text()
tree = ast.parse(source, filename=str(file_path))
+141
View File
@@ -566,6 +566,147 @@ class TestKnownHelpers:
assert "subprocess" in HELPER_INTERNAL_CALLS["update_doc_versions"]
class TestIOFunctionChecks:
"""Tests for unpatched I/O function detection."""
def test_unpatched_get_pat_violation(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
from mymodule import get_pat
class TestExample:
def test_calls_get_pat(self) -> None:
result = get_pat("staging")
""",
)
violations = analyze_file(file)
assert len(violations) == 1
assert violations[0].category == "unpatched-io"
assert "get_pat" in violations[0].message
def test_patched_get_pat_no_violation(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
from unittest.mock import patch
from mymodule import get_pat
class TestExample:
@patch("mymodule.get_pat", return_value="pat")
def test_patched(self, mock) -> None:
result = get_pat("staging")
""",
)
violations = analyze_file(file)
assert violations == []
def test_unpatched_load_secrets_violation(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
from mymodule import load_secrets
class TestExample:
def test_calls_load_secrets(self) -> None:
result = load_secrets("staging")
""",
)
violations = analyze_file(file)
assert len(violations) == 1
assert violations[0].category == "unpatched-io"
assert "load_secrets" in violations[0].message
def test_patched_load_secrets_no_violation(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
from unittest.mock import patch
from mymodule import load_secrets
class TestExample:
@patch("mymodule.load_secrets", return_value={})
def test_patched(self, mock) -> None:
result = load_secrets("staging")
""",
)
violations = analyze_file(file)
assert violations == []
def test_unpatched_requests_get_violation(self, tmp_path: Path) -> None:
file = _write_test_file(
tmp_path,
"""
import requests
class TestExample:
def test_calls_requests(self) -> None:
resp = requests.get("https://example.com")
""",
)
violations = analyze_file(file)
assert len(violations) == 1
assert violations[0].category == "unpatched-io"
assert "requests.get" in violations[0].message or "get" in violations[0].message
def test_integration_marker_skips_subprocess(self, tmp_path: Path) -> None:
"""@pytest.mark.integration tests should not be flagged for subprocess.run."""
file = _write_test_file(
tmp_path,
"""
import subprocess
import pytest
@pytest.mark.integration
def test_real_subprocess():
subprocess.run(["echo", "hello"])
""",
)
violations = analyze_file(file)
assert violations == []
def test_integration_marker_skips_sleep(self, tmp_path: Path) -> None:
"""@pytest.mark.integration tests should not be flagged for time.sleep."""
file = _write_test_file(
tmp_path,
"""
import time
import pytest
@pytest.mark.integration
def test_real_sleep():
time.sleep(1)
""",
)
violations = analyze_file(file)
assert violations == []
def test_integration_marker_with_args_skips(self, tmp_path: Path) -> None:
"""@pytest.mark.integration(...) with args should also be skipped."""
file = _write_test_file(
tmp_path,
"""
import subprocess
import pytest
@pytest.mark.integration(scope="module")
def test_real_subprocess():
subprocess.run(["echo", "hello"])
""",
)
violations = analyze_file(file)
assert violations == []
def test_integration_directory_skipped(self, tmp_path: Path) -> None:
"""Files in integration/ directories should be skipped entirely."""
integration_dir = tmp_path / "integration"
integration_dir.mkdir()
file = integration_dir / "test_real_io.py"
file.write_text("import subprocess\ndef test_real_subprocess():\n subprocess.run(['echo', 'hello'])\n")
violations = analyze_file(file)
assert violations == []
class TestCli:
"""Tests for the standalone CLI interface."""