diff --git a/.deepsource.toml b/.deepsource.toml index 180d391..5680e6e 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -1,6 +1,11 @@ version = 1 -test_patterns = ["test/**"] +test_patterns = [ + "test/**", + "tests/**", + "**/test/**", + "**/tests/**", +] [[analyzers]] name = "python" diff --git a/Access/is_auth.py b/Access/is_auth.py index e15ef4f..3cb52e5 100644 --- a/Access/is_auth.py +++ b/Access/is_auth.py @@ -5,7 +5,7 @@ from flask import g, request from flask_httpauth import HTTPBasicAuth -from Api.api import conn, api +from Api.core import conn, api from Access.policy import authorize userpass: HTTPBasicAuth = HTTPBasicAuth() diff --git a/Api/api.py b/Api/api.py index 9b191e4..30a4047 100644 --- a/Api/api.py +++ b/Api/api.py @@ -1,28 +1,50 @@ #!/usr/bin/env python3 import os -from connection import Connection -from flask import Flask, Blueprint from flask_cors import CORS -from flask_restx import Api -authorizations = { - "Token": {"type": "apiKey", "in": "header", "name": "X-API-KEY"}, - "Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}, - "UserPass": {"type": "basic"}, -} - -conn = Connection() -api_v1 = Blueprint("api", __name__, url_prefix="/api") -api = Api( - api_v1, - version="2.0.0", - title="Simple Secrets Manager", - description="Secrets management simplified", - authorizations=authorizations, +from Api.core import app +from Api.resources.secrets.kv_resource import Engine_KV # noqa: F401 +from Api.resources.auth.tokens_resource import Auth_Tokens # noqa: F401 +from Api.resources.auth.tokens_v2_resource import ( # noqa: F401 + ListTokensResource, + PersonalTokenResource, + RevokeTokenResource, + ServiceTokenResource, +) +from Api.resources.auth.onboarding_resource import ( # noqa: F401 + OnboardingBootstrapResource, + OnboardingStatusResource, +) +from Api.resources.projects.projects_resource import ProjectsResource # noqa: F401 +from Api.resources.configs.configs_resource import ConfigsResource # noqa: F401 +from Api.resources.secrets.secrets_resource import ( # noqa: F401 + SecretExportResource, + SecretItemResource, +) +from Api.resources.compare.compare_secret_resource import ( # noqa: F401 + CompareSecretResource, ) -app = Flask(__name__) -app.register_blueprint(api_v1) +from Api.resources.audit.audit_resource import AuditEventsResource # noqa: F401 +from Api.resources.me import MeResource # noqa: F401 +from Api.resources.workspace.workspace_resource import ( # noqa: F401 + WorkspaceGroupItemResource, + WorkspaceGroupMappingItemResource, + WorkspaceGroupMappingsResource, + WorkspaceGroupMembersResource, + WorkspaceGroupsResource, + WorkspaceMemberItemResource, + WorkspaceMembersResource, + WorkspaceProjectMemberItemResource, + WorkspaceProjectMembersResource, + WorkspaceSettingsResource, +) +from Api.resources.auth.userpass_resource import ( # noqa: F401 + Auth_Userpass_delete, + Auth_Userpass_register, +) +from Api.resources.meta.version_resource import VersionResource # noqa: F401 +from Api.errors import errors # noqa: F401 cors_origins = [ origin.strip() @@ -34,46 +56,3 @@ resources={r"/api/*": {"origins": cors_origins or "*"}}, allow_headers=["Authorization", "Content-Type", "X-API-KEY"], ) - -if True: - from Api.resources.secrets.kv_resource import Engine_KV # noqa: F401 - from Api.resources.auth.tokens_resource import Auth_Tokens # noqa: F401 - from Api.resources.auth.tokens_v2_resource import ( # noqa: F401 - ListTokensResource, - ServiceTokenResource, - PersonalTokenResource, - RevokeTokenResource, - ) - from Api.resources.auth.onboarding_resource import ( # noqa: F401 - OnboardingStatusResource, - OnboardingBootstrapResource, - ) - from Api.resources.projects.projects_resource import ProjectsResource # noqa: F401 - from Api.resources.configs.configs_resource import ConfigsResource # noqa: F401 - from Api.resources.secrets.secrets_resource import ( # noqa: F401 - SecretItemResource, - SecretExportResource, - ) - from Api.resources.compare.compare_secret_resource import ( - CompareSecretResource, # noqa: F401 - ) - from Api.resources.audit.audit_resource import AuditEventsResource # noqa: F401 - from Api.resources.me import MeResource # noqa: F401 - from Api.resources.workspace.workspace_resource import ( # noqa: F401 - WorkspaceSettingsResource, - WorkspaceMembersResource, - WorkspaceMemberItemResource, - WorkspaceProjectMembersResource, - WorkspaceProjectMemberItemResource, - WorkspaceGroupsResource, - WorkspaceGroupItemResource, - WorkspaceGroupMembersResource, - WorkspaceGroupMappingsResource, - WorkspaceGroupMappingItemResource, - ) - from Api.resources.auth.userpass_resource import ( # noqa: F401 - Auth_Userpass_delete, - Auth_Userpass_register, - ) - from Api.resources.meta.version_resource import VersionResource # noqa: F401 - from Api.errors import errors # noqa: F401 diff --git a/Api/core.py b/Api/core.py new file mode 100644 index 0000000..7650f86 --- /dev/null +++ b/Api/core.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +from connection import Connection +from flask import Blueprint, Flask +from flask_restx import Api + +authorizations = { + "Token": {"type": "apiKey", "in": "header", "name": "X-API-KEY"}, + "Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}, + "UserPass": {"type": "basic"}, +} + +conn = Connection() +api_v1 = Blueprint("api", __name__, url_prefix="/api") +api = Api( + api_v1, + version="2.0.0", + title="Simple Secrets Manager", + description="Secrets management simplified", + authorizations=authorizations, +) +app = Flask(__name__) +app.register_blueprint(api_v1) diff --git a/Api/errors/errors.py b/Api/errors/errors.py index 747418b..2d821b9 100644 --- a/Api/errors/errors.py +++ b/Api/errors/errors.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -from Api.api import app from flask import jsonify +from Api.core import app @app.errorhandler(404) -def not_found(e): +def not_found(_error): return jsonify(error="Resource not found"), 404 @app.errorhandler(Exception) -def server_error(e): - app.logger.exception(e) +def server_error(error): + app.logger.exception(error) return jsonify(error="Server error. Contact administrator"), 500 diff --git a/Api/resources/audit/audit_resource.py b/Api/resources/audit/audit_resource.py index 773e135..a89e927 100644 --- a/Api/resources/audit/audit_resource.py +++ b/Api/resources/audit/audit_resource.py @@ -3,7 +3,7 @@ from flask_restx import Resource -from Api.api import api, conn +from Api.core import api, conn from Api.resources.helpers import resolve_project_config from Api.serialization import oid_to_str from Access.is_auth import with_token, require_scope diff --git a/Api/resources/auth/onboarding_resource.py b/Api/resources/auth/onboarding_resource.py index 4ee4823..308146b 100644 --- a/Api/resources/auth/onboarding_resource.py +++ b/Api/resources/auth/onboarding_resource.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from flask_restx import Resource -from Api.api import api, conn +from Api.core import api, conn onboarding_ns = api.namespace("onboarding", description="First-time setup") diff --git a/Api/resources/auth/tokens_resource.py b/Api/resources/auth/tokens_resource.py index babd4b8..e124379 100644 --- a/Api/resources/auth/tokens_resource.py +++ b/Api/resources/auth/tokens_resource.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # Token Authentication API Resource from flask_restx import fields, Resource -from Api.api import api, conn +from Api.core import api, conn from Access.is_auth import userpass # tokens Namespace diff --git a/Api/resources/auth/tokens_v2_resource.py b/Api/resources/auth/tokens_v2_resource.py index 447e0a8..eef8e5c 100644 --- a/Api/resources/auth/tokens_v2_resource.py +++ b/Api/resources/auth/tokens_v2_resource.py @@ -4,7 +4,7 @@ from flask import g from flask_restx import Resource, inputs -from Api.api import api, conn +from Api.core import api, conn from Api.resources.helpers import resolve_project_config from Access.is_auth import with_token, require_scope, audit_event diff --git a/Api/resources/auth/userpass_resource.py b/Api/resources/auth/userpass_resource.py index c323c70..c48e289 100644 --- a/Api/resources/auth/userpass_resource.py +++ b/Api/resources/auth/userpass_resource.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from flask_restx import fields, Resource -from Api.api import api, conn +from Api.core import api, conn from Access.is_auth import require_token, require_scope userpass_ns = api.namespace( diff --git a/Api/resources/compare/compare_secret_resource.py b/Api/resources/compare/compare_secret_resource.py index f0550c9..6885f3d 100644 --- a/Api/resources/compare/compare_secret_resource.py +++ b/Api/resources/compare/compare_secret_resource.py @@ -2,7 +2,7 @@ from flask import g from flask_restx import Resource, inputs -from Api.api import api, conn +from Api.core import api, conn from Api.resources.helpers import resolve_project_config from Api.resources.secrets.references import ( SecretReferenceError, @@ -80,6 +80,187 @@ def _build_compare_reference_resolver( ) +def _parse_compare_args(): + args = compare_secret_parser.parse_args() + limit_configs = args["limit_configs"] + if limit_configs < 1: + api.abort(400, "limit_configs must be >= 1") + if limit_configs > 500: + api.abort(400, "limit_configs must be <= 500") + return args, limit_configs + + +def _authorized_configs_for_actor( + actor, project_id, all_configs, limit_configs +): + authorized = [] + for cfg in all_configs: + if authorize( + actor, + "secrets:export", + project_id=project_id, + config_id=cfg.get("_id"), + ): + authorized.append(cfg) + if len(authorized) >= limit_configs: + break + return authorized + + +def _load_exported_config(config_id, include_parent, exported_cache): + if config_id not in exported_cache: + exported, _, export_msg, export_code = conn.secrets_v2.export_config( + config_id, + include_parent=include_parent, + include_metadata=False, + ) + exported_cache[config_id] = (exported, export_msg, export_code) + return exported_cache[config_id] + + +def _row_value_issues(effective): + value = effective.get("value") + if value is None: + return ( + value, + [ + build_issue( + ISSUE_MISSING_EFFECTIVE_VALUE, + "Key is missing in this config and its inheritance chain.", + ) + ], + True, + ) + if not isinstance(value, str) or "${" not in value: + return value, [], True + return value, [], False + + +def _collect_validation_issues(resolver, key, value): + issues = [] + seen_codes = set() + for error_message in resolver.validate_value_references( + key=key, value=value + ): + code_for_error = classify_reference_error(error_message) + if code_for_error in seen_codes: + continue + seen_codes.add(code_for_error) + issues.append(build_issue(code_for_error, error_message)) + return issues, seen_codes + + +def _resolve_value_if_allowed( + *, + resolve_references, + issues, + resolver, + exported, + effective, + key, + seen_codes, +): + if not resolve_references or has_broken_reference(issues): + return + try: + resolved = resolver.resolve_map(exported) + except SecretReferenceError as exc: + code_for_error = classify_reference_error(exc.message) + if code_for_error not in seen_codes: + issues.append(build_issue(code_for_error, exc.message)) + else: + effective["value"] = resolved.get(key) + + +def _annotate_row_issues( + row, + *, + actor, + project_slug, + key, + args, + resolve_references, + config_id_by_slug, + exported_cache, +): + effective = row.get("effective", {}) + value, issues, done = _row_value_issues(effective) + if done: + return issues + + config_id = config_id_by_slug.get(row.get("configSlug")) + if config_id is None: + issues.append( + build_issue( + ISSUE_BROKEN_REFERENCE_UNRESOLVED, + "Unable to validate references for this config.", + ) + ) + return issues + + exported, export_msg, export_code = _load_exported_config( + config_id, + include_parent=args["include_parent"], + exported_cache=exported_cache, + ) + if export_code >= 400 or exported is None: + issues.append( + build_issue( + ISSUE_BROKEN_REFERENCE_UNRESOLVED, + f"Unable to evaluate references: {export_msg}", + ) + ) + return issues + + resolver = _build_compare_reference_resolver( + actor=actor, + project_slug=project_slug, + config_slug=row.get("configSlug"), + max_depth=args["placeholder_max_depth"], + root_data=exported, + ) + validation_issues, seen_codes = _collect_validation_issues( + resolver, key, value + ) + issues.extend(validation_issues) + _resolve_value_if_allowed( + resolve_references=resolve_references, + issues=issues, + resolver=resolver, + exported=exported, + effective=effective, + key=key, + seen_codes=seen_codes, + ) + return issues + + +def _annotate_rows( + rows, + *, + actor, + project_slug, + key, + args, + resolve_references, + config_id_by_slug, +): + exported_cache = {} + for row in rows: + issues = _annotate_row_issues( + row, + actor=actor, + project_slug=project_slug, + key=key, + args=args, + resolve_references=resolve_references, + config_id_by_slug=config_id_by_slug, + exported_cache=exported_cache, + ) + row["issues"] = issues + row["hasIssues"] = len(issues) > 0 + + @compare_ns.route("/secrets/") class CompareSecretResource(Resource): @api.doc(security=["Bearer", "Token"], parser=compare_secret_parser) @@ -88,26 +269,16 @@ def get(self, project_slug, key): if not is_valid_env_key(key): api.abort(400, "Invalid secret key") - args = compare_secret_parser.parse_args() - limit_configs = args["limit_configs"] - if limit_configs < 1: - api.abort(400, "limit_configs must be >= 1") - if limit_configs > 500: - api.abort(400, "limit_configs must be <= 500") - + args, limit_configs = _parse_compare_args() project, _ = resolve_project_config(project_slug) all_configs = conn.configs.list_raw(project["_id"]) actor = g.actor - authorized_configs = [ - cfg - for cfg in all_configs - if authorize( - actor, - "secrets:export", - project_id=project["_id"], - config_id=cfg.get("_id"), - ) - ][:limit_configs] + authorized_configs = _authorized_configs_for_actor( + actor, + project_id=project["_id"], + all_configs=all_configs, + limit_configs=limit_configs, + ) rows, msg, code = conn.secrets_v2.compare_key_across_configs( authorized_configs, @@ -127,93 +298,15 @@ def get(self, project_slug, key): for cfg in authorized_configs if "slug" in cfg and "_id" in cfg } - exported_cache = {} - for row in rows: - effective = row.get("effective", {}) - value = effective.get("value") - issues = [] - - if value is None: - issues.append( - build_issue( - ISSUE_MISSING_EFFECTIVE_VALUE, - "Key is missing in this config and its inheritance " - "chain.", - ) - ) - row["issues"] = issues - row["hasIssues"] = True - continue - - if not isinstance(value, str) or "${" not in value: - row["issues"] = issues - row["hasIssues"] = False - continue - - config_slug = row.get("configSlug") - config_id = config_id_by_slug.get(config_slug) - if config_id is None: - issues.append( - build_issue( - ISSUE_BROKEN_REFERENCE_UNRESOLVED, - "Unable to validate references for this config.", - ) - ) - row["issues"] = issues - row["hasIssues"] = True - continue - - if config_id not in exported_cache: - exported, _, export_msg, export_code = ( - conn.secrets_v2.export_config( - config_id, - include_parent=args["include_parent"], - include_metadata=False, - ) - ) - exported_cache[config_id] = (exported, export_msg, export_code) - exported, export_msg, export_code = exported_cache[config_id] - if export_code >= 400 or exported is None: - issues.append( - build_issue( - ISSUE_BROKEN_REFERENCE_UNRESOLVED, - f"Unable to evaluate references: {export_msg}", - ) - ) - row["issues"] = issues - row["hasIssues"] = True - continue - - resolver = _build_compare_reference_resolver( - actor=actor, - project_slug=project_slug, - config_slug=config_slug, - max_depth=args["placeholder_max_depth"], - root_data=exported, - ) - validation_errors = resolver.validate_value_references( - key=key, value=value - ) - seen_codes = set() - for error_message in validation_errors: - code_for_error = classify_reference_error(error_message) - if code_for_error in seen_codes: - continue - seen_codes.add(code_for_error) - issues.append(build_issue(code_for_error, error_message)) - - if resolve_references and not has_broken_reference(issues): - try: - resolved = resolver.resolve_map(exported) - except SecretReferenceError as exc: - code_for_error = classify_reference_error(exc.message) - if code_for_error not in seen_codes: - issues.append(build_issue(code_for_error, exc.message)) - else: - effective["value"] = resolved.get(key) - - row["issues"] = issues - row["hasIssues"] = len(issues) > 0 + _annotate_rows( + rows, + actor=actor, + project_slug=project_slug, + key=key, + args=args, + resolve_references=resolve_references, + config_id_by_slug=config_id_by_slug, + ) response_configs = [] unique_effective_values = set() diff --git a/Api/resources/configs/configs_resource.py b/Api/resources/configs/configs_resource.py index 12e887a..7b93ab4 100644 --- a/Api/resources/configs/configs_resource.py +++ b/Api/resources/configs/configs_resource.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from flask_restx import Resource -from Api.api import api, conn +from Api.core import api, conn from Api.resources.helpers import resolve_project_config from Access.is_auth import with_token, require_scope diff --git a/Api/resources/helpers.py b/Api/resources/helpers.py index 37198bf..e11f52e 100644 --- a/Api/resources/helpers.py +++ b/Api/resources/helpers.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -from Api.api import api, conn +from Api.core import api, conn def resolve_project_config(project_slug, config_slug=None): diff --git a/Api/resources/me.py b/Api/resources/me.py index d8929fe..faa46bf 100644 --- a/Api/resources/me.py +++ b/Api/resources/me.py @@ -2,7 +2,7 @@ from flask import g, request from flask_restx import Resource -from Api.api import api, conn +from Api.core import api, conn from Access.is_auth import with_token me_ns = api.namespace("me", description="Current authenticated user") diff --git a/Api/resources/meta/version_resource.py b/Api/resources/meta/version_resource.py index 21f67f5..0b91f7e 100644 --- a/Api/resources/meta/version_resource.py +++ b/Api/resources/meta/version_resource.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 from flask_restx import Resource -from Api.api import api +from Api.core import api from Api.versioning import get_application_version meta_ns = api.namespace("version", description="Application version") diff --git a/Api/resources/projects/projects_resource.py b/Api/resources/projects/projects_resource.py index f471b62..b54234b 100644 --- a/Api/resources/projects/projects_resource.py +++ b/Api/resources/projects/projects_resource.py @@ -2,7 +2,7 @@ from flask_restx import Resource, fields from flask import g -from Api.api import api, conn +from Api.core import api, conn from Access.is_auth import with_token, require_scope from Access.policy import authorize diff --git a/Api/resources/secrets/kv_resource.py b/Api/resources/secrets/kv_resource.py index 5625240..c3ccce8 100644 --- a/Api/resources/secrets/kv_resource.py +++ b/Api/resources/secrets/kv_resource.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # Key-Value (KV) Secrets Engines API Resource from flask_restx import fields, Resource -from Api.api import api, conn +from Api.core import api, conn from Access.is_auth import require_token # KV Namespace diff --git a/Api/resources/secrets/secrets_resource.py b/Api/resources/secrets/secrets_resource.py index 9319757..4b8b524 100644 --- a/Api/resources/secrets/secrets_resource.py +++ b/Api/resources/secrets/secrets_resource.py @@ -4,7 +4,7 @@ from flask import Response, g, request from flask_restx import Resource, inputs -from Api.api import api, conn +from Api.core import api, conn from Api.resources.helpers import resolve_project_config from Api.resources.secrets.references import ( SecretReferenceError, diff --git a/Api/resources/workspace/workspace_resource.py b/Api/resources/workspace/workspace_resource.py index de27c37..04e63e4 100644 --- a/Api/resources/workspace/workspace_resource.py +++ b/Api/resources/workspace/workspace_resource.py @@ -2,7 +2,7 @@ from flask import request from flask_restx import Resource -from Api.api import api, conn +from Api.core import api, conn from Api.serialization import oid_to_str, to_iso from Access.is_auth import with_token, require_scope @@ -149,6 +149,48 @@ def _resolve_project(project_slug): return project +def _parse_member_payload(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + api.abort(400, "Invalid JSON payload") + return payload + + +def _update_member_profile(username, payload): + if "email" not in payload and "fullName" not in payload: + return + _, msg, code = conn.users.update_profile( + username, + email=payload.get("email") if "email" in payload else None, + full_name=payload.get("fullName") if "fullName" in payload else None, + ) + if code >= 400: + api.abort(code, msg) + + +def _update_member_disabled(username, payload): + if "disabled" not in payload: + return + disabled = payload.get("disabled") + if not isinstance(disabled, bool): + api.abort(400, "disabled must be boolean") + _, msg, code = conn.users.set_disabled(username, disabled) + if code >= 400: + api.abort(code, msg) + + +def _update_member_workspace_role(workspace_id, username, payload): + if "workspaceRole" not in payload: + return + _, msg, code = conn.memberships.upsert_workspace_membership( + workspace_id, + username, + payload.get("workspaceRole"), + ) + if code >= 400: + api.abort(code, msg) + + @workspace_ns.route("/settings") class WorkspaceSettingsResource(Resource): @api.doc(security=["Bearer", "Token"]) @@ -268,40 +310,10 @@ def patch(self, username): workspace_id, username, "viewer" ) - payload = request.get_json(silent=True) - if not isinstance(payload, dict): - api.abort(400, "Invalid JSON payload") - - if "email" in payload or "fullName" in payload: - _, msg, code = conn.users.update_profile( - username, - email=payload.get("email") if "email" in payload else None, - full_name=payload.get("fullName") - if "fullName" in payload - else None, - ) - if code >= 400: - api.abort(code, msg) - - if "disabled" in payload: - if not isinstance(payload.get("disabled"), bool): - api.abort(400, "disabled must be boolean") - _, msg, code = conn.users.set_disabled( - username, payload.get("disabled") - ) - if code >= 400: - api.abort(code, msg) - - if "workspaceRole" in payload: - membership, msg, code = ( - conn.memberships.upsert_workspace_membership( - workspace_id, - username, - payload.get("workspaceRole"), - ) - ) - if code >= 400: - api.abort(code, msg) + payload = _parse_member_payload() + _update_member_profile(username, payload) + _update_member_disabled(username, payload) + _update_member_workspace_role(workspace_id, username, payload) user = conn.users.get(username) membership = conn.memberships.get_workspace_membership( diff --git a/Dockerfile b/Dockerfile index 6e7d868..b8c3933 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,8 +27,12 @@ LABEL org.opencontainers.image.source="https://github.com/bearlike/simple-secret LABEL org.opencontainers.image.description="Simple Secrets Manager unified image with backend API and admin console." ARG DEBIAN_FRONTEND=noninteractive +ARG NGINX_VERSION=1.22.* +ARG SUPERVISOR_VERSION=4.2.* RUN apt-get update \ - && apt-get install -y --no-install-recommends nginx supervisor \ + && apt-get install -y --no-install-recommends \ + nginx=${NGINX_VERSION} \ + supervisor=${SUPERVISOR_VERSION} \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/pyproject.toml b/pyproject.toml index cb9bbd2..d2e9b2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ target-version = "py39" exclude = [".venv"] [tool.ruff.lint] -select = ["E", "F"] +select = ["E", "F", "C901", "SIM102", "PERF403"] [tool.mypy] python_version = "3.9" diff --git a/scripts/quality.sh b/scripts/quality.sh index 174aff6..74062fb 100755 --- a/scripts/quality.sh +++ b/scripts/quality.sh @@ -20,6 +20,10 @@ else exit 2 fi +echo "Running targeted Pylint anti-pattern checks..." +uvx pylint --disable=all --enable=R1711,R0401,W0613,W0125,W0621 \ + Access Api Engines ssm_cli tests connection.py server.py + echo "Running MyPy..." uv run mypy . diff --git a/ssm_cli/api.py b/ssm_cli/api.py index c730567..74606f2 100644 --- a/ssm_cli/api.py +++ b/ssm_cli/api.py @@ -165,11 +165,11 @@ def export_secrets_json( "Secrets response is invalid", status_code=1, body=payload ) - parsed: dict[str, str] = {} - for key, value in data.items(): - if isinstance(key, str) and isinstance(value, str): - parsed[key] = value - return parsed + return { + key: value + for key, value in data.items() + if isinstance(key, str) and isinstance(value, str) + } def list_projects(self) -> list[dict[str, Any]]: payload = self.request("GET", "/projects", accept="application/json") diff --git a/ssm_cli/cache.py b/ssm_cli/cache.py index 84b2f15..0ef2cd8 100644 --- a/ssm_cli/cache.py +++ b/ssm_cli/cache.py @@ -53,8 +53,8 @@ def load_secret_cache( if age > max_age_seconds: return None - parsed: dict[str, str] = {} - for key, value in data.items(): - if isinstance(key, str) and isinstance(value, str): - parsed[key] = value - return parsed + return { + key: value + for key, value in data.items() + if isinstance(key, str) and isinstance(value, str) + } diff --git a/ssm_cli/config.py b/ssm_cli/config.py index 2e88137..4761e45 100644 --- a/ssm_cli/config.py +++ b/ssm_cli/config.py @@ -157,11 +157,11 @@ def load_credentials() -> dict[str, str]: tokens = raw.get("tokens") if not isinstance(tokens, dict): return {} - parsed: dict[str, str] = {} - for key, value in tokens.items(): - if isinstance(key, str) and isinstance(value, str): - parsed[key] = value - return parsed + return { + key: value + for key, value in tokens.items() + if isinstance(key, str) and isinstance(value, str) + } def save_credentials(tokens: dict[str, str]) -> None: diff --git a/ssm_cli/main.py b/ssm_cli/main.py index 26c7f38..e72bfd4 100644 --- a/ssm_cli/main.py +++ b/ssm_cli/main.py @@ -366,11 +366,11 @@ def run( @cli.group(help="Secret export and mount commands") -def secrets() -> None: +def secrets_cmd() -> None: pass -@secrets.command("download", help="Download secrets to stdout") +@secrets_cmd.command("download", help="Download secrets to stdout") @click.option( "--format", "output_format", @@ -439,7 +439,7 @@ def secrets_download( console.print(render_env_lines(secrets_data), soft_wrap=True) -@secrets.command("mount", help="Write secrets to a named pipe (FIFO)") +@secrets_cmd.command("mount", help="Write secrets to a named pipe (FIFO)") @click.option( "--path", "fifo_path", @@ -1058,11 +1058,11 @@ def workspace_project_member_remove( @cli.group(help="Manage CLI profiles") -def profile() -> None: +def profile_cmd() -> None: pass -@profile.command("list", help="List profiles") +@profile_cmd.command("list", help="List profiles") @_handle_errors def profile_list() -> None: cfg = load_global_config() @@ -1089,7 +1089,7 @@ def profile_list() -> None: console.print(table) -@profile.command("use", help="Set active profile") +@profile_cmd.command("use", help="Set active profile") @click.argument("name") @_handle_errors def profile_use(name: str) -> None: @@ -1104,7 +1104,7 @@ def profile_use(name: str) -> None: console.print(f"Active profile set to [bold]{profile_name}[/bold]") -@profile.command("set", help="Set profile fields") +@profile_cmd.command("set", help="Set profile fields") @click.argument("name") @click.option("--base-url", default=None, help="Base URL") @click.option("--project", default=None, help="Default project") diff --git a/tests/test_audit_pagination.py b/tests/test_audit_pagination.py index 5a39338..b165389 100644 --- a/tests/test_audit_pagination.py +++ b/tests/test_audit_pagination.py @@ -28,7 +28,7 @@ class FakeCollection: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def _match(self, doc, query): @@ -43,16 +43,16 @@ def _match(self, doc, query): continue current = doc.get(key) - if isinstance(value, dict): - if "$gte" in value: - if current is None or current < value["$gte"]: - return False - continue + if isinstance(value, dict) and "$gte" in value: + if current is None or current < value["$gte"]: + return False + continue if current != value: return False return True def find(self, query, projection=None): + _ = projection return FakeCursor( [doc for doc in self.docs if self._match(doc, query)] ) diff --git a/tests/test_cli_api.py b/tests/test_cli_api.py index 98a4869..224dd83 100644 --- a/tests/test_cli_api.py +++ b/tests/test_cli_api.py @@ -81,7 +81,7 @@ def fake_request(**kwargs): def test_request_raises_api_error_on_http_failure(monkeypatch): client = ApiClient("http://localhost:8080", token="t") - def fake_request(**kwargs): + def fake_request(**_kwargs): return _response(401, {"message": "Not Authorized"}) monkeypatch.setattr(client.session, "request", fake_request) diff --git a/tests/test_compare_secrets.py b/tests/test_compare_secrets.py index f0f0b91..adb3233 100644 --- a/tests/test_compare_secrets.py +++ b/tests/test_compare_secrets.py @@ -7,7 +7,7 @@ class FakeSecrets: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def find(self, query): diff --git a/tests/test_configs_list.py b/tests/test_configs_list.py index 193c545..d6ef691 100644 --- a/tests/test_configs_list.py +++ b/tests/test_configs_list.py @@ -26,10 +26,11 @@ class FakeCollection: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def find(self, query, projection): + _ = projection project_id = query.get("project_id") docs = [ doc for doc in self.docs if doc.get("project_id") == project_id @@ -37,6 +38,7 @@ def find(self, query, projection): return FakeCursor(list(docs)) def find_one(self, query, projection=None): + _ = projection for doc in self.docs: if query.get("_id") == doc.get("_id") and query.get( "project_id" diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index 7499c18..4d42fd4 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -5,13 +5,14 @@ class FakeSecrets: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def find(self, query): return [d for d in self.docs if d["config_id"] == query["config_id"]] def update_one(self, query, update, upsert=False): + _ = upsert for doc in self.docs: if doc.get("config_id") == query.get("config_id") and doc.get( "key" diff --git a/tests/test_memberships.py b/tests/test_memberships.py index 77185c9..e8f7800 100644 --- a/tests/test_memberships.py +++ b/tests/test_memberships.py @@ -7,7 +7,7 @@ class FakeCollection: def __init__(self): self.last_query = None - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def find(self, query): diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 6dc9f90..b7b84d3 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -6,7 +6,7 @@ class FakeStateCollection: def __init__(self): self.docs = {} - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def find_one(self, query): @@ -20,7 +20,6 @@ def insert_one(self, doc): if doc_id in self.docs: raise DuplicateKeyError("duplicate key") self.docs[doc_id] = dict(doc) - return None def update_one(self, query, update): doc_id = query.get("_id") @@ -31,7 +30,6 @@ def update_one(self, query, update): target[key] = value for key in update.get("$unset", {}).keys(): target.pop(key, None) - return None class FakeUserPass: @@ -61,6 +59,7 @@ def create_token( subject_service_name=None, expires_at=None, ): + _ = expires_at self.calls += 1 username = subject_user or created_by or "user" return { diff --git a/tests/test_rbac.py b/tests/test_rbac.py index b864fd2..6f1d7d8 100644 --- a/tests/test_rbac.py +++ b/tests/test_rbac.py @@ -107,6 +107,7 @@ def __init__(self, docs): self.docs = docs def list_docs(self, workspace_id=None): + _ = workspace_id return list(self.docs) diff --git a/tests/test_secret_references.py b/tests/test_secret_references.py index 58b7983..45222f2 100644 --- a/tests/test_secret_references.py +++ b/tests/test_secret_references.py @@ -42,7 +42,6 @@ def export_config(self, config_id): def require_scope(self, action: str, project_id, config_id): self.scope_checks.append((action, str(project_id), str(config_id))) - return None def test_resolves_same_config_cross_config_and_cross_project_references(): diff --git a/tests/test_secrets_v2_icons.py b/tests/test_secrets_v2_icons.py index e4e1183..def09bf 100644 --- a/tests/test_secrets_v2_icons.py +++ b/tests/test_secrets_v2_icons.py @@ -6,7 +6,7 @@ class FakeSecrets: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def _match(self, doc, query): @@ -61,7 +61,6 @@ def update_many(self, query, update): continue for key, value in update.get("$set", {}).items(): doc[key] = value - return None class FakeConfigs: diff --git a/tests/test_token_expiry.py b/tests/test_token_expiry.py index 596ba65..7325bb6 100644 --- a/tests/test_token_expiry.py +++ b/tests/test_token_expiry.py @@ -7,7 +7,7 @@ class FakeCollection: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None def find_one(self, query): @@ -17,7 +17,7 @@ def find_one(self, query): return None def update_one(self, query, update): - return None + _ = (query, update) def test_expired_token_is_rejected(): diff --git a/tests/test_tokens_metadata.py b/tests/test_tokens_metadata.py index 87ac485..5fa7ee6 100644 --- a/tests/test_tokens_metadata.py +++ b/tests/test_tokens_metadata.py @@ -23,35 +23,34 @@ class FakeCollection: def __init__(self, docs): self.docs = docs - def create_index(self, *args, **kwargs): + def create_index(self, *_args, **_kwargs): return None + @staticmethod + def _is_gt(current, target): + if current is None: + return False + try: + return current > target + except TypeError: + return current.timestamp() > target.timestamp() + + def _match_value(self, doc, key, value): + current = doc.get(key) + if isinstance(value, dict): + if "$gt" in value: + return self._is_gt(current, value["$gt"]) + if "$exists" in value: + return (key in doc) == bool(value["$exists"]) + return current == value + def _match(self, doc, query): for key, value in query.items(): if key == "$or": if not any(self._match(doc, clause) for clause in value): return False continue - - current = doc.get(key) - if isinstance(value, dict): - if "$gt" in value: - if current is None: - return False - try: - if current <= value["$gt"]: - return False - except TypeError: - if current.timestamp() <= value["$gt"].timestamp(): - return False - continue - if "$exists" in value: - exists = key in doc - if exists != bool(value["$exists"]): - return False - continue - - if current != value: + if not self._match_value(doc, key, value): return False return True @@ -70,17 +69,14 @@ def update_one(self, query, update): target = self.find_one(query) if target and "$set" in update: target.update(update["$set"]) - return None def update_many(self, query, update): for doc in self.docs: if self._match(doc, query) and "$set" in update: doc.update(update["$set"]) - return None def insert_one(self, doc): self.docs.append(doc) - return None def test_list_tokens_serializes_metadata():