diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d3df21..94a25b0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -6,6 +6,7 @@ # the test matrix across all supported Python versions. # # Jobs: quality → test (matrix: 3.10–3.14) +# quality: lint, format-check, typecheck, security, license # Secrets required: none # --- name: CI @@ -28,7 +29,7 @@ permissions: jobs: # -- quality - # Runs all non-test checks from `make ci`: lint, format, security, license. + # Runs all non-test checks from `make ci`: lint, format, typecheck, security, license. # Executes once on Python 3.13 — these checks are Python-version-independent. # The test matrix will not start until this job passes. quality: @@ -53,6 +54,9 @@ jobs: - name: Check formatting run: make format-check + - name: Type check + run: make typecheck + - name: Security scan run: make security diff --git a/AGENTS.md b/AGENTS.md index 0eda9b3..62e1aff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ make test # pytest make coverage # pytest --cov (100% required) make lint # ruff check make format # ruff format +make typecheck # mypy static type checking make security # bandit security scan make license # Check GPL headers make license-fix # Add missing headers @@ -72,7 +73,7 @@ tox -e ci # All checks **Note**: `make ci` includes the license header check; `tox -e ci` does not. Run `make ci` locally before pushing. -CI (`ci.yaml`): runs `make ci` then a separate coverage check with `--cov-fail-under=95`. +CI (`ci.yaml`): runs lint, format-check, typecheck, security, license, then a matrix test run with `--cov-fail-under=95`. ## Code Standards @@ -99,14 +100,6 @@ CI (`ci.yaml`): runs `make ci` then a separate coverage check with `--cov-fail-u ## Known Issues -**mypy is broken (~22 errors)**: -- `logging.py`: Custom `TRACE`/`NONE`/`FATAL` constants assigned to `logging` module (stdlib doesn't declare these attributes) -- `heuristics.py`: Lambda type inference and `Callable` annotation mismatches -- `http.py`: `HTTPMethod` import/redefinition conflict (stdlib vs. fallback enum) -- `platform.py`: Return type annotation uses dynamic `type()` result - -**Impact**: Mypy is not run in CI, so this doesn't block development. Runtime behavior is correct. Fix approach: add `# type: ignore[misc]` to custom level assignments in `logging.py`. - **Missing `.pre-commit-config.yaml`**: `pre-commit` is listed as a dev dependency but the config file doesn't exist in the repo. ## Gotchas diff --git a/Makefile b/Makefile index 7b9cec0..d801289 100644 --- a/Makefile +++ b/Makefile @@ -53,7 +53,10 @@ build: ## Build distribution packages (wheel + sdist) # Quality checks # ------------------------------------------------------------------------------ -.PHONY: lint format format-check ruff-fix security license license-fix notice-check +.PHONY: lint format format-check ruff-fix security typecheck license license-fix notice-check + +typecheck: ## Run mypy static type checking + $(UV) run mypy $(SRC) lint: ## Lint with ruff $(UV) run ruff check $(SRC) $(TESTS) @@ -94,7 +97,7 @@ notice-check: ## Verify NOTICE file lists all packages in uv.lock .PHONY: ci -ci: clean lint format-check security license tox ## Run all checks (required before committing) +ci: clean lint format-check typecheck security license tox ## Run all checks (required before committing) # ------------------------------------------------------------------------------ # Tox (multi-version) diff --git a/src/ipsdk/gateway.py b/src/ipsdk/gateway.py index f1eba76..4716585 100644 --- a/src/ipsdk/gateway.py +++ b/src/ipsdk/gateway.py @@ -279,10 +279,10 @@ async def authenticate(self) -> None: # Define dynamically created classes for runtime and type checking if TYPE_CHECKING: # For type checkers: provide explicit class definitions - class Gateway(AuthMixin, connection.Connection): + class Gateway(AuthMixin, connection.Connection): # type: ignore[misc] """Synchronous Gateway client with authentication.""" - class AsyncGateway(AsyncAuthMixin, connection.AsyncConnection): + class AsyncGateway(AsyncAuthMixin, connection.AsyncConnection): # type: ignore[misc] """Asynchronous Gateway client with authentication.""" else: diff --git a/src/ipsdk/heuristics.py b/src/ipsdk/heuristics.py index 32d59b2..43d403e 100644 --- a/src/ipsdk/heuristics.py +++ b/src/ipsdk/heuristics.py @@ -21,6 +21,10 @@ from re import Pattern +def _make_redaction_func(name: str) -> Callable[[str], str]: + return lambda _: f"[REDACTED_{name.upper()}]" + + class Scanner: """Scanner for detecting and redacting sensitive data patterns in text. @@ -84,7 +88,8 @@ def __init__(self, custom_patterns: dict[str, str | None] | None = None) -> None # Add custom patterns if provided if custom_patterns is not None: for name, pattern in custom_patterns.items(): - self.add_pattern(name, pattern) + if pattern is not None: + self.add_pattern(name, pattern) # Mark as initialized Scanner._initialized = True @@ -148,13 +153,10 @@ def _init_default_patterns(self) -> None: for name, pattern_str in patterns_to_compile.items(): compiled_pattern = re.compile(pattern_str) Scanner._default_patterns[name] = compiled_pattern - # Use default argument to capture current value of name - # (avoid late-binding closure issue) - Scanner._default_redactions[name] = lambda _, n=name: ( - f"[REDACTED_{n.upper()}]" - ) + Scanner._default_redactions[name] = _make_redaction_func(name) # Copy pre-compiled patterns to instance + assert Scanner._default_redactions is not None self._patterns = Scanner._default_patterns.copy() self._redaction_functions = Scanner._default_redactions.copy() @@ -162,14 +164,14 @@ def add_pattern( self, name: str, pattern: str, - redaction_func: Callable[[str | None, str]] | None = None, + redaction_func: Callable[[str], str] | None = None, ) -> None: """Add a new sensitive data pattern to scan for. Args: name (str): Name of the pattern for identification. pattern (str): Regular expression pattern to match sensitive data. - redaction_func (Callable[[str | None, str]]): Custom function + redaction_func (Callable[[str], str]): Custom function to redact matches. If None, uses default redaction with pattern name. @@ -239,10 +241,7 @@ def scan_and_redact(self, text: str) -> str: for pattern_name, pattern in self._patterns.items(): redaction_func = self._redaction_functions[pattern_name] - # Use lambda with default arg to capture current redaction_func - result = pattern.sub( - lambda match, func=redaction_func: func(match.group(0)), result - ) + result = pattern.sub(lambda match: redaction_func(match.group(0)), result) # noqa: B023 return result diff --git a/src/ipsdk/http.py b/src/ipsdk/http.py index d8040a7..3ca4297 100644 --- a/src/ipsdk/http.py +++ b/src/ipsdk/http.py @@ -23,12 +23,12 @@ # Import HTTPMethod from standard library (Python 3.11+) or define fallback try: - from http import HTTPMethod + from http import HTTPMethod # type: ignore[attr-defined] except ImportError: # Python < 3.11: Define HTTPMethod enum for backward compatibility from enum import Enum - class HTTPMethod(Enum): + class HTTPMethod(Enum): # type: ignore[no-redef] """Enumeration of HTTP methods. Includes all standard HTTP methods defined in RFC specifications. diff --git a/src/ipsdk/logging.py b/src/ipsdk/logging.py index 8c0d17f..0bee902 100644 --- a/src/ipsdk/logging.py +++ b/src/ipsdk/logging.py @@ -124,26 +124,28 @@ def process_data(data): logging_message_format = "%(asctime)s: [%(name)s] %(levelname)s: %(message)s" -# Add the FATAL logging level -logging.FATAL = 90 # type: ignore[misc] -logging.addLevelName(logging.FATAL, "FATAL") +# Custom logging level constants +TRACE = 5 +FATAL = 90 +NONE = 100 -logging.NONE = logging.FATAL + 10 -logging.addLevelName(logging.NONE, "NONE") +# Register custom levels on the stdlib logging module +logging.FATAL = FATAL # type: ignore[misc] +logging.addLevelName(FATAL, "FATAL") -logging.TRACE = 5 -logging.addLevelName(logging.TRACE, "TRACE") +logging.NONE = NONE # type: ignore[attr-defined] +logging.addLevelName(NONE, "NONE") + +logging.TRACE = TRACE # type: ignore[attr-defined] +logging.addLevelName(TRACE, "TRACE") # Logging level constants that wrap stdlib logging module constants NOTSET = logging.NOTSET -TRACE = logging.TRACE DEBUG = logging.DEBUG INFO = logging.INFO WARNING = logging.WARNING ERROR = logging.ERROR CRITICAL = logging.CRITICAL -FATAL = logging.FATAL -NONE = logging.NONE # Set initial log level to NONE (disabled) logging.getLogger(metadata.name).setLevel(NONE) @@ -237,16 +239,16 @@ def process_data(data): @wraps(f) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: start_time = time.perf_counter() - log(logging.TRACE, f"→ {func_name}") + log(TRACE, f"→ {func_name}") try: result = await f(*args, **kwargs) except Exception: elapsed_ms = (time.perf_counter() - start_time) * 1000 - log(logging.TRACE, f"← {func_name} (exception, {elapsed_ms:.2f}ms)") + log(TRACE, f"← {func_name} (exception, {elapsed_ms:.2f}ms)") raise else: elapsed_ms = (time.perf_counter() - start_time) * 1000 - log(logging.TRACE, f"← {func_name} ({elapsed_ms:.2f}ms)") + log(TRACE, f"← {func_name} ({elapsed_ms:.2f}ms)") return result return async_wrapper # type: ignore[return-value] @@ -254,16 +256,16 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: @wraps(f) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: start_time = time.perf_counter() - log(logging.TRACE, f"→ {func_name}") + log(TRACE, f"→ {func_name}") try: result = f(*args, **kwargs) except Exception: elapsed_ms = (time.perf_counter() - start_time) * 1000 - log(logging.TRACE, f"← {func_name} (exception, {elapsed_ms:.2f}ms)") + log(TRACE, f"← {func_name} (exception, {elapsed_ms:.2f}ms)") raise else: elapsed_ms = (time.perf_counter() - start_time) * 1000 - log(logging.TRACE, f"← {func_name} ({elapsed_ms:.2f}ms)") + log(TRACE, f"← {func_name} ({elapsed_ms:.2f}ms)") return result return sync_wrapper # type: ignore[return-value] @@ -307,7 +309,7 @@ def fatal(msg: str) -> None: Raises: SystemExit: Always raised with exit code 1 after logging the fatal error. """ - log(logging.FATAL, msg) + log(FATAL, msg) print(f"ERROR: {msg}", file=sys.stderr) sys.exit(1) diff --git a/src/ipsdk/platform.py b/src/ipsdk/platform.py index ff05b14..da0714e 100644 --- a/src/ipsdk/platform.py +++ b/src/ipsdk/platform.py @@ -596,10 +596,10 @@ async def authenticate_oauth(self) -> None: # Define dynamically created classes for runtime and type checking if TYPE_CHECKING: # For type checkers: provide explicit class definitions - class Platform(AuthMixin, connection.Connection): + class Platform(AuthMixin, connection.Connection): # type: ignore[misc] """Synchronous Platform client with authentication.""" - class AsyncPlatform(AsyncAuthMixin, connection.AsyncConnection): + class AsyncPlatform(AsyncAuthMixin, connection.AsyncConnection): # type: ignore[misc] """Asynchronous Platform client with authentication.""" else: