Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -53,6 +54,9 @@ jobs:
- name: Check formatting
run: make format-check

- name: Type check
run: make typecheck

- name: Security scan
run: make security

Expand Down
11 changes: 2 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions src/ipsdk/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 11 additions & 12 deletions src/ipsdk/heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -148,28 +153,25 @@ 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()

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.

Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/ipsdk/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
36 changes: 19 additions & 17 deletions src/ipsdk/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -237,33 +239,33 @@ 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]

@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]
Expand Down Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/ipsdk/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down