diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d6e4939..aee2278 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -31,6 +31,11 @@ updates: default-days: 7 semver-major-days: 14 groups: + # Listed first, since a dependency joins the first group it matches. A new + # lint rule or type-check error in one of these must not hold up the + # runtime floor bumps grouped below, so they get a PR of their own. + lint-tools: + patterns: ["ruff", "mypy", "typos"] minor-and-patch: update-types: ["minor", "patch"] ignore: @@ -49,6 +54,31 @@ updates: labels: - "dependencies" + # pre-commit hook revisions in .pre-commit-config.yaml. Dependabot follows the + # `# frozen: vX` comment on SHA-pinned revs and rewrites the SHA and the + # comment together. `repo: local` hooks (ruff, mypy, typos) are skipped: + # their versions come from uv.lock, which the "uv" entry above maintains. + # Only `default-days` cooldown is supported for this ecosystem. + - package-ecosystem: "pre-commit" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + cooldown: + default-days: 7 + groups: + minor-and-patch: + update-types: ["minor", "patch"] + ignore: + # The uv-lock hook's version must equal [tool.uv] required-version in + # pyproject.toml; uv is upgraded by hand, all three places at once. + - dependency-name: "*astral-sh/uv-pre-commit" + commit-message: + prefix: "deps" + labels: + - "dependencies" + # GitHub Actions versions. # Note: cooldown.semver-major-days is not supported for github-actions -- # Dependabot only honours it on semver-strict ecosystems like uv and npm. diff --git a/.github/scripts/format_audit.py b/.github/scripts/format_audit.py old mode 100644 new mode 100755 index 947a179..1902703 --- a/.github/scripts/format_audit.py +++ b/.github/scripts/format_audit.py @@ -26,7 +26,7 @@ import json import sys from pathlib import Path -from typing import Any, Optional +from typing import Any MARKER = "" @@ -49,11 +49,17 @@ # the string render as markdown/HTML in the comment and the job summary. FENCE = "~~~~~~" +# GitHub truncates annotation text; cut it ourselves so the ellipsis is visible. +_ANNOTATION_MAX_CHARS = 200 +# The Slack message is two header lines, then one line per package. +_SLACK_HEADER_LINES = 2 +_SLACK_MAX_PACKAGES = 10 + class Finding: """One vulnerability, normalized across scanners.""" - def __init__( + def __init__( # noqa: PLR0917 - one field per scanner column; built positionally self, vuln_id: str, package: str, @@ -63,7 +69,7 @@ def __init__( title: str, url: str, source: str, - ): + ) -> None: self.id = vuln_id self.package = package self.installed = installed @@ -75,6 +81,7 @@ def __init__( @property def key(self) -> tuple[str, str]: + """Identity used to merge the same advisory reported by several scanners.""" return (self.package, self.id) @property @@ -99,7 +106,7 @@ def _md_cell(text: str) -> str: return _truncate(text, 140).replace("|", "\\|").replace("`", "'") -def _load(path: Optional[str], label: str) -> tuple[Optional[Any], Optional[str]]: +def _load(path: str | None, label: str) -> tuple[Any | None, str | None]: """Return (parsed, error). Never raises -- a bad report must not kill the run.""" if not path: return None, None @@ -115,7 +122,7 @@ def _load(path: Optional[str], label: str) -> tuple[Optional[Any], Optional[str] return None, f"{label}: {path} is not valid JSON: {exc}" -def trivy_scanned_nothing(doc: Any) -> bool: +def trivy_scanned_nothing(doc: object) -> bool: """True when Trivy produced no package Result at all. Trivy writes {"Results": null} and exits 0 when it recognises no package @@ -132,7 +139,8 @@ def trivy_scanned_nothing(doc: Any) -> bool: return not any(isinstance(r, dict) and r.get("Target") for r in results) -def parse_trivy(doc: Any, source: str = "trivy") -> list[Finding]: +def parse_trivy(doc: object, source: str = "trivy") -> list[Finding]: + """Extract the findings of a Trivy JSON report; malformed entries are skipped.""" findings: list[Finding] = [] if not isinstance(doc, dict): return findings @@ -158,7 +166,7 @@ def parse_trivy(doc: Any, source: str = "trivy") -> list[Finding]: return findings -def parse_pip_audit(doc: Any) -> list[Finding]: +def parse_pip_audit(doc: object) -> list[Finding]: """pip-audit carries no severity at all, so everything lands in UNKNOWN. That is why pip-audit is advisory-only here and never gates the build: it @@ -179,7 +187,9 @@ def parse_pip_audit(doc: Any) -> list[Finding]: if not isinstance(vuln, dict): continue fixes = vuln.get("fix_versions") or [] - fixed = ", ".join(str(f) for f in fixes) if isinstance(fixes, list) and fixes else NO_FIX + fixed = ( + ", ".join(str(f) for f in fixes) if isinstance(fixes, list) and fixes else NO_FIX + ) aliases = vuln.get("aliases") or [] alias_str = "" if isinstance(aliases, list) and aliases: @@ -232,17 +242,20 @@ def _annotation_escape(text: str) -> str: later replacements introduce. """ text = str(text) - text = text if len(text) <= 200 else text[:199] + "…" + text = text if len(text) <= _ANNOTATION_MAX_CHARS else text[: _ANNOTATION_MAX_CHARS - 1] + "…" return text.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") def render_annotations(findings: list[Finding]) -> str: + """Render one GitHub `::error` workflow command per blocking finding.""" lines = [] for finding in findings: if not finding.blocking: continue title = _annotation_escape(f"{finding.severity}: {finding.id} in {finding.package}") - body = _annotation_escape(f"{finding.package} {finding.installed} -- fixed in {finding.fixed}. {finding.title}") + body = _annotation_escape( + f"{finding.package} {finding.installed} -- fixed in {finding.fixed}. {finding.title}" + ) lines.append(f"::error title={title}::{body}") return "\n".join(lines) @@ -299,7 +312,9 @@ def render_slack(findings: list[Finding], errors: list[str], run_url: str, repo: # Highest fix target across the group -- upgrading to anything lower # would leave part of the group unresolved. targets = sorted({f.fixed for f in group if f.fixed != NO_FIX}) - target = f" — upgrade to `{_slack_escape(targets[-1])}`" if targets else " — no fix available" + target = ( + f" — upgrade to `{_slack_escape(targets[-1])}`" if targets else " — no fix available" + ) installed = _slack_escape(worst.installed) lines.append( f">• `{_slack_escape(package)}` {installed} — " @@ -308,21 +323,31 @@ def render_slack(findings: list[Finding], errors: list[str], run_url: str, repo: ) # Slack truncates long messages; keep it to something a human will read. - if len(lines) > 12: - lines = lines[:12] + [f">…and {len(by_package) - 10} more packages."] + if len(lines) > _SLACK_HEADER_LINES + _SLACK_MAX_PACKAGES: + lines = [ + *lines[: _SLACK_HEADER_LINES + _SLACK_MAX_PACKAGES], + f">…and {len(by_package) - _SLACK_MAX_PACKAGES} more packages.", + ] lines.append(f">{link}") return "\n".join(lines) -def render( +def _advisory_link(finding: Finding) -> str: + if finding.url.startswith("http"): + return f"[{_md_cell(finding.id)}]({finding.url})" + return _md_cell(finding.id) + + +def render( # noqa: C901, PLR0915 - one linear pass appending each report section findings: list[Finding], errors: list[str], context: str, *, blocking: bool, - warnings: Optional[list[str]] = None, + warnings: list[str] | None = None, ) -> str: + """Render the markdown PR comment body.""" out: list[str] = [MARKER, "", "## Dependency Security Audit", ""] if context: @@ -370,7 +395,10 @@ def render( out.append(f":x: **{len(blockers)} fixable HIGH/CRITICAL {noun}** -- {verb}.") if unfixable: out.append("") - out.append(f":warning: A further **{unfixable}** HIGH/CRITICAL have no fix available yet and do not block.") + out.append( + f":warning: A further **{unfixable}** HIGH/CRITICAL have no fix available yet " + "and do not block." + ) elif severe: # Do not say "none at HIGH or CRITICAL" here: there are some, they # just cannot be fixed by bumping a bound. Saying otherwise would @@ -381,33 +409,39 @@ def render( "but they are real exposure and need a decision." ) else: - out.append(":warning: Advisories found, but none at HIGH or CRITICAL. This does not block the build.") + out.append( + ":warning: Advisories found, but none at HIGH or CRITICAL. " + "This does not block the build." + ) out.append("") out.append("| Severity | Count |") out.append("| --- | --- |") - for severity in SEVERITY_ORDER: - if counts.get(severity): - out.append(f"| {SEVERITY_EMOJI[severity]} {severity} | {counts[severity]} |") + out.extend( + f"| {SEVERITY_EMOJI[severity]} {severity} | {counts[severity]} |" + for severity in SEVERITY_ORDER + if counts.get(severity) + ) out.append("") out.append("| Severity | Package | Installed | Fixed in | Advisory |") out.append("| --- | --- | --- | --- | --- |") - for finding in findings: - link = f"[{_md_cell(finding.id)}]({finding.url})" if finding.url.startswith("http") else _md_cell(finding.id) - out.append( - f"| {SEVERITY_EMOJI[finding.severity]} {finding.severity} " - f"| `{_md_cell(finding.package)}` " - f"| `{_md_cell(finding.installed)}` " - f"| `{_md_cell(finding.fixed)}` " - f"| {link} |" - ) + out.extend( + f"| {SEVERITY_EMOJI[finding.severity]} {finding.severity} " + f"| `{_md_cell(finding.package)}` " + f"| `{_md_cell(finding.installed)}` " + f"| `{_md_cell(finding.fixed)}` " + f"| {_advisory_link(finding)} |" + for finding in findings + ) out.append("") out.append("
Advisory details") out.append("") for finding in findings: - out.append(f"**{finding.severity} -- {finding.id}** (`{finding.package}` {finding.installed})") + out.append( + f"**{finding.severity} -- {finding.id}** (`{finding.package}` {finding.installed})" + ) out.append("") out.append(f"Found by: {', '.join(sorted(finding.sources))}") out.append("") @@ -439,7 +473,8 @@ def render( return "\n".join(out) + "\n" -def main() -> int: +def main() -> int: # noqa: C901, PLR0912 - argument handling, then one pass per output mode + """Parse the scanner reports and print the requested output; return the exit status.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "trivy_json", @@ -468,8 +503,12 @@ def main() -> int: action="store_true", help="emit a single-line Slack message body carrying the findings", ) - parser.add_argument("--run-url", default="", help="workflow run URL to link from the Slack message") - parser.add_argument("--repo", default="permit-python", help="repository name for the Slack message") + parser.add_argument( + "--run-url", default="", help="workflow run URL to link from the Slack message" + ) + parser.add_argument( + "--repo", default="permit-python", help="repository name for the Slack message" + ) parser.add_argument( "--gate", action="store_true", @@ -523,7 +562,8 @@ def main() -> int: blockers = [f for f in findings if f.blocking] for finding in blockers: print( - f"{finding.severity} {finding.id} {finding.package} " f"{finding.installed} -> {finding.fixed}", + f"{finding.severity} {finding.id} {finding.package} " + f"{finding.installed} -> {finding.fixed}", file=sys.stderr, ) if errors: @@ -537,7 +577,9 @@ def main() -> int: print(rendered) return 0 - sys.stdout.write(render(findings, errors, args.context, blocking=args.blocking, warnings=warnings)) + sys.stdout.write( + render(findings, errors, args.context, blocking=args.blocking, warnings=warnings) + ) return 0 diff --git a/.github/scripts/test_format_audit.py b/.github/scripts/test_format_audit.py index edc76b6..76ea8d6 100644 --- a/.github/scripts/test_format_audit.py +++ b/.github/scripts/test_format_audit.py @@ -13,6 +13,7 @@ import subprocess import sys from pathlib import Path +from typing import Any import pytest @@ -20,7 +21,7 @@ sys.path.insert(0, str(Path(__file__).parent)) -from format_audit import ( # noqa: E402 +from format_audit import ( # noqa: E402 - importable only once sys.path has its directory MARKER, Finding, merge, @@ -34,7 +35,7 @@ def run(*args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( + return subprocess.run( # noqa: S603 - runs the script under test with this interpreter [sys.executable, str(SCRIPT), *args], capture_output=True, text=True, @@ -42,14 +43,14 @@ def run(*args: str) -> subprocess.CompletedProcess[str]: ) -def trivy_report(*vulns: dict) -> dict: +def trivy_report(*vulns: dict[str, Any]) -> dict[str, Any]: return { "SchemaVersion": 2, "Results": [{"Target": "requirements.txt", "Type": "pip", "Vulnerabilities": list(vulns)}], } -def clean_report() -> dict: +def clean_report() -> dict[str, Any]: """What Trivy really writes for a scanned file with no advisories. Verified against actual output: a clean scan still carries a Target and a @@ -70,7 +71,7 @@ def clean_report() -> dict: } -def vuln(**kwargs) -> dict: +def vuln(**kwargs: Any) -> dict[str, Any]: base = { "VulnerabilityID": "CVE-2026-69244", "PkgName": "aiohttp", @@ -87,12 +88,12 @@ def vuln(**kwargs) -> dict: # --- CLI contract ----------------------------------------------------------- -def test_missing_argument_exits_2(): +def test_missing_argument_exits_2() -> None: result = run() assert result.returncode == 2 -def test_garbage_input_still_exits_0_with_marker(tmp_path: Path): +def test_garbage_input_still_exits_0_with_marker(tmp_path: Path) -> None: bad = tmp_path / "trivy.json" bad.write_bytes(b"\x00\x01not json at all{{{") result = run(str(bad)) @@ -102,7 +103,7 @@ def test_garbage_input_still_exits_0_with_marker(tmp_path: Path): assert "No known vulnerabilities found" not in result.stdout -def test_empty_file_exits_0_and_does_not_claim_clean(tmp_path: Path): +def test_empty_file_exits_0_and_does_not_claim_clean(tmp_path: Path) -> None: empty = tmp_path / "trivy.json" empty.write_text("") result = run(str(empty)) @@ -111,13 +112,13 @@ def test_empty_file_exits_0_and_does_not_claim_clean(tmp_path: Path): assert "No known vulnerabilities found" not in result.stdout -def test_missing_file_exits_0(tmp_path: Path): +def test_missing_file_exits_0(tmp_path: Path) -> None: result = run(str(tmp_path / "nope.json")) assert result.returncode == 0 assert result.stdout.split("\n")[0] == MARKER -def test_clean_report_reports_clean(tmp_path: Path): +def test_clean_report_reports_clean(tmp_path: Path) -> None: report = tmp_path / "trivy.json" report.write_text(json.dumps(clean_report())) result = run(str(report)) @@ -126,7 +127,7 @@ def test_clean_report_reports_clean(tmp_path: Path): assert "No known vulnerabilities found" in result.stdout -def test_vulnerable_report_lists_the_finding(tmp_path: Path): +def test_vulnerable_report_lists_the_finding(tmp_path: Path) -> None: report = tmp_path / "trivy.json" report.write_text(json.dumps(trivy_report(vuln()))) result = run(str(report)) @@ -142,7 +143,7 @@ def test_vulnerable_report_lists_the_finding(tmp_path: Path): @pytest.mark.parametrize( - "findings,errors", + ("findings", "errors"), [ ([], []), ([], ["trivy: boom"]), @@ -150,7 +151,7 @@ def test_vulnerable_report_lists_the_finding(tmp_path: Path): ([Finding("CVE-1", "pkg", "1.0", "LOW", "2.0", "t", "", "trivy")], ["trivy: boom"]), ], ) -def test_marker_is_first_line_in_every_state(findings, errors): +def test_marker_is_first_line_in_every_state(findings: list[Finding], errors: list[str]) -> None: out = render(findings, errors, "", blocking=True) assert out.split("\n")[0] == MARKER @@ -158,7 +159,7 @@ def test_marker_is_first_line_in_every_state(findings, errors): # --- parsing ---------------------------------------------------------------- -def test_labelled_trivy_reports_are_tagged_with_their_tree(tmp_path: Path): +def test_labelled_trivy_reports_are_tagged_with_their_tree(tmp_path: Path) -> None: ceiling = tmp_path / "ceiling.json" floor = tmp_path / "floor.json" ceiling.write_text(json.dumps(clean_report())) @@ -170,7 +171,7 @@ def test_labelled_trivy_reports_are_tagged_with_their_tree(tmp_path: Path): assert "CVE-2026-69244" in result.stdout -def test_one_bad_tree_does_not_lose_the_other(tmp_path: Path): +def test_one_bad_tree_does_not_lose_the_other(tmp_path: Path) -> None: good = tmp_path / "good.json" bad = tmp_path / "bad.json" good.write_text(json.dumps(trivy_report(vuln()))) @@ -181,7 +182,7 @@ def test_one_bad_tree_does_not_lose_the_other(tmp_path: Path): assert "could not be parsed" in result.stdout or "not valid JSON" in result.stdout -def test_parse_trivy_tolerates_missing_and_malformed_nodes(): +def test_parse_trivy_tolerates_missing_and_malformed_nodes() -> None: assert parse_trivy(None) == [] assert parse_trivy({"Results": None}) == [] assert parse_trivy({"Results": [{"Vulnerabilities": None}]}) == [] @@ -189,28 +190,36 @@ def test_parse_trivy_tolerates_missing_and_malformed_nodes(): assert parse_trivy({"Results": [{"Vulnerabilities": ["not a dict"]}]}) == [] -def test_parse_trivy_defaults_missing_fix_version(): +def test_parse_trivy_defaults_missing_fix_version() -> None: findings = parse_trivy(trivy_report(vuln(FixedVersion=""))) assert findings[0].fixed == "none available" -def test_pip_audit_is_passed_by_flag_not_position(tmp_path: Path): +def test_pip_audit_is_passed_by_flag_not_position(tmp_path: Path) -> None: trivy = tmp_path / "trivy.json" pa = tmp_path / "pa.json" trivy.write_text(json.dumps(clean_report())) - pa.write_text(json.dumps({"dependencies": [{"name": "x", "version": "1", "vulns": [{"id": "PYSEC-1"}]}]})) + pa.write_text( + json.dumps({"dependencies": [{"name": "x", "version": "1", "vulns": [{"id": "PYSEC-1"}]}]}) + ) result = run(str(trivy), "--pip-audit", str(pa)) assert result.returncode == 0 assert "PYSEC-1" in result.stdout -def test_parse_pip_audit_marks_severity_unknown(): +def test_parse_pip_audit_marks_severity_unknown() -> None: doc = { "dependencies": [ { "name": "aiohttp", "version": "3.12.14", - "vulns": [{"id": "PYSEC-2026-1", "fix_versions": ["3.14.3"], "aliases": ["CVE-2026-69244"]}], + "vulns": [ + { + "id": "PYSEC-2026-1", + "fix_versions": ["3.14.3"], + "aliases": ["CVE-2026-69244"], + } + ], } ] } @@ -221,7 +230,7 @@ def test_parse_pip_audit_marks_severity_unknown(): assert findings[0].blocking is False, "pip-audit has no severity, so it must never gate" -def test_parse_pip_audit_tolerates_garbage(): +def test_parse_pip_audit_tolerates_garbage() -> None: assert parse_pip_audit({}) == [] assert parse_pip_audit({"dependencies": "nope"}) == [] assert parse_pip_audit({"dependencies": [{"vulns": None}]}) == [] @@ -230,7 +239,7 @@ def test_parse_pip_audit_tolerates_garbage(): # --- merging ---------------------------------------------------------------- -def test_merge_dedupes_across_scanners_and_keeps_worst_severity(): +def test_merge_dedupes_across_scanners_and_keeps_worst_severity() -> None: a = Finding("CVE-1", "aiohttp", "3.12.14", "UNKNOWN", "none available", "t", "", "pip-audit") b = Finding("CVE-1", "aiohttp", "3.12.14", "HIGH", "3.14.3", "t", "", "trivy") merged = merge([[a], [b]]) @@ -240,7 +249,7 @@ def test_merge_dedupes_across_scanners_and_keeps_worst_severity(): assert merged[0].sources == {"pip-audit", "trivy"} -def test_merge_sorts_critical_first(): +def test_merge_sorts_critical_first() -> None: findings = merge( [ [ @@ -256,13 +265,13 @@ def test_merge_sorts_critical_first(): # --- injection defences ----------------------------------------------------- -def test_pipe_in_package_name_cannot_break_the_table(): +def test_pipe_in_package_name_cannot_break_the_table() -> None: findings = [Finding("CVE-1", "evil|pkg", "1.0", "HIGH", "2.0", "title", "", "trivy")] out = render(findings, [], "", blocking=True) assert "evil\\|pkg" in out -def test_backticks_in_advisory_text_cannot_escape_the_fence(): +def test_backticks_in_advisory_text_cannot_escape_the_fence() -> None: nasty = "benign ``` text" findings = [Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", nasty, "", "trivy")] out = render(findings, [], "", blocking=True) @@ -272,13 +281,13 @@ def test_backticks_in_advisory_text_cannot_escape_the_fence(): assert "```" in body -def test_non_http_url_is_not_rendered_as_a_link(): +def test_non_http_url_is_not_rendered_as_a_link() -> None: findings = [Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", "t", "javascript:alert(1)", "trivy")] out = render(findings, [], "", blocking=True) assert "javascript:" not in out -def test_annotations_escape_newlines_so_they_cannot_forge_commands(): +def test_annotations_escape_newlines_so_they_cannot_forge_commands() -> None: # GitHub only interprets a ::command:: at the START of a line, so the # property that matters is that one finding renders as exactly one line # with no raw terminators -- not that the literal text "::error" is absent @@ -286,13 +295,14 @@ def test_annotations_escape_newlines_so_they_cannot_forge_commands(): nasty = "line one\n::error::forged command\rmore" findings = [Finding("CVE-1", "pkg", "1.0", "CRITICAL", "2.0", nasty, "", "trivy")] out = render_annotations(findings) - assert "\n" not in out and "\r" not in out, "a raw terminator would let advisory text forge a command" + assert "\n" not in out, "a raw terminator would let advisory text forge a command" + assert "\r" not in out, "a raw terminator would let advisory text forge a command" assert len([line for line in out.split("\n") if line.startswith("::error")]) == 1 assert "%0A" in out assert "%0D" in out -def test_annotation_percent_escaped_before_newline_markers(): +def test_annotation_percent_escaped_before_newline_markers() -> None: # If % were escaped after \n, the %0A introduced here would itself become # %250A and stop suppressing the newline. findings = [Finding("CVE-1", "pkg", "1.0", "CRITICAL", "2.0", "100%\nnext", "", "trivy")] @@ -300,7 +310,7 @@ def test_annotation_percent_escaped_before_newline_markers(): assert "100%25%0Anext" in out -def test_annotations_only_cover_blocking_severities(): +def test_annotations_only_cover_blocking_severities() -> None: findings = [ Finding("CVE-LOW", "p", "1", "LOW", "2", "t", "", "trivy"), Finding("CVE-MED", "p", "1", "MEDIUM", "2", "t", "", "trivy"), @@ -312,7 +322,7 @@ def test_annotations_only_cover_blocking_severities(): assert "CVE-MED" not in out -def test_non_blocking_findings_do_not_claim_to_block(): +def test_non_blocking_findings_do_not_claim_to_block() -> None: findings = [Finding("CVE-1", "p", "1", "MEDIUM", "2", "t", "", "trivy")] out = render(findings, [], "", blocking=True) assert "does not block" in out @@ -321,18 +331,18 @@ def test_non_blocking_findings_do_not_claim_to_block(): # --- gate semantics --------------------------------------------------------- -def test_unfixable_high_is_reported_but_does_not_block(): +def test_unfixable_high_is_reported_but_does_not_block() -> None: finding = Finding("CVE-1", "pkg", "1.0", "CRITICAL", "none available", "t", "", "trivy") assert finding.blocking is False, "an unpatched upstream CVE must not wedge every release" out = render([finding], [], "", blocking=True) assert "CVE-1" in out, "but it must still be visible in the report" -def test_fixable_high_blocks(): +def test_fixable_high_blocks() -> None: assert Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", "t", "", "trivy").blocking is True -def test_gate_exits_1_on_fixable_high(tmp_path: Path): +def test_gate_exits_1_on_fixable_high(tmp_path: Path) -> None: report = tmp_path / "trivy.json" report.write_text(json.dumps(trivy_report(vuln()))) result = run(str(report), "--gate") @@ -341,28 +351,28 @@ def test_gate_exits_1_on_fixable_high(tmp_path: Path): assert "CVE-2026-69244" in result.stderr -def test_gate_exits_0_on_clean(tmp_path: Path): +def test_gate_exits_0_on_clean(tmp_path: Path) -> None: report = tmp_path / "trivy.json" report.write_text(json.dumps(clean_report())) result = run(str(report), "--gate") assert result.returncode == 0 -def test_gate_exits_0_on_unfixable_only(tmp_path: Path): +def test_gate_exits_0_on_unfixable_only(tmp_path: Path) -> None: report = tmp_path / "trivy.json" report.write_text(json.dumps(trivy_report(vuln(FixedVersion="")))) result = run(str(report), "--gate") assert result.returncode == 0 -def test_gate_fails_closed_on_unparseable_report(tmp_path: Path): +def test_gate_fails_closed_on_unparsable_report(tmp_path: Path) -> None: bad = tmp_path / "trivy.json" bad.write_text("{{{ not json") result = run(str(bad), "--gate") assert result.returncode == 1, "a scan that did not run must never be reported as a pass" -def test_missing_pip_audit_does_not_fail_the_gate(tmp_path: Path): +def test_missing_pip_audit_does_not_fail_the_gate(tmp_path: Path) -> None: # audit-deps.sh deletes a partial pip-audit report on failure, so "absent" # is an expected state. pip-audit is advisory-only and must never gate -- # otherwise a pip-audit outage blocks every PR and release. @@ -372,15 +382,15 @@ def test_missing_pip_audit_does_not_fail_the_gate(tmp_path: Path): assert result.returncode == 0 -def test_missing_pip_audit_is_surfaced_as_a_note_not_a_parse_failure(tmp_path: Path): +def test_missing_pip_audit_is_surfaced_as_a_note_not_a_parse_failure(tmp_path: Path) -> None: clean = tmp_path / "trivy.json" clean.write_text(json.dumps(clean_report())) result = run(str(clean), "--pip-audit", str(tmp_path / "absent.json")) assert result.returncode == 0 assert "do not affect the gate" in result.stdout - assert ( - "No known vulnerabilities found" in result.stdout - ), "a missing advisory scanner must not suppress the clean verdict from the gating one" + assert "No known vulnerabilities found" in result.stdout, ( + "a missing advisory scanner must not suppress the clean verdict from the gating one" + ) # --- an empty scan is not a clean scan -------------------------------------- @@ -397,16 +407,19 @@ def test_missing_pip_audit_is_surfaced_as_a_note_not_a_parse_failure(tmp_path: P {"SchemaVersion": 2, "Results": [{"Class": "lang-pkgs"}]}, # Target-less ], ) -def test_reports_with_no_scanned_target_are_detected(doc): +def test_reports_with_no_scanned_target_are_detected(doc: object) -> None: assert trivy_scanned_nothing(doc) is True -def test_real_report_is_not_flagged_as_empty(): +def test_real_report_is_not_flagged_as_empty() -> None: assert trivy_scanned_nothing(trivy_report(vuln())) is False - assert trivy_scanned_nothing({"Results": [{"Target": "requirements.txt", "Vulnerabilities": []}]}) is False + assert ( + trivy_scanned_nothing({"Results": [{"Target": "requirements.txt", "Vulnerabilities": []}]}) + is False + ) -def test_gate_fails_closed_when_trivy_scanned_nothing(tmp_path: Path): +def test_gate_fails_closed_when_trivy_scanned_nothing(tmp_path: Path) -> None: # Trivy writes exactly this, with exit code 0, when it recognises no # package file -- e.g. the compiled tree was empty or misnamed. Treating # it as clean is the single most dangerous silent failure for this gate. @@ -417,7 +430,7 @@ def test_gate_fails_closed_when_trivy_scanned_nothing(tmp_path: Path): assert "empty scan" in result.stderr or "no scanned package file" in result.stderr -def test_empty_scan_does_not_render_as_clean(tmp_path: Path): +def test_empty_scan_does_not_render_as_clean(tmp_path: Path) -> None: report = tmp_path / "trivy.json" report.write_text(json.dumps({"SchemaVersion": 2, "Results": None})) result = run(str(report)) @@ -429,25 +442,33 @@ def test_empty_scan_does_not_render_as_clean(tmp_path: Path): # --- unfixable HIGH/CRITICAL must not be described as absent ---------------- -def test_unfixable_critical_is_not_reported_as_none_at_high_or_critical(): - findings = [Finding("CVE-1", "aiohttp", "1.0", "CRITICAL", "none available", "unpatched RCE", "", "trivy")] +def test_unfixable_critical_is_not_reported_as_none_at_high_or_critical() -> None: + findings = [ + Finding( + "CVE-1", "aiohttp", "1.0", "CRITICAL", "none available", "unpatched RCE", "", "trivy" + ) + ] out = render(findings, [], "", blocking=True) - assert ( - "none at HIGH or CRITICAL" not in out - ), "the severity table directly below says CRITICAL 1; the headline must not contradict it" + assert "none at HIGH or CRITICAL" not in out, ( + "the severity table directly below says CRITICAL 1; the headline must not contradict it" + ) assert "no fix available" in out assert "CRITICAL" in out -def test_unfixable_critical_slack_message_is_not_reassuring(): - findings = [Finding("CVE-1", "aiohttp", "1.0", "CRITICAL", "none available", "unpatched RCE", "", "trivy")] +def test_unfixable_critical_slack_message_is_not_reassuring() -> None: + findings = [ + Finding( + "CVE-1", "aiohttp", "1.0", "CRITICAL", "none available", "unpatched RCE", "", "trivy" + ) + ] out = render_slack(findings, [], "", "repo") assert "none HIGH/CRITICAL" not in out assert ":rotating_light:" in out assert "aiohttp" in out -def test_mixed_fixable_and_unfixable_reports_both_counts(): +def test_mixed_fixable_and_unfixable_reports_both_counts() -> None: findings = [ Finding("CVE-FIX", "a", "1.0", "HIGH", "2.0", "t", "", "trivy"), Finding("CVE-NOFIX", "b", "1.0", "CRITICAL", "none available", "t", "", "trivy"), diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 8824883..1cf3c1a 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -34,9 +34,22 @@ jobs: pre-commit-${{ runner.os }}-py${{ steps.setup-uv.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }} - # pre-commit itself comes from the locked dev group; --only-dev skips - # installing the project, which no hook needs. + # The ruff, mypy and typos hooks run from this environment (they are + # `repo: local`), and mypy needs the SDK's dependencies to check against, + # so the whole locked project is installed, pydantic 2 included. + - name: Install dependencies + run: uv sync --locked + - name: Run pre-commit run: >- - uv run --locked --only-dev + uv run --no-sync pre-commit run --all-files --show-diff-on-failure --color=always + + # The SDK imports pydantic differently per major, so its types are checked + # against pydantic 1 as well (the hook above ran against pydantic 2). mypy + # is called directly with --no-sync, not through the hook: the hook's + # `uv run --locked` would sync .venv back to the default groups (pydantic 2). + - name: Type-check against pydantic 1 + run: | + uv sync --locked --group pydantic-v1 + uv run --no-sync mypy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ddb826b..baefec1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,45 +1,69 @@ +# `language: unsupported` (the pre-commit 4.4 name for `system`) runs a command +# from the environment pre-commit was started in. +minimum_pre_commit_version: "4.4.0" + repos: + # Pinned to a commit rather than a tag, which can be moved; the `# frozen:` + # comment names the release, and Dependabot updates both. - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # frozen: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-added-large-files - id: check-case-conflict - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable - id: check-json - id: check-toml - id: check-yaml - id: check-xml - id: check-merge-conflict - id: mixed-line-ending - args: [ --fix=lf ] + args: [--fix=lf] - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + # ruff, mypy and typos run from the project environment, so the versions in + # uv.lock (the `dev` dependency group) are the only ones there are, and mypy + # sees the SDK's real dependencies. `uv run --locked` first syncs .venv to + # uv.lock (default groups, so the tools are always installed there and a copy + # elsewhere on PATH is never picked up) and fails if uv.lock is stale. + - repo: local hooks: - - id: ruff - args: [--fix] - files: \.py$ - types: [ file ] + - id: ruff-check + name: ruff check + entry: uv run --locked ruff check --fix + language: unsupported + # pyproject.toml too: ruff validates its [project] table (RUF200). + files: (\.pyi?|(^|/)pyproject\.toml)$ + require_serial: true - id: ruff-format - files: \.py$ - types: [ file ] - - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.2 - hooks: + name: ruff format + entry: uv run --locked ruff format + language: unsupported + types_or: [python, pyi] + require_serial: true - id: mypy + name: mypy + # No file names: mypy checks the `files` set in pyproject.toml as a whole, + # which is what makes cross-module errors visible. + entry: uv run --locked mypy + language: unsupported + # pyproject.toml and uv.lock too: they hold mypy's config and the + # dependency versions it checks against. + files: (\.pyi?|^pyproject\.toml|^uv\.lock)$ pass_filenames: false - additional_dependencies: - - pydantic - files: \.py$ - types: [ file ] + require_serial: true + - id: typos + name: typos + entry: uv run --locked typos --force-exclude + language: unsupported + types: [text] + require_serial: true - # Fails when pyproject.toml and uv.lock disagree. Keep rev equal to - # [tool.uv] required-version in pyproject.toml, the uv version's source of - # truth. + # Fails when pyproject.toml and uv.lock disagree. Its version must equal + # [tool.uv] required-version in pyproject.toml, which is why Dependabot is + # told to leave it alone (.github/dependabot.yml). - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.12.18 + rev: 9b16a472943852b803af3785c45041dcf10b9f12 # frozen: 0.12.18 hooks: - id: uv-lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6400e9e..04d1339 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,25 @@ uv sync # .venv with the SDK and the dev tools, exactly as uv run pre-commit install # lint, format, type-check and uv.lock checks on every commit ``` +The ruff, mypy and typos hooks run through `uv run --locked`, which syncs `.venv` to `uv.lock` +before running the tool, so the versions in `uv.lock` are the only ones in play; the hooks fail +if `uv.lock` is out of date with `pyproject.toml`. The same checks by hand: + +```sh +uv run ruff check # lint (the rule set is `select = ["ALL"]` minus justified ignores) +uv run ruff format # format +uv run mypy # strict type check of permit/, tests/ and .github/scripts/ +uv run typos # spelling +``` + +The SDK is type-checked against both pydantic majors, because it imports pydantic differently +per major. CI runs mypy once more under pydantic 1; do the same locally when touching a pydantic +import (see [Both pydantic majors](#both-pydantic-majors) for why `--no-sync`): + +```sh +uv sync --group pydantic-v1 && uv run --no-sync mypy +``` + `.python-version` selects Python 3.11, the version CI runs on. The SDK itself supports Python 3.10 and later. @@ -69,7 +88,9 @@ uv sync --group pydantic-v1 # pydantic 1.x uv sync --group pydantic-v2 # pydantic 2.x ``` -A plain `uv sync` afterwards returns to the default resolution (pydantic 2.x). +Run commands in a lane with `uv run --no-sync` (as CI does): a plain `uv run`, and so every +pre-commit hook, syncs `.venv` back to the default resolution (pydantic 2.x) first, as does a +plain `uv sync`. ## Building @@ -111,22 +132,30 @@ has to be restored by hand. from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr ``` - Replace it with the block below, keeping exactly the names the generator imported in both - branches: + Replace it with the block below, keeping exactly the names the generator imported in all + three branches, and add `import typing as _typing` above the generated `from datetime import + datetime` line: ```py - from ..utils.pydantic_version import PYDANTIC_VERSION + from permit.utils.pydantic_version import PYDANTIC_VERSION - if PYDANTIC_VERSION < (2, 0): + if _typing.TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr + elif PYDANTIC_VERSION < (2, 0): from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr else: - from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr # type: ignore + from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr ``` - Without it, the v1-style models do not load under pydantic 2. + Without it, the v1-style models do not load under pydantic 2. The `TYPE_CHECKING` branch + makes mypy see them as the v1 models they are on both majors; otherwise mypy takes the + first import it finds and, under pydantic 2, checks every model against the v2 API. + `typing` is imported under a private alias because `permit/__init__.py` star-imports this + module: a public `TYPE_CHECKING` or `typing` name would become part of the `permit` namespace. -3. Do not run `ruff format` on it: `permit/api/models.py` is excluded from ruff in - `pyproject.toml` and keeps the generator's formatting, so the diff shows only API changes. +3. Do not run `ruff format` on it: `permit/api/models.py` is excluded from ruff and typos + in `pyproject.toml` and keeps the generator's formatting, so the diff shows only API changes. 4. Run the offline tests under both pydantic majors (see above) and `uv run pre-commit run --all-files`. diff --git a/permit/__init__.py b/permit/__init__.py index 786631c..8bd5daf 100644 --- a/permit/__init__.py +++ b/permit/__init__.py @@ -1,24 +1,29 @@ -# ruff: noqa: F401 -from .api.models import * # noqa: F403 -from .config import PermitConfig -from .enforcement.enforcer import Action, Resource, User -from .enforcement.interfaces import ( - AssignedRole, - AuthorizedUsersResult, - ResourceInput, - UserInput, -) -from .exceptions import ( - PermitAlreadyExistsError, - PermitApiDetailedError, - PermitApiError, - PermitConnectionError, - PermitContextChangeError, - PermitContextError, - PermitError, - PermitException, - PermitNotFoundError, - PermitValidationError, -) -from .permit import Permit -from .utils.context import Context +"""Permit.io SDK: authorization checks and the Permit REST API from Python. + +The `X as X` imports mark the package's public names as explicit re-exports +for type checkers. +""" + +from permit.api.models import * # noqa: F403 - every API model is part of the public surface +from permit.config import PermitConfig as PermitConfig +from permit.enforcement.enforcer import Action as Action +from permit.enforcement.enforcer import Resource as Resource +from permit.enforcement.enforcer import User as User +from permit.enforcement.interfaces import AssignedRole as AssignedRole +from permit.enforcement.interfaces import AuthorizedUsersResult as AuthorizedUsersResult +from permit.enforcement.interfaces import ResourceInput as ResourceInput +from permit.enforcement.interfaces import UserInput as UserInput +from permit.exceptions import PermitAlreadyExistsError as PermitAlreadyExistsError +from permit.exceptions import PermitApiDetailedError as PermitApiDetailedError +from permit.exceptions import PermitApiError as PermitApiError +from permit.exceptions import PermitConnectionError as PermitConnectionError +from permit.exceptions import PermitContextChangeError as PermitContextChangeError +from permit.exceptions import PermitContextError as PermitContextError +from permit.exceptions import PermitError as PermitError + +# Deprecated, but still exported for existing callers. +from permit.exceptions import PermitException as PermitException # type: ignore[deprecated] +from permit.exceptions import PermitNotFoundError as PermitNotFoundError +from permit.exceptions import PermitValidationError as PermitValidationError +from permit.permit import Permit as Permit +from permit.utils.context import Context as Context diff --git a/permit/api/api_client.py b/permit/api/api_client.py index 478b22d..3152b05 100644 --- a/permit/api/api_client.py +++ b/permit/api/api_client.py @@ -1,28 +1,29 @@ -from ..config import PermitConfig -from .condition_set_rules import ConditionSetRulesApi -from .condition_sets import ConditionSetsApi -from .deprecated import DeprecatedApi -from .environments import EnvironmentsApi -from .projects import ProjectsApi -from .relationship_tuples import RelationshipTuplesApi -from .resource_action_groups import ResourceActionGroupsApi -from .resource_actions import ResourceActionsApi -from .resource_attributes import ResourceAttributesApi -from .resource_instances import ResourceInstancesApi -from .resource_relations import ResourceRelationsApi -from .resource_roles import ResourceRolesApi -from .resources import ResourcesApi -from .role_assignments import RoleAssignmentsApi -from .roles import RolesApi -from .tenants import TenantsApi -from .user_invites import UserInvitesApi -from .users import UsersApi +from permit.api.condition_set_rules import ConditionSetRulesApi +from permit.api.condition_sets import ConditionSetsApi +from permit.api.deprecated import DeprecatedApi +from permit.api.environments import EnvironmentsApi +from permit.api.projects import ProjectsApi +from permit.api.relationship_tuples import RelationshipTuplesApi +from permit.api.resource_action_groups import ResourceActionGroupsApi +from permit.api.resource_actions import ResourceActionsApi +from permit.api.resource_attributes import ResourceAttributesApi +from permit.api.resource_instances import ResourceInstancesApi +from permit.api.resource_relations import ResourceRelationsApi +from permit.api.resource_roles import ResourceRolesApi +from permit.api.resources import ResourcesApi +from permit.api.role_assignments import RoleAssignmentsApi +from permit.api.roles import RolesApi +from permit.api.tenants import TenantsApi +from permit.api.user_invites import UserInvitesApi +from permit.api.users import UsersApi +from permit.config import PermitConfig class PermitApiClient(DeprecatedApi): - def __init__(self, config: PermitConfig): - """ - Constructs a new instance of the ApiClient class with the specified SDK configuration. + """Entry point to the Permit REST API; one attribute per API area.""" + + def __init__(self, config: PermitConfig) -> None: + """Constructs a new instance of the ApiClient class with the specified SDK configuration. Args: config: The configuration for the Permit SDK. @@ -49,136 +50,136 @@ def __init__(self, config: PermitConfig): @property def condition_set_rules(self) -> ConditionSetRulesApi: - """ - API for managing condition set rules. + """API for managing condition set rules. + See: https://api.permit.io/v2/redoc#tag/Condition-Set-Rules """ return self._condition_set_rules @property def condition_sets(self) -> ConditionSetsApi: - """ - API for managing condition sets. + """API for managing condition sets. + See: https://api.permit.io/v2/redoc#tag/Condition-Sets """ return self._condition_sets @property def projects(self) -> ProjectsApi: - """ - API for managing projects. + """API for managing projects. + See: https://api.permit.io/v2/redoc#tag/Projects """ return self._projects @property def environments(self) -> EnvironmentsApi: - """ - API for managing environments. + """API for managing environments. + See: https://api.permit.io/v2/redoc#tag/Environments """ return self._environments @property def action_groups(self) -> ResourceActionGroupsApi: - """ - API for managing resource action groups. + """API for managing resource action groups. + See: https://api.permit.io/v2/redoc#tag/Resource-Action-Groups """ return self._action_groups @property def resource_actions(self) -> ResourceActionsApi: - """ - API for managing resource actions. + """API for managing resource actions. + See: https://api.permit.io/v2/redoc#tag/Resource-Actions """ return self._resource_actions @property def resource_attributes(self) -> ResourceAttributesApi: - """ - API for managing resource attributes. + """API for managing resource attributes. + See: https://api.permit.io/v2/redoc#tag/Resource-Attributes """ return self._resource_attributes @property def resource_roles(self) -> ResourceRolesApi: - """ - API for managing resource roles. + """API for managing resource roles. + See: https://api.permit.io/v2/redoc#tag/Resource-Roles """ return self._resource_roles @property def resource_relations(self) -> ResourceRelationsApi: - """ - API for managing resource relations. + """API for managing resource relations. + See: https://api.permit.io/v2/redoc#tag/Resource-Relations """ return self._resource_relations @property def resource_instances(self) -> ResourceInstancesApi: - """ - API for managing resource instances. + """API for managing resource instances. + See: https://api.permit.io/v2/redoc#tag/Resource-Instances """ return self._resource_instances @property def resources(self) -> ResourcesApi: - """ - API for managing resources. + """API for managing resources. + See: https://api.permit.io/v2/redoc#tag/Resources """ return self._resources @property def role_assignments(self) -> RoleAssignmentsApi: - """ - API for managing role assignments. + """API for managing role assignments. + See: https://api.permit.io/v2/redoc#tag/Role-Assignments """ return self._role_assignments @property def relationship_tuples(self) -> RelationshipTuplesApi: - """ - API for managing relationship tuples. + """API for managing relationship tuples. + See: https://api.permit.io/v2/redoc#tag/Relationship-tuples """ return self._relationship_tuples @property def roles(self) -> RolesApi: - """ - API for managing roles. + """API for managing roles. + See: https://api.permit.io/v2/redoc#tag/Roles """ return self._roles @property def tenants(self) -> TenantsApi: - """ - API for managing tenants. + """API for managing tenants. + See: https://api.permit.io/v2/redoc#tag/Tenants """ return self._tenants @property def user_invites(self) -> UserInvitesApi: - """ - API for managing user invites. + """API for managing user invites. + See: https://api.permit.io/v2/redoc#tag/User-Invites """ return self._user_invites @property def users(self) -> UsersApi: - """ - API for managing users. + """API for managing users. + See: https://api.permit.io/v2/redoc#tag/Users """ return self._users diff --git a/permit/api/base.py b/permit/api/base.py index e116672..369b0e2 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -1,31 +1,45 @@ -from typing import Optional, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar, cast, overload import aiohttp from aiohttp import ClientTimeout from loguru import logger -from ..utils.pydantic_version import PYDANTIC_VERSION -from .encoders import jsonable_encoder +from permit.api.encoders import jsonable_encoder +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel, Extra, Field, parse_obj_as +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel, Extra, Field, parse_obj_as else: - from pydantic.v1 import BaseModel, Extra, Field, parse_obj_as # type: ignore + from pydantic.v1 import BaseModel, Extra, Field, parse_obj_as -from ..config import PermitConfig -from ..exceptions import PermitContextError, handle_api_error, handle_client_error -from .context import API_ACCESS_LEVELS, ApiContextLevel, ApiKeyAccessLevel -from .models import APIKeyScopeRead +from permit.api.context import API_ACCESS_LEVELS, ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import APIKeyScopeRead +from permit.config import PermitConfig +from permit.exceptions import PermitContextError, handle_api_error, handle_client_error -TModel = TypeVar("TModel", bound=BaseModel) -TData = TypeVar("TData", bound=BaseModel) +# Whatever `parse_obj_as` can build: a model, or e.g. `list[Model]` for list endpoints. +TModel = TypeVar("TModel") -def pagination_params(page: int, per_page: int) -> dict: +def pagination_params(page: int, per_page: int) -> dict[str, str | int]: + """Build the query parameters of a paginated list request. + + Args: + page: The page number, starting at 1. + per_page: How many items to fetch per page. + + Returns: + The `page` and `per_page` query parameters. + """ return {"page": page, "per_page": per_page} class ClientConfig(BaseModel): + """Connection settings of a `SimpleHttpClient`.""" + class Config: extra = Extra.allow @@ -33,15 +47,19 @@ class Config: ..., description="base url that will prefix the url fragment sent via the client", ) - headers: dict = Field(..., description="http headers sent to the API server") + # Bare `dict` on purpose: pydantic v1 passes it through as is, while a parameterized + # dict would be validated as a mapping and copied. + headers: dict = Field( # type: ignore[type-arg] + ..., description="http headers sent to the API server" + ) class SimpleHttpClient: - """ - wraps aiohttp client to reduce boilerplace - """ + """wraps aiohttp client to reduce boilerplace.""" - def __init__(self, client_config: dict, base_url: str = "", timeout: Optional[int] = None): + def __init__( + self, client_config: dict[str, Any], base_url: str = "", timeout: int | None = None + ) -> None: self._client_config = client_config self._base_url = base_url if timeout is not None: @@ -53,7 +71,9 @@ def _log_request(self, url: str, method: str) -> None: def _log_response(self, url: str, method: str, status: int) -> None: logger.debug(f"Received HTTP response: {method} {url}, status: {status}") - def _prepare_json(self, json: Optional[Union[TData, dict, list]] = None) -> Optional[Union[dict, list]]: + def _prepare_json( + self, json: BaseModel | dict[str, Any] | list[Any] | None = None + ) -> dict[str, Any] | list[Any] | None: """Normalize a request body into JSON-serializable primitives. Models, dicts and lists all go through the same encoder so that nested @@ -72,10 +92,11 @@ def _prepare_json(self, json: Optional[Union[TData, dict, list]] = None) -> Opti if json is None: return None - return jsonable_encoder(json, exclude_unset=True) + return cast("dict[str, Any] | list[Any]", jsonable_encoder(json, exclude_unset=True)) @handle_client_error - async def get(self, url, model: Type[TModel], **kwargs) -> TModel: + async def get(self, url: str, model: type[TModel], **kwargs: Any) -> TModel: + """Send a GET request and parse the JSON response into `model`.""" url = f"{self._base_url}{url}" async with aiohttp.ClientSession(**self._client_config) as client: self._log_request(url, "GET") @@ -88,11 +109,12 @@ async def get(self, url, model: Type[TModel], **kwargs) -> TModel: @handle_client_error async def post( self, - url, - model: Type[TModel], - json: Optional[Union[TData, dict, list]] = None, - **kwargs, + url: str, + model: type[TModel], + json: BaseModel | dict[str, Any] | list[Any] | None = None, + **kwargs: Any, ) -> TModel: + """Send a POST request with a JSON body and parse the JSON response into `model`.""" url = f"{self._base_url}{url}" async with aiohttp.ClientSession(**self._client_config) as client: self._log_request(url, "POST") @@ -105,11 +127,12 @@ async def post( @handle_client_error async def put( self, - url, - model: Type[TModel], - json: Optional[Union[TData, dict, list]] = None, - **kwargs, + url: str, + model: type[TModel], + json: BaseModel | dict[str, Any] | list[Any] | None = None, + **kwargs: Any, ) -> TModel: + """Send a PUT request with a JSON body and parse the JSON response into `model`.""" url = f"{self._base_url}{url}" async with aiohttp.ClientSession(**self._client_config) as client: self._log_request(url, "PUT") @@ -122,11 +145,12 @@ async def put( @handle_client_error async def patch( self, - url, - model: Type[TModel], - json: Optional[Union[TData, dict, list]] = None, - **kwargs, + url: str, + model: type[TModel], + json: BaseModel | dict[str, Any] | list[Any] | None = None, + **kwargs: Any, ) -> TModel: + """Send a PATCH request with a JSON body and parse the JSON response into `model`.""" url = f"{self._base_url}{url}" async with aiohttp.ClientSession(**self._client_config) as client: self._log_request(url, "PATCH") @@ -136,14 +160,33 @@ async def patch( data = await response.json() return parse_obj_as(model, data) + @overload + async def delete( + self, + url: str, + model: None = None, + json: BaseModel | dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> None: ... + + @overload + async def delete( + self, + url: str, + model: type[TModel], + json: BaseModel | dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> TModel: ... + @handle_client_error async def delete( self, - url, - model: Optional[Type[TModel]] = None, - json: Optional[Union[TData, dict, list]] = None, - **kwargs, - ) -> Optional[TModel]: + url: str, + model: type[TModel] | None = None, + json: BaseModel | dict[str, Any] | list[Any] | None = None, + **kwargs: Any, + ) -> TModel | None: + """Send a DELETE request; parse the JSON response into `model` if one is given.""" url = f"{self._base_url}{url}" async with aiohttp.ClientSession(**self._client_config) as client: self._log_request(url, "DELETE") @@ -157,13 +200,10 @@ async def delete( class BasePermitApi: - """ - The base class for Permit APIs. - """ + """The base class for Permit APIs.""" - def __init__(self, config: PermitConfig): - """ - Initialize a BasePermitApi. + def __init__(self, config: PermitConfig) -> None: + """Initialize a BasePermitApi. Args: config: The Permit SDK configuration. @@ -171,7 +211,9 @@ def __init__(self, config: PermitConfig): self.config = config self.__api_keys = self._build_http_client("/v2/api-key") - def _build_http_client(self, endpoint_url: str = "", *, use_pdp: bool = False, **kwargs): + def _build_http_client( + self, endpoint_url: str = "", *, use_pdp: bool = False, **kwargs: Any + ) -> SimpleHttpClient: optional_headers = {} if self.config.proxy_facts_via_pdp: if self.config.facts_sync_timeout: @@ -196,18 +238,18 @@ def _build_http_client(self, endpoint_url: str = "", *, use_pdp: bool = False, * ) async def _set_context_from_api_key(self) -> None: - """ - Set the API context and permitted access level based on the API key scope. - """ + """Set the API context and permitted access level based on the API key scope.""" logger.debug("Fetching api key scope") scope = await self.__api_keys.get("/scope", model=APIKeyScopeRead) if scope.organization_id is not None: # saves the permitted access level by that api key - self.config.api_context._save_api_key_accessible_scope( + self.config.api_context._save_api_key_accessible_scope( # noqa: SLF001 - SDK-internal org=str(scope.organization_id), project=(str(scope.project_id) if scope.project_id is not None else None), - environment=(str(scope.environment_id) if scope.environment_id is not None else None), + environment=( + str(scope.environment_id) if scope.environment_id is not None else None + ), ) if scope.project_id is not None: @@ -221,18 +263,22 @@ async def _set_context_from_api_key(self) -> None: return # Set project level context - self.config.api_context.set_project_level_context(str(scope.organization_id), str(scope.project_id)) + self.config.api_context.set_project_level_context( + str(scope.organization_id), str(scope.project_id) + ) return # Set org level context self.config.api_context.set_organization_level_context(str(scope.organization_id)) return - raise PermitContextError("Could not set API context level") + # Defensive: the schema makes organization_id required, so mypy knows this + # is unreachable for a well-formed response. + msg = "Could not set API context level" # type: ignore[unreachable] + raise PermitContextError(msg) async def _ensure_access_level(self, required_access_level: ApiKeyAccessLevel) -> None: - """ - Ensure that the API Key has the necessary permissions to successfully call the API endpoint. + """Ensure that the API Key has the access level the API endpoint requires. Note that this check is not full proof, and the API may still throw 401. @@ -240,7 +286,8 @@ async def _ensure_access_level(self, required_access_level: ApiKeyAccessLevel) - required_access_level: The required API Key Access level for the endpoint. Raises: - PermitContextError: If the currently set API key access level does not match the required access level. + PermitContextError: If the currently set API key access level does not match the + required access level. """ # should only happen once in the lifetime of the sdk if ( @@ -253,21 +300,22 @@ async def _ensure_access_level(self, required_access_level: ApiKeyAccessLevel) - if required_access_level != permitted_access_level and API_ACCESS_LEVELS.index( required_access_level ) < API_ACCESS_LEVELS.index(permitted_access_level): - raise PermitContextError( + msg = ( f"You're trying to use an SDK method that requires an API Key " f"with access level: {required_access_level}, however the SDK is running " f"with an API key with level {permitted_access_level}." ) + raise PermitContextError(msg) async def _ensure_context(self, required_context: ApiContextLevel) -> None: - """ - Ensure that the API context matches the required endpoint context. + """Ensure that the API context matches the required endpoint context. Args: - context: The required API context level for the endpoint. + required_context: The required API context level for the endpoint. Raises: - PermitContextError: If the currently set API context level does not match the required context level. + PermitContextError: If the currently set API context level does not match the required + context level. """ # should only happen once in the lifetime of the sdk if ( @@ -277,7 +325,10 @@ async def _ensure_context(self, required_context: ApiContextLevel) -> None: await self._set_context_from_api_key() if self.config.api_context.level.value < required_context.value: - raise PermitContextError( - f"You're trying to use an SDK method that requires an api context of {required_context.name}, " - + f"however the SDK is running in a less specific context level: {self.config.api_context.level}." + msg = ( + f"You're trying to use an SDK method that requires an api context of " + f"{required_context.name}, " + f"however the SDK is running in a less specific context level: " + f"{self.config.api_context.level}." ) + raise PermitContextError(msg) diff --git a/permit/api/condition_set_rules.py b/permit/api/condition_set_rules.py index 0c9e797..69725fd 100644 --- a/permit/api/condition_set_rules.py +++ b/permit/api/condition_set_rules.py @@ -1,45 +1,53 @@ -from typing import List, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ConditionSetRuleCreate, ConditionSetRuleRead, ConditionSetRuleRemove +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ConditionSetRuleCreate, ConditionSetRuleRead, ConditionSetRuleRemove class ConditionSetRulesApi(BasePermitApi): + """Manage condition set rules: which user sets may act on which resource sets.""" + @property def __condition_set_rules(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/set_rules" ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def list( self, - user_set_key: Optional[str] = None, - permission_key: Optional[str] = None, - resource_set_key: Optional[str] = None, + user_set_key: str | None = None, + permission_key: str | None = None, + resource_set_key: str | None = None, page: int = 1, per_page: int = 100, - ) -> List[ConditionSetRuleRead]: - """ - Retrieves a list of condition set rule rules. + ) -> list[ConditionSetRuleRead]: + """Retrieves a list of condition set rule rules. Args: - user_set_key: the key of the userset, if used only rules matching that userset will be fetched. + user_set_key: the key of the userset, if used only rules matching that userset will be + fetched. permission_key: the key of the permission, formatted as :. if used, only rules granting that permission will be fetched. - resource_set_key: the key of the resourceset, if used only rules matching that resourceset will be fetched. + resource_set_key: the key of the resourceset, if used only rules matching that + resourceset will be fetched. page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -48,7 +56,8 @@ async def list( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -61,14 +70,13 @@ async def list( params.update(resource_set=resource_set_key) return await self.__condition_set_rules.get( "", - model=List[ConditionSetRuleRead], + model=list[ConditionSetRuleRead], params=params, ) - @validate_arguments # type: ignore[operator] - async def create(self, rule: ConditionSetRuleCreate) -> List[ConditionSetRuleRead]: - """ - Creates a new condition set rule. + @validate_arguments + async def create(self, rule: ConditionSetRuleCreate) -> builtins.list[ConditionSetRuleRead]: + """Creates a new condition set rule. Args: rule: The condition set rule to create. @@ -78,23 +86,26 @@ async def create(self, rule: ConditionSetRuleCreate) -> List[ConditionSetRuleRea Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__condition_set_rules.post("", model=List[ConditionSetRuleRead], json=rule) + return await self.__condition_set_rules.post( + "", model=list[ConditionSetRuleRead], json=rule + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, rule: ConditionSetRuleRemove) -> None: - """ - Deletes a condition set rule. + """Deletes a condition set rule. Args: rule: The condition set rule to delete. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/condition_sets.py b/permit/api/condition_sets.py index d6d4e57..1bdf87e 100644 --- a/permit/api/condition_sets.py +++ b/permit/api/condition_sets.py @@ -1,32 +1,36 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ConditionSetCreate, ConditionSetRead, ConditionSetUpdate +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ConditionSetCreate, ConditionSetRead, ConditionSetUpdate class ConditionSetsApi(BasePermitApi): + """Manage condition sets (user sets and resource sets) for ABAC policies.""" + @property def __condition_sets(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/condition_sets" ) - @validate_arguments # type: ignore[operator] - async def list(self, page: int = 1, per_page: int = 100) -> List[ConditionSetRead]: - """ - Retrieves a list of condition sets. + @validate_arguments + async def list(self, page: int = 1, per_page: int = 100) -> list[ConditionSetRead]: + """Retrieves a list of condition sets. Args: page: The page number to fetch (default: 1). @@ -37,21 +41,21 @@ async def list(self, page: int = 1, per_page: int = 100) -> List[ConditionSetRea Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__condition_sets.get( - "", model=List[ConditionSetRead], params=pagination_params(page, per_page) + "", model=list[ConditionSetRead], params=pagination_params(page, per_page) ) async def _get(self, condition_set_key: str) -> ConditionSetRead: return await self.__condition_sets.get(f"/{condition_set_key}", model=ConditionSetRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, condition_set_key: str) -> ConditionSetRead: - """ - Retrieves a condition set by its key. + """Retrieves a condition set by its key. Args: condition_set_key: The key of the condition set. @@ -61,16 +65,17 @@ async def get(self, condition_set_key: str) -> ConditionSetRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(condition_set_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, condition_set_key: str) -> ConditionSetRead: - """ - Retrieves a condition set by its key. + """Retrieves a condition set by its key. + Alias for the get method. Args: @@ -81,16 +86,17 @@ async def get_by_key(self, condition_set_key: str) -> ConditionSetRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(condition_set_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, condition_set_id: str) -> ConditionSetRead: - """ - Retrieves a condition set by its ID. + """Retrieves a condition set by its ID. + Alias for the get method. Args: @@ -101,16 +107,16 @@ async def get_by_id(self, condition_set_id: str) -> ConditionSetRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(condition_set_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, condition_set_data: ConditionSetCreate) -> ConditionSetRead: - """ - Creates a new condition set. + """Creates a new condition set. Args: condition_set_data: The data for the new condition set. @@ -120,16 +126,18 @@ async def create(self, condition_set_data: ConditionSetCreate) -> ConditionSetRe Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__condition_sets.post("", model=ConditionSetRead, json=condition_set_data) - @validate_arguments # type: ignore[operator] - async def update(self, condition_set_key: str, condition_set_data: ConditionSetUpdate) -> ConditionSetRead: - """ - Updates a condition set. + @validate_arguments + async def update( + self, condition_set_key: str, condition_set_data: ConditionSetUpdate + ) -> ConditionSetRead: + """Updates a condition set. Args: condition_set_key: The key of the condition set. @@ -140,7 +148,8 @@ async def update(self, condition_set_key: str, condition_set_data: ConditionSetU Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -150,17 +159,17 @@ async def update(self, condition_set_key: str, condition_set_data: ConditionSetU json=condition_set_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, condition_set_key: str) -> None: - """ - Deletes a condition set. + """Deletes a condition set. Args: condition_set_key: The key of the condition set to delete. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/context.py b/permit/api/context.py index f824d7d..9d72d83 100644 --- a/permit/api/context.py +++ b/permit/api/context.py @@ -1,15 +1,12 @@ from enum import Enum -from typing import Optional from loguru import logger -from ..exceptions import PermitContextChangeError +from permit.exceptions import PermitContextChangeError class ApiKeyAccessLevel(str, Enum): - """ - The `ApiKeyAccessLevel` enum represents the access level of a Permit API Key. - """ + """The `ApiKeyAccessLevel` enum represents the access level of a Permit API Key.""" WAIT_FOR_INIT = "WAIT_FOR_INIT" """ @@ -42,9 +39,7 @@ class ApiKeyAccessLevel(str, Enum): class ApiContextLevel(int, Enum): - """ - The `ApiContextLevel` enum represents the context level in which the SDK is running. - """ + """The `ApiContextLevel` enum represents the context level in which the SDK is running.""" WAIT_FOR_INIT = 0 """ @@ -63,13 +58,13 @@ class ApiContextLevel(int, Enum): ENVIRONMENT = 3 """ - When running in this context level, the SDK knows the current organization, project and environment. + When running in this context level, the SDK knows the current organization, project and + environment. """ class ApiContext: - """ - The `ApiContext` class represents the required known context for an API method. + """The `ApiContext` class represents the required known context for an API method. Since the Permit API hierarchy is deeply nested, it is less convenient to specify the full object hierarchy in every request. @@ -93,22 +88,22 @@ class ApiContext: we are running under a `ApiContextLevel.ENVIRONMENT` context. """ - def __init__(self): + def __init__(self) -> None: self._permitted_access_level = ApiKeyAccessLevel.WAIT_FOR_INIT # org, project and environment the API Key is allowed to access - self._permitted_organization = None - self._permitted_project = None - self._permitted_environment = None + self._permitted_organization: str | None = None + self._permitted_project: str | None = None + self._permitted_environment: str | None = None # current known context self._context_level = ApiContextLevel.WAIT_FOR_INIT - self._organization = None - self._project = None - self._environment = None + self._organization: str | None = None + self._project: str | None = None + self._environment: str | None = None def _save_api_key_accessible_scope( - self, org: str, project: Optional[str] = None, environment: Optional[str] = None - ): + self, org: str, project: str | None = None, environment: str | None = None + ) -> None: """Do not call this method directly!""" self._permitted_organization = org # cannot be none @@ -127,8 +122,7 @@ def _save_api_key_accessible_scope( @property def permitted_access_level(self) -> ApiKeyAccessLevel: - """ - Get the current API key level. + """Get the current API key level. Returns: The current API key level. @@ -137,8 +131,7 @@ def permitted_access_level(self) -> ApiKeyAccessLevel: @property def level(self) -> ApiContextLevel: - """ - Get the current SDK context level. + """Get the current SDK context level. Returns: The current SDK context level. @@ -146,9 +139,8 @@ def level(self) -> ApiContextLevel: return self._context_level @property - def organization(self) -> Optional[str]: - """ - Get the current organization from the SDK context or None if unset. + def organization(self) -> str | None: + """Get the current organization from the SDK context or None if unset. Returns: The current organization in the context. @@ -156,9 +148,8 @@ def organization(self) -> Optional[str]: return self._organization @property - def project(self) -> Optional[str]: - """ - Get the current project from the SDK context or None if unset. + def project(self) -> str | None: + """Get the current project from the SDK context or None if unset. Returns: The current project in the context. @@ -166,39 +157,42 @@ def project(self) -> Optional[str]: return self._project @property - def environment(self) -> Optional[str]: - """ - Get the current environment from the SDK context or None if unset. + def environment(self) -> str | None: + """Get the current environment from the SDK context or None if unset. Returns: The current environment in the context. """ return self._environment - def __verify_can_access_org(self, org: str): + def __verify_can_access_org(self, org: str) -> None: if org != self._permitted_organization: - raise PermitContextChangeError( - f"You cannot set an SDK context with org '{org}' due to insufficient API Key permissions" + msg = ( + f"You cannot set an SDK context with org '{org}' " + f"due to insufficient API Key permissions" ) + raise PermitContextChangeError(msg) - def __verify_can_access_project(self, org: str, project: str): + def __verify_can_access_project(self, org: str, project: str) -> None: self.__verify_can_access_org(org) if self._permitted_project is not None and project != self._permitted_project: - raise PermitContextChangeError( - f"You cannot set an SDK context with project '{project}' due to insufficient API Key permissions" + msg = ( + f"You cannot set an SDK context with project '{project}' " + f"due to insufficient API Key permissions" ) + raise PermitContextChangeError(msg) - def __verify_can_access_environment(self, org: str, project: str, environment: str): + def __verify_can_access_environment(self, org: str, project: str, environment: str) -> None: self.__verify_can_access_project(org, project) if self._permitted_environment is not None and environment != self._permitted_environment: - raise PermitContextChangeError( + msg = ( f"You cannot set an SDK context with environment '{environment}' " f"due to insufficient API Key permissions" ) + raise PermitContextChangeError(msg) - def set_organization_level_context(self, org: str): - """ - Set the current context of the SDK to a specific organization. + def set_organization_level_context(self, org: str) -> None: + """Set the current context of the SDK to a specific organization. Args: org: The organization key. @@ -210,9 +204,8 @@ def set_organization_level_context(self, org: str): self._project = None self._environment = None - def set_project_level_context(self, org: str, project: str): - """ - Set the current context of the SDK to a specific organization and project. + def set_project_level_context(self, org: str, project: str) -> None: + """Set the current context of the SDK to a specific organization and project. Args: org: The organization key. @@ -225,9 +218,8 @@ def set_project_level_context(self, org: str, project: str): self._project = project self._environment = None - def set_environment_level_context(self, org: str, project: str, environment: str): - """ - Set the current context of the SDK to a specific organization, project and environment. + def set_environment_level_context(self, org: str, project: str, environment: str) -> None: + """Set the current context of the SDK to an organization, project and environment. Args: org: The organization key. diff --git a/permit/api/deprecated.py b/permit/api/deprecated.py index cbb93fc..806f2a0 100644 --- a/permit/api/deprecated.py +++ b/permit/api/deprecated.py @@ -1,11 +1,9 @@ -from typing import List, Optional, Union +from typing import Any from uuid import UUID -from ..config import PermitConfig -from ..utils.deprecation import deprecated -from .base import BasePermitApi -from .elements import ElementsApi, EmbeddedLoginRequestOutput -from .models import ( +from permit.api.base import BasePermitApi +from permit.api.elements import ElementsApi, EmbeddedLoginRequestOutput +from permit.api.models import ( ResourceCreate, ResourceRead, ResourceUpdate, @@ -21,19 +19,19 @@ UserCreate, UserRead, ) -from .resources import ResourcesApi -from .role_assignments import RoleAssignmentsApi -from .roles import RolesApi -from .tenants import TenantsApi -from .users import UsersApi +from permit.api.resources import ResourcesApi +from permit.api.role_assignments import RoleAssignmentsApi +from permit.api.roles import RolesApi +from permit.api.tenants import TenantsApi +from permit.api.users import UsersApi +from permit.config import PermitConfig +from permit.utils.deprecation import deprecated class DeprecatedApi(BasePermitApi): - """ - Represents the interface for managing roles. - """ + """Deprecated aliases of methods that now live on the specific APIs.""" - def __init__(self, config: PermitConfig): + def __init__(self, config: PermitConfig) -> None: super().__init__(config) self.__resources = ResourcesApi(config) self.__role_assignments = RoleAssignmentsApi(config) @@ -44,102 +42,135 @@ def __init__(self, config: PermitConfig): @deprecated("use permit.api.users.get() instead") async def get_user(self, user_key: str) -> UserRead: + """Deprecated: use `permit.api.users.get()` instead.""" return await self.__users.get(user_key) @deprecated("use permit.api.roles.get() instead") async def get_role(self, role_key: str) -> RoleRead: + """Deprecated: use `permit.api.roles.get()` instead.""" return await self.__roles.get(role_key) @deprecated("use permit.api.tenants.get() instead") async def get_tenant(self, tenant_key: str) -> TenantRead: + """Deprecated: use `permit.api.tenants.get()` instead.""" return await self.__tenants.get(tenant_key) @deprecated("use permit.api.users.get_assigned_roles() instead") async def get_assigned_roles( self, user_key: str, - tenant_key: Optional[str], + tenant_key: str | None, page: int = 1, per_page: int = 100, - ) -> List[RoleAssignmentRead]: - return await self.__users.get_assigned_roles(user_key, tenant=tenant_key, page=page, per_page=per_page) + ) -> list[RoleAssignmentRead]: + """Deprecated: use `permit.api.users.get_assigned_roles()` instead.""" + return await self.__users.get_assigned_roles( + user_key, tenant=tenant_key, page=page, per_page=per_page + ) @deprecated("use permit.api.resources.get() instead") async def get_resource(self, resource_key: str) -> ResourceRead: + """Deprecated: use `permit.api.resources.get()` instead.""" return await self.__resources.get(resource_key) @deprecated("use permit.api.roles.list() instead") - async def list_roles(self, page: int = 1, per_page: int = 100) -> List[RoleRead]: + async def list_roles(self, page: int = 1, per_page: int = 100) -> list[RoleRead]: + """Deprecated: use `permit.api.roles.list()` instead.""" return await self.__roles.list(page=page, per_page=per_page) @deprecated("use permit.api.users.sync() instead") - async def sync_user(self, user: Union[UserCreate, dict]) -> UserRead: + async def sync_user(self, user: UserCreate | dict[str, Any]) -> UserRead: + """Deprecated: use `permit.api.users.sync()` instead.""" return await self.__users.sync(user) @deprecated("use permit.api.users.delete() instead") async def delete_user(self, user_key: str) -> None: + """Deprecated: use `permit.api.users.delete()` instead.""" return await self.__users.delete(user_key) @deprecated("use permit.api.tenants.list() instead") - async def list_tenants(self, page: int = 1, per_page: int = 100) -> List[TenantRead]: + async def list_tenants(self, page: int = 1, per_page: int = 100) -> list[TenantRead]: + """Deprecated: use `permit.api.tenants.list()` instead.""" return await self.__tenants.list(page=page, per_page=per_page) @deprecated("use permit.api.tenants.create() instead") - async def create_tenant(self, tenant: Union[TenantCreate, dict]) -> TenantRead: + async def create_tenant(self, tenant: TenantCreate | dict[str, Any]) -> TenantRead: + """Deprecated: use `permit.api.tenants.create()` instead.""" tenant_data = tenant if isinstance(tenant, TenantCreate) else TenantCreate(**tenant) return await self.__tenants.create(tenant_data) @deprecated("use permit.api.tenants.update() instead") - async def update_tenant(self, tenant_key: str, tenant: Union[TenantUpdate, dict]) -> TenantRead: + async def update_tenant( + self, tenant_key: str, tenant: TenantUpdate | dict[str, Any] + ) -> TenantRead: + """Deprecated: use `permit.api.tenants.update()` instead.""" tenant_data = tenant if isinstance(tenant, TenantUpdate) else TenantUpdate(**tenant) return await self.__tenants.update(tenant_key, tenant_data) @deprecated("use permit.api.tenants.delete() instead") async def delete_tenant(self, tenant_key: str) -> None: + """Deprecated: use `permit.api.tenants.delete()` instead.""" return await self.__tenants.delete(tenant_key) @deprecated("use permit.api.roles.create() instead") - async def create_role(self, role: Union[RoleCreate, dict]) -> RoleRead: + async def create_role(self, role: RoleCreate | dict[str, Any]) -> RoleRead: + """Deprecated: use `permit.api.roles.create()` instead.""" role_data = role if isinstance(role, RoleCreate) else RoleCreate(**role) return await self.__roles.create(role_data) @deprecated("use permit.api.roles.update() instead") - async def update_role(self, role_key: str, role: Union[RoleUpdate, dict]) -> RoleRead: + async def update_role(self, role_key: str, role: RoleUpdate | dict[str, Any]) -> RoleRead: + """Deprecated: use `permit.api.roles.update()` instead.""" role_data = role if isinstance(role, RoleUpdate) else RoleUpdate(**role) return await self.__roles.update(role_key, role_data) @deprecated("use permit.api.users.assign_role() instead") - async def assign_role(self, user_key: str, role_key: str, tenant_key: str) -> RoleAssignmentRead: + async def assign_role( + self, user_key: str, role_key: str, tenant_key: str + ) -> RoleAssignmentRead: + """Deprecated: use `permit.api.users.assign_role()` instead.""" return await self.__role_assignments.assign( RoleAssignmentCreate(user=user_key, role=role_key, tenant=tenant_key) ) @deprecated("use permit.api.users.unassign_role() instead") async def unassign_role(self, user_key: str, role_key: str, tenant_key: str) -> None: + """Deprecated: use `permit.api.users.unassign_role()` instead.""" return await self.__role_assignments.unassign( RoleAssignmentRemove(user=user_key, role=role_key, tenant=tenant_key) ) @deprecated("use permit.api.roles.delete() instead") - async def delete_role(self, role_key: str): + async def delete_role(self, role_key: str) -> None: + """Deprecated: use `permit.api.roles.delete()` instead.""" return await self.__roles.delete(role_key) @deprecated("use permit.api.resources.create() instead") - async def create_resource(self, resource: Union[ResourceCreate, dict]) -> ResourceRead: - resource_data = resource if isinstance(resource, ResourceCreate) else ResourceCreate(**resource) + async def create_resource(self, resource: ResourceCreate | dict[str, Any]) -> ResourceRead: + """Deprecated: use `permit.api.resources.create()` instead.""" + resource_data = ( + resource if isinstance(resource, ResourceCreate) else ResourceCreate(**resource) + ) return await self.__resources.create(resource_data) @deprecated("use permit.api.resources.update() instead") - async def update_resource(self, resource_key: str, resource: Union[ResourceUpdate, dict]) -> ResourceRead: - resource_data = resource if isinstance(resource, ResourceUpdate) else ResourceUpdate(**resource) + async def update_resource( + self, resource_key: str, resource: ResourceUpdate | dict[str, Any] + ) -> ResourceRead: + """Deprecated: use `permit.api.resources.update()` instead.""" + resource_data = ( + resource if isinstance(resource, ResourceUpdate) else ResourceUpdate(**resource) + ) return await self.__resources.update(resource_key, resource_data) @deprecated("use permit.api.resources.delete() instead") - async def delete_resource(self, resource_key: str): + async def delete_resource(self, resource_key: str) -> None: + """Deprecated: use `permit.api.resources.delete()` instead.""" return await self.__resources.delete(resource_key) @deprecated("use permit.elements.login_as() instead") async def elements_login_as( - self, user_id: Union[str, UUID], tenant_id: Union[str, UUID] + self, user_id: str | UUID, tenant_id: str | UUID ) -> EmbeddedLoginRequestOutput: + """Deprecated: use `permit.elements.login_as()` instead.""" return await self.__elements.login_as(user_id=user_id, tenant_id=tenant_id) diff --git a/permit/api/elements.py b/permit/api/elements.py index abbf899..73181ec 100644 --- a/permit/api/elements.py +++ b/permit/api/elements.py @@ -1,75 +1,97 @@ -from typing import Optional, Union +from typing import TYPE_CHECKING from uuid import UUID -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel, Extra, Field +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel, Extra, Field else: - from pydantic.v1 import BaseModel, Extra, Field # type: ignore + from pydantic.v1 import BaseModel, Extra, Field -from ..config import PermitConfig -from ..utils.sync import SyncClass -from .base import BasePermitApi +from permit.api.base import BasePermitApi +from permit.config import PermitConfig +from permit.utils.sync import SyncClass class EmbeddedLoginRequestOutput(BaseModel): + """The API's answer to an Elements login request.""" + class Config: extra = Extra.allow - error: Optional[str] = Field( - None, + error: str | None = Field( + default=None, description="If the login request failed, this field will contain the error message", title="Error", ) - error_code: Optional[int] = Field( - None, + error_code: int | None = Field( + default=None, description="If the login request failed, this field will contain the error code", title="Error Code", ) - token: Optional[str] = Field( - None, + token: str | None = Field( + default=None, description="The auth token that lets your users login into permit elements", title="Token", ) - extra: Optional[str] = Field( - None, + extra: str | None = Field( + default=None, description="Extra data that you can pass to the login request", title="Extra", ) redirect_url: str = Field( ..., - description="The full URL to which the user should be redirected in order to complete the login process", + description="The full URL to which the user should be redirected " + "in order to complete the login process", title="Redirect Url", ) class LoginAsSchema(BaseModel): - """ - Represents the schema for the loginAs request. - """ + """Represents the schema for the loginAs request.""" user_id: str = Field(..., description="The key (or ID) of the user the element will log in as.") tenant_id: str = Field( ..., description="The key (or ID) of the active tenant for the logged in user." - + "The embedded user will only be able to access the active tenant.", + "The embedded user will only be able to access the active tenant.", ) class UserLoginAsResponse(EmbeddedLoginRequestOutput): - content: Optional[dict] = Field( + """The result of `ElementsApi.login_as()`.""" + + # Bare `dict` on purpose: pydantic v1 passes it through as is, while a parameterized + # dict would be validated as a mapping and copied. + content: dict | None = Field( # type: ignore[type-arg] None, description="Content to return in the response body for header/bearer login", ) class ElementsApi(BasePermitApi): - def __init__(self, config: PermitConfig): + """Log users into Permit Elements (embeddable UI components).""" + + def __init__(self, config: PermitConfig) -> None: super().__init__(config) self.__auth = self._build_http_client("/v2/auth") - async def login_as(self, user_id: Union[str, UUID], tenant_id: Union[str, UUID]) -> UserLoginAsResponse: + async def login_as(self, user_id: str | UUID, tenant_id: str | UUID) -> UserLoginAsResponse: + """Log a user into Permit Elements, in the context of a tenant. + + Args: + user_id: The key or ID of the user to log in as. + tenant_id: The key or ID of the tenant the user will be able to access. + + Returns: + The login ticket, including the URL that completes the login. + + Raises: + PermitApiError: If the API returns an error HTTP status code. + """ if isinstance(user_id, UUID): user_id = str(user_id) if isinstance(tenant_id, UUID): @@ -83,4 +105,4 @@ async def login_as(self, user_id: Union[str, UUID], tenant_id: Union[str, UUID]) class SyncElementsApi(ElementsApi, metaclass=SyncClass): - pass + """Blocking variant of `ElementsApi`.""" diff --git a/permit/api/encoders.py b/permit/api/encoders.py index 3f269b6..606bfc2 100644 --- a/permit/api/encoders.py +++ b/permit/api/encoders.py @@ -2,6 +2,7 @@ import dataclasses import datetime from collections import defaultdict, deque +from collections.abc import Callable from decimal import Decimal from enum import Enum from ipaddress import ( @@ -15,25 +16,37 @@ from pathlib import Path, PurePath from re import Pattern from types import GeneratorType -from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Type, Union +from typing import ( # noqa: UP035 - public alias below + TYPE_CHECKING, + Any, + Dict, + Literal, + Set, + Union, +) from uuid import UUID -from permit import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel + from pydantic.v1.color import Color + from pydantic.v1.networks import AnyUrl, NameEmail + from pydantic.v1.types import SecretBytes, SecretStr +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel from pydantic.color import Color from pydantic.networks import AnyUrl, NameEmail from pydantic.types import SecretBytes, SecretStr - else: - from pydantic.v1 import BaseModel # type: ignore[assignment] - from pydantic.v1.color import Color # type: ignore[assignment] - from pydantic.v1.networks import AnyUrl, NameEmail # type: ignore[assignment] - from pydantic.v1.types import SecretBytes, SecretStr # type: ignore[assignment] + from pydantic.v1 import BaseModel + from pydantic.v1.color import Color + from pydantic.v1.networks import AnyUrl, NameEmail + from pydantic.v1.types import SecretBytes, SecretStr -def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any: # noqa: ARG001 +def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kwargs: Any) -> Any: # noqa: ARG001 - `mode` is absorbed on purpose """Serialize a model to a dict. Both pydantic majors take the same path: the SDK's models are always v1 @@ -49,16 +62,16 @@ def _model_dump(model: BaseModel, mode: Literal["json", "python"] = "json", **kw return model.dict(**kwargs) -def isoformat(o: Union[datetime.date, datetime.time]) -> str: +def isoformat(o: datetime.date | datetime.time) -> str: + """Encode a date or time in ISO 8601 format.""" return o.isoformat() -def decimal_encoder(dec_value: Decimal) -> Union[int, float]: - """ - Encodes a Decimal as int of there's no exponent, otherwise float +def decimal_encoder(dec_value: Decimal) -> int | float: + """Encodes a Decimal as int if there's no exponent, otherwise float. This is useful when we use ConstrainedDecimal to represent Numeric(x,0) - where a integer (but not int typed) is used. Encoding this as a float + where an integer (but not int typed) is used. Encoding this as a float results in failed round-tripping between encode and parse. Our Id type is a prime example of this. @@ -67,15 +80,19 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]: >>> decimal_encoder(Decimal("1")) 1 + + >>> decimal_encoder(Decimal("NaN")) + nan """ - if dec_value.as_tuple().exponent >= 0: # type: ignore[operator] + exponent = dec_value.as_tuple().exponent + if isinstance(exponent, int) and exponent >= 0: return int(dec_value) - else: - return float(dec_value) + return float(dec_value) -IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]] -ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { +# Public alias; runtime object kept identical (a `typing` generic, not a builtin one). +IncEx = Union[Set[int], Set[str], Dict[int, Any], Dict[str, Any]] # noqa: UP006, UP007 +ENCODERS_BY_TYPE: dict[type[Any], Callable[[Any], Any]] = { bytes: lambda o: o.decode(), Color: str, datetime.date: isoformat, @@ -105,9 +122,10 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]: def generate_encoders_by_class_tuples( - type_encoder_map: Dict[Any, Callable[[Any], Any]], -) -> Dict[Callable[[Any], Any], Tuple[Any, ...]]: - encoders_by_class_tuples: Dict[Callable[[Any], Any], Tuple[Any, ...]] = defaultdict(tuple) + type_encoder_map: dict[Any, Callable[[Any], Any]], +) -> dict[Callable[[Any], Any], tuple[Any, ...]]: + """Invert a type -> encoder map into encoder -> tuple of types, for `isinstance` checks.""" + encoders_by_class_tuples: dict[Callable[[Any], Any], tuple[Any, ...]] = defaultdict(tuple) for type_, encoder in type_encoder_map.items(): encoders_by_class_tuples[encoder] += (type_,) return encoders_by_class_tuples @@ -119,17 +137,16 @@ def generate_encoders_by_class_tuples( def jsonable_encoder( obj: Any, *, - include: Optional[IncEx] = None, - exclude: Optional[IncEx] = None, + include: IncEx | None = None, + exclude: IncEx | None = None, by_alias: bool = True, exclude_unset: bool = False, exclude_defaults: bool = False, exclude_none: bool = False, - custom_encoder: Optional[Dict[Any, Callable[[Any], Any]]] = None, + custom_encoder: dict[Any, Callable[[Any], Any]] | None = None, sqlalchemy_safe: bool = True, ) -> Any: - """ - Convert any object to something that can be encoded in JSON. + """Convert any object to something that can be encoded in JSON. This is used internally by FastAPI to make sure anything you return can be encoded as JSON before it is sent to the client. @@ -144,16 +161,15 @@ def jsonable_encoder( if custom_encoder: if type(obj) in custom_encoder: return custom_encoder[type(obj)](obj) - else: - for encoder_type, encoder_instance in custom_encoder.items(): - if isinstance(obj, encoder_type): - return encoder_instance(obj) + for encoder_type, encoder_instance in custom_encoder.items(): + if isinstance(obj, encoder_type): + return encoder_instance(obj) if include is not None and not isinstance(include, (set, dict)): - include = set(include) # type: ignore[unreachable] + include = set(include) # type: ignore[unreachable] # defensive, as upstream if exclude is not None and not isinstance(exclude, (set, dict)): - exclude = set(exclude) # type: ignore[unreachable] + exclude = set(exclude) # type: ignore[unreachable] # defensive, as upstream if isinstance(obj, BaseModel): - encoders = getattr(obj.__config__, "json_encoders", {}) # type: ignore[attr-defined] + encoders = getattr(obj.__config__, "json_encoders", {}) if custom_encoder: encoders.update(custom_encoder) @@ -173,12 +189,12 @@ def jsonable_encoder( obj_dict, exclude_none=exclude_none, exclude_defaults=exclude_defaults, - # TODO: remove when deprecating Pydantic v1 + # Only needed while pydantic v1 is supported. custom_encoder=encoders, sqlalchemy_safe=sqlalchemy_safe, ) - if dataclasses.is_dataclass(obj): - obj_dict = dataclasses.asdict(obj) # type: ignore[call-overload] + if dataclasses.is_dataclass(obj) and not isinstance(obj, type): + obj_dict = dataclasses.asdict(obj) return jsonable_encoder( obj_dict, include=include, @@ -228,22 +244,20 @@ def jsonable_encoder( encoded_dict[encoded_key] = encoded_value return encoded_dict if isinstance(obj, (list, set, frozenset, GeneratorType, tuple, deque)): - encoded_list = [] - for item in obj: - encoded_list.append( - jsonable_encoder( - item, - include=include, - exclude=exclude, - by_alias=by_alias, - exclude_unset=exclude_unset, - exclude_defaults=exclude_defaults, - exclude_none=exclude_none, - custom_encoder=custom_encoder, - sqlalchemy_safe=sqlalchemy_safe, - ) + return [ + jsonable_encoder( + item, + include=include, + exclude=exclude, + by_alias=by_alias, + exclude_unset=exclude_unset, + exclude_defaults=exclude_defaults, + exclude_none=exclude_none, + custom_encoder=custom_encoder, + sqlalchemy_safe=sqlalchemy_safe, ) - return encoded_list + for item in obj + ] if type(obj) in ENCODERS_BY_TYPE: return ENCODERS_BY_TYPE[type(obj)](obj) @@ -253,8 +267,8 @@ def jsonable_encoder( try: data = dict(obj) - except Exception as e: # noqa: BLE001 - errors: List[Exception] = [] + except Exception as e: # noqa: BLE001 - any failure falls back to vars(), as upstream + errors: list[Exception] = [] errors.append(e) try: data = vars(obj) diff --git a/permit/api/environments.py b/permit/api/environments.py index 29c9052..ed807d7 100644 --- a/permit/api/environments.py +++ b/permit/api/environments.py @@ -1,19 +1,21 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from ..config import PermitConfig -from .base import ( +from permit.api.base import ( BasePermitApi, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( APIKeyRead, EnvironmentCopy, EnvironmentCreate, @@ -21,33 +23,40 @@ EnvironmentStats, EnvironmentUpdate, ) +from permit.config import PermitConfig class EnvironmentsApi(BasePermitApi): - def __init__(self, config: PermitConfig): + """Manage the environments of a project.""" + + def __init__(self, config: PermitConfig) -> None: super().__init__(config) self.__environments = self._build_http_client("") - @validate_arguments # type: ignore[operator] - async def list(self, project_key: str, page: int = 1, per_page: int = 100) -> List[EnvironmentRead]: - """ - Retrieves a list of environments. + @validate_arguments + async def list( + self, project_key: str, page: int = 1, per_page: int = 100 + ) -> list[EnvironmentRead]: + """Retrieves a list of environments. Args: - params: The filters and pagination options. + project_key: The key of the project whose environments to list. + page: The page number to fetch (default: 1). + per_page: How many items to fetch per page (default: 100). Returns: an array of EnvironmentRead objects representing the listed environments. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self.__environments.get( f"/v2/projects/{project_key}/envs", - model=List[EnvironmentRead], + model=list[EnvironmentRead], params=pagination_params(page, per_page), ) @@ -56,10 +65,9 @@ async def _get(self, project_key: str, environment_key: str) -> EnvironmentRead: f"/v2/projects/{project_key}/envs/{environment_key}", model=EnvironmentRead ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, project_key: str, environment_key: str) -> EnvironmentRead: - """ - Gets an environment by project key and environment key. + """Gets an environment by project key and environment key. Args: project_key: The project key. @@ -70,16 +78,17 @@ async def get(self, project_key: str, environment_key: str) -> EnvironmentRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self._get(project_key, environment_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, project_key: str, environment_key: str) -> EnvironmentRead: - """ - Gets an environment by project key and environment key. + """Gets an environment by project key and environment key. + Alias for the get method. Args: @@ -91,16 +100,17 @@ async def get_by_key(self, project_key: str, environment_key: str) -> Environmen Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self._get(project_key, environment_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, project_id: str, environment_id: str) -> EnvironmentRead: - """ - Gets an environment by project ID and environment ID. + """Gets an environment by project ID and environment ID. + Alias for the get method. Args: @@ -112,16 +122,16 @@ async def get_by_id(self, project_id: str, environment_id: str) -> EnvironmentRe Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self._get(project_id, environment_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_stats(self, project_key: str, environment_key: str) -> EnvironmentStats: - """ - Retrieves statistics and metadata for an environment. + """Retrieves statistics and metadata for an environment. Args: project_key: The project key. @@ -132,7 +142,8 @@ async def get_stats(self, project_key: str, environment_key: str) -> Environment Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) @@ -141,10 +152,9 @@ async def get_stats(self, project_key: str, environment_key: str) -> Environment model=EnvironmentStats, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_api_key(self, project_key: str, environment_key: str) -> APIKeyRead: - """ - Retrieves the API key that grants access for an environment. + """Retrieves the API key that grants access for an environment. Args: project_key: The project key. @@ -155,7 +165,8 @@ async def get_api_key(self, project_key: str, environment_key: str) -> APIKeyRea Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) @@ -164,10 +175,11 @@ async def get_api_key(self, project_key: str, environment_key: str) -> APIKeyRea model=APIKeyRead, ) - @validate_arguments # type: ignore[operator] - async def create(self, project_key: str, environment_data: EnvironmentCreate) -> EnvironmentRead: - """ - Creates a new environment. + @validate_arguments + async def create( + self, project_key: str, environment_data: EnvironmentCreate + ) -> EnvironmentRead: + """Creates a new environment. Args: project_key: The project key. @@ -178,7 +190,8 @@ async def create(self, project_key: str, environment_data: EnvironmentCreate) -> Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) @@ -188,15 +201,14 @@ async def create(self, project_key: str, environment_data: EnvironmentCreate) -> json=environment_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update( self, project_key: str, environment_key: str, environment_data: EnvironmentUpdate, ) -> EnvironmentRead: - """ - Updates an existing environment. + """Updates an existing environment. Args: project_key: The project key. @@ -208,7 +220,8 @@ async def update( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) @@ -218,10 +231,11 @@ async def update( json=environment_data, ) - @validate_arguments # type: ignore[operator] - async def copy(self, project_key: str, environment_key: str, copy_params: EnvironmentCopy) -> EnvironmentRead: - """ - Clones data from a source specified environment into a different target environment in the same project. + @validate_arguments + async def copy( + self, project_key: str, environment_key: str, copy_params: EnvironmentCopy + ) -> EnvironmentRead: + """Clones data from a source environment into another environment of the same project. Args: project_key: The project key. @@ -233,7 +247,8 @@ async def copy(self, project_key: str, environment_key: str, copy_params: Enviro Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) @@ -243,10 +258,9 @@ async def copy(self, project_key: str, environment_key: str, copy_params: Enviro json=copy_params, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, project_key: str, environment_key: str) -> None: - """ - Deletes an environment. + """Deletes an environment. Args: project_key: The project key. @@ -254,8 +268,11 @@ async def delete(self, project_key: str, environment_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) - return await self.__environments.delete(f"/v2/projects/{project_key}/envs/{environment_key}") + return await self.__environments.delete( + f"/v2/projects/{project_key}/envs/{environment_key}" + ) diff --git a/permit/api/models.py b/permit/api/models.py index 414624a..ebab829 100644 --- a/permit/api/models.py +++ b/permit/api/models.py @@ -4,17 +4,21 @@ from __future__ import annotations +import typing as _typing from datetime import datetime from enum import Enum from typing import Any, Dict, List, Literal, Optional, Union from uuid import UUID -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if _typing.TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr +elif PYDANTIC_VERSION < (2, 0): from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr else: - from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr # type: ignore + from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr class APIHistoryEventFullRead(BaseModel): diff --git a/permit/api/projects.py b/permit/api/projects.py index 8dad80d..28c3634 100644 --- a/permit/api/projects.py +++ b/permit/api/projects.py @@ -1,30 +1,34 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from ..config import PermitConfig -from .base import ( +from permit.api.base import ( BasePermitApi, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ProjectCreate, ProjectRead, ProjectUpdate +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ProjectCreate, ProjectRead, ProjectUpdate +from permit.config import PermitConfig class ProjectsApi(BasePermitApi): - def __init__(self, config: PermitConfig): + """Manage the projects of an organization.""" + + def __init__(self, config: PermitConfig) -> None: super().__init__(config) self.__projects = self._build_http_client("/v2/projects") - @validate_arguments # type: ignore[operator] - async def list(self, page: int = 1, per_page: int = 100) -> List[ProjectRead]: - """ - Retrieves a list of projects. + @validate_arguments + async def list(self, page: int = 1, per_page: int = 100) -> list[ProjectRead]: + """Retrieves a list of projects. Args: page: The page number to fetch (default: 1). @@ -35,19 +39,21 @@ async def list(self, page: int = 1, per_page: int = 100) -> List[ProjectRead]: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) - return await self.__projects.get("", model=List[ProjectRead], params=pagination_params(page, per_page)) + return await self.__projects.get( + "", model=list[ProjectRead], params=pagination_params(page, per_page) + ) async def _get(self, project_key: str) -> ProjectRead: return await self.__projects.get(f"/{project_key}", model=ProjectRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, project_key: str) -> ProjectRead: - """ - Retrieves a project by its key. + """Retrieves a project by its key. Args: project_key: The key of the project. @@ -57,16 +63,17 @@ async def get(self, project_key: str) -> ProjectRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self._get(project_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, project_key: str) -> ProjectRead: - """ - Retrieves a project by its key. + """Retrieves a project by its key. + Alias for the get method. Args: @@ -77,16 +84,17 @@ async def get_by_key(self, project_key: str) -> ProjectRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self._get(project_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, project_id: str) -> ProjectRead: - """ - Retrieves a project by its ID. + """Retrieves a project by its ID. + Alias for the get method. Args: @@ -97,16 +105,16 @@ async def get_by_id(self, project_id: str) -> ProjectRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self._get(project_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, project_data: ProjectCreate) -> ProjectRead: - """ - Creates a new project. + """Creates a new project. Args: project_data: The data for the new project. @@ -116,16 +124,16 @@ async def create(self, project_data: ProjectCreate) -> ProjectRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ORGANIZATION_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self.__projects.post("", model=ProjectRead, json=project_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update(self, project_key: str, project_data: ProjectUpdate) -> ProjectRead: - """ - Updates a project. + """Updates a project. Args: project_key: The key of the project. @@ -136,16 +144,16 @@ async def update(self, project_key: str, project_data: ProjectUpdate) -> Project Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) return await self.__projects.patch(f"/{project_key}", model=ProjectRead, json=project_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, project_key: str) -> None: - """ - Deletes a project. + """Deletes a project. Args: project_key: The key of the project to delete. @@ -155,7 +163,8 @@ async def delete(self, project_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) diff --git a/permit/api/relationship_tuples.py b/permit/api/relationship_tuples.py index 1ad2f90..2565921 100644 --- a/permit/api/relationship_tuples.py +++ b/permit/api/relationship_tuples.py @@ -1,19 +1,24 @@ -from typing import List, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( RelationshipTupleCreate, RelationshipTupleCreateBulkOperation, RelationshipTupleCreateBulkOperationResult, @@ -25,27 +30,27 @@ class RelationshipTuplesApi(BasePermitApi): + """Manage relationship tuples between resource instances (ReBAC).""" + @property def __relationship_tuples(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/relationship_tuples", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/relationship_tuples" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/relationship_tuples" + ) - @validate_arguments # type: ignore[operator] - async def list( + @validate_arguments + async def list( # noqa: PLR0917 - public signature; callers may pass these positionally self, page: int = 1, per_page: int = 100, - subject_key: Optional[str] = None, - relation_key: Optional[str] = None, - object_key: Optional[str] = None, - tenant_key: Optional[str] = None, - ) -> List[RelationshipTupleRead]: - """ - Retrieves a list of relationship tuples based on the specified filters. + subject_key: str | None = None, + relation_key: str | None = None, + object_key: str | None = None, + tenant_key: str | None = None, + ) -> list[RelationshipTupleRead]: + """Retrieves a list of relationship tuples based on the specified filters. Args: page: The page number to fetch (default: 1). @@ -60,7 +65,8 @@ async def list( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -77,15 +83,16 @@ async def list( return await self.__relationship_tuples.get( "", - model=List[RelationshipTupleRead], + model=list[RelationshipTupleRead], params=params, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, tuple_data: RelationshipTupleCreate) -> RelationshipTupleRead: - """ - Creates a new relationship tuple, that states that a relationship (of type: relation) - exists between two resource instances: the subject and the object. + """Creates a new relationship tuple. + + The tuple states that a relationship (of type: relation) exists between two + resource instances: the subject and the object. Args: tuple_data: The relationship tuple to create. @@ -95,32 +102,36 @@ async def create(self, tuple_data: RelationshipTupleCreate) -> RelationshipTuple Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__relationship_tuples.post("", model=RelationshipTupleRead, json=tuple_data) + return await self.__relationship_tuples.post( + "", model=RelationshipTupleRead, json=tuple_data + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, tuple_data: RelationshipTupleDelete) -> None: - """ - Removes a relationship tuple. + """Removes a relationship tuple. Args: tuple_data: The relationship tuple to delete. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__relationship_tuples.delete("", json=tuple_data) - @validate_arguments # type: ignore[operator] - async def bulk_create(self, tuples: List[RelationshipTupleCreate]) -> RelationshipTupleCreateBulkOperationResult: - """ - Creates multiple relationship tuples at once using the provided tuple data. + @validate_arguments + async def bulk_create( + self, tuples: builtins.list[RelationshipTupleCreate] + ) -> RelationshipTupleCreateBulkOperationResult: + """Creates multiple relationship tuples at once using the provided tuple data. Args: tuples: The relationship tuples to create. @@ -140,7 +151,8 @@ async def bulk_create(self, tuples: List[RelationshipTupleCreate]) -> Relationsh Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -150,10 +162,11 @@ async def bulk_create(self, tuples: List[RelationshipTupleCreate]) -> Relationsh json=RelationshipTupleCreateBulkOperation(operations=tuples), ) - @validate_arguments # type: ignore[operator] - async def bulk_delete(self, tuples: List[RelationshipTupleDelete]) -> RelationshipTupleDeleteBulkOperationResult: - """ - Deletes multiple relationship tuples at once using the provided tuple data. + @validate_arguments + async def bulk_delete( + self, tuples: builtins.list[RelationshipTupleDelete] + ) -> RelationshipTupleDeleteBulkOperationResult: + """Deletes multiple relationship tuples at once using the provided tuple data. Args: tuples: The relationship tuples to delete. @@ -169,7 +182,8 @@ async def bulk_delete(self, tuples: List[RelationshipTupleDelete]) -> Relationsh Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/resource_action_groups.py b/permit/api/resource_action_groups.py index 743963e..f89c967 100644 --- a/permit/api/resource_action_groups.py +++ b/permit/api/resource_action_groups.py @@ -1,19 +1,22 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ResourceActionGroupCreate, ResourceActionGroupRead, ResourceActionGroupUpdate, @@ -21,16 +24,19 @@ class ResourceActionGroupsApi(BasePermitApi): + """Manage the action groups of a resource.""" + @property def __action_groups(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/resources" ) - @validate_arguments # type: ignore[operator] - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceActionGroupRead]: - """ - Retrieves a list of action groups. + @validate_arguments + async def list( + self, resource_key: str, page: int = 1, per_page: int = 100 + ) -> list[ResourceActionGroupRead]: + """Retrieves a list of action groups. Args: resource_key: The key of the resource to filter on. @@ -42,13 +48,14 @@ async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> L Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__action_groups.get( f"/{resource_key}/action_groups", - model=List[ResourceActionGroupRead], + model=list[ResourceActionGroupRead], params=pagination_params(page, per_page), ) @@ -58,10 +65,9 @@ async def _get(self, resource_key: str, group_key: str) -> ResourceActionGroupRe model=ResourceActionGroupRead, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, resource_key: str, group_key: str) -> ResourceActionGroupRead: - """ - Retrieves a action group by its key. + """Retrieves a action group by its key. Args: resource_key: The key of the resource the action group belongs to. @@ -72,16 +78,17 @@ async def get(self, resource_key: str, group_key: str) -> ResourceActionGroupRea Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, group_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, resource_key: str, group_key: str) -> ResourceActionGroupRead: - """ - Retrieves a action group by its key. + """Retrieves a action group by its key. + Alias for the get method. Args: @@ -93,16 +100,17 @@ async def get_by_key(self, resource_key: str, group_key: str) -> ResourceActionG Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, group_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, resource_id: str, group_id: str) -> ResourceActionGroupRead: - """ - Retrieves a action group by its ID. + """Retrieves a action group by its ID. + Alias for the get method. Args: @@ -114,16 +122,18 @@ async def get_by_id(self, resource_id: str, group_id: str) -> ResourceActionGrou Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_id, group_id) - @validate_arguments # type: ignore[operator] - async def create(self, resource_key: str, group_data: ResourceActionGroupCreate) -> ResourceActionGroupRead: - """ - Creates a new action group. + @validate_arguments + async def create( + self, resource_key: str, group_data: ResourceActionGroupCreate + ) -> ResourceActionGroupRead: + """Creates a new action group. Args: resource_key: The key of the resource under which the action group should be created. @@ -134,7 +144,8 @@ async def create(self, resource_key: str, group_data: ResourceActionGroupCreate) Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -144,12 +155,11 @@ async def create(self, resource_key: str, group_data: ResourceActionGroupCreate) json=group_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update( self, resource_key: str, group_key: str, group_data: ResourceActionGroupUpdate ) -> ResourceActionGroupRead: - """ - Updates an action group. + """Updates an action group. Args: resource_key: The key of the resource the action group belongs to. @@ -161,7 +171,8 @@ async def update( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -171,10 +182,9 @@ async def update( json=group_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, resource_key: str, group_key: str) -> None: - """ - Deletes a action group. + """Deletes a action group. Args: resource_key: The key of the resource the action group belongs to. @@ -182,7 +192,8 @@ async def delete(self, resource_key: str, group_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/resource_actions.py b/permit/api/resource_actions.py index 33941c5..545b558 100644 --- a/permit/api/resource_actions.py +++ b/permit/api/resource_actions.py @@ -1,32 +1,38 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ResourceActionCreate, ResourceActionRead, ResourceActionUpdate +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ResourceActionCreate, ResourceActionRead, ResourceActionUpdate class ResourceActionsApi(BasePermitApi): + """Manage the actions of a resource.""" + @property def __actions(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/resources" ) - @validate_arguments # type: ignore[operator] - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceActionRead]: - """ - Retrieves a list of actions. + @validate_arguments + async def list( + self, resource_key: str, page: int = 1, per_page: int = 100 + ) -> list[ResourceActionRead]: + """Retrieves a list of actions. Args: resource_key: The key of the resource to filter on. @@ -38,23 +44,25 @@ async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> L Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__actions.get( f"/{resource_key}/actions", - model=List[ResourceActionRead], + model=list[ResourceActionRead], params=pagination_params(page, per_page), ) async def _get(self, resource_key: str, action_key: str) -> ResourceActionRead: - return await self.__actions.get(f"/{resource_key}/actions/{action_key}", model=ResourceActionRead) + return await self.__actions.get( + f"/{resource_key}/actions/{action_key}", model=ResourceActionRead + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, resource_key: str, action_key: str) -> ResourceActionRead: - """ - Retrieves a action by its key. + """Retrieves a action by its key. Args: resource_key: The key of the resource the action belongs to. @@ -65,16 +73,17 @@ async def get(self, resource_key: str, action_key: str) -> ResourceActionRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, action_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, resource_key: str, action_key: str) -> ResourceActionRead: - """ - Retrieves a action by its key. + """Retrieves a action by its key. + Alias for the get method. Args: @@ -86,16 +95,17 @@ async def get_by_key(self, resource_key: str, action_key: str) -> ResourceAction Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, action_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, resource_id: str, action_id: str) -> ResourceActionRead: - """ - Retrieves a action by its ID. + """Retrieves a action by its ID. + Alias for the get method. Args: @@ -107,16 +117,18 @@ async def get_by_id(self, resource_id: str, action_id: str) -> ResourceActionRea Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_id, action_id) - @validate_arguments # type: ignore[operator] - async def create(self, resource_key: str, action_data: ResourceActionCreate) -> ResourceActionRead: - """ - Creates a new action. + @validate_arguments + async def create( + self, resource_key: str, action_data: ResourceActionCreate + ) -> ResourceActionRead: + """Creates a new action. Args: resource_key: The key of the resource under which the action should be created. @@ -127,7 +139,8 @@ async def create(self, resource_key: str, action_data: ResourceActionCreate) -> Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -137,10 +150,11 @@ async def create(self, resource_key: str, action_data: ResourceActionCreate) -> json=action_data, ) - @validate_arguments # type: ignore[operator] - async def update(self, resource_key: str, action_key: str, action_data: ResourceActionUpdate) -> ResourceActionRead: - """ - Updates a action. + @validate_arguments + async def update( + self, resource_key: str, action_key: str, action_data: ResourceActionUpdate + ) -> ResourceActionRead: + """Updates a action. Args: resource_key: The key of the resource the action belongs to. @@ -152,7 +166,8 @@ async def update(self, resource_key: str, action_key: str, action_data: Resource Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -162,10 +177,9 @@ async def update(self, resource_key: str, action_key: str, action_data: Resource json=action_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, resource_key: str, action_key: str) -> None: - """ - Deletes a action. + """Deletes a action. Args: resource_key: The key of the resource the action belongs to. @@ -173,7 +187,8 @@ async def delete(self, resource_key: str, action_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/resource_attributes.py b/permit/api/resource_attributes.py index 0833bc1..024672c 100644 --- a/permit/api/resource_attributes.py +++ b/permit/api/resource_attributes.py @@ -1,19 +1,22 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ResourceAttributeCreate, ResourceAttributeRead, ResourceAttributeUpdate, @@ -21,16 +24,19 @@ class ResourceAttributesApi(BasePermitApi): + """Manage the attributes of a resource.""" + @property def __attributes(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/resources" ) - @validate_arguments # type: ignore[operator] - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceAttributeRead]: - """ - Retrieves a list of attributes. + @validate_arguments + async def list( + self, resource_key: str, page: int = 1, per_page: int = 100 + ) -> list[ResourceAttributeRead]: + """Retrieves a list of attributes. Args: resource_key: The key of the resource to filter on. @@ -42,23 +48,25 @@ async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> L Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__attributes.get( f"/{resource_key}/attributes", - model=List[ResourceAttributeRead], + model=list[ResourceAttributeRead], params=pagination_params(page, per_page), ) async def _get(self, resource_key: str, attribute_key: str) -> ResourceAttributeRead: - return await self.__attributes.get(f"/{resource_key}/attributes/{attribute_key}", model=ResourceAttributeRead) + return await self.__attributes.get( + f"/{resource_key}/attributes/{attribute_key}", model=ResourceAttributeRead + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, resource_key: str, attribute_key: str) -> ResourceAttributeRead: - """ - Retrieves a attribute by its key. + """Retrieves a attribute by its key. Args: resource_key: The key of the resource the attribute belongs to. @@ -69,16 +77,17 @@ async def get(self, resource_key: str, attribute_key: str) -> ResourceAttributeR Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, attribute_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, resource_key: str, attribute_key: str) -> ResourceAttributeRead: - """ - Retrieves a attribute by its key. + """Retrieves a attribute by its key. + Alias for the get method. Args: @@ -90,16 +99,17 @@ async def get_by_key(self, resource_key: str, attribute_key: str) -> ResourceAtt Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, attribute_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, resource_id: str, attribute_id: str) -> ResourceAttributeRead: - """ - Retrieves a attribute by its ID. + """Retrieves a attribute by its ID. + Alias for the get method. Args: @@ -111,16 +121,18 @@ async def get_by_id(self, resource_id: str, attribute_id: str) -> ResourceAttrib Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_id, attribute_id) - @validate_arguments # type: ignore[operator] - async def create(self, resource_key: str, attribute_data: ResourceAttributeCreate) -> ResourceAttributeRead: - """ - Creates a new attribute. + @validate_arguments + async def create( + self, resource_key: str, attribute_data: ResourceAttributeCreate + ) -> ResourceAttributeRead: + """Creates a new attribute. Args: resource_key: The key of the resource under which the attribute should be created. @@ -131,7 +143,8 @@ async def create(self, resource_key: str, attribute_data: ResourceAttributeCreat Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -141,15 +154,14 @@ async def create(self, resource_key: str, attribute_data: ResourceAttributeCreat json=attribute_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update( self, resource_key: str, attribute_key: str, attribute_data: ResourceAttributeUpdate, ) -> ResourceAttributeRead: - """ - Updates a attribute. + """Updates a attribute. Args: resource_key: The key of the resource the attribute belongs to. @@ -161,7 +173,8 @@ async def update( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -171,10 +184,9 @@ async def update( json=attribute_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, resource_key: str, attribute_key: str) -> None: - """ - Deletes a attribute. + """Deletes a attribute. Args: resource_key: The key of the resource the attribute belongs to. @@ -182,7 +194,8 @@ async def delete(self, resource_key: str, attribute_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/resource_instances.py b/permit/api/resource_instances.py index 1e45256..9ee9a83 100644 --- a/permit/api/resource_instances.py +++ b/permit/api/resource_instances.py @@ -1,19 +1,24 @@ -from typing import List, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ResourceInstanceCreate, ResourceInstanceCreateBulkOperation, ResourceInstanceCreateBulkOperationResult, @@ -25,47 +30,51 @@ class ResourceInstancesApi(BasePermitApi): + """Manage resource instances.""" + @property def __resource_instances(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/resource_instances", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/resource_instances" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/resource_instances" + ) @property def __bulk_operations(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/bulk/resource_instances", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/bulk/resource_instances" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/bulk/resource_instances" + ) - @validate_arguments # type: ignore[operator] - async def list( + @validate_arguments + async def list( # noqa: PLR0917 - public signature; callers may pass these positionally self, page: int = 1, per_page: int = 100, - tenant_key: Optional[str] = None, - resource_key: Optional[str] = None, - detailed_key: Optional[bool] = None, - search_key: Optional[str] = None, - ) -> List[ResourceInstanceRead]: - """ - Retrieves a list of resource instances. + tenant_key: str | None = None, + resource_key: str | None = None, + detailed_key: bool | None = None, # noqa: FBT001 - public signature, positional callers + search_key: str | None = None, + ) -> list[ResourceInstanceRead]: + """Retrieves a list of resource instances. Args: page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). + tenant_key: Only return instances that belong to this tenant. + resource_key: Only return instances of this resource type. + detailed_key: Whether to return detailed instances. + search_key: Only return instances matching this search string. Returns: an array of resource instances. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -82,17 +91,16 @@ async def list( return await self.__resource_instances.get( "", - model=List[ResourceInstanceRead], + model=list[ResourceInstanceRead], params=params, ) async def _get(self, instance_key: str) -> ResourceInstanceRead: return await self.__resource_instances.get(f"/{instance_key}", model=ResourceInstanceRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, instance_key: str) -> ResourceInstanceRead: - """ - Retrieves a resource instance by its identity. + """Retrieves a resource instance by its identity. Args: instance_key: The resource instance identity. Either `resource_type:instance_key` @@ -104,16 +112,17 @@ async def get(self, instance_key: str) -> ResourceInstanceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(instance_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, instance_key: str) -> ResourceInstanceRead: - """ - Retrieves a resource instance by its identity. + """Retrieves a resource instance by its identity. + Alias for the get method. Args: @@ -126,16 +135,17 @@ async def get_by_key(self, instance_key: str) -> ResourceInstanceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(instance_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, instance_id: str) -> ResourceInstanceRead: - """ - Retrieves a resource instance by its ID. + """Retrieves a resource instance by its ID. + Alias for the get method. Args: @@ -146,16 +156,16 @@ async def get_by_id(self, instance_id: str) -> ResourceInstanceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(instance_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, instance_data: ResourceInstanceCreate) -> ResourceInstanceRead: - """ - Creates a new resource instance. + """Creates a new resource instance. Args: instance_data: The data for the new resource instance. @@ -165,16 +175,20 @@ async def create(self, instance_data: ResourceInstanceCreate) -> ResourceInstanc Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__resource_instances.post("", model=ResourceInstanceRead, json=instance_data) + return await self.__resource_instances.post( + "", model=ResourceInstanceRead, json=instance_data + ) - @validate_arguments # type: ignore[operator] - async def update(self, instance_key: str, instance_data: ResourceInstanceUpdate) -> ResourceInstanceRead: - """ - Updates a resource instance. + @validate_arguments + async def update( + self, instance_key: str, instance_data: ResourceInstanceUpdate + ) -> ResourceInstanceRead: + """Updates a resource instance. Args: instance_key: The resource instance identity. Either `resource_type:instance_key` @@ -187,7 +201,8 @@ async def update(self, instance_key: str, instance_data: ResourceInstanceUpdate) Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -197,13 +212,13 @@ async def update(self, instance_key: str, instance_data: ResourceInstanceUpdate) json=instance_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, instance_key: str) -> None: - """ - Deletes a resource instance. + """Deletes a resource instance. Args: - instance_key: The identity of the resource instance to delete. Either `resource_type:instance_key` + instance_key: The identity of the resource instance to delete. Either + `resource_type:instance_key` (like Repository:react) or the resource instance uuid. A bare instance key is rejected by the API with a 422. @@ -212,18 +227,18 @@ async def delete(self, instance_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__resource_instances.delete(f"/{instance_key}") - @validate_arguments # type: ignore[operator] + @validate_arguments async def bulk_replace( - self, resource_instances: List[ResourceInstanceCreate] + self, resource_instances: builtins.list[ResourceInstanceCreate] ) -> ResourceInstanceCreateBulkOperationResult: - """ - Creates (and if need replaces) resource instances in bulk. + """Creates (and if need replaces) resource instances in bulk. If the resource instance exists - replaces it. Otherwise creates previously non-existing resource instances. @@ -236,7 +251,8 @@ async def bulk_replace( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -246,22 +262,25 @@ async def bulk_replace( json=ResourceInstanceCreateBulkOperation(operations=resource_instances), ) - @validate_arguments # type: ignore[operator] - async def bulk_delete(self, resource_instances: List[str]) -> ResourceInstanceDeleteBulkOperationResult: - """ - Deletes resource instances in bulk. + @validate_arguments + async def bulk_delete( + self, resource_instances: builtins.list[str] + ) -> ResourceInstanceDeleteBulkOperationResult: + """Deletes resource instances in bulk. Args: resource_instances: The resource instance identities to delete. - Each identity can be either `resource_type:instance_key` (like Repository:react) or the resource instance uuid. + Each identity can be either `resource_type:instance_key` (like Repository:react) or the + resource instance uuid. Returns: the bulk delete report. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ # noqa: E501 + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__bulk_operations.delete( diff --git a/permit/api/resource_relations.py b/permit/api/resource_relations.py index 6f414fd..df02862 100644 --- a/permit/api/resource_relations.py +++ b/permit/api/resource_relations.py @@ -1,30 +1,38 @@ -from ..utils.pydantic_version import PYDANTIC_VERSION +from typing import TYPE_CHECKING -if PYDANTIC_VERSION < (2, 0): +from permit.utils.pydantic_version import PYDANTIC_VERSION + +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import PaginatedResultRelationRead, RelationCreate, RelationRead +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import PaginatedResultRelationRead, RelationCreate, RelationRead class ResourceRelationsApi(BasePermitApi): + """Manage the relations between resources (ReBAC).""" + @property def __relations(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/resources" ) - @validate_arguments # type: ignore[operator] - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> PaginatedResultRelationRead: - """ - Retrieves a list of outgoing relations originating in a specific (object) resource. + @validate_arguments + async def list( + self, resource_key: str, page: int = 1, per_page: int = 100 + ) -> PaginatedResultRelationRead: + """Retrieves a list of outgoing relations originating in a specific (object) resource. Args: resource_key: The key of the resource to filter on. @@ -37,7 +45,8 @@ async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> P Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -48,12 +57,13 @@ async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> P ) async def _get(self, resource_key: str, relation_key: str) -> RelationRead: - return await self.__relations.get(f"/{resource_key}/relations/{relation_key}", model=RelationRead) + return await self.__relations.get( + f"/{resource_key}/relations/{relation_key}", model=RelationRead + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, resource_key: str, relation_key: str) -> RelationRead: - """ - Retrieves a relation by its key. + """Retrieves a relation by its key. Args: resource_key: The key of the resource the relation belongs to. @@ -64,17 +74,17 @@ async def get(self, resource_key: str, relation_key: str) -> RelationRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, relation_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, resource_key: str, relation_key: str) -> RelationRead: - """ - Retrieves a relation by its key. + """Retrieves a relation by its key. + Alias for the get method. Args: @@ -86,16 +96,17 @@ async def get_by_key(self, resource_key: str, relation_key: str) -> RelationRead Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, relation_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, resource_id: str, relation_id: str) -> RelationRead: - """ - Retrieves a relation by its ID. + """Retrieves a relation by its ID. + Alias for the get method. Args: @@ -107,16 +118,16 @@ async def get_by_id(self, resource_id: str, relation_id: str) -> RelationRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_id, relation_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, resource_key: str, relation_data: RelationCreate) -> RelationRead: - """ - Creates a new relation. + """Creates a new relation. Args: resource_key: The key of the resource under which the relation should be created. @@ -127,7 +138,8 @@ async def create(self, resource_key: str, relation_data: RelationCreate) -> Rela Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -137,10 +149,9 @@ async def create(self, resource_key: str, relation_data: RelationCreate) -> Rela json=relation_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, resource_key: str, relation_key: str) -> None: - """ - Deletes a relation. + """Deletes a relation. Args: resource_key: The key of the resource the relation belongs to. @@ -148,7 +159,8 @@ async def delete(self, resource_key: str, relation_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/resource_roles.py b/permit/api/resource_roles.py index 74674be..90f2ca0 100644 --- a/permit/api/resource_roles.py +++ b/permit/api/resource_roles.py @@ -1,19 +1,24 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( AddRolePermissions, DerivedRoleRuleCreate, DerivedRoleRuleDelete, @@ -27,9 +32,7 @@ class ResourceRolesApi(BasePermitApi): - """ - Represents the interface for managing resource roles. - """ + """Represents the interface for managing resource roles.""" @property def __resource_roles(self) -> SimpleHttpClient: @@ -37,10 +40,11 @@ def __resource_roles(self) -> SimpleHttpClient: f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/resources" ) - @validate_arguments # type: ignore[operator] - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceRoleRead]: - """ - Retrieves a list of resource roles. + @validate_arguments + async def list( + self, resource_key: str, page: int = 1, per_page: int = 100 + ) -> list[ResourceRoleRead]: + """Retrieves a list of resource roles. Args: resource_key: The key of the resource to filter on. @@ -52,23 +56,25 @@ async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> L Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__resource_roles.get( f"/{resource_key}/roles", - model=List[ResourceRoleRead], + model=list[ResourceRoleRead], params=pagination_params(page, per_page), ) async def _get(self, resource_key: str, role_key: str) -> ResourceRoleRead: - return await self.__resource_roles.get(f"/{resource_key}/roles/{role_key}", model=ResourceRoleRead) + return await self.__resource_roles.get( + f"/{resource_key}/roles/{role_key}", model=ResourceRoleRead + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, resource_key: str, role_key: str) -> ResourceRoleRead: - """ - Retrieves a resource role by its key. + """Retrieves a resource role by its key. Args: resource_key: The key of the resource the role belongs to. @@ -79,16 +85,17 @@ async def get(self, resource_key: str, role_key: str) -> ResourceRoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, role_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, resource_key: str, role_key: str) -> ResourceRoleRead: - """ - Retrieves a resource role by its key. + """Retrieves a resource role by its key. + Alias for the get method. Args: @@ -100,16 +107,17 @@ async def get_by_key(self, resource_key: str, role_key: str) -> ResourceRoleRead Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key, role_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, resource_id: str, role_id: str) -> ResourceRoleRead: - """ - Retrieves a resource role by its ID. + """Retrieves a resource role by its ID. + Alias for the get method. Args: @@ -121,16 +129,16 @@ async def get_by_id(self, resource_id: str, role_id: str) -> ResourceRoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_id, role_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, resource_key: str, role_data: ResourceRoleCreate) -> ResourceRoleRead: - """ - Creates a new resource role. + """Creates a new resource role. Args: resource_key: The key of the resource under which the role should be created. @@ -141,16 +149,20 @@ async def create(self, resource_key: str, role_data: ResourceRoleCreate) -> Reso Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__resource_roles.post(f"/{resource_key}/roles", model=ResourceRoleRead, json=role_data) + return await self.__resource_roles.post( + f"/{resource_key}/roles", model=ResourceRoleRead, json=role_data + ) - @validate_arguments # type: ignore[operator] - async def update(self, resource_key: str, role_key: str, role_data: ResourceRoleUpdate) -> ResourceRoleRead: - """ - Updates a resource role. + @validate_arguments + async def update( + self, resource_key: str, role_key: str, role_data: ResourceRoleUpdate + ) -> ResourceRoleRead: + """Updates a resource role. Args: resource_key: The key of the resource the role belongs to. @@ -162,7 +174,8 @@ async def update(self, resource_key: str, role_key: str, role_data: ResourceRole Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -170,10 +183,9 @@ async def update(self, resource_key: str, role_key: str, role_data: ResourceRole f"/{resource_key}/roles/{role_key}", model=ResourceRoleRead, json=role_data ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, resource_key: str, role_key: str) -> None: - """ - Deletes a resource role. + """Deletes a resource role. Args: resource_key: The key of the resource the role belongs to. @@ -181,16 +193,18 @@ async def delete(self, resource_key: str, role_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__resource_roles.delete(f"/{resource_key}/roles/{role_key}") - @validate_arguments # type: ignore[operator] - async def assign_permissions(self, resource_key: str, role_key: str, permissions: List[str]) -> ResourceRoleRead: - """ - Assigns permissions to a resource role. + @validate_arguments + async def assign_permissions( + self, resource_key: str, role_key: str, permissions: builtins.list[str] + ) -> ResourceRoleRead: + """Assigns permissions to a resource role. Args: resource_key: The key of the resource the role belongs to. @@ -206,7 +220,8 @@ async def assign_permissions(self, resource_key: str, role_key: str, permissions Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -216,10 +231,11 @@ async def assign_permissions(self, resource_key: str, role_key: str, permissions json=AddRolePermissions(permissions=permissions), ) - @validate_arguments # type: ignore[operator] - async def remove_permissions(self, resource_key: str, role_key: str, permissions: List[str]) -> ResourceRoleRead: - """ - Removes permissions from a resource role. + @validate_arguments + async def remove_permissions( + self, resource_key: str, role_key: str, permissions: builtins.list[str] + ) -> ResourceRoleRead: + """Removes permissions from a resource role. Args: resource_key: The key of the resource the role belongs to. @@ -233,7 +249,8 @@ async def remove_permissions(self, resource_key: str, role_key: str, permissions Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -243,14 +260,14 @@ async def remove_permissions(self, resource_key: str, role_key: str, permissions json=RemoveRolePermissions(permissions=permissions), ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create_role_derivation( self, resource_key: str, role_key: str, derivation_rule: DerivedRoleRuleCreate ) -> DerivedRoleRuleRead: - """ - Create a conditional derivation from another role. + """Create a conditional derivation from another role. - The derivation states that users with some other role on a related object will implicitly also be granted this role. + The derivation states that users with some other role on a related object will implicitly + also be granted this role. Args: resource_key: The key of the resource the role belongs to. @@ -262,8 +279,9 @@ async def create_role_derivation( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ # noqa: E501 + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__resource_roles.post( @@ -272,12 +290,11 @@ async def create_role_derivation( json=derivation_rule, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete_role_derivation( self, resource_key: str, role_key: str, derivation_rule: DerivedRoleRuleDelete ) -> None: - """ - Delete a role derivation. + """Delete a role derivation. Args: resource_key: The key of the resource the role belongs to. @@ -286,7 +303,8 @@ async def delete_role_derivation( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -295,15 +313,14 @@ async def delete_role_derivation( json=derivation_rule, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update_role_derivation_conditions( self, resource_key: str, role_key: str, conditions: PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings, ) -> PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings: - """ - Update the optional (ABAC) conditions when to derive this role from other roles. + """Update the optional (ABAC) conditions when to derive this role from other roles. Args: resource_key: The key of the resource the role belongs to. @@ -312,7 +329,8 @@ async def update_role_derivation_conditions( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/resources.py b/permit/api/resources.py index c0a5d5b..e737dbf 100644 --- a/permit/api/resources.py +++ b/permit/api/resources.py @@ -1,32 +1,36 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ResourceCreate, ResourceRead, ResourceReplace, ResourceUpdate +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ResourceCreate, ResourceRead, ResourceReplace, ResourceUpdate class ResourcesApi(BasePermitApi): + """Manage resources (the object types permissions are granted on).""" + @property def __resources(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/resources" ) - @validate_arguments # type: ignore[operator] - async def list(self, page: int = 1, per_page: int = 100) -> List[ResourceRead]: - """ - Retrieves a list of resources. + @validate_arguments + async def list(self, page: int = 1, per_page: int = 100) -> list[ResourceRead]: + """Retrieves a list of resources. Args: page: The page number to fetch (default: 1). @@ -37,23 +41,23 @@ async def list(self, page: int = 1, per_page: int = 100) -> List[ResourceRead]: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__resources.get( "", - model=List[ResourceRead], + model=list[ResourceRead], params=pagination_params(page, per_page), ) async def _get(self, resource_key: str) -> ResourceRead: return await self.__resources.get(f"/{resource_key}", model=ResourceRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, resource_key: str) -> ResourceRead: - """ - Retrieves a resource by its key. + """Retrieves a resource by its key. Args: resource_key: The key of the resource. @@ -63,16 +67,17 @@ async def get(self, resource_key: str) -> ResourceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, resource_key: str) -> ResourceRead: - """ - Retrieves a resource by its key. + """Retrieves a resource by its key. + Alias for the get method. Args: @@ -83,16 +88,17 @@ async def get_by_key(self, resource_key: str) -> ResourceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, resource_id: str) -> ResourceRead: - """ - Retrieves a resource by its ID. + """Retrieves a resource by its ID. + Alias for the get method. Args: @@ -103,16 +109,16 @@ async def get_by_id(self, resource_id: str) -> ResourceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(resource_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, resource_data: ResourceCreate) -> ResourceRead: - """ - Creates a new resource. + """Creates a new resource. Args: resource_data: The data for the new resource. @@ -122,16 +128,16 @@ async def create(self, resource_data: ResourceCreate) -> ResourceRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__resources.post("", model=ResourceRead, json=resource_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update(self, resource_key: str, resource_data: ResourceUpdate) -> ResourceRead: - """ - Updates a resource. + """Updates a resource. Args: resource_key: The key of the resource. @@ -142,7 +148,8 @@ async def update(self, resource_key: str, resource_data: ResourceUpdate) -> Reso Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -152,10 +159,9 @@ async def update(self, resource_key: str, resource_data: ResourceUpdate) -> Reso json=resource_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def replace(self, resource_key: str, resource_data: ResourceReplace) -> ResourceRead: - """ - Creates a resource if no such resource exists, otherwise completely replaces the resource in place. + """Creates a resource, or completely replaces it in place if it already exists. Args: resource_key: The key of the resource. @@ -166,7 +172,8 @@ async def replace(self, resource_key: str, resource_data: ResourceReplace) -> Re Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -176,17 +183,17 @@ async def replace(self, resource_key: str, resource_data: ResourceReplace) -> Re json=resource_data, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, resource_key: str) -> None: - """ - Deletes a resource. + """Deletes a resource. Args: resource_key: The key of the resource to delete. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/role_assignments.py b/permit/api/role_assignments.py index 25452c0..af53c87 100644 --- a/permit/api/role_assignments.py +++ b/permit/api/role_assignments.py @@ -1,19 +1,24 @@ -from typing import List, Optional, Union +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( BulkRoleAssignmentReport, BulkRoleUnAssignmentReport, RoleAssignmentCreate, @@ -23,35 +28,40 @@ class RoleAssignmentsApi(BasePermitApi): + """Assign roles to users and list or remove role assignments.""" + @property def __role_assignments(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/role_assignments", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/role_assignments" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/role_assignments" + ) - @validate_arguments # type: ignore[operator] - async def list( + @validate_arguments + async def list( # noqa: PLR0917 - public signature; callers may pass these positionally self, - user_key: Optional[Union[str, List[str]]] = None, - role_key: Optional[Union[str, List[str]]] = None, - tenant_key: Optional[Union[str, List[str]]] = None, - resource_key: Optional[str] = None, - resource_instance_key: Optional[str] = None, + user_key: str | list[str] | None = None, + role_key: str | list[str] | None = None, + tenant_key: str | list[str] | None = None, + resource_key: str | None = None, + resource_instance_key: str | None = None, page: int = 1, per_page: int = 100, - ) -> List[RoleAssignmentRead]: - """ - Retrieves a list of role assignments based on the specified filters. + ) -> list[RoleAssignmentRead]: + """Retrieves a list of role assignments based on the specified filters. Args: user_key: if specified, only role granted to this user will be fetched. role_key: if specified, only assignments of this role will be fetched. - tenant_key: (for roles) if specified, only role granted within this tenant will be fetched. - resource_key: (for resource roles) if specified, only roles granted on instances of this resource type will be fetched. - resource_instance_key: (for resource roles) if specified, only roles granted with this instance as the object will be fetched. The instance identity, either `resource_type:instance_key` (like Repository:react) or the instance uuid; a bare instance key is rejected by the API with a 400. + tenant_key: (for roles) if specified, only role granted within this tenant will be + fetched. + resource_key: (for resource roles) if specified, only roles granted on instances of this + resource type will be fetched. + resource_instance_key: (for resource roles) if specified, only roles granted with this + instance as the object will be fetched. The instance identity, either + `resource_type:instance_key` (like Repository:react) or the instance uuid; a bare + instance key is rejected by the API with a 400. page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -60,27 +70,25 @@ async def list( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ # noqa: E501 + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) params = list(pagination_params(page, per_page).items()) if user_key is not None: if isinstance(user_key, list): - for user in user_key: - params.append(("user", user)) + params.extend(("user", user) for user in user_key) else: params.append(("user", user_key)) if role_key is not None: if isinstance(role_key, list): - for role in role_key: - params.append(("role", role)) + params.extend(("role", role) for role in role_key) else: params.append(("role", role_key)) if tenant_key is not None: if isinstance(tenant_key, list): - for tenant in tenant_key: - params.append(("tenant", tenant)) + params.extend(("tenant", tenant) for tenant in tenant_key) else: params.append(("tenant", tenant_key)) if resource_key is not None: @@ -89,14 +97,13 @@ async def list( params.append(("resource_instance", resource_instance_key)) return await self.__role_assignments.get( "", - model=List[RoleAssignmentRead], + model=list[RoleAssignmentRead], params=params, ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def assign(self, assignment: RoleAssignmentCreate) -> RoleAssignmentRead: - """ - Assigns a role to a user in the scope of a given tenant. + """Assigns a role to a user in the scope of a given tenant. Args: assignment: The role assignment details. @@ -106,32 +113,35 @@ async def assign(self, assignment: RoleAssignmentCreate) -> RoleAssignmentRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__role_assignments.post("", model=RoleAssignmentRead, json=assignment) - @validate_arguments # type: ignore[operator] + @validate_arguments async def unassign(self, unassignment: RoleAssignmentRemove) -> None: - """ - Unassigns a role from a user in the scope of a given tenant. + """Unassigns a role from a user in the scope of a given tenant. Args: unassignment: The role unassignment details. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__role_assignments.delete("", json=unassignment) - @validate_arguments # type: ignore[operator] - async def bulk_assign(self, assignments: List[RoleAssignmentCreate]) -> BulkRoleAssignmentReport: - """ - Assigns multiple roles in bulk using the provided role assignments data. + @validate_arguments + async def bulk_assign( + self, assignments: builtins.list[RoleAssignmentCreate] + ) -> BulkRoleAssignmentReport: + """Assigns multiple roles in bulk using the provided role assignments data. + Each role assignment is a tuple of (user, role, tenant). Args: @@ -142,7 +152,8 @@ async def bulk_assign(self, assignments: List[RoleAssignmentCreate]) -> BulkRole Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -152,10 +163,12 @@ async def bulk_assign(self, assignments: List[RoleAssignmentCreate]) -> BulkRole json=list(assignments), ) - @validate_arguments # type: ignore[operator] - async def bulk_unassign(self, unassignments: List[RoleAssignmentRemove]) -> BulkRoleUnAssignmentReport: - """ - Removes multiple role assignments in bulk using the provided unassignment data. + @validate_arguments + async def bulk_unassign( + self, unassignments: builtins.list[RoleAssignmentRemove] + ) -> BulkRoleUnAssignmentReport: + """Removes multiple role assignments in bulk using the provided unassignment data. + Each role to unassign is a tuple of (user, role, tenant). Args: @@ -166,7 +179,8 @@ async def bulk_unassign(self, unassignments: List[RoleAssignmentRemove]) -> Bulk Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/roles.py b/permit/api/roles.py index 57d26fb..8ca6db1 100644 --- a/permit/api/roles.py +++ b/permit/api/roles.py @@ -1,19 +1,24 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( AddRolePermissions, RemoveRolePermissions, RoleCreate, @@ -23,9 +28,7 @@ class RolesApi(BasePermitApi): - """ - Represents the interface for managing roles. - """ + """Represents the interface for managing roles.""" @property def __roles(self) -> SimpleHttpClient: @@ -33,10 +36,9 @@ def __roles(self) -> SimpleHttpClient: f"/v2/schema/{self.config.api_context.project}/{self.config.api_context.environment}/roles" ) - @validate_arguments # type: ignore[operator] - async def list(self, page: int = 1, per_page: int = 100) -> List[RoleRead]: - """ - Retrieves a list of roles. + @validate_arguments + async def list(self, page: int = 1, per_page: int = 100) -> list[RoleRead]: + """Retrieves a list of roles. Args: page: The page number to fetch (default: 1). @@ -47,19 +49,21 @@ async def list(self, page: int = 1, per_page: int = 100) -> List[RoleRead]: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__roles.get("", model=List[RoleRead], params=pagination_params(page, per_page)) + return await self.__roles.get( + "", model=list[RoleRead], params=pagination_params(page, per_page) + ) async def _get(self, role_key: str) -> RoleRead: return await self.__roles.get(f"/{role_key}", model=RoleRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, role_key: str) -> RoleRead: - """ - Retrieves a role by its key. + """Retrieves a role by its key. Args: role_key: The key of the role. @@ -69,16 +73,17 @@ async def get(self, role_key: str) -> RoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(role_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, role_key: str) -> RoleRead: - """ - Retrieves a role by its key. + """Retrieves a role by its key. + Alias for the get method. Args: @@ -89,16 +94,17 @@ async def get_by_key(self, role_key: str) -> RoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(role_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, role_id: str) -> RoleRead: - """ - Retrieves a role by its ID. + """Retrieves a role by its ID. + Alias for the get method. Args: @@ -109,16 +115,16 @@ async def get_by_id(self, role_id: str) -> RoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(role_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, role_data: RoleCreate) -> RoleRead: - """ - Creates a new role. + """Creates a new role. Args: role_data: The data for the new role. @@ -128,16 +134,16 @@ async def create(self, role_data: RoleCreate) -> RoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__roles.post("", model=RoleRead, json=role_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update(self, role_key: str, role_data: RoleUpdate) -> RoleRead: - """ - Updates a role. + """Updates a role. Args: role_key: The key of the role. @@ -148,43 +154,45 @@ async def update(self, role_key: str, role_data: RoleUpdate) -> RoleRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__roles.patch(f"/{role_key}", model=RoleRead, json=role_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, role_key: str) -> None: - """ - Deletes a role. + """Deletes a role. Args: role_key: The key of the role to delete. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__roles.delete(f"/{role_key}") - @validate_arguments # type: ignore[operator] - async def assign_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Assigns permissions to a role. + @validate_arguments + async def assign_permissions(self, role_key: str, permissions: builtins.list[str]) -> RoleRead: + """Assigns permissions to a role. Args: role_key: The key of the role. - permissions: An array of permission keys () to be assigned to the role. + permissions: An array of permission keys () to be assigned to the + role. Returns: A RoleRead object representing the updated role. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -194,21 +202,22 @@ async def assign_permissions(self, role_key: str, permissions: List[str]) -> Rol json=AddRolePermissions(permissions=permissions), ) - @validate_arguments # type: ignore[operator] - async def remove_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Removes permissions from a role. + @validate_arguments + async def remove_permissions(self, role_key: str, permissions: builtins.list[str]) -> RoleRead: + """Removes permissions from a role. Args: role_key: The key of the role. - permissions: An array of permission keys () to be removed from the role. + permissions: An array of permission keys () to be removed from + the role. Returns: A RoleRead object representing the updated role. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/sync_api_client.py b/permit/api/sync_api_client.py index 4b7afcd..9f30fd4 100644 --- a/permit/api/sync_api_client.py +++ b/permit/api/sync_api_client.py @@ -1,101 +1,102 @@ -from ..config import PermitConfig -from ..utils.sync import SyncClass -from .condition_set_rules import ConditionSetRulesApi -from .condition_sets import ConditionSetsApi -from .deprecated import DeprecatedApi -from .environments import EnvironmentsApi -from .projects import ProjectsApi -from .relationship_tuples import RelationshipTuplesApi -from .resource_action_groups import ResourceActionGroupsApi -from .resource_actions import ResourceActionsApi -from .resource_attributes import ResourceAttributesApi -from .resource_instances import ResourceInstancesApi -from .resource_relations import ResourceRelationsApi -from .resource_roles import ResourceRolesApi -from .resources import ResourcesApi -from .role_assignments import RoleAssignmentsApi -from .roles import RolesApi -from .tenants import TenantsApi -from .user_invites import UserInvitesApi -from .users import UsersApi +from permit.api.condition_set_rules import ConditionSetRulesApi +from permit.api.condition_sets import ConditionSetsApi +from permit.api.deprecated import DeprecatedApi +from permit.api.environments import EnvironmentsApi +from permit.api.projects import ProjectsApi +from permit.api.relationship_tuples import RelationshipTuplesApi +from permit.api.resource_action_groups import ResourceActionGroupsApi +from permit.api.resource_actions import ResourceActionsApi +from permit.api.resource_attributes import ResourceAttributesApi +from permit.api.resource_instances import ResourceInstancesApi +from permit.api.resource_relations import ResourceRelationsApi +from permit.api.resource_roles import ResourceRolesApi +from permit.api.resources import ResourcesApi +from permit.api.role_assignments import RoleAssignmentsApi +from permit.api.roles import RolesApi +from permit.api.tenants import TenantsApi +from permit.api.user_invites import UserInvitesApi +from permit.api.users import UsersApi +from permit.config import PermitConfig +from permit.utils.sync import SyncClass class SyncConditionSetRulesApi(ConditionSetRulesApi, metaclass=SyncClass): - pass + """Blocking variant of `ConditionSetRulesApi`.""" class SyncConditionSetsApi(ConditionSetsApi, metaclass=SyncClass): - pass + """Blocking variant of `ConditionSetsApi`.""" class SyncDeprecatedApi(DeprecatedApi, metaclass=SyncClass): - pass + """Blocking variant of `DeprecatedApi`.""" class SyncEnvironmentsApi(EnvironmentsApi, metaclass=SyncClass): - pass + """Blocking variant of `EnvironmentsApi`.""" class SyncProjectsApi(ProjectsApi, metaclass=SyncClass): - pass + """Blocking variant of `ProjectsApi`.""" class SyncRelationshipTuplesApi(RelationshipTuplesApi, metaclass=SyncClass): - pass + """Blocking variant of `RelationshipTuplesApi`.""" class SyncResourceActionGroupsApi(ResourceActionGroupsApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourceActionGroupsApi`.""" class SyncResourceActionsApi(ResourceActionsApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourceActionsApi`.""" class SyncResourceAttributesApi(ResourceAttributesApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourceAttributesApi`.""" class SyncResourceInstancesApi(ResourceInstancesApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourceInstancesApi`.""" class SyncResourceRelationsApi(ResourceRelationsApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourceRelationsApi`.""" class SyncResourceRolesApi(ResourceRolesApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourceRolesApi`.""" class SyncResourcesApi(ResourcesApi, metaclass=SyncClass): - pass + """Blocking variant of `ResourcesApi`.""" class SyncRoleAssignmentsApi(RoleAssignmentsApi, metaclass=SyncClass): - pass + """Blocking variant of `RoleAssignmentsApi`.""" class SyncRolesApi(RolesApi, metaclass=SyncClass): - pass + """Blocking variant of `RolesApi`.""" class SyncTenantsApi(TenantsApi, metaclass=SyncClass): - pass + """Blocking variant of `TenantsApi`.""" class SyncUserInvitesApi(UserInvitesApi, metaclass=SyncClass): - pass + """Blocking variant of `UserInvitesApi`.""" class SyncUsersApi(UsersApi, metaclass=SyncClass): - pass + """Blocking variant of `UsersApi`.""" class SyncPermitApiClient(SyncDeprecatedApi): - def __init__(self, config: PermitConfig): - """ - Constructs a new instance of the SyncPermitApiClient class with the specified SDK configuration. + """Blocking variant of `PermitApiClient`.""" + + def __init__(self, config: PermitConfig) -> None: + """Constructs a new SyncPermitApiClient with the specified SDK configuration. Args: config: The configuration for the Permit SDK. @@ -122,136 +123,136 @@ def __init__(self, config: PermitConfig): @property def condition_set_rules(self) -> SyncConditionSetRulesApi: - """ - API for managing condition set rules. + """API for managing condition set rules. + See: https://api.permit.io/v2/redoc#tag/Condition-Set-Rules """ return self._condition_set_rules @property def condition_sets(self) -> SyncConditionSetsApi: - """ - API for managing condition sets. + """API for managing condition sets. + See: https://api.permit.io/v2/redoc#tag/Condition-Sets """ return self._condition_sets @property def projects(self) -> SyncProjectsApi: - """ - API for managing projects. + """API for managing projects. + See: https://api.permit.io/v2/redoc#tag/Projects """ return self._projects @property def environments(self) -> SyncEnvironmentsApi: - """ - API for managing environments. + """API for managing environments. + See: https://api.permit.io/v2/redoc#tag/Environments """ return self._environments @property def action_groups(self) -> SyncResourceActionGroupsApi: - """ - API for managing resource action groups. + """API for managing resource action groups. + See: https://api.permit.io/v2/redoc#tag/Resource-Action-Groups """ return self._action_groups @property def resource_actions(self) -> SyncResourceActionsApi: - """ - API for managing resource actions. + """API for managing resource actions. + See: https://api.permit.io/v2/redoc#tag/Resource-Actions """ return self._resource_actions @property def resource_attributes(self) -> SyncResourceAttributesApi: - """ - API for managing resource attributes. + """API for managing resource attributes. + See: https://api.permit.io/v2/redoc#tag/Resource-Attributes """ return self._resource_attributes @property def resource_roles(self) -> SyncResourceRolesApi: - """ - API for managing resource roles. + """API for managing resource roles. + See: https://api.permit.io/v2/redoc#tag/Resource-Roles """ return self._resource_roles @property def resource_relations(self) -> SyncResourceRelationsApi: - """ - API for managing resource relations. + """API for managing resource relations. + See: https://api.permit.io/v2/redoc#tag/Resource-Relations """ return self._resource_relations @property def resource_instances(self) -> SyncResourceInstancesApi: - """ - API for managing resource instances. + """API for managing resource instances. + See: https://api.permit.io/v2/redoc#tag/Resource-Instances """ return self._resource_instances @property def resources(self) -> SyncResourcesApi: - """ - API for managing resources. + """API for managing resources. + See: https://api.permit.io/v2/redoc#tag/Resources """ return self._resources @property def role_assignments(self) -> SyncRoleAssignmentsApi: - """ - API for managing role assignments. + """API for managing role assignments. + See: https://api.permit.io/v2/redoc#tag/Role-Assignments """ return self._role_assignments @property def relationship_tuples(self) -> SyncRelationshipTuplesApi: - """ - API for managing relationship tuples. + """API for managing relationship tuples. + See: https://api.permit.io/v2/redoc#tag/Relationship-tuples """ return self._relationship_tuples @property def roles(self) -> SyncRolesApi: - """ - API for managing roles. + """API for managing roles. + See: https://api.permit.io/v2/redoc#tag/Roles """ return self._roles @property def tenants(self) -> SyncTenantsApi: - """ - API for managing tenants. + """API for managing tenants. + See: https://api.permit.io/v2/redoc#tag/Tenants """ return self._tenants @property def user_invites(self) -> SyncUserInvitesApi: - """ - API for managing user invites. + """API for managing user invites. + See: https://api.permit.io/v2/redoc#tag/User-Invites """ return self._user_invites @property def users(self) -> SyncUsersApi: - """ - API for managing users. + """API for managing users. + See: https://api.permit.io/v2/redoc#tag/Users """ return self._users diff --git a/permit/api/tenants.py b/permit/api/tenants.py index ba13b13..1208abf 100644 --- a/permit/api/tenants.py +++ b/permit/api/tenants.py @@ -1,19 +1,24 @@ -from typing import List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( PaginatedResultUserRead, TenantCreate, TenantCreateBulkOperation, @@ -26,28 +31,27 @@ class TenantsApi(BasePermitApi): + """Manage tenants and the users in them.""" + @property def __tenants(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/tenants", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/tenants" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/tenants" + ) @property def __bulk_operations(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/bulk/tenants", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/bulk/tenants" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/bulk/tenants" + ) - @validate_arguments # type: ignore[operator] - async def list(self, page: int = 1, per_page: int = 100) -> List[TenantRead]: - """ - Retrieves a list of tenants. + @validate_arguments + async def list(self, page: int = 1, per_page: int = 100) -> list[TenantRead]: + """Retrieves a list of tenants. Args: page: The page number to fetch (default: 1). @@ -58,16 +62,20 @@ async def list(self, page: int = 1, per_page: int = 100) -> List[TenantRead]: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__tenants.get("", model=List[TenantRead], params=pagination_params(page, per_page)) + return await self.__tenants.get( + "", model=list[TenantRead], params=pagination_params(page, per_page) + ) - @validate_arguments # type: ignore[operator] - async def list_tenant_users(self, tenant_key: str, page: int = 1, per_page: int = 100) -> PaginatedResultUserRead: - """ - Retrieves a list of users for a given tenant. + @validate_arguments + async def list_tenant_users( + self, tenant_key: str, page: int = 1, per_page: int = 100 + ) -> PaginatedResultUserRead: + """Retrieves a list of users for a given tenant. Args: tenant_key: The key of the tenant. @@ -79,7 +87,8 @@ async def list_tenant_users(self, tenant_key: str, page: int = 1, per_page: int Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -92,10 +101,9 @@ async def list_tenant_users(self, tenant_key: str, page: int = 1, per_page: int async def _get(self, tenant_key: str) -> TenantRead: return await self.__tenants.get(f"/{tenant_key}", model=TenantRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, tenant_key: str) -> TenantRead: - """ - Retrieves a tenant by its key. + """Retrieves a tenant by its key. Args: tenant_key: The key of the tenant. @@ -105,16 +113,17 @@ async def get(self, tenant_key: str) -> TenantRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(tenant_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, tenant_key: str) -> TenantRead: - """ - Retrieves a tenant by its key. + """Retrieves a tenant by its key. + Alias for the get method. Args: @@ -125,16 +134,17 @@ async def get_by_key(self, tenant_key: str) -> TenantRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(tenant_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, tenant_id: str) -> TenantRead: - """ - Retrieves a tenant by its ID. + """Retrieves a tenant by its ID. + Alias for the get method. Args: @@ -145,16 +155,16 @@ async def get_by_id(self, tenant_id: str) -> TenantRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(tenant_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, tenant_data: TenantCreate) -> TenantRead: - """ - Creates a new tenant. + """Creates a new tenant. Args: tenant_data: The data for the new tenant. @@ -164,16 +174,16 @@ async def create(self, tenant_data: TenantCreate) -> TenantRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__tenants.post("", model=TenantRead, json=tenant_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update(self, tenant_key: str, tenant_data: TenantUpdate) -> TenantRead: - """ - Updates a tenant. + """Updates a tenant. Args: tenant_key: The key of the tenant. @@ -184,16 +194,16 @@ async def update(self, tenant_key: str, tenant_data: TenantUpdate) -> TenantRead Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__tenants.patch(f"/{tenant_key}", model=TenantRead, json=tenant_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, tenant_key: str) -> None: - """ - Deletes a tenant. + """Deletes a tenant. Args: tenant_key: The key of the tenant to delete. @@ -203,16 +213,16 @@ async def delete(self, tenant_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__tenants.delete(f"/{tenant_key}") - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete_tenant_user(self, tenant_key: str, user_key: str) -> None: - """ - Deletes a user from a given tenant (also removes all roles granted to the user in that tenant). + """Deletes a user from a tenant, removing all roles granted to the user in that tenant. Args: tenant_key: The key of the tenant from which the user will be deleted. @@ -220,16 +230,18 @@ async def delete_tenant_user(self, tenant_key: str, user_key: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__tenants.delete(f"/{tenant_key}/users/{user_key}") - @validate_arguments # type: ignore[operator] - async def bulk_create(self, tenants: List[TenantCreate]) -> TenantCreateBulkOperationResult: - """ - Creates tenants in bulk. + @validate_arguments + async def bulk_create( + self, tenants: builtins.list[TenantCreate] + ) -> TenantCreateBulkOperationResult: + """Creates tenants in bulk. Args: tenants: The tenants to create @@ -239,7 +251,8 @@ async def bulk_create(self, tenants: List[TenantCreate]) -> TenantCreateBulkOper Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -249,20 +262,21 @@ async def bulk_create(self, tenants: List[TenantCreate]) -> TenantCreateBulkOper json=TenantCreateBulkOperation(operations=tenants), ) - @validate_arguments # type: ignore[operator] - async def bulk_delete(self, tenants: List[str]) -> TenantDeleteBulkOperationResult: - """ - Deletes tenants in bulk. + @validate_arguments + async def bulk_delete(self, tenants: builtins.list[str]) -> TenantDeleteBulkOperationResult: + """Deletes tenants in bulk. Args: - tenants: The tenants identities to delete. Each identity can be either the tenant key or the tenant id. + tenants: The tenants identities to delete. Each identity can be either the tenant key or + the tenant id. Returns: the bulk delete report. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/user_invites.py b/permit/api/user_invites.py index 4a08fa6..5e94308 100644 --- a/permit/api/user_invites.py +++ b/permit/api/user_invites.py @@ -1,17 +1,22 @@ -from ..utils.pydantic_version import PYDANTIC_VERSION +from typing import TYPE_CHECKING -if PYDANTIC_VERSION < (2, 0): +from permit.utils.pydantic_version import PYDANTIC_VERSION + +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ElementsUserInviteApprove, ElementsUserInviteCreate, ElementsUserInviteRead, @@ -21,16 +26,19 @@ class UserInvitesApi(BasePermitApi): + """Manage user invites.""" + @property def __user_invites(self) -> SimpleHttpClient: return self._build_http_client( f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/user_invites" ) - @validate_arguments # type: ignore[operator] - async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultElementsUserInviteRead: - """ - Retrieves a list of user invites. + @validate_arguments + async def list( + self, page: int = 1, per_page: int = 100 + ) -> PaginatedResultElementsUserInviteRead: + """Retrieves a list of user invites. Args: page: The page number to retrieve (default: 1). @@ -41,7 +49,8 @@ async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultEleme Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -51,10 +60,9 @@ async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultEleme params=pagination_params(page, per_page), ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, user_invite_id: str) -> ElementsUserInviteRead: - """ - Retrieves a single user invite by ID. + """Retrieves a single user invite by ID. Args: user_invite_id: The ID of the user invite to retrieve. @@ -64,16 +72,16 @@ async def get(self, user_invite_id: str) -> ElementsUserInviteRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__user_invites.get(f"/{user_invite_id}", model=ElementsUserInviteRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, user_invite_data: ElementsUserInviteCreate) -> ElementsUserInviteRead: - """ - Creates a new user invite. + """Creates a new user invite. Args: user_invite_data: The user invite data to create. @@ -83,16 +91,18 @@ async def create(self, user_invite_data: ElementsUserInviteCreate) -> ElementsUs Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) - return await self.__user_invites.post("", model=ElementsUserInviteRead, json=user_invite_data) + return await self.__user_invites.post( + "", model=ElementsUserInviteRead, json=user_invite_data + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, user_invite_id: str) -> None: - """ - Deletes a user invite. + """Deletes a user invite. Args: user_invite_id: The ID of the user invite to delete. @@ -102,16 +112,18 @@ async def delete(self, user_invite_id: str) -> None: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) await self.__user_invites.delete(f"/{user_invite_id}") - @validate_arguments # type: ignore[operator] - async def approve(self, user_invite_id: str, approve_data: ElementsUserInviteApprove) -> UserRead: - """ - Approves a user invite. + @validate_arguments + async def approve( + self, user_invite_id: str, approve_data: ElementsUserInviteApprove + ) -> UserRead: + """Approves a user invite. Args: user_invite_id: The ID of the user invite to approve. @@ -122,7 +134,8 @@ async def approve(self, user_invite_id: str, approve_data: ElementsUserInviteApp Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) diff --git a/permit/api/users.py b/permit/api/users.py index 7ca4075..804ab33 100644 --- a/permit/api/users.py +++ b/permit/api/users.py @@ -1,19 +1,24 @@ -from typing import List, Optional, Union +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments -from .base import ( +import builtins + +from permit.api.base import ( BasePermitApi, SimpleHttpClient, pagination_params, ) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( PaginatedResultUserRead, RoleAssignmentCreate, RoleAssignmentRead, @@ -31,37 +36,35 @@ class UsersApi(BasePermitApi): + """Manage users and their role assignments.""" + @property def __users(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/users", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/users" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/users" + ) @property def __role_assignments(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/role_assignments", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/role_assignments" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/role_assignments" + ) @property def __bulk_operations(self) -> SimpleHttpClient: if self.config.proxy_facts_via_pdp: return self._build_http_client("/facts/bulk/users", use_pdp=True) - else: - return self._build_http_client( - f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/bulk/users" - ) + return self._build_http_client( + f"/v2/facts/{self.config.api_context.project}/{self.config.api_context.environment}/bulk/users" + ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultUserRead: - """ - Retrieves a list of users. + """Retrieves a list of users. Args: page: The page number to fetch (default: 1). @@ -72,7 +75,8 @@ async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultUserR Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -85,10 +89,9 @@ async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultUserR async def _get(self, user_key: str) -> UserRead: return await self.__users.get(f"/{user_key}", model=UserRead) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get(self, user_key: str) -> UserRead: - """ - Retrieves a user by its key. + """Retrieves a user by its key. Args: user_key: The key of the user. @@ -98,16 +101,17 @@ async def get(self, user_key: str) -> UserRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(user_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_key(self, user_key: str) -> UserRead: - """ - Retrieves a user by its key. + """Retrieves a user by its key. + Alias for the get method. Args: @@ -118,16 +122,17 @@ async def get_by_key(self, user_key: str) -> UserRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(user_key) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_by_id(self, user_id: str) -> UserRead: - """ - Retrieves a user by its ID. + """Retrieves a user by its ID. + Alias for the get method. Args: @@ -138,16 +143,16 @@ async def get_by_id(self, user_id: str) -> UserRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self._get(user_id) - @validate_arguments # type: ignore[operator] + @validate_arguments async def create(self, user_data: UserCreate) -> UserRead: - """ - Creates a new user. + """Creates a new user. Args: user_data: The data for the new user. @@ -157,16 +162,16 @@ async def create(self, user_data: UserCreate) -> UserRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__users.post("", model=UserRead, json=user_data) - @validate_arguments # type: ignore[operator] + @validate_arguments async def update(self, user_key: str, user_data: UserUpdate) -> UserRead: - """ - Updates a user. + """Updates a user. Args: user_key: The key of the user. @@ -177,16 +182,18 @@ async def update(self, user_key: str, user_data: UserUpdate) -> UserRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__users.patch(f"/{user_key}", model=UserRead, json=user_data) - @validate_arguments # type: ignore[operator] - async def sync(self, user: Union[UserCreate, dict]) -> UserRead: - """ - Synchronizes user data by creating or updating a user. + # Bare `dict` on purpose: pydantic v1 passes it through as is, while a parameterized + # dict would be validated as a mapping and copied. + @validate_arguments + async def sync(self, user: UserCreate | dict) -> UserRead: # type: ignore[type-arg] + """Synchronizes user data by creating or updating a user. Args: user: The data of the user to be synchronized. @@ -196,38 +203,39 @@ async def sync(self, user: Union[UserCreate, dict]) -> UserRead: Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) if isinstance(user, dict): user_key = user.get("key") if user_key is None: - raise KeyError("required 'key' in input dictionary") + msg = "required 'key' in input dictionary" + raise KeyError(msg) else: user_key = user.key return await self.__users.put(f"/{user_key}", model=UserRead, json=user) - @validate_arguments # type: ignore[operator] + @validate_arguments async def delete(self, user_key: str) -> None: - """ - Deletes a user. + """Deletes a user. Args: user_key: The key of the user to delete. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) return await self.__users.delete(f"/{user_key}") - @validate_arguments # type: ignore[operator] - async def bulk_create(self, users: List[UserCreate]) -> UserCreateBulkOperationResult: - """ - Creates users in bulk. + @validate_arguments + async def bulk_create(self, users: builtins.list[UserCreate]) -> UserCreateBulkOperationResult: + """Creates users in bulk. Args: users: The users to create @@ -237,7 +245,8 @@ async def bulk_create(self, users: List[UserCreate]) -> UserCreateBulkOperationR Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -247,10 +256,11 @@ async def bulk_create(self, users: List[UserCreate]) -> UserCreateBulkOperationR json=UserCreateBulkOperation(operations=users), ) - @validate_arguments # type: ignore[operator] - async def bulk_replace(self, users: List[UserCreate]) -> UserReplaceBulkOperationResult: - """ - Replaces users in bulk. + @validate_arguments + async def bulk_replace( + self, users: builtins.list[UserCreate] + ) -> UserReplaceBulkOperationResult: + """Replaces users in bulk. If the user exists - replaces it. Otherwise, creates previously non-existing users. @@ -263,7 +273,8 @@ async def bulk_replace(self, users: List[UserCreate]) -> UserReplaceBulkOperatio Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -273,20 +284,21 @@ async def bulk_replace(self, users: List[UserCreate]) -> UserReplaceBulkOperatio json=UserReplaceBulkOperation(operations=users), ) - @validate_arguments # type: ignore[operator] - async def bulk_delete(self, users: List[str]) -> UserDeleteBulkOperationResult: - """ - Deletes users in bulk. + @validate_arguments + async def bulk_delete(self, users: builtins.list[str]) -> UserDeleteBulkOperationResult: + """Deletes users in bulk. Args: - users: The users identities to delete. Each identity can be either the user key or the user id. + users: The users identities to delete. Each identity can be either the user key or the + user id. Returns: the bulk delete report. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -296,10 +308,9 @@ async def bulk_delete(self, users: List[str]) -> UserDeleteBulkOperationResult: json=UserDeleteBulkOperation(idents=users), ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def assign_role(self, assignment: RoleAssignmentCreate) -> RoleAssignmentRead: - """ - Assigns a role to a user in the scope of a given tenant. + """Assigns a role to a user in the scope of a given tenant. Args: assignment: The role assignment details. @@ -309,7 +320,8 @@ async def assign_role(self, assignment: RoleAssignmentCreate) -> RoleAssignmentR Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -319,17 +331,17 @@ async def assign_role(self, assignment: RoleAssignmentCreate) -> RoleAssignmentR json=assignment.copy(exclude={"user"}), ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def unassign_role(self, unassignment: RoleAssignmentRemove) -> None: - """ - Unassigns a role from a user in the scope of a given tenant. + """Unassigns a role from a user in the scope of a given tenant. Args: unassignment: The role unassignment details. Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -338,17 +350,18 @@ async def unassign_role(self, unassignment: RoleAssignmentRemove) -> None: json=unassignment.copy(exclude={"user"}), ) - @validate_arguments # type: ignore[operator] + @validate_arguments async def get_assigned_roles( self, user: str, - tenant: Optional[str] = None, + tenant: str | None = None, page: int = 1, per_page: int = 100, - ) -> List[RoleAssignmentRead]: - """ - Retrieves the roles assigned to a user in a given tenant (if the tenant filter is provided) - or across all tenants (if the tenant filter is not provided). + ) -> builtins.list[RoleAssignmentRead]: + """Retrieves the roles assigned to a user, in one tenant or across all of them. + + The roles come from the given tenant if the tenant filter is provided, or from + all tenants if it is not. Args: user: The key of the user. @@ -361,7 +374,8 @@ async def get_assigned_roles( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ await self._ensure_access_level(ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ENVIRONMENT) @@ -371,6 +385,6 @@ async def get_assigned_roles( params.update({"tenant": tenant}) return await self.__role_assignments.get( "", - model=List[RoleAssignmentRead], + model=list[RoleAssignmentRead], params=params, ) diff --git a/permit/config.py b/permit/config.py index f1d4fe7..de43b33 100644 --- a/permit/config.py +++ b/permit/config.py @@ -1,17 +1,26 @@ -from typing import Literal, Optional +from typing import TYPE_CHECKING, Literal -from .api.context import ApiContext -from .utils.pydantic_version import PYDANTIC_VERSION +from permit.api.context import ApiContext +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel, Field +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel, Field else: - from pydantic.v1 import BaseModel, Field # type: ignore + from pydantic.v1 import BaseModel, Field class LoggerConfig(BaseModel): - enable: bool = Field(default=False, description="Whether or not to enable logging from the Permit library") - level: str = Field(default="info", description="Sets the log level configured for the Permit SDK Logger.") + """Logging settings of the SDK.""" + + enable: bool = Field( + default=False, description="Whether or not to enable logging from the Permit library" + ) + level: str = Field( + default="info", description="Sets the log level configured for the Permit SDK Logger." + ) label: str = Field( default="Permit", description="Sets the label configured for logs emitted by the Permit SDK Logger.", @@ -24,38 +33,50 @@ class LoggerConfig(BaseModel): class MultiTenancyConfig(BaseModel): + """How resources without a tenant are assigned one.""" + default_tenant: str = Field( default="default", - description="the key of the default tenant to be used if use_default_tenant_if_empty == True", + description="the key of the default tenant to be used " + "if use_default_tenant_if_empty == True", ) use_default_tenant_if_empty: bool = Field( default=True, - description="whether or not the SDK should automatically associate a resource with the defaultTenant " - + "if the resource provided in permit.check() was not associated with a tenant (i.e: undefined tenant).", + description="whether or not the SDK should automatically associate a resource " + "with the defaultTenant " + "if the resource provided in permit.check() was not associated with a tenant " + "(i.e: undefined tenant).", ) class PermitConfig(BaseModel): + """Configuration of the Permit SDK.""" + token: str = Field( default=..., - description="The token (API Key) used for authorization against the PDP and the Permit REST API.", + description="The token (API Key) used for authorization against the PDP " + "and the Permit REST API.", ) pdp: str = Field( default="http://localhost:7766", description="Configures the Policy Decision Point (PDP) url.", ) api_url: str = Field(default="https://api.permit.io", description="The url of Permit REST API") - log: LoggerConfig = Field(LoggerConfig(), description="the logger configuration used by the SDK") + log: LoggerConfig = Field( + default=LoggerConfig(), description="the logger configuration used by the SDK" + ) multi_tenancy: MultiTenancyConfig = Field( - MultiTenancyConfig(), + default=MultiTenancyConfig(), description="configuration of default tenant assignment for RBAC", ) - api_context: ApiContext = Field(ApiContext(), description="represents the current API key authorization level.") - api_timeout: Optional[int] = Field( + api_context: ApiContext = Field( + default=ApiContext(), description="represents the current API key authorization level." + ) + api_timeout: int | None = Field( default=None, description="The timeout in seconds for requests to the Permit REST API.", ) - pdp_timeout: Optional[int] = Field( + pdp_timeout: int | None = Field( default=None, description="The timeout in seconds for requests to the PDP.", ) @@ -63,12 +84,12 @@ class PermitConfig(BaseModel): default=False, description="Create facts via the PDP API instead of using the default Permit REST API.", ) - facts_sync_timeout: Optional[float] = Field( + facts_sync_timeout: float | None = Field( default=None, description="The amount of time in seconds to wait for facts to be available " "in the PDP cache before returning the response.", ) - facts_sync_timeout_policy: Optional[Literal["ignore", "fail"]] = Field( + facts_sync_timeout_policy: Literal["ignore", "fail"] | None = Field( default=None, description="The policy to apply when the facts sync timeout is reached.", ) diff --git a/permit/enforcement/enforcer.py b/permit/enforcement/enforcer.py index 3ff8c9b..adc4b32 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -1,31 +1,40 @@ import json +from http import HTTPStatus from pprint import pformat -from typing import Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Union import aiohttp from aiohttp import ClientTimeout from loguru import logger from typing_extensions import NotRequired, TypedDict -from ..config import PermitConfig -from ..exceptions import PermitConnectionError -from ..utils.context import Context, ContextStore -from ..utils.dicts import deep_merge -from ..utils.pydantic_version import PYDANTIC_VERSION -from ..utils.sync import SyncClass -from .interfaces import AuthorizedUsersResult, ResourceInput, UserInput - -if PYDANTIC_VERSION < (2, 0): +from permit.config import PermitConfig +from permit.enforcement.interfaces import AuthorizedUsersResult, ResourceInput, UserInput +from permit.exceptions import PermitConnectionError +from permit.utils.context import Context, ContextStore +from permit.utils.dicts import deep_merge +from permit.utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.sync import SyncClass + +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import parse_obj_as +elif PYDANTIC_VERSION < (2, 0): from pydantic import parse_obj_as else: - from pydantic.v1 import parse_obj_as # type: ignore + from pydantic.v1 import parse_obj_as RESOURCE_DELIMITER = ":" -User = Union[dict, str] +# Public aliases kept exactly as they were (bare `dict`, `typing.Union`): unlike a +# parameterized form, they still work in `isinstance(value, User)`. +User = Union[dict, str] # type: ignore[type-arg] # noqa: UP007 Action = str -Resource = Union[dict, str] +Resource = Union[dict, str] # type: ignore[type-arg] # noqa: UP007 + +# A resource string is "type" or "type:key". +_MAX_RESOURCE_STRING_PARTS = 2 async def read_error_body(response: aiohttp.ClientResponse) -> str: @@ -50,19 +59,25 @@ async def read_error_body(response: aiohttp.ClientResponse) -> str: class CheckQuery(TypedDict): + """One authorization query of a `bulk_check()` call.""" + user: User action: Action resource: Resource - context: NotRequired[Optional[Context]] + context: NotRequired[Context | None] + + +SETUP_PDP_DOCS_LINK = "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" -SETUP_PDP_DOCS_LINK = ( - "https://docs.permit.io/sdk/python/quickstart-python/#2-setup-your-pdp-policy-decision-point-container" -) +class _TimeoutConfig(TypedDict, total=False): + timeout: ClientTimeout class Enforcer: - def __init__(self, config: PermitConfig): + """Sends authorization queries to the PDP.""" + + def __init__(self, config: PermitConfig) -> None: self._config = config self._context_store = ContextStore() self._headers = { @@ -72,16 +87,17 @@ def __init__(self, config: PermitConfig): self._base_url = self._config.pdp @property - def context_store(self): - """ - we let context store be accessed from the outside so that the - using app can setup a flexible contextual behavior for authorization queries + def context_store(self) -> ContextStore: + """The base context merged into every query. + + It is exposed so the application can set up flexible contextual behavior for + authorization queries. """ return self._context_store @property - def _timeout_config(self): - timeout_config = {} + def _timeout_config(self) -> _TimeoutConfig: + timeout_config: _TimeoutConfig = {} if self._config.pdp_timeout is not None: timeout_config["timeout"] = ClientTimeout(total=self._config.pdp_timeout) return timeout_config @@ -90,24 +106,25 @@ async def authorized_users( self, action: Action, resource: Resource, - context: Optional[Context] = None, + context: Context | None = None, ) -> AuthorizedUsersResult: - """ - Queries to get all the users that are authorized to perform an action on a resource within the specified context. + """Get all the users authorized to perform an action on a resource in a context. Args: action: The action to be performed on the resource. resource: The resource object representing the resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: - AuthorizedUsersResult: Contains all the authorized users and the role assignments that granted the permission. + AuthorizedUsersResult: Contains all the authorized users and the role assignments that + granted the permission. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # all the users that can close any issue? await permit.authorized_users('close', 'issue') @@ -117,14 +134,16 @@ async def authorized_users( # all the users that can close (any) issues belonging to the 't1' tenant? # (in a multi tenant application) await permit.authorized_users('close', {'type': 'issue', 'tenant': 't1'}) - """ # noqa: E501 + """ context = context or {} normalized_resource: ResourceInput = self._normalize_resource( - self._resource_from_string(resource) if isinstance(resource, str) else ResourceInput(**resource) + self._resource_from_string(resource) + if isinstance(resource, str) + else ResourceInput(**resource) ) query_context = self._context_store.get_derived_context(context) - input = { + request_body = { "action": action, "resource": normalized_resource.dict(exclude_unset=True), "context": query_context, @@ -135,18 +154,22 @@ async def authorized_users( try: async with session.post( check_url, - data=json.dumps(input), + data=json.dumps(request_body), ) as response: - if response.status != 200: - if response.status == 501: - raise PermitConnectionError( - f"Permit SDK got an error: {response.status}, and cannot connect to the PDP container." + if response.status != HTTPStatus.OK: + if response.status == HTTPStatus.NOT_IMPLEMENTED: + msg = ( + f"Permit SDK got an error: {response.status}, " + f"and cannot connect to the PDP container." f"\nPlease ensure you are not using ABAC/ReBAC policies," - f"as the cloud PDP is not compatible with these kinds of policies.\n" + f"as the cloud PDP is not compatible with these kinds " + f"of policies.\n" f"Also, please check your configuration and " - f"make sure it's running at {self._base_url} and accepting requests.\n" + f"make sure it's running at {self._base_url} " + f"and accepting requests.\n" f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) + raise PermitConnectionError(msg) error_body = await read_error_body(response) logger.error( @@ -157,7 +180,7 @@ async def authorized_users( error_body, ) ) - raise PermitConnectionError( + msg = ( f"Permit SDK got unexpected status code: {response.status} " f"from the PDP at {self._base_url}.\nResponse body: {error_body}\n" f"The PDP is reachable, so this is a rejected request rather than a " @@ -165,11 +188,12 @@ async def authorized_users( f"with a different API key than the SDK is using.\n" f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) + raise PermitConnectionError(msg) - content: dict = await response.json() + content: dict[str, Any] = await response.json() logger.debug( f"permit.authorized_users() response:" - f"\ninput: {pformat(input, indent=2)}" + f"\ninput: {pformat(request_body, indent=2)}" f"\nresponse status: {response.status}" f"\nresponse data: {pformat(content, indent=2)}" ) @@ -177,38 +201,44 @@ async def authorized_users( return result except aiohttp.ClientError as err: logger.error( - f"error in permit.authorized_users({action}, {self._resource_repr(normalized_resource)}):\n{err}" + f"error in permit.authorized_users({action}, " + f"{self._resource_repr(normalized_resource)}):\n{err}" ) - raise PermitConnectionError( + msg = ( f"Permit SDK got error: {err}, and cannot connect to the PDP container.\n" f"Please check your configuration and make sure it's running at " f"{self._base_url} and accepting requests.\n " - f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" + ) + raise PermitConnectionError( + msg, error=err, ) from err async def bulk_check( self, - checks: List[CheckQuery], - context: Optional[Context] = None, - ) -> List[bool]: - """ - Checks if a user is authorized to perform an action on a resource within the specified context. + checks: list[CheckQuery], + context: Context | None = None, + ) -> list[bool]: + """Checks if a user is authorized to perform an action on a resource in a context. Args: - checks: A list of CheckQuery objects representing the authorization queries to be performed. + checks: A list of CheckQuery objects representing the authorization queries to be + performed. Each check may carry its own ``context``, which is merged over the method-level ``context`` for that check only. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: - list[bool]: A list of booleans indicating whether the user is authorized for each resource. + list[bool]: A list of booleans indicating whether the user is authorized for each + resource. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # Bulk query of multiple check conventions await permit.bulk_check([ { @@ -229,10 +259,12 @@ async def bulk_check( ]) """ context = context or {} - input = [] + request_body = [] for check in checks: normalized_user: UserInput = ( - UserInput(key=check["user"]) if isinstance(check["user"], str) else UserInput(**check["user"]) + UserInput(key=check["user"]) + if isinstance(check["user"], str) + else UserInput(**check["user"]) ) normalized_resource: ResourceInput = self._normalize_resource( self._resource_from_string(check["resource"]) @@ -240,8 +272,10 @@ async def bulk_check( else ResourceInput(**check["resource"]) ) check_context: Context = check.get("context") or {} - query_context = self._context_store.get_derived_context(deep_merge(context, check_context)) - input.append( + query_context = self._context_store.get_derived_context( + deep_merge(context, check_context) + ) + request_body.append( { "user": normalized_user.dict(exclude_unset=True), "action": check["action"], @@ -255,9 +289,9 @@ async def bulk_check( try: async with session.post( check_url, - data=json.dumps(input), + data=json.dumps(request_body), ) as response: - if response.status != 200: + if response.status != HTTPStatus.OK: error_body = await read_error_body(response) msg = "error in permit.check({}):\n{}\n{}".format( ( @@ -267,7 +301,7 @@ async def bulk_check( check.get("action"), check.get("resource"), ] - for check in input + for check in request_body ] ), f"status code: {response.status}", @@ -275,15 +309,15 @@ async def bulk_check( ) logger.error(msg) raise PermitConnectionError(msg) - content: dict = await response.json() + content: dict[str, Any] = await response.json() logger.debug( f"permit.check() response:\n" - f"input: {pformat(input, indent=2)}\n" + f"input: {pformat(request_body, indent=2)}\n" f"response status: {response.status}\n" f"response data: {pformat(content, indent=2)}" ) data = content.get("allow", content.get("result", {}).get("allow", [])) - decisions: List[bool] = [bool(item.get("allow", False)) for item in data] + decisions: list[bool] = [bool(item.get("allow", False)) for item in data] except aiohttp.ClientError as err: msg = "error in permit.check({}):\n{}".format( ( @@ -293,7 +327,7 @@ async def bulk_check( check.get("action"), check.get("resource"), ] - for check in input + for check in request_body ] ), err, @@ -307,25 +341,25 @@ async def check( user: User, action: Action, resource: Resource, - context: Optional[Context] = None, + context: Context | None = None, ) -> bool: - """ - Checks if a user is authorized to perform an action on a resource within the specified context. + """Checks if a user is authorized to perform an action on a resource in a context. Args: user: The user object representing the user. action: The action to be performed on the resource. resource: The resource object representing the resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: bool: True if the user is authorized, False otherwise. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # can the user close any issue? await permit.check(user, 'close', 'issue') @@ -338,9 +372,13 @@ async def check( """ context = context or {} - normalized_user: UserInput = UserInput(key=user) if isinstance(user, str) else UserInput(**user) + normalized_user: UserInput = ( + UserInput(key=user) if isinstance(user, str) else UserInput(**user) + ) normalized_resource: ResourceInput = self._normalize_resource( - self._resource_from_string(resource) if isinstance(resource, str) else ResourceInput(**resource) + self._resource_from_string(resource) + if isinstance(resource, str) + else ResourceInput(**resource) ) query_context = self._context_store.get_derived_context(context) body = { @@ -356,16 +394,19 @@ async def check( check_url, data=json.dumps(body), ) as response: - if response.status != 200: - if response.status == 501: - raise PermitConnectionError( - f"Permit SDK got an error: {response.status}, and cannot connect to the PDP container." + if response.status != HTTPStatus.OK: + if response.status == HTTPStatus.NOT_IMPLEMENTED: + msg = ( + f"Permit SDK got an error: {response.status}, " + f"and cannot connect to the PDP container." f"\nPlease ensure you are not using ABAC/ReBAC policies,\n" - f"as the cloud PDP is not compatible with these kinds of policies.\n" + f"as the cloud PDP is not compatible with these kinds " + f"of policies.\n" f"Also, please check your configuration and make sure it's running " f"at {self._base_url} and accepting requests.\n" f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) + raise PermitConnectionError(msg) error_body = await read_error_body(response) logger.error( @@ -377,7 +418,7 @@ async def check( error_body, ) ) - raise PermitConnectionError( + msg = ( f"Permit SDK got unexpected status code: {response.status} " f"from the PDP at {self._base_url}.\nResponse body: {error_body}\n" f"The PDP is reachable, so this is a rejected request rather than a " @@ -385,8 +426,9 @@ async def check( f"with a different API key than the SDK is using.\n" f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) + raise PermitConnectionError(msg) - content: dict = await response.json() + content: dict[str, Any] = await response.json() logger.debug( f"permit.check() response:\n" f"body: {pformat(body, indent=2)}\n" @@ -397,24 +439,43 @@ async def check( return decision except aiohttp.ClientError as err: logger.error( - f"error in permit.check({normalized_user}, {action}, {self._resource_repr(normalized_resource)}):" + f"error in permit.check({normalized_user}, {action}, " + f"{self._resource_repr(normalized_resource)}):" f"\n{err}" ) - raise PermitConnectionError( + msg = ( f"Permit SDK got error: {err}, \n" - f"and cannot connect to the PDP container, please check your configuration and make sure it's " + f"and cannot connect to the PDP container, please check your configuration " + f"and make sure it's " f"running at {self._base_url} and accepting requests. \n" - f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" + ) + raise PermitConnectionError( + msg, error=err, ) from err async def get_user_permissions( self, - user: Union[dict, str], - tenants: Optional[List[str]] = None, - resources: Optional[List[str]] = None, - resource_types: Optional[List[str]] = None, - ) -> dict: + user: dict[str, Any] | str, + tenants: list[str] | None = None, + resources: list[str] | None = None, + resource_types: list[str] | None = None, + ) -> dict[str, Any]: + """Get all permissions of a user. + + Args: + user: The user object or user key. + tenants: Only return permissions in these tenants. + resources: Only return permissions on these resources. + resource_types: Only return permissions on these resource types. + + Returns: + The user's permissions per tenant and resource. + + Raises: + PermitConnectionError: If the PDP rejects the request or cannot be reached. + """ input_data = { "user": {"key": user} if isinstance(user, str) else user, "tenants": tenants, @@ -429,15 +490,22 @@ async def get_user_permissions( url, data=json.dumps(input_data), ) as response: - if response.status != 200: - raise PermitConnectionError( - f"Permit.getUserPermissions() got an unexpected status code: {response.status}, " - f"please check your SDK init and make sure the PDP sidecar is configured correctly.\n" + if response.status != HTTPStatus.OK: + msg = ( + f"Permit.getUserPermissions() got an unexpected status code: " + f"{response.status}, " + f"please check your SDK init and make sure the PDP sidecar " + f"is configured correctly.\n" f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" ) + raise PermitConnectionError(msg) content = await response.json() - permissions = content.get("result", {}).get("permissions", {}) if "result" in content else content + permissions: dict[str, Any] = ( + content.get("result", {}).get("permissions", {}) + if "result" in content + else content + ) logger.debug( f"permit.get_user_permissions() response:\n" @@ -448,17 +516,21 @@ async def get_user_permissions( except aiohttp.ClientError as err: logger.error(f"Error in permit.get_user_permissions(): {err}") - raise PermitConnectionError( + msg = ( f"Permit SDK got error: {err}, \n" - f"and cannot connect to the PDP container, please check your configuration and make sure it's " + f"and cannot connect to the PDP container, please check your configuration " + f"and make sure it's " f"running at {self._base_url} and accepting requests. \n" - f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}", + f"Read more about setting up the PDP at {SETUP_PDP_DOCS_LINK}" + ) + raise PermitConnectionError( + msg, error=err, ) from err async def filter_objects( - self, user: User, action: Action, context: Context, resources: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: + self, user: User, action: Action, context: Context, resources: list[dict[str, Any]] + ) -> list[dict[str, Any]]: """Filter the given resources down to the ones the user is allowed to act on. Args: @@ -471,20 +543,25 @@ async def filter_objects( Returns: list[dict]: The subset of ``resources`` the user is authorized for, in input order. """ - requests: List[CheckQuery] = [] + requests: list[CheckQuery] = [] for resource in resources: - permit_resource: Dict[str, Any] = { + permit_resource: dict[str, Any] = { "type": resource.get("type"), "key": resource.get("key"), "context": resource.get("context", {}), "attributes": resource.get("attributes", {}), "tenant": resource.get("tenant"), } - check_query: CheckQuery = {"user": user, "action": action, "resource": permit_resource, "context": context} + check_query: CheckQuery = { + "user": user, + "action": action, + "resource": permit_resource, + "context": context, + } requests.append(check_query) results = await self.bulk_check(requests, context=context) - filtered_resources: List[Dict[str, Any]] = [] + filtered_resources: list[dict[str, Any]] = [] for i, result in enumerate(results): if result: filtered_resources.append(resources[i]) @@ -495,12 +572,18 @@ def _normalize_resource(self, resource: ResourceInput) -> ResourceInput: if normalized_resource.context is None: normalized_resource.context = {} - # if tenant is empty, we migth auto-set the default tenant according to config - if normalized_resource.tenant is None and self._config.multi_tenancy.use_default_tenant_if_empty: + # if tenant is empty, we might auto-set the default tenant according to config + if ( + normalized_resource.tenant is None + and self._config.multi_tenancy.use_default_tenant_if_empty + ): normalized_resource.tenant = self._config.multi_tenancy.default_tenant # copy tenant from resource.tenant to resource.context.tenant (until we change RBAC policy) - if normalized_resource.context.get("tenant", None) is None and normalized_resource.tenant is not None: + if ( + normalized_resource.context.get("tenant", None) is None + and normalized_resource.tenant is not None + ): normalized_resource.context["tenant"] = normalized_resource.tenant return normalized_resource @@ -516,10 +599,11 @@ def _resource_repr(resource: ResourceInput) -> str: @staticmethod def _resource_from_string(resource: str) -> ResourceInput: parts = resource.split(RESOURCE_DELIMITER) - if len(parts) < 1 or len(parts) > 2: - raise ValueError(f"permit.check() got invalid resource string: {resource}") + if len(parts) < 1 or len(parts) > _MAX_RESOURCE_STRING_PARTS: + msg = f"permit.check() got invalid resource string: {resource}" + raise ValueError(msg) return ResourceInput(type=parts[0], key=(parts[1] if len(parts) > 1 else None)) class SyncEnforcer(Enforcer, metaclass=SyncClass): - pass + """Blocking variant of `Enforcer`.""" diff --git a/permit/enforcement/interfaces.py b/permit/enforcement/interfaces.py index 91cc279..a7de216 100644 --- a/permit/enforcement/interfaces.py +++ b/permit/enforcement/interfaces.py @@ -1,18 +1,25 @@ -from typing import Dict, List, Optional +from typing import TYPE_CHECKING, Any, Dict, List # noqa: UP035 - public alias below -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel, Field +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel, Field else: - from pydantic.v1 import BaseModel, Field # type: ignore + from pydantic.v1 import BaseModel, Field class UserKey(BaseModel): + """A user identified by key only.""" + key: str class AssignedRole(BaseModel): + """A role a user holds in a tenant.""" + role: str # role key tenant: str # tenant key @@ -28,33 +35,40 @@ class UserInput(UserKey): class Config: allow_population_by_field_name = True - first_name: Optional[str] = Field(None, alias="firstName") - last_name: Optional[str] = Field(None, alias="lastName") - email: Optional[str] = None - roles: Optional[List[AssignedRole]] = None - attributes: Optional[Dict] = None + first_name: str | None = Field(default=None, alias="firstName") + last_name: str | None = Field(default=None, alias="lastName") + email: str | None = None + roles: list[AssignedRole] | None = None + attributes: dict[Any, Any] | None = None class ResourceInput(BaseModel): + """A resource as sent to the PDP on an authorization query.""" + type: str # namespace/type of resources/objects - id: Optional[str] = None # id of individual object - key: Optional[str] = None # key of individual object - tenant: Optional[str] = None # tenant the resource belongs to - attributes: Optional[Dict] = None # extra resources attributes - context: Optional[Dict] = None # extra context + id: str | None = None # id of individual object + key: str | None = None # key of individual object + tenant: str | None = None # tenant the resource belongs to + attributes: dict[Any, Any] | None = None # extra resources attributes + context: dict[Any, Any] | None = None # extra context class AuthorizedUserAssignment(BaseModel): + """A role assignment that grants a user the queried permission.""" + user: str = Field(..., description="The user that is authorized") tenant: str = Field(..., description="The tenant that the user is authorized for") resource: str = Field(..., description="The resource that the user is authorized for") role: str = Field(..., description="The role that the user is assigned to") -AuthorizedUsersDict = Dict[str, List[AuthorizedUserAssignment]] +# Public alias; runtime object kept identical (a `typing` generic, not a builtin one). +AuthorizedUsersDict = Dict[str, List[AuthorizedUserAssignment]] # noqa: UP006 class AuthorizedUsersResult(BaseModel): + """The result of an `authorized_users()` query.""" + resource: str = Field( ..., description="The resource that the result is about." @@ -65,6 +79,7 @@ class AuthorizedUsersResult(BaseModel): ..., description="A key value mapping of the users that are " "authorized for the resource." - "The key is the user key and the value is a list of assignments allowing the user to perform" + "The key is the user key and the value is a list of assignments " + "allowing the user to perform" "the requested action", ) diff --git a/permit/exceptions.py b/permit/exceptions.py index 3c1fa6b..5a71176 100644 --- a/permit/exceptions.py +++ b/permit/exceptions.py @@ -1,52 +1,67 @@ import functools -from typing import Optional +import warnings +from collections.abc import Awaitable, Callable, Coroutine +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, TypeVar import aiohttp from loguru import logger -from typing_extensions import deprecated +from typing_extensions import ParamSpec, deprecated from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import ValidationError +elif PYDANTIC_VERSION < (2, 0): from pydantic import ValidationError else: - from pydantic.v1 import ValidationError # type: ignore[assignment] + from pydantic.v1 import ValidationError from permit.api.models import ErrorDetails, HTTPValidationError DEFAULT_SUPPORT_LINK = "https://permit-io.slack.com/ssb/redirect" +P = ParamSpec("P") +R = TypeVar("R") + class PermitError(Exception): - """Permit base exception""" + """Permit base exception.""" @deprecated("Use PermitError instead") -class PermitException(PermitError): # noqa: N818 - """Permit base exception (deprecated, use PermitError instead)""" +class PermitException(PermitError): # noqa: N818 - public name, kept for existing callers + """Permit base exception (deprecated, use PermitError instead).""" -class PermitConnectionError(PermitException): - """Permit connection exception +# Subclassing a `@deprecated` class warns (typing_extensions hooks `__init_subclass__`). +# This subclass is the SDK's own, so the warning is silenced here: importing the SDK +# stays warning-free, while code that subclasses or raises `PermitException` still warns. +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) - Note: this deliberately still inherits from the deprecated `PermitException` - rather than from `PermitError`. Re-parenting it looks like tidying, but it - silently breaks every consumer whose handler is `except PermitException` -- - a connection blip would stop being caught and become an unhandled crash. - That is a breaking change worth making, but it belongs in a major version - with a changelog entry, not in a dependency-security patch. - """ + class PermitConnectionError(PermitException): # type: ignore[deprecated] # kept, see docstring + """Permit connection exception. + + Note: this deliberately still inherits from the deprecated `PermitException` + rather than from `PermitError`. Re-parenting it looks like tidying, but it + silently breaks every consumer whose handler is `except PermitException` -- + a connection blip would stop being caught and become an unhandled crash. + That is a breaking change worth making, but it belongs in a major version + with a changelog entry, not in a dependency-security patch. + """ - def __init__(self, message: str, *, error: Optional[aiohttp.ClientError] = None): - super().__init__(message) - self.original_error = error + def __init__(self, message: str, *, error: aiohttp.ClientError | None = None) -> None: + super().__init__(message) + self.original_error = error class PermitContextError(PermitError): - """ - The `PermitContextError` class represents an error that occurs when an API method - is called with insufficient context (not knowing in what environment, project or - organization the API call is being made). + """An API method was called without the context it needs. + + The context tells the SDK in which environment, project or organization an + API call is being made. Some of the input for the API method is provided via the SDK context. If the context is missing some data required for a method - the api call will fail. @@ -54,23 +69,21 @@ class PermitContextError(PermitError): class PermitContextChangeError(PermitError): - """ - The `PermitContextChangeError` will be thrown when the user is trying to set the - SDK context to an object that the current API Key cannot access (and if allowed, - such api calls will result is 401). Instead, the SDK throws this exception. + """The SDK context was set to an object the current API key cannot access. + + API calls made in such a context would fail with 401, so the SDK refuses to + switch to it and raises this exception instead. """ class PermitApiError(PermitError): - """ - Wraps an error HTTP Response that occurred during a Permit REST API request. - """ + """Wraps an error HTTP Response that occurred during a Permit REST API request.""" def __init__( self, response: aiohttp.ClientResponse, - body: Optional[dict] = None, - ): + body: dict[str, Any] | None = None, + ) -> None: super().__init__() self._response = response self._body = body @@ -78,17 +91,17 @@ def __init__( def _get_message(self) -> str: return f"{self.status_code} API Error: {self.details}" - def __str__(self): + def __str__(self) -> str: return self._get_message() @property def message(self) -> str: + """The human-readable error message, as `str(error)` renders it.""" return self._get_message() @property def response(self) -> aiohttp.ClientResponse: - """ - Get the HTTP response that returned an error status code + """Get the HTTP response that returned an error status code. Returns: The HTTP response object. @@ -96,9 +109,8 @@ def response(self) -> aiohttp.ClientResponse: return self._response @property - def details(self) -> Optional[dict]: - """ - Get the HTTP response JSON body. Contains details about the error. + def details(self) -> dict[str, Any] | None: + """Get the HTTP response JSON body. Contains details about the error. Returns: The HTTP response json. If no content will return None. @@ -107,8 +119,7 @@ def details(self) -> Optional[dict]: @property def request_url(self) -> str: - """ - Get the HTTP request URL that caused the error code. + """Get the HTTP request URL that caused the error code. Returns: The HTTP request url @@ -117,8 +128,7 @@ def request_url(self) -> str: @property def status_code(self) -> int: - """ - Get the HTTP response status code + """Get the HTTP response status code. Returns: The status code returned. @@ -126,9 +136,8 @@ def status_code(self) -> int: return self._response.status @property - def content_type(self) -> Optional[str]: - """ - Get the HTTP content type header of the error response. + def content_type(self) -> str | None: + """Get the HTTP content type header of the error response. Returns: The value of the HTTP Response Content-type header, or None @@ -137,11 +146,11 @@ def content_type(self) -> Optional[str]: class PermitValidationError(PermitApiError): - """ - Validation error response from the Permit API. - """ + """Validation error response from the Permit API.""" - def __init__(self, response: aiohttp.ClientResponse, content: HTTPValidationError, body: dict): + def __init__( + self, response: aiohttp.ClientResponse, content: HTTPValidationError, body: dict[str, Any] + ) -> None: self._content = content super().__init__(response, body) @@ -155,15 +164,16 @@ def _get_message(self) -> str: @property def content(self) -> HTTPValidationError: + """The parsed validation error body: one entry per invalid input.""" return self._content class PermitApiDetailedError(PermitApiError): - """ - Detailed error response from the Permit API. - """ + """Detailed error response from the Permit API.""" - def __init__(self, response: aiohttp.ClientResponse, content: ErrorDetails, body: dict): + def __init__( + self, response: aiohttp.ClientResponse, content: ErrorDetails, body: dict[str, Any] + ) -> None: self._content = content super().__init__(response, body) @@ -177,47 +187,62 @@ def _get_message(self) -> str: @property def content(self) -> ErrorDetails: + """The parsed error body.""" return self._content @property def id(self) -> str: + """The request ID, for reference when contacting Permit support.""" return self.content.id @property def code(self) -> str: + """The machine-readable error code.""" return self.content.error_code.value @property def title(self) -> str: + """A short summary of the error.""" return self.content.title @property def explanation(self) -> str: + """The API's explanation of the error, or a placeholder when it gave none.""" return self.content.message or "No further explanation provided" @property def support_link(self) -> str: + """Where to get help with this error.""" return str(self.content.support_link or DEFAULT_SUPPORT_LINK) @property - def additional_info(self): + def additional_info(self) -> Any: # noqa: ANN401 - arbitrary JSON sent by the API + """Extra error-specific data from the API, if any.""" return self.content.additional_info class PermitAlreadyExistsError(PermitApiDetailedError): - """ - Object already exists response from the Permit API. - """ + """Object already exists response from the Permit API.""" class PermitNotFoundError(PermitApiDetailedError): - """ - Object not found response from the Permit API. - """ + """Object not found response from the Permit API.""" + +async def handle_api_error(response: aiohttp.ClientResponse) -> None: + """Raise the matching SDK exception if `response` has a non-2xx status. -async def handle_api_error(response: aiohttp.ClientResponse): - if 200 <= response.status < 300: + Args: + response: The Permit REST API response to inspect. + + Raises: + PermitValidationError: On 422 with a validation error body. + PermitAlreadyExistsError: On 409. + PermitNotFoundError: On 404. + PermitApiDetailedError: On any other error status with a detailed error body. + PermitApiError: When the error body is not JSON or has an unexpected shape. + """ + if HTTPStatus.OK <= response.status < HTTPStatus.MULTIPLE_CHOICES: return try: @@ -226,7 +251,7 @@ async def handle_api_error(response: aiohttp.ClientResponse): text = await response.text() raise PermitApiError(response, {"details": text}) from e - if response.status == 422: + if response.status == HTTPStatus.UNPROCESSABLE_ENTITY: try: validation_content = HTTPValidationError.parse_obj(json) except ValidationError as e: @@ -239,21 +264,32 @@ async def handle_api_error(response: aiohttp.ClientResponse): except ValidationError as e: raise PermitApiError(response, json) from e - if response.status == 409: + if response.status == HTTPStatus.CONFLICT: raise PermitAlreadyExistsError(response, content, json) - elif response.status == 404: + if response.status == HTTPStatus.NOT_FOUND: raise PermitNotFoundError(response, content, json) - else: - raise PermitApiDetailedError(response, content, json) + raise PermitApiDetailedError(response, content, json) -def handle_client_error(func): +def handle_client_error( + func: Callable[P, Awaitable[R]], +) -> Callable[P, Coroutine[Any, Any, R]]: + """Re-raise aiohttp client errors from `func` as `PermitConnectionError`. + + Args: + func: The coroutine function sending the HTTP request. + + Returns: + A coroutine function with the same signature. + """ + @functools.wraps(func) - async def wrapped(*args, **kwargs): + async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: try: return await func(*args, **kwargs) except aiohttp.ClientError as err: logger.error(f"got client error while sending an http request:\n{err}") - raise PermitConnectionError(f"{err}", error=err) from err + msg = f"{err}" + raise PermitConnectionError(msg, error=err) from err return wrapped diff --git a/permit/logger.py b/permit/logger.py index b4c05ee..d1677f8 100644 --- a/permit/logger.py +++ b/permit/logger.py @@ -1,10 +1,15 @@ from loguru import logger -from .config import PermitConfig +from permit.config import PermitConfig PERMIT_MODULE = "permit" -def configure_logger(config: PermitConfig): +def configure_logger(config: PermitConfig) -> None: + """Silence the SDK's loguru output unless the config enables logging. + + Args: + config: The SDK configuration; only `config.log.enable` is read. + """ if not config.log.enable: logger.disable(PERMIT_MODULE) diff --git a/permit/pdp_api/base.py b/permit/pdp_api/base.py index 108e5f0..0bfcc29 100644 --- a/permit/pdp_api/base.py +++ b/permit/pdp_api/base.py @@ -1,3 +1,5 @@ +from typing import Any + from permit import PermitConfig from permit.api.base import ClientConfig, SimpleHttpClient, pagination_params @@ -5,20 +7,17 @@ class BasePdpPermitApi: - """ - The base class for Permit APIs. - """ + """The base class for Permit APIs.""" - def __init__(self, config: PermitConfig): - """ - Initialize a BasePermitApi. + def __init__(self, config: PermitConfig) -> None: + """Initialize a BasePermitApi. Args: config: The Permit SDK configuration. """ self.config = config - def _build_http_client(self, endpoint_url: str = "", **kwargs): + def _build_http_client(self, endpoint_url: str = "", **kwargs: Any) -> SimpleHttpClient: client_config = ClientConfig( base_url=f"{self.config.pdp}", headers={ diff --git a/permit/pdp_api/models.py b/permit/pdp_api/models.py index 2b102d5..2919270 100644 --- a/permit/pdp_api/models.py +++ b/permit/pdp_api/models.py @@ -1,25 +1,29 @@ # generated by datamodel-codegen: # filename: open.json (local PDP) # timestamp: 2024-04-09T15:36:45+00:00 - from __future__ import annotations -from typing import Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel, Field +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel, Field else: - from pydantic.v1 import BaseModel, Field # type: ignore + from pydantic.v1 import BaseModel, Field class RoleAssignment(BaseModel): + """A role granted to a user in a tenant, optionally on one resource instance.""" + user: str = Field(..., description="the user the role is assigned to", title="User") role: str = Field(..., description="the role that is assigned", title="Role") tenant: str = Field(..., description="the tenant the role is associated with", title="Tenant") - resource_instance: Optional[str] = Field( - None, + resource_instance: str | None = Field( + default=None, description="the resource instance the role is associated with", title="Resource Instance", ) diff --git a/permit/pdp_api/pdp_api_client.py b/permit/pdp_api/pdp_api_client.py index 08ffa39..ab6c476 100644 --- a/permit/pdp_api/pdp_api_client.py +++ b/permit/pdp_api/pdp_api_client.py @@ -1,17 +1,17 @@ +from permit.config import PermitConfig +from permit.pdp_api.role_assignments import RoleAssignmentsApi from permit.utils.sync import SyncClass -from ..config import PermitConfig -from .role_assignments import RoleAssignmentsApi - class SyncRoleAssignmentsApi(RoleAssignmentsApi, metaclass=SyncClass): - pass + """Blocking variant of `RoleAssignmentsApi`.""" class PermitPdpApiClient: - def __init__(self, config: PermitConfig): - """ - Constructs a new instance of the PdpApiClient class with the specified SDK configuration. + """Entry point to the APIs served by the PDP itself.""" + + def __init__(self, config: PermitConfig) -> None: + """Constructs a new instance of the PdpApiClient class with the specified SDK configuration. Args: config: The configuration for the Permit SDK. @@ -27,14 +27,18 @@ def __init__(self, config: PermitConfig): @property def role_assignments(self) -> RoleAssignmentsApi: + """Role assignments as the PDP currently sees them.""" return self._role_assignments class SyncPDPApi(PermitPdpApiClient): - def __init__(self, config: PermitConfig): + """Blocking variant of `PermitPdpApiClient`.""" + + def __init__(self, config: PermitConfig) -> None: super().__init__(config) self._role_assignments = SyncRoleAssignmentsApi(config) @property def role_assignments(self) -> SyncRoleAssignmentsApi: - return self._role_assignments # type: ignore[return-value] + """Role assignments as the PDP currently sees them.""" + return self._role_assignments # type: ignore[return-value] # set to the sync type diff --git a/permit/pdp_api/role_assignments.py b/permit/pdp_api/role_assignments.py index a0abec7..614acc2 100644 --- a/permit/pdp_api/role_assignments.py +++ b/permit/pdp_api/role_assignments.py @@ -1,41 +1,48 @@ -from typing import List, Optional +from typing import TYPE_CHECKING -from permit import PYDANTIC_VERSION from permit.api.base import SimpleHttpClient from permit.pdp_api.base import BasePdpPermitApi, pagination_params from permit.pdp_api.models import RoleAssignment +from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import validate_arguments +elif PYDANTIC_VERSION < (2, 0): from pydantic import validate_arguments else: from pydantic.v1 import validate_arguments class RoleAssignmentsApi(BasePdpPermitApi): + """Read role assignments from the PDP's local cache.""" + @property def __role_assignments(self) -> SimpleHttpClient: return self._build_http_client("/local/role_assignments") - @validate_arguments # type: ignore[operator] - async def list( + @validate_arguments + async def list( # noqa: PLR0917 - public signature; callers may pass these positionally self, - user_key: Optional[str] = None, - role_key: Optional[str] = None, - tenant_key: Optional[str] = None, - resource_key: Optional[str] = None, - resource_instance_key: Optional[str] = None, + user_key: str | None = None, + role_key: str | None = None, + tenant_key: str | None = None, + resource_key: str | None = None, + resource_instance_key: str | None = None, page: int = 1, per_page: int = 100, - ) -> List[RoleAssignment]: - """ - Retrieves a list of role assignments based on the specified filters. + ) -> list[RoleAssignment]: + """Retrieves a list of role assignments based on the specified filters. Args: user_key: optional user filter, will only return role assignments granted to this user. role_key: optional role filter, will only return role assignments granting this role. - tenant_key: optional tenant filter, will only return role assignments granted in that tenant. - resource_key: optional resource type filter, will only return role assignments granted on that resource type. - resource_instance_key: optional resource instance filter, will only return role assignments granted on that resource instance. + tenant_key: optional tenant filter, will only return role assignments granted in that + tenant. + resource_key: optional resource type filter, will only return role assignments granted + on that resource type. + resource_instance_key: optional resource instance filter, will only return role + assignments granted on that resource instance. page: The page number to fetch (default: 1). per_page: How many items to fetch per page (default: 100). @@ -44,8 +51,9 @@ async def list( Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ # noqa: E501 + PermitContextError: If the configured ApiContext does not match the required endpoint + context. + """ params = pagination_params(page, per_page) if user_key is not None: params.update(user=user_key) @@ -59,6 +67,6 @@ async def list( params.update(resource_instance=resource_instance_key) return await self.__role_assignments.get( "", - model=List[RoleAssignment], + model=list[RoleAssignment], params=params, ) diff --git a/permit/permit.py b/permit/permit.py index 17f50c0..51d6f51 100644 --- a/permit/permit.py +++ b/permit/permit.py @@ -1,28 +1,37 @@ import json +from collections.abc import Generator from contextlib import contextmanager -from typing import Any, Dict, Generator, List, Literal, Optional +from typing import Any, Literal from loguru import logger from typing_extensions import Self -from .api.api_client import PermitApiClient -from .api.elements import ElementsApi -from .config import PermitConfig -from .enforcement.enforcer import ( +from permit.api.api_client import PermitApiClient +from permit.api.elements import ElementsApi +from permit.config import PermitConfig +from permit.enforcement.enforcer import ( Action, - AuthorizedUsersResult, CheckQuery, Enforcer, Resource, User, ) -from .logger import configure_logger -from .pdp_api.pdp_api_client import PermitPdpApiClient -from .utils.context import Context +from permit.enforcement.interfaces import AuthorizedUsersResult +from permit.logger import configure_logger +from permit.pdp_api.pdp_api_client import PermitPdpApiClient +from permit.utils.context import Context class Permit: - def __init__(self, config: Optional[PermitConfig] = None, **options): + """The Permit SDK client (asyncio): authorization checks and the Permit REST API. + + Args: + config: The SDK configuration. + **options: `PermitConfig` fields, used to build the configuration when `config` + is not given. + """ + + def __init__(self, config: PermitConfig | None = None, **options: Any) -> None: self._config: PermitConfig = config if config is not None else PermitConfig(**options) configure_logger(self._config) @@ -36,9 +45,9 @@ def __init__(self, config: Optional[PermitConfig] = None, **options): ) @property - def config(self): - """ - Access the SDK configuration using this property. + def config(self) -> PermitConfig: + """Access the SDK configuration using this property. + Once the SDK is initialized, the configuration is read-only. Usage example: @@ -50,19 +59,20 @@ def config(self): @contextmanager def wait_for_sync( - self, timeout: float = 10.0, policy: Optional[Literal["ignore", "fail"]] = None + self, timeout: float = 10.0, policy: Literal["ignore", "fail"] | None = None ) -> Generator[Self, None, None]: - """ - Context manager that returns a client that is configured - to wait for facts to be synced before proceeding. + """Context manager returning a client that waits for facts to be synced. + Requests made through the returned client wait for the facts they write to be + available in the PDP before proceeding. Args: timeout: The amount of time in seconds to wait for facts to be available in the PDP cache before returning the response. policy: Weather to fail the request when the timeout is reached or ignore. - Set None to keep the default policy set in the instance config or the default value of PDP. + Set None to keep the default policy set in the instance config or the default value of + PDP. Yields: Permit: A Permit instance that is configured to wait for facts to be synced. @@ -71,7 +81,9 @@ def wait_for_sync( https://docs.permit.io/how-to/manage-data/local-facts-uploader """ if not self._config.proxy_facts_via_pdp: - logger.warning("Tried to wait for synced facts but proxy_facts_via_pdp is disabled, ignoring...") + logger.warning( + "Tried to wait for synced facts but proxy_facts_via_pdp is disabled, ignoring..." + ) yield self return contextualized_config = self.config # this copies the config @@ -82,8 +94,7 @@ def wait_for_sync( @property def api(self) -> PermitApiClient: - """ - Access the Permit REST API using this property. + """Access the Permit REST API using this property. Usage example: @@ -94,8 +105,7 @@ def api(self) -> PermitApiClient: @property def elements(self) -> ElementsApi: - """ - Access the Permit Elements API using this property. + """Access the Permit Elements API using this property. Usage example: @@ -106,8 +116,7 @@ def elements(self) -> ElementsApi: @property def pdp_api(self) -> PermitPdpApiClient: - """ - Access the Permit PDP API using this property. + """Access the Permit PDP API using this property. Usage example: @@ -120,24 +129,25 @@ async def authorized_users( self, action: Action, resource: Resource, - context: Optional[Context] = None, + context: Context | None = None, ) -> AuthorizedUsersResult: - """ - Queries to get all the users that are authorized to perform an action on a resource within the specified context. + """Get all the users authorized to perform an action on a resource in a context. Args: action: The action to be performed on the resource. resource: The resource object representing the resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: - AuthorizedUsersResult: Contains all the authorized users and the role assignments that granted the permission. + AuthorizedUsersResult: Contains all the authorized users and the role assignments that + granted the permission. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # all the users that can close any issue? await permit.authorized_users('close', 'issue') @@ -147,29 +157,30 @@ async def authorized_users( # all the users that can close (any) issues belonging to the 't1' tenant? # (in a multi tenant application) await permit.authorized_users('close', {'type': 'issue', 'tenant': 't1'}) - """ # noqa: E501 + """ return await self._enforcer.authorized_users(action, resource, context) async def bulk_check( self, - checks: List[CheckQuery], - context: Optional[Context] = None, - ) -> List[bool]: - """ - Checks if a user is authorized to perform an action on a list of resources within the specified context. + checks: list[CheckQuery], + context: Context | None = None, + ) -> list[bool]: + """Checks many authorization queries in a single request to the PDP. Args: checks: A list of check queries, each query contain user, action, and resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: - list[bool]: A list of booleans indicating whether the user is authorized for each resource. + list[bool]: A list of booleans indicating whether the user is authorized for each + resource. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # Bulk query of multiple check conventions await permit.bulk_check([ { @@ -196,25 +207,25 @@ async def check( user: User, action: Action, resource: Resource, - context: Optional[Context] = None, + context: Context | None = None, ) -> bool: - """ - Checks if a user is authorized to perform an action on a resource within the specified context. + """Checks if a user is authorized to perform an action on a resource in a context. Args: user: The user object representing the user. action: The action to be performed on the resource. resource: The resource object representing the resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: bool: True if the user is authorized, False otherwise. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # can the user close any issue? await permit.check(user, 'close', 'issue') @@ -230,12 +241,11 @@ async def check( async def get_user_permissions( self, user: User, - tenants: Optional[List[str]] = None, - resources: Optional[List[str]] = None, - resource_types: Optional[List[str]] = None, - ) -> dict: - """ - Get all permissions for a user. + tenants: list[str] | None = None, + resources: list[str] | None = None, + resource_types: list[str] | None = None, + ) -> dict[str, Any]: + """Get all permissions for a user. Args: user: The user object or user key @@ -252,10 +262,9 @@ async def get_user_permissions( return await self._enforcer.get_user_permissions(user, tenants, resources, resource_types) async def filter_objects( - self, user: User, action: Action, context: Context, resources: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: - """ - Filter a list of resources, keeping only those the user is permitted to act on. + self, user: User, action: Action, context: Context, resources: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Filter a list of resources, keeping only those the user is permitted to act on. Args: user: The user object or user key @@ -265,7 +274,7 @@ async def filter_objects( `type`, `key`, `context`, `attributes` and `tenant`. Returns: - List[Dict[str, Any]]: The permitted subset of `resources`, in their original order + list[dict[str, Any]]: The permitted subset of `resources`, in their original order Raises: PermitConnectionError: If an error occurs while sending the request to the PDP diff --git a/permit/sync.py b/permit/sync.py index 8aa9865..c34503f 100644 --- a/permit/sync.py +++ b/permit/sync.py @@ -1,23 +1,34 @@ -from typing import Any, Dict, List, Optional +from typing import Any -from .api.elements import SyncElementsApi -from .api.sync_api_client import SyncPermitApiClient -from .config import PermitConfig -from .enforcement.enforcer import ( +from permit.api.elements import SyncElementsApi +from permit.api.sync_api_client import SyncPermitApiClient +from permit.config import PermitConfig +from permit.enforcement.enforcer import ( Action, - AuthorizedUsersResult, CheckQuery, Resource, SyncEnforcer, User, ) -from .pdp_api.pdp_api_client import SyncPDPApi -from .permit import Permit as AsyncPermit -from .utils.context import Context +from permit.enforcement.interfaces import AuthorizedUsersResult +from permit.pdp_api.pdp_api_client import SyncPDPApi +from permit.permit import Permit as AsyncPermit +from permit.utils.context import Context +# The overrides below return plain values where the async base class returns +# coroutines. That breaks substitutability on purpose -- it is what makes this the +# blocking client -- hence the `override` and `return-value` ignores. class Permit(AsyncPermit): - def __init__(self, config: Optional[PermitConfig] = None, **options): + """The Permit SDK client with a blocking interface. + + Args: + config: The SDK configuration. + **options: `PermitConfig` fields, used to build the configuration when `config` + is not given. + """ + + def __init__(self, config: PermitConfig | None = None, **options: Any) -> None: super().__init__(config, **options) self._enforcer = SyncEnforcer(self._config) self._api = SyncPermitApiClient(self._config) # type: ignore[assignment] @@ -26,8 +37,7 @@ def __init__(self, config: Optional[PermitConfig] = None, **options): @property def api(self) -> SyncPermitApiClient: # type: ignore[override] - """ - Access the Permit REST API using this property. + """Access the Permit REST API using this property. Usage example: @@ -38,8 +48,7 @@ def api(self) -> SyncPermitApiClient: # type: ignore[override] @property def elements(self) -> SyncElementsApi: - """ - Access the Permit Elements API using this property. + """Access the Permit Elements API using this property. Usage example: @@ -50,8 +59,7 @@ def elements(self) -> SyncElementsApi: @property def pdp_api(self) -> SyncPDPApi: - """ - Access the Permit PDP API using this property. + """Access the Permit PDP API using this property. Usage example: permit = Permit(token="") @@ -61,24 +69,26 @@ def pdp_api(self) -> SyncPDPApi: def bulk_check( # type: ignore[override] self, - checks: List[CheckQuery], - context: Optional[Context] = None, - ) -> List[bool]: - """ - Checks if a user is authorized to perform an action on a list of resources within the specified context. + checks: list[CheckQuery], + context: Context | None = None, + ) -> list[bool]: + """Checks many authorization queries in a single request to the PDP. Args: - checks: A list of CheckQuery objects representing the authorization checks to be performed. - context: The context object representing the context in which the action is performed. Defaults to None. + checks: A list of CheckQuery objects representing the authorization checks to be + performed. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: - list[bool]: A list of booleans indicating whether the user is authorized for each resource. + list[bool]: A list of booleans indicating whether the user is authorized for each + resource. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # Bulk query of multiple check conventions await permit.bulk_check([ { @@ -105,25 +115,25 @@ def check( # type: ignore[override] user: User, action: Action, resource: Resource, - context: Optional[Context] = None, + context: Context | None = None, ) -> bool: - """ - Checks if a user is authorized to perform an action on a resource within the specified context. + """Checks if a user is authorized to perform an action on a resource in a context. Args: user: The user object representing the user. action: The action to be performed on the resource. resource: The resource object representing the resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: bool: True if the user is authorized, False otherwise. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # can the user close any issue? permit.check(user, 'close', 'issue') @@ -140,24 +150,25 @@ def authorized_users( # type: ignore[override] self, action: Action, resource: Resource, - context: Optional[Context] = None, + context: Context | None = None, ) -> AuthorizedUsersResult: - """ - Queries to get all the users that are authorized to perform an action on a resource within the specified context. + """Get all the users authorized to perform an action on a resource in a context. Args: action: The action to be performed on the resource. resource: The resource object representing the resource. - context: The context object representing the context in which the action is performed. Defaults to None. + context: The context object representing the context in which the action is performed. + Defaults to None. Returns: - AuthorizedUsersResult: Contains all the authorized users and the role assignments that granted the permission. + AuthorizedUsersResult: Contains all the authorized users and the role assignments that + granted the permission. Raises: - PermitConnectionError: If an error occurs while sending the authorization request to the PDP. + PermitConnectionError: If an error occurs while sending the authorization request to the + PDP. Examples: - # all the users that can close any issue? permit.authorized_users('close', 'issue') @@ -167,18 +178,17 @@ def authorized_users( # type: ignore[override] # all the users that can close (any) issues belonging to the 't1' tenant? # (in a multi tenant application) permit.authorized_users('close', {'type': 'issue', 'tenant': 't1'}) - """ # noqa: E501 + """ return self._enforcer.authorized_users(action, resource, context) # type: ignore[return-value] def get_user_permissions( # type: ignore[override] self, user: User, - tenants: Optional[List[str]] = None, - resources: Optional[List[str]] = None, - resource_types: Optional[List[str]] = None, - ) -> dict: - """ - Get all permissions for a user. + tenants: list[str] | None = None, + resources: list[str] | None = None, + resource_types: list[str] | None = None, + ) -> dict[str, Any]: + """Get all permissions for a user. Args: user: The user object or user key @@ -197,10 +207,9 @@ def get_user_permissions( # type: ignore[override] ) def filter_objects( # type: ignore[override] - self, user: User, action: Action, context: Context, resources: List[Dict[str, Any]] - ) -> List[Dict[str, Any]]: - """ - Filter a list of resources, keeping only those the user is permitted to act on. + self, user: User, action: Action, context: Context, resources: list[dict[str, Any]] + ) -> list[dict[str, Any]]: + """Filter a list of resources, keeping only those the user is permitted to act on. Args: user: The user object or user key @@ -210,7 +219,7 @@ def filter_objects( # type: ignore[override] `type`, `key`, `context`, `attributes` and `tenant`. Returns: - List[Dict[str, Any]]: The permitted subset of `resources`, in their original order + list[dict[str, Any]]: The permitted subset of `resources`, in their original order Raises: PermitConnectionError: If an error occurs while sending the request to the PDP diff --git a/permit/utils/context.py b/permit/utils/context.py index caea821..577f0a0 100644 --- a/permit/utils/context.py +++ b/permit/utils/context.py @@ -1,16 +1,32 @@ -from typing import Any, Dict +from typing import Any, Dict # noqa: UP035 - public alias below -from .dicts import deep_merge +from permit.utils.dicts import deep_merge -Context = Dict[str, Any] +# Public alias; runtime object kept identical (a `typing` generic, not a builtin one). +Context = Dict[str, Any] # noqa: UP006 class ContextStore: - def __init__(self): + """A base context that is merged into the context of every authorization query.""" + + def __init__(self) -> None: self._base_context: Context = {} - def add(self, context: Context): + def add(self, context: Context) -> None: + """Deep-merge `context` into the base context. + + Args: + context: Values to add; they take precedence over what is already stored. + """ self._base_context = deep_merge(self._base_context, context) def get_derived_context(self, context: Context) -> Context: + """Build the context for one query: the base context overridden by `context`. + + Args: + context: The query's own context. + + Returns: + A new dict; the base context is left unchanged. + """ return deep_merge(self._base_context, context) diff --git a/permit/utils/deprecation.py b/permit/utils/deprecation.py index 49e21a1..bd935d4 100644 --- a/permit/utils/deprecation.py +++ b/permit/utils/deprecation.py @@ -1,23 +1,42 @@ from asyncio import iscoroutinefunction +from collections.abc import Awaitable, Callable from functools import wraps +from typing import TypeVar, cast from warnings import warn +from typing_extensions import ParamSpec -def deprecated(message: str): - def decorator(func): +P = ParamSpec("P") +R = TypeVar("R") + + +def deprecated(message: str) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Mark a function or coroutine function as deprecated. + + Every call emits a `DeprecationWarning` attributed to the caller. + + Args: + message: The warning text, typically naming the replacement. + + Returns: + A decorator that keeps the decorated function's signature. + """ + + def decorator(func: Callable[P, R]) -> Callable[P, R]: @wraps(func) - def wrapper(*args, **kwargs): + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: warn(message, DeprecationWarning, stacklevel=2) return func(*args, **kwargs) + async_func = cast("Callable[P, Awaitable[object]]", func) + @wraps(func) - async def async_wrapper(*args, **kwargs): + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> object: warn(message, DeprecationWarning, stacklevel=2) - return await func(*args, **kwargs) + return await async_func(*args, **kwargs) if iscoroutinefunction(func): - return async_wrapper - else: - return wrapper + return cast("Callable[P, R]", async_wrapper) + return wrapper return decorator diff --git a/permit/utils/dicts.py b/permit/utils/dicts.py index b7e98e7..8a7b715 100644 --- a/permit/utils/dicts.py +++ b/permit/utils/dicts.py @@ -1,13 +1,20 @@ from copy import deepcopy -from typing import Dict +from typing import Any -def deep_merge(base: Dict, overrides: Dict): - """ - merges two dicts recursively +def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: + """Merge two dicts recursively, without modifying either of them. + + Args: + base: The dict to start from. + overrides: Values that take precedence over `base`. Nested dicts are merged + key by key; any other value replaces what `base` has. + + Returns: + A new dict holding the merged result. """ result = base.copy() # create a clean copy of base - for key in overrides: + for key in overrides: # noqa: PLC0206 - reads overrides[key] as before (dict subclasses) if key not in result or not isinstance(result[key], dict): result[key] = deepcopy(overrides[key]) else: diff --git a/permit/utils/pydantic_version.py b/permit/utils/pydantic_version.py index 3f61cae..065afb7 100644 --- a/permit/utils/pydantic_version.py +++ b/permit/utils/pydantic_version.py @@ -1,3 +1,25 @@ +import re + import pydantic -PYDANTIC_VERSION = tuple(map(int, pydantic.__version__.split("."))) + +def _parse(version: str) -> tuple[int, ...]: + """Turn a pydantic version string into a tuple of ints, e.g. "2.14.0b2" -> (2, 14, 0). + + Only the leading digits of the first three components count, so a pre-release, + dev or local suffix does not stop the SDK from importing. + + Raises: + ValueError: A component does not start with a digit. + """ + parts = [] + for part in version.split(".")[:3]: + digits = re.match(r"[0-9]+", part) + if digits is None: + msg = f"Cannot parse pydantic version {version!r}: {part!r} does not start with a digit" + raise ValueError(msg) + parts.append(int(digits.group())) + return tuple(parts) + + +PYDANTIC_VERSION: tuple[int, ...] = _parse(pydantic.__version__) diff --git a/permit/utils/sync.py b/permit/utils/sync.py index a17914a..61f67b3 100644 --- a/permit/utils/sync.py +++ b/permit/utils/sync.py @@ -1,12 +1,13 @@ import asyncio import functools import inspect +from collections.abc import Awaitable, Callable, Coroutine from concurrent.futures import ThreadPoolExecutor from contextvars import ContextVar from functools import wraps -from typing import Any, Awaitable, Callable, Coroutine, Optional, Set, TypeVar, cast +from typing import Any, TypeGuard, TypeVar, cast -from typing_extensions import ParamSpec, TypeGuard +from typing_extensions import ParamSpec P = ParamSpec("P") T = TypeVar("T") @@ -69,14 +70,16 @@ def async_to_sync(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]: @wraps(func) def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: if _driving_coroutine.get(): - return func(*args, **kwargs) # type: ignore[return-value] + return func(*args, **kwargs) # type: ignore[return-value] # the driver awaits it return run_coroutine_sync(func(*args, **kwargs)) setattr(wrapper, SYNC_WRAPPER_MARKER, True) return wrapper -def iscoroutine_func(callable: Callable) -> TypeGuard[Callable[..., Awaitable]]: +def iscoroutine_func( + callable: Callable[..., object], # noqa: A002 - public parameter; renaming breaks keyword callers +) -> TypeGuard[Callable[..., Awaitable[object]]]: """Whether calling `callable` produces an awaitable. `inspect.iscoroutinefunction` on its own is not enough: a decorator may wrap @@ -92,8 +95,8 @@ def iscoroutine_func(callable: Callable) -> TypeGuard[Callable[..., Awaitable]]: Returns: True if calling it returns an awaitable. """ - candidate: Optional[Any] = callable - seen: Set[int] = set() + candidate: object | None = callable + seen: set[int] = set() while candidate is not None and id(candidate) not in seen: seen.add(id(candidate)) if getattr(candidate, SYNC_WRAPPER_MARKER, False): @@ -117,7 +120,8 @@ class SyncClass(type): bodies - every method they expose is inherited from their async counterpart. """ - def __new__(cls, name, bases, class_dict): + def __new__(cls, name: str, bases: tuple[type, ...], class_dict: dict[str, Any]) -> "SyncClass": + """Create the class, then replace each public coroutine method with a blocking wrapper.""" class_obj = super().__new__(cls, name, bases, class_dict) for attr_name in dir(class_obj): @@ -130,7 +134,7 @@ def __new__(cls, name, bases, class_dict): continue # monkey-patch public async method using the async_to_sync decorator - coroutine_function = cast(Callable[..., Coroutine[Any, Any, Any]], attr) + coroutine_function = cast("Callable[..., Coroutine[Any, Any, Any]]", attr) setattr(class_obj, attr_name, async_to_sync(coroutine_function)) return class_obj diff --git a/pyproject.toml b/pyproject.toml index 20cb52f..7cd0d52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,16 +37,17 @@ Repository = "https://github.com/permitio/permit-python" [dependency-groups] # Exact pins, so every developer, CI lane and the dev-ceiling audit tree -# resolve the same versions. Dependabot raises them. ruff and mypy match the -# hook revs in .pre-commit-config.yaml; the hooks install their own copies, -# and those are what CI lints and type-checks with. +# resolve the same versions. Dependabot raises them. These pins are the only +# place ruff, mypy and typos versions are set: their pre-commit hooks are +# `repo: local` and run the copies installed from uv.lock. dev = [ - "mypy==1.11.2", + "mypy==2.3.1", "pre-commit==4.6.2", "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-httpserver==1.1.5", - "ruff==0.6.9", + "ruff==0.16.7", + "typos==1.50.2", # Imported directly by the offline tests to assert on what the SDK put on # the wire, as well as backing pytest-httpserver. "werkzeug==3.1.8", @@ -73,59 +74,161 @@ module-root = "" [tool.pytest] asyncio_mode = "auto" testpaths = ["tests"] +# strict_config, strict_markers, strict_xfail and strict_parametrization_ids. +strict = true +filterwarnings = ["error"] [tool.ruff] -line-length = 120 -src = ["permit"] -exclude = ["permit/api/models.py"] -target-version = "py310" +line-length = 100 +# Generated from the Permit OpenAPI spec by datamodel-code-generator, then +# hand-patched at the top (CONTRIBUTING.md, "Regenerating the API models"). +# Linting or formatting it would rewrite ~7k generated lines on every regen +# and bury the real API diff; its content is owned by the generator. +extend-exclude = ["permit/api/models.py"] +# pre-commit passes file names explicitly, which bypasses exclusions unless +# this is set -- without it the hook would lint and reformat models.py. +force-exclude = true + +[tool.ruff.format] +docstring-code-format = true [tool.ruff.lint] -select = [ - "E", # pycodestyle - "W", # pycodestyle - "F", # pyflakes - "N", # pep8 - "I", # isort - "BLE", # flake8 blind except - "FBT", # flake8 boolean trap - "B", # flake8 bug bear - "C4", # flake8 comprehensions - "PIE", # flake8 pie - "T20", # flake8 print - "SIM", # flake8 simplify - "ARG", # flake8 unused arguments - "PTH", # flake8 pathlib - "ASYNC", # flake8 Asyncio rules -# "UP", # pyupgrade - "ERA", # comment out code - "RUF", # ruff rules - "FAST", # FastAPI rules +select = ["ALL"] +ignore = [ + # Conflict with `ruff format` (listed as such in the ruff formatter docs). + "COM812", # trailing commas are the formatter's call + # Per-file license headers: the Apache-2.0 LICENSE file at the root and + # the package metadata already carry the license. + "CPY001", + # Long messages at the raise site. The alternative is a new exception + # subclass per message, which would widen the public exception API. + "TRY003", + # Module and package docstrings. Users reach the SDK through the `permit` + # package (which has one) and the documented classes and functions; most + # modules hold a single class, so a module docstring would repeat its. + "D100", + "D104", + # Magic-method docstrings restate the protocol (`__repr__`, `__eq__`). + "D105", + # Nested classes are pydantic's `class Config:` blocks: configuration, not API. + "D106", + # Google style documents constructor arguments in the class docstring, + # so a separate `__init__` docstring would repeat it. + "D107", + # The maintainers' limit is on *positional* parameters, enforced by + # PLR0917 (max 5). PLR0913 counts keyword-only parameters too, which is + # the very shape PLR0917 steers towards. + "PLR0913", ] +[tool.ruff.lint.flake8-annotations] +# `*args: Any` / `**kwargs: Any` are pass-throughs to aiohttp and pydantic, +# which accept arbitrary values; Any elsewhere is still flagged (ANN401). +allow-star-arg-any = true + +[tool.ruff.lint.pydocstyle] +# Also resolves the mutually exclusive pairs (D203/D211, D212/D213) and +# turns off the rules Google style contradicts (D401 imperative mood, D413 ...). +convention = "google" + [tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" +[tool.ruff.lint.flake8-type-checking] +# pydantic evaluates field annotations at runtime, so the imports they use +# must never be moved under `if TYPE_CHECKING:`. +runtime-evaluated-base-classes = ["pydantic.BaseModel", "pydantic.v1.BaseModel"] + [tool.ruff.lint.per-file-ignores] +# Adapted from fastapi.encoders and kept structurally close to it, so upstream +# fixes still port over: its dispatch-by-type function is long by nature, and it +# encodes arbitrary objects, which is what `Any` says. +"permit/api/encoders.py" = ["C901", "PLR0911", "PLR0912", "ANN401"] +"tests/**/*.py" = [ + "S101", # assert is how pytest checks things + "S105", # hard-coded fake credentials are test fixtures + "S106", # hard-coded fake credentials are test fixtures + "PLR2004", # literal expected values are the point of an assertion + "SLF001", # white-box tests reach into private state on purpose + "D1", # test names document the test; docstrings where they add something + # End-to-end scenarios run a whole create/check/tear-down story against a live + # backend; splitting them would only scatter one sequence of API side effects. + "C901", + "PLR0912", + "PLR0915", + "PERF203", # try/except in retry and cleanup loops; speed is not what tests measure + "T201", # progress output for long e2e runs; pytest captures it + "BLE001", # e2e tests turn any unexpected exception into a readable pytest.fail +] # These are standalone CLI programs, not library code: writing the rendered # report to stdout IS their interface, so the "no print" rule does not apply. -".github/scripts/*.py" = ["T201"] +".github/scripts/*.py" = [ + "T201", + "INP001", # standalone scripts run by path, not an importable package +] +".github/scripts/test_*.py" = ["S101", "PLR2004", "D1"] + +[tool.typos.files] +# Generated (see [tool.ruff]); its misspellings come from the OpenAPI spec's +# descriptions and have to be fixed there. +extend-exclude = ["permit/api/models.py"] [tool.mypy] python_version = "3.10" -packages = ["permit"] -plugins = ["pydantic.mypy"] - -check_untyped_defs = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true +files = ["permit", "tests", ".github/scripts"] +strict = true warn_unreachable = true +enable_error_code = [ + "deprecated", + "exhaustive-match", + "ignore-without-code", + "mutable-override", + "possibly-undefined", + "redundant-expr", + "redundant-self", + "truthy-bool", + "truthy-iterable", + "unimported-reveal", + "unused-awaitable", +] +# The SDK's models are pydantic-v1 models on both majors: `pydantic.BaseModel` +# under pydantic 1, `pydantic.v1.BaseModel` under pydantic 2. `pydantic.v1.mypy` +# is the v1 plugin and is importable on both, so each CI lane type-checks the +# models the same way. (`pydantic.mypy` under pydantic 2 is the v2 plugin, +# which misreads v1 models.) +plugins = ["pydantic.v1.mypy"] + +[tool.pydantic-mypy] +# Model constructors are typed the way pydantic v1 behaves: it coerces input (a +# str for a UUID or EmailStr field) and the API models accept extra fields, so a +# strictly typed or closed `__init__` would reject calls that work. Missing +# required fields are still reported. +init_forbid_extra = false +init_typed = false +warn_required_dynamic_aliases = true +warn_untyped_fields = true [[tool.mypy.overrides]] +# Generated code (see [tool.ruff] above); checked as a dependency, not linted. module = ["permit.api.models"] ignore_errors = true [[tool.mypy.overrides]] -module = ["tests"] -ignore_errors = true +# These tests drive the blocking API that the SyncClass metaclass generates at +# runtime from the async classes. mypy only sees the inherited `async def` +# signatures, so every call looks like it returns a coroutine. The checks that +# rest on those result types are off here; annotation completeness is not. +module = [ + "tests.endpoints.test_resources_sync", + "tests.test_fix_sync", + "tests.test_rbac_e2e_sync", + "tests.test_sync_client", +] +disable_error_code = [ + "arg-type", + "attr-defined", + "comparison-overlap", + "return-value", + "unreachable", + "unused-coroutine", +] diff --git a/tests/conftest.py b/tests/conftest.py index a6b0b4d..54babc2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,12 @@ import functools import os import random +from collections.abc import Awaitable, Callable, Coroutine, Iterator +from typing import Any, TypeVar import pytest from loguru import logger +from typing_extensions import ParamSpec from permit import Permit, PermitConfig from permit.api.base import SimpleHttpClient @@ -23,18 +26,25 @@ # tests fail with "Cannot connect to host localhost:9999". MOCKED_PORT = 9999 +P = ParamSpec("P") +R = TypeVar("R") + @pytest.fixture(scope="session") -def httpserver_listen_address() -> tuple: +def httpserver_listen_address() -> tuple[str, int]: return "localhost", MOCKED_PORT @pytest.fixture def permit_config() -> PermitConfig: default_pdp_address = ( - "https://cloudpdp.api.permit.io" if os.getenv("CLOUD_PDP") == "true" else "http://localhost:7766" + "https://cloudpdp.api.permit.io" + if os.getenv("CLOUD_PDP") == "true" + else "http://localhost:7766" + ) + default_api_address = ( + "https://api.permit.io" if os.getenv("API_TIER") == "prod" else "http://localhost:8000" ) - default_api_address = "https://api.permit.io" if os.getenv("API_TIER") == "prod" else "http://localhost:8000" token = os.getenv("PDP_API_KEY", "") pdp_address = os.getenv("PDP_URL", default_pdp_address) @@ -122,7 +132,7 @@ def _retry_after_seconds(err: PermitApiError) -> float | None: """The server's own Retry-After, when it sends one.""" try: raw = err.response.headers.get("Retry-After") - except Exception: # noqa: BLE001 - a missing/odd header must never mask the 429 + except Exception: # a missing/odd header must never mask the 429 return None if not raw: return None @@ -132,9 +142,11 @@ def _retry_after_seconds(err: PermitApiError) -> float | None: return None -def _retry_on_rate_limit(method): +def _retry_on_rate_limit( + method: Callable[P, Awaitable[R]], +) -> Callable[P, Coroutine[Any, Any, R]]: @functools.wraps(method) - async def wrapper(*args, **kwargs): + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: for attempt in range(_MAX_RETRIES): try: return await method(*args, **kwargs) @@ -147,16 +159,20 @@ async def wrapper(*args, **kwargs): delay = _retry_after_seconds(err) if delay is None: delay = min(_BASE_BACKOFF_S * (2**attempt), _MAX_BACKOFF_S) - delay *= 0.5 + random.random() / 2 - logger.warning(f"rate limited (429); retrying in {delay:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})") + delay *= 0.5 + random.random() / 2 # noqa: S311 - jitter, not crypto + logger.warning( + f"rate limited (429); retrying in {delay:.1f}s " + f"(attempt {attempt + 1}/{_MAX_RETRIES})" + ) await asyncio.sleep(delay) - raise AssertionError("unreachable") # pragma: no cover + msg = "unreachable" + raise AssertionError(msg) # pragma: no cover return wrapper @pytest.fixture(scope="session", autouse=True) -def retry_rate_limited_requests(): +def retry_rate_limited_requests() -> Iterator[None]: """Make every SDK HTTP verb retry a 429 for the duration of the test session.""" verbs = ("get", "post", "put", "patch", "delete") originals = {verb: getattr(SimpleHttpClient, verb) for verb in verbs} diff --git a/tests/endpoints/__init__.py b/tests/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/endpoints/test_bulk_operations.py b/tests/endpoints/test_bulk_operations.py index 0e2e034..fd3fc1a 100644 --- a/tests/endpoints/test_bulk_operations.py +++ b/tests/endpoints/test_bulk_operations.py @@ -135,7 +135,7 @@ ] -async def test_bulk_operations(permit: Permit): +async def test_bulk_operations(permit: Permit) -> None: ## create resource and global role ------------------------------------ try: resource = await permit.api.resources.create(ACCOUNT) @@ -225,7 +225,8 @@ async def test_bulk_operations(permit: Permit): assignments = await permit.api.role_assignments.list() # Not +1: the surviving tenant-level assignment (USER_A/admin/TENANT_1) belongs to USER_A, - # and deleting a user cascades away their role assignments, so we are back to the original count. + # and deleting a user cascades away their role assignments, so we are back to the + # original count. assert len(assignments) == len_assignments_original ## bulk delete tenants ----------------------------------- diff --git a/tests/endpoints/test_envs.py b/tests/endpoints/test_envs.py index 430de99..bee63ab 100644 --- a/tests/endpoints/test_envs.py +++ b/tests/endpoints/test_envs.py @@ -1,9 +1,7 @@ import os -from typing import List import pytest from loguru import logger -from tests.utils import handle_api_error from permit import Permit from permit.api.context import ApiKeyAccessLevel @@ -15,6 +13,7 @@ ) from permit.config import PermitConfig from permit.exceptions import PermitApiError, PermitConnectionError, PermitContextError +from tests.utils import handle_api_error CREATED_PROJECTS = [ProjectCreate(key="test-python-proj", name="New Python Project")] CREATED_ENVIRONMENTS = [ @@ -61,18 +60,18 @@ def permit_with_project_level_api_key() -> Permit: ) -async def cleanup(permit: Permit, project_key: str): +async def cleanup(permit: Permit, project_key: str) -> None: for env in CREATED_ENVIRONMENTS: try: await permit.api.environments.delete(project_key, env.key) except PermitApiError as error: if error.status_code == 404: - print(f"SKIPPING delete, env does not exist: {env.key}, project_key={project_key}") # noqa: T201 + print(f"SKIPPING delete, env does not exist: {env.key}, project_key={project_key}") async def test_environment_creation_with_org_level_api_key( permit_with_org_level_api_key: Permit, -): +) -> None: permit = permit_with_org_level_api_key try: await permit.api._ensure_access_level(ApiKeyAccessLevel.ORGANIZATION_LEVEL_API_KEY) @@ -82,15 +81,15 @@ async def test_environment_creation_with_org_level_api_key( try: await cleanup(permit, CREATED_PROJECTS[0].key) - projects: List[ProjectRead] = [] + projects: list[ProjectRead] = [] for project_data in CREATED_PROJECTS: - print(f"trying to creating project: {project_data.key}") # noqa: T201 + print(f"trying to creating project: {project_data.key}") try: - project: ProjectRead = await permit.api.projects.create(project_data) + project = await permit.api.projects.create(project_data) except PermitApiError as error: if error.status_code == 409: - print(f"SKIPPING create, project already exists: {project_data.key}") # noqa: T201 - project: ProjectRead = await permit.api.projects.get(project_key=project_data.key) + print(f"SKIPPING create, project already exists: {project_data.key}") + project = await permit.api.projects.get(project_key=project_data.key) assert project is not None assert project.key == project_data.key assert project.name == project_data.name @@ -99,9 +98,9 @@ async def test_environment_creation_with_org_level_api_key( # create environments for environment_data in CREATED_ENVIRONMENTS: - print(f"creating environment: {environment_data.key}") # noqa: T201 + print(f"creating environment: {environment_data.key}") environment: EnvironmentRead = await permit.api.environments.create( - project_key=project.key, environment_data=environment_data + project_key=projects[-1].key, environment_data=environment_data ) assert environment is not None assert environment.key == environment_data.key @@ -116,7 +115,9 @@ async def test_environment_creation_with_org_level_api_key( ) # each project has 2 default `dev` and `prod` environments # create first item - test_environment = await permit.api.environments.get(CREATED_PROJECTS[0].key, CREATED_ENVIRONMENTS[0].key) + test_environment = await permit.api.environments.get( + CREATED_PROJECTS[0].key, CREATED_ENVIRONMENTS[0].key + ) assert test_environment is not None assert test_environment.key == CREATED_ENVIRONMENTS[0].key @@ -126,7 +127,7 @@ async def test_environment_creation_with_org_level_api_key( handle_api_error(error, "Got API Error") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error: {error}") pytest.fail(f"Got error: {error}") finally: @@ -135,7 +136,7 @@ async def test_environment_creation_with_org_level_api_key( async def test_environment_creation_with_project_level_api_key( permit_with_project_level_api_key: Permit, -): +) -> None: permit = permit_with_project_level_api_key try: await permit.api._ensure_access_level(ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY) @@ -143,19 +144,18 @@ async def test_environment_creation_with_project_level_api_key( logger.warning("this test must run with a project level api key") return - try: - project = permit.config.api_context.project - assert project is not None - project_id = str(project) - - project = await permit.api.projects.get(project_id) - assert str(project.id) == project_id + context_project = permit.config.api_context.project + assert context_project is not None + project_id = str(context_project) + project = await permit.api.projects.get(project_id) + assert str(project.id) == project_id + try: await cleanup(permit, project.key) # create environments for environment_data in CREATED_ENVIRONMENTS: - print(f"creating environment: {environment_data.key}") # noqa: T201 + print(f"creating environment: {environment_data.key}") environment: EnvironmentRead = await permit.api.environments.create( project_key=project.key, environment_data=environment_data ) @@ -174,7 +174,7 @@ async def test_environment_creation_with_project_level_api_key( handle_api_error(error, "Got API Error") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error: {error}") pytest.fail(f"Got error: {error}") finally: diff --git a/tests/endpoints/test_error_response.py b/tests/endpoints/test_error_response.py index 17f0dec..c512ed7 100644 --- a/tests/endpoints/test_error_response.py +++ b/tests/endpoints/test_error_response.py @@ -2,21 +2,16 @@ from loguru import logger from permit import Permit -from permit.exceptions import PermitApiError, PermitConnectionError +from permit.exceptions import PermitApiError -async def test_api_error(permit: Permit): - try: +async def test_api_error(permit: Permit) -> None: + with pytest.raises(PermitApiError) as exc_info: await permit.api.users.get("this_key_does_not_exists") - except PermitApiError as error: - err = ( - f"Got error: status={error.status_code}, url={error.request_url}, method={error.response.method}, " - f"details={error.details}, content-type={error.content_type}" - ) - logger.info(err) - assert error.content_type == "application/json" - except PermitConnectionError: - raise - except Exception as error: # noqa: BLE001 - logger.error(f"Got error: {error}") - pytest.fail(f"Got error: {error}") + error = exc_info.value + logger.info( + f"Got error: status={error.status_code}, url={error.request_url}, " + f"method={error.response.method}, " + f"details={error.details}, content-type={error.content_type}" + ) + assert error.content_type == "application/json" diff --git a/tests/endpoints/test_resources.py b/tests/endpoints/test_resources.py index 499a3a4..28587c5 100644 --- a/tests/endpoints/test_resources.py +++ b/tests/endpoints/test_resources.py @@ -1,11 +1,9 @@ -from typing import List - import pytest from loguru import logger -from tests.utils import handle_cleanup_error, unique_key from permit import ActionBlockEditable, Permit, ResourceCreate from permit.exceptions import PermitApiError +from tests.utils import handle_cleanup_error, unique_key # The whole e2e suite shares a single Permit environment, so every object this # module creates is namespaced under one prefix. That keeps the keys collision @@ -20,7 +18,7 @@ TEST_RESOURCE_DOC_URN = f"prn:gdrive:{TEST_PREFIX}" -async def list_own_resource_keys(permit: Permit) -> List[str]: +async def list_own_resource_keys(permit: Permit) -> list[str]: """The keys of resources created by this test, sorted, across all pages. The shared environment can easily hold more resources than fit on a single @@ -29,7 +27,7 @@ async def list_own_resource_keys(permit: Permit) -> List[str]: """ per_page = 100 page = 1 - keys: List[str] = [] + keys: list[str] = [] while True: resources = await permit.api.resources.list(page=page, per_page=per_page) keys.extend(resource.key for resource in resources if resource.key.startswith(TEST_PREFIX)) @@ -38,7 +36,7 @@ async def list_own_resource_keys(permit: Permit) -> List[str]: page += 1 -async def test_resources(permit: Permit): +async def test_resources(permit: Permit) -> None: logger.info("initial setup of objects") # none of this test's resources exist yet assert await list_own_resource_keys(permit) == [] @@ -79,12 +77,18 @@ async def test_resources(permit: Permit): # create existing -> 409 with pytest.raises(PermitApiError) as e: - await permit.api.resources.create({"key": TEST_RESOURCE_DOC_KEY, "name": "document2", "actions": {}}) + await permit.api.resources.create( + { # type: ignore[arg-type] # dict input, coerced by the SDK + "key": TEST_RESOURCE_DOC_KEY, + "name": "document2", + "actions": {}, + } + ) assert e.value.status_code == 409 # create empty item empty = await permit.api.resources.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": TEST_RESOURCE_FOLDER_KEY, "name": TEST_RESOURCE_FOLDER_KEY, "description": "empty resource", @@ -107,7 +111,10 @@ async def test_resources(permit: Permit): # update actions await permit.api.resources.update( TEST_RESOURCE_FOLDER_KEY, - {"description": "wat", "actions": {"pick": {}}}, + { # type: ignore[arg-type] # dict input, coerced by the SDK + "description": "wat", + "actions": {"pick": {}}, + }, ) # get diff --git a/tests/endpoints/test_resources_sync.py b/tests/endpoints/test_resources_sync.py index bf2fd85..fae20fd 100644 --- a/tests/endpoints/test_resources_sync.py +++ b/tests/endpoints/test_resources_sync.py @@ -1,11 +1,9 @@ -from typing import List - import pytest from loguru import logger -from tests.utils import handle_cleanup_error, unique_key from permit.exceptions import PermitApiError from permit.sync import Permit as SyncPermit +from tests.utils import handle_cleanup_error, unique_key # The whole e2e suite shares a single Permit environment, so every object this # module creates is namespaced under one prefix. That keeps the keys collision @@ -20,7 +18,7 @@ TEST_RESOURCE_DOC_URN = f"prn:gdrive:{TEST_PREFIX}" -def list_own_resource_keys(permit: SyncPermit) -> List[str]: +def list_own_resource_keys(permit: SyncPermit) -> list[str]: """The keys of resources created by this test, sorted, across all pages. The shared environment can easily hold more resources than fit on a single @@ -29,7 +27,7 @@ def list_own_resource_keys(permit: SyncPermit) -> List[str]: """ per_page = 100 page = 1 - keys: List[str] = [] + keys: list[str] = [] while True: resources = permit.api.resources.list(page=page, per_page=per_page) keys.extend(resource.key for resource in resources if resource.key.startswith(TEST_PREFIX)) @@ -38,7 +36,7 @@ def list_own_resource_keys(permit: SyncPermit) -> List[str]: page += 1 -def test_resources_sync(sync_permit: SyncPermit): +def test_resources_sync(sync_permit: SyncPermit) -> None: permit = sync_permit logger.info("initial setup of objects") # none of this test's resources exist yet @@ -80,7 +78,9 @@ def test_resources_sync(sync_permit: SyncPermit): # create existing -> 409 with pytest.raises(PermitApiError) as e: - permit.api.resources.create({"key": TEST_RESOURCE_DOC_KEY, "name": "document2", "actions": {}}) + permit.api.resources.create( + {"key": TEST_RESOURCE_DOC_KEY, "name": "document2", "actions": {}} + ) assert e.value.status_code == 409 # create empty item diff --git a/tests/endpoints/test_role_assignments.py b/tests/endpoints/test_role_assignments.py index 0703bef..4ef659e 100644 --- a/tests/endpoints/test_role_assignments.py +++ b/tests/endpoints/test_role_assignments.py @@ -1,8 +1,8 @@ import asyncio -from typing import Awaitable, Callable, List, Sequence, TypeVar, Union +from collections.abc import Awaitable, Callable, Sequence +from typing import TypeVar from loguru import logger -from tests.utils import handle_cleanup_error, unique_key from permit import ( Permit, @@ -13,6 +13,7 @@ UserCreate, ) from permit.exceptions import PermitApiDetailedError +from tests.utils import handle_cleanup_error, unique_key TPropagated = TypeVar("TPropagated") @@ -24,7 +25,7 @@ PROPAGATION_POLL_INTERVAL_SECONDS = 0.5 -def user_keys(prefix: str, count: int = USER_COUNT) -> List[str]: +def user_keys(prefix: str, count: int = USER_COUNT) -> list[str]: return [f"{prefix}-user-{index}" for index in range(count)] @@ -71,9 +72,9 @@ async def create_role_assignments(permit: Permit, role_key: str, users: Sequence async def list_assignments( permit: Permit, - role_key: Union[str, List[str]], + role_key: str | list[str], expected_count: int, -) -> List[RoleAssignmentRead]: +) -> list[RoleAssignmentRead]: """List the assignments of the given role(s), polling until they are all visible. Returns whatever the last call reported once the count matches or the @@ -103,7 +104,7 @@ async def cleanup(permit: Permit, role_keys: Sequence[str], users: Sequence[str] handle_cleanup_error(error, f"could not delete user {user}") -async def test_list_filter_by_role(permit: Permit): +async def test_list_filter_by_role(permit: Permit) -> None: prefix = unique_key("ra-single") role_1 = f"{prefix}-role-1" role_2 = f"{prefix}-role-2" @@ -126,7 +127,7 @@ async def test_list_filter_by_role(permit: Permit): await cleanup(permit, [role_1, role_2], [*users_1, *users_2]) -async def test_list_filter_by_role_multiple(permit: Permit): +async def test_list_filter_by_role_multiple(permit: Permit) -> None: prefix = unique_key("ra-multi") role_1 = f"{prefix}-role-1" role_2 = f"{prefix}-role-2" @@ -140,7 +141,9 @@ async def test_list_filter_by_role_multiple(permit: Permit): await create_role_assignments(permit, role_2, users_2) await create_role_assignments(permit, role_3, users_3) - role_assignments = await list_assignments(permit, [role_1, role_2], expected_count=len(users_1) + len(users_2)) + role_assignments = await list_assignments( + permit, [role_1, role_2], expected_count=len(users_1) + len(users_2) + ) # a multi-valued role filter is a union of the roles asked for, and # excludes role_3 which was created in the same environment diff --git a/tests/endpoints/test_roles.py b/tests/endpoints/test_roles.py index 34c290b..3fbd195 100644 --- a/tests/endpoints/test_roles.py +++ b/tests/endpoints/test_roles.py @@ -1,12 +1,13 @@ import asyncio -from typing import Awaitable, Callable, List, TypeVar +from collections.abc import Awaitable, Callable +from typing import TypeVar import pytest from loguru import logger -from tests.utils import handle_cleanup_error, unique_key from permit import ActionBlockEditable, Permit, ResourceCreate from permit.exceptions import PermitApiDetailedError, PermitApiError +from tests.utils import handle_cleanup_error, unique_key # The whole e2e suite shares a single Permit environment, so every object this # module creates is namespaced under one prefix. That keeps the keys collision @@ -51,7 +52,7 @@ async def retry_while_permissions_propagate( await asyncio.sleep(PROPAGATION_POLL_INTERVAL_SECONDS) -async def list_own_role_keys(permit: Permit) -> List[str]: +async def list_own_role_keys(permit: Permit) -> list[str]: """The keys of roles created by this test, sorted, across all pages. The shared environment can easily hold more roles than fit on a single page, @@ -60,7 +61,7 @@ async def list_own_role_keys(permit: Permit) -> List[str]: """ per_page = 100 page = 1 - keys: List[str] = [] + keys: list[str] = [] while True: roles = await permit.api.roles.list(page=page, per_page=per_page) keys.extend(role.key for role in roles if role.key.startswith(TEST_PREFIX)) @@ -69,7 +70,7 @@ async def list_own_role_keys(permit: Permit) -> List[str]: page += 1 -async def test_roles(permit: Permit): +async def test_roles(permit: Permit) -> None: logger.info("initial setup of objects") # none of this test's roles exist yet assert await list_own_role_keys(permit) == [] @@ -92,7 +93,7 @@ async def test_roles(permit: Permit): # create admin role admin = await retry_while_permissions_propagate( lambda: permit.api.roles.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": TEST_ADMIN_ROLE_KEY, "name": TEST_ADMIN_ROLE_KEY, "description": "a test role", @@ -123,7 +124,7 @@ async def test_roles(permit: Permit): # create existing role -> 409 with pytest.raises(PermitApiError) as e: await permit.api.roles.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": TEST_ADMIN_ROLE_KEY, "name": f"{TEST_ADMIN_ROLE_KEY}-2", } @@ -132,7 +133,7 @@ async def test_roles(permit: Permit): # create empty role empty = await permit.api.roles.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": TEST_EMPTY_ROLE_KEY, "name": TEST_EMPTY_ROLE_KEY, "description": "empty role", @@ -147,19 +148,26 @@ async def test_roles(permit: Permit): assert len(empty.permissions) == 0 # both of this test's roles are now listed, and nothing else of its own - assert await list_own_role_keys(permit) == sorted([TEST_ADMIN_ROLE_KEY, TEST_EMPTY_ROLE_KEY]) + assert await list_own_role_keys(permit) == sorted( + [TEST_ADMIN_ROLE_KEY, TEST_EMPTY_ROLE_KEY] + ) # assign permissions to roles assigned_empty = await retry_while_permissions_propagate( - lambda: permit.api.roles.assign_permissions(TEST_EMPTY_ROLE_KEY, [f"{TEST_RESOURCE_KEY}:delete"]) + lambda: permit.api.roles.assign_permissions( + TEST_EMPTY_ROLE_KEY, [f"{TEST_RESOURCE_KEY}:delete"] + ) ) assert assigned_empty.key == empty.key + assert assigned_empty.permissions is not None assert len(assigned_empty.permissions) == 1 assert f"{TEST_RESOURCE_KEY}:delete" in assigned_empty.permissions # remove permissions from role - await permit.api.roles.remove_permissions(TEST_ADMIN_ROLE_KEY, [f"{TEST_RESOURCE_KEY}:create"]) + await permit.api.roles.remove_permissions( + TEST_ADMIN_ROLE_KEY, [f"{TEST_RESOURCE_KEY}:create"] + ) # get admin = await permit.api.roles.get(TEST_ADMIN_ROLE_KEY) @@ -168,13 +176,14 @@ async def test_roles(permit: Permit): assert admin is not None assert admin.key == TEST_ADMIN_ROLE_KEY assert admin.description == "a test role" + assert admin.permissions is not None assert f"{TEST_RESOURCE_KEY}:create" not in admin.permissions assert f"{TEST_RESOURCE_KEY}:read" in admin.permissions # update await permit.api.roles.update( TEST_ADMIN_ROLE_KEY, - {"description": "wat"}, + {"description": "wat"}, # type: ignore[arg-type] # dict input, coerced by the SDK ) # get @@ -184,6 +193,7 @@ async def test_roles(permit: Permit): assert admin is not None assert admin.key == TEST_ADMIN_ROLE_KEY assert admin.description == "wat" + assert admin.permissions is not None assert f"{TEST_RESOURCE_KEY}:create" not in admin.permissions assert f"{TEST_RESOURCE_KEY}:read" in admin.permissions finally: diff --git a/tests/endpoints/test_users_tenants.py b/tests/endpoints/test_users_tenants.py index 432705a..29d6e9c 100644 --- a/tests/endpoints/test_users_tenants.py +++ b/tests/endpoints/test_users_tenants.py @@ -47,7 +47,7 @@ CREATED_ROLES = [ADMIN, VIEWER] -async def test_users_tenants(permit: Permit): +async def test_users_tenants(permit: Permit) -> None: logger.info("initial setup of objects") # initial number of tenants tenants = await permit.api.tenants.list() @@ -91,6 +91,8 @@ async def test_users_tenants(permit: Permit): assert user.email == user_data.email assert user.first_name == user_data.first_name assert user.last_name == user_data.last_name + assert user.attributes is not None + assert user_data.attributes is not None assert set(user.attributes.keys()) == set(user_data.attributes.keys()) # get non existing user -> 404 @@ -115,6 +117,8 @@ async def test_users_tenants(permit: Permit): assert user.email == USER_BB.email assert user.first_name == USER_BB.first_name assert user.last_name == USER_BB.last_name + assert user.attributes is not None + assert USER_BB.attributes is not None assert set(user.attributes.keys()) == set(USER_BB.attributes.keys()) # get user after sync/update @@ -124,7 +128,12 @@ async def test_users_tenants(permit: Permit): assert ub.email == USER_BB.email # update tenant - t2 = await permit.api.tenants.update(TENANT_2.key, {"description": "t2 update"}) + t2 = await permit.api.tenants.update( + TENANT_2.key, + { # type: ignore[arg-type] # dict input, coerced by the SDK + "description": "t2 update", + }, + ) assert t2.key == TENANT_2.key assert t2.description != TENANT_2.description assert t2.description == "t2 update" @@ -161,13 +170,18 @@ async def test_users_tenants(permit: Permit): assert len(roles_a2) == 0 # assign role - ra = await permit.api.users.assign_role(RoleAssignmentCreate(user=USER_C.key, role=ADMIN.key, tenant=TENANT_2.key)) - assert ra.user == USER_C.key or ra.user == USER_C.email # TODO: fix bug in api + ra = await permit.api.users.assign_role( + RoleAssignmentCreate(user=USER_C.key, role=ADMIN.key, tenant=TENANT_2.key) + ) + # The API may report the user by email rather than by key. + assert ra.user in (USER_C.key, USER_C.email) assert ra.role == ADMIN.key assert ra.tenant == TENANT_2.key # add user a to another tenant - ra = await permit.api.users.assign_role(RoleAssignmentCreate(user=USER_A.key, role=ADMIN.key, tenant=TENANT_2.key)) + ra = await permit.api.users.assign_role( + RoleAssignmentCreate(user=USER_A.key, role=ADMIN.key, tenant=TENANT_2.key) + ) # get assigned roles roles_a = await permit.api.users.get_assigned_roles(USER_A.key) @@ -179,7 +193,8 @@ async def test_users_tenants(permit: Permit): assert len(tenant2_users.data) == 2 await permit.api.tenants.delete_tenant_user(TENANT_2.key, USER_A.key) tenant2_users = await permit.api.tenants.list_tenant_users(TENANT_2.key) - assert len(tenant2_users.data) == 2 # TODO: change to 1, fix bug in delete_tenant_user + # Still 2, not 1: the API keeps listing a user removed with delete_tenant_user. + assert len(tenant2_users.data) == 2 # list role assignments role_assignments = await permit.api.role_assignments.list() diff --git a/tests/test_abac_e2e.py b/tests/test_abac_e2e.py index 272f8d7..3ea9772 100644 --- a/tests/test_abac_e2e.py +++ b/tests/test_abac_e2e.py @@ -1,6 +1,8 @@ import asyncio +import functools import time -from typing import Any, Awaitable, Callable, Final, List, Optional +from collections.abc import Awaitable, Callable +from typing import Any, Final, Protocol, TypeVar import pytest from loguru import logger @@ -18,12 +20,11 @@ UserCreate, ) from permit.exceptions import PermitApiError, PermitConnectionError +from tests.utils import handle_api_error, handle_cleanup_error, unique_key -from .utils import handle_api_error, handle_cleanup_error, unique_key - -def print_break(): - print("\n\n ----------- \n\n") # noqa: T201 +def print_break() -> None: + print("\n\n ----------- \n\n") PER_PAGE: Final[int] = 100 @@ -64,7 +65,17 @@ async def wait_until( await asyncio.sleep(interval) -async def find_by_key(list_page: Callable[[int], Awaitable[List[Any]]], key: str) -> Optional[Any]: +class _Keyed(Protocol): + @property + def key(self) -> str: ... + + +KeyedT = TypeVar("KeyedT", bound=_Keyed) + + +async def find_by_key( + list_page: Callable[[int], Awaitable[list[KeyedT]]], key: str +) -> KeyedT | None: """Find an object by key across all pages of a paginated list endpoint. The environment is shared, so the object under test is not necessarily on @@ -89,7 +100,7 @@ async def cleanup_step(action: Callable[[], Awaitable[Any]], description: str) - handle_cleanup_error(error, f"Got API Error during cleanup of {description}") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error during cleanup of {description}: {error}") pytest.fail(f"Got error during cleanup of {description}: {error}") @@ -101,7 +112,7 @@ async def assert_gone(get: Callable[[str], Awaitable[Any]], key: str, descriptio assert exc_info.value.status_code == 404, f"{description} '{key}' still exists after cleanup" -async def test_abac_e2e(permit: Permit): +async def test_abac_e2e(permit: Permit) -> None: logger.info("initial setup of objects") # Every key is unique to this run: the e2e suite shares a single environment, # so fixed keys ("document", "admin", "viewer", "tesla") are objects other @@ -113,7 +124,9 @@ async def test_abac_e2e(permit: Permit): name="Admin", permissions=[f"{resource_key}:create", f"{resource_key}:read"], ) - viewer = RoleCreate(key=unique_ident("viewer"), name="Viewer", permissions=[f"{resource_key}:read"]) + viewer = RoleCreate( + key=unique_ident("viewer"), name="Viewer", permissions=[f"{resource_key}:read"] + ) tesla = TenantCreate(key=unique_ident("tesla"), name="Tesla Inc") user_a = UserCreate( key=unique_ident("asaf"), @@ -156,7 +169,7 @@ async def test_abac_e2e(permit: Permit): sign_permission = f"{resource_key}:sign" try: document = await permit.api.resources.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": resource_key, "name": "Document", "urn": f"prn:gdrive:{resource_key}", @@ -199,7 +212,9 @@ async def test_abac_e2e(permit: Permit): listed_document = await find_by_key( lambda page: permit.api.resources.list(page=page, per_page=PER_PAGE), resource_key ) - assert listed_document is not None, f"resource '{resource_key}' is missing from the resource list" + assert listed_document is not None, ( + f"resource '{resource_key}' is missing from the resource list" + ) assert listed_document.id == document.id assert listed_document.key == document.key assert listed_document.name == document.name @@ -227,6 +242,8 @@ async def test_abac_e2e(permit: Permit): assert user.email == user_data.email assert user.first_name == user_data.first_name assert user.last_name == user_data.last_name + assert user.attributes is not None + assert user_data.attributes is not None assert set(user.attributes.keys()) == set(user_data.attributes.keys()) # create role @@ -235,7 +252,7 @@ async def test_abac_e2e(permit: Permit): # assign role to user in tenant await permit.api.users.assign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user_a.key, "role": admin.key, "tenant": tesla.key, @@ -243,7 +260,7 @@ async def test_abac_e2e(permit: Permit): ) await permit.api.users.assign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user_b.key, "role": admin.key, "tenant": tesla.key, @@ -318,7 +335,9 @@ async def test_abac_e2e(permit: Permit): lambda page: permit.api.condition_sets.list(page=page, per_page=PER_PAGE), condition_set_data.key, ) - assert listed_set is not None, f"condition set '{condition_set_data.key}' is missing from the list" + assert listed_set is not None, ( + f"condition set '{condition_set_data.key}' is missing from the list" + ) assert listed_set.type == condition_set_data.type await permit.api.condition_set_rules.create( @@ -376,7 +395,7 @@ async def test_abac_e2e(permit: Permit): handle_api_error(error, "Got API Error") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error: {error}") pytest.fail(f"Got error: {error}") finally: @@ -393,29 +412,39 @@ async def test_abac_e2e(permit: Permit): "condition set rule", ) for role in created_roles: - await cleanup_step(lambda key=role.key: permit.api.roles.delete(key), f"role '{role.key}'") - for user in created_users: - await cleanup_step(lambda key=user.key: permit.api.users.delete(key), f"user '{user.key}'") + await cleanup_step( + functools.partial(permit.api.roles.delete, role.key), f"role '{role.key}'" + ) + for created_user in created_users: + await cleanup_step( + functools.partial(permit.api.users.delete, created_user.key), + f"user '{created_user.key}'", + ) for tenant_data in created_tenants: await cleanup_step( - lambda key=tenant_data.key: permit.api.tenants.delete(key), f"tenant '{tenant_data.key}'" + functools.partial(permit.api.tenants.delete, tenant_data.key), + f"tenant '{tenant_data.key}'", ) for condition_set_data in condition_sets: await cleanup_step( - lambda key=condition_set_data.key: permit.api.condition_sets.delete(key), + functools.partial(permit.api.condition_sets.delete, condition_set_data.key), f"condition set '{condition_set_data.key}'", ) - await cleanup_step(lambda: permit.api.resources.delete(resource_key), f"resource '{resource_key}'") + await cleanup_step( + lambda: permit.api.resources.delete(resource_key), f"resource '{resource_key}'" + ) await cleanup_step( lambda: permit.api.resource_attributes.delete("__user", age_attribute), f"user attribute '{age_attribute}'", ) for role in created_roles: await assert_gone(permit.api.roles.get, role.key, "role") - for user in created_users: - await assert_gone(permit.api.users.get, user.key, "user") + for created_user in created_users: + await assert_gone(permit.api.users.get, created_user.key, "user") for tenant_data in created_tenants: await assert_gone(permit.api.tenants.get, tenant_data.key, "tenant") for condition_set_data in condition_sets: - await assert_gone(permit.api.condition_sets.get, condition_set_data.key, "condition set") + await assert_gone( + permit.api.condition_sets.get, condition_set_data.key, "condition set" + ) await assert_gone(permit.api.resources.get, resource_key, "resource") diff --git a/tests/test_abac_pdp.py b/tests/test_abac_pdp.py index 7b44dab..8e0a248 100644 --- a/tests/test_abac_pdp.py +++ b/tests/test_abac_pdp.py @@ -1,5 +1,5 @@ import os -from typing import Any, Dict, List +from typing import Any import aiohttp import pytest @@ -33,11 +33,11 @@ ) -def abac_user(user: UserCreate): +def abac_user(user: UserCreate) -> dict[str, Any]: return user.dict(exclude={"first_name", "last_name"}) -async def test_abac_pdp_cloud_error(permit_cloud: Permit): +async def test_abac_pdp_cloud_error(permit_cloud: Permit) -> None: user_test = UserCreate( key="maya@permit.io", email="maya@permit.io", @@ -47,7 +47,7 @@ async def test_abac_pdp_cloud_error(permit_cloud: Permit): ) tesla = TenantCreate(key="tesla", name="Tesla Inc") - try: + with pytest.raises((PermitConnectionError, aiohttp.ClientError)) as exc_info: await permit_cloud.check( abac_user(user_test), "sign", @@ -57,13 +57,10 @@ async def test_abac_pdp_cloud_error(permit_cloud: Permit): "attributes": {"private": False}, }, ) - except (PermitConnectionError, aiohttp.ClientError) as error: - assert isinstance(error, PermitConnectionError) - else: - pytest.fail("Should have raised an exception") + assert isinstance(exc_info.value, PermitConnectionError) -async def test_get_user_permissions_cloud_error(permit_cloud: Permit): +async def test_get_user_permissions_cloud_error(permit_cloud: Permit) -> None: user_test = UserCreate( key="maya@permit.io", email="maya@permit.io", @@ -72,30 +69,30 @@ async def test_get_user_permissions_cloud_error(permit_cloud: Permit): attributes={"age": 23}, ) - try: + with pytest.raises((PermitConnectionError, aiohttp.ClientError)) as exc_info: await permit_cloud.get_user_permissions( - user={"key": user_test.key, "email": user_test.email, "attributes": user_test.attributes}, + user={ + "key": user_test.key, + "email": user_test.email, + "attributes": user_test.attributes, + }, tenants=["default"], resources=["Blog:dddddd"], resource_types=["Blog"], ) - except (PermitConnectionError, aiohttp.ClientError) as error: - assert isinstance(error, PermitConnectionError) - else: - pytest.fail("Should have raised an exception") + assert isinstance(exc_info.value, PermitConnectionError) -async def test_filter_objects_cloud_error(permit_cloud: Permit): +async def test_filter_objects_cloud_error(permit_cloud: Permit) -> None: user_test = {"key": "maya@permit.io", "email": "maya@permit.io", "attributes": {"age": 23}} - test_resources: List[Dict[str, Any]] = [ + test_resources: list[dict[str, Any]] = [ {"type": "Blog", "key": "doc1", "context": {}, "attributes": {}, "tenant": "default"}, {"type": "Document", "key": "doc2", "context": {}, "attributes": {}, "tenant": "default"}, ] - try: - await permit_cloud.filter_objects(user=user_test, action="read", context={}, resources=test_resources) - except (PermitConnectionError, aiohttp.ClientError) as error: - assert isinstance(error, PermitConnectionError) - else: - pytest.fail("Should have raised an exception") + with pytest.raises((PermitConnectionError, aiohttp.ClientError)) as exc_info: + await permit_cloud.filter_objects( + user=user_test, action="read", context={}, resources=test_resources + ) + assert isinstance(exc_info.value, PermitConnectionError) diff --git a/tests/test_fix_enforcement.py b/tests/test_fix_enforcement.py index f8b286e..aa62a22 100644 --- a/tests/test_fix_enforcement.py +++ b/tests/test_fix_enforcement.py @@ -6,7 +6,8 @@ """ import json -from typing import Any, Dict, List +from collections.abc import Callable +from typing import Any import pytest from pytest_httpserver import HTTPServer @@ -34,7 +35,7 @@ def enforcer(pdp_url: str) -> Enforcer: ) -def _recorder(bodies: List[Any], payload: Any): +def _recorder(bodies: list[Any], payload: object) -> Callable[[Request], Response]: def handler(request: Request) -> Response: bodies.append(json.loads(request.get_data())) return Response(json.dumps(payload), content_type="application/json") @@ -46,14 +47,16 @@ def handler(request: Request) -> Response: @pytest.mark.asyncio -async def test_authorized_users_parses_pdp_response(httpserver: HTTPServer, enforcer: Enforcer): +async def test_authorized_users_parses_pdp_response( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: """Before the fix this raised TypeError under pydantic v2. ``AuthorizedUsersResult`` is a pydantic v1 model, so the v2 ``parse_obj_as`` shim called ``BaseModel.validate(cls, obj)`` on it: "BaseModel.validate() takes 2 positional arguments but 3 were given". """ - bodies: List[Any] = [] + bodies: list[Any] = [] pdp_response = { "resource": "document:readme", "tenant": "default", @@ -68,7 +71,9 @@ async def test_authorized_users_parses_pdp_response(httpserver: HTTPServer, enfo ] }, } - httpserver.expect_request("/authorized_users", method="POST").respond_with_handler(_recorder(bodies, pdp_response)) + httpserver.expect_request("/authorized_users", method="POST").respond_with_handler( + _recorder(bodies, pdp_response) + ) result = await enforcer.authorized_users("read", "document:readme", {"attr": 1}) @@ -94,9 +99,11 @@ async def test_authorized_users_parses_pdp_response(httpserver: HTTPServer, enfo @pytest.mark.asyncio -async def test_bulk_check_sends_per_check_context(httpserver: HTTPServer, enforcer: Enforcer): +async def test_bulk_check_sends_per_check_context( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: """A per-check ``context`` must reach the wire, not be silently discarded.""" - bodies: List[Any] = [] + bodies: list[Any] = [] httpserver.expect_request("/allowed/bulk", method="POST").respond_with_handler( _recorder(bodies, {"allow": [{"allow": True}, {"allow": False}]}) ) @@ -123,9 +130,11 @@ async def test_bulk_check_sends_per_check_context(httpserver: HTTPServer, enforc @pytest.mark.asyncio -async def test_bulk_check_merges_per_check_context_over_method_context(httpserver: HTTPServer, enforcer: Enforcer): +async def test_bulk_check_merges_per_check_context_over_method_context( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: """Precedence: per-check context wins over the method-level context.""" - bodies: List[Any] = [] + bodies: list[Any] = [] httpserver.expect_request("/allowed/bulk", method="POST").respond_with_handler( _recorder(bodies, {"allow": [{"allow": True}]}) ) @@ -150,8 +159,10 @@ async def test_bulk_check_merges_per_check_context_over_method_context(httpserve @pytest.mark.asyncio -async def test_bulk_check_uses_method_context_when_check_has_none(httpserver: HTTPServer, enforcer: Enforcer): - bodies: List[Any] = [] +async def test_bulk_check_uses_method_context_when_check_has_none( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: + bodies: list[Any] = [] httpserver.expect_request("/allowed/bulk", method="POST").respond_with_handler( _recorder(bodies, {"allow": [{"allow": True}]}) ) @@ -165,22 +176,26 @@ async def test_bulk_check_uses_method_context_when_check_has_none(httpserver: HT @pytest.mark.asyncio -async def test_filter_objects_forwards_caller_context(httpserver: HTTPServer, enforcer: Enforcer): +async def test_filter_objects_forwards_caller_context( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: """Before the fix every check went out with ``"context": {}``. A context-dependent ABAC policy therefore evaluated against an empty context and could return the wrong subset. """ - bodies: List[Any] = [] + bodies: list[Any] = [] httpserver.expect_request("/allowed/bulk", method="POST").respond_with_handler( _recorder(bodies, {"allow": [{"allow": True}, {"allow": False}]}) ) - resources: List[Dict[str, Any]] = [ + resources: list[dict[str, Any]] = [ {"type": "document", "key": "a", "tenant": "t1", "attributes": {"owner": "user_a"}}, {"type": "document", "key": "b", "tenant": "t1", "attributes": {"owner": "user_b"}}, ] - allowed = await enforcer.filter_objects("user_a", "read", {"location": "eu", "mfa": True}, resources) + allowed = await enforcer.filter_objects( + "user_a", "read", {"location": "eu", "mfa": True}, resources + ) assert allowed == [resources[0]] assert [entry["context"] for entry in bodies[0]] == [ @@ -190,9 +205,11 @@ async def test_filter_objects_forwards_caller_context(httpserver: HTTPServer, en @pytest.mark.asyncio -async def test_filter_objects_keeps_per_resource_context_on_the_resource(httpserver: HTTPServer, enforcer: Enforcer): +async def test_filter_objects_keeps_per_resource_context_on_the_resource( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: """A resource-level ``context`` stays on the resource, not on the query.""" - bodies: List[Any] = [] + bodies: list[Any] = [] httpserver.expect_request("/allowed/bulk", method="POST").respond_with_handler( _recorder(bodies, {"allow": [{"allow": True}]}) ) @@ -212,8 +229,10 @@ async def test_filter_objects_keeps_per_resource_context_on_the_resource(httpser # --- bug 3: snake_case user fields silently dropped -------------------------- -def test_user_input_accepts_snake_case_and_alias(): - assert UserInput(key="u1", first_name="John", last_name="Doe", email="a@b.c").dict(exclude_unset=True) == { +def test_user_input_accepts_snake_case_and_alias() -> None: + assert UserInput(key="u1", first_name="John", last_name="Doe", email="a@b.c").dict( + exclude_unset=True + ) == { "key": "u1", "first_name": "John", "last_name": "Doe", @@ -227,10 +246,14 @@ def test_user_input_accepts_snake_case_and_alias(): @pytest.mark.asyncio -async def test_check_sends_snake_case_user_fields(httpserver: HTTPServer, enforcer: Enforcer): +async def test_check_sends_snake_case_user_fields( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: """The PDP reads ``first_name``/``last_name``; both spellings must reach it.""" - bodies: List[Any] = [] - httpserver.expect_request("/allowed", method="POST").respond_with_handler(_recorder(bodies, {"allow": True})) + bodies: list[Any] = [] + httpserver.expect_request("/allowed", method="POST").respond_with_handler( + _recorder(bodies, {"allow": True}) + ) decision = await enforcer.check( {"key": "u1", "first_name": "John", "last_name": "Doe", "attributes": {"tier": "gold"}}, @@ -248,8 +271,10 @@ async def test_check_sends_snake_case_user_fields(httpserver: HTTPServer, enforc @pytest.mark.asyncio -async def test_bulk_check_sends_snake_case_user_fields(httpserver: HTTPServer, enforcer: Enforcer): - bodies: List[Any] = [] +async def test_bulk_check_sends_snake_case_user_fields( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: + bodies: list[Any] = [] httpserver.expect_request("/allowed/bulk", method="POST").respond_with_handler( _recorder(bodies, {"allow": [{"allow": True}]}) ) @@ -266,3 +291,29 @@ async def test_bulk_check_sends_snake_case_user_fields(httpserver: HTTPServer, e ) assert bodies[0][0]["user"] == {"key": "u1", "first_name": "John"} + + +# --- the caller's objects are left alone ------------------------------------ + + +@pytest.mark.asyncio +async def test_check_does_not_modify_the_callers_resource_context( + httpserver: HTTPServer, enforcer: Enforcer +) -> None: + """The tenant is written into the context the SDK sends, not into the caller's dict. + + ``ResourceInput.context`` is annotated ``dict[Any, Any]``, which pydantic v1 + validates into a copy. A bare ``dict`` keeps the caller's object instead, and the + tenant that ``_normalize_resource`` adds would then leak into it. + """ + bodies: list[Any] = [] + httpserver.expect_request("/allowed", method="POST").respond_with_handler( + _recorder(bodies, {"allow": True}) + ) + context: dict[str, Any] = {"region": "eu"} + resource = {"type": "document", "key": "readme", "tenant": "t1", "context": context} + + assert await enforcer.check("user-1", "read", resource) is True + + assert context == {"region": "eu"} + assert bodies[0]["resource"]["context"] == {"region": "eu", "tenant": "t1"} diff --git a/tests/test_fix_permissions.py b/tests/test_fix_permissions.py index 5497887..5b97454 100644 --- a/tests/test_fix_permissions.py +++ b/tests/test_fix_permissions.py @@ -24,7 +24,7 @@ import json import uuid -from typing import Any, Dict, List +from typing import Any from pytest_httpserver import HTTPServer @@ -64,7 +64,7 @@ def _make_permit(httpserver: HTTPServer) -> Permit: ) -def _resource_role_response(permissions: List[str]) -> Dict[str, Any]: +def _resource_role_response(permissions: list[str]) -> dict[str, Any]: """One ``ResourceRoleRead`` as the backend serializes it (bare action keys).""" return { "id": str(uuid.uuid4()), @@ -84,7 +84,7 @@ def _resource_role_response(permissions: List[str]) -> Dict[str, Any]: } -def _role_response(permissions: List[str]) -> Dict[str, Any]: +def _role_response(permissions: list[str]) -> dict[str, Any]: """One ``RoleRead`` as the backend serializes it (``resource:action`` strings).""" return { "id": str(uuid.uuid4()), @@ -102,14 +102,19 @@ def _role_response(permissions: List[str]) -> Dict[str, Any]: } -def _sent_body(httpserver: HTTPServer, path: str, method: str) -> Dict[str, Any]: +def _sent_body(httpserver: HTTPServer, path: str, method: str) -> dict[str, Any]: """The JSON body of the single request the SDK made to ``path``.""" - requests = [request for request, _response in httpserver.log if request.path == path and request.method == method] + requests = [ + request + for request, _response in httpserver.log + if request.path == path and request.method == method + ] assert len(requests) == 1, f"expected exactly one {method} {path}, got {len(requests)}" - return json.loads(requests[0].get_data(as_text=True)) + body: dict[str, Any] = json.loads(requests[0].get_data(as_text=True)) + return body -async def test_resource_role_create_sends_bare_action_keys(httpserver: HTTPServer): +async def test_resource_role_create_sends_bare_action_keys(httpserver: HTTPServer) -> None: """``resource_roles.create`` must forward the action keys it was given, unprefixed.""" httpserver.expect_request(RESOURCE_ROLES_PATH, method="POST").respond_with_json( _resource_role_response(["read", "update"]) @@ -126,7 +131,9 @@ async def test_resource_role_create_sends_bare_action_keys(httpserver: HTTPServe httpserver.check_assertions() -async def test_resource_role_create_does_not_strip_a_caller_supplied_prefix(httpserver: HTTPServer): +async def test_resource_role_create_does_not_strip_a_caller_supplied_prefix( + httpserver: HTTPServer, +) -> None: """A caller who sends ``resource:action`` gets it on the wire, verbatim. The SDK must not paper over the format mismatch: the server's @@ -143,11 +150,15 @@ async def test_resource_role_create_does_not_strip_a_caller_supplied_prefix(http ResourceRoleCreate(key=ROLE_KEY, name="Editor", permissions=[f"{RESOURCE_KEY}:read"]), ) - assert _sent_body(httpserver, RESOURCE_ROLES_PATH, "POST")["permissions"] == [f"{RESOURCE_KEY}:read"] + assert _sent_body(httpserver, RESOURCE_ROLES_PATH, "POST")["permissions"] == [ + f"{RESOURCE_KEY}:read" + ] httpserver.check_assertions() -async def test_resource_role_assign_permissions_sends_bare_action_keys(httpserver: HTTPServer): +async def test_resource_role_assign_permissions_sends_bare_action_keys( + httpserver: HTTPServer, +) -> None: """``assign_permissions`` must send exactly the strings it was handed.""" httpserver.expect_request(RESOURCE_ROLE_PERMISSIONS_PATH, method="POST").respond_with_json( _resource_role_response(["read", "update"]) @@ -156,12 +167,16 @@ async def test_resource_role_assign_permissions_sends_bare_action_keys(httpserve granted = await permit.api.resource_roles.assign_permissions(RESOURCE_KEY, ROLE_KEY, ["update"]) - assert _sent_body(httpserver, RESOURCE_ROLE_PERMISSIONS_PATH, "POST") == {"permissions": ["update"]} + assert _sent_body(httpserver, RESOURCE_ROLE_PERMISSIONS_PATH, "POST") == { + "permissions": ["update"] + } assert granted.permissions == ["read", "update"] httpserver.check_assertions() -async def test_resource_role_remove_permissions_sends_bare_action_keys(httpserver: HTTPServer): +async def test_resource_role_remove_permissions_sends_bare_action_keys( + httpserver: HTTPServer, +) -> None: """``remove_permissions`` carries its body on a DELETE, unprefixed.""" httpserver.expect_request(RESOURCE_ROLE_PERMISSIONS_PATH, method="DELETE").respond_with_json( _resource_role_response(["read"]) @@ -170,25 +185,35 @@ async def test_resource_role_remove_permissions_sends_bare_action_keys(httpserve revoked = await permit.api.resource_roles.remove_permissions(RESOURCE_KEY, ROLE_KEY, ["update"]) - assert _sent_body(httpserver, RESOURCE_ROLE_PERMISSIONS_PATH, "DELETE") == {"permissions": ["update"]} + assert _sent_body(httpserver, RESOURCE_ROLE_PERMISSIONS_PATH, "DELETE") == { + "permissions": ["update"] + } assert revoked.permissions == ["read"] httpserver.check_assertions() -async def test_top_level_role_create_keeps_the_resource_qualified_form(httpserver: HTTPServer): +async def test_top_level_role_create_keeps_the_resource_qualified_form( + httpserver: HTTPServer, +) -> None: """A tenant role's permissions are ``resource:action`` and must not be rewritten.""" permissions = [f"{RESOURCE_KEY}:read", f"{RESOURCE_KEY}:update", "folder:read"] - httpserver.expect_request(ROLES_PATH, method="POST").respond_with_json(_role_response(permissions)) + httpserver.expect_request(ROLES_PATH, method="POST").respond_with_json( + _role_response(permissions) + ) permit = _make_permit(httpserver) - created = await permit.api.roles.create(RoleCreate(key="admin", name="Admin", permissions=permissions)) + created = await permit.api.roles.create( + RoleCreate(key="admin", name="Admin", permissions=permissions) + ) assert _sent_body(httpserver, ROLES_PATH, "POST")["permissions"] == permissions assert created.permissions == permissions httpserver.check_assertions() -async def test_role_assignment_filters_send_the_instance_ident_verbatim(httpserver: HTTPServer): +async def test_role_assignment_filters_send_the_instance_ident_verbatim( + httpserver: HTTPServer, +) -> None: """``resource_instance_key`` is a ``resource:key`` ident and travels unchanged. The server resolves this filter with ``get_or_create_resource_instance_by_string`` @@ -206,7 +231,9 @@ async def test_role_assignment_filters_send_the_instance_ident_verbatim(httpserv per_page=50, ) - requests = [request for request, _response in httpserver.log if request.path == ROLE_ASSIGNMENTS_PATH] + requests = [ + request for request, _response in httpserver.log if request.path == ROLE_ASSIGNMENTS_PATH + ] assert len(requests) == 1 assert requests[0].args["resource_instance"] == f"{RESOURCE_KEY}:readme" assert requests[0].args["resource"] == RESOURCE_KEY diff --git a/tests/test_fix_relations.py b/tests/test_fix_relations.py index 4f2bb0f..8f869f4 100644 --- a/tests/test_fix_relations.py +++ b/tests/test_fix_relations.py @@ -13,7 +13,7 @@ import re import uuid -from typing import Any, Dict +from typing import Any import pytest from pytest_httpserver import HTTPServer @@ -30,7 +30,7 @@ RELATIONS_PATH = f"/v2/schema/{PROJECT_ID}/{ENV_ID}/resources/{RESOURCE_KEY}/relations" -def _relation(key: str) -> Dict[str, Any]: +def _relation(key: str) -> dict[str, Any]: """One ``RelationRead`` exactly as the backend serializes it.""" return { "id": str(uuid.uuid4()), @@ -70,7 +70,7 @@ def _make_permit(httpserver: HTTPServer) -> Permit: ) -async def test_relations_list_parses_the_paginated_envelope(httpserver: HTTPServer): +async def test_relations_list_parses_the_paginated_envelope(httpserver: HTTPServer) -> None: """The envelope the backend really sends must parse, field for field.""" relations = [_relation("parent"), _relation("owner")] httpserver.expect_request(RELATIONS_PATH, method="GET").respond_with_json( @@ -94,7 +94,7 @@ async def test_relations_list_parses_the_paginated_envelope(httpserver: HTTPServ httpserver.check_assertions() -async def test_relations_list_sends_pagination_on_the_wire(httpserver: HTTPServer): +async def test_relations_list_sends_pagination_on_the_wire(httpserver: HTTPServer) -> None: """``page``/``per_page`` must reach the server, or paging silently does nothing.""" httpserver.expect_request(RELATIONS_PATH, method="GET").respond_with_json( {"data": [], "total_count": 0, "page_count": 0} @@ -110,7 +110,7 @@ async def test_relations_list_sends_pagination_on_the_wire(httpserver: HTTPServe httpserver.check_assertions() -async def test_relations_list_rejects_a_bare_array(httpserver: HTTPServer): +async def test_relations_list_rejects_a_bare_array(httpserver: HTTPServer) -> None: """A bare array is not what this endpoint returns, and must not parse as an envelope. This pins the contract in the other direction: the SDK surfaces a parse error rather diff --git a/tests/test_fix_serialization.py b/tests/test_fix_serialization.py index ef8cc3e..b1e72b5 100644 --- a/tests/test_fix_serialization.py +++ b/tests/test_fix_serialization.py @@ -15,11 +15,12 @@ import datetime from decimal import Decimal from enum import Enum +from typing import TYPE_CHECKING, Any from uuid import UUID import pytest from pytest_httpserver import HTTPServer -from werkzeug.wrappers import Response +from werkzeug.wrappers import Request, Response from permit.api.base import SimpleHttpClient from permit.api.models import ( @@ -30,12 +31,16 @@ ) from permit.utils.pydantic_version import PYDANTIC_VERSION -if PYDANTIC_VERSION < (2, 0): +if TYPE_CHECKING: + # The v1 API is what runs under either pydantic major, so type-check against it. + from pydantic.v1 import BaseModel +elif PYDANTIC_VERSION < (2, 0): from pydantic import BaseModel else: - from pydantic.v1 import BaseModel # type: ignore[assignment] + from pydantic.v1 import BaseModel -FIXED_DATETIME = datetime.datetime(2024, 3, 1, 12, 30, 45) +# Pins how the encoder renders a datetime without an offset. +FIXED_DATETIME = datetime.datetime(2024, 3, 1, 12, 30, 45) # noqa: DTZ001 - naive on purpose FIXED_UUID = UUID("11111111-2222-3333-4444-555555555555") @@ -58,11 +63,11 @@ def client(httpserver: HTTPServer) -> SimpleHttpClient: @pytest.fixture -def captured(httpserver: HTTPServer) -> list: +def captured(httpserver: HTTPServer) -> list[Any]: """Register a catch-all handler that records every received JSON body.""" - bodies: list = [] + bodies: list[Any] = [] - def handler(request): + def handler(request: Request) -> Response: bodies.append(request.get_json()) return Response('{"ok": true}', status=200, content_type="application/json") @@ -70,7 +75,9 @@ def handler(request): return bodies -async def test_explicitly_set_none_is_transmitted_as_null(client: SimpleHttpClient, captured: list): +async def test_explicitly_set_none_is_transmitted_as_null( + client: SimpleHttpClient, captured: list[Any] +) -> None: """An explicit ``email=None`` must reach the API as ``null``, not be dropped. Before the fix ``exclude_none=True`` removed it, so ``users.update()`` silently @@ -81,7 +88,7 @@ async def test_explicitly_set_none_is_transmitted_as_null(client: SimpleHttpClie assert captured == [{"email": None, "first_name": "Jane"}] -async def test_never_set_field_is_omitted(client: SimpleHttpClient, captured: list): +async def test_never_set_field_is_omitted(client: SimpleHttpClient, captured: list[Any]) -> None: """``exclude_unset`` still applies: untouched fields never appear in the body.""" await client.patch("/echo", model=Ack, json=UserUpdate(first_name="Jane")) @@ -90,7 +97,9 @@ async def test_never_set_field_is_omitted(client: SimpleHttpClient, captured: li assert "last_name" not in captured[0] -async def test_null_inside_attributes_dict_is_preserved(client: SimpleHttpClient, captured: list): +async def test_null_inside_attributes_dict_is_preserved( + client: SimpleHttpClient, captured: list[Any] +) -> None: """A ``null`` the caller put inside an ``attributes`` dict must survive. ``exclude_none`` recursed into plain dicts, so an attribute explicitly set to null @@ -102,10 +111,14 @@ async def test_null_inside_attributes_dict_is_preserved(client: SimpleHttpClient json=UserUpdate(attributes={"department": None, "age": 30, "nested": {"expired": None}}), ) - assert captured == [{"attributes": {"department": None, "age": 30, "nested": {"expired": None}}}] + assert captured == [ + {"attributes": {"department": None, "age": 30, "nested": {"expired": None}}} + ] -async def test_attributes_set_to_null_wholesale(client: SimpleHttpClient, captured: list): +async def test_attributes_set_to_null_wholesale( + client: SimpleHttpClient, captured: list[Any] +) -> None: """Clearing the whole attributes bag is expressible as ``attributes=None``. ``attributes`` defaults to ``{}``, so ``exclude_none`` made an explicit ``None`` @@ -116,7 +129,9 @@ async def test_attributes_set_to_null_wholesale(client: SimpleHttpClient, captur assert captured == [{"attributes": None}] -async def test_raw_dict_with_datetime_uuid_and_enum_is_encoded(client: SimpleHttpClient, captured: list): +async def test_raw_dict_with_datetime_uuid_and_enum_is_encoded( + client: SimpleHttpClient, captured: list[Any] +) -> None: """A raw dict body is now encoded. Before the fix ``_prepare_json`` returned dicts unchanged, and aiohttp raised @@ -151,9 +166,14 @@ async def test_raw_dict_with_datetime_uuid_and_enum_is_encoded(client: SimpleHtt ] -async def test_raw_dict_keys_are_never_dropped(client: SimpleHttpClient, captured: list): - """Encoding a dict must not remove keys -- the API schemas use ``Extra.forbid``, - and a silently dropped key is how the original ``exclude_none`` bug manifested.""" +async def test_raw_dict_keys_are_never_dropped( + client: SimpleHttpClient, captured: list[Any] +) -> None: + """Encoding a dict must not remove keys. + + The API schemas use ``Extra.forbid``, and a silently dropped key is how the + original ``exclude_none`` bug manifested. + """ body = {"key": "user-1", "email": None, "first_name": None} await client.post("/echo", model=Ack, json=body) @@ -161,7 +181,7 @@ async def test_raw_dict_keys_are_never_dropped(client: SimpleHttpClient, capture assert captured == [body] -async def test_list_body_encodes_each_item(client: SimpleHttpClient, captured: list): +async def test_list_body_encodes_each_item(client: SimpleHttpClient, captured: list[Any]) -> None: """A list body is handled, mixing models and raw dicts.""" await client.post( "/echo", @@ -180,11 +200,11 @@ async def test_list_body_encodes_each_item(client: SimpleHttpClient, captured: l ] -async def test_no_body_stays_absent(client: SimpleHttpClient, httpserver: HTTPServer): +async def test_no_body_stays_absent(client: SimpleHttpClient, httpserver: HTTPServer) -> None: """``json=None`` must not turn into a ``null`` body.""" - seen: list = [] + seen: list[bytes] = [] - def handler(request): + def handler(request: Request) -> Response: seen.append(request.get_data()) return Response('{"ok": true}', status=200, content_type="application/json") @@ -195,7 +215,9 @@ def handler(request): assert seen == [b""] -async def test_role_assignment_body_unchanged(client: SimpleHttpClient, captured: list): +async def test_role_assignment_body_unchanged( + client: SimpleHttpClient, captured: list[Any] +) -> None: """users.assign_role routes a model through this path; its body must not grow keys. The backend's ``UserRoleCreate.tenant``/``resource_instance`` are nullable, but an diff --git a/tests/test_fix_sync.py b/tests/test_fix_sync.py index fc45494..efbfd61 100644 --- a/tests/test_fix_sync.py +++ b/tests/test_fix_sync.py @@ -7,9 +7,10 @@ import asyncio import inspect +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone -from typing import Any, Callable +from typing import Any from uuid import uuid4 import pytest @@ -47,19 +48,20 @@ def config(httpserver: HTTPServer) -> PermitConfig: return offline_config(httpserver.url_for("").rstrip("/")) -def sync_wrapper_depth(func: Callable) -> int: +def sync_wrapper_depth(func: Callable[..., object]) -> int: """Count how many ``async_to_sync`` wrappers a callable is nested in.""" depth = 0 - seen = set() - while func is not None and id(func) not in seen: - seen.add(id(func)) - if getattr(func, SYNC_WRAPPER_MARKER, False): + seen: set[int] = set() + candidate: object = func + while candidate is not None and id(candidate) not in seen: + seen.add(id(candidate)) + if getattr(candidate, SYNC_WRAPPER_MARKER, False): depth += 1 - func = getattr(func, "__wrapped__", None) + candidate = getattr(candidate, "__wrapped__", None) return depth -def user_payload(key: str) -> dict: +def user_payload(key: str) -> dict[str, Any]: now = datetime.now(timezone.utc).isoformat() return { "key": key, @@ -76,7 +78,7 @@ def user_payload(key: str) -> dict: # --- the metaclass itself ------------------------------------------------- -def test_async_method_is_wrapped_exactly_once(): +def test_async_method_is_wrapped_exactly_once() -> None: class Base(metaclass=SyncClass): async def fetch(self) -> str: return "fetched" @@ -85,7 +87,7 @@ async def fetch(self) -> str: assert Base().fetch() == "fetched" -def test_subclass_does_not_rewrap_inherited_methods(): +def test_subclass_does_not_rewrap_inherited_methods() -> None: class Base(metaclass=SyncClass): async def fetch(self) -> str: return "fetched" @@ -100,7 +102,7 @@ async def other(self) -> str: assert Child().other() == "other" -def test_genuinely_sync_method_is_left_untouched(): +def test_genuinely_sync_method_is_left_untouched() -> None: class Mixed(metaclass=SyncClass): def ping(self) -> str: return "pong" @@ -114,12 +116,14 @@ async def fetch(self) -> str: assert Mixed().fetch() == "fetched" -def test_method_wrapped_by_a_plain_decorator_is_still_converted(): - """A sync decorator that returns the inner coroutine (e.g. pydantic's - ``validate_arguments``) must not hide the fact that the method is async.""" +def test_method_wrapped_by_a_plain_decorator_is_still_converted() -> None: + """A sync decorator returning the inner coroutine must not hide that it is async. - def passthrough(func: Callable) -> Callable: - def wrapper(*args, **kwargs): + pydantic's ``validate_arguments`` is such a decorator. + """ + + def passthrough(func: Callable[..., object]) -> Callable[..., object]: + def wrapper(*args: Any, **kwargs: Any) -> object: return func(*args, **kwargs) wrapper.__wrapped__ = func # what functools.wraps records @@ -134,14 +138,14 @@ async def fetch(self) -> str: assert Decorated().fetch() == "fetched" -def test_real_sdk_classes_are_wrapped_exactly_once(): +def test_real_sdk_classes_are_wrapped_exactly_once() -> None: assert sync_wrapper_depth(SyncPermitApiClient.get_user) == 1 assert sync_wrapper_depth(SyncUsersApi.get) == 1 assert sync_wrapper_depth(SyncEnforcer.check) == 1 assert sync_wrapper_depth(SyncEnforcer.filter_objects) == 1 -def test_every_public_method_of_the_api_client_is_synchronous(): +def test_every_public_method_of_the_api_client_is_synchronous() -> None: for name in dir(SyncPermitApiClient): if name.startswith("_"): continue @@ -155,23 +159,31 @@ def test_every_public_method_of_the_api_client_is_synchronous(): # --- the deprecated facade ------------------------------------------------ -def test_deprecated_facade_get_user_issues_a_request(httpserver: HTTPServer, config: PermitConfig): +def test_deprecated_facade_get_user_issues_a_request( + httpserver: HTTPServer, config: PermitConfig +) -> None: payload = user_payload("user-1") - httpserver.expect_oneshot_request(f"{FACTS}/users/user-1", method="GET").respond_with_json(payload) + httpserver.expect_oneshot_request(f"{FACTS}/users/user-1", method="GET").respond_with_json( + payload + ) client = SyncPermitApiClient(config) - with pytest.warns(DeprecationWarning): + with pytest.warns(DeprecationWarning, match=r"permit\.api\.users\.get\(\)"): user = client.get_user("user-1") assert user.key == "user-1" httpserver.check_assertions() -def test_deprecated_facade_list_roles_issues_a_request(httpserver: HTTPServer, config: PermitConfig): - httpserver.expect_oneshot_request(f"/v2/schema/{PROJECT}/{ENVIRONMENT}/roles", method="GET").respond_with_json([]) +def test_deprecated_facade_list_roles_issues_a_request( + httpserver: HTTPServer, config: PermitConfig +) -> None: + httpserver.expect_oneshot_request( + f"/v2/schema/{PROJECT}/{ENVIRONMENT}/roles", method="GET" + ).respond_with_json([]) client = SyncPermitApiClient(config) - with pytest.warns(DeprecationWarning): + with pytest.warns(DeprecationWarning, match=r"permit\.api\.roles\.list\(\)"): roles = client.list_roles() assert roles == [] @@ -181,7 +193,7 @@ def test_deprecated_facade_list_roles_issues_a_request(httpserver: HTTPServer, c # --- the sync Permit facade ------------------------------------------------ -def test_sync_permit_check(httpserver: HTTPServer, config: PermitConfig): +def test_sync_permit_check(httpserver: HTTPServer, config: PermitConfig) -> None: httpserver.expect_oneshot_request("/allowed", method="POST").respond_with_json({"allow": True}) result = SyncPermit(config).check("user-1", "read", "document") @@ -190,7 +202,7 @@ def test_sync_permit_check(httpserver: HTTPServer, config: PermitConfig): httpserver.check_assertions() -def test_sync_permit_authorized_users(httpserver: HTTPServer, config: PermitConfig): +def test_sync_permit_authorized_users(httpserver: HTTPServer, config: PermitConfig) -> None: httpserver.expect_oneshot_request("/authorized_users", method="POST").respond_with_json( { "resource": "document:*", @@ -216,7 +228,7 @@ def test_sync_permit_authorized_users(httpserver: HTTPServer, config: PermitConf httpserver.check_assertions() -def test_sync_permit_get_user_permissions(httpserver: HTTPServer, config: PermitConfig): +def test_sync_permit_get_user_permissions(httpserver: HTTPServer, config: PermitConfig) -> None: httpserver.expect_oneshot_request( "/user-permissions", method="POST", @@ -226,7 +238,9 @@ def test_sync_permit_get_user_permissions(httpserver: HTTPServer, config: Permit "resources": None, "resource_types": None, }, - ).respond_with_json({"default": {"tenant": {"key": "default"}, "permissions": ["document:read"]}}) + ).respond_with_json( + {"default": {"tenant": {"key": "default"}, "permissions": ["document:read"]}} + ) result = SyncPermit(config).get_user_permissions("user-1") @@ -235,9 +249,12 @@ def test_sync_permit_get_user_permissions(httpserver: HTTPServer, config: Permit httpserver.check_assertions() -def test_sync_permit_filter_objects(httpserver: HTTPServer, config: PermitConfig): - """``Enforcer.filter_objects`` awaits ``self.bulk_check``, which the sync - client has already converted - the re-entrant call has to keep working.""" +def test_sync_permit_filter_objects(httpserver: HTTPServer, config: PermitConfig) -> None: + """The re-entrant call from ``filter_objects`` to ``bulk_check`` has to keep working. + + ``Enforcer.filter_objects`` awaits ``self.bulk_check``, which the sync client + has already converted. + """ httpserver.expect_oneshot_request("/allowed/bulk", method="POST").respond_with_json( {"allow": [{"allow": True}, {"allow": False}, {"allow": True}]} ) @@ -254,7 +271,7 @@ def test_sync_permit_filter_objects(httpserver: HTTPServer, config: PermitConfig httpserver.check_assertions() -def test_sync_permit_bulk_check(httpserver: HTTPServer, config: PermitConfig): +def test_sync_permit_bulk_check(httpserver: HTTPServer, config: PermitConfig) -> None: httpserver.expect_oneshot_request("/allowed/bulk", method="POST").respond_with_json( {"allow": [{"allow": True}, {"allow": False}]} ) @@ -270,20 +287,29 @@ def test_sync_permit_bulk_check(httpserver: HTTPServer, config: PermitConfig): httpserver.check_assertions() -def test_sync_permit_check_from_a_worker_thread(httpserver: HTTPServer, config: PermitConfig): +def test_sync_permit_check_from_a_worker_thread( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_request("/allowed", method="POST").respond_with_json({"allow": True}) permit = SyncPermit(config) with ThreadPoolExecutor(max_workers=2) as executor: - results = [future.result() for future in [executor.submit(permit.check, "u", "read", "document")] * 2] + results = [ + future.result() + for future in [executor.submit(permit.check, "u", "read", "document")] * 2 + ] assert results == [True, True] httpserver.check_assertions() -def test_sync_permit_check_from_inside_a_running_event_loop(httpserver: HTTPServer, config: PermitConfig): - """Calling the sync client from async code used to raise - ``RuntimeError: This event loop is already running``.""" +def test_sync_permit_check_from_inside_a_running_event_loop( + httpserver: HTTPServer, config: PermitConfig +) -> None: + """The sync client can be called from async code. + + It used to raise ``RuntimeError: This event loop is already running``. + """ httpserver.expect_oneshot_request("/allowed", method="POST").respond_with_json({"allow": True}) permit = SyncPermit(config) @@ -295,9 +321,12 @@ async def main() -> bool: httpserver.check_assertions() -def test_sync_pdp_api_role_assignments_list(httpserver: HTTPServer, config: PermitConfig): - """``RoleAssignmentsApi.list`` is decorated with pydantic's ``validate_arguments``, - which hides the ``async def`` behind a plain function.""" +def test_sync_pdp_api_role_assignments_list(httpserver: HTTPServer, config: PermitConfig) -> None: + """The PDP role assignments list works through the sync client. + + ``RoleAssignmentsApi.list`` is decorated with pydantic's ``validate_arguments``, + which hides the ``async def`` behind a plain function. + """ httpserver.expect_oneshot_request( "/local/role_assignments", method="GET", @@ -310,7 +339,15 @@ def test_sync_pdp_api_role_assignments_list(httpserver: HTTPServer, config: Perm httpserver.check_assertions() -def test_sync_permit_public_methods_are_not_coroutines(): - for name in ("check", "bulk_check", "authorized_users", "get_user_permissions", "filter_objects"): +def test_sync_permit_public_methods_are_not_coroutines() -> None: + for name in ( + "check", + "bulk_check", + "authorized_users", + "get_user_permissions", + "filter_objects", + ): attr = getattr(SyncPermit, name) - assert not inspect.iscoroutinefunction(attr), f"SyncPermit.{name} is still a coroutine function" + assert not inspect.iscoroutinefunction(attr), ( + f"SyncPermit.{name} is still a coroutine function" + ) diff --git a/tests/test_fix_tenants.py b/tests/test_fix_tenants.py index 7f7cb57..4bbb6cd 100644 --- a/tests/test_fix_tenants.py +++ b/tests/test_fix_tenants.py @@ -9,7 +9,7 @@ import json import re import uuid -from typing import List, Tuple +from typing import Any from pytest_httpserver import HTTPServer @@ -22,7 +22,7 @@ SCOPE_PATH = "/v2/api-key/scope" -RecordedRequest = Tuple[str, str, dict] +RecordedRequest = tuple[str, str, dict[str, Any]] def _make_permit(httpserver: HTTPServer, *, proxy_facts_via_pdp: bool) -> Permit: @@ -51,7 +51,7 @@ def _make_permit(httpserver: HTTPServer, *, proxy_facts_via_pdp: bool) -> Permit ) -def _facts_requests(httpserver: HTTPServer) -> List[RecordedRequest]: +def _facts_requests(httpserver: HTTPServer) -> list[RecordedRequest]: """Every request the SDK made, except the api-key scope bootstrap call.""" requests = [] for request, _response in httpserver.log: @@ -62,7 +62,7 @@ def _facts_requests(httpserver: HTTPServer) -> List[RecordedRequest]: return requests -async def test_tenants_bulk_create_targets_the_pdp_tenants_endpoint(httpserver: HTTPServer): +async def test_tenants_bulk_create_targets_the_pdp_tenants_endpoint(httpserver: HTTPServer) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=True) await permit.api.tenants.bulk_create([TenantCreate(key="tenant-1", name="Tenant 1")]) @@ -77,16 +77,20 @@ async def test_tenants_bulk_create_targets_the_pdp_tenants_endpoint(httpserver: httpserver.check_assertions() -async def test_tenants_bulk_delete_targets_the_pdp_tenants_endpoint(httpserver: HTTPServer): +async def test_tenants_bulk_delete_targets_the_pdp_tenants_endpoint(httpserver: HTTPServer) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=True) await permit.api.tenants.bulk_delete(["tenant-1", "tenant-2"]) - assert _facts_requests(httpserver) == [("DELETE", "/facts/bulk/tenants", {"idents": ["tenant-1", "tenant-2"]})] + assert _facts_requests(httpserver) == [ + ("DELETE", "/facts/bulk/tenants", {"idents": ["tenant-1", "tenant-2"]}) + ] httpserver.check_assertions() -async def test_tenant_bulk_operations_never_reach_the_users_endpoint(httpserver: HTTPServer): +async def test_tenant_bulk_operations_never_reach_the_users_endpoint( + httpserver: HTTPServer, +) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=True) await permit.api.tenants.bulk_create([TenantCreate(key="tenant-1", name="Tenant 1")]) @@ -96,15 +100,19 @@ async def test_tenant_bulk_operations_never_reach_the_users_endpoint(httpserver: assert paths == {"/facts/bulk/tenants"} -async def test_users_bulk_create_targets_the_pdp_users_endpoint(httpserver: HTTPServer): +async def test_users_bulk_create_targets_the_pdp_users_endpoint(httpserver: HTTPServer) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=True) await permit.api.users.bulk_create([UserCreate(key="user-1")]) - assert _facts_requests(httpserver) == [("POST", "/facts/bulk/users", {"operations": [{"key": "user-1"}]})] + assert _facts_requests(httpserver) == [ + ("POST", "/facts/bulk/users", {"operations": [{"key": "user-1"}]}) + ] -async def test_resource_instances_bulk_operations_target_their_pdp_endpoint(httpserver: HTTPServer): +async def test_resource_instances_bulk_operations_target_their_pdp_endpoint( + httpserver: HTTPServer, +) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=True) await permit.api.resource_instances.bulk_replace( @@ -122,7 +130,9 @@ async def test_resource_instances_bulk_operations_target_their_pdp_endpoint(http ] -async def test_tenants_bulk_create_without_pdp_proxy_targets_the_rest_api(httpserver: HTTPServer): +async def test_tenants_bulk_create_without_pdp_proxy_targets_the_rest_api( + httpserver: HTTPServer, +) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=False) await permit.api.tenants.bulk_create([TenantCreate(key="tenant-1", name="Tenant 1")]) diff --git a/tests/test_offline_regressions.py b/tests/test_offline_regressions.py index 774e1ce..e79f473 100644 --- a/tests/test_offline_regressions.py +++ b/tests/test_offline_regressions.py @@ -6,17 +6,26 @@ issued. """ +import math +import subprocess +import sys +import warnings +from collections.abc import AsyncIterator from datetime import datetime, timezone -from typing import Optional +from decimal import Decimal +from typing import Any from uuid import UUID, uuid4 import aiohttp +import pydantic import pytest from pytest_httpserver import HTTPServer from werkzeug import Request +from permit import exceptions from permit.api.context import ApiContext, ApiKeyAccessLevel from permit.api.elements import ElementsApi +from permit.api.encoders import jsonable_encoder from permit.api.models import RoleAssignmentCreate, RoleAssignmentRemove from permit.api.resource_instances import ResourceInstancesApi from permit.api.users import UsersApi @@ -27,10 +36,10 @@ PermitConnectionError, PermitContextError, PermitError, - PermitException, handle_api_error, ) from permit.pdp_api.pdp_api_client import SyncPDPApi +from permit.utils import pydantic_version from permit.utils.context import ContextStore ORG = "test-org" @@ -39,7 +48,7 @@ FACTS = f"/v2/facts/{PROJECT}/{ENVIRONMENT}" -def offline_config(base_url: str, **overrides) -> PermitConfig: +def offline_config(base_url: str, **overrides: Any) -> PermitConfig: """Build a PermitConfig whose context is already resolved to environment level. This is the state the SDK holds after a successful ``/v2/api-key/scope`` @@ -62,7 +71,7 @@ def config(httpserver: HTTPServer) -> PermitConfig: return offline_config(httpserver.url_for("").rstrip("/")) -def role_assignment_read_payload() -> dict: +def role_assignment_read_payload() -> dict[str, Any]: now = datetime.now(timezone.utc).isoformat() return { "id": str(uuid4()), @@ -79,7 +88,7 @@ def role_assignment_read_payload() -> dict: } -def user_read_payload(key: str) -> dict: +def user_read_payload(key: str) -> dict[str, Any]: now = datetime.now(timezone.utc).isoformat() return { "key": key, @@ -94,13 +103,15 @@ def user_read_payload(key: str) -> dict: def single_request(httpserver: HTTPServer) -> Request: """Return the only request the server handled, failing if there was not exactly one.""" - assert len(httpserver.log) == 1, f"expected exactly one request, got {[r.url for r, _ in httpserver.log]}" + assert len(httpserver.log) == 1, ( + f"expected exactly one request, got {[r.url for r, _ in httpserver.log]}" + ) return httpserver.log[0][0] async def test_resource_instances_list_sends_detailed_filter_as_query_string( httpserver: HTTPServer, config: PermitConfig -): +) -> None: """detailed_key must reach the wire as a string: yarl rejects bool query values.""" httpserver.expect_request(f"{FACTS}/resource_instances", method="GET").respond_with_json([]) @@ -111,7 +122,7 @@ async def test_resource_instances_list_sends_detailed_filter_as_query_string( async def test_resource_instances_list_sends_detailed_false_as_query_string( httpserver: HTTPServer, config: PermitConfig -): +) -> None: httpserver.expect_request(f"{FACTS}/resource_instances", method="GET").respond_with_json([]) await ResourceInstancesApi(config).list(detailed_key=False) @@ -119,7 +130,9 @@ async def test_resource_instances_list_sends_detailed_false_as_query_string( assert single_request(httpserver).args["detailed"] == "false" -async def test_resource_instances_list_omits_detailed_when_not_requested(httpserver: HTTPServer, config: PermitConfig): +async def test_resource_instances_list_omits_detailed_when_not_requested( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_request(f"{FACTS}/resource_instances", method="GET").respond_with_json([]) await ResourceInstancesApi(config).list() @@ -127,21 +140,29 @@ async def test_resource_instances_list_omits_detailed_when_not_requested(httpser assert "detailed" not in single_request(httpserver).args -async def test_users_sync_does_not_mutate_the_caller_dict(httpserver: HTTPServer, config: PermitConfig): +async def test_users_sync_does_not_mutate_the_caller_dict( + httpserver: HTTPServer, config: PermitConfig +) -> None: """The dict branch of users.sync() must not pop 'key' out of the caller's dict.""" # an invalid email keeps pydantic's Union[UserCreate, dict] coercion on the dict branch user = {"key": "user-1", "email": "not-an-email"} - httpserver.expect_request(f"{FACTS}/users/user-1", method="PUT").respond_with_json(user_read_payload("user-1")) + httpserver.expect_request(f"{FACTS}/users/user-1", method="PUT").respond_with_json( + user_read_payload("user-1") + ) await UsersApi(config).sync(user) assert user == {"key": "user-1", "email": "not-an-email"} -async def test_users_sync_dict_branch_is_reusable(httpserver: HTTPServer, config: PermitConfig): +async def test_users_sync_dict_branch_is_reusable( + httpserver: HTTPServer, config: PermitConfig +) -> None: """A caller may retry with the same dict; the second call must not raise KeyError.""" user = {"key": "user-1", "email": "not-an-email"} - httpserver.expect_request(f"{FACTS}/users/user-1", method="PUT").respond_with_json(user_read_payload("user-1")) + httpserver.expect_request(f"{FACTS}/users/user-1", method="PUT").respond_with_json( + user_read_payload("user-1") + ) api = UsersApi(config) await api.sync(user) @@ -150,34 +171,46 @@ async def test_users_sync_dict_branch_is_reusable(httpserver: HTTPServer, config assert len(httpserver.log) == 2 -async def test_users_assign_role_strips_unset_optional_fields(httpserver: HTTPServer, config: PermitConfig): +async def test_users_assign_role_strips_unset_optional_fields( + httpserver: HTTPServer, config: PermitConfig +) -> None: """users.assign_role must match role_assignments.assign and not transmit explicit nulls.""" httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="POST").respond_with_json( role_assignment_read_payload() ) - await UsersApi(config).assign_role(RoleAssignmentCreate(user="user-1", role="admin", tenant="tenant-1")) + await UsersApi(config).assign_role( + RoleAssignmentCreate(user="user-1", role="admin", tenant="tenant-1") + ) assert single_request(httpserver).get_json() == {"role": "admin", "tenant": "tenant-1"} -async def test_users_unassign_role_strips_unset_optional_fields(httpserver: HTTPServer, config: PermitConfig): - httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="DELETE").respond_with_data("", status=204) +async def test_users_unassign_role_strips_unset_optional_fields( + httpserver: HTTPServer, config: PermitConfig +) -> None: + httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="DELETE").respond_with_data( + "", status=204 + ) - await UsersApi(config).unassign_role(RoleAssignmentRemove(user="user-1", role="admin", tenant="tenant-1")) + await UsersApi(config).unassign_role( + RoleAssignmentRemove(user="user-1", role="admin", tenant="tenant-1") + ) assert single_request(httpserver).get_json() == {"role": "admin", "tenant": "tenant-1"} async def test_users_assign_role_keeps_explicitly_provided_resource_instance( httpserver: HTTPServer, config: PermitConfig -): +) -> None: httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="POST").respond_with_json( role_assignment_read_payload() ) await UsersApi(config).assign_role( - RoleAssignmentCreate(user="user-1", role="admin", tenant="tenant-1", resource_instance="doc:readme") + RoleAssignmentCreate( + user="user-1", role="admin", tenant="tenant-1", resource_instance="doc:readme" + ) ) assert single_request(httpserver).get_json() == { @@ -195,12 +228,15 @@ async def test_users_assign_role_keeps_explicitly_provided_resource_instance( (ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY, ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY), (ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY, ApiKeyAccessLevel.ENVIRONMENT_LEVEL_API_KEY), (ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY, ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY), - (ApiKeyAccessLevel.ORGANIZATION_LEVEL_API_KEY, ApiKeyAccessLevel.ORGANIZATION_LEVEL_API_KEY), + ( + ApiKeyAccessLevel.ORGANIZATION_LEVEL_API_KEY, + ApiKeyAccessLevel.ORGANIZATION_LEVEL_API_KEY, + ), ], ) async def test_ensure_access_level_accepts_a_key_broad_enough_for_the_endpoint( config: PermitConfig, permitted: ApiKeyAccessLevel, required: ApiKeyAccessLevel -): +) -> None: api = UsersApi(config) api.config.api_context._permitted_access_level = permitted @@ -217,7 +253,7 @@ async def test_ensure_access_level_accepts_a_key_broad_enough_for_the_endpoint( ) async def test_ensure_access_level_rejects_a_key_too_narrow_for_the_endpoint( config: PermitConfig, permitted: ApiKeyAccessLevel, required: ApiKeyAccessLevel -): +) -> None: api = UsersApi(config) api.config.api_context._permitted_access_level = permitted @@ -225,7 +261,7 @@ async def test_ensure_access_level_rejects_a_key_too_narrow_for_the_endpoint( await api._ensure_access_level(required) -def test_sync_pdp_api_initializes_the_base_client_state(config: PermitConfig): +def test_sync_pdp_api_initializes_the_base_client_state(config: PermitConfig) -> None: """SyncPDPApi must run PermitPdpApiClient.__init__, not skip it.""" client = SyncPDPApi(config) @@ -235,7 +271,9 @@ def test_sync_pdp_api_initializes_the_base_client_state(config: PermitConfig): assert client._headers["Content-Type"] == "application/json" -async def test_elements_login_as_sends_canonical_uuid_strings(httpserver: HTTPServer, config: PermitConfig): +async def test_elements_login_as_sends_canonical_uuid_strings( + httpserver: HTTPServer, config: PermitConfig +) -> None: """UUID ids must be sent in canonical hyphenated form, not UUID.hex.""" httpserver.expect_request("/v2/auth/elements_login_as", method="POST").respond_with_json( {"redirect_url": "http://elements.permit.test/login"} @@ -252,7 +290,9 @@ async def test_elements_login_as_sends_canonical_uuid_strings(httpserver: HTTPSe } -async def test_elements_login_as_passes_string_ids_through(httpserver: HTTPServer, config: PermitConfig): +async def test_elements_login_as_passes_string_ids_through( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_request("/v2/auth/elements_login_as", method="POST").respond_with_json( {"redirect_url": "http://elements.permit.test/login"} ) @@ -262,13 +302,13 @@ async def test_elements_login_as_passes_string_ids_through(httpserver: HTTPServe assert single_request(httpserver).get_json() == {"user_id": "user-1", "tenant_id": "tenant-1"} -def test_context_store_exposes_no_silently_ignored_transform_api(): +def test_context_store_exposes_no_silently_ignored_transform_api() -> None: """register_transform()/transform() were dead: the enforcer never consulted them.""" assert not hasattr(ContextStore, "register_transform") assert not hasattr(ContextStore, "transform") -def test_context_store_derives_context_by_deep_merging_the_base_context(): +def test_context_store_derives_context_by_deep_merging_the_base_context() -> None: store = ContextStore() store.add({"tenant": "t1", "attributes": {"region": "eu"}}) @@ -277,7 +317,9 @@ def test_context_store_derives_context_by_deep_merging_the_base_context(): assert derived == {"tenant": "t1", "attributes": {"region": "eu", "tier": "gold"}} -async def _response_for(httpserver: HTTPServer, status: int, body: str, content_type: Optional[str] = None): +async def _response_for( + httpserver: HTTPServer, status: int, body: str, content_type: str | None = None +) -> AsyncIterator[aiohttp.ClientResponse]: """Perform one real (localhost) request and hand the live aiohttp response to the caller.""" httpserver.expect_request("/probe", method="GET").respond_with_data( body, @@ -286,42 +328,136 @@ async def _response_for(httpserver: HTTPServer, status: int, body: str, content_ headers={"Location": "http://elsewhere.test/"}, ) url = httpserver.url_for("/probe") - async with aiohttp.ClientSession() as session, session.get(url, allow_redirects=False) as response: + async with ( + aiohttp.ClientSession() as session, + session.get(url, allow_redirects=False) as response, + ): yield response @pytest.mark.parametrize("status", [200, 201, 204, 299]) -async def test_handle_api_error_accepts_success_statuses(httpserver: HTTPServer, status: int): +async def test_handle_api_error_accepts_success_statuses( + httpserver: HTTPServer, status: int +) -> None: async for response in _response_for(httpserver, status, ""): - assert await handle_api_error(response) is None + await handle_api_error(response) # accepted: does not raise @pytest.mark.parametrize("status", [301, 302, 303, 307, 308]) -async def test_handle_api_error_rejects_redirect_statuses(httpserver: HTTPServer, status: int): +async def test_handle_api_error_rejects_redirect_statuses( + httpserver: HTTPServer, status: int +) -> None: """A redirect the client did not follow is not a successful API response.""" - async for response in _response_for(httpserver, status, "Moved", content_type="text/html"): + async for response in _response_for( + httpserver, status, "Moved", content_type="text/html" + ): with pytest.raises(PermitApiError) as exc_info: await handle_api_error(response) assert exc_info.value.status_code == status -def test_permit_connection_error_still_caught_by_the_deprecated_base(): +def test_permit_connection_error_still_caught_by_the_deprecated_base() -> None: # Regression guard, not an endorsement. `PermitException` is deprecated, # but consumers on 2.6.x catch it, and re-parenting PermitConnectionError # onto PermitError would silently stop `except PermitException` from # catching connection failures. Re-parent it in a major version, not here. - assert issubclass(PermitConnectionError, PermitException) + assert issubclass(PermitConnectionError, exceptions.PermitException) # type: ignore[deprecated] -def test_permit_connection_error_is_still_a_permit_error(): +def test_permit_connection_error_is_still_a_permit_error() -> None: error = PermitConnectionError("boom") assert isinstance(error, PermitError) assert error.original_error is None -def test_check_query_context_is_optional(): +def test_check_query_context_is_optional() -> None: # bulk_check reads each check's context with .get(), so a query without one # is valid and the TypedDict must not make type checkers demand it. assert CheckQuery.__required_keys__ == {"user", "action", "resource"} assert CheckQuery.__optional_keys__ == {"context"} + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (Decimal(1), 1), + (Decimal("1E+2"), 100), + (Decimal("1.0"), 1.0), + (Decimal("-2.5"), -2.5), + (Decimal("Infinity"), math.inf), + (Decimal("-Infinity"), -math.inf), + ], +) +def test_jsonable_encoder_encodes_decimals(value: Decimal, expected: float) -> None: + encoded = jsonable_encoder({"value": value})["value"] + + assert encoded == expected + assert type(encoded) is type(expected) + + +@pytest.mark.parametrize("value", [Decimal("NaN"), Decimal("-NaN")]) +def test_jsonable_encoder_encodes_decimal_nan_as_float_nan(value: Decimal) -> None: + # A non-finite Decimal has a str exponent ("n" or "F"), which used to be + # compared with 0 and raise TypeError. + encoded = jsonable_encoder([value])[0] + + assert isinstance(encoded, float) + assert math.isnan(encoded) + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("1.10.13", (1, 10, 13)), + ("2.13.5", (2, 13, 5)), + ("2.0", (2, 0)), + ("2.14.0b2", (2, 14, 0)), + ("2.12.0a1", (2, 12, 0)), + ("2.11.0rc1", (2, 11, 0)), + ("2.13.0.dev0", (2, 13, 0)), + ("2.13.5+local", (2, 13, 5)), + ], +) +def test_pydantic_version_parses_release_and_pre_release_versions( + version: str, expected: tuple[int, ...] +) -> None: + assert pydantic_version._parse(version) == expected + + +def test_pydantic_version_rejects_a_component_without_a_leading_number() -> None: + with pytest.raises(ValueError, match=r"'x1'"): + pydantic_version._parse("2.x1.0") + + +def test_pydantic_version_constant_is_the_installed_version() -> None: + assert pydantic_version._parse(pydantic.__version__) == pydantic_version.PYDANTIC_VERSION + + +def test_importing_the_sdk_emits_no_deprecation_warning() -> None: + result = subprocess.run( + [sys.executable, "-W", "error::DeprecationWarning", "-c", "import permit"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + + +def test_permit_exception_still_warns_when_instantiated() -> None: + with pytest.warns(DeprecationWarning, match="Use PermitError instead"): + exceptions.PermitException("boom") # type: ignore[deprecated] + + +def test_permit_exception_still_warns_when_subclassed() -> None: + with pytest.warns(DeprecationWarning, match="Use PermitError instead"): + + class _Custom(exceptions.PermitException): # type: ignore[deprecated] + pass + + +def test_permit_connection_error_instantiation_does_not_warn() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + PermitConnectionError("boom") diff --git a/tests/test_rbac_e2e.py b/tests/test_rbac_e2e.py index 7902baf..c88fad3 100644 --- a/tests/test_rbac_e2e.py +++ b/tests/test_rbac_e2e.py @@ -1,6 +1,7 @@ import asyncio import time -from typing import Any, AsyncIterable, Awaitable, Callable, Final, List, Optional +from collections.abc import AsyncIterable, Awaitable, Callable +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar import pytest from loguru import logger @@ -9,14 +10,15 @@ from permit import Permit, ResourceRead, RoleAssignmentRead, RoleRead from permit.exceptions import PermitApiError, PermitConnectionError -from permit.pdp_api.models import RoleAssignment +from tests.conftest import MOCKED_PORT +from tests.utils import handle_api_error, handle_cleanup_error, unique_key -from .conftest import MOCKED_PORT -from .utils import handle_api_error, handle_cleanup_error, unique_key +if TYPE_CHECKING: + from permit.pdp_api.models import RoleAssignment -def print_break(): - print("\n\n ----------- \n\n") # noqa: T201 +def print_break() -> None: + print("\n\n ----------- \n\n") TEST_TIMEOUT = 1 @@ -28,7 +30,7 @@ def print_break(): RESOURCE_READ_ACTION: Final[str] = "read" RESOURCE_UPDATE_ACTION: Final[str] = "update" RESOURCE_DELETE_ACTION: Final[str] = "delete" -RESOURCE_ACTIONS: Final[List[str]] = [ +RESOURCE_ACTIONS: Final[list[str]] = [ RESOURCE_CREATE_ACTION, RESOURCE_READ_ACTION, RESOURCE_UPDATE_ACTION, @@ -64,7 +66,17 @@ async def wait_until( await asyncio.sleep(interval) -async def find_by_key(list_page: Callable[[int], Awaitable[List[Any]]], key: str) -> Optional[Any]: +class _Keyed(Protocol): + @property + def key(self) -> str: ... + + +KeyedT = TypeVar("KeyedT", bound=_Keyed) + + +async def find_by_key( + list_page: Callable[[int], Awaitable[list[KeyedT]]], key: str +) -> KeyedT | None: """Find an object by key across all pages of a paginated list endpoint. The environment is shared, so the object under test is not necessarily on @@ -81,7 +93,9 @@ async def find_by_key(list_page: Callable[[int], Awaitable[List[Any]]], key: str page += 1 -async def delete_quietly(delete: Callable[[str], Awaitable[None]], key: str, description: str) -> None: +async def delete_quietly( + delete: Callable[[str], Awaitable[None]], key: str, description: str +) -> None: """Delete one object during teardown, tolerating one that is already gone.""" try: await delete(key) @@ -89,7 +103,7 @@ async def delete_quietly(delete: Callable[[str], Awaitable[None]], key: str, des handle_cleanup_error(error, f"Got API Error during cleanup of {description} '{key}'") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error during cleanup of {description} '{key}': {error}") pytest.fail(f"Got error during cleanup of {description} '{key}': {error}") @@ -101,12 +115,12 @@ async def assert_gone(get: Callable[[str], Awaitable[Any]], key: str, descriptio assert exc_info.value.status_code == 404, f"{description} '{key}' still exists after cleanup" -def sleeping(request: Request): # noqa: ARG001 +def sleeping(request: Request) -> Response: # noqa: ARG001 - werkzeug handler signature time.sleep(TEST_TIMEOUT + 1) return Response("OK", status=200) -async def test_api_timeout(httpserver: HTTPServer): +async def test_api_timeout(httpserver: HTTPServer) -> None: permit = Permit( token="mocked", pdp=f"{MOCKED_URL}:{MOCKED_PORT}", @@ -121,7 +135,7 @@ async def test_api_timeout(httpserver: HTTPServer): assert time_passed < 3 -async def test_pdp_timeout(httpserver: HTTPServer): +async def test_pdp_timeout(httpserver: HTTPServer) -> None: permit = Permit( token="mocked", pdp=f"{MOCKED_URL}:{MOCKED_PORT}", @@ -166,7 +180,7 @@ async def setup_env( viewer_role_permissions = [f"{resource_key}:{RESOURCE_READ_ACTION}"] try: document = await permit.api.resources.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": resource_key, "name": "Document", "urn": f"prn:gdrive:{resource_key}", @@ -201,7 +215,9 @@ async def setup_env( listed_document = await find_by_key( lambda page: permit.api.resources.list(page=page, per_page=PER_PAGE), resource_key ) - assert listed_document is not None, f"resource '{resource_key}' is missing from the resource list" + assert listed_document is not None, ( + f"resource '{resource_key}' is missing from the resource list" + ) assert listed_document.id == document.id assert listed_document.key == document.key assert listed_document.name == document.name @@ -210,7 +226,7 @@ async def setup_env( # create admin role admin = await permit.api.roles.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": admin_role_key, "name": "Admin", "description": "an admin role", @@ -222,12 +238,13 @@ async def setup_env( assert admin.name == "Admin" assert admin.description == "an admin role" assert len(admin.permissions or []) == len(admin_role_permissions) + assert admin.permissions is not None for permission in admin_role_permissions: assert permission in admin.permissions # create viewer role viewer = await permit.api.roles.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": viewer_role_key, "name": "Viewer", "description": "an viewer role", @@ -241,10 +258,13 @@ async def setup_env( assert len(viewer.permissions) == 0 # assign permissions to roles - assigned_viewer = await permit.api.roles.assign_permissions(viewer_role_key, viewer_role_permissions) + assigned_viewer = await permit.api.roles.assign_permissions( + viewer_role_key, viewer_role_permissions + ) assert assigned_viewer.key == viewer_role_key assert len(assigned_viewer.permissions or []) == len(viewer_role_permissions) + assert assigned_viewer.permissions is not None for permission in viewer_role_permissions: assert permission in assigned_viewer.permissions yield document, admin, viewer @@ -262,14 +282,14 @@ async def setup_env( async def test_permission_check_e2e( permit: Permit, setup_env: tuple[ResourceRead, RoleRead, RoleRead], -): +) -> None: document, admin, viewer = setup_env tenant_key = unique_key("tesla") user_key = unique_key("auth0|elon") try: # create a tenant tenant = await permit.api.tenants.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": tenant_key, "name": "Tesla Inc", "description": "The car company", @@ -300,12 +320,13 @@ async def test_permission_check_e2e( assert user.first_name == "Elon" assert user.last_name == "Musk" assert len(user.attributes or {}) == 2 + assert user.attributes is not None assert user.attributes["age"] == 50 assert user.attributes["favoriteColor"] == "red" # assign role to user in tenant ra = await permit.api.users.assign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user_key, "role": viewer.key, "tenant": tenant_key, @@ -315,14 +336,15 @@ async def test_permission_check_e2e( assert ra.user_id == user.id assert ra.role_id == viewer.id assert ra.tenant_id == tenant.id - assert ra.user == user.email or ra.user == user.key + assert ra.user in (user.email, user.key) assert ra.role == viewer.key assert ra.tenant == tenant.key logger.info("waiting for the viewer role assignment to propagate to the PDP") resource_attributes = {"secret": True} - # positive permission check (will be True because elon is a viewer, and a viewer can read a document) + # positive permission check (will be True because elon is a viewer, and a viewer + # can read a document) logger.info("testing positive permission check") await wait_until( lambda: permit.check( @@ -391,7 +413,7 @@ async def test_permission_check_e2e( logger.info("testing list role assignments") # scoped to this test's user and tenant: the environment is shared, so # the unfiltered list contains every other test's assignments too. - assignments_returned: List[RoleAssignment] = await permit.pdp_api.role_assignments.list( + assignments_returned: list[RoleAssignment] = await permit.pdp_api.role_assignments.list( user_key=user.key, tenant_key=tenant.key ) assert len(assignments_returned) == 1 @@ -404,7 +426,7 @@ async def test_permission_check_e2e( # change the user role - assign admin role await permit.api.users.assign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user.key, "role": admin.key, "tenant": tenant.key, @@ -412,7 +434,7 @@ async def test_permission_check_e2e( ) # change the user role - remove viewer role await permit.api.users.unassign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user.key, "role": viewer.key, "tenant": tenant.key, @@ -420,7 +442,9 @@ async def test_permission_check_e2e( ) # list user roles in all tenants - assigned_roles: List[RoleAssignmentRead] = await permit.api.users.get_assigned_roles(user=user.key) + assigned_roles: list[RoleAssignmentRead] = await permit.api.users.get_assigned_roles( + user=user.key + ) assert len(assigned_roles) == 1 assert assigned_roles[0].user_id == user.id @@ -460,7 +484,7 @@ async def test_permission_check_e2e( handle_api_error(error, "Got API Error") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error: {error}") pytest.fail(f"Got error: {error}") finally: @@ -474,17 +498,18 @@ async def test_permission_check_e2e( async def test_local_facts_uploader_permission_check_e2e( permit: Permit, setup_env: tuple[ResourceRead, RoleRead, RoleRead], -): +) -> None: permit._config.proxy_facts_via_pdp = True assert permit.api.users.config.proxy_facts_via_pdp is True document, admin, viewer = setup_env tenant_key = unique_key("tesla") user_key = unique_key("auth0|elon") try: - with permit.wait_for_sync() as permit: + # Rebinding on purpose: the cleanup below runs on the synced client. + with permit.wait_for_sync() as permit: # noqa: PLR1704 # create a tenant tenant = await permit.api.tenants.create( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "key": tenant_key, "name": "Tesla Inc", "description": "The car company", @@ -515,12 +540,13 @@ async def test_local_facts_uploader_permission_check_e2e( assert user.first_name == "Elon" assert user.last_name == "Musk" assert len(user.attributes or {}) == 2 + assert user.attributes is not None assert user.attributes["age"] == 50 assert user.attributes["favoriteColor"] == "red" # assign role to user in tenant ra = await permit.api.users.assign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user_key, "role": viewer.key, "tenant": tenant_key, @@ -530,10 +556,11 @@ async def test_local_facts_uploader_permission_check_e2e( assert ra.user_id == user.id assert ra.role_id == viewer.id assert ra.tenant_id == tenant.id - assert ra.user == user.email or ra.user == user.key + assert ra.user in (user.email, user.key) assert ra.role == viewer.key assert ra.tenant == tenant.key - # positive permission check (will be True because elon is a viewer, and a viewer can read a document) + # positive permission check (will be True because elon is a viewer, and a viewer + # can read a document) logger.info("testing positive permission check") resource_attributes = {"secret": True} # the facts were written through the PDP with wait_for_sync, so they @@ -607,7 +634,7 @@ async def test_local_facts_uploader_permission_check_e2e( # change the user role - assign admin role await permit.api.users.assign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user.key, "role": admin.key, "tenant": tenant.key, @@ -615,7 +642,7 @@ async def test_local_facts_uploader_permission_check_e2e( ) # change the user role - remove viewer role await permit.api.users.unassign_role( - { + { # type: ignore[arg-type] # dict input, coerced by the SDK "user": user.key, "role": viewer.key, "tenant": tenant.key, @@ -623,7 +650,9 @@ async def test_local_facts_uploader_permission_check_e2e( ) # list user roles in all tenants - assigned_roles: List[RoleAssignmentRead] = await permit.api.users.get_assigned_roles(user=user.key) + assigned_roles: list[RoleAssignmentRead] = await permit.api.users.get_assigned_roles( + user=user.key + ) assert len(assigned_roles) == 1 assert assigned_roles[0].user_id == user.id diff --git a/tests/test_rbac_e2e_sync.py b/tests/test_rbac_e2e_sync.py index e04c9c3..7fbc81b 100644 --- a/tests/test_rbac_e2e_sync.py +++ b/tests/test_rbac_e2e_sync.py @@ -1,19 +1,21 @@ import time -from typing import Any, Callable, Final, List, Optional +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar import pytest from loguru import logger -from permit import RoleAssignmentRead from permit.exceptions import PermitApiError, PermitConnectionError -from permit.pdp_api.models import RoleAssignment from permit.sync import Permit as SyncPermit +from tests.utils import handle_api_error, handle_cleanup_error, unique_key -from .utils import handle_api_error, handle_cleanup_error, unique_key +if TYPE_CHECKING: + from permit import RoleAssignmentRead + from permit.pdp_api.models import RoleAssignment -def print_break(): - print("\n\n ----------- \n\n") # noqa: T201 +def print_break() -> None: + print("\n\n ----------- \n\n") # Every object below is created with a key derived from unique_key(): the whole @@ -45,7 +47,15 @@ def wait_until( time.sleep(interval) -def find_by_key(list_page: Callable[[int], List[Any]], key: str) -> Optional[Any]: +class _Keyed(Protocol): + @property + def key(self) -> str: ... + + +KeyedT = TypeVar("KeyedT", bound=_Keyed) + + +def find_by_key(list_page: Callable[[int], list[KeyedT]], key: str) -> KeyedT | None: """Find an object by key across all pages of a paginated list endpoint. The environment is shared, so the object under test is not necessarily on @@ -70,7 +80,7 @@ def delete_quietly(delete: Callable[[str], None], key: str, description: str) -> handle_cleanup_error(error, f"Got API Error during cleanup of {description} '{key}'") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error during cleanup of {description} '{key}': {error}") pytest.fail(f"Got error during cleanup of {description} '{key}': {error}") @@ -82,7 +92,7 @@ def assert_gone(get: Callable[[str], Any], key: str, description: str) -> None: assert exc_info.value.status_code == 404, f"{description} '{key}' still exists after cleanup" -def test_permission_check_e2e(sync_permit: SyncPermit): +def test_permission_check_e2e(sync_permit: SyncPermit) -> None: permit = sync_permit logger.info("initial setup of objects") resource_key = unique_key("document") @@ -132,7 +142,9 @@ def test_permission_check_e2e(sync_permit: SyncPermit): listed_document = find_by_key( lambda page: permit.api.resources.list(page=page, per_page=PER_PAGE), resource_key ) - assert listed_document is not None, f"resource '{resource_key}' is missing from the resource list" + assert listed_document is not None, ( + f"resource '{resource_key}' is missing from the resource list" + ) assert listed_document.id == document.id assert listed_document.key == document.key assert listed_document.name == document.name @@ -229,14 +241,15 @@ def test_permission_check_e2e(sync_permit: SyncPermit): assert ra.user_id == user.id assert ra.role_id == viewer.id assert ra.tenant_id == tenant.id - assert ra.user == user.email or ra.user == user.key + assert ra.user in (user.email, user.key) assert ra.role == viewer.key assert ra.tenant == tenant.key logger.info("waiting for the viewer role assignment to propagate to the PDP") resource_attributes = {"secret": True} - # positive permission check (will be True because elon is a viewer, and a viewer can read a document) + # positive permission check (will be True because elon is a viewer, and a viewer + # can read a document) logger.info("testing positive permission check") wait_until( lambda: permit.check( @@ -289,7 +302,7 @@ def test_permission_check_e2e(sync_permit: SyncPermit): logger.info("testing list role assignments") # scoped to this test's user and tenant: the environment is shared, so # the unfiltered list contains every other test's assignments too. - assignments_returned: List[RoleAssignment] = permit.pdp_api.role_assignments.list( + assignments_returned: list[RoleAssignment] = permit.pdp_api.role_assignments.list( user_key=user.key, tenant_key=tenant.key ) assert len(assignments_returned) == 1 @@ -318,7 +331,9 @@ def test_permission_check_e2e(sync_permit: SyncPermit): ) # list user roles in all tenants - assigned_roles: List[RoleAssignmentRead] = permit.api.users.get_assigned_roles(user=user.key) + assigned_roles: list[RoleAssignmentRead] = permit.api.users.get_assigned_roles( + user=user.key + ) assert len(assigned_roles) == 1 assert assigned_roles[0].user_id == user.id @@ -328,7 +343,9 @@ def test_permission_check_e2e(sync_permit: SyncPermit): # run the same negative permission check again, this time it's True logger.info("testing previously negative permission check, should now be positive") wait_until( - lambda: permit.check(user.dict(), "create", {"type": document.key, "tenant": tenant.key}), + lambda: permit.check( + user.dict(), "create", {"type": document.key, "tenant": tenant.key} + ), f"user '{user_key}' to be allowed to create '{resource_key}' after the role change", ) @@ -338,7 +355,7 @@ def test_permission_check_e2e(sync_permit: SyncPermit): handle_api_error(error, "Got API Error") except PermitConnectionError: raise - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error: {error}") pytest.fail(f"Got error: {error}") finally: diff --git a/tests/test_rebac_e2e.py b/tests/test_rebac_e2e.py index 605466a..3a532d6 100644 --- a/tests/test_rebac_e2e.py +++ b/tests/test_rebac_e2e.py @@ -1,7 +1,8 @@ import asyncio import time +from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, Awaitable, Callable, List, Optional +from typing import Any import pytest from loguru import logger @@ -53,16 +54,16 @@ def object_key(self) -> str: class CheckAssertion: user: str action: str - resource: dict + resource: dict[str, Any] expected_decision: bool - pre_assertion_hook: Optional[Callable[[Permit], Awaitable[Any]]] = None - post_assertion_hook: Optional[Callable[[Permit], Awaitable[Any]]] = None + pre_assertion_hook: Callable[[Permit], Awaitable[Any]] | None = None + post_assertion_hook: Callable[[Permit], Awaitable[Any]] | None = None @dataclass class PermissionAssertions: - assignments: List[RoleAssignmentCreate] - assertions: List[CheckAssertion] + assignments: list[RoleAssignmentCreate] + assertions: list[CheckAssertion] # Graph Schema ---------------------------------------------------------------- @@ -306,7 +307,7 @@ class PermissionAssertions: f"{DOCUMENT.key}:movie2", ] -ASSIGNMENTS_AND_ASSERTIONS: List[PermissionAssertions] = [ +ASSIGNMENTS_AND_ASSERTIONS: list[PermissionAssertions] = [ # direct access PermissionAssertions( assignments=[ @@ -437,19 +438,23 @@ class PermissionAssertions: "tenant": TENANT_PERMIT.key, }, expected_decision=True, - pre_assertion_hook=lambda permit: permit.api.resource_roles.update_role_derivation_conditions( - resource_key=FOLDER.key, - role_key=EDITOR, - conditions=PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings( - no_direct_roles_on_object=False - ), + pre_assertion_hook=lambda permit: ( + permit.api.resource_roles.update_role_derivation_conditions( + resource_key=FOLDER.key, + role_key=EDITOR, + conditions=PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings( + no_direct_roles_on_object=False + ), + ) ), - post_assertion_hook=lambda permit: permit.api.resource_roles.update_role_derivation_conditions( - resource_key=FOLDER.key, - role_key=EDITOR, - conditions=PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings( - no_direct_roles_on_object=True - ), + post_assertion_hook=lambda permit: ( + permit.api.resource_roles.update_role_derivation_conditions( + resource_key=FOLDER.key, + role_key=EDITOR, + conditions=PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings( + no_direct_roles_on_object=True + ), + ) ), ) for action in ["read", "comment", "update", "delete"] @@ -565,7 +570,7 @@ class PermissionAssertions: ] -async def cleanup(permit: Permit): +async def cleanup(permit: Permit) -> None: """Remove everything this module created. Every delete tolerates a 404 (the object is already gone, which is the @@ -586,10 +591,10 @@ async def cleanup(permit: Permit): except PermitApiError as error: handle_cleanup_error(error, f"Could not delete tenant {tenant.key}") for rel_tuple in RELATIONSHIPS: - subject, relation, object, tenant = rel_tuple + subject, relation, obj, tenant = rel_tuple try: await permit.api.relationship_tuples.delete( - RelationshipTupleDelete(subject=subject, relation=relation, object=object) + RelationshipTupleDelete(subject=subject, relation=relation, object=obj) ) except PermitApiError as error: handle_cleanup_error( @@ -620,7 +625,7 @@ async def cleanup(permit: Permit): handle_cleanup_error(error, f"Could not delete resource {resource.key}") except PermitApiError as error: handle_api_error(error, "Got API Error during cleanup") - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error during cleanup: {error}") pytest.fail(f"Got error during cleanup: {error}") logger.debug("Cleanup finished.") @@ -650,13 +655,17 @@ async def wait_for_decision(permit: Permit, q: CheckAssertion) -> bool: return decision -async def assert_permit_check(permit: Permit, q: CheckAssertion): - logger.info(f"asserting: permit.check({q.user}, {q.action}, {q.resource!s}) === {q.expected_decision!s}") +async def assert_permit_check(permit: Permit, q: CheckAssertion) -> None: + logger.info( + f"asserting: permit.check({q.user}, {q.action}, {q.resource!s}) === {q.expected_decision!s}" + ) decision = await wait_for_decision(permit, q) assert q.expected_decision == decision -async def assert_permit_authorized_users(permit: Permit, q: CheckAssertion, assignments: list[RoleAssignmentCreate]): +async def assert_permit_authorized_users( + permit: Permit, q: CheckAssertion, assignments: list[RoleAssignmentCreate] +) -> None: logger.info( f"asserting: permit.authorized_users({q.action}, {q.resource}) === {q.expected_decision}", ) @@ -683,7 +692,7 @@ async def assert_permit_authorized_users(permit: Permit, q: CheckAssertion, assi assert q.user not in authorized_users.users -async def own_relationship_tuples(permit: Permit, tenant_key: str) -> List[Any]: +async def own_relationship_tuples(permit: Permit, tenant_key: str) -> list[Any]: """The relationship tuples this test created inside one of its own tenants. relationship_tuples.list() is environment-wide and paginated, so counting @@ -697,11 +706,12 @@ async def own_relationship_tuples(permit: Permit, tenant_key: str) -> List[Any]: return [ rel_tuple for rel_tuple in tuples - if rel_tuple.subject.split(":")[0] in own_resource_keys and rel_tuple.object.split(":")[0] in own_resource_keys + if rel_tuple.subject.split(":")[0] in own_resource_keys + and rel_tuple.object.split(":")[0] in own_resource_keys ] -async def test_rebac_policy(permit: Permit): +async def test_rebac_policy(permit: Permit) -> None: # No pre-test cleanup: every key this module uses is unique per run, so # there is nothing left over from an earlier run to collide with, and # deleting fixed keys here is what used to break the tests running @@ -727,12 +737,15 @@ async def test_rebac_policy(permit: Permit): for resource_key, resource_roles in iter(RESOURCE_ROLES.items()): for role_data in resource_roles: logger.debug(f"creating resource role: {resource_key}#{role_data.key}") - role = await permit.api.resource_roles.create(resource_key=resource_key, role_data=role_data) + role = await permit.api.resource_roles.create( + resource_key=resource_key, role_data=role_data + ) assert role is not None assert role.key == role_data.key assert role.name == role_data.name assert role.description == role_data.description assert role.permissions is not None + assert role_data.permissions is not None assert len(role.permissions) == len(role_data.permissions) # create resource relations @@ -751,7 +764,8 @@ async def test_rebac_policy(permit: Permit): # create role derivations for derivation_data in ROLE_DERIVATIONS: logger.debug( - f"creating derivation: {derivation_data.source_role} -> {derivation_data.derived_role} " + f"creating derivation: {derivation_data.source_role} -> " + f"{derivation_data.derived_role} " f"(via {derivation_data.via_relation})" ) derivation = await permit.api.resource_roles.create_role_derivation( @@ -788,33 +802,41 @@ async def test_rebac_policy(permit: Permit): assert user.email == user_data.email assert user.first_name == user_data.first_name assert user.last_name == user_data.last_name + assert user.attributes is not None + assert user_data.attributes is not None assert set(user.attributes.keys()) == set(user_data.attributes.keys()) # relationship tuples for tuple_data in RELATIONSHIPS: - subject, relation, object, tenant = tuple_data - logger.debug(f"creating relationship tuple: ({subject}, {relation}, {object}, {tenant})") + subject, relation_key, obj, tenant = tuple_data + logger.debug( + f"creating relationship tuple: ({subject}, {relation_key}, {obj}, {tenant})" + ) rel_tuple = await permit.api.relationship_tuples.create( - RelationshipTupleCreate(subject=subject, relation=relation, object=object, tenant=tenant) + RelationshipTupleCreate( + subject=subject, relation=relation_key, object=obj, tenant=tenant + ) ) assert rel_tuple is not None assert rel_tuple.subject == subject - assert rel_tuple.relation == relation - assert rel_tuple.object == object + assert rel_tuple.relation == relation_key + assert rel_tuple.object == obj assert rel_tuple.tenant == tenant own_tuples = await own_relationship_tuples(permit, TENANT_PERMIT.key) len_tuples = len(own_tuples) - logger.debug(f"this test currently owns {len_tuples} relationship tuples in {TENANT_PERMIT.key}") + logger.debug( + f"this test currently owns {len_tuples} relationship tuples in {TENANT_PERMIT.key}" + ) # bulk create relationship tuples bulk_relationships_to_create = [ - RelationshipTupleCreate(subject=subject, relation=relation, object=object, tenant=tenant) - for (subject, relation, object, tenant) in BULK_RELATIONSHIPS + RelationshipTupleCreate(subject=subject, relation=relation, object=obj, tenant=tenant) + for (subject, relation, obj, tenant) in BULK_RELATIONSHIPS ] bulk_relationships_to_delete = [ - RelationshipTupleDelete(subject=subject, relation=relation, object=object) - for (subject, relation, object, tenant) in BULK_RELATIONSHIPS + RelationshipTupleDelete(subject=subject, relation=relation, object=obj) + for (subject, relation, obj, _tenant) in BULK_RELATIONSHIPS ] for instance_key in BULK_RELATIONSHIPS_INSTANCES: @@ -824,28 +846,38 @@ async def test_rebac_policy(permit: Permit): ResourceInstanceCreate(key=parts[1], resource=parts[0], tenant=TENANT_PERMIT.key) ) - async def create_relationships_in_bulk(): + async def create_relationships_in_bulk() -> None: await permit.api.relationship_tuples.bulk_create(tuples=bulk_relationships_to_create) tuples = await own_relationship_tuples(permit, TENANT_PERMIT.key) assert len(tuples) == len_tuples + len(BULK_RELATIONSHIPS) - created = {(rel_tuple.subject, rel_tuple.relation, rel_tuple.object) for rel_tuple in tuples} - for subject, relation, object, _tenant in BULK_RELATIONSHIPS: - assert (subject, relation, object) in created + created = { + (rel_tuple.subject, rel_tuple.relation, rel_tuple.object) for rel_tuple in tuples + } + for subject, relation, obj, _tenant in BULK_RELATIONSHIPS: + assert (subject, relation, obj) in created - async def remove_relationships_in_bulk(): + async def remove_relationships_in_bulk() -> None: await permit.api.relationship_tuples.bulk_delete(tuples=bulk_relationships_to_delete) tuples = await own_relationship_tuples(permit, TENANT_PERMIT.key) assert len(tuples) == len_tuples - remaining = {(rel_tuple.subject, rel_tuple.relation, rel_tuple.object) for rel_tuple in tuples} - for subject, relation, object, _tenant in BULK_RELATIONSHIPS: - assert (subject, relation, object) not in remaining - - logger.debug(f"creating {len(BULK_RELATIONSHIPS)} relationship tuples in bulk: {BULK_RELATIONSHIPS!s}") + remaining = { + (rel_tuple.subject, rel_tuple.relation, rel_tuple.object) for rel_tuple in tuples + } + for subject, relation, obj, _tenant in BULK_RELATIONSHIPS: + assert (subject, relation, obj) not in remaining + + logger.debug( + f"creating {len(BULK_RELATIONSHIPS)} relationship tuples in bulk: " + f"{BULK_RELATIONSHIPS!s}" + ) await create_relationships_in_bulk() - logger.debug(f"removing the same {len(BULK_RELATIONSHIPS)} relationship tuples in bulk: {BULK_RELATIONSHIPS!s}") + logger.debug( + f"removing the same {len(BULK_RELATIONSHIPS)} relationship tuples in bulk: " + f"{BULK_RELATIONSHIPS!s}" + ) await remove_relationships_in_bulk() # assign roles and then run permission checks @@ -899,7 +931,7 @@ async def remove_relationships_in_bulk(): ) except PermitApiError as error: handle_api_error(error, "Got API Error") - except Exception as error: # noqa: BLE001 + except Exception as error: logger.error(f"Got error: {error}") pytest.fail(f"Got error: {error}") finally: diff --git a/tests/test_sync_client.py b/tests/test_sync_client.py index ae3cef2..087ba64 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -7,13 +7,13 @@ from permit.sync import Permit -@pytest.fixture() +@pytest.fixture def permit(permit_config: PermitConfig) -> Permit: return Permit(permit_config) -def test_sync_client(permit: Permit): - user_key = f"user-{random.randint(0, 1000)}" +def test_sync_client(permit: Permit) -> None: + user_key = f"user-{random.randint(0, 1000)}" # noqa: S311 - a test key, not a secret permit.api.users.create( UserCreate( key=user_key, @@ -25,7 +25,7 @@ def test_sync_client(permit: Permit): permit.api.users.delete(user_key) -def test_sync_client_multithreading(permit_config: PermitConfig): +def test_sync_client_multithreading(permit_config: PermitConfig) -> None: instances = [Permit(permit_config) for _ in range(10)] with ThreadPoolExecutor() as executor: diff --git a/tests/test_user_invites_complete_e2e.py b/tests/test_user_invites_complete_e2e.py index da3dd15..f772b16 100644 --- a/tests/test_user_invites_complete_e2e.py +++ b/tests/test_user_invites_complete_e2e.py @@ -1,5 +1,5 @@ import uuid -from typing import List, Optional, cast +from collections.abc import AsyncIterator import pytest from loguru import logger @@ -23,8 +23,8 @@ from permit.exceptions import PermitApiError -def print_break(): - print("\n\n ----------- \n\n") # noqa: T201 +def print_break() -> None: + print("\n\n ----------- \n\n") class SetupUserInvites(NamedTuple): @@ -32,14 +32,16 @@ class SetupUserInvites(NamedTuple): created_resource_instance: ResourceInstanceRead created_role: RoleRead created_tenant: TenantRead - to_create_invites: List[ElementsUserInviteCreate] + to_create_invites: list[ElementsUserInviteCreate] -@pytest.fixture(scope="function") -async def setup_user_invites(permit: Permit): +@pytest.fixture +async def setup_user_invites(permit: Permit) -> AsyncIterator[SetupUserInvites]: run_id = uuid.uuid4() # Test data - test_tenant = TenantCreate(key=f"test_tenant_invites_{run_id.hex}", name="Test Tenant for Invites") + test_tenant = TenantCreate( + key=f"test_tenant_invites_{run_id.hex}", name="Test Tenant for Invites" + ) # Test user invites data (will be populated with actual IDs in the test) test_invite_data_1 = { @@ -57,11 +59,11 @@ async def setup_user_invites(permit: Permit): "first_name": "Test", "last_name": "User2", } - created_role: Optional[RoleRead] = None - created_tenant: Optional[TenantRead] = None - created_resource: Optional[ResourceRead] = None - created_resource_instance: Optional[ResourceInstanceRead] = None - to_create_invites: List[ElementsUserInviteCreate] = [] + created_role: RoleRead | None = None + created_tenant: TenantRead | None = None + created_resource: ResourceRead | None = None + created_resource_instance: ResourceInstanceRead | None = None + to_create_invites: list[ElementsUserInviteCreate] = [] try: # ========================================== @@ -75,8 +77,12 @@ async def setup_user_invites(permit: Permit): name="Test Resource for Invites", description="Resource for testing user invites", actions={ - "read": ActionBlockEditable(name="Read Access", description="Read access to the resource"), - "write": ActionBlockEditable(name="Write Access", description="Write access to the resource"), + "read": ActionBlockEditable( + name="Read Access", description="Read access to the resource" + ), + "write": ActionBlockEditable( + name="Write Access", description="Write access to the resource" + ), }, ) created_resource = await permit.api.resources.create(test_resource) @@ -98,7 +104,9 @@ async def setup_user_invites(permit: Permit): tenant=created_tenant.key, attributes={"test": "invites"}, ) - created_resource_instance = await permit.api.resource_instances.create(test_resource_instance) + created_resource_instance = await permit.api.resource_instances.create( + test_resource_instance + ) assert created_resource_instance is not None assert created_resource_instance.key == test_resource_instance.key logger.info(f"Created test resource instance: {created_resource_instance.key}") @@ -107,7 +115,10 @@ async def setup_user_invites(permit: Permit): test_role = RoleCreate( key=f"test_role_invites-{run_id.hex}", name="Test Role for Invites", - permissions=[f"{created_resource.key}:read", f"{created_resource.key}:write"], # Use our resource actions + permissions=[ + f"{created_resource.key}:read", + f"{created_resource.key}:write", + ], # Use our resource actions ) created_role = await permit.api.roles.create(test_role) assert created_role is not None @@ -133,10 +144,10 @@ async def setup_user_invites(permit: Permit): print_break() yield SetupUserInvites( - created_resource=cast(ResourceRead, created_resource), - created_resource_instance=cast(ResourceInstanceRead, created_resource_instance), - created_role=cast(RoleRead, created_role), - created_tenant=cast(TenantRead, created_tenant), + created_resource=created_resource, + created_resource_instance=created_resource_instance, + created_role=created_role, + created_tenant=created_tenant, to_create_invites=to_create_invites, ) finally: @@ -146,7 +157,7 @@ async def setup_user_invites(permit: Permit): logger.info("Starting cleanup") try: # Delete test role - if created_role: + if created_role is not None: try: await permit.api.roles.delete(created_role.key) logger.info(f"Cleaned up role: {created_role.key}") @@ -155,7 +166,7 @@ async def setup_user_invites(permit: Permit): logger.warning(f"Failed to delete role {created_role.key}: {e}") # Delete test tenant - if created_tenant: + if created_tenant is not None: try: await permit.api.tenants.delete(created_tenant.key) logger.info(f"Cleaned up tenant: {created_tenant.key}") @@ -164,16 +175,19 @@ async def setup_user_invites(permit: Permit): logger.warning(f"Failed to delete tenant {created_tenant.key}: {e}") # Delete test resource instance - if created_resource_instance: + if created_resource_instance is not None: try: await permit.api.resource_instances.delete(created_resource_instance.key) logger.info(f"Cleaned up resource instance: {created_resource_instance.key}") except PermitApiError as e: if e.status_code != 404: # Ignore if already deleted - logger.warning(f"Failed to delete resource instance {created_resource_instance.key}: {e}") + logger.warning( + f"Failed to delete resource instance " + f"{created_resource_instance.key}: {e}" + ) # Delete test resource - if created_resource: + if created_resource is not None: try: await permit.api.resources.delete(created_resource.key) logger.info(f"Cleaned up resource: {created_resource.key}") @@ -192,9 +206,8 @@ async def setup_user_invites(permit: Permit): async def test_user_invites_complete_e2e( permit: Permit, setup_user_invites: SetupUserInvites, -): - """ - Complete end-to-end test for User Invites API functionality. +) -> None: + """Complete end-to-end test for User Invites API functionality. Tests the complete lifecycle: 1. Setup (create resource, tenant, resource instance, role) @@ -205,7 +218,6 @@ async def test_user_invites_complete_e2e( 6. Delete user invite 7. Cleanup """ - logger.info("Starting User Invites Complete E2E test") created_role = setup_user_invites.created_role @@ -260,9 +272,14 @@ async def test_user_invites_complete_e2e( assert invites_list.total_count >= 2 # At least our 2 invites # Find our created invites in the list - our_invites = [invite for invite in invites_list.data if invite.id in [invite_1.id, invite_2.id]] + our_invites = [ + invite for invite in invites_list.data if invite.id in [invite_1.id, invite_2.id] + ] assert len(our_invites) == 2 - logger.info(f"✅ Listed invites: found {invites_list.total_count} total, including our 2 test invites") + logger.info( + f"✅ Listed invites: found {invites_list.total_count} total, " + f"including our 2 test invites" + ) print_break() @@ -277,7 +294,9 @@ async def test_user_invites_complete_e2e( assert retrieved_invite.email == invite_1.email assert retrieved_invite.key == invite_1.key assert retrieved_invite.status == UserInviteStatus.pending - logger.info(f"✅ Retrieved invite: {retrieved_invite.email} (Status: {retrieved_invite.status})") + logger.info( + f"✅ Retrieved invite: {retrieved_invite.email} (Status: {retrieved_invite.status})" + ) print_break() @@ -289,7 +308,11 @@ async def test_user_invites_complete_e2e( approve_data = ElementsUserInviteApprove( email=invite_1.email, key=invite_1.key, - attributes={"department": "Engineering", "role": "Developer", "test": "complete_e2e_test"}, + attributes={ + "department": "Engineering", + "role": "Developer", + "test": "complete_e2e_test", + }, ) approved_user = await permit.api.user_invites.approve( @@ -318,13 +341,11 @@ async def test_user_invites_complete_e2e( logger.info(f"✅ Deleted invite: {invite_2.email}") # Verify deletion - trying to get the deleted invite should fail - try: + with pytest.raises(PermitApiError) as exc_info: await permit.api.user_invites.get(str(invite_2.id)) - pytest.fail("Expected invite to be deleted, but it still exists") - except PermitApiError as e: - # Expected - invite should not be found - assert e.status_code in [404, 403] # Not found or forbidden - logger.info("✅ Confirmed: Invite successfully deleted (not found)") + # Expected - invite should not be found + assert exc_info.value.status_code in [404, 403] # Not found or forbidden + logger.info("✅ Confirmed: Invite successfully deleted (not found)") # Remove from our tracking list since it's deleted created_invites = [inv for inv in created_invites if inv.id != invite_2.id] @@ -342,8 +363,12 @@ async def test_user_invites_complete_e2e( assert final_invites_list.data[0].id == invite_1.id # Should have 1 invite remaining (invite_1 which was approved) - # Note: approved invites might still be in the list or might be removed depending on API behavior - logger.info(f"✅ Final verification: {len(final_invites_list.data)} of our test invites remain in the list") + # Note: approved invites might still be in the list or might be removed depending on + # API behavior + logger.info( + f"✅ Final verification: {len(final_invites_list.data)} of our test invites " + f"remain in the list" + ) finally: # Delete remaining user invites for invite in created_invites: diff --git a/tests/utils.py b/tests/utils.py index 7e9f327..cf3bea6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -6,9 +6,10 @@ from permit.exceptions import PermitApiError -def handle_api_error(error: PermitApiError, message: str): +def handle_api_error(error: PermitApiError, message: str) -> None: err = ( - f"{message}: status={error.status_code}, url={error.request_url}, method={error.response.method}, " + f"{message}: status={error.status_code}, url={error.request_url}, " + f"method={error.response.method}, " f"details={error.details}, content-type={error.content_type}" ) logger.error(err) @@ -25,7 +26,7 @@ def handle_api_error(error: PermitApiError, message: str): _CLEANUP_TOLERATED_STATUSES = frozenset({404}) -def handle_cleanup_error(error: PermitApiError, message: str): +def handle_cleanup_error(error: PermitApiError, message: str) -> None: """Report a teardown failure without failing an otherwise-passing test. Failing a test for a teardown hiccup hides whatever it was actually @@ -36,7 +37,8 @@ def handle_cleanup_error(error: PermitApiError, message: str): """ if error.status_code in _CLEANUP_TOLERATED_STATUSES: logger.warning( - f"{message}: tolerated during cleanup (status={error.status_code}), continuing. " f"url={error.request_url}" + f"{message}: tolerated during cleanup (status={error.status_code}), " + f"continuing. url={error.request_url}" ) return handle_api_error(error, message) diff --git a/uv.lock b/uv.lock index c8a43d9..bee1a70 100644 --- a/uv.lock +++ b/uv.lock @@ -182,6 +182,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.11.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/1e/4f6082cdd6e5a29093513e9a3eabc5ed1c5331a9a84386b2fece80a00a48/ast_serialize-0.11.2.tar.gz", hash = "sha256:976a5bd75845d22f4b52905ddf53ab669ef1b14dba7735f5512841a2ef2b5450", size = 954387, upload-time = "2026-09-13T18:48:55.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/2e/beec3364eef4b01793a676d8cd16e9014c42044a5505000ceae3955e33fa/ast_serialize-0.11.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f6a8dfc5ab204a706f6e5d39c6f77c18c27ef084fa2081803a64a9160ce89277", size = 897089, upload-time = "2026-09-13T18:47:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d7/ef56443df2891c6ba2c4019c2cb3dcaf97c9948da6d963068e04e8dac6ea/ast_serialize-0.11.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:cb073bfa15742699d408ac50f60878383b5665ae1791d1b6799ea6f08633cd77", size = 1235218, upload-time = "2026-09-13T18:47:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/42/8d/cff58d17ba1d0272ff0b7ab5d3bdfcf8f47317eb0f47c001d394bffebf95/ast_serialize-0.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1d6ad94edbe93bf1dabc06c9f37d55b898fdabc456aa6d7ced5e23c14f795f32", size = 1216399, upload-time = "2026-09-13T18:47:26.202Z" }, + { url = "https://files.pythonhosted.org/packages/de/d2/a1da7675af5f42335c36e4da6d86ef4fd7168cead18de81df0a2d6faeb1a/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40b2801cf2221bd922d9f69d2f0ebc373c3db47207315d525b2d87fa161a2af4", size = 1282064, upload-time = "2026-09-13T18:47:27.787Z" }, + { url = "https://files.pythonhosted.org/packages/97/89/5a400a13b2c9c0152ebb5ad45408a3fe5e4e60e325d3ac4e5cf6e915a0cc/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd666cebd6ab3b3c0fd348a6202c26e18a401ee34293c3804d3472266bc146f6", size = 1285864, upload-time = "2026-09-13T18:47:29.667Z" }, + { url = "https://files.pythonhosted.org/packages/02/b8/80a381c70fd49f0316fb0383c4f9e4c13e81b010b64889bd45898ce8f5f4/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0d01f61352c96370febf6c0dbd488dee9183a731fb2702170da9163ae317cded", size = 1554755, upload-time = "2026-09-13T18:47:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/dcaa34a32d2db789221c125b3eb10feb5089715fe53d9874d627afc26231/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0fd40c668b0fa19b8fdb61d9e63d547e2e19cfbfe053a51ef0b6c37070298a8", size = 1301807, upload-time = "2026-09-13T18:47:32.714Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/84a22420cb312642d7d31547c644d09a3d101418c6d6b9ef2ec30735cf11/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efa819d7c14c8e4153dcd84671826331538be7cbe460383fc6386f5eea5bd234", size = 1301941, upload-time = "2026-09-13T18:47:34.418Z" }, + { url = "https://files.pythonhosted.org/packages/20/8a/aa5f3dcf1aed9678c25982f40d366004e3c0cac47bc0c240f6b837dcbb1f/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a9ffa8a197a721f07a352d0be6185f5b3e6f9aaebfdb66169ed652108531ae3b", size = 1307910, upload-time = "2026-09-13T18:47:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/78/79/91a5102797fe3dc992171382d8579bcb33cbd1424b864ad3117ac43fb3fe/ast_serialize-0.11.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:00119a8fb8c1dc0f1fab023f4d8071fa49e3b0208ee54d589fd463c16ab0124e", size = 1356258, upload-time = "2026-09-13T18:47:37.984Z" }, + { url = "https://files.pythonhosted.org/packages/51/52/54eeef9918e187ced417c4363eecea66975314cd5b9c91759eef7f7b714b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0de02520c11391a026e62987a9aa2c3c2ff01545155059ddf0c4bdf2c5ecbe9f", size = 1459057, upload-time = "2026-09-13T18:47:39.891Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/fd84b52b15d42f2423319cffd1fb7f1e9df5d5198e69ab0b449c450254cf/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e4558956b6a0fb35e18fba58f7d1810b1f2c0e6b52352572cd5dfb6b4ef33a", size = 1562447, upload-time = "2026-09-13T18:47:41.727Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/9fb34f2e64b84a63cca92fb86bd0847b995a63b67477f44c20502fb60352/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6061a54f39e82a9f2cbcb9c268fc441890e4818a6636473caa4f4063254e0750", size = 1556423, upload-time = "2026-09-13T18:47:43.357Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/c43c44449e7ebc4e83ebd48750088fb06234622faa2d62d2a6dc8970d2a3/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:85fbb01e83967a126d71f679f2b9528ef0912cb0854aa1a4657314c34e255b57", size = 1687156, upload-time = "2026-09-13T18:47:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a7/9e520f4a79b639da9ee20c1e747c3d739329e902fc55ac38065f25419f56/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7aaaffc32905159774a107d3cf33dad59bd41b7a0d1bc9885532186753ee7439", size = 1481008, upload-time = "2026-09-13T18:47:46.602Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/bdd3989f19de09cffcd8179c131f6741a5a8619705fc75b09541ff61530b/ast_serialize-0.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:08eda88a0f290a36c38cab33df8bf7e35eb95bc802ca5beb2c8fcda471a7d10c", size = 1501597, upload-time = "2026-09-13T18:47:48.265Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8a/ca2dce2950875a4ef1d7c298196f803b0adcdb7c15ed0cecc71d84bccd70/ast_serialize-0.11.2-cp314-cp314t-win32.whl", hash = "sha256:76cc294246e60a914326b4ca88c6a5ea89c064906614aaf1537ce82f09e9449f", size = 1119503, upload-time = "2026-09-13T18:47:49.896Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/3f38e3613d07c46f9f81c5b1352748c6552397cc52825502e2c6ae44c6ea/ast_serialize-0.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:43b51e6ebe6549bf21416c3c78ee886147b80875a87cc6f69e303dde0d75be0b", size = 1156828, upload-time = "2026-09-13T18:47:51.454Z" }, + { url = "https://files.pythonhosted.org/packages/22/90/f89a4f67428a261daafdb69a0d0132c27933268702d1ba47e0b61c51aff1/ast_serialize-0.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:8df32ad4ff7843734a6c2f067ee974f6d3109ee5a2c3e1a9d2f79347bd282a9a", size = 1128298, upload-time = "2026-09-13T18:47:53.008Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/a1962188abf0e62d84d55892bb044347e434711763b9a1d4ad867a70c1be/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:ab924ba260efd7509492f272d4e236d24564033f20c005d7c63a107c6a76fc85", size = 1235457, upload-time = "2026-09-13T18:47:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ad/439c2959150718446af76fbe2f4000f35eba9869ef8564f3d9a3d0b1c370/ast_serialize-0.11.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:a586be418eb70a9f1396cea29ddac8f4b9bf277fb73ea2340db31e218bc00f32", size = 1215705, upload-time = "2026-09-13T18:47:56.178Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d8/2c6542fc3e7c56a0a25d8d12d034d5a2d2e1900e292567b1c1dca8e83124/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8532f20916fa3189d4d785ef2a62d93c4d651ec9c5bffda66d2fc36898351f34", size = 1282530, upload-time = "2026-09-13T18:47:57.619Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ad/6f6755cd0842db46c3b10b1e4735f14aad78d71dea4753eb46933101711b/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee732ae167e686d1d3c00f98d7d82b23138304694f0441b14d7ddf9c0f8a921c", size = 1287792, upload-time = "2026-09-13T18:47:59.227Z" }, + { url = "https://files.pythonhosted.org/packages/03/40/5da672f5dd23fb7dc0c884c97711e56a3540f2fe3c4355a81f8beb385911/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75a1c7f46b9c19fc0ae01ca6fd076301628faa2ed7a8edbd55c6353c483946a3", size = 1557971, upload-time = "2026-09-13T18:48:00.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c7/2bb25684f697801eb72866fdb94ed5edbff3867ce878b0e542a4a5b9dab9/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdf31a0bb85ea2575cc91669f005e6647d2efed491231c4dc1497bc9a5b3aa6", size = 1303230, upload-time = "2026-09-13T18:48:02.337Z" }, + { url = "https://files.pythonhosted.org/packages/d8/85/754681846f26e0ff1da729b1ffe3171e93c22f0aa6ec3cea5b14e3703846/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b78e6fdef3b06c86ed263e1962fee5a7b9d2d158e738b212d13b2c605ee12f5", size = 1302271, upload-time = "2026-09-13T18:48:03.915Z" }, + { url = "https://files.pythonhosted.org/packages/fb/dc/f5521d8cb44b69095c3982ae3658a12c403e0efa19e51aeb9c8a79dff60c/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:8d62a47714c8bc432b9fabcc29989c815c5da17327d35151f2fd0d85c2a7a5ff", size = 1309529, upload-time = "2026-09-13T18:48:05.562Z" }, + { url = "https://files.pythonhosted.org/packages/73/0d/649182c7fd7c4f782279bed514de2dd67e48a5afecb605a098d64fdc01fd/ast_serialize-0.11.2-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8a5ffa70e76191dcf240d3c43e20c93b3bfd26f54d89148c762d57837f5bcd2c", size = 1356869, upload-time = "2026-09-13T18:48:07.534Z" }, + { url = "https://files.pythonhosted.org/packages/bf/cc/aff4d84c16afa742d13a75384127c7d24594dc8c304f0558a15924fd51af/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:bfbe47a3a7c368f28836e78b2440a3643ac0ec4c67d9fe53588e1448f0a3d35d", size = 1460006, upload-time = "2026-09-13T18:48:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/94/a7/891cbec2e5e0d7159196159d3ff0646622f3120ff4576c839ac2dd56c719/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7f1823275b246f9c7d373be6879e4eec09686948895d4ad083f4b27fd7e4da70", size = 1562935, upload-time = "2026-09-13T18:48:10.978Z" }, + { url = "https://files.pythonhosted.org/packages/45/c4/2c8c4498340ea9aff87a9fd408309aa25d56dd51d7bbddfdb46a3c31424a/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:57c0f5cb0021a5beb1e5e4d6e840ae2f23a28909703ef4d256a144cc1ad3d437", size = 1557109, upload-time = "2026-09-13T18:48:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/0d/8b/c5d4e5226fa18885fe17f949aee3ab1aeb8389c384d946ec1b7c9489cc94/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cd320a5c4f1f2742af97eea22954f776379175c5ef2504801e9a155f2ff9a4d7", size = 1691603, upload-time = "2026-09-13T18:48:14.293Z" }, + { url = "https://files.pythonhosted.org/packages/73/d6/1d2ca472586f9e3416a289a22f36eeb6dd6f47d77b1a4aba358405babbc7/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:13b13afe32e845c86a573497729e1b7ddeb26c572c78bf50ece51da23b8fad5e", size = 1483053, upload-time = "2026-09-13T18:48:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/d3703a7c1e3c76b144ac9349a54d3926d0749918a8dc13a66cede208b8ec/ast_serialize-0.11.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:9d80a81ec84660422579bdb8e789f656a794b48c7a1ae1261f6bd8bc1897d17d", size = 1502499, upload-time = "2026-09-13T18:48:17.405Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e4/d974e55c2e247ef26ed1df01c74940583db9a5b3a8bcaad5732c6e2047fb/ast_serialize-0.11.2-cp315-abi3.abi3t-win32.whl", hash = "sha256:af8c003ce721b0099dd55cef4ba733500fc3054ea0cc8565d8957aaf7cccdeb4", size = 1119739, upload-time = "2026-09-13T18:48:19.005Z" }, + { url = "https://files.pythonhosted.org/packages/0d/00/d229443488e095054d5e0c0cc20689a2633b899d735849ff1b2c8e4f0cbf/ast_serialize-0.11.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:554d117cb916d8032d85007c654d179efbbfd446174c048062778136a922944f", size = 1158602, upload-time = "2026-09-13T18:48:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/389fc1a6cfa0c4b2ce522f47d8401329d8bb11732e516d46465960fef1d9/ast_serialize-0.11.2-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:d60515335750d431e462af6e722bb55720a5e7827192777bddfd9c4376065a4d", size = 1128842, upload-time = "2026-09-13T18:48:22.052Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/c4f36898f19c728d091cdfdf960c9488e8d82bbe2e49ba13c05f43907a5d/ast_serialize-0.11.2-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:89499a439955931281986e97ca4dd3c064bf0d2e0027c0017344eb86667733a1", size = 897204, upload-time = "2026-09-13T18:48:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/54/f67120006fc73a55b6d057d4662d061fbb4eceafce3047c76ca8b382eb11/ast_serialize-0.11.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:daadf1c3e0224621607ffe16f1379e4bd372271ed2e1db8a67878f0bab3ef7e4", size = 1240734, upload-time = "2026-09-13T18:48:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7e/8f2ab68bddbe58a66fbbaad87beeae3e7d7edddb17263d1fc423936cf34d/ast_serialize-0.11.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1844ed9a487fb3de7325c52ddb33f2918b66b65cd54d3f8d83d23785ffe99fa4", size = 1228053, upload-time = "2026-09-13T18:48:26.788Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/8a69ab68f4c1603819f0481d756abdd8caf27cec7f1d77caa71007ebe997/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b17869f4ba261a5fa468a753328a548f4dbaf74b4eadae9e28aff66df7f1425b", size = 1292542, upload-time = "2026-09-13T18:48:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ce/872f2e00f0467c289e483f0a34543463347243a2d0632748d89fcee5e0dc/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feb16d9c2a720e0120c58dd5d6e7b3c7c86b43249b60a3bc212bcb8fa031e2dd", size = 1294791, upload-time = "2026-09-13T18:48:29.969Z" }, + { url = "https://files.pythonhosted.org/packages/3a/82/36277c12af861c64b375c316135d8feffe3f400568463a8d2b2de4c2c4fb/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3109fe4805384effc8d0f8e41fbf875aa8f389af91b4348c1cfb60ea6e4cb82", size = 1567583, upload-time = "2026-09-13T18:48:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d7/ec643df91cea8bcbcb4e8011d6a8b08e5119b84f9554879f3e3c786d29d1/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abdb3e49ba053c3486ac1263bee9f16cc9a4a8abd9f8c90bfc21e3669f3ad9d1", size = 1312878, upload-time = "2026-09-13T18:48:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/4c992cd7841ba589fefb14ddc9aff2f6db7f2a615d4074f9ad04115b5ce0/ast_serialize-0.11.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7004ba572f09be34342ccb98dcd4bad5707d3d81adc8cb4c3f685d2a2c51bbc", size = 1312642, upload-time = "2026-09-13T18:48:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/22aaa209c231a83cfea004fd67dee7a7a54da3f169c6c460b14b96887385/ast_serialize-0.11.2-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:59c25f47524efa052971b860e128b1add0c94ede7dd16b2962952c85c3582365", size = 1319776, upload-time = "2026-09-13T18:48:36.866Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/d4685fb54d10108ce44d3bc893ef670854d61645d47ed96d73524db90c23/ast_serialize-0.11.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3a367e0e05ed2d1b747ceb07aa728a8c204cc008b589127e9bd4f40053d7575", size = 1365324, upload-time = "2026-09-13T18:48:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/42/3a/250643ffad02bda520c50a9a5f02a5d43259a06f34ce393c91761d134d7e/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:00bbf1f6669f813b48925b759f7ae4591067d456d443924055cab386e7e0a719", size = 1467653, upload-time = "2026-09-13T18:48:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/af66a646b9b7f8fdec95ce83fc7b1fe538b06864bc79bd554ac4fae2e6ea/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ec1c20f89c3e0d83576e3c06f79375ce936266591fe0d5fd969914af3185cbaa", size = 1571914, upload-time = "2026-09-13T18:48:41.968Z" }, + { url = "https://files.pythonhosted.org/packages/34/82/77a9714564b9e8800087a8afec41527c65c39e49282baae2ac847b9c1c6a/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c58bb119b73657fdc5569692f316e1e25ca114bd62f7782eb527c6be438ba3a9", size = 1569862, upload-time = "2026-09-13T18:48:43.701Z" }, + { url = "https://files.pythonhosted.org/packages/65/06/fa77b52f46b9bd6dcd8ff2b880e3781f8c1a316bb1342bc3de92907c6f96/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:f739e0b601be7300c5697a2573d9200bd1db74b34ab111ef9537b9d5dcd7f106", size = 1699020, upload-time = "2026-09-13T18:48:45.261Z" }, + { url = "https://files.pythonhosted.org/packages/e1/09/239c83153c7e0798e5867d6909cb06f53dccfef02f6999c8e2e21ecb98c3/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cae5addfbb54cc1d47fe947ef9138e9d83849ed1cbc72b819cf36d96a2315b07", size = 1492869, upload-time = "2026-09-13T18:48:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/2f/eb/6108fb9a43fc7ab5529856e38e33c6e3e064fbfe375fdcbb208c7cd5438d/ast_serialize-0.11.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2fa3be25f7f5351b1b39c9f8a52779b2dbf21199efbae564b4746422e8edca4e", size = 1511621, upload-time = "2026-09-13T18:48:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/8a/82/60367e58ef346a41ebc90d3f28593c1b8f5c2cb5314c7b2bbd98910ee131/ast_serialize-0.11.2-cp39-abi3-win32.whl", hash = "sha256:d70556a2f9230a44c99a655774cde823f056efc34466eabfb4085f0cb1ea9f99", size = 1125873, upload-time = "2026-09-13T18:48:50.661Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/b419c3205ce1143ba7c69baef4f0ba43c14d8712113bf34f9e0d27d609be/ast_serialize-0.11.2-cp39-abi3-win_amd64.whl", hash = "sha256:b9065dd23131a23b41f5bab3bf4e9b3c350a3fe8e36e8200eded9b729fcea484", size = 1165434, upload-time = "2026-09-13T18:48:52.169Z" }, + { url = "https://files.pythonhosted.org/packages/91/a7/c8bbb2173f7a7131b3b2412035b2d814ab5ef2ce9799bd06f07c451640e4/ast_serialize-0.11.2-cp39-abi3-win_arm64.whl", hash = "sha256:dab599cbdcb7b45b18c41fad746645580b3a24357082b7f0e8921cd373804f27", size = 1136031, upload-time = "2026-09-13T18:48:54.04Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -427,6 +491,136 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/12/e2e9ca532cf5a0e08c9489826c4a35c6958c92ba0313fda70e8c6c3912be/librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489", size = 148673, upload-time = "2026-08-07T10:46:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/6d/7c/02005e23478bd5950618d9712e0fd2b4c511657857f3efd8ba6a5feabcdd/librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52", size = 153547, upload-time = "2026-08-07T10:46:23.931Z" }, + { url = "https://files.pythonhosted.org/packages/a0/90/d8848a735f5642077fc4b3b4bebcdb08edf10178e3add45597f5201a368f/librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8", size = 494355, upload-time = "2026-08-07T10:46:25.122Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0b/8604f41ea02feace490e9e405a338a15f9905369f55b239a9ce31c946f24/librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1", size = 485459, upload-time = "2026-08-07T10:46:26.447Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ac/84153bda1ce0da609182527ab92b40d961809e544eefdc5a1c2422971416/librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d", size = 498398, upload-time = "2026-08-07T10:46:27.701Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3a/5ca6cd282b2c244bec8ec84102e09773264e9c02891d56ab3a8f0e4d7083/librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702", size = 515474, upload-time = "2026-08-07T10:46:28.9Z" }, + { url = "https://files.pythonhosted.org/packages/73/d3/bd34110234779eb843c6ed66aba7c9b2091d3dd85989f1fb9922f564cb7a/librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9", size = 509484, upload-time = "2026-08-07T10:46:30.124Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/43c3f7f071d71631a7daa3b835ef2168ea39f20692d81464d4e47fbaa6d6/librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b", size = 532534, upload-time = "2026-08-07T10:46:31.511Z" }, + { url = "https://files.pythonhosted.org/packages/c5/1c/b854adf036ea817c40408873a5b794d65a91d9f0f39826f2ad2a2d5d7f48/librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db", size = 537087, upload-time = "2026-08-07T10:46:32.734Z" }, + { url = "https://files.pythonhosted.org/packages/25/5c/c9a890e244e7dd725d3bd8b560e41f0aec787eaf343b46956a290ab7b841/librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451", size = 536575, upload-time = "2026-08-07T10:46:33.965Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c5/c8e70b60b704299555f55db468eb46b1c81bfc60201ffbfe20407d89870c/librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389", size = 517142, upload-time = "2026-08-07T10:46:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/d1/767a90c41f5d381b3195bc88ac0ec4afda35777c9c781e1f9848fedd965e/librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c", size = 558714, upload-time = "2026-08-07T10:46:36.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/3c0624b8dc8301ab808f2b3a910995bcabe28df070fb9a0e5505ae997dae/librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e", size = 104426, upload-time = "2026-08-07T10:46:38.594Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/e91c0382304bedb2db9c6801897319a9dcb68daac5e975819b562362f20d/librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053", size = 125057, upload-time = "2026-08-07T10:46:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + [[package]] name = "loguru" version = "0.7.3" @@ -708,31 +902,69 @@ wheels = [ [[package]] name = "mypy" -version = "1.11.2" +version = "2.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/86/5d7cbc4974fd564550b80fbb8103c05501ea11aa7835edf3351d90095896/mypy-1.11.2.tar.gz", hash = "sha256:7f9993ad3e0ffdc95c2a14b66dee63729f021968bff8ad911867579c65d13a79", size = 3078806, upload-time = "2024-08-24T22:50:11.357Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/cd/815368cd83c3a31873e5e55b317551500b12f2d1d7549720632f32630333/mypy-1.11.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d42a6dd818ffce7be66cce644f1dff482f1d97c53ca70908dff0b9ddc120b77a", size = 10939401, upload-time = "2024-08-24T22:49:18.929Z" }, - { url = "https://files.pythonhosted.org/packages/f1/27/e18c93a195d2fad75eb96e1f1cbc431842c332e8eba2e2b77eaf7313c6b7/mypy-1.11.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:801780c56d1cdb896eacd5619a83e427ce436d86a3bdf9112527f24a66618fef", size = 10111697, upload-time = "2024-08-24T22:49:32.504Z" }, - { url = "https://files.pythonhosted.org/packages/dc/08/cdc1fc6d0d5a67d354741344cc4aa7d53f7128902ebcbe699ddd4f15a61c/mypy-1.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41ea707d036a5307ac674ea172875f40c9d55c5394f888b168033177fce47383", size = 12500508, upload-time = "2024-08-24T22:49:12.327Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/aad3af008c92c2d5d0720ea3b6674ba94a98cdb86888d389acdb5f218c30/mypy-1.11.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6e658bd2d20565ea86da7d91331b0eed6d2eee22dc031579e6297f3e12c758c8", size = 13020712, upload-time = "2024-08-24T22:49:49.399Z" }, - { url = "https://files.pythonhosted.org/packages/03/e6/a7d97cc124a565be5e9b7d5c2a6ebf082379ffba99646e4863ed5bbcb3c3/mypy-1.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:478db5f5036817fe45adb7332d927daa62417159d49783041338921dcf646fc7", size = 9567319, upload-time = "2024-08-24T22:49:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/e2/aa/cc56fb53ebe14c64f1fe91d32d838d6f4db948b9494e200d2f61b820b85d/mypy-1.11.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:75746e06d5fa1e91bfd5432448d00d34593b52e7e91a187d981d08d1f33d4385", size = 10859630, upload-time = "2024-08-24T22:49:51.895Z" }, - { url = "https://files.pythonhosted.org/packages/04/c8/b19a760fab491c22c51975cf74e3d253b8c8ce2be7afaa2490fbf95a8c59/mypy-1.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a976775ab2256aadc6add633d44f100a2517d2388906ec4f13231fafbb0eccca", size = 10037973, upload-time = "2024-08-24T22:49:21.428Z" }, - { url = "https://files.pythonhosted.org/packages/88/57/7e7e39f2619c8f74a22efb9a4c4eff32b09d3798335625a124436d121d89/mypy-1.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd953f221ac1379050a8a646585a29574488974f79d8082cedef62744f0a0104", size = 12416659, upload-time = "2024-08-24T22:49:35.02Z" }, - { url = "https://files.pythonhosted.org/packages/fc/a6/37f7544666b63a27e46c48f49caeee388bf3ce95f9c570eb5cfba5234405/mypy-1.11.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:57555a7715c0a34421013144a33d280e73c08df70f3a18a552938587ce9274f4", size = 12897010, upload-time = "2024-08-24T22:49:29.725Z" }, - { url = "https://files.pythonhosted.org/packages/84/8b/459a513badc4d34acb31c736a0101c22d2bd0697b969796ad93294165cfb/mypy-1.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:36383a4fcbad95f2657642a07ba22ff797de26277158f1cc7bd234821468b1b6", size = 9562873, upload-time = "2024-08-24T22:49:40.448Z" }, - { url = "https://files.pythonhosted.org/packages/35/3a/ed7b12ecc3f6db2f664ccf85cb2e004d3e90bec928e9d7be6aa2f16b7cdf/mypy-1.11.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e8960dbbbf36906c5c0b7f4fbf2f0c7ffb20f4898e6a879fcf56a41a08b0d318", size = 10990335, upload-time = "2024-08-24T22:49:54.245Z" }, - { url = "https://files.pythonhosted.org/packages/04/e4/1a9051e2ef10296d206519f1df13d2cc896aea39e8683302f89bf5792a59/mypy-1.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06d26c277962f3fb50e13044674aa10553981ae514288cb7d0a738f495550b36", size = 10007119, upload-time = "2024-08-24T22:49:03.451Z" }, - { url = "https://files.pythonhosted.org/packages/f3/3c/350a9da895f8a7e87ade0028b962be0252d152e0c2fbaafa6f0658b4d0d4/mypy-1.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7184632d89d677973a14d00ae4d03214c8bc301ceefcdaf5c474866814c987", size = 12506856, upload-time = "2024-08-24T22:50:08.804Z" }, - { url = "https://files.pythonhosted.org/packages/b6/49/ee5adf6a49ff13f4202d949544d3d08abb0ea1f3e7f2a6d5b4c10ba0360a/mypy-1.11.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3a66169b92452f72117e2da3a576087025449018afc2d8e9bfe5ffab865709ca", size = 12952066, upload-time = "2024-08-24T22:50:03.89Z" }, - { url = "https://files.pythonhosted.org/packages/27/c0/b19d709a42b24004d720db37446a42abadf844d5c46a2c442e2a074d70d9/mypy-1.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:969ea3ef09617aff826885a22ece0ddef69d95852cdad2f60c8bb06bf1f71f70", size = 9664000, upload-time = "2024-08-24T22:49:59.703Z" }, - { url = "https://files.pythonhosted.org/packages/42/3a/bdf730640ac523229dd6578e8a581795720a9321399de494374afc437ec5/mypy-1.11.2-py3-none-any.whl", hash = "sha256:b499bc07dbdcd3de92b0a8b29fdf592c111276f6a12fe29c30f6c417dd546d12", size = 2619625, upload-time = "2024-08-24T22:50:01.842Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/de8f67e12d721cdcc8ba6cfc440b989a4ba4dfabe4402ae94dfdd8bb30a4/mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41", size = 14015541, upload-time = "2026-08-15T03:01:53.104Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8a/9e746ab012c67ed8ea3232a613716c306ee8c0b5682c80d8103b4f04568e/mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0", size = 14248142, upload-time = "2026-08-15T03:02:43.201Z" }, + { url = "https://files.pythonhosted.org/packages/f7/5c/c99ff2d8d0e2c53393e32dfe22d9aa43a5d959d30db46c786dafd24527d3/mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167", size = 15193309, upload-time = "2026-08-15T03:01:28.714Z" }, + { url = "https://files.pythonhosted.org/packages/64/39/124638f745243faae1ff4b37d5426fe41c0f0454535edc82fe8102b56a3c/mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13", size = 15498246, upload-time = "2026-08-15T03:02:46.29Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/31c0781e243836505c0fb5f4e865487d6df1023e4ad959f4ebd4b84a0226/mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53", size = 11155028, upload-time = "2026-08-15T03:01:39.08Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ab/bc2eb0129e72d7d7d93d5e981a78084a9abefda7efa732a7e02f97d6e27d/mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90", size = 10151438, upload-time = "2026-08-15T03:02:19.04Z" }, + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, ] [[package]] @@ -762,6 +994,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "permit" version = "3.0.0" @@ -782,6 +1023,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-httpserver" }, { name = "ruff" }, + { name = "typos" }, { name = "werkzeug" }, ] pydantic-v1 = [ @@ -801,12 +1043,13 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = "==1.11.2" }, + { name = "mypy", specifier = "==2.3.1" }, { name = "pre-commit", specifier = "==4.6.2" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-asyncio", specifier = "==1.4.0" }, { name = "pytest-httpserver", specifier = "==1.1.5" }, - { name = "ruff", specifier = "==0.6.9" }, + { name = "ruff", specifier = "==0.16.7" }, + { name = "typos", specifier = "==1.50.2" }, { name = "werkzeug", specifier = "==3.1.8" }, ] pydantic-v1 = [{ name = "pydantic", specifier = "<2" }] @@ -1308,27 +1551,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.6.9" +version = "0.16.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/0d/6148a48dab5662ca1d5a93b7c0d13c03abd3cc7e2f35db08410e47cef15d/ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2", size = 3095355, upload-time = "2024-10-04T13:40:28.594Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/8f/f7a0a0ef1818662efb32ed6df16078c95da7a0a3248d64c2410c1e27799f/ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd", size = 10440526, upload-time = "2024-10-04T13:39:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/8b/69/b179a5faf936a9e2ab45bb412a668e4661eded964ccfa19d533f29463ef6/ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec", size = 10034612, upload-time = "2024-10-04T13:39:26.301Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/fd1b4be979c579d191eeac37b5cfc0ec906de72c8bcd8595e2c81bb700c1/ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c", size = 9706197, upload-time = "2024-10-04T13:39:29.297Z" }, - { url = "https://files.pythonhosted.org/packages/29/61/b376d775deb5851cb48d893c568b511a6d3625ef2c129ad5698b64fb523c/ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e", size = 10751855, upload-time = "2024-10-04T13:39:33.175Z" }, - { url = "https://files.pythonhosted.org/packages/13/d7/def9e5f446d75b9a9c19b24231a3a658c075d79163b08582e56fa5dcfa38/ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577", size = 10200889, upload-time = "2024-10-04T13:39:36.867Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/7f34160818bcb6e84ce293a5966cba368d9112ff0289b273fbb689046047/ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829", size = 11038678, upload-time = "2024-10-04T13:39:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/13/34/a40ff8ae62fb1b26fb8e6fa7e64bc0e0a834b47317880de22edd6bfb54fb/ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5", size = 11808682, upload-time = "2024-10-04T13:39:52.141Z" }, - { url = "https://files.pythonhosted.org/packages/2e/6d/25a4386ae4009fc798bd10ba48c942d1b0b3e459b5403028f1214b6dd161/ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7", size = 11330446, upload-time = "2024-10-04T13:39:55.783Z" }, - { url = "https://files.pythonhosted.org/packages/f7/f6/bdf891a9200d692c94ebcd06ae5a2fa5894e522f2c66c2a12dd5d8cb2654/ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f", size = 12483048, upload-time = "2024-10-04T13:39:58.845Z" }, - { url = "https://files.pythonhosted.org/packages/a7/86/96f4252f41840e325b3fa6c48297e661abb9f564bd7dcc0572398c8daa42/ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa", size = 10936855, upload-time = "2024-10-04T13:40:01.818Z" }, - { url = "https://files.pythonhosted.org/packages/45/87/801a52d26c8dbf73424238e9908b9ceac430d903c8ef35eab1b44fcfa2bd/ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb", size = 10713007, upload-time = "2024-10-04T13:40:05.384Z" }, - { url = "https://files.pythonhosted.org/packages/be/27/6f7161d90320a389695e32b6ebdbfbedde28ccbf52451e4b723d7ce744ad/ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0", size = 10274594, upload-time = "2024-10-04T13:40:08.801Z" }, - { url = "https://files.pythonhosted.org/packages/00/52/dc311775e7b5f5b19831563cb1572ecce63e62681bccc609867711fae317/ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625", size = 10608024, upload-time = "2024-10-04T13:40:11.923Z" }, - { url = "https://files.pythonhosted.org/packages/98/b6/be0a1ddcbac65a30c985cf7224c4fce786ba2c51e7efeb5178fe410ed3cf/ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039", size = 10982085, upload-time = "2024-10-04T13:40:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a4/c84bc13d0b573cf7bb7d17b16d6d29f84267c92d79b2f478d4ce322e8e72/ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d", size = 8522088, upload-time = "2024-10-04T13:40:19.168Z" }, - { url = "https://files.pythonhosted.org/packages/74/be/fc352bd8ca40daae8740b54c1c3e905a7efe470d420a268cd62150248c91/ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117", size = 9359275, upload-time = "2024-10-04T13:40:22.852Z" }, - { url = "https://files.pythonhosted.org/packages/3e/14/fd026bc74ded05e2351681545a5f626e78ef831f8edce064d61acd2e6ec7/ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93", size = 8679879, upload-time = "2024-10-04T13:40:25.797Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" }, + { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] [[package]] @@ -1406,6 +1649,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "typos" +version = "1.50.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/f1/34819984332007cd897c8b3ff6612fca4162c1d840c6b97ca6e4bca14882/typos-1.50.2.tar.gz", hash = "sha256:3323df228ee42338e8eaefd321da4973978c70684f3285c5136b39d3bde5b0e3", size = 1855161, upload-time = "2026-09-15T13:49:56.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ec/685bf28bcda9b310704f6f301b0e95ad194318a2d4d87e0816de3cf31a8b/typos-1.50.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:85f576c9d7a82b8b1bc56aa8afb67e57e464e4f06240e47588ba17703e40618d", size = 3443185, upload-time = "2026-09-15T13:49:42.671Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6e/1d5c55c1615ca11f32553dfb2a4e987be1738c78718fafa6bd3d276b7aaa/typos-1.50.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dc31974f537beb62de7ba565c051b2a8a4a273420a98ff863be308951d212d2f", size = 3350952, upload-time = "2026-09-15T13:49:44.329Z" }, + { url = "https://files.pythonhosted.org/packages/74/22/da8a5a1aa8b8ef35c9cad91122d2a2b20795e64aa23555ac8ca39b85e71f/typos-1.50.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e7277cccda66d70d1fd49decb5518c7ad7e3b261e05cd96bc3fcd7d1c73a820", size = 8301748, upload-time = "2026-09-15T13:49:46.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f9/2a02e76cbc758d6ae083b878488826015daa307585a6c6695e25f79439e6/typos-1.50.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:380f2c4c114ed1b46481eb14d050dd1a056eb17fec585dd72b4766b8ba60242f", size = 7372364, upload-time = "2026-09-15T13:49:47.621Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/5acc91acd1009eabc885578cc71b1246cd008bbe5fafe16bbb0374844024/typos-1.50.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d962e69c10834aef71a90f5f53ce653e67fcf96b815423a809a16bdad53ba2d", size = 7814826, upload-time = "2026-09-15T13:49:48.936Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b9/9c62492850758f4942889530acd8ef3aa5e88d5c6705a325de9706e3e6f6/typos-1.50.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61610f620dcc376f6aad9d5c5e254f2e2fc96ecd88cda87ed32d67191edcf563", size = 7148899, upload-time = "2026-09-15T13:49:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/12/86/c66efde3e31f5ab7dc59e703045ea75b0446fe9fa1b7a318ea3675a9ebf8/typos-1.50.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:08e44e337db8f8a5c8a9af4d9764f569171c81cfbe1a5e1c51aeb2dbea678dca", size = 8203074, upload-time = "2026-09-15T13:49:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/3f/4e/da254036ae19bba9b94cfee413fddc71d5fe0d1a2bff976f670d8027c97a/typos-1.50.2-py3-none-win32.whl", hash = "sha256:adacc8ea43cf2eb0dfea3c6aa30ef094b2bd617f4655f7b05c3ec9782b958717", size = 3170066, upload-time = "2026-09-15T13:49:53.618Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e9/d47467641f9a59d6f0cd7067c59b28b0c14415a6896ef0c57b1cef8da0ac/typos-1.50.2-py3-none-win_amd64.whl", hash = "sha256:f86d0b87e495689f166c0eb8c3c518597c5efe75314d7b9339a4030683a6f6f9", size = 3347168, upload-time = "2026-09-15T13:49:54.824Z" }, +] + [[package]] name = "virtualenv" version = "21.7.10"