diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d8ea2bd..557bdd5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -35,6 +35,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: @@ -72,6 +77,28 @@ 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, uv-lock) are + # skipped: ruff, mypy and typos come from uv.lock, which the "uv" entry above + # maintains, and uv-lock runs the uv on PATH. 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"] + 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/check_schema_drift.py b/.github/scripts/check_schema_drift.py old mode 100644 new mode 100755 index fe2d046..f01564f --- a/.github/scripts/check_schema_drift.py +++ b/.github/scripts/check_schema_drift.py @@ -118,6 +118,8 @@ class DriftError(Exception): @dataclass(frozen=True) class FieldShape: + """A model field, reduced to what decides what the SDK sends and accepts.""" + type: str required: bool default: str | None @@ -126,6 +128,8 @@ class FieldShape: @dataclass(frozen=True) class ClassShape: + """A model or enum class: its fields, `Config.extra` and enum members.""" + kind: str fields: dict[str, FieldShape] extra: str @@ -134,6 +138,8 @@ class ClassShape: @dataclass(frozen=True) class Difference: + """One way the SDK's models and the spec's differ.""" + kind: str cls: str name: str @@ -142,16 +148,20 @@ class Difference: @property def id(self) -> str: + """The allowlist key: kind, class and, for a field or member, its name.""" target = f"{self.cls}.{self.name}" if self.name else self.cls return f"{self.kind}:{target}" @property def failing(self) -> bool: + """Whether the SDK would send what the API rejects, or reject what it returns.""" return self.kind in FAILING_KINDS @dataclass(frozen=True) class AllowlistEntry: + """A known difference, with the reason it is accepted.""" + id: str sdk: str spec: str @@ -160,20 +170,25 @@ class AllowlistEntry: @dataclass class Result: + """The outcome of comparing the SDK's models with the spec's.""" + new: list[Difference] allowlisted: list[Difference] stale: list[AllowlistEntry] @property def failing(self) -> list[Difference]: + """New differences that fail the check.""" return [d for d in self.new if d.failing] @property def informational(self) -> list[Difference]: + """New differences that are only reported.""" return [d for d in self.new if not d.failing] @property def exit_code(self) -> int: + """1 for failing drift or a stale allowlist entry, else 0.""" return 1 if self.failing or self.stale else 0 @@ -190,7 +205,9 @@ def _is_optional(annotation: ast.expr) -> bool: if text.startswith("Optional["): return True if isinstance(annotation, ast.Subscript) and ast.unparse(annotation.value) == "Union": - members = annotation.slice.elts if isinstance(annotation.slice, ast.Tuple) else [annotation.slice] + members = ( + annotation.slice.elts if isinstance(annotation.slice, ast.Tuple) else [annotation.slice] + ) return any(ast.unparse(member) == "None" for member in members) return False @@ -199,7 +216,7 @@ def _is_ellipsis(node: ast.expr) -> bool: return isinstance(node, ast.Constant) and node.value is Ellipsis -def _field_shape(node: ast.AnnAssign) -> FieldShape: +def _field_shape(node: ast.AnnAssign) -> FieldShape: # noqa: C901 - one case per pydantic 1 rule """Read one annotated class attribute the way pydantic 1 reads a field.""" annotation = node.annotation value = node.value @@ -248,7 +265,7 @@ def _config_extra(node: ast.ClassDef) -> str | None: return None -def parse_models(source: str, label: str) -> dict[str, ClassShape]: +def parse_models(source: str, label: str) -> dict[str, ClassShape]: # noqa: C901 - see resolve """Return the shape of every top-level class in a generated models module. Args: @@ -265,18 +282,21 @@ def parse_models(source: str, label: str) -> dict[str, ClassShape]: try: tree = ast.parse(source) except SyntaxError as exc: - raise DriftError(f"{label} does not parse: {exc}") from exc + msg = f"{label} does not parse: {exc}" + raise DriftError(msg) from exc nodes = {node.name: node for node in tree.body if isinstance(node, ast.ClassDef)} if not nodes: - raise DriftError(f"{label} declares no classes, so there is nothing to compare") + msg = f"{label} declares no classes, so there is nothing to compare" + raise DriftError(msg) resolved: dict[str, ClassShape] = {} - def resolve(name: str, chain: tuple[str, ...]) -> ClassShape: + def resolve(name: str, chain: tuple[str, ...]) -> ClassShape: # noqa: C901 - one class per call if name in resolved: return resolved[name] if name in chain: - raise DriftError(f"{label}: class {name} inherits from itself") + msg = f"{label}: class {name} inherits from itself" + raise DriftError(msg) node = nodes[name] bases = _base_names(node) local_bases = [resolve(base, (*chain, name)) for base in bases if base in nodes] @@ -362,7 +382,9 @@ def _compare_members(cls: str, ours: dict[str, str], theirs: dict[str, str]) -> return out -def _compare_fields(cls: str, ours: dict[str, FieldShape], theirs: dict[str, FieldShape]) -> list[Difference]: +def _compare_fields( + cls: str, ours: dict[str, FieldShape], theirs: dict[str, FieldShape] +) -> list[Difference]: out = [] for field in sorted(set(ours) | set(theirs)): if field not in ours: @@ -370,7 +392,11 @@ def _compare_fields(cls: str, ours: dict[str, FieldShape], theirs: dict[str, Fie out.append(Difference(kind, cls, field, ABSENT, _describe_field(theirs[field]))) continue if field not in theirs: - out.append(Difference("field_removed_from_spec", cls, field, _describe_field(ours[field]), ABSENT)) + out.append( + Difference( + "field_removed_from_spec", cls, field, _describe_field(ours[field]), ABSENT + ) + ) continue mine, spec = ours[field], theirs[field] if mine.type != spec.type: @@ -386,9 +412,15 @@ def _compare_fields(cls: str, ours: dict[str, FieldShape], theirs: dict[str, Fie ) ) elif mine.default != spec.default: - out.append(Difference("field_default_changed", cls, field, str(mine.default), str(spec.default))) + out.append( + Difference( + "field_default_changed", cls, field, str(mine.default), str(spec.default) + ) + ) if mine.alias != spec.alias: - out.append(Difference("field_alias_changed", cls, field, str(mine.alias), str(spec.alias))) + out.append( + Difference("field_alias_changed", cls, field, str(mine.alias), str(spec.alias)) + ) return out @@ -405,30 +437,39 @@ def load_allowlist(path: Path) -> list[AllowlistEntry]: try: doc = json.loads(path.read_text(encoding="utf-8")) except OSError as exc: - raise DriftError(f"could not read the allowlist {path}: {exc}") from exc + msg = f"could not read the allowlist {path}: {exc}" + raise DriftError(msg) from exc except json.JSONDecodeError as exc: - raise DriftError(f"the allowlist {path} is not valid JSON: {exc}") from exc + msg = f"the allowlist {path} is not valid JSON: {exc}" + raise DriftError(msg) from exc raw_entries = doc.get("entries") if isinstance(doc, dict) else None if not isinstance(raw_entries, list): - raise DriftError(f'the allowlist {path} must be an object with an "entries" list') + msg = f'the allowlist {path} must be an object with an "entries" list' + raise DriftError(msg) entries: list[AllowlistEntry] = [] seen: set[str] = set() for index, raw in enumerate(raw_entries): if not isinstance(raw, dict): - raise DriftError(f"allowlist entry {index} is not an object") + msg = f"allowlist entry {index} is not an object" + raise DriftError(msg) values = {key: raw.get(key) for key in ("id", "sdk", "spec", "reason")} for key, value in values.items(): if not isinstance(value, str) or (key in ("id", "reason") and not value.strip()): - raise DriftError(f'allowlist entry {index} needs a non-empty string "{key}"') + msg = f'allowlist entry {index} needs a non-empty string "{key}"' + raise DriftError(msg) entry_id = str(values["id"]) kind = entry_id.split(":", 1)[0] if kind not in FAILING_KINDS | INFORMATIONAL_KINDS: - raise DriftError(f"allowlist entry {entry_id} has an unknown kind {kind!r}") + msg = f"allowlist entry {entry_id} has an unknown kind {kind!r}" + raise DriftError(msg) if entry_id in seen: - raise DriftError(f"allowlist entry {entry_id} appears more than once") + msg = f"allowlist entry {entry_id} appears more than once" + raise DriftError(msg) seen.add(entry_id) - entries.append(AllowlistEntry(entry_id, str(values["sdk"]), str(values["spec"]), str(values["reason"]))) + entries.append( + AllowlistEntry(entry_id, str(values["sdk"]), str(values["spec"]), str(values["reason"])) + ) return entries @@ -441,7 +482,8 @@ def apply_allowlist(differences: list[Difference], entries: list[AllowlistEntry] for difference in differences: entry = by_id.get(difference.id) if entry is not None and ( - not difference.failing or (entry.sdk == difference.sdk and entry.spec == difference.spec) + not difference.failing + or (entry.sdk == difference.sdk and entry.spec == difference.spec) ): matched.add(entry.id) allowlisted.append(difference) @@ -473,7 +515,8 @@ def render(result: Result, compared_with: str) -> str: out = ["## API schema drift", ""] if result.exit_code == 0: out.append( - ":white_check_mark: **permit/api/models.py matches the API schema** apart from allowlisted differences." + ":white_check_mark: **permit/api/models.py matches the API schema** " + "apart from allowlisted differences." ) else: out.append(":x: **permit/api/models.py has drifted from the API schema.**") @@ -483,11 +526,19 @@ def render(result: Result, compared_with: str) -> str: "", "| New failing | New informational | Stale allowlist entries | Allowlisted |", "|---|---|---|---|", - f"| {len(failing)} | {len(informational)} | {len(result.stale)} | {len(result.allowlisted)} |", + ( + f"| {len(failing)} | {len(informational)} " + f"| {len(result.stale)} | {len(result.allowlisted)} |" + ), "", ] if failing: - out += ["### New failing differences", "", "| Difference | SDK | API schema |", "|---|---|---|"] + out += [ + "### New failing differences", + "", + "| Difference | SDK | API schema |", + "|---|---|---|", + ] out += [f"| `{_cell(d.id)}` | `{_cell(d.sdk)}` | `{_cell(d.spec)}` |" for d in failing] out.append("") if informational: @@ -495,14 +546,19 @@ def render(result: Result, compared_with: str) -> str: out += [f"- `{_cell(d.id)}`: `{_cell(d.spec)}`" for d in informational] out.append("") if result.stale: - out += ["### Stale allowlist entries", "", "These match no current difference. Remove them.", ""] + out += [ + "### Stale allowlist entries", + "", + "These match no current difference. Remove them.", + "", + ] out += [f"- `{_cell(entry.id)}`" for entry in result.stale] out.append("") if result.new or result.stale: out.append( - "To resolve: regenerate the models (`bash scripts/generate_models.sh`, see the comment at the top of " - "that script), or add each intended difference to `.github/scripts/schema_drift_allowlist.json` " - "with a one-line reason." + "To resolve: regenerate the models (`bash scripts/generate_models.sh`, see the " + "comment at the top of that script), or add each intended difference to " + "`.github/scripts/schema_drift_allowlist.json` with a one-line reason." ) out.append("") return "\n".join(out) @@ -515,17 +571,21 @@ def _download(url: str, target: Path) -> None: """Download url to target, retrying a failed attempt after a growing pause.""" for attempt in range(1, FETCH_ATTEMPTS + 1): try: - with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT_S) as response: + # fetch_spec passes only http(s) URLs here. + with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT_S) as response: # noqa: S310 target.write_bytes(response.read()) - return - except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) as exc: + # A retry loop: one attempt per iteration, so the try belongs inside it. + except (urllib.error.URLError, http.client.HTTPException, TimeoutError, OSError) as exc: # noqa: PERF203 if attempt == FETCH_ATTEMPTS: - raise DriftError( + msg = ( f"could not fetch the API schema from {url} in {FETCH_ATTEMPTS} attempts: {exc}" - ) from exc + ) + raise DriftError(msg) from exc pause = FETCH_BACKOFF_S * attempt print(f"fetching the API schema failed ({exc}); retrying in {pause}s", file=sys.stderr) time.sleep(pause) + else: + return def fetch_spec(source: str, workdir: Path) -> Path: @@ -538,13 +598,16 @@ def fetch_spec(source: str, workdir: Path) -> Path: try: json.loads(target.read_text(encoding="utf-8")) except OSError as exc: - raise DriftError(f"could not read the API schema at {target}: {exc}") from exc + msg = f"could not read the API schema at {target}: {exc}" + raise DriftError(msg) from exc except json.JSONDecodeError as exc: - raise DriftError(f"the API schema from {source} is not valid JSON: {exc}") from exc + msg = f"the API schema from {source} is not valid JSON: {exc}" + raise DriftError(msg) from exc return target def generator_command(spec: Path, output: Path) -> list[str]: + """The command that runs the pinned generator the way scripts/generate_models.sh does.""" return [ "uvx", "--python", @@ -566,7 +629,7 @@ def generate(spec: Path, workdir: Path) -> Path: """Run the pinned generator on the schema and return the generated module's path.""" output = workdir / "generated_models.py" try: - completed = subprocess.run( + completed = subprocess.run( # noqa: S603 - the pinned generator, arguments built here generator_command(spec, output), capture_output=True, text=True, @@ -574,12 +637,15 @@ def generate(spec: Path, workdir: Path) -> Path: check=False, ) except FileNotFoundError as exc: - raise DriftError("uvx is not on PATH; it runs the pinned model generator") from exc + msg = "uvx is not on PATH; it runs the pinned model generator" + raise DriftError(msg) from exc except subprocess.TimeoutExpired as exc: - raise DriftError(f"the model generator did not finish within {GENERATE_TIMEOUT_S}s") from exc + msg = f"the model generator did not finish within {GENERATE_TIMEOUT_S}s" + raise DriftError(msg) from exc if completed.returncode != 0 or not output.is_file(): tail = "\n".join((completed.stderr or completed.stdout).strip().splitlines()[-20:]) - raise DriftError(f"the model generator failed (exit {completed.returncode}):\n{tail}") + msg = f"the model generator failed (exit {completed.returncode}):\n{tail}" + raise DriftError(msg) return output @@ -587,7 +653,8 @@ def _read(path: Path, label: str) -> str: try: return path.read_text(encoding="utf-8") except OSError as exc: - raise DriftError(f"could not read {label} at {path}: {exc}") from exc + msg = f"could not read {label} at {path}: {exc}" + raise DriftError(msg) from exc def run(args: argparse.Namespace) -> Result: @@ -596,19 +663,34 @@ def run(args: argparse.Namespace) -> Result: sdk = parse_models(_read(Path(args.models), "the SDK models"), str(args.models)) with tempfile.TemporaryDirectory() as tmp: workdir = Path(tmp) - generated = Path(args.generated) if args.generated else generate(fetch_spec(args.spec, workdir), workdir) - spec = parse_models(_read(generated, "the generated models"), "the models generated from the API schema") + generated = ( + Path(args.generated) + if args.generated + else generate(fetch_spec(args.spec, workdir), workdir) + ) + spec = parse_models( + _read(generated, "the generated models"), "the models generated from the API schema" + ) return apply_allowlist(compare(sdk, spec), entries) def main(argv: list[str] | None = None) -> int: + """Run the check and write its reports; return the exit status (0, 1 or 2).""" parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) - parser.add_argument("--models", required=True, help="the SDK's models module (permit/api/models.py)") + parser.add_argument( + "--models", required=True, help="the SDK's models module (permit/api/models.py)" + ) parser.add_argument("--allowlist", required=True, help="JSON allowlist of known differences") - parser.add_argument("--spec", default=DEFAULT_SPEC, help="API schema URL or path (default: %(default)s)") - parser.add_argument("--generated", help="compare this generated module instead of running the generator") + parser.add_argument( + "--spec", default=DEFAULT_SPEC, help="API schema URL or path (default: %(default)s)" + ) + parser.add_argument( + "--generated", help="compare this generated module instead of running the generator" + ) parser.add_argument("--summary", help="write the markdown report here instead of stdout") - parser.add_argument("--github-output", help="append failing=, informational= and stale= counts here") + parser.add_argument( + "--github-output", help="append failing=, informational= and stale= counts here" + ) args = parser.parse_args(argv) if args.generated: @@ -636,7 +718,10 @@ def main(argv: list[str] | None = None) -> int: f"stale={len(result.stale)}\n" ) for difference in result.failing: - print(f"new drift: {difference.id}: SDK {difference.sdk!r}, API schema {difference.spec!r}", file=sys.stderr) + print( + f"new drift: {difference.id}: SDK {difference.sdk!r}, API schema {difference.spec!r}", + file=sys.stderr, + ) for entry in result.stale: print(f"stale allowlist entry: {entry.id}", file=sys.stderr) return result.exit_code @@ -645,7 +730,8 @@ def main(argv: list[str] | None = None) -> int: def _did_not_run_report(reason: str) -> str: first_line = (reason.splitlines() or [""])[0] return ( - "## API schema drift\n\n:warning: **The check did not run**, so this is not a clean result.\n\n" + "## API schema drift\n\n" + ":warning: **The check did not run**, so this is not a clean result.\n\n" f"`{_cell(first_line)}`\n" ) diff --git a/.github/scripts/format_audit.py b/.github/scripts/format_audit.py old mode 100644 new mode 100755 index 53f1721..9c44f69 --- a/.github/scripts/format_audit.py +++ b/.github/scripts/format_audit.py @@ -29,7 +29,7 @@ import json import sys from pathlib import Path -from typing import Any, Optional +from typing import Any MARKER = "" @@ -52,11 +52,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, @@ -66,7 +72,7 @@ def __init__( title: str, url: str, source: str, - ): + ) -> None: self.id = vuln_id self.package = package self.installed = installed @@ -78,6 +84,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 @@ -102,7 +109,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 @@ -131,7 +138,7 @@ def _split_spec(spec: str, scanner: str) -> tuple[str, str]: return f"{scanner}:{label}", path -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 @@ -148,7 +155,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 @@ -174,12 +182,12 @@ def parse_trivy(doc: Any, source: str = "trivy") -> list[Finding]: return findings -def _pip_audit_dependencies(doc: Any) -> list[Any]: +def _pip_audit_dependencies(doc: object) -> list[Any]: deps = doc.get("dependencies") if isinstance(doc, dict) else doc return deps if isinstance(deps, list) else [] -def parse_pip_audit(doc: Any, source: str = "pip-audit") -> list[Finding]: +def parse_pip_audit(doc: object, source: str = "pip-audit") -> 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 @@ -197,7 +205,9 @@ def parse_pip_audit(doc: Any, source: str = "pip-audit") -> 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 + ) # Sorted because pip-audit keeps aliases in a set and lists them in # a different order on each run. The id is half of the merge key, # so an unsorted one would list the same advisory once per tree. @@ -231,7 +241,9 @@ def load_pip_audit(spec: str) -> tuple[list[Finding], list[tuple[str, str]]]: """ label, path = _split_spec(spec, "pip-audit") if not Path(path).is_file(): - return [], [(label, f"{label}: no report at {path}; pip-audit did not run or did not finish")] + return [], [ + (label, f"{label}: no report at {path}; pip-audit did not run or did not finish") + ] doc, err = _load(path, label) if err: return [], [(label, err)] @@ -279,17 +291,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) @@ -305,7 +320,7 @@ def render_slack( run_url: str, repo: str, *, - pip_audit_gaps: Optional[list[tuple[str, str]]] = None, + pip_audit_gaps: list[tuple[str, str]] | None = None, ) -> str: """One line of Slack `text`, carrying the findings rather than a verdict. @@ -331,8 +346,10 @@ def _slack_body(findings: list[Finding], errors: list[str], repo: str) -> list[s if errors: return [ f":warning: *{_slack_escape(repo)} — weekly dependency audit could not complete*", - ">A scanner report could not be parsed, so the tree was not fully scanned. " - "A clean history is not evidence of a clean tree.", + ( + ">A scanner report could not be parsed, so the tree was not fully scanned. " + "A clean history is not evidence of a clean tree." + ), ] blockers = [f for f in findings if f.blocking] @@ -340,7 +357,10 @@ def _slack_body(findings: list[Finding], errors: list[str], repo: str) -> list[s if not findings: return [ f":white_check_mark: *{_slack_escape(repo)} — weekly dependency audit clean*", - ">No known advisories in either the resolved tree or the lowest versions the published specs permit.", + ( + ">No known advisories in either the resolved tree or the lowest versions " + "the published specs permit." + ), ] # Collapse to one line per package: a package with 30 advisories should not @@ -364,7 +384,9 @@ def _slack_body(findings: list[Finding], errors: list[str], repo: str) -> list[s # 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} — " @@ -373,19 +395,27 @@ def _slack_body(findings: list[Finding], errors: list[str], repo: str) -> list[s ) # 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."] + limit = _SLACK_HEADER_LINES + _SLACK_MAX_PACKAGES + if len(lines) > limit: + lines = [*lines[:limit], f">…and {len(by_package) - _SLACK_MAX_PACKAGES} more packages."] return 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, - pip_audit_gaps: Optional[list[tuple[str, str]]] = None, + pip_audit_gaps: list[tuple[str, str]] | None = None, ) -> str: + """Render the markdown PR comment body.""" out: list[str] = [MARKER, "", "## Dependency Security Audit", ""] if context: @@ -437,7 +467,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 @@ -448,33 +481,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("") @@ -514,7 +553,8 @@ def render( return "\n".join(out) + "\n" -def main() -> int: +def main() -> int: # noqa: C901 - 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", @@ -531,7 +571,10 @@ def main() -> int: dest="pip_audit_json", action="append", default=[], - help="pip-audit JSON report, as LABEL=PATH like the Trivy reports. Repeat it once per dependency tree.", + help=( + "pip-audit JSON report, as LABEL=PATH like the Trivy reports. " + "Repeat it once per dependency tree." + ), ) parser.add_argument("--context", default="", help="human label for what was scanned") parser.add_argument( @@ -549,8 +592,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", @@ -595,14 +642,17 @@ def main() -> int: print(message, file=sys.stderr) if args.slack: - print(render_slack(findings, errors, args.run_url, args.repo, pip_audit_gaps=pip_audit_gaps)) + print( + render_slack(findings, errors, args.run_url, args.repo, pip_audit_gaps=pip_audit_gaps) + ) return 0 if args.gate: 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: @@ -616,7 +666,11 @@ def main() -> int: print(rendered) return 0 - sys.stdout.write(render(findings, errors, args.context, blocking=args.blocking, pip_audit_gaps=pip_audit_gaps)) + sys.stdout.write( + render( + findings, errors, args.context, blocking=args.blocking, pip_audit_gaps=pip_audit_gaps + ) + ) return 0 diff --git a/.github/scripts/pytest.ini b/.github/scripts/pytest.ini index a64b24b..6f9dbda 100644 --- a/.github/scripts/pytest.ini +++ b/.github/scripts/pytest.ini @@ -4,4 +4,6 @@ # asyncio_mode belong to the SDK's suite. These tests need only pytest and the # standard library, and warn about nothing: any warning is an error. [pytest] +# strict_config, strict_markers, strict_xfail and strict_parametrization_ids. +strict = true filterwarnings = error diff --git a/.github/scripts/test_check_schema_drift.py b/.github/scripts/test_check_schema_drift.py index e15f072..c4825d7 100644 --- a/.github/scripts/test_check_schema_drift.py +++ b/.github/scripts/test_check_schema_drift.py @@ -20,7 +20,9 @@ import subprocess import sys import textwrap +import time import urllib.error +import urllib.request from pathlib import Path import pytest @@ -30,8 +32,7 @@ sys.path.insert(0, str(Path(__file__).parent)) -import check_schema_drift # noqa: E402 -from check_schema_drift import ( # noqa: E402 +from check_schema_drift import ( # noqa: E402 - importable only once sys.path has its directory GENERATOR_EXCLUDE_NEWER, GENERATOR_FLAGS, GENERATOR_PACKAGE, @@ -79,10 +80,18 @@ def differences(sdk: str, spec: str) -> dict[str, tuple[str, str]]: def cli(*args: str | Path) -> subprocess.CompletedProcess[str]: - return subprocess.run([sys.executable, str(SCRIPT), *map(str, args)], capture_output=True, text=True, check=False) + return subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT), *map(str, args)], capture_output=True, text=True, check=False + ) -def run(tmp_path: Path, sdk: str, spec: str, entries: list | None = None, *extra: str): +def run( + tmp_path: Path, + sdk: str, + spec: str, + entries: list[dict[str, str]] | None = None, + *extra: str, +) -> subprocess.CompletedProcess[str]: sdk_path = tmp_path / "models.py" spec_path = tmp_path / "generated.py" allowlist = tmp_path / "allowlist.json" @@ -95,36 +104,41 @@ def run(tmp_path: Path, sdk: str, spec: str, entries: list | None = None, *extra # --- CLI contract ------------------------------------------------------------- -def test_identical_modules_pass(tmp_path: Path): +def test_identical_modules_pass(tmp_path: Path) -> None: result = run(tmp_path, module(), module()) assert result.returncode == 0, result.stderr assert "matches the API schema" in result.stdout -def test_failing_drift_exits_1_and_names_it(tmp_path: Path): - spec = module().replace("key: str = Field(..., title='Key')", "key: int = Field(..., title='Key')") +def test_failing_drift_exits_1_and_names_it(tmp_path: Path) -> None: + spec = module().replace( + "key: str = Field(..., title='Key')", "key: int = Field(..., title='Key')" + ) result = run(tmp_path, module(), spec) assert result.returncode == 1 assert "field_type_changed:UserRead.key" in result.stdout assert "field_type_changed:UserRead.key" in result.stderr -def test_informational_drift_does_not_fail(tmp_path: Path): +def test_informational_drift_does_not_fail(tmp_path: Path) -> None: spec = module() + "\n\nclass NewThing(BaseModel):\n name: str\n" result = run(tmp_path, module(), spec) assert result.returncode == 0 assert "class_added:NewThing" in result.stdout -def test_github_output_carries_the_counts(tmp_path: Path): +def test_github_output_carries_the_counts(tmp_path: Path) -> None: output = tmp_path / "github_output" - spec = module().replace(" blue = 'blue'\n", "") + "\n\nclass NewThing(BaseModel):\n name: str\n" + spec = ( + module().replace(" blue = 'blue'\n", "") + + "\n\nclass NewThing(BaseModel):\n name: str\n" + ) result = run(tmp_path, module(), spec, None, "--github-output", str(output)) assert result.returncode == 1 assert output.read_text() == "failing=1\ninformational=1\nstale=0\n" -def test_summary_is_written_to_the_given_file_not_stdout(tmp_path: Path): +def test_summary_is_written_to_the_given_file_not_stdout(tmp_path: Path) -> None: summary = tmp_path / "summary.md" result = run(tmp_path, module(), module(), None, "--summary", str(summary)) assert result.returncode == 0 @@ -132,23 +146,25 @@ def test_summary_is_written_to_the_given_file_not_stdout(tmp_path: Path): assert summary.read_text().startswith("## API schema drift") -def test_unparsable_models_exit_2_and_never_read_as_clean(tmp_path: Path): +def test_unparsable_models_exit_2_and_never_read_as_clean(tmp_path: Path) -> None: result = run(tmp_path, "class Broken(:\n", module()) assert result.returncode == 2 assert "did not run" in result.stdout assert "matches the API schema" not in result.stdout -def test_missing_generated_file_exits_2(tmp_path: Path): +def test_missing_generated_file_exits_2(tmp_path: Path) -> None: allowlist = tmp_path / "allowlist.json" allowlist.write_text('{"entries": []}') models = tmp_path / "models.py" models.write_text(module()) - result = cli("--models", models, "--generated", tmp_path / "absent.py", "--allowlist", allowlist) + result = cli( + "--models", models, "--generated", tmp_path / "absent.py", "--allowlist", allowlist + ) assert result.returncode == 2 -def test_spec_that_is_not_json_exits_2_before_generating(tmp_path: Path): +def test_spec_that_is_not_json_exits_2_before_generating(tmp_path: Path) -> None: spec = tmp_path / "openapi.json" spec.write_text("not a schema") allowlist = tmp_path / "allowlist.json" @@ -160,7 +176,7 @@ def test_spec_that_is_not_json_exits_2_before_generating(tmp_path: Path): assert "not valid JSON" in result.stderr -def test_an_unexpected_error_exits_2_not_1(tmp_path: Path): +def test_an_unexpected_error_exits_2_not_1(tmp_path: Path) -> None: # A models file that is not UTF-8 raises UnicodeDecodeError, not DriftError. Exit 1 # would read as drift with nothing listed. summary = tmp_path / "summary.md" @@ -187,9 +203,9 @@ def test_an_unexpected_error_exits_2_not_1(tmp_path: Path): class FlakyUrlopen: - """Stands in for urllib.request.urlopen: raises each of `failures` in turn, then serves `body`.""" + """Stands in for urlopen: raises each of `failures` in turn, then serves `body`.""" - def __init__(self, failures: list[Exception], body: bytes = b"{}"): + def __init__(self, failures: list[Exception], body: bytes = b"{}") -> None: self.failures = failures self.body = body self.requests: list[tuple[str, float]] = [] @@ -204,15 +220,31 @@ def __call__(self, url: str, timeout: float) -> io.BytesIO: @pytest.fixture def sleeps(monkeypatch: pytest.MonkeyPatch) -> list[float]: pauses: list[float] = [] - monkeypatch.setattr(check_schema_drift.time, "sleep", pauses.append) + monkeypatch.setattr(time, "sleep", pauses.append) return pauses -def test_a_failed_schema_download_is_retried(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sleeps: list[float]): +def test_a_schema_download_that_succeeds_is_not_repeated( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: + urlopen = FlakyUrlopen([], b'{"openapi": "3"}') + monkeypatch.setattr(urllib.request, "urlopen", urlopen) + + spec = fetch_spec(SPEC_URL, tmp_path) + + assert spec.read_text() == '{"openapi": "3"}' + assert urlopen.requests == [(SPEC_URL, 60)] + assert sleeps == [] + + +def test_a_failed_schema_download_is_retried( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sleeps: list[float] +) -> None: urlopen = FlakyUrlopen( - [urllib.error.URLError("connection reset"), http.client.IncompleteRead(b"{")], b'{"openapi": "3"}' + [urllib.error.URLError("connection reset"), http.client.IncompleteRead(b"{")], + b'{"openapi": "3"}', ) - monkeypatch.setattr(check_schema_drift.urllib.request, "urlopen", urlopen) + monkeypatch.setattr(urllib.request, "urlopen", urlopen) spec = fetch_spec(SPEC_URL, tmp_path) @@ -223,9 +255,9 @@ def test_a_failed_schema_download_is_retried(tmp_path: Path, monkeypatch: pytest def test_a_schema_download_that_keeps_failing_is_a_drift_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, sleeps: list[float] -): +) -> None: urlopen = FlakyUrlopen([urllib.error.URLError("down") for _ in range(3)]) - monkeypatch.setattr(check_schema_drift.urllib.request, "urlopen", urlopen) + monkeypatch.setattr(urllib.request, "urlopen", urlopen) with pytest.raises(DriftError, match="in 3 attempts: "): fetch_spec(SPEC_URL, tmp_path) @@ -237,7 +269,7 @@ def test_a_schema_download_that_keeps_failing_is_a_drift_error( # --- what counts as a difference ---------------------------------------------- -def test_formatting_titles_and_field_order_are_not_differences(): +def test_formatting_titles_and_field_order_are_not_differences() -> None: spec = module( """\ class Color(str, Enum): @@ -270,7 +302,11 @@ class Config: "x: Optional[str] = Field(...)", {"field_required_changed:M.x": ("optional", "required")}, ), - ("x: str = Field(default='a')", "x: str = Field(default='b')", {"field_default_changed:M.x": ("'a'", "'b'")}), + ( + "x: str = Field(default='a')", + "x: str = Field(default='b')", + {"field_default_changed:M.x": ("'a'", "'b'")}, + ), ( "x: str = Field(default=None, alias='a')", "x: str = Field(default=None, alias='b')", @@ -283,7 +319,9 @@ class Config: ), ], ) -def test_changed_field_is_detected(sdk_field: str, spec_field: str, expected: dict): +def test_changed_field_is_detected( + sdk_field: str, spec_field: str, expected: dict[str, tuple[str, str]] +) -> None: sdk = module(f"class M(BaseModel):\n {sdk_field}\n") spec = module(f"class M(BaseModel):\n {spec_field}\n") assert differences(sdk, spec) == expected @@ -303,14 +341,16 @@ def test_changed_field_is_detected(sdk_field: str, spec_field: str, expected: di ("x: Dict[str, Any] = Field(default_factory=dict)", "optional"), ], ) -def test_required_follows_pydantic_1(declaration: str, expected: str): +def test_required_follows_pydantic_1(declaration: str, expected: str) -> None: shapes = parse_models(module(f"class M(BaseModel):\n {declaration}\n"), "m") assert ("required" if shapes["M"].fields["x"].required else "optional") == expected -def test_field_in_one_module_only(): +def test_field_in_one_module_only() -> None: sdk = module("class M(BaseModel):\n a: str\n gone: str\n") - spec = module("class M(BaseModel):\n a: str\n needed: str\n maybe: Optional[str] = None\n") + spec = module( + "class M(BaseModel):\n a: str\n needed: str\n maybe: Optional[str] = None\n" + ) assert differences(sdk, spec) == { "field_removed_from_spec:M.gone": ("required str", "(absent)"), "field_added_required:M.needed": ("(absent)", "required str"), @@ -318,7 +358,7 @@ def test_field_in_one_module_only(): } -def test_enum_members_are_compared(): +def test_enum_members_are_compared() -> None: sdk = module("class E(str, Enum):\n a = 'a'\n b = 'b'\n c = 'c'\n") spec = module("class E(str, Enum):\n a = 'a'\n b = 'B'\n d = 'd'\n") assert differences(sdk, spec) == { @@ -328,7 +368,7 @@ def test_enum_members_are_compared(): } -def test_class_level_differences(): +def test_class_level_differences() -> None: sdk = module( """\ class Kind(BaseModel): @@ -371,25 +411,29 @@ class Added(BaseModel): } -def test_inherited_fields_are_compared(): +def test_inherited_fields_are_compared() -> None: sdk = module("class Base(BaseModel):\n a: str\n\n\nclass Child(Base):\n b: str\n") - spec = module("class Base(BaseModel):\n a: str\n\n\nclass Child(BaseModel):\n a: int\n b: str\n") + spec = module( + "class Base(BaseModel):\n a: str\n\n\nclass Child(BaseModel):\n a: int\n b: str\n" + ) assert differences(sdk, spec) == {"field_type_changed:Child.a": ("str", "int")} -def test_root_models_compare_their_root_type(): +def test_root_models_compare_their_root_type() -> None: sdk = module("class R(BaseModel):\n __root__: List[str] = Field(..., title='R')\n") spec = module("class R(BaseModel):\n __root__: List[int] = Field(..., title='R')\n") assert differences(sdk, spec) == {"field_type_changed:R.__root__": ("List[str]", "List[int]")} -def test_a_module_without_classes_is_an_error(): +def test_a_module_without_classes_is_an_error() -> None: with pytest.raises(DriftError, match="no classes"): parse_models(HEADER, "empty") -def test_the_sdk_models_module_parses_with_its_hand_written_header(): - shapes = parse_models((REPO_ROOT / "permit" / "api" / "models.py").read_text(encoding="utf-8"), "models.py") +def test_the_sdk_models_module_parses_with_its_hand_written_header() -> None: + shapes = parse_models( + (REPO_ROOT / "permit" / "api" / "models.py").read_text(encoding="utf-8"), "models.py" + ) assert len(shapes) > 300 assert shapes["UserRead"].kind == "model" assert shapes["UserRead"].fields["key"].required is True @@ -402,28 +446,34 @@ def test_the_sdk_models_module_parses_with_its_hand_written_header(): # --- allowlist ---------------------------------------------------------------- -def entry(entry_id: str, sdk: str, spec: str, reason: str = "known") -> dict: +def entry(entry_id: str, sdk: str, spec: str, reason: str = "known") -> dict[str, str]: return {"id": entry_id, "sdk": sdk, "spec": spec, "reason": reason} def int_key_spec() -> str: - return module().replace("key: str = Field(..., title='Key')", "key: int = Field(..., title='Key')") + return module().replace( + "key: str = Field(..., title='Key')", "key: int = Field(..., title='Key')" + ) -def test_allowlist_suppresses_an_exact_match(tmp_path: Path): - result = run(tmp_path, module(), int_key_spec(), [entry("field_type_changed:UserRead.key", "str", "int")]) +def test_allowlist_suppresses_an_exact_match(tmp_path: Path) -> None: + result = run( + tmp_path, module(), int_key_spec(), [entry("field_type_changed:UserRead.key", "str", "int")] + ) assert result.returncode == 0, result.stdout assert "| 0 | 0 | 0 | 1 |" in result.stdout -def test_allowlist_does_not_suppress_a_further_change(tmp_path: Path): - spec = module().replace("key: str = Field(..., title='Key')", "key: float = Field(..., title='Key')") +def test_allowlist_does_not_suppress_a_further_change(tmp_path: Path) -> None: + spec = module().replace( + "key: str = Field(..., title='Key')", "key: float = Field(..., title='Key')" + ) result = run(tmp_path, module(), spec, [entry("field_type_changed:UserRead.key", "str", "int")]) assert result.returncode == 1 assert "field_type_changed:UserRead.key" in result.stdout -def test_informational_entries_match_on_id_alone(tmp_path: Path): +def test_informational_entries_match_on_id_alone(tmp_path: Path) -> None: # Recorded as a model, now an enum: still the same missing class, so still allowlisted. spec = module() + "\n\nclass NewThing(str, Enum):\n a = 'a'\n" result = run(tmp_path, module(), spec, [entry("class_added:NewThing", "(absent)", "model")]) @@ -431,8 +481,10 @@ def test_informational_entries_match_on_id_alone(tmp_path: Path): assert "| 0 | 0 | 0 | 1 |" in result.stdout -def test_stale_entry_fails(tmp_path: Path): - result = run(tmp_path, module(), module(), [entry("field_type_changed:UserRead.key", "str", "int")]) +def test_stale_entry_fails(tmp_path: Path) -> None: + result = run( + tmp_path, module(), module(), [entry("field_type_changed:UserRead.key", "str", "int")] + ) assert result.returncode == 1 assert "Stale allowlist entries" in result.stdout assert "stale allowlist entry: field_type_changed:UserRead.key" in result.stderr @@ -448,7 +500,7 @@ def test_stale_entry_fails(tmp_path: Path): (json.dumps({"entries": [entry("made_up_kind:A", "", "")]}), "unknown kind"), ], ) -def test_invalid_allowlist_exits_2(tmp_path: Path, content: str, message: str): +def test_invalid_allowlist_exits_2(tmp_path: Path, content: str, message: str) -> None: (tmp_path / "models.py").write_text(module()) (tmp_path / "allowlist.json").write_text(content) result = cli( @@ -463,7 +515,7 @@ def test_invalid_allowlist_exits_2(tmp_path: Path, content: str, message: str): assert message in result.stderr -def test_the_committed_allowlist_is_valid_and_every_entry_has_a_reason(): +def test_the_committed_allowlist_is_valid_and_every_entry_has_a_reason() -> None: entries = load_allowlist(Path(__file__).parent / "schema_drift_allowlist.json") assert entries assert all(len(e.reason.strip()) > 10 for e in entries) @@ -472,7 +524,7 @@ def test_the_committed_allowlist_is_valid_and_every_entry_has_a_reason(): # --- report ------------------------------------------------------------------- -def test_pipes_and_backticks_in_schema_text_cannot_break_the_table(): +def test_pipes_and_backticks_in_schema_text_cannot_break_the_table() -> None: sdk = module("class M(BaseModel):\n x: constr(regex='^a$')\n") spec = module("class M(BaseModel):\n x: constr(regex='^a|`b`$')\n") result = apply_allowlist(compare(parse_models(sdk, "sdk"), parse_models(spec, "spec")), []) @@ -485,7 +537,7 @@ def test_pipes_and_backticks_in_schema_text_cannot_break_the_table(): # --- the generator is the one scripts/generate_models.sh runs ----------------- -def test_generator_matches_the_generate_models_script(): +def test_generator_matches_the_generate_models_script() -> None: script = (REPO_ROOT / "scripts" / "generate_models.sh").read_text(encoding="utf-8") # The command starts on a line beginning with `uvx ` and continues over every # line that ends in a backslash. diff --git a/.github/scripts/test_format_audit.py b/.github/scripts/test_format_audit.py index 1664007..d15f3cc 100644 --- a/.github/scripts/test_format_audit.py +++ b/.github/scripts/test_format_audit.py @@ -15,6 +15,7 @@ import subprocess import sys from pathlib import Path +from typing import Any import pytest @@ -22,7 +23,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, @@ -36,7 +37,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, @@ -44,14 +45,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 @@ -72,7 +73,7 @@ def clean_report() -> dict: } -def vuln(**kwargs) -> dict: +def vuln(**kwargs: Any) -> dict[str, Any]: base = { "VulnerabilityID": "CVE-2026-69244", "PkgName": "aiohttp", @@ -89,12 +90,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)) @@ -104,7 +105,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)) @@ -113,13 +114,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)) @@ -128,7 +129,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)) @@ -144,7 +145,7 @@ def test_vulnerable_report_lists_the_finding(tmp_path: Path): @pytest.mark.parametrize( - "findings,errors", + ("findings", "errors"), [ ([], []), ([], ["trivy: boom"]), @@ -152,7 +153,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 @@ -160,7 +161,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())) @@ -172,7 +173,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()))) @@ -183,7 +184,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}]}) == [] @@ -191,28 +192,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"], + } + ], } ] } @@ -223,11 +232,11 @@ 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_same_pip_audit_advisory_from_two_trees_merges_whatever_the_alias_order(): +def test_same_pip_audit_advisory_from_two_trees_merges_whatever_the_alias_order() -> None: # pip-audit keeps aliases in a set, so each run lists them in its own # order. The finding id must not depend on that order, or the same # advisory shows up once per tree. - def report(aliases: list[str]) -> dict: + def report(aliases: list[str]) -> dict[str, Any]: vuln = {"id": "PYSEC-1", "aliases": aliases} return pip_audit_report({"name": "aiohttp", "version": "3.12.14", "vulns": [vuln]}) @@ -239,7 +248,7 @@ def report(aliases: list[str]) -> dict: assert merged[0].sources == {"pip-audit:runtime-ceiling", "pip-audit:runtime-floor"} -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}]}) == [] @@ -248,7 +257,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]]) @@ -258,7 +267,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( [ [ @@ -274,13 +283,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) @@ -290,13 +299,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 @@ -304,13 +313,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")] @@ -318,7 +328,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"), @@ -330,7 +340,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 @@ -339,26 +349,28 @@ 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_fix_instructions_cover_a_fix_uv_lock_still_filters_out(): +def test_fix_instructions_cover_a_fix_uv_lock_still_filters_out() -> None: # The gate resolves with --exclude-newer false, so it blocks on the day a fix # is released, while `uv lock` keeps that release out for 7 days. The report # must say how to lock it anyway, or the block cannot be cleared. - out = render([Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", "t", "", "trivy")], [], "", blocking=True) + out = render( + [Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", "t", "", "trivy")], [], "", blocking=True + ) assert "exclude-newer-package = { = false }" in out -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") @@ -367,28 +379,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: # A pip-audit run that did not finish leaves no report, 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. @@ -398,7 +410,7 @@ def test_missing_pip_audit_does_not_fail_the_gate(tmp_path: Path): assert result.returncode == 0 -def test_missing_pip_audit_is_named_in_the_report_not_a_parse_failure(tmp_path: Path): +def test_missing_pip_audit_is_named_in_the_report_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", f"runtime-floor={tmp_path / 'absent.json'}") @@ -407,28 +419,36 @@ def test_missing_pip_audit_is_named_in_the_report_not_a_parse_failure(tmp_path: assert "pip-audit:runtime-floor: no report at" in result.stdout assert "does not affect the gate" in result.stdout assert "could not be parsed" not 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" + ) # --- pip-audit, one report per tree ----------------------------------------- -def pip_audit_report(*deps: dict) -> dict: +def pip_audit_report(*deps: dict[str, Any]) -> dict[str, Any]: return {"dependencies": list(deps), "fixes": []} -def test_pip_audit_is_repeatable_and_tags_findings_with_their_tree(tmp_path: Path): +def test_pip_audit_is_repeatable_and_tags_findings_with_their_tree(tmp_path: Path) -> None: trivy = tmp_path / "trivy.json" ceiling = tmp_path / "pa-ceiling.json" floor = tmp_path / "pa-floor.json" trivy.write_text(json.dumps(clean_report())) ceiling.write_text( - json.dumps(pip_audit_report({"name": "werkzeug", "version": "3.1.6", "vulns": [{"id": "PYSEC-2026-2"}]})) + json.dumps( + pip_audit_report( + {"name": "werkzeug", "version": "3.1.6", "vulns": [{"id": "PYSEC-2026-2"}]} + ) + ) ) floor.write_text( - json.dumps(pip_audit_report({"name": "aiohttp", "version": "3.12.14", "vulns": [{"id": "PYSEC-2026-1"}]})) + json.dumps( + pip_audit_report( + {"name": "aiohttp", "version": "3.12.14", "vulns": [{"id": "PYSEC-2026-1"}]} + ) + ) ) result = run( str(trivy), @@ -438,13 +458,19 @@ def test_pip_audit_is_repeatable_and_tags_findings_with_their_tree(tmp_path: Pat f"runtime-floor={floor}", ) assert result.returncode == 0 - assert "**UNKNOWN -- PYSEC-2026-2** (`werkzeug` 3.1.6)\n\nFound by: pip-audit:runtime-ceiling" in result.stdout - assert "**UNKNOWN -- PYSEC-2026-1** (`aiohttp` 3.12.14)\n\nFound by: pip-audit:runtime-floor" in result.stdout + assert ( + "**UNKNOWN -- PYSEC-2026-2** (`werkzeug` 3.1.6)\n\nFound by: pip-audit:runtime-ceiling" + in result.stdout + ) + assert ( + "**UNKNOWN -- PYSEC-2026-1** (`aiohttp` 3.12.14)\n\nFound by: pip-audit:runtime-floor" + in result.stdout + ) assert "pip-audit did not check everything" not in result.stdout @pytest.mark.parametrize( - "content,expected", + ("content", "expected"), [ ("", "is empty"), ("{{{ truncated", "not valid JSON"), @@ -452,7 +478,7 @@ def test_pip_audit_is_repeatable_and_tags_findings_with_their_tree(tmp_path: Pat (json.dumps(pip_audit_report()), "lists no audited packages"), ], ) -def test_incomplete_pip_audit_report_is_named(tmp_path: Path, content: str, expected: str): +def test_incomplete_pip_audit_report_is_named(tmp_path: Path, content: str, expected: str) -> None: trivy = tmp_path / "trivy.json" report = tmp_path / "pa.json" trivy.write_text(json.dumps(clean_report())) @@ -465,7 +491,7 @@ def test_incomplete_pip_audit_report_is_named(tmp_path: Path, content: str, expe assert expected in result.stdout -def test_package_pip_audit_skipped_is_named(tmp_path: Path): +def test_package_pip_audit_skipped_is_named(tmp_path: Path) -> None: trivy = tmp_path / "trivy.json" report = tmp_path / "pa.json" trivy.write_text(json.dumps(clean_report())) @@ -473,18 +499,24 @@ def test_package_pip_audit_skipped_is_named(tmp_path: Path): json.dumps( pip_audit_report( {"name": "aiohttp", "version": "3.14.3", "vulns": []}, - {"name": "private-pkg", "skip_reason": "Dependency not found on PyPI and could not be audited"}, + { + "name": "private-pkg", + "skip_reason": "Dependency not found on PyPI and could not be audited", + }, ) ) ) result = run(str(trivy), "--pip-audit", f"runtime-ceiling={report}") assert result.returncode == 0 assert "pip-audit did not check everything" in result.stdout - assert "pip-audit:runtime-ceiling: skipped private-pkg: Dependency not found on PyPI" in result.stdout + assert ( + "pip-audit:runtime-ceiling: skipped private-pkg: Dependency not found on PyPI" + in result.stdout + ) @pytest.mark.parametrize("content", ["", "{{{ truncated", json.dumps({})]) -def test_incomplete_pip_audit_never_fails_the_gate(tmp_path: Path, content: str): +def test_incomplete_pip_audit_never_fails_the_gate(tmp_path: Path, content: str) -> None: trivy = tmp_path / "trivy.json" report = tmp_path / "pa.json" trivy.write_text(json.dumps(clean_report())) @@ -495,20 +527,24 @@ def test_incomplete_pip_audit_never_fails_the_gate(tmp_path: Path, content: str) @pytest.mark.parametrize( - "findings,errors", + ("findings", "errors"), [ ([], []), ([Finding("CVE-1", "aiohttp", "1.0", "HIGH", "2.0", "t", "", "trivy")], []), ([], ["trivy: boom"]), ], ) -def test_slack_names_the_trees_pip_audit_did_not_check(findings, errors): +def test_slack_names_the_trees_pip_audit_did_not_check( + findings: list[Finding], errors: list[str] +) -> None: gaps = [ ("pip-audit:runtime-floor", "pip-audit:runtime-floor: no report at /tmp/x.json"), ("pip-audit:dev-ceiling", "pip-audit:dev-ceiling: skipped a: b"), ("pip-audit:dev-ceiling", "pip-audit:dev-ceiling: skipped c: d"), ] - lines = render_slack(findings, errors, "https://example.invalid/run", "repo", pip_audit_gaps=gaps).split("\n") + lines = render_slack( + findings, errors, "https://example.invalid/run", "repo", pip_audit_gaps=gaps + ).split("\n") assert lines[-2] == ( ">:warning: pip-audit did not fully check dev-ceiling, runtime-floor, so an advisory " "only pip-audit reports could be missing." @@ -516,12 +552,12 @@ def test_slack_names_the_trees_pip_audit_did_not_check(findings, errors): assert lines[-1] == ">" -def test_slack_says_nothing_about_pip_audit_when_it_checked_everything(): +def test_slack_says_nothing_about_pip_audit_when_it_checked_everything() -> None: out = render_slack([], [], "", "repo") assert "pip-audit" not in out -def test_slack_message_from_cli_names_a_missing_pip_audit_report(tmp_path: Path): +def test_slack_message_from_cli_names_a_missing_pip_audit_report(tmp_path: Path) -> None: trivy = tmp_path / "trivy.json" trivy.write_text(json.dumps(clean_report())) result = run(str(trivy), "--pip-audit", f"runtime-floor={tmp_path / 'absent.json'}", "--slack") @@ -544,16 +580,19 @@ def test_slack_message_from_cli_names_a_missing_pip_audit_report(tmp_path: Path) {"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. @@ -564,7 +603,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)) @@ -576,25 +615,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 a5b049a..903ec33 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -41,9 +41,17 @@ 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. + # pre-commit itself comes from the locked dev group. The ruff, mypy and + # typos hooks are `repo: local` and run through `uv run --locked`, which + # syncs .venv to the default groups first: the project, its dependencies + # (pydantic 2) and the dev tools, so mypy checks against the SDK's real + # dependencies. - name: Run pre-commit run: >- - uv run --locked --only-dev + uv run --locked 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). + - name: Type-check against pydantic 1 + run: uv run --locked --group pydantic-v1 mypy diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d693c2..d993969 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -340,16 +340,12 @@ jobs: # Every test that needs credentials, the Permit API or a PDP is marked # e2e (see [tool.pytest] in pyproject.toml), so `-m "not e2e"` selects - # everything else. - # The filter fails the run on Python 3.14's deprecation of - # asyncio.iscoroutinefunction, whether permit or a dependency calls it. - # It is deliberately narrow: a blanket error::DeprecationWarning would - # also trip on PermitConnectionError, which still subclasses the - # deprecated PermitException on purpose. + # everything else. [tool.pytest] also makes every warning an error, so + # this fails on Python 3.14's deprecation of asyncio.iscoroutinefunction, + # whether permit or a dependency calls it, and on any warning a floor + # version of a dependency issues. - name: Offline tests - run: | - python -m pytest -q -m "not e2e" \ - -W "error:'asyncio.iscoroutinefunction' is deprecated:DeprecationWarning" + run: python -m pytest -q -m "not e2e" # The migration skill's tests (skills/tests): MIGRATION.md, the skill and its # scanner, checked against each other and against the SDK. They run apart from diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4208d8c..b852b03 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,40 +1,64 @@ +# `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; run `uv lock` to fix. It runs # the uv on PATH rather than installing its own, so there is no second uv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f467a08..a1b5a5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,27 @@ uv run pre-commit install # lint, format, type-check and uv.lock checks on ev import the working tree's `permit`. `.python-version` selects Python 3.11, the version the end-to-end CI job runs on. The SDK itself supports Python 3.10 and later. +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`. That sync uses the default groups, so a commit +also switches a `.venv` synced with `--group pydantic-v1` back to pydantic 2.x. 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 every Python file but the generated models +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: + +```sh +uv run --group pydantic-v1 mypy +``` + ## Dependencies - Runtime requirements are `[project].dependencies` in `pyproject.toml`. They are open @@ -47,6 +68,10 @@ against local mock servers and need no PDP, API key or network access: uv run pytest -m "not e2e" ``` +Any warning fails the test that raised it (`filterwarnings` in `[tool.pytest]`), except the +one `import permit` issues on pydantic 1 on purpose. The migration skill's and the CI +scripts' tests do the same with their own configs. + ### Both pydantic majors The SDK supports pydantic 1 and 2, and CI runs the suite once per major. Each major is a @@ -77,7 +102,7 @@ versions the runtime requirements allow and at the newest. `tests/test_typing_surface.py` runs mypy on `tests/type_check/consumer.py` the way a user's project sees an installed permit, and fails while `permit/_sync_types.pyi` is out of date (see [Regenerating the sync stubs](#regenerating-the-sync-stubs)). The `mypy` pre-commit -hook type-checks the SDK itself. +hook type-checks the SDK itself, strictly and with the pydantic plugin (see [Setup](#setup)). ### The migration skill's tests @@ -191,8 +216,8 @@ has to be restored by hand. 3. Re-apply the hand fixes: the entries in `.github/scripts/schema_drift_allowlist.json` whose reason says "by hand". -4. 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. +4. 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. 5. Run the schema drift check, 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 5a7be1c..8dbc757 100644 --- a/permit/__init__.py +++ b/permit/__init__.py @@ -24,7 +24,7 @@ from permit.exceptions import PermitError as PermitError # Deprecated, but still exported for existing callers. -from permit.exceptions import PermitException as PermitException +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 @@ -37,7 +37,8 @@ # import machinery's frames are skipped), which Python shows by default when that is # __main__. _warnings.warn( - "Support for pydantic 1 is deprecated and will be removed in permit 4.0. Upgrade to pydantic 2.", + "Support for pydantic 1 is deprecated and will be removed in permit 4.0. " + "Upgrade to pydantic 2.", DeprecationWarning, stacklevel=2, ) diff --git a/permit/_sync_types.pyi b/permit/_sync_types.pyi index 2c1fc11..558bcc4 100644 --- a/permit/_sync_types.pyi +++ b/permit/_sync_types.pyi @@ -1,7 +1,8 @@ # Generated by scripts/generate_sync_stubs.py from the async classes. Do not edit; # run `uv run python scripts/generate_sync_stubs.py` instead. -from typing import Any, Dict, List, Optional, Union +import builtins +from typing import Any from uuid import UUID from permit.api.base import BasePermitApi @@ -90,26 +91,41 @@ from permit.utils.context import Context, ContextStore from permit.utils.model_input import ModelInput, ModelListInput class SyncElementsApi(BasePermitApi): - def __init__(self, config: PermitConfig): ... - def login_as(self, user_id: Union[str, UUID], tenant_id: Union[str, UUID]) -> UserLoginAsResponse: ... + """Log users into Permit Elements (embeddable UI components).""" + def __init__(self, config: PermitConfig) -> None: ... + 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. + """ class SyncConditionSetRulesApi(BasePermitApi): + """Manage condition set rules: which user sets may act on which resource sets.""" 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). @@ -118,11 +134,13 @@ class SyncConditionSetRulesApi(BasePermitApi): 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. """ - def create(self, rule: ModelInput[ConditionSetRuleCreate]) -> List[ConditionSetRuleRead]: - """ - Creates a new condition set rule. + def create( + self, rule: ModelInput[ConditionSetRuleCreate] + ) -> builtins.list[ConditionSetRuleRead]: + """Creates a new condition set rule. Args: rule: The condition set rule to create. @@ -132,24 +150,25 @@ class SyncConditionSetRulesApi(BasePermitApi): 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. """ def delete(self, rule: ModelInput[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. """ class SyncConditionSetsApi(BasePermitApi): - def list(self, page: int = 1, per_page: int = 100) -> List[ConditionSetRead]: - """ - Retrieves a list of condition sets. + """Manage condition sets (user sets and resource sets) for ABAC policies.""" + 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). @@ -160,11 +179,11 @@ class SyncConditionSetsApi(BasePermitApi): 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. """ 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. @@ -174,11 +193,12 @@ class SyncConditionSetsApi(BasePermitApi): 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. """ 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: @@ -189,11 +209,12 @@ class SyncConditionSetsApi(BasePermitApi): 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. """ 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: @@ -204,11 +225,11 @@ class SyncConditionSetsApi(BasePermitApi): 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. """ def create(self, condition_set_data: ModelInput[ConditionSetCreate]) -> ConditionSetRead: - """ - Creates a new condition set. + """Creates a new condition set. Args: condition_set_data: The data for the new condition set. @@ -218,11 +239,13 @@ class SyncConditionSetsApi(BasePermitApi): 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. """ - def update(self, condition_set_key: str, condition_set_data: ModelInput[ConditionSetUpdate]) -> ConditionSetRead: - """ - Updates a condition set. + def update( + self, condition_set_key: str, condition_set_data: ModelInput[ConditionSetUpdate] + ) -> ConditionSetRead: + """Updates a condition set. Args: condition_set_key: The key of the condition set. @@ -233,72 +256,97 @@ class SyncConditionSetsApi(BasePermitApi): 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. """ 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. """ class SyncDeprecatedApi(BasePermitApi): - """ - The flat methods on permit.api that predate the per-resource APIs. + """The flat methods on permit.api that predate the per-resource APIs. Each one warns and calls the method named in its warning. They will be removed in permit 4.0. """ - def __init__(self, config: PermitConfig): ... - def get_user(self, user_key: str) -> UserRead: ... - def get_role(self, role_key: str) -> RoleRead: ... - def get_tenant(self, tenant_key: str) -> TenantRead: ... + def __init__(self, config: PermitConfig) -> None: ... + def get_user(self, user_key: str) -> UserRead: + """Deprecated: use `permit.api.users.get()` instead.""" + def get_role(self, role_key: str) -> RoleRead: + """Deprecated: use `permit.api.roles.get()` instead.""" + def get_tenant(self, tenant_key: str) -> TenantRead: + """Deprecated: use `permit.api.tenants.get()` instead.""" def get_assigned_roles( - self, user_key: str, tenant_key: Optional[str], page: int = 1, per_page: int = 100 - ) -> List[RoleAssignmentRead]: ... - def get_resource(self, resource_key: str) -> ResourceRead: ... - def list_roles(self, page: int = 1, per_page: int = 100) -> List[RoleRead]: ... - def sync_user(self, user: Union[UserCreate, Dict[str, Any]]) -> UserRead: ... - def delete_user(self, user_key: str) -> None: ... - def list_tenants(self, page: int = 1, per_page: int = 100) -> List[TenantRead]: ... - def create_tenant(self, tenant: Union[TenantCreate, Dict[str, Any]]) -> TenantRead: ... - def update_tenant(self, tenant_key: str, tenant: Union[TenantUpdate, Dict[str, Any]]) -> TenantRead: ... - def delete_tenant(self, tenant_key: str) -> None: ... - def create_role(self, role: Union[RoleCreate, Dict[str, Any]]) -> RoleRead: ... - def update_role(self, role_key: str, role: Union[RoleUpdate, Dict[str, Any]]) -> RoleRead: ... - def assign_role(self, user_key: str, role_key: str, tenant_key: str) -> RoleAssignmentRead: ... - def unassign_role(self, user_key: str, role_key: str, tenant_key: str) -> None: ... - def delete_role(self, role_key: str) -> None: ... - def create_resource(self, resource: Union[ResourceCreate, Dict[str, Any]]) -> ResourceRead: ... - def update_resource(self, resource_key: str, resource: Union[ResourceUpdate, Dict[str, Any]]) -> ResourceRead: ... - def delete_resource(self, resource_key: str) -> None: ... + self, user_key: str, tenant_key: str | None, page: int = 1, per_page: int = 100 + ) -> list[RoleAssignmentRead]: + """Deprecated: use `permit.api.users.get_assigned_roles()` instead.""" + def get_resource(self, resource_key: str) -> ResourceRead: + """Deprecated: use `permit.api.resources.get()` instead.""" + def list_roles(self, page: int = 1, per_page: int = 100) -> list[RoleRead]: + """Deprecated: use `permit.api.roles.list()` instead.""" + def sync_user(self, user: UserCreate | dict[str, Any]) -> UserRead: + """Deprecated: use `permit.api.users.sync()` instead.""" + def delete_user(self, user_key: str) -> None: + """Deprecated: use `permit.api.users.delete()` instead.""" + def list_tenants(self, page: int = 1, per_page: int = 100) -> list[TenantRead]: + """Deprecated: use `permit.api.tenants.list()` instead.""" + def create_tenant(self, tenant: TenantCreate | dict[str, Any]) -> TenantRead: + """Deprecated: use `permit.api.tenants.create()` instead.""" + def update_tenant(self, tenant_key: str, tenant: TenantUpdate | dict[str, Any]) -> TenantRead: + """Deprecated: use `permit.api.tenants.update()` instead.""" + def delete_tenant(self, tenant_key: str) -> None: + """Deprecated: use `permit.api.tenants.delete()` instead.""" + def create_role(self, role: RoleCreate | dict[str, Any]) -> RoleRead: + """Deprecated: use `permit.api.roles.create()` instead.""" + def update_role(self, role_key: str, role: RoleUpdate | dict[str, Any]) -> RoleRead: + """Deprecated: use `permit.api.roles.update()` instead.""" + def assign_role(self, user_key: str, role_key: str, tenant_key: str) -> RoleAssignmentRead: + """Deprecated: use `permit.api.users.assign_role()` instead.""" + def unassign_role(self, user_key: str, role_key: str, tenant_key: str) -> None: + """Deprecated: use `permit.api.users.unassign_role()` instead.""" + def delete_role(self, role_key: str) -> None: + """Deprecated: use `permit.api.roles.delete()` instead.""" + def create_resource(self, resource: ResourceCreate | dict[str, Any]) -> ResourceRead: + """Deprecated: use `permit.api.resources.create()` instead.""" + def update_resource( + self, resource_key: str, resource: ResourceUpdate | dict[str, Any] + ) -> ResourceRead: + """Deprecated: use `permit.api.resources.update()` instead.""" + def delete_resource(self, resource_key: str) -> None: + """Deprecated: use `permit.api.resources.delete()` instead.""" def elements_login_as( - self, user_id: Union[str, UUID], tenant_id: Union[str, UUID] - ) -> EmbeddedLoginRequestOutput: ... + self, user_id: str | UUID, tenant_id: str | UUID + ) -> EmbeddedLoginRequestOutput: + """Deprecated: use `permit.elements.login_as()` instead.""" class SyncEnvironmentsApi(BasePermitApi): - def __init__(self, config: PermitConfig): ... - def list(self, project_key: str, page: int = 1, per_page: int = 100) -> List[EnvironmentRead]: - """ - Retrieves a list of environments. + """Manage the environments of a project.""" + def __init__(self, config: PermitConfig) -> None: ... + 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. """ 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. @@ -309,11 +357,12 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ 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: @@ -325,11 +374,12 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ 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: @@ -341,11 +391,11 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ 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. @@ -356,11 +406,11 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ 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. @@ -371,11 +421,13 @@ class SyncEnvironmentsApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def create(self, project_key: str, environment_data: ModelInput[EnvironmentCreate]) -> EnvironmentRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Creates a new environment. + def create( + self, project_key: str, environment_data: ModelInput[EnvironmentCreate] + ) -> EnvironmentRead: + """Creates a new environment. Args: project_key: The project key. @@ -386,13 +438,16 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ def update( - self, project_key: str, environment_key: str, environment_data: ModelInput[EnvironmentUpdate] + self, + project_key: str, + environment_key: str, + environment_data: ModelInput[EnvironmentUpdate], ) -> EnvironmentRead: - """ - Updates an existing environment. + """Updates an existing environment. Args: project_key: The project key. @@ -404,11 +459,13 @@ class SyncEnvironmentsApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def copy(self, project_key: str, environment_key: str, copy_params: ModelInput[EnvironmentCopy]) -> EnvironmentRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Clones data from a source specified environment into a different target environment in the same project. + def copy( + self, project_key: str, environment_key: str, copy_params: ModelInput[EnvironmentCopy] + ) -> EnvironmentRead: + """Clones data from a source environment into another environment of the same project. Args: project_key: The project key. @@ -420,11 +477,11 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ def delete(self, project_key: str, environment_key: str) -> None: - """ - Deletes an environment. + """Deletes an environment. Args: project_key: The project key. @@ -432,14 +489,15 @@ class SyncEnvironmentsApi(BasePermitApi): 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. """ class SyncProjectsApi(BasePermitApi): - def __init__(self, config: PermitConfig): ... - def list(self, page: int = 1, per_page: int = 100) -> List[ProjectRead]: - """ - Retrieves a list of projects. + """Manage the projects of an organization.""" + def __init__(self, config: PermitConfig) -> None: ... + 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). @@ -450,11 +508,11 @@ class SyncProjectsApi(BasePermitApi): 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. """ 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. @@ -464,11 +522,12 @@ class SyncProjectsApi(BasePermitApi): 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. """ 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: @@ -479,11 +538,12 @@ class SyncProjectsApi(BasePermitApi): 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. """ 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: @@ -494,11 +554,11 @@ class SyncProjectsApi(BasePermitApi): 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. """ def create(self, project_data: ModelInput[ProjectCreate]) -> ProjectRead: - """ - Creates a new project. + """Creates a new project. Args: project_data: The data for the new project. @@ -508,11 +568,11 @@ class SyncProjectsApi(BasePermitApi): 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. """ def update(self, project_key: str, project_data: ModelInput[ProjectUpdate]) -> ProjectRead: - """ - Updates a project. + """Updates a project. Args: project_key: The key of the project. @@ -523,11 +583,11 @@ class SyncProjectsApi(BasePermitApi): 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. """ def delete(self, project_key: str) -> None: - """ - Deletes a project. + """Deletes a project. Args: project_key: The key of the project to delete. @@ -537,21 +597,22 @@ class SyncProjectsApi(BasePermitApi): 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. """ class SyncRelationshipTuplesApi(BasePermitApi): + """Manage relationship tuples between resource instances (ReBAC).""" def list( 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). @@ -566,12 +627,14 @@ class SyncRelationshipTuplesApi(BasePermitApi): 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. """ def create(self, tuple_data: ModelInput[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. @@ -581,24 +644,24 @@ class SyncRelationshipTuplesApi(BasePermitApi): 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. """ def delete(self, tuple_data: ModelInput[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. """ def bulk_create( self, tuples: ModelListInput[RelationshipTupleCreate] ) -> RelationshipTupleCreateBulkOperationResult: - """ - Creates multiple relationship tuples at once using the provided tuple data. + """Creates multiple relationship tuples at once using the provided tuple data. Args: tuples: The relationship tuples to create. @@ -618,13 +681,13 @@ class SyncRelationshipTuplesApi(BasePermitApi): 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. """ def bulk_delete( self, tuples: ModelListInput[RelationshipTupleDelete] ) -> RelationshipTupleDeleteBulkOperationResult: - """ - Deletes multiple relationship tuples at once using the provided tuple data. + """Deletes multiple relationship tuples at once using the provided tuple data. Args: tuples: The relationship tuples to delete. @@ -640,13 +703,16 @@ class SyncRelationshipTuplesApi(BasePermitApi): 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. """ class SyncResourceActionGroupsApi(BasePermitApi): - def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceActionGroupRead]: - """ - Retrieves a list of action groups. + """Manage the action groups of a resource.""" + 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. @@ -658,11 +724,11 @@ class SyncResourceActionGroupsApi(BasePermitApi): 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. """ 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. @@ -673,11 +739,12 @@ class SyncResourceActionGroupsApi(BasePermitApi): 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. """ 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: @@ -689,11 +756,12 @@ class SyncResourceActionGroupsApi(BasePermitApi): 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. """ 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: @@ -705,11 +773,13 @@ class SyncResourceActionGroupsApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def create(self, resource_key: str, group_data: ModelInput[ResourceActionGroupCreate]) -> ResourceActionGroupRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Creates a new action group. + def create( + self, resource_key: str, group_data: ModelInput[ResourceActionGroupCreate] + ) -> ResourceActionGroupRead: + """Creates a new action group. Args: resource_key: The key of the resource under which the action group should be created. @@ -720,13 +790,13 @@ class SyncResourceActionGroupsApi(BasePermitApi): 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. """ def update( self, resource_key: str, group_key: str, group_data: ModelInput[ResourceActionGroupUpdate] ) -> ResourceActionGroupRead: - """ - Updates an action group. + """Updates an action group. Args: resource_key: The key of the resource the action group belongs to. @@ -738,11 +808,11 @@ class SyncResourceActionGroupsApi(BasePermitApi): 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. """ 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. @@ -750,13 +820,16 @@ class SyncResourceActionGroupsApi(BasePermitApi): 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. """ class SyncResourceActionsApi(BasePermitApi): - def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceActionRead]: - """ - Retrieves a list of actions. + """Manage the actions of a resource.""" + 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. @@ -768,11 +841,11 @@ class SyncResourceActionsApi(BasePermitApi): 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. """ 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. @@ -783,11 +856,12 @@ class SyncResourceActionsApi(BasePermitApi): 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. """ 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: @@ -799,11 +873,12 @@ class SyncResourceActionsApi(BasePermitApi): 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. """ 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: @@ -815,11 +890,13 @@ class SyncResourceActionsApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def create(self, resource_key: str, action_data: ModelInput[ResourceActionCreate]) -> ResourceActionRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Creates a new action. + def create( + self, resource_key: str, action_data: ModelInput[ResourceActionCreate] + ) -> ResourceActionRead: + """Creates a new action. Args: resource_key: The key of the resource under which the action should be created. @@ -830,13 +907,13 @@ class SyncResourceActionsApi(BasePermitApi): 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. """ def update( self, resource_key: str, action_key: str, action_data: ModelInput[ResourceActionUpdate] ) -> ResourceActionRead: - """ - Updates a action. + """Updates a action. Args: resource_key: The key of the resource the action belongs to. @@ -848,11 +925,11 @@ class SyncResourceActionsApi(BasePermitApi): 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. """ 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. @@ -860,13 +937,16 @@ class SyncResourceActionsApi(BasePermitApi): 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. """ class SyncResourceAttributesApi(BasePermitApi): - def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceAttributeRead]: - """ - Retrieves a list of attributes. + """Manage the attributes of a resource.""" + 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. @@ -878,11 +958,11 @@ class SyncResourceAttributesApi(BasePermitApi): 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. """ 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. @@ -893,11 +973,12 @@ class SyncResourceAttributesApi(BasePermitApi): 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. """ 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: @@ -909,11 +990,12 @@ class SyncResourceAttributesApi(BasePermitApi): 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. """ 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: @@ -925,11 +1007,13 @@ class SyncResourceAttributesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def create(self, resource_key: str, attribute_data: ModelInput[ResourceAttributeCreate]) -> ResourceAttributeRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Creates a new attribute. + def create( + self, resource_key: str, attribute_data: ModelInput[ResourceAttributeCreate] + ) -> ResourceAttributeRead: + """Creates a new attribute. Args: resource_key: The key of the resource under which the attribute should be created. @@ -940,13 +1024,16 @@ class SyncResourceAttributesApi(BasePermitApi): 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. """ def update( - self, resource_key: str, attribute_key: str, attribute_data: ModelInput[ResourceAttributeUpdate] + self, + resource_key: str, + attribute_key: str, + attribute_data: ModelInput[ResourceAttributeUpdate], ) -> ResourceAttributeRead: - """ - Updates a attribute. + """Updates a attribute. Args: resource_key: The key of the resource the attribute belongs to. @@ -958,11 +1045,11 @@ class SyncResourceAttributesApi(BasePermitApi): 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. """ 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. @@ -970,36 +1057,41 @@ class SyncResourceAttributesApi(BasePermitApi): 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. """ class SyncResourceInstancesApi(BasePermitApi): + """Manage resource instances.""" def list( 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, + 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. """ 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` @@ -1011,11 +1103,12 @@ class SyncResourceInstancesApi(BasePermitApi): 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. """ 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: @@ -1028,11 +1121,12 @@ class SyncResourceInstancesApi(BasePermitApi): 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. """ 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: @@ -1043,11 +1137,11 @@ class SyncResourceInstancesApi(BasePermitApi): 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. """ def create(self, instance_data: ModelInput[ResourceInstanceCreate]) -> ResourceInstanceRead: - """ - Creates a new resource instance. + """Creates a new resource instance. Args: instance_data: The data for the new resource instance. @@ -1057,11 +1151,13 @@ class SyncResourceInstancesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def update(self, instance_key: str, instance_data: ModelInput[ResourceInstanceUpdate]) -> ResourceInstanceRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Updates a resource instance. + def update( + self, instance_key: str, instance_data: ModelInput[ResourceInstanceUpdate] + ) -> ResourceInstanceRead: + """Updates a resource instance. Args: instance_key: The resource instance identity. Either `resource_type:instance_key` @@ -1074,14 +1170,15 @@ class SyncResourceInstancesApi(BasePermitApi): 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. """ 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. @@ -1090,13 +1187,13 @@ class SyncResourceInstancesApi(BasePermitApi): 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. """ def bulk_replace( self, resource_instances: ModelListInput[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. @@ -1109,28 +1206,34 @@ class SyncResourceInstancesApi(BasePermitApi): 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. """ - def bulk_delete(self, resource_instances: List[str]) -> ResourceInstanceDeleteBulkOperationResult: - """ - Deletes resource instances in bulk. + 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. + """ class SyncResourceRelationsApi(BasePermitApi): - 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. + """Manage the relations between resources (ReBAC).""" + 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. @@ -1143,11 +1246,11 @@ class SyncResourceRelationsApi(BasePermitApi): 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. """ 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. @@ -1158,11 +1261,12 @@ class SyncResourceRelationsApi(BasePermitApi): 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. """ 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: @@ -1174,11 +1278,12 @@ class SyncResourceRelationsApi(BasePermitApi): 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. """ 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: @@ -1190,11 +1295,11 @@ class SyncResourceRelationsApi(BasePermitApi): 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. """ def create(self, resource_key: str, relation_data: ModelInput[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. @@ -1205,11 +1310,11 @@ class SyncResourceRelationsApi(BasePermitApi): 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. """ 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. @@ -1217,16 +1322,14 @@ class SyncResourceRelationsApi(BasePermitApi): 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. """ class SyncResourceRolesApi(BasePermitApi): - """ - Represents the interface for managing resource roles. - """ - def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceRoleRead]: - """ - Retrieves a list of resource roles. + """Represents the interface for managing resource roles.""" + 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. @@ -1238,11 +1341,11 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ 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. @@ -1253,11 +1356,12 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ 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: @@ -1269,11 +1373,12 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ 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: @@ -1285,11 +1390,13 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ - def create(self, resource_key: str, role_data: ModelInput[ResourceRoleCreate]) -> ResourceRoleRead: - """ - Creates a new resource role. + def create( + self, resource_key: str, role_data: ModelInput[ResourceRoleCreate] + ) -> ResourceRoleRead: + """Creates a new resource role. Args: resource_key: The key of the resource under which the role should be created. @@ -1300,11 +1407,13 @@ class SyncResourceRolesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def update(self, resource_key: str, role_key: str, role_data: ModelInput[ResourceRoleUpdate]) -> ResourceRoleRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Updates a resource role. + def update( + self, resource_key: str, role_key: str, role_data: ModelInput[ResourceRoleUpdate] + ) -> ResourceRoleRead: + """Updates a resource role. Args: resource_key: The key of the resource the role belongs to. @@ -1316,11 +1425,11 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ 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. @@ -1328,11 +1437,13 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ - def assign_permissions(self, resource_key: str, role_key: str, permissions: List[str]) -> ResourceRoleRead: - """ - Assigns permissions to a resource role. + 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. @@ -1348,11 +1459,13 @@ class SyncResourceRolesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def remove_permissions(self, resource_key: str, role_key: str, permissions: List[str]) -> ResourceRoleRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Removes permissions from a resource role. + 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. @@ -1366,15 +1479,16 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ def create_role_derivation( self, resource_key: str, role_key: str, derivation_rule: ModelInput[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. @@ -1386,13 +1500,13 @@ class SyncResourceRolesApi(BasePermitApi): 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. + """ def delete_role_derivation( self, resource_key: str, role_key: str, derivation_rule: ModelInput[DerivedRoleRuleDelete] ) -> None: - """ - Delete a role derivation. + """Delete a role derivation. Args: resource_key: The key of the resource the role belongs to. @@ -1401,7 +1515,8 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ def update_role_derivation_conditions( self, @@ -1409,8 +1524,7 @@ class SyncResourceRolesApi(BasePermitApi): role_key: str, conditions: ModelInput[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. @@ -1419,13 +1533,14 @@ class SyncResourceRolesApi(BasePermitApi): 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. """ class SyncResourcesApi(BasePermitApi): - def list(self, page: int = 1, per_page: int = 100) -> List[ResourceRead]: - """ - Retrieves a list of resources. + """Manage resources (the object types permissions are granted on).""" + 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). @@ -1436,11 +1551,11 @@ class SyncResourcesApi(BasePermitApi): 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. """ 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. @@ -1450,11 +1565,12 @@ class SyncResourcesApi(BasePermitApi): 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. """ 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: @@ -1465,11 +1581,12 @@ class SyncResourcesApi(BasePermitApi): 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. """ 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: @@ -1480,11 +1597,11 @@ class SyncResourcesApi(BasePermitApi): 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. """ def create(self, resource_data: ModelInput[ResourceCreate]) -> ResourceRead: - """ - Creates a new resource. + """Creates a new resource. Args: resource_data: The data for the new resource. @@ -1494,11 +1611,11 @@ class SyncResourcesApi(BasePermitApi): 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. """ def update(self, resource_key: str, resource_data: ModelInput[ResourceUpdate]) -> ResourceRead: - """ - Updates a resource. + """Updates a resource. Args: resource_key: The key of the resource. @@ -1509,11 +1626,13 @@ class SyncResourcesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def replace(self, resource_key: str, resource_data: ModelInput[ResourceReplace]) -> ResourceRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Creates a resource if no such resource exists, otherwise completely replaces the resource in place. + def replace( + self, resource_key: str, resource_data: ModelInput[ResourceReplace] + ) -> ResourceRead: + """Creates a resource, or completely replaces it in place if it already exists. Args: resource_key: The key of the resource. @@ -1524,40 +1643,46 @@ class SyncResourcesApi(BasePermitApi): 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. """ 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. """ class SyncRoleAssignmentsApi(BasePermitApi): + """Assign roles to users and list or remove role assignments.""" def list( 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). @@ -1566,11 +1691,11 @@ class SyncRoleAssignmentsApi(BasePermitApi): 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 - def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Assigns a role to a user in the scope of a given tenant. + def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssignmentRead: + """Assigns a role to a user in the scope of a given tenant. Args: assignment: The role assignment details. @@ -1580,22 +1705,25 @@ class SyncRoleAssignmentsApi(BasePermitApi): 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. """ def unassign(self, unassignment: ModelInput[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. - """ - def bulk_assign(self, assignments: ModelListInput[RoleAssignmentCreate]) -> BulkRoleAssignmentReport: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Assigns multiple roles in bulk using the provided role assignments data. + def bulk_assign( + self, assignments: ModelListInput[RoleAssignmentCreate] + ) -> BulkRoleAssignmentReport: + """Assigns multiple roles in bulk using the provided role assignments data. + Each role assignment is a tuple of (user, role, tenant). Args: @@ -1606,11 +1734,14 @@ class SyncRoleAssignmentsApi(BasePermitApi): 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. """ - def bulk_unassign(self, unassignments: ModelListInput[RoleAssignmentRemove]) -> BulkRoleUnAssignmentReport: - """ - Removes multiple role assignments in bulk using the provided unassignment data. + def bulk_unassign( + self, unassignments: ModelListInput[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: @@ -1621,16 +1752,14 @@ class SyncRoleAssignmentsApi(BasePermitApi): 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. """ class SyncRolesApi(BasePermitApi): - """ - Represents the interface for managing roles. - """ - def list(self, page: int = 1, per_page: int = 100) -> List[RoleRead]: - """ - Retrieves a list of roles. + """Represents the interface for managing roles.""" + 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). @@ -1641,11 +1770,11 @@ class SyncRolesApi(BasePermitApi): 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. """ 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. @@ -1655,11 +1784,12 @@ class SyncRolesApi(BasePermitApi): 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. """ 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: @@ -1670,11 +1800,12 @@ class SyncRolesApi(BasePermitApi): 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. """ 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: @@ -1685,11 +1816,11 @@ class SyncRolesApi(BasePermitApi): 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. """ def create(self, role_data: ModelInput[RoleCreate]) -> RoleRead: - """ - Creates a new role. + """Creates a new role. Args: role_data: The data for the new role. @@ -1699,11 +1830,11 @@ class SyncRolesApi(BasePermitApi): 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. """ def update(self, role_key: str, role_data: ModelInput[RoleUpdate]) -> RoleRead: - """ - Updates a role. + """Updates a role. Args: role_key: The key of the role. @@ -1714,54 +1845,57 @@ class SyncRolesApi(BasePermitApi): 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. """ 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. """ - def assign_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Assigns permissions to a role. + 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. - """ - def remove_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Removes permissions from a role. + 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. """ class SyncTenantsApi(BasePermitApi): - def list(self, page: int = 1, per_page: int = 100) -> List[TenantRead]: - """ - Retrieves a list of tenants. + """Manage tenants and the users in them.""" + 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). @@ -1772,11 +1906,13 @@ class SyncTenantsApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def list_tenant_users(self, tenant_key: str, page: int = 1, per_page: int = 100) -> PaginatedResultUserRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Retrieves a list of users for a given tenant. + 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. @@ -1788,11 +1924,11 @@ class SyncTenantsApi(BasePermitApi): 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. """ 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. @@ -1802,11 +1938,12 @@ class SyncTenantsApi(BasePermitApi): 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. """ 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: @@ -1817,11 +1954,12 @@ class SyncTenantsApi(BasePermitApi): 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. """ 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: @@ -1832,11 +1970,11 @@ class SyncTenantsApi(BasePermitApi): 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. """ def create(self, tenant_data: ModelInput[TenantCreate]) -> TenantRead: - """ - Creates a new tenant. + """Creates a new tenant. Args: tenant_data: The data for the new tenant. @@ -1846,11 +1984,11 @@ class SyncTenantsApi(BasePermitApi): 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. """ def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) -> TenantRead: - """ - Updates a tenant. + """Updates a tenant. Args: tenant_key: The key of the tenant. @@ -1861,11 +1999,11 @@ class SyncTenantsApi(BasePermitApi): 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. """ def delete(self, tenant_key: str) -> None: - """ - Deletes a tenant. + """Deletes a tenant. Args: tenant_key: The key of the tenant to delete. @@ -1875,11 +2013,11 @@ class SyncTenantsApi(BasePermitApi): 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. """ 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. @@ -1887,11 +2025,11 @@ class SyncTenantsApi(BasePermitApi): 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. """ def bulk_create(self, tenants: ModelListInput[TenantCreate]) -> TenantCreateBulkOperationResult: - """ - Creates tenants in bulk. + """Creates tenants in bulk. Args: tenants: The tenants to create @@ -1901,27 +2039,29 @@ class SyncTenantsApi(BasePermitApi): 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. """ - def bulk_delete(self, tenants: List[str]) -> TenantDeleteBulkOperationResult: - """ - Deletes tenants in bulk. + 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. """ class SyncUserInvitesApi(BasePermitApi): + """Manage user invites.""" def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultElementsUserInviteRead: - """ - Retrieves a list of user invites. + """Retrieves a list of user invites. Args: page: The page number to retrieve (default: 1). @@ -1932,11 +2072,11 @@ class SyncUserInvitesApi(BasePermitApi): 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. """ 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. @@ -1946,11 +2086,13 @@ class SyncUserInvitesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def create(self, user_invite_data: ModelInput[ElementsUserInviteCreate]) -> ElementsUserInviteRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Creates a new user invite. + def create( + self, user_invite_data: ModelInput[ElementsUserInviteCreate] + ) -> ElementsUserInviteRead: + """Creates a new user invite. Args: user_invite_data: The user invite data to create. @@ -1960,11 +2102,11 @@ class SyncUserInvitesApi(BasePermitApi): 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. """ 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. @@ -1974,11 +2116,13 @@ class SyncUserInvitesApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def approve(self, user_invite_id: str, approve_data: ModelInput[ElementsUserInviteApprove]) -> UserRead: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Approves a user invite. + def approve( + self, user_invite_id: str, approve_data: ModelInput[ElementsUserInviteApprove] + ) -> UserRead: + """Approves a user invite. Args: user_invite_id: The ID of the user invite to approve. @@ -1989,13 +2133,14 @@ class SyncUserInvitesApi(BasePermitApi): 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. """ class SyncUsersApi(BasePermitApi): + """Manage users and their role assignments.""" 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). @@ -2006,11 +2151,11 @@ class SyncUsersApi(BasePermitApi): 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. """ 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. @@ -2020,11 +2165,12 @@ class SyncUsersApi(BasePermitApi): 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. """ 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: @@ -2035,11 +2181,12 @@ class SyncUsersApi(BasePermitApi): 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. """ 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: @@ -2050,11 +2197,11 @@ class SyncUsersApi(BasePermitApi): 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. """ def create(self, user_data: ModelInput[UserCreate]) -> UserRead: - """ - Creates a new user. + """Creates a new user. Args: user_data: The data for the new user. @@ -2064,11 +2211,11 @@ class SyncUsersApi(BasePermitApi): 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. """ def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> UserRead: - """ - Updates a user. + """Updates a user. Args: user_key: The key of the user. @@ -2079,11 +2226,11 @@ class SyncUsersApi(BasePermitApi): 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. """ def sync(self, user: _UserSyncInput) -> UserRead: - """ - Synchronizes user data by creating or updating a user. + """Synchronizes user data by creating or updating a user. Args: user: The data of the user to be synchronized. @@ -2093,22 +2240,22 @@ class SyncUsersApi(BasePermitApi): 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. """ 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. """ def bulk_create(self, users: ModelListInput[UserCreate]) -> UserCreateBulkOperationResult: - """ - Creates users in bulk. + """Creates users in bulk. Args: users: The users to create @@ -2118,11 +2265,11 @@ class SyncUsersApi(BasePermitApi): 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. """ def bulk_replace(self, users: ModelListInput[UserCreate]) -> UserReplaceBulkOperationResult: - """ - Replaces users in bulk. + """Replaces users in bulk. If the user exists - replaces it. Otherwise, creates previously non-existing users. @@ -2135,25 +2282,26 @@ class SyncUsersApi(BasePermitApi): Raises: PermitApiError: If the API returns an error HTTP status code. - PermitContextError: If the configured ApiContext does not match the required endpoint context. - """ - def bulk_delete(self, users: List[str]) -> UserDeleteBulkOperationResult: + PermitContextError: If the configured ApiContext does not match the required endpoint + context. """ - Deletes users in bulk. + 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. """ def assign_role(self, assignment: ModelInput[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. @@ -2163,25 +2311,27 @@ class SyncUsersApi(BasePermitApi): 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. """ def unassign_role(self, unassignment: ModelInput[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. """ def get_assigned_roles( - self, user: str, tenant: Optional[str] = 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). + self, user: str, tenant: str | None = None, page: int = 1, per_page: int = 100 + ) -> 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. @@ -2194,36 +2344,40 @@ class SyncUsersApi(BasePermitApi): 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. """ class SyncEnforcer: - def __init__(self, config: PermitConfig): ... + """Sends authorization queries to the PDP.""" + def __init__(self, config: PermitConfig) -> None: ... @property def context_store(self) -> ContextStore: - """ - we let context store be accessed from the outside so that the - using app can setup a flexible contextual behavior for authorization queries + """The base context merged into every query. + + It is exposed so the application can set up flexible contextual behavior for + authorization queries. """ def authorized_users( - self, action: Action, resource: Resource, context: Optional[Context] = None + self, action: Action, resource: Resource, 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') @@ -2233,25 +2387,27 @@ class SyncEnforcer: # 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 - 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. + def bulk_check(self, 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([ { @@ -2271,24 +2427,26 @@ class SyncEnforcer: }, ]) """ - def check(self, user: User, action: Action, resource: Resource, context: Optional[Context] = None) -> bool: - """ - Checks if a user is authorized to perform an action on a resource within the specified context. + def check( + self, user: User, action: Action, resource: Resource, context: Context | None = None + ) -> bool: + """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') @@ -2301,14 +2459,28 @@ class SyncEnforcer: """ def get_user_permissions( self, - user: Union[Dict[str, Any], str], - tenants: Optional[List[str]] = None, - resources: Optional[List[str]] = None, - resource_types: Optional[List[str]] = None, - ) -> Dict[str, Any]: ... + 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. + """ 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: @@ -2323,25 +2495,28 @@ class SyncEnforcer: """ class SyncPdpRoleAssignmentsApi(BasePdpPermitApi): + """Read role assignments from the PDP's local cache.""" def list( 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). @@ -2350,5 +2525,6 @@ class SyncPdpRoleAssignmentsApi(BasePdpPermitApi): 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. + """ 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 179282f..def26a0 100644 --- a/permit/api/base.py +++ b/permit/api/base.py @@ -1,11 +1,11 @@ -from typing import TYPE_CHECKING, 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 TYPE_CHECKING: # The v1 API is what runs under either pydantic major, so type-check against it. @@ -15,20 +15,33 @@ else: 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) +# Whatever `parse_obj_as` can build: a model, or e.g. `list[Model]` for list endpoints. +TModel = TypeVar("TModel") +# Unused by the SDK. Kept because it is importable from this module in 3.0.0. TData = TypeVar("TData", bound=BaseModel) -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 @@ -36,15 +49,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: @@ -56,7 +73,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 @@ -75,10 +94,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") @@ -91,11 +111,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") @@ -108,11 +129,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") @@ -125,11 +147,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") @@ -139,14 +162,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") @@ -160,13 +202,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. @@ -174,7 +213,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: @@ -199,18 +240,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: @@ -224,18 +265,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. @@ -243,7 +288,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 ( @@ -256,21 +302,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 ( @@ -280,7 +327,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 0ffca0a..f0d4232 100644 --- a/permit/api/condition_set_rules.py +++ b/permit/api/condition_set_rules.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,18 +10,17 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput +import builtins -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ConditionSetRuleCreate, ConditionSetRuleRead, ConditionSetRuleRemove +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ConditionSetRuleCreate, ConditionSetRuleRead, ConditionSetRuleRemove +from permit.utils.model_input import ModelInput 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( @@ -31,20 +30,21 @@ def __condition_set_rules(self) -> SimpleHttpClient: @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). @@ -53,7 +53,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) @@ -66,14 +67,15 @@ 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 - async def create(self, rule: ModelInput[ConditionSetRuleCreate]) -> List[ConditionSetRuleRead]: - """ - Creates a new condition set rule. + async def create( + self, rule: ModelInput[ConditionSetRuleCreate] + ) -> builtins.list[ConditionSetRuleRead]: + """Creates a new condition set rule. Args: rule: The condition set rule to create. @@ -83,23 +85,26 @@ async def create(self, rule: ModelInput[ConditionSetRuleCreate]) -> List[Conditi 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 async def delete(self, rule: ModelInput[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 fbe4be1..906ab09 100644 --- a/permit/api/condition_sets.py +++ b/permit/api/condition_sets.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,18 +10,15 @@ else: from pydantic.v1 import validate_arguments +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ConditionSetCreate, ConditionSetRead, ConditionSetUpdate from permit.utils.model_input import ModelInput -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .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( @@ -29,9 +26,8 @@ def __condition_sets(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, page: int = 1, per_page: int = 100) -> List[ConditionSetRead]: - """ - Retrieves a list of condition sets. + 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). @@ -42,12 +38,13 @@ 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: @@ -55,8 +52,7 @@ async def _get(self, condition_set_key: str) -> ConditionSetRead: @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. @@ -66,7 +62,8 @@ 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) @@ -74,8 +71,8 @@ async def get(self, condition_set_key: str) -> ConditionSetRead: @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: @@ -86,7 +83,8 @@ 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) @@ -94,8 +92,8 @@ async def get_by_key(self, condition_set_key: str) -> ConditionSetRead: @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: @@ -106,7 +104,8 @@ 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) @@ -114,8 +113,7 @@ async def get_by_id(self, condition_set_id: str) -> ConditionSetRead: @validate_arguments async def create(self, condition_set_data: ModelInput[ConditionSetCreate]) -> ConditionSetRead: - """ - Creates a new condition set. + """Creates a new condition set. Args: condition_set_data: The data for the new condition set. @@ -125,7 +123,8 @@ async def create(self, condition_set_data: ModelInput[ConditionSetCreate]) -> Co 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) @@ -135,8 +134,7 @@ async def create(self, condition_set_data: ModelInput[ConditionSetCreate]) -> Co async def update( self, condition_set_key: str, condition_set_data: ModelInput[ConditionSetUpdate] ) -> ConditionSetRead: - """ - Updates a condition set. + """Updates a condition set. Args: condition_set_key: The key of the condition set. @@ -147,7 +145,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) @@ -159,15 +158,15 @@ async def update( @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 b8eb887..e5a27a9 100644 --- a/permit/api/deprecated.py +++ b/permit/api/deprecated.py @@ -1,11 +1,9 @@ -from typing import Any, Dict, 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,24 +19,28 @@ UserCreate, UserRead, ) -from .resources import ResourcesApi -from .roles import RolesApi -from .tenants import TenantsApi -from .users import UsersApi +from permit.api.resources import ResourcesApi +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 def _removal_notice(method: str, replacement: str) -> str: - return f"permit.api.{method}() is deprecated and will be removed in permit 4.0; use {replacement}() instead." + return ( + f"permit.api.{method}() is deprecated and will be removed in permit 4.0; " + f"use {replacement}() instead." + ) class DeprecatedApi(BasePermitApi): - """ - The flat methods on permit.api that predate the per-resource APIs. + """The flat methods on permit.api that predate the per-resource APIs. Each one warns and calls the method named in its warning. They will be removed in permit 4.0. """ - def __init__(self, config: PermitConfig): + def __init__(self, config: PermitConfig) -> None: super().__init__(config) self.__resources = ResourcesApi(config) self.__roles = RolesApi(config) @@ -48,98 +50,135 @@ def __init__(self, config: PermitConfig): @deprecated(_removal_notice("get_user", "permit.api.users.get")) async def get_user(self, user_key: str) -> UserRead: + """Deprecated: use `permit.api.users.get()` instead.""" return await self.__users.get(user_key) @deprecated(_removal_notice("get_role", "permit.api.roles.get")) async def get_role(self, role_key: str) -> RoleRead: + """Deprecated: use `permit.api.roles.get()` instead.""" return await self.__roles.get(role_key) @deprecated(_removal_notice("get_tenant", "permit.api.tenants.get")) async def get_tenant(self, tenant_key: str) -> TenantRead: + """Deprecated: use `permit.api.tenants.get()` instead.""" return await self.__tenants.get(tenant_key) @deprecated(_removal_notice("get_assigned_roles", "permit.api.users.get_assigned_roles")) 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(_removal_notice("get_resource", "permit.api.resources.get")) async def get_resource(self, resource_key: str) -> ResourceRead: + """Deprecated: use `permit.api.resources.get()` instead.""" return await self.__resources.get(resource_key) @deprecated(_removal_notice("list_roles", "permit.api.roles.list")) - 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(_removal_notice("sync_user", "permit.api.users.sync")) - async def sync_user(self, user: Union[UserCreate, Dict[str, Any]]) -> 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(_removal_notice("delete_user", "permit.api.users.delete")) async def delete_user(self, user_key: str) -> None: + """Deprecated: use `permit.api.users.delete()` instead.""" return await self.__users.delete(user_key) @deprecated(_removal_notice("list_tenants", "permit.api.tenants.list")) - 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(_removal_notice("create_tenant", "permit.api.tenants.create")) - async def create_tenant(self, tenant: Union[TenantCreate, Dict[str, Any]]) -> 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(_removal_notice("update_tenant", "permit.api.tenants.update")) - async def update_tenant(self, tenant_key: str, tenant: Union[TenantUpdate, Dict[str, Any]]) -> 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(_removal_notice("delete_tenant", "permit.api.tenants.delete")) async def delete_tenant(self, tenant_key: str) -> None: + """Deprecated: use `permit.api.tenants.delete()` instead.""" return await self.__tenants.delete(tenant_key) @deprecated(_removal_notice("create_role", "permit.api.roles.create")) - async def create_role(self, role: Union[RoleCreate, Dict[str, Any]]) -> 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(_removal_notice("update_role", "permit.api.roles.update")) - async def update_role(self, role_key: str, role: Union[RoleUpdate, Dict[str, Any]]) -> 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(_removal_notice("assign_role", "permit.api.users.assign_role")) - async def assign_role(self, user_key: str, role_key: str, tenant_key: str) -> RoleAssignmentRead: - return await self.__users.assign_role(RoleAssignmentCreate(user=user_key, role=role_key, tenant=tenant_key)) + 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.__users.assign_role( + RoleAssignmentCreate(user=user_key, role=role_key, tenant=tenant_key) + ) @deprecated(_removal_notice("unassign_role", "permit.api.users.unassign_role")) async def unassign_role(self, user_key: str, role_key: str, tenant_key: str) -> None: - return await self.__users.unassign_role(RoleAssignmentRemove(user=user_key, role=role_key, tenant=tenant_key)) + """Deprecated: use `permit.api.users.unassign_role()` instead.""" + return await self.__users.unassign_role( + RoleAssignmentRemove(user=user_key, role=role_key, tenant=tenant_key) + ) @deprecated(_removal_notice("delete_role", "permit.api.roles.delete")) async def delete_role(self, role_key: str) -> None: + """Deprecated: use `permit.api.roles.delete()` instead.""" return await self.__roles.delete(role_key) @deprecated(_removal_notice("create_resource", "permit.api.resources.create")) - async def create_resource(self, resource: Union[ResourceCreate, Dict[str, Any]]) -> 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(_removal_notice("update_resource", "permit.api.resources.update")) - async def update_resource(self, resource_key: str, resource: Union[ResourceUpdate, Dict[str, Any]]) -> 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(_removal_notice("delete_resource", "permit.api.resources.delete")) async def delete_resource(self, resource_key: str) -> None: + """Deprecated: use `permit.api.resources.delete()` instead.""" return await self.__resources.delete(resource_key) @deprecated(_removal_notice("elements_login_as", "permit.elements.login_as")) 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 e09e651..2b55350 100644 --- a/permit/api/elements.py +++ b/permit/api/elements.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING, 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 TYPE_CHECKING: # The v1 API is what runs under either pydantic major, so type-check against it. @@ -11,68 +11,87 @@ else: 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( + 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( + 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( + token: str | None = Field( default=None, description="The auth token that lets your users login into permit elements", title="Token", ) - extra: Optional[str] = Field( + 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 response to a login-as request: where to redirect, and what to send.""" + + # 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] default=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): @@ -92,4 +111,4 @@ async def login_as(self, user_id: Union[str, UUID], tenant_id: Union[str, UUID]) else: class SyncElementsApi(ElementsApi, metaclass=SyncClass): - pass + """Blocking variant of `ElementsApi`.""" diff --git a/permit/api/encoders.py b/permit/api/encoders.py index 8109c0a..8ef1fa5 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,7 +16,14 @@ from pathlib import Path, PurePath from re import Pattern from types import GeneratorType -from typing import TYPE_CHECKING, 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.utils.pydantic_version import PYDANTIC_VERSION @@ -38,7 +46,7 @@ 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 @@ -54,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. @@ -72,15 +80,23 @@ def decimal_encoder(dec_value: Decimal) -> Union[int, float]: >>> decimal_encoder(Decimal("1")) 1 + + Raises: + TypeError: If ``dec_value`` is NaN or infinite. JSON has no such values, so + encoding one would send the API an invalid request body. """ - if dec_value.as_tuple().exponent >= 0: # type: ignore[operator] + exponent = dec_value.as_tuple().exponent + if not isinstance(exponent, int): + msg = f"{dec_value!r} is not JSON serializable: JSON has no NaN or Infinity" + raise TypeError(msg) + if 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, @@ -110,9 +126,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 @@ -124,17 +141,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. @@ -149,14 +165,13 @@ 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", {}) if custom_encoder: @@ -178,12 +193,15 @@ 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] + # A dataclass class (not an instance) also gets here, and asdict() raises + # TypeError for it, as it always has; skipping the class instead would change + # the error the caller sees. + obj_dict = dataclasses.asdict(obj) # type: ignore[arg-type] return jsonable_encoder( obj_dict, include=include, @@ -233,22 +251,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) @@ -258,8 +274,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 041509d..a6cafd9 100644 --- a/permit/api/environments.py +++ b/permit/api/environments.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,15 +10,9 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput - -from ..config import PermitConfig -from .base import ( - BasePermitApi, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( APIKeyRead, EnvironmentCopy, EnvironmentCreate, @@ -26,33 +20,41 @@ EnvironmentStats, EnvironmentUpdate, ) +from permit.config import PermitConfig +from permit.utils.model_input import ModelInput 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 - async def list(self, project_key: str, page: int = 1, per_page: int = 100) -> List[EnvironmentRead]: - """ - Retrieves a list of environments. + 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), ) @@ -63,8 +65,7 @@ async def _get(self, project_key: str, environment_key: str) -> EnvironmentRead: @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. @@ -75,7 +76,8 @@ 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) @@ -83,8 +85,8 @@ async def get(self, project_key: str, environment_key: str) -> EnvironmentRead: @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: @@ -96,7 +98,8 @@ 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) @@ -104,8 +107,8 @@ async def get_by_key(self, project_key: str, environment_key: str) -> Environmen @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: @@ -117,7 +120,8 @@ 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) @@ -125,8 +129,7 @@ async def get_by_id(self, project_id: str, environment_id: str) -> EnvironmentRe @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. @@ -137,7 +140,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) @@ -148,8 +152,7 @@ async def get_stats(self, project_key: str, environment_key: str) -> Environment @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. @@ -160,7 +163,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) @@ -170,9 +174,10 @@ async def get_api_key(self, project_key: str, environment_key: str) -> APIKeyRea ) @validate_arguments - async def create(self, project_key: str, environment_data: ModelInput[EnvironmentCreate]) -> EnvironmentRead: - """ - Creates a new environment. + async def create( + self, project_key: str, environment_data: ModelInput[EnvironmentCreate] + ) -> EnvironmentRead: + """Creates a new environment. Args: project_key: The project key. @@ -183,7 +188,8 @@ async def create(self, project_key: str, environment_data: ModelInput[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.PROJECT_LEVEL_API_KEY) await self._ensure_context(ApiContextLevel.ORGANIZATION) @@ -200,8 +206,7 @@ async def update( environment_key: str, environment_data: ModelInput[EnvironmentUpdate], ) -> EnvironmentRead: - """ - Updates an existing environment. + """Updates an existing environment. Args: project_key: The project key. @@ -213,7 +218,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) @@ -227,8 +233,7 @@ async def update( async def copy( self, project_key: str, environment_key: str, copy_params: ModelInput[EnvironmentCopy] ) -> EnvironmentRead: - """ - Clones data from a source specified environment into a different target environment in the same project. + """Clones data from a source environment into another environment of the same project. Args: project_key: The project key. @@ -240,7 +245,8 @@ async def copy( 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) @@ -252,8 +258,7 @@ async def copy( @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. @@ -261,8 +266,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/projects.py b/permit/api/projects.py index e046a28..9d531d5 100644 --- a/permit/api/projects.py +++ b/permit/api/projects.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,26 +10,23 @@ else: from pydantic.v1 import validate_arguments +from permit.api.base import BasePermitApi, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ProjectCreate, ProjectRead, ProjectUpdate +from permit.config import PermitConfig from permit.utils.model_input import ModelInput -from ..config import PermitConfig -from .base import ( - BasePermitApi, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ProjectCreate, ProjectRead, ProjectUpdate - 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 - async def list(self, page: int = 1, per_page: int = 100) -> List[ProjectRead]: - """ - Retrieves a list of projects. + 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). @@ -40,19 +37,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 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. @@ -62,7 +61,8 @@ 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) @@ -70,8 +70,8 @@ async def get(self, project_key: str) -> ProjectRead: @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: @@ -82,7 +82,8 @@ 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) @@ -90,8 +91,8 @@ async def get_by_key(self, project_key: str) -> ProjectRead: @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: @@ -102,7 +103,8 @@ 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) @@ -110,8 +112,7 @@ async def get_by_id(self, project_id: str) -> ProjectRead: @validate_arguments async def create(self, project_data: ModelInput[ProjectCreate]) -> ProjectRead: - """ - Creates a new project. + """Creates a new project. Args: project_data: The data for the new project. @@ -121,16 +122,18 @@ async def create(self, project_data: ModelInput[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 - async def update(self, project_key: str, project_data: ModelInput[ProjectUpdate]) -> ProjectRead: - """ - Updates a project. + async def update( + self, project_key: str, project_data: ModelInput[ProjectUpdate] + ) -> ProjectRead: + """Updates a project. Args: project_key: The key of the project. @@ -141,7 +144,8 @@ async def update(self, project_key: str, project_data: ModelInput[ProjectUpdate] 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) @@ -149,8 +153,7 @@ async def update(self, project_key: str, project_data: ModelInput[ProjectUpdate] @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. @@ -160,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 8c98725..c796b69 100644 --- a/permit/api/relationship_tuples.py +++ b/permit/api/relationship_tuples.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,15 +10,13 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput, ModelListInput - -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 ( RelationshipTupleCreate, RelationshipTupleCreateBulkOperation, RelationshipTupleCreateBulkOperationResult, @@ -27,30 +25,31 @@ RelationshipTupleDeleteBulkOperationResult, RelationshipTupleRead, ) +from permit.utils.model_input import ModelInput, ModelListInput 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 - async def list( + 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). @@ -65,7 +64,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) @@ -82,15 +82,18 @@ async def list( return await self.__relationship_tuples.get( "", - model=List[RelationshipTupleRead], + model=list[RelationshipTupleRead], params=params, ) @validate_arguments - async def create(self, tuple_data: ModelInput[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. + async def create( + self, tuple_data: ModelInput[RelationshipTupleCreate] + ) -> RelationshipTupleRead: + """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. @@ -100,23 +103,26 @@ async def create(self, tuple_data: ModelInput[RelationshipTupleCreate]) -> Relat 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 async def delete(self, tuple_data: ModelInput[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) @@ -126,8 +132,7 @@ async def delete(self, tuple_data: ModelInput[RelationshipTupleDelete]) -> None: async def bulk_create( self, tuples: ModelListInput[RelationshipTupleCreate] ) -> RelationshipTupleCreateBulkOperationResult: - """ - Creates multiple relationship tuples at once using the provided tuple data. + """Creates multiple relationship tuples at once using the provided tuple data. Args: tuples: The relationship tuples to create. @@ -147,7 +152,8 @@ async def bulk_create( 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) @@ -161,8 +167,7 @@ async def bulk_create( async def bulk_delete( self, tuples: ModelListInput[RelationshipTupleDelete] ) -> RelationshipTupleDeleteBulkOperationResult: - """ - Deletes multiple relationship tuples at once using the provided tuple data. + """Deletes multiple relationship tuples at once using the provided tuple data. Args: tuples: The relationship tuples to delete. @@ -178,7 +183,8 @@ async def bulk_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/resource_action_groups.py b/permit/api/resource_action_groups.py index e7b3fe6..7fc268d 100644 --- a/permit/api/resource_action_groups.py +++ b/permit/api/resource_action_groups.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,22 +10,19 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput - -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ResourceActionGroupCreate, ResourceActionGroupRead, ResourceActionGroupUpdate, ) +from permit.utils.model_input import ModelInput class ResourceActionGroupsApi(BasePermitApi): + """Manage the action groups of a resource.""" + @property def __action_groups(self) -> SimpleHttpClient: return self._build_http_client( @@ -33,9 +30,10 @@ def __action_groups(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceActionGroupRead]: - """ - Retrieves a list of action groups. + 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. @@ -47,13 +45,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), ) @@ -65,8 +64,7 @@ async def _get(self, resource_key: str, group_key: str) -> ResourceActionGroupRe @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. @@ -77,7 +75,8 @@ 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) @@ -85,8 +84,8 @@ async def get(self, resource_key: str, group_key: str) -> ResourceActionGroupRea @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: @@ -98,7 +97,8 @@ 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) @@ -106,8 +106,8 @@ async def get_by_key(self, resource_key: str, group_key: str) -> ResourceActionG @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: @@ -119,7 +119,8 @@ 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) @@ -129,8 +130,7 @@ async def get_by_id(self, resource_id: str, group_id: str) -> ResourceActionGrou async def create( self, resource_key: str, group_data: ModelInput[ResourceActionGroupCreate] ) -> ResourceActionGroupRead: - """ - Creates a new action group. + """Creates a new action group. Args: resource_key: The key of the resource under which the action group should be created. @@ -141,7 +141,8 @@ async def create( 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) @@ -155,8 +156,7 @@ async def create( async def update( self, resource_key: str, group_key: str, group_data: ModelInput[ResourceActionGroupUpdate] ) -> ResourceActionGroupRead: - """ - Updates an action group. + """Updates an action group. Args: resource_key: The key of the resource the action group belongs to. @@ -168,7 +168,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) @@ -180,8 +181,7 @@ async def update( @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. @@ -189,7 +189,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 d8d3633..0043605 100644 --- a/permit/api/resource_actions.py +++ b/permit/api/resource_actions.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,18 +10,15 @@ else: from pydantic.v1 import validate_arguments +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ResourceActionCreate, ResourceActionRead, ResourceActionUpdate from permit.utils.model_input import ModelInput -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ResourceActionCreate, ResourceActionRead, ResourceActionUpdate - class ResourceActionsApi(BasePermitApi): + """Manage the actions of a resource.""" + @property def __actions(self) -> SimpleHttpClient: return self._build_http_client( @@ -29,9 +26,10 @@ def __actions(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceActionRead]: - """ - Retrieves a list of actions. + 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. @@ -43,23 +41,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 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. @@ -70,7 +70,8 @@ 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) @@ -78,8 +79,8 @@ async def get(self, resource_key: str, action_key: str) -> ResourceActionRead: @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: @@ -91,7 +92,8 @@ 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) @@ -99,8 +101,8 @@ async def get_by_key(self, resource_key: str, action_key: str) -> ResourceAction @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: @@ -112,16 +114,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 - async def create(self, resource_key: str, action_data: ModelInput[ResourceActionCreate]) -> ResourceActionRead: - """ - Creates a new action. + async def create( + self, resource_key: str, action_data: ModelInput[ResourceActionCreate] + ) -> ResourceActionRead: + """Creates a new action. Args: resource_key: The key of the resource under which the action should be created. @@ -132,7 +136,8 @@ async def create(self, resource_key: str, action_data: ModelInput[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) @@ -146,8 +151,7 @@ async def create(self, resource_key: str, action_data: ModelInput[ResourceAction async def update( self, resource_key: str, action_key: str, action_data: ModelInput[ResourceActionUpdate] ) -> ResourceActionRead: - """ - Updates a action. + """Updates a action. Args: resource_key: The key of the resource the action belongs to. @@ -159,7 +163,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,8 +176,7 @@ async def update( @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. @@ -180,7 +184,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 07c4152..4d539d7 100644 --- a/permit/api/resource_attributes.py +++ b/permit/api/resource_attributes.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,22 +10,19 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput - -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ResourceAttributeCreate, ResourceAttributeRead, ResourceAttributeUpdate, ) +from permit.utils.model_input import ModelInput class ResourceAttributesApi(BasePermitApi): + """Manage the attributes of a resource.""" + @property def __attributes(self) -> SimpleHttpClient: return self._build_http_client( @@ -33,9 +30,10 @@ def __attributes(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceAttributeRead]: - """ - Retrieves a list of attributes. + 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. @@ -47,23 +45,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 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. @@ -74,7 +74,8 @@ 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) @@ -82,8 +83,8 @@ async def get(self, resource_key: str, attribute_key: str) -> ResourceAttributeR @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: @@ -95,7 +96,8 @@ 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) @@ -103,8 +105,8 @@ async def get_by_key(self, resource_key: str, attribute_key: str) -> ResourceAtt @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: @@ -116,7 +118,8 @@ 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) @@ -126,8 +129,7 @@ async def get_by_id(self, resource_id: str, attribute_id: str) -> ResourceAttrib async def create( self, resource_key: str, attribute_data: ModelInput[ResourceAttributeCreate] ) -> ResourceAttributeRead: - """ - Creates a new attribute. + """Creates a new attribute. Args: resource_key: The key of the resource under which the attribute should be created. @@ -138,7 +140,8 @@ async def create( 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) @@ -155,8 +158,7 @@ async def update( attribute_key: str, attribute_data: ModelInput[ResourceAttributeUpdate], ) -> ResourceAttributeRead: - """ - Updates a attribute. + """Updates a attribute. Args: resource_key: The key of the resource the attribute belongs to. @@ -168,7 +170,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) @@ -180,8 +183,7 @@ async def update( @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. @@ -189,7 +191,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 1d5360e..d0b5bda 100644 --- a/permit/api/resource_instances.py +++ b/permit/api/resource_instances.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,15 +10,11 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput, ModelListInput +import builtins -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ResourceInstanceCreate, ResourceInstanceCreateBulkOperation, ResourceInstanceCreateBulkOperationResult, @@ -27,50 +23,55 @@ ResourceInstanceRead, ResourceInstanceUpdate, ) +from permit.utils.model_input import ModelInput, ModelListInput 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 - async def list( + 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) @@ -87,7 +88,7 @@ async def list( return await self.__resource_instances.get( "", - model=List[ResourceInstanceRead], + model=list[ResourceInstanceRead], params=params, ) @@ -96,8 +97,7 @@ async def _get(self, instance_key: str) -> ResourceInstanceRead: @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` @@ -109,7 +109,8 @@ 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) @@ -117,8 +118,8 @@ async def get(self, instance_key: str) -> ResourceInstanceRead: @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: @@ -131,7 +132,8 @@ 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) @@ -139,8 +141,8 @@ async def get_by_key(self, instance_key: str) -> ResourceInstanceRead: @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: @@ -151,16 +153,18 @@ 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 - async def create(self, instance_data: ModelInput[ResourceInstanceCreate]) -> ResourceInstanceRead: - """ - Creates a new resource instance. + async def create( + self, instance_data: ModelInput[ResourceInstanceCreate] + ) -> ResourceInstanceRead: + """Creates a new resource instance. Args: instance_data: The data for the new resource instance. @@ -170,18 +174,20 @@ async def create(self, instance_data: ModelInput[ResourceInstanceCreate]) -> Res 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 async def update( self, instance_key: str, instance_data: ModelInput[ResourceInstanceUpdate] ) -> ResourceInstanceRead: - """ - Updates a resource instance. + """Updates a resource instance. Args: instance_key: The resource instance identity. Either `resource_type:instance_key` @@ -194,7 +200,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) @@ -206,11 +213,11 @@ async def update( @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. @@ -219,7 +226,8 @@ 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) @@ -229,8 +237,7 @@ async def delete(self, instance_key: str) -> None: async def bulk_replace( self, resource_instances: ModelListInput[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. @@ -243,7 +250,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) @@ -254,21 +262,24 @@ async def bulk_replace( ) @validate_arguments - async def bulk_delete(self, resource_instances: List[str]) -> ResourceInstanceDeleteBulkOperationResult: - """ - Deletes resource instances in bulk. + 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 736a635..b8bece2 100644 --- a/permit/api/resource_relations.py +++ b/permit/api/resource_relations.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,18 +10,15 @@ else: from pydantic.v1 import validate_arguments +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import PaginatedResultRelationRead, RelationCreate, RelationRead from permit.utils.model_input import ModelInput -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import PaginatedResultRelationRead, RelationCreate, RelationRead - class ResourceRelationsApi(BasePermitApi): + """Manage the relations between resources (ReBAC).""" + @property def __relations(self) -> SimpleHttpClient: return self._build_http_client( @@ -29,9 +26,10 @@ def __relations(self) -> SimpleHttpClient: ) @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. + 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. @@ -44,7 +42,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) @@ -55,12 +54,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 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. @@ -71,17 +71,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 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: @@ -93,7 +93,8 @@ 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) @@ -101,8 +102,8 @@ async def get_by_key(self, resource_key: str, relation_key: str) -> RelationRead @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: @@ -114,16 +115,18 @@ 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 - async def create(self, resource_key: str, relation_data: ModelInput[RelationCreate]) -> RelationRead: - """ - Creates a new relation. + async def create( + self, resource_key: str, relation_data: ModelInput[RelationCreate] + ) -> RelationRead: + """Creates a new relation. Args: resource_key: The key of the resource under which the relation should be created. @@ -134,7 +137,8 @@ async def create(self, resource_key: str, relation_data: ModelInput[RelationCrea 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) @@ -146,8 +150,7 @@ async def create(self, resource_key: str, relation_data: ModelInput[RelationCrea @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. @@ -155,7 +158,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 b88a60f..3a5dc76 100644 --- a/permit/api/resource_roles.py +++ b/permit/api/resource_roles.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,15 +10,11 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput +import builtins -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( AddRolePermissions, DerivedRoleRuleCreate, DerivedRoleRuleDelete, @@ -29,12 +25,11 @@ ResourceRoleRead, ResourceRoleUpdate, ) +from permit.utils.model_input import ModelInput class ResourceRolesApi(BasePermitApi): - """ - Represents the interface for managing resource roles. - """ + """Represents the interface for managing resource roles.""" @property def __resource_roles(self) -> SimpleHttpClient: @@ -43,9 +38,10 @@ def __resource_roles(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, resource_key: str, page: int = 1, per_page: int = 100) -> List[ResourceRoleRead]: - """ - Retrieves a list of resource roles. + 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. @@ -57,23 +53,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 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. @@ -84,7 +82,8 @@ 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) @@ -92,8 +91,8 @@ async def get(self, resource_key: str, role_key: str) -> ResourceRoleRead: @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: @@ -105,7 +104,8 @@ 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) @@ -113,8 +113,8 @@ async def get_by_key(self, resource_key: str, role_key: str) -> ResourceRoleRead @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: @@ -126,16 +126,18 @@ 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 - async def create(self, resource_key: str, role_data: ModelInput[ResourceRoleCreate]) -> ResourceRoleRead: - """ - Creates a new resource role. + async def create( + self, resource_key: str, role_data: ModelInput[ResourceRoleCreate] + ) -> ResourceRoleRead: + """Creates a new resource role. Args: resource_key: The key of the resource under which the role should be created. @@ -146,18 +148,20 @@ async def create(self, resource_key: str, role_data: ModelInput[ResourceRoleCrea 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 async def update( self, resource_key: str, role_key: str, role_data: ModelInput[ResourceRoleUpdate] ) -> ResourceRoleRead: - """ - Updates a resource role. + """Updates a resource role. Args: resource_key: The key of the resource the role belongs to. @@ -169,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) @@ -179,8 +184,7 @@ async def update( @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. @@ -188,16 +192,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 - async def assign_permissions(self, resource_key: str, role_key: str, permissions: List[str]) -> ResourceRoleRead: - """ - Assigns permissions to a resource role. + 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. @@ -213,7 +219,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) @@ -224,9 +231,10 @@ async def assign_permissions(self, resource_key: str, role_key: str, permissions ) @validate_arguments - async def remove_permissions(self, resource_key: str, role_key: str, permissions: List[str]) -> ResourceRoleRead: - """ - Removes permissions from a resource role. + 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. @@ -240,7 +248,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) @@ -254,10 +263,10 @@ async def remove_permissions(self, resource_key: str, role_key: str, permissions async def create_role_derivation( self, resource_key: str, role_key: str, derivation_rule: ModelInput[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. @@ -269,8 +278,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( @@ -283,8 +293,7 @@ async def create_role_derivation( async def delete_role_derivation( self, resource_key: str, role_key: str, derivation_rule: ModelInput[DerivedRoleRuleDelete] ) -> None: - """ - Delete a role derivation. + """Delete a role derivation. Args: resource_key: The key of the resource the role belongs to. @@ -293,7 +302,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) @@ -309,8 +319,7 @@ async def update_role_derivation_conditions( role_key: str, conditions: ModelInput[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. @@ -319,7 +328,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 d2f1947..2cbe868 100644 --- a/permit/api/resources.py +++ b/permit/api/resources.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,18 +10,15 @@ else: from pydantic.v1 import validate_arguments +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ResourceCreate, ResourceRead, ResourceReplace, ResourceUpdate from permit.utils.model_input import ModelInput -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .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( @@ -29,9 +26,8 @@ def __resources(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, page: int = 1, per_page: int = 100) -> List[ResourceRead]: - """ - Retrieves a list of resources. + 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). @@ -42,13 +38,14 @@ 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), ) @@ -57,8 +54,7 @@ async def _get(self, resource_key: str) -> ResourceRead: @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. @@ -68,7 +64,8 @@ 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) @@ -76,8 +73,8 @@ async def get(self, resource_key: str) -> ResourceRead: @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: @@ -88,7 +85,8 @@ 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) @@ -96,8 +94,8 @@ async def get_by_key(self, resource_key: str) -> ResourceRead: @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: @@ -108,7 +106,8 @@ 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) @@ -116,8 +115,7 @@ async def get_by_id(self, resource_id: str) -> ResourceRead: @validate_arguments async def create(self, resource_data: ModelInput[ResourceCreate]) -> ResourceRead: - """ - Creates a new resource. + """Creates a new resource. Args: resource_data: The data for the new resource. @@ -127,16 +125,18 @@ async def create(self, resource_data: ModelInput[ResourceCreate]) -> ResourceRea 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 - async def update(self, resource_key: str, resource_data: ModelInput[ResourceUpdate]) -> ResourceRead: - """ - Updates a resource. + async def update( + self, resource_key: str, resource_data: ModelInput[ResourceUpdate] + ) -> ResourceRead: + """Updates a resource. Args: resource_key: The key of the resource. @@ -147,7 +147,8 @@ async def update(self, resource_key: str, resource_data: ModelInput[ResourceUpda 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) @@ -158,9 +159,10 @@ async def update(self, resource_key: str, resource_data: ModelInput[ResourceUpda ) @validate_arguments - async def replace(self, resource_key: str, resource_data: ModelInput[ResourceReplace]) -> ResourceRead: - """ - Creates a resource if no such resource exists, otherwise completely replaces the resource in place. + async def replace( + self, resource_key: str, resource_data: ModelInput[ResourceReplace] + ) -> ResourceRead: + """Creates a resource, or completely replaces it in place if it already exists. Args: resource_key: The key of the resource. @@ -171,7 +173,8 @@ async def replace(self, resource_key: str, resource_data: ModelInput[ResourceRep 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) @@ -183,15 +186,15 @@ async def replace(self, resource_key: str, resource_data: ModelInput[ResourceRep @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 a607862..372cb98 100644 --- a/permit/api/role_assignments.py +++ b/permit/api/role_assignments.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,53 +10,57 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput, ModelListInput - -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 ( BulkRoleAssignmentReport, BulkRoleUnAssignmentReport, RoleAssignmentCreate, RoleAssignmentRead, RoleAssignmentRemove, ) +from permit.utils.model_input import ModelInput, ModelListInput 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 - async def list( + 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). @@ -65,27 +69,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: @@ -94,14 +96,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 async def assign(self, assignment: ModelInput[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. @@ -111,7 +112,8 @@ async def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssi 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) @@ -119,24 +121,26 @@ async def assign(self, assignment: ModelInput[RoleAssignmentCreate]) -> RoleAssi @validate_arguments async def unassign(self, unassignment: ModelInput[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 - async def bulk_assign(self, assignments: ModelListInput[RoleAssignmentCreate]) -> BulkRoleAssignmentReport: - """ - Assigns multiple roles in bulk using the provided role assignments data. + async def bulk_assign( + self, assignments: ModelListInput[RoleAssignmentCreate] + ) -> BulkRoleAssignmentReport: + """Assigns multiple roles in bulk using the provided role assignments data. + Each role assignment is a tuple of (user, role, tenant). Args: @@ -147,7 +151,8 @@ async def bulk_assign(self, assignments: ModelListInput[RoleAssignmentCreate]) - 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) @@ -158,9 +163,11 @@ async def bulk_assign(self, assignments: ModelListInput[RoleAssignmentCreate]) - ) @validate_arguments - async def bulk_unassign(self, unassignments: ModelListInput[RoleAssignmentRemove]) -> BulkRoleUnAssignmentReport: - """ - Removes multiple role assignments in bulk using the provided unassignment data. + async def bulk_unassign( + self, unassignments: ModelListInput[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: @@ -171,7 +178,8 @@ async def bulk_unassign(self, unassignments: ModelListInput[RoleAssignmentRemove 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 7c3bd92..fb2900e 100644 --- a/permit/api/roles.py +++ b/permit/api/roles.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,27 +10,22 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput +import builtins -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( AddRolePermissions, RemoveRolePermissions, RoleCreate, RoleRead, RoleUpdate, ) +from permit.utils.model_input import ModelInput class RolesApi(BasePermitApi): - """ - Represents the interface for managing roles. - """ + """Represents the interface for managing roles.""" @property def __roles(self) -> SimpleHttpClient: @@ -39,9 +34,8 @@ def __roles(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, page: int = 1, per_page: int = 100) -> List[RoleRead]: - """ - Retrieves a list of roles. + 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). @@ -52,19 +46,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 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. @@ -74,7 +70,8 @@ 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) @@ -82,8 +79,8 @@ async def get(self, role_key: str) -> RoleRead: @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: @@ -94,7 +91,8 @@ 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) @@ -102,8 +100,8 @@ async def get_by_key(self, role_key: str) -> RoleRead: @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: @@ -114,7 +112,8 @@ 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) @@ -122,8 +121,7 @@ async def get_by_id(self, role_id: str) -> RoleRead: @validate_arguments async def create(self, role_data: ModelInput[RoleCreate]) -> RoleRead: - """ - Creates a new role. + """Creates a new role. Args: role_data: The data for the new role. @@ -133,7 +131,8 @@ async def create(self, role_data: ModelInput[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) @@ -141,8 +140,7 @@ async def create(self, role_data: ModelInput[RoleCreate]) -> RoleRead: @validate_arguments async def update(self, role_key: str, role_data: ModelInput[RoleUpdate]) -> RoleRead: - """ - Updates a role. + """Updates a role. Args: role_key: The key of the role. @@ -153,7 +151,8 @@ async def update(self, role_key: str, role_data: ModelInput[RoleUpdate]) -> 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) @@ -161,35 +160,36 @@ async def update(self, role_key: str, role_data: ModelInput[RoleUpdate]) -> Role @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 - async def assign_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Assigns permissions to a role. + 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) @@ -200,20 +200,21 @@ async def assign_permissions(self, role_key: str, permissions: List[str]) -> Rol ) @validate_arguments - async def remove_permissions(self, role_key: str, permissions: List[str]) -> RoleRead: - """ - Removes permissions from a role. + 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 754795e..5f71206 100644 --- a/permit/api/sync_api_client.py +++ b/permit/api/sync_api_client.py @@ -1,25 +1,25 @@ from typing import TYPE_CHECKING -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 # Type checkers read these classes from a generated stub: the SyncClass metaclass # makes their methods blocking at runtime, which they cannot see. @@ -45,64 +45,65 @@ else: 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. @@ -129,136 +130,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 23fb178..11ae144 100644 --- a/permit/api/tenants.py +++ b/permit/api/tenants.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,15 +10,11 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput, ModelListInput +import builtins -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( PaginatedResultUserRead, TenantCreate, TenantCreateBulkOperation, @@ -28,31 +24,31 @@ TenantRead, TenantUpdate, ) +from permit.utils.model_input import ModelInput, ModelListInput 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 - async def list(self, page: int = 1, per_page: int = 100) -> List[TenantRead]: - """ - Retrieves a list of tenants. + 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). @@ -63,16 +59,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 - 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. + 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. @@ -84,7 +84,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) @@ -99,8 +100,7 @@ async def _get(self, tenant_key: str) -> TenantRead: @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. @@ -110,7 +110,8 @@ 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) @@ -118,8 +119,8 @@ async def get(self, tenant_key: str) -> TenantRead: @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: @@ -130,7 +131,8 @@ 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) @@ -138,8 +140,8 @@ async def get_by_key(self, tenant_key: str) -> TenantRead: @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: @@ -150,7 +152,8 @@ 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) @@ -158,8 +161,7 @@ async def get_by_id(self, tenant_id: str) -> TenantRead: @validate_arguments async def create(self, tenant_data: ModelInput[TenantCreate]) -> TenantRead: - """ - Creates a new tenant. + """Creates a new tenant. Args: tenant_data: The data for the new tenant. @@ -169,7 +171,8 @@ async def create(self, tenant_data: ModelInput[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) @@ -177,8 +180,7 @@ async def create(self, tenant_data: ModelInput[TenantCreate]) -> TenantRead: @validate_arguments async def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) -> TenantRead: - """ - Updates a tenant. + """Updates a tenant. Args: tenant_key: The key of the tenant. @@ -189,7 +191,8 @@ async def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) - 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,8 +200,7 @@ async def update(self, tenant_key: str, tenant_data: ModelInput[TenantUpdate]) - @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. @@ -208,7 +210,8 @@ 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) @@ -216,8 +219,7 @@ async def delete(self, tenant_key: str) -> None: @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. @@ -225,16 +227,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 - async def bulk_create(self, tenants: ModelListInput[TenantCreate]) -> TenantCreateBulkOperationResult: - """ - Creates tenants in bulk. + async def bulk_create( + self, tenants: ModelListInput[TenantCreate] + ) -> TenantCreateBulkOperationResult: + """Creates tenants in bulk. Args: tenants: The tenants to create @@ -244,7 +248,8 @@ async def bulk_create(self, tenants: ModelListInput[TenantCreate]) -> TenantCrea 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) @@ -255,19 +260,20 @@ async def bulk_create(self, tenants: ModelListInput[TenantCreate]) -> TenantCrea ) @validate_arguments - async def bulk_delete(self, tenants: List[str]) -> TenantDeleteBulkOperationResult: - """ - Deletes tenants in bulk. + 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 22d9fb1..225b063 100644 --- a/permit/api/user_invites.py +++ b/permit/api/user_invites.py @@ -1,6 +1,6 @@ from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,24 +10,21 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput - -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( ElementsUserInviteApprove, ElementsUserInviteCreate, ElementsUserInviteRead, PaginatedResultElementsUserInviteRead, UserRead, ) +from permit.utils.model_input import ModelInput class UserInvitesApi(BasePermitApi): + """Manage user invites.""" + @property def __user_invites(self) -> SimpleHttpClient: return self._build_http_client( @@ -35,9 +32,10 @@ def __user_invites(self) -> SimpleHttpClient: ) @validate_arguments - async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultElementsUserInviteRead: - """ - Retrieves a list of user invites. + 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). @@ -48,7 +46,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) @@ -60,8 +59,7 @@ async def list(self, page: int = 1, per_page: int = 100) -> PaginatedResultEleme @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. @@ -71,16 +69,18 @@ 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 - async def create(self, user_invite_data: ModelInput[ElementsUserInviteCreate]) -> ElementsUserInviteRead: - """ - Creates a new user invite. + async def create( + self, user_invite_data: ModelInput[ElementsUserInviteCreate] + ) -> ElementsUserInviteRead: + """Creates a new user invite. Args: user_invite_data: The user invite data to create. @@ -90,16 +90,18 @@ async def create(self, user_invite_data: ModelInput[ElementsUserInviteCreate]) - 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 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. @@ -109,16 +111,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 - async def approve(self, user_invite_id: str, approve_data: ModelInput[ElementsUserInviteApprove]) -> UserRead: - """ - Approves a user invite. + async def approve( + self, user_invite_id: str, approve_data: ModelInput[ElementsUserInviteApprove] + ) -> UserRead: + """Approves a user invite. Args: user_invite_id: The ID of the user invite to approve. @@ -129,7 +133,8 @@ async def approve(self, user_invite_id: str, approve_data: ModelInput[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) diff --git a/permit/api/users.py b/permit/api/users.py index a20ed39..47e5317 100644 --- a/permit/api/users.py +++ b/permit/api/users.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast +from typing import TYPE_CHECKING, Any, Union, cast -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -10,15 +10,11 @@ else: from pydantic.v1 import validate_arguments -from permit.utils.model_input import ModelInput, ModelListInput +import builtins -from .base import ( - BasePermitApi, - SimpleHttpClient, - pagination_params, -) -from .context import ApiContextLevel, ApiKeyAccessLevel -from .models import ( +from permit.api.base import BasePermitApi, SimpleHttpClient, pagination_params +from permit.api.context import ApiContextLevel, ApiKeyAccessLevel +from permit.api.models import ( PaginatedResultUserRead, RoleAssignmentCreate, RoleAssignmentRead, @@ -33,49 +29,49 @@ UserReplaceBulkOperationResult, UserUpdate, ) +from permit.utils.model_input import ModelInput, ModelListInput # sync() sends a dict that is not a valid UserCreate as it is, so the annotation # validate_arguments reads keeps the bare `dict` it always had: `Dict[str, Any]` # would copy that dict and coerce its keys. Type checkers get `Dict[str, Any]`, # since pyright's strict mode reports a bare `dict` parameter as partially unknown. if TYPE_CHECKING: - _UserSyncInput = Union[UserCreate, Dict[str, Any]] + _UserSyncInput = UserCreate | dict[str, Any] else: - _UserSyncInput = Union[UserCreate, dict] + # validate_arguments reads this annotation, so it stays exactly as it was. + _UserSyncInput = Union[UserCreate, dict] # noqa: UP007 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 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). @@ -86,7 +82,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) @@ -101,8 +98,7 @@ async def _get(self, user_key: str) -> UserRead: @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. @@ -112,7 +108,8 @@ 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) @@ -120,8 +117,8 @@ async def get(self, user_key: str) -> UserRead: @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: @@ -132,7 +129,8 @@ 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) @@ -140,8 +138,8 @@ async def get_by_key(self, user_key: str) -> UserRead: @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: @@ -152,7 +150,8 @@ 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) @@ -160,8 +159,7 @@ async def get_by_id(self, user_id: str) -> UserRead: @validate_arguments async def create(self, user_data: ModelInput[UserCreate]) -> UserRead: - """ - Creates a new user. + """Creates a new user. Args: user_data: The data for the new user. @@ -171,7 +169,8 @@ async def create(self, user_data: ModelInput[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) @@ -179,8 +178,7 @@ async def create(self, user_data: ModelInput[UserCreate]) -> UserRead: @validate_arguments async def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> UserRead: - """ - Updates a user. + """Updates a user. Args: user_key: The key of the user. @@ -191,7 +189,8 @@ async def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> User 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) @@ -199,8 +198,7 @@ async def update(self, user_key: str, user_data: ModelInput[UserUpdate]) -> User @validate_arguments async def sync(self, user: _UserSyncInput) -> UserRead: - """ - Synchronizes user data by creating or updating a user. + """Synchronizes user data by creating or updating a user. Args: user: The data of the user to be synchronized. @@ -210,29 +208,31 @@ async def sync(self, user: _UserSyncInput) -> 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 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) @@ -240,8 +240,7 @@ async def delete(self, user_key: str) -> None: @validate_arguments async def bulk_create(self, users: ModelListInput[UserCreate]) -> UserCreateBulkOperationResult: - """ - Creates users in bulk. + """Creates users in bulk. Args: users: The users to create @@ -251,7 +250,8 @@ async def bulk_create(self, users: ModelListInput[UserCreate]) -> UserCreateBulk 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) @@ -262,9 +262,10 @@ async def bulk_create(self, users: ModelListInput[UserCreate]) -> UserCreateBulk ) @validate_arguments - async def bulk_replace(self, users: ModelListInput[UserCreate]) -> UserReplaceBulkOperationResult: - """ - Replaces users in bulk. + async def bulk_replace( + self, users: ModelListInput[UserCreate] + ) -> UserReplaceBulkOperationResult: + """Replaces users in bulk. If the user exists - replaces it. Otherwise, creates previously non-existing users. @@ -277,7 +278,8 @@ async def bulk_replace(self, users: ModelListInput[UserCreate]) -> UserReplaceBu 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) @@ -288,19 +290,20 @@ async def bulk_replace(self, users: ModelListInput[UserCreate]) -> UserReplaceBu ) @validate_arguments - async def bulk_delete(self, users: List[str]) -> UserDeleteBulkOperationResult: - """ - Deletes users in bulk. + 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) @@ -312,8 +315,7 @@ async def bulk_delete(self, users: List[str]) -> UserDeleteBulkOperationResult: @validate_arguments async def assign_role(self, assignment: ModelInput[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. @@ -323,12 +325,13 @@ async def assign_role(self, assignment: ModelInput[RoleAssignmentCreate]) -> Rol 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) # validate_arguments has already turned a dict argument into the model. - assignment = cast(RoleAssignmentCreate, assignment) + assignment = cast("RoleAssignmentCreate", assignment) return await self.__users.post( f"/{assignment.user}/roles", model=RoleAssignmentRead, @@ -337,20 +340,20 @@ async def assign_role(self, assignment: ModelInput[RoleAssignmentCreate]) -> Rol @validate_arguments async def unassign_role(self, unassignment: ModelInput[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) # validate_arguments has already turned a dict argument into the model. - unassignment = cast(RoleAssignmentRemove, unassignment) + unassignment = cast("RoleAssignmentRemove", unassignment) return await self.__users.delete( f"/{unassignment.user}/roles", json=unassignment.copy(exclude={"user"}), @@ -360,13 +363,14 @@ async def unassign_role(self, unassignment: ModelInput[RoleAssignmentRemove]) -> 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. @@ -379,7 +383,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) @@ -389,6 +394,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 20f10e5..7a42f94 100644 --- a/permit/config.py +++ b/permit/config.py @@ -1,7 +1,7 @@ -from typing import TYPE_CHECKING, 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 TYPE_CHECKING: # The v1 API is what runs under either pydantic major, so type-check against it. @@ -13,8 +13,14 @@ 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.", @@ -27,30 +33,40 @@ 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.""" + # A positional `...`, not `default=...`: type checkers take any `default=` # keyword as a default, so `PermitConfig()` without a token would pass them. token: str = Field( ..., - 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(default=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( default=MultiTenancyConfig(), description="configuration of default tenant assignment for RBAC", @@ -58,11 +74,11 @@ class PermitConfig(BaseModel): api_context: ApiContext = Field( default=ApiContext(), description="represents the current API key authorization level." ) - api_timeout: Optional[int] = Field( + 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.", ) @@ -70,12 +86,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 538619d..119a5d4 100644 --- a/permit/enforcement/enforcer.py +++ b/permit/enforcement/enforcer.py @@ -1,19 +1,20 @@ import json +from http import HTTPStatus from pprint import pformat -from typing import TYPE_CHECKING, 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 +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. @@ -31,13 +32,17 @@ # `Dict[str, Any]`, since pyright's strict mode reports a bare `dict` in a # signature as partially unknown. if TYPE_CHECKING: - User = Union[Dict[str, Any], str] - Resource = Union[Dict[str, Any], str] + User = dict[str, Any] | str + Resource = dict[str, Any] | str else: - User = Union[dict, str] - Resource = Union[dict, str] + # Public aliases, so the runtime objects stay the `typing.Union`s they were. + User = Union[dict, str] # noqa: UP007 + Resource = Union[dict, str] # noqa: UP007 Action = str +# A resource string is "type" or "type:key". +_MAX_RESOURCE_STRING_PARTS = 2 + async def read_error_body(response: aiohttp.ClientResponse) -> str: """Read an error response body without assuming it is JSON. @@ -61,19 +66,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 = { @@ -84,15 +95,16 @@ def __init__(self, config: PermitConfig): @property def context_store(self) -> ContextStore: - """ - we let context store be accessed from the outside so that the - using app can setup a flexible contextual behavior for authorization queries + """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 @@ -101,24 +113,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') @@ -128,14 +141,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, @@ -146,18 +161,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( @@ -168,7 +187,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 " @@ -176,11 +195,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)}" ) @@ -188,38 +208,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([ { @@ -240,10 +266,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"]) @@ -251,8 +279,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"], @@ -266,9 +296,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( ( @@ -278,7 +308,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}", @@ -286,15 +316,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( ( @@ -304,7 +334,7 @@ async def bulk_check( check.get("action"), check.get("resource"), ] - for check in input + for check in request_body ] ), err, @@ -318,25 +348,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') @@ -349,9 +379,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 = { @@ -367,16 +401,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( @@ -388,7 +425,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 " @@ -396,8 +433,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" @@ -408,24 +446,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, Any], str], - tenants: Optional[List[str]] = None, - resources: Optional[List[str]] = None, - resource_types: Optional[List[str]] = None, - ) -> Dict[str, Any]: + 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, @@ -440,15 +497,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" @@ -459,17 +523,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: @@ -482,20 +550,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]) @@ -506,12 +579,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 @@ -527,8 +606,9 @@ 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)) @@ -539,4 +619,4 @@ def _resource_from_string(resource: str) -> ResourceInput: else: class SyncEnforcer(Enforcer, metaclass=SyncClass): - pass + """Blocking variant of `Enforcer`.""" diff --git a/permit/enforcement/interfaces.py b/permit/enforcement/interfaces.py index b041382..5408208 100644 --- a/permit/enforcement/interfaces.py +++ b/permit/enforcement/interfaces.py @@ -1,6 +1,6 @@ -from typing import TYPE_CHECKING, 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 TYPE_CHECKING: # The v1 API is what runs under either pydantic major, so type-check against it. @@ -12,10 +12,14 @@ 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 @@ -31,11 +35,13 @@ class UserInput(UserKey): class Config: allow_population_by_field_name = True - first_name: Optional[str] = Field(default=None, alias="firstName") - last_name: Optional[str] = Field(default=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 + # A parameterized dict, not a bare one: pydantic v1 validates it into a copy, and + # keeps the caller's object for a bare dict. + attributes: dict[Any, Any] | None = None if TYPE_CHECKING: # Type checkers derive the constructor from the fields and know only the @@ -44,36 +50,44 @@ def __init__( self, *, key: str, - first_name: Optional[str] = None, - firstName: Optional[str] = None, # noqa: N803 - the field's wire alias - last_name: Optional[str] = None, - lastName: Optional[str] = None, # noqa: N803 - the field's wire alias - email: Optional[str] = None, - roles: Optional[List[AssignedRole]] = None, - attributes: Optional[Dict] = None, + first_name: str | None = None, + firstName: str | None = None, # noqa: N803 - the field's wire alias + last_name: str | None = None, + lastName: str | None = None, # noqa: N803 - the field's wire alias + email: str | None = None, + roles: list[AssignedRole] | None = None, + attributes: dict[Any, Any] | None = 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 + # Parameterized dicts: see UserInput.attributes. + 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." @@ -84,6 +98,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 38077de..5a71176 100644 --- a/permit/exceptions.py +++ b/permit/exceptions.py @@ -1,9 +1,12 @@ import functools -from typing import TYPE_CHECKING, 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 @@ -19,37 +22,46 @@ 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. @@ -57,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 @@ -81,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. @@ -99,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. @@ -110,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 @@ -120,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. @@ -129,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 @@ -140,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) @@ -158,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) @@ -180,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: @@ -229,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: @@ -242,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 0685588..1002226 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 7561d32..2919270 100644 --- a/permit/pdp_api/models.py +++ b/permit/pdp_api/models.py @@ -1,12 +1,11 @@ # generated by datamodel-codegen: # filename: open.json (local PDP) # timestamp: 2024-04-09T15:36:45+00:00 - from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING -from ..utils.pydantic_version import PYDANTIC_VERSION +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. @@ -18,10 +17,12 @@ 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( + 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 256bdad..6417858 100644 --- a/permit/pdp_api/pdp_api_client.py +++ b/permit/pdp_api/pdp_api_client.py @@ -1,10 +1,9 @@ from typing import TYPE_CHECKING +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 - # Type checkers read this class from a generated stub: the SyncClass metaclass # makes its methods blocking at runtime, which they cannot see. if TYPE_CHECKING: @@ -16,13 +15,14 @@ else: 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. @@ -38,16 +38,20 @@ def __init__(self, config: PermitConfig): @property def role_assignments(self) -> RoleAssignmentsApi: + """Role assignments as the PDP currently sees them.""" return self._role_assignments # Holds the blocking role assignments client where the async base holds the async # one, which breaks substitutability on purpose, hence the ignores. 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) # type: ignore[assignment] @property def role_assignments(self) -> SyncRoleAssignmentsApi: # type: ignore[override] + """Role assignments as the PDP currently sees them.""" return self._role_assignments # type: ignore[return-value] diff --git a/permit/pdp_api/role_assignments.py b/permit/pdp_api/role_assignments.py index 804151a..614acc2 100644 --- a/permit/pdp_api/role_assignments.py +++ b/permit/pdp_api/role_assignments.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING from permit.api.base import SimpleHttpClient from permit.pdp_api.base import BasePdpPermitApi, pagination_params @@ -15,30 +15,34 @@ 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 - async def list( + 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). @@ -47,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) @@ -62,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 14da8bf..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) @@ -37,8 +46,8 @@ def __init__(self, config: Optional[PermitConfig] = None, **options): @property def config(self) -> PermitConfig: - """ - Access the SDK configuration using this property. + """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) -> PermitConfig: @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[str, Any]: - """ - 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 8a229d0..c2e066a 100644 --- a/permit/sync.py +++ b/permit/sync.py @@ -1,19 +1,19 @@ -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 blocking client keeps the blocking twins of the async client's helpers in the @@ -21,7 +21,15 @@ # That breaks substitutability on purpose, hence the assignment, override and # return-value ignores below. 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) # type: ignore[assignment] self._api = SyncPermitApiClient(self._config) # type: ignore[assignment] @@ -30,8 +38,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: @@ -42,8 +49,7 @@ def api(self) -> SyncPermitApiClient: # type: ignore[override] @property def elements(self) -> SyncElementsApi: # type: ignore[override] - """ - Access the Permit Elements API using this property. + """Access the Permit Elements API using this property. Usage example: @@ -54,8 +60,7 @@ def elements(self) -> SyncElementsApi: # type: ignore[override] @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="") @@ -65,24 +70,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([ { @@ -109,25 +116,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') @@ -144,24 +151,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') @@ -171,18 +179,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[str, Any]: - """ - 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 @@ -201,10 +208,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 @@ -214,7 +220,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 5f2d4af..15f4731 100644 --- a/permit/utils/deprecation.py +++ b/permit/utils/deprecation.py @@ -1,6 +1,7 @@ +from collections.abc import Callable from functools import wraps from inspect import iscoroutinefunction -from typing import Any, Callable, TypeVar, cast +from typing import Any, TypeVar, cast from warnings import warn from permit.utils.sync import _blocking_call_site @@ -9,14 +10,25 @@ def deprecated(message: str) -> Callable[[_F], _F]: + """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: _F) -> _F: @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: + def wrapper(*args: Any, **kwargs: Any) -> object: warn(message, DeprecationWarning, stacklevel=2) return func(*args, **kwargs) @wraps(func) - async def async_wrapper(*args: Any, **kwargs: Any) -> Any: + async def async_wrapper(*args: Any, **kwargs: Any) -> object: call_site = _blocking_call_site.get() if call_site is None: warn(message, DeprecationWarning, stacklevel=2) @@ -28,8 +40,7 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # Either wrapper takes and returns what func does, so callers keep func's type. if iscoroutinefunction(func): - return cast(_F, async_wrapper) - else: - return cast(_F, wrapper) + return cast("_F", async_wrapper) + return cast("_F", 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/model_input.py b/permit/utils/model_input.py index cfe209b..3c0c58a 100644 --- a/permit/utils/model_input.py +++ b/permit/utils/model_input.py @@ -1,9 +1,12 @@ -from typing import TYPE_CHECKING, Any, Dict, List, Sequence, TypeVar, Union +from typing import TYPE_CHECKING, Any, TypeVar if TYPE_CHECKING: + from collections.abc import Sequence + from typing import TypeAlias + _Model = TypeVar("_Model") - ModelInput = Union[_Model, Dict[str, Any]] + ModelInput: TypeAlias = _Model | dict[str, Any] """Annotation for an SDK method parameter that takes a model or an equivalent dict. Methods decorated with ``validate_arguments`` validate a dict argument into the @@ -11,7 +14,7 @@ that call if the annotation also allows a dict. """ - ModelListInput = Sequence[Union[_Model, Dict[str, Any]]] + ModelListInput: TypeAlias = Sequence[_Model | dict[str, Any]] """Annotation for a bulk parameter that takes a list of models or equivalent dicts. A ``Sequence``, not a ``List``: ``List`` is invariant, so a type checker would @@ -33,12 +36,12 @@ def __class_getitem__(cls, model: type) -> type: return model class ModelListInput: - """Runtime twin of the type-checking alias: ``ModelListInput[X]`` is ``List[X]``. + """Runtime twin of the type-checking alias: ``ModelListInput[X]`` is ``list[X]``. ``validate_arguments`` must keep building a list of validated models, as it did before this annotation existed. Given ``Sequence[X]`` it would, for one, hand the method a tuple when the caller passed a tuple. """ - def __class_getitem__(cls, model: type) -> Any: - return List[model] + def __class_getitem__(cls, model: type) -> object: + return list[model] diff --git a/permit/utils/sync.py b/permit/utils/sync.py index 9a1a313..9e7572e 100644 --- a/permit/utils/sync.py +++ b/permit/utils/sync.py @@ -3,13 +3,20 @@ import inspect import sys import warnings +from collections.abc import Awaitable, Callable, Coroutine from concurrent.futures import ThreadPoolExecutor from contextvars import ContextVar from functools import wraps from types import FrameType -from typing import Any, Awaitable, Callable, Coroutine, Dict, NamedTuple, Optional, Set, Type, TypeVar, cast +from typing import ( + Any, + NamedTuple, + TypeGuard, + TypeVar, + cast, +) -from typing_extensions import ParamSpec, TypeGuard +from typing_extensions import ParamSpec P = ParamSpec("P") T = TypeVar("T") @@ -28,10 +35,10 @@ class _CallSite(NamedTuple): filename: str lineno: int - module_globals: Dict[str, Any] + module_globals: dict[str, Any] @classmethod - def from_frame(cls, frame: Optional[FrameType]) -> "_CallSite": + def from_frame(cls, frame: FrameType | None) -> "_CallSite": """The line `frame` is running, or, with no frame, the place `warnings.warn` blames then. There is no frame when C code calls the blocking method directly, as it does an @@ -41,7 +48,7 @@ def from_frame(cls, frame: Optional[FrameType]) -> "_CallSite": return cls("", 0, sys.__dict__) return cls(frame.f_code.co_filename, frame.f_lineno, frame.f_globals) - def warn(self, message: str, category: Type[Warning]) -> None: + def warn(self, message: str, category: type[Warning]) -> None: """Issue a warning attributed to this line, exactly as `warnings.warn` would from its frame. The module name and the once-per-line registry come from the calling module, as @@ -65,7 +72,9 @@ def warn(self, message: str, category: Type[Warning]) -> None: ) -_blocking_call_site: ContextVar[Optional[_CallSite]] = ContextVar("permit_blocking_call_site", default=None) +_blocking_call_site: ContextVar[_CallSite | None] = ContextVar( + "permit_blocking_call_site", default=None +) """The line that made the blocking call whose coroutine runs in this context, otherwise None. The coroutine runs under asyncio, whose frames stand between it and that line, so code in it @@ -109,7 +118,8 @@ def run_coroutine_sync(coroutine: Coroutine[Any, Any, T]) -> T: Returns: Whatever the coroutine returns. """ - return _run_blocking(coroutine, _CallSite.from_frame(sys._getframe(0).f_back)) + caller = sys._getframe(0).f_back # noqa: SLF001 - the documented way to read a caller's frame + return _run_blocking(coroutine, _CallSite.from_frame(caller)) def async_to_sync(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]: @@ -130,14 +140,17 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T: if _blocking_call_site.get() is not None: return func(*args, **kwargs) # type: ignore[return-value] # Read in the caller's thread, while its frame is the one that called us. - call_site = _CallSite.from_frame(sys._getframe(0).f_back) + caller = sys._getframe(0).f_back # noqa: SLF001 - see run_coroutine_sync + call_site = _CallSite.from_frame(caller) return _run_blocking(func(*args, **kwargs), call_site) 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 @@ -153,8 +166,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): @@ -178,7 +191,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): @@ -191,7 +205,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 576d106..d177b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,19 +77,20 @@ Repository = "https://github.com/permitio/permit-python" # Exact pins, so every developer, CI lane and the dev-ceiling audit tree # resolve the same versions. Dependabot raises them. A pin also gives the CVE # scan a version to evaluate: a spec with no bound has none, so a package listed -# that way is absent from every audit. The ruff and mypy hooks in -# .pre-commit-config.yaml install their own copies at their revs, and those are -# what CI lints and type-checks with. Dependabot bumps the ruff and mypy pins -# here but not the hook revs, so the two can differ. +# that way is absent from every audit. These pins are the only place the ruff, +# mypy and typos versions are set: their pre-commit hooks are `repo: local` and +# run the copies `uv run --locked` installs from uv.lock, so CI lints and +# type-checks with exactly these versions, and a Dependabot bump of a pin moves +# the hook with it. # aioresponses is left out on purpose. No test imports it, and its latest # release (0.7.9) is incompatible with the aiohttp 3.14.3 floor: every mocked # request raises "ClientResponse.__init__() missing 1 required keyword-only # argument: 'stream_writer'". Offline HTTP tests use pytest-httpserver, which # asserts on real request bodies. dev = [ - # tests/test_typing_surface.py runs mypy on tests/type_check/consumer.py; - # 1.11.2 passes it on Python 3.10-3.14 with either pydantic major. - "mypy==1.11.2", + # Also what tests/test_typing_surface.py runs on tests/type_check/consumer.py; + # 2.3.1 passes it on Python 3.10-3.14 with either pydantic major. + "mypy==2.3.1", # Imported directly by the offline tests, which evaluate the version markers # in [project].dependencies the way an installer does. "packaging==26.3", @@ -100,12 +101,13 @@ dev = [ "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-httpserver==1.1.5", - "ruff==0.6.9", + "ruff==0.16.8", # The offline tests and the migration skill's tests read [project].dependencies # from this file, and tomllib is in the standard library only from Python 3.11. # The marker says == "3.10" rather than < "3.11", which is the same under # requires-python, because Dependabot skips a requirement whose marker has `<`. 'tomli==2.4.1; python_version == "3.10"', + "typos==1.50.2", # The uv version CI runs: every setup-uv step reads it from uv.lock # (version-file), except the publish build job, which pins its own version # and checksum. Dependabot bumps it like any other pin. The uv-lock pre-commit @@ -162,62 +164,200 @@ testpaths = ["tests"] markers = [ 'e2e: needs PDP_API_KEY (or another credential), the Permit API and a running PDP. Deselect with -m "not e2e".', ] +# strict_config, strict_markers, strict_xfail and strict_parametrization_ids. +strict = true +# Any warning fails the test that raised it. The one exception is the warning +# `import permit` issues on pydantic 1 on purpose (tests/test_fix_pydantic1_deprecation.py +# checks it in a fresh interpreter). +filterwarnings = [ + "error", + "ignore:Support for pydantic 1 is deprecated:DeprecationWarning", +] [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. +# The migration skill's sample apps are the scanner's test input, written the +# way a permit 2.x project is; their exact text is what the tests assert on. +extend-exclude = ["permit/api/models.py", "skills/tests/fixtures"] +# 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.per-file-target-version] +# The migration scanner runs on the project being migrated, before it has moved +# off Python 3.8 or 3.9 (its docstring says so, and CI runs it on 3.9), so no +# fix may use syntax newer than 3.8. +"skills/permit-python-3-migration/scripts/*.py" = "py38" + +[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", + # `from module import X as X` is how a module re-exports a name to type + # checkers (PEP 484; mypy's strict mode has no implicit re-export). The SDK + # does this for the blocking classes it declares in permit/_sync_types.pyi. + "PLC0414", ] +[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"] +# Generated by scripts/generate_sync_stubs.py. Its docstrings are copied from the async +# methods on purpose: they are what an editor shows for the blocking client. +"permit/_sync_types.pyi" = ["PYI021"] +"{tests,skills/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 + "S603", # subprocesses run the interpreter under test with the test's own arguments +] # 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,scripts,skills/permit-python-3-migration/scripts}/*.py" = [ + "T201", + "INP001", # standalone scripts run by path, not an importable package +] +".github/scripts/test_*.py" = ["S101", "PLR2004", "D1"] +# The migration skill's tests are run by path with their own pytest.ini, like +# the CI scripts' tests, not imported as a package. +"skills/tests/*.py" = ["INP001"] +# The migration scanner runs on Python 3.8, where builtin generics fail at runtime, and +# keeps its annotations evaluated as written rather than add a __future__ import (FA100). +# Each of its checks walks the syntax cases it recognizes in one function; split up, a +# rule would be scattered over helpers that mean nothing on their own. Its literals are +# version components (3.10's minor, the parts `~=` needs) and argument counts (PLR2004). +"skills/permit-python-3-migration/scripts/scan.py" = [ + "FA100", + "C901", + "PLR0911", + "PLR0912", + "PLR2004", +] +# A user's code as mypy sees it (tests/test_typing_surface.py): a file mypy is +# pointed at, not a module of the tests package. +"tests/type_check/*.py" = ["INP001"] + +[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.typos.default.extend-words] +# The certifi package, which the migration guide lists among httpx's dependencies. +certifi = "certifi" [tool.mypy] python_version = "3.10" -packages = ["permit"] -# The SDK's models are pydantic v1 models under both pydantic majors, and type -# checkers see them through pydantic.v1. pydantic.mypy is the v2 plugin under -# pydantic 2 and does not recognise v1 models, so use the v1 plugin. +files = ["permit", "tests", ".github/scripts", "scripts", "skills"] +# The sample apps are the migration scanner's test input (see [tool.ruff]). +exclude = ["^skills/tests/fixtures/"] +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"] -check_untyped_defs = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true -warn_unreachable = true +[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 +# Installed only on Python 3.10 (see the dev group), where the standard library has +# no tomllib. mypy reads that branch for python_version 3.10 on any interpreter. +module = ["tomli"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +# These tests declare classes with `metaclass=SyncClass`, which makes their async +# methods blocking at runtime. mypy sees only the `async def` signatures, so every +# call looks like it returns a coroutine. +module = ["tests.test_fix_sync"] +disable_error_code = ["comparison-overlap", "unused-coroutine"] diff --git a/scripts/generate_sync_stubs.py b/scripts/generate_sync_stubs.py index c94b200..6396fed 100644 --- a/scripts/generate_sync_stubs.py +++ b/scripts/generate_sync_stubs.py @@ -31,7 +31,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] STUB_PATH = REPO_ROOT / "permit" / "_sync_types.pyi" -LINE_LENGTH = 120 +LINE_LENGTH = 100 INDENT = " " # The stub is a single namespace, so runtime classes that share a name need distinct stub names. @@ -51,10 +51,12 @@ class StubError(Exception): def qualified_name(cls: type) -> str: + """``cls``'s module and qualified name, e.g. ``permit.api.users.SyncUsersApi``.""" return f"{cls.__module__}.{cls.__qualname__}" def stub_name(cls: type) -> str: + """The name ``cls`` has in the stub, which is one namespace for every module.""" return STUB_NAMES.get(qualified_name(cls), cls.__name__) @@ -64,7 +66,9 @@ def introduces_sync_class(value: object) -> bool: Subclasses of such a class inherit the metaclass (``SyncPermitApiClient`` does), but they are ordinary classes whose own source type checkers can read. """ - return isinstance(value, SyncClass) and not any(isinstance(base, SyncClass) for base in value.__bases__) + return isinstance(value, SyncClass) and not any( + isinstance(base, SyncClass) for base in value.__bases__ + ) def sync_classes() -> list[type]: @@ -78,20 +82,24 @@ def sync_classes() -> list[type]: names = [stub_name(cls) for cls in found.values()] duplicates = sorted({name for name in names if names.count(name) > 1}) if duplicates: - raise StubError(f"Stub class names collide: {duplicates}. Add an entry to STUB_NAMES.") + msg = f"Stub class names collide: {duplicates}. Add an entry to STUB_NAMES." + raise StubError(msg) return [found[key] for key in sorted(found)] def module_tree(module_name: str) -> tuple[str, ast.Module]: + """The source of an imported module and its syntax tree.""" source = Path(inspect.getfile(sys.modules[module_name])).read_text() return source, ast.parse(source) def class_node(tree: ast.Module, name: str) -> ast.ClassDef: + """The definition of class ``name`` in ``tree``.""" for node in ast.walk(tree): if isinstance(node, ast.ClassDef) and node.name == name: return node - raise StubError(f"class {name} not found in its module's source") + msg = f"class {name} not found in its module's source" + raise StubError(msg) def type_checking_statements(tree: ast.Module) -> list[ast.stmt]: @@ -106,6 +114,7 @@ def type_checking_statements(tree: ast.Module) -> list[ast.stmt]: def converted_names(sync_cls: type) -> set[str]: + """The public methods the ``SyncClass`` metaclass made blocking on ``sync_cls``.""" return { name for name in dir(sync_cls) @@ -116,32 +125,47 @@ def converted_names(sync_cls: type) -> set[str]: def async_class(sync_cls: type) -> type: """The async class a sync class converts, checking the shape the generator relies on.""" if len(sync_cls.__bases__) != 1: - raise StubError(f"{qualified_name(sync_cls)} must have exactly one base, the async class") + msg = f"{qualified_name(sync_cls)} must have exactly one base, the async class" + raise StubError(msg) (async_cls,) = sync_cls.__bases__ _, tree = module_tree(sync_cls.__module__) - body = class_node(tree, sync_cls.__name__).body - if not all(isinstance(node, ast.Pass) for node in body): - raise StubError(f"{qualified_name(sync_cls)} must have an empty body; the stub only mirrors its async base") + node = class_node(tree, sync_cls.__name__) + body = node.body[1:] if ast.get_docstring(node) is not None else node.body + if not all(isinstance(statement, ast.Pass) for statement in body): + msg = ( + f"{qualified_name(sync_cls)} must have an empty body (a docstring at most); " + "the stub only mirrors its async base" + ) + raise StubError(msg) for base in async_cls.__bases__: coroutines = sorted( - name for name in dir(base) if not name.startswith("_") and iscoroutine_func(getattr(base, name)) + name + for name in dir(base) + if not name.startswith("_") and iscoroutine_func(getattr(base, name)) ) if coroutines: - raise StubError(f"{qualified_name(base)} has public coroutine methods {coroutines}; the stub subclasses it") + msg = ( + f"{qualified_name(base)} has public coroutine methods {coroutines}; " + "the stub subclasses it" + ) + raise StubError(msg) return async_cls def is_simple_default(node: ast.expr) -> bool: + """Whether a default is a literal a stub can spell out (a number, string, bool or None).""" if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): node = node.operand return isinstance(node, ast.Constant) and not isinstance(node.value, bytes) def stub_default(node: ast.expr) -> str: + """A default as the stub writes it: the literal itself, or ``...``.""" return ast.unparse(node) if is_simple_default(node) else "..." def parameter(arg: ast.arg, default: ast.expr | None, prefix: str = "") -> str: + """One parameter as the stub writes it, with its annotation and default.""" text = prefix + arg.arg if arg.annotation is not None: text += f": {ast.unparse(arg.annotation)}" @@ -153,8 +177,11 @@ def parameter(arg: ast.arg, default: ast.expr | None, prefix: str = "") -> str: def parameters(args: ast.arguments) -> list[str]: + """Every parameter of a signature, with the ``/`` and ``*`` markers it needs.""" positional = args.posonlyargs + args.args - defaults: list[ast.expr | None] = [None] * (len(positional) - len(args.defaults)) + list(args.defaults) + defaults: list[ast.expr | None] = [None] * (len(positional) - len(args.defaults)) + list( + args.defaults + ) parts = [parameter(arg, default) for arg, default in zip(positional, defaults, strict=True)] if args.posonlyargs: parts.insert(len(args.posonlyargs), "/") @@ -162,7 +189,10 @@ def parameters(args: ast.arguments) -> list[str]: parts.append(parameter(args.vararg, None, "*")) elif args.kwonlyargs: parts.append("*") - parts.extend(parameter(arg, default) for arg, default in zip(args.kwonlyargs, args.kw_defaults, strict=True)) + parts.extend( + parameter(arg, default) + for arg, default in zip(args.kwonlyargs, args.kw_defaults, strict=True) + ) if args.kwarg is not None: parts.append(parameter(args.kwarg, None, "**")) return parts @@ -180,15 +210,20 @@ def signature_lines(head: str, params: list[str], tail: str, indent: str) -> lis return [f"{indent}{head}(", *(f"{inner}{param}," for param in params), f"{indent}){tail}"] -def docstring_lines(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, source: str, indent: str) -> list[str]: +def docstring_lines( + node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, source: str, indent: str +) -> list[str]: + """``node``'s docstring as its source spells it, re-indented for the stub, if it has one.""" if ast.get_docstring(node, clean=False) is None: return [] expr = node.body[0] if expr.col_offset != len(indent): - raise StubError(f"docstring of {node.name} is not indented {len(indent)} spaces") + msg = f"docstring of {node.name} is not indented {len(indent)} spaces" + raise StubError(msg) segment = ast.get_source_segment(source, expr) if segment is None or expr.end_lineno is None or expr.end_col_offset is None: - raise StubError(f"cannot read the docstring source of {node.name}") + msg = f"cannot read the docstring source of {node.name}" + raise StubError(msg) # Keep a trailing comment: a noqa directive there covers every line of the docstring. last_line = source.splitlines()[expr.end_lineno - 1].encode() trailing = last_line[expr.end_col_offset :].decode().strip() @@ -197,18 +232,21 @@ def docstring_lines(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef, def decorator_name(node: ast.expr) -> str: + """A decorator's dotted name, without the call arguments of a decorator factory.""" target = node.func if isinstance(node, ast.Call) else node return ast.unparse(target) def function_lines(node: ast.FunctionDef | ast.AsyncFunctionDef, source: str) -> list[str]: + """A method as a blocking stub ``def``: its typing decorators, signature and docstring.""" lines = [] for decorator in node.decorator_list: name = decorator_name(decorator) if name in TYPING_DECORATORS or name.endswith(".setter"): lines.append(f"{INDENT}@{ast.unparse(decorator)}") elif name not in TRANSPARENT_DECORATORS: - raise StubError(f"{node.name}: unknown decorator @{name}; classify it in the generator") + msg = f"{node.name}: unknown decorator @{name}; classify it in the generator" + raise StubError(msg) tail = f" -> {ast.unparse(node.returns)}:" if node.returns is not None else ":" body_indent = INDENT * 2 docstring = docstring_lines(node, source, body_indent) @@ -220,40 +258,68 @@ def function_lines(node: ast.FunctionDef | ast.AsyncFunctionDef, source: str) -> def annotation_nodes(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]: + """The annotations and typing decorators of a method, whose names the stub must import.""" args = node.args - every_arg = args.posonlyargs + args.args + args.kwonlyargs + [a for a in (args.vararg, args.kwarg) if a] + every_arg = ( + args.posonlyargs + args.args + args.kwonlyargs + [a for a in (args.vararg, args.kwarg) if a] + ) nodes = [arg.annotation for arg in every_arg if arg.annotation is not None] if node.returns is not None: nodes.append(node.returns) nodes.extend( decorator for decorator in node.decorator_list - if decorator_name(decorator) in TYPING_DECORATORS or decorator_name(decorator).endswith(".setter") + if decorator_name(decorator) in TYPING_DECORATORS + or decorator_name(decorator).endswith(".setter") ) return nodes def referenced_names(nodes: list[ast.expr]) -> set[str]: + """The bare names used in ``nodes``, which the stub must import or get from builtins.""" names: set[str] = set() for root in nodes: for node in ast.walk(root): if isinstance(node, ast.Name): names.add(node.id) elif isinstance(node, ast.Constant) and isinstance(node.value, str): - raise StubError(f"string annotation {node.value!r} is not supported; use the name directly") + msg = f"string annotation {node.value!r} is not supported; use the name directly" + raise StubError(msg) return names -def resolve(module_name: str, tree: ast.Module, name: str) -> tuple[str, str] | None: - """Where a type checker finds ``name`` as used in ``module_name``: (module, attribute), or None for a builtin.""" +def import_location( + node: ast.Import | ast.ImportFrom, name: str, package: str +) -> tuple[str, str | None] | None: + """Where the import statement ``node`` gets ``name``, in ``resolve``'s terms, or None.""" + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname is None and alias.name == name: + return alias.name, None + return None + for alias in node.names: + if (alias.asname or alias.name) == name: + source = importlib.util.resolve_name("." * node.level + (node.module or ""), package) + return source, alias.name + return None + + +def resolve(module_name: str, tree: ast.Module, name: str) -> tuple[str, str | None] | None: + """Where a type checker finds ``name`` as used in ``module_name``. + + (module, attribute) for a name imported from a module or defined in one, (module, None) + for a module imported whole (``import builtins``), or None for a builtin. + """ package = module_name.rpartition(".")[0] for node in type_checking_statements(tree): - if isinstance(node, ast.ImportFrom): - for alias in node.names: - if (alias.asname or alias.name) == name: - source = importlib.util.resolve_name("." * node.level + (node.module or ""), package) - return source, alias.name - elif isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: + if isinstance(node, (ast.Import, ast.ImportFrom)): + location = import_location(node, name, package) + if location is not None: + return location + elif ( + isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == name + ): return module_name, name elif isinstance(node, (ast.Assign, ast.AnnAssign)): targets = node.targets if isinstance(node, ast.Assign) else [node.target] @@ -261,11 +327,12 @@ def resolve(module_name: str, tree: ast.Module, name: str) -> tuple[str, str] | return module_name, name if hasattr(builtins, name): return None - raise StubError(f"cannot find where {module_name} gets {name!r}") + msg = f"cannot find where {module_name} gets {name!r}" + raise StubError(msg) def member_sort_key(name: str) -> tuple[int, str]: - """isort's order-by-type: constants, then classes, then everything else.""" + """The order isort's order-by-type uses: constants, then classes, then everything else.""" if name.isupper() and len(name) > 1: return 0, name if name[0].isupper(): @@ -273,22 +340,55 @@ def member_sort_key(name: str) -> tuple[int, str]: return 2, name -def import_block(imports: dict[str, set[str]]) -> str: - """``from module import names`` lines, grouped and ordered the way ruff's isort rules want.""" +def import_block(imports: dict[str, set[str | None]]) -> str: + """The import lines, grouped and ordered the way ruff's isort rules want. + + A None among a module's names stands for the module itself (``import module``). + Within a section, ``import module`` lines come before ``from module import`` ones. + """ + whole: dict[int, list[str]] = defaultdict(list) sections: dict[int, list[str]] = defaultdict(list) for module in sorted(imports): top = module.partition(".")[0] section = 0 if top in sys.stdlib_module_names else 2 if top == "permit" else 1 - names = sorted(imports[module], key=member_sort_key) + if None in imports[module]: + whole[section].append(f"import {module}") + names = sorted((n for n in imports[module] if n is not None), key=member_sort_key) + if not names: + continue line = f"from {module} import {', '.join(names)}" if len(line) <= LINE_LENGTH: sections[section].append(line) else: - sections[section].append(f"from {module} import (\n" + "".join(f"{INDENT}{n},\n" for n in names) + ")") - return "\n\n".join("\n".join(sections[key]) for key in sorted(sections)) + sections[section].append( + f"from {module} import (\n" + "".join(f"{INDENT}{n},\n" for n in names) + ")" + ) + return "\n\n".join( + "\n".join(whole[key] + sections[key]) for key in sorted(set(whole) | set(sections)) + ) + + +def record_imports( + async_cls: type, + tree: ast.Module, + annotations: list[ast.expr], + imports: dict[str, set[str | None]], +) -> list[str]: + """Add what the stub class needs to ``imports``, and return its base class names.""" + bases = [] + for base in async_cls.__bases__: + if base is not object: + bases.append(base.__name__) + imports[base.__module__].add(base.__name__) + for name in sorted(referenced_names(annotations)): + location = resolve(async_cls.__module__, tree, name) + if location is not None: + imports[location[0]].add(location[1]) + return bases -def class_lines(sync_cls: type, imports: dict[str, set[str]]) -> list[str]: +def class_lines(sync_cls: type, imports: dict[str, set[str | None]]) -> list[str]: + """The stub class for ``sync_cls``; the imports it needs are added to ``imports``.""" async_cls = async_class(sync_cls) source, tree = module_tree(async_cls.__module__) node = class_node(tree, async_cls.__name__) @@ -307,22 +407,25 @@ def class_lines(sync_cls: type, imports: dict[str, set[str]]) -> list[str]: targets = member.targets if isinstance(member, ast.Assign) else [member.target] public = [ast.unparse(t) for t in targets if not ast.unparse(t).startswith("_")] if public: - raise StubError(f"{async_cls.__name__} has class attributes {public}; teach the generator to copy them") + msg = ( + f"{async_cls.__name__} has class attributes {public}; " + "teach the generator to copy them" + ) + raise StubError(msg) missing = sorted(converted - emitted) if missing: - raise StubError(f"{qualified_name(sync_cls)} converts {missing}, which {async_cls.__name__} does not define") - - bases = [] - for base in async_cls.__bases__: - if base is not object: - bases.append(base.__name__) - imports[base.__module__].add(base.__name__) - for name in sorted(referenced_names(annotations)): - location = resolve(async_cls.__module__, tree, name) - if location is not None: - imports[location[0]].add(location[1]) + msg = ( + f"{qualified_name(sync_cls)} converts {missing}, " + f"which {async_cls.__name__} does not define" + ) + raise StubError(msg) - head = f"class {stub_name(sync_cls)}({', '.join(bases)}):" if bases else f"class {stub_name(sync_cls)}:" + bases = record_imports(async_cls, tree, annotations, imports) + head = ( + f"class {stub_name(sync_cls)}({', '.join(bases)}):" + if bases + else f"class {stub_name(sync_cls)}:" + ) return [head, *(body or [f"{INDENT}..."])] @@ -332,10 +435,11 @@ def render_stub() -> str: if imported_from != STUB_PATH.parent: msg = ( f"imported permit from {imported_from}, not {STUB_PATH.parent}; run " - f"`uv run python scripts/generate_sync_stubs.py` in {REPO_ROOT}, or set PYTHONPATH={REPO_ROOT}" + f"`uv run python scripts/generate_sync_stubs.py` in {REPO_ROOT}, " + f"or set PYTHONPATH={REPO_ROOT}" ) raise StubError(msg) - imports: dict[str, set[str]] = defaultdict(set) + imports: dict[str, set[str | None]] = defaultdict(set) classes = [class_lines(sync_cls, imports) for sync_cls in sync_classes()] parts = [HEADER, import_block(imports)] parts.extend("\n".join(lines) for lines in classes) @@ -343,6 +447,7 @@ def render_stub() -> str: def main() -> None: + """Write the stub for the SDK in this working tree.""" STUB_PATH.write_text(render_stub()) diff --git a/skills/permit-python-3-migration/references/changes.md b/skills/permit-python-3-migration/references/changes.md index b9d3d56..67a0ae1 100644 --- a/skills/permit-python-3-migration/references/changes.md +++ b/skills/permit-python-3-migration/references/changes.md @@ -34,7 +34,7 @@ the site to the user with the recommendation, and apply what they choose). or `uv pip compile`, recognised by its header or `# via` lines) that pins permit below 3. - Edit: `permit>=3.0.0,<4`. Regenerate the lock file with the project's own tool (`uv lock`, `poetry lock`, `pipenv lock`, `pip-compile`, or the command in a compiled file's header); never - edit a lock by hand. **SAFE.** A direct URL or unparseable spec is **NEEDS-REVIEW**. + edit a lock by hand. **SAFE.** A direct URL or unparsable spec is **NEEDS-REVIEW**. ## Compatibility diff --git a/skills/permit-python-3-migration/scripts/scan.py b/skills/permit-python-3-migration/scripts/scan.py index 0b7eceb..97b7183 100755 --- a/skills/permit-python-3-migration/scripts/scan.py +++ b/skills/permit-python-3-migration/scripts/scan.py @@ -62,7 +62,10 @@ "get_user": ("api.users.get", {}), "get_role": ("api.roles.get", {}), "get_tenant": ("api.tenants.get", {}), - "get_assigned_roles": ("api.users.get_assigned_roles", {"user_key": "user", "tenant_key": "tenant"}), + "get_assigned_roles": ( + "api.users.get_assigned_roles", + {"user_key": "user", "tenant_key": "tenant"}, + ), "get_resource": ("api.resources.get", {}), "list_roles": ("api.roles.list", {}), "sync_user": ("api.users.sync", {}), @@ -85,7 +88,14 @@ NOW_SYNC_METHODS = {"authorized_users", "get_user_permissions", "filter_objects"} PAGE_FIELDS = {"data", "total_count", "page_count"} -COROUTINE_RUNNERS = {"run", "run_until_complete", "gather", "create_task", "ensure_future", "wait_for"} +COROUTINE_RUNNERS = { + "run", + "run_until_complete", + "gather", + "create_task", + "ensure_future", + "wait_for", +} # Modules whose import aliases the scan follows: permit, and asyncio for its runners. TRACED_MODULES = {"permit", "asyncio"} @@ -98,13 +108,19 @@ _PYDANTIC_FIX = "import it from pydantic.v1, which permit 3's pydantic floors always provide" _VERSION_FIX = "import PYDANTIC_VERSION from permit.utils.pydantic_version" REMOVED: Dict[Tuple[str, str], Tuple[str, str, str]] = { - ("permit.api.context", "ApiKeyLevel"): ("A3", SAFE, "use ApiKeyAccessLevel, which has the same members"), + ("permit.api.context", "ApiKeyLevel"): ( + "A3", + SAFE, + "use ApiKeyAccessLevel, which has the same members", + ), ("permit.enforcement.interfaces", "JWT"): ("A3", SAFE, "JWT was an alias of str; use str"), ("permit.utils.context", "ContextTransform"): ( "A3", REVIEW, - "removed with ContextStore.register_transform(); use Callable[[Dict[str, Any]], Dict[str, Any]] if you " - "still need the type", + ( + "removed with ContextStore.register_transform(); use Callable[[Dict[str, Any]], " + "Dict[str, Any]] if you still need the type" + ), ), ("permit.api.elements", "LoginAsErrorMessages"): ( "A3", @@ -119,7 +135,11 @@ ("permit", "PYDANTIC_VERSION"): ("A6", SAFE, _VERSION_FIX), ("permit.api.models", "PYDANTIC_VERSION"): ("A6", SAFE, _VERSION_FIX), ("permit.pdp_api.base", "PYDANTIC_VERSION"): ("A6", SAFE, _VERSION_FIX), - ("permit.enforcement.enforcer", "set_if_not_none"): ("A6", SAFE, "removed; copy the three-line helper"), + ("permit.enforcement.enforcer", "set_if_not_none"): ( + "A6", + SAFE, + "removed; copy the three-line helper", + ), ("permit.pdp_api.base", "T"): ("A6", SAFE, _TYPEVAR_FIX), ("permit.pdp_api.base", "TModel"): ("A6", SAFE, _TYPEVAR_FIX), ("permit.pdp_api.base", "TData"): ("A6", SAFE, _TYPEVAR_FIX), @@ -132,14 +152,27 @@ ("permit.utils.context", "List"): ("A6", SAFE, _TYPING_FIX), ("permit.api.resource_relations", "List"): ("A6", SAFE, _TYPING_FIX), ("permit.api.elements", "Enum"): ("A6", SAFE, "import it from enum"), - ("permit.api.deprecated", "RoleAssignmentsApi"): ("A6", SAFE, "import it from permit.api.role_assignments"), + ("permit.api.deprecated", "RoleAssignmentsApi"): ( + "A6", + SAFE, + "import it from permit.api.role_assignments", + ), ("permit.utils.sync", "iscoroutinefunction"): ("A6", SAFE, "import it from inspect"), } AUDIT_LOG_MODELS = {"AuditLogModel", "DetailedAuditLogModel"} TUPLE_MODELS = {"RelationshipTupleRead", "RelationshipTupleDetailedRead"} -TUPLE_OPTIONAL_FIELDS = {"object_id", "subject_details", "relation_details", "object_details", "tenant_details"} -NEW_ENUM_MEMBERS = {"Engine": ("A4", "Engine.GENERIC"), "APIKeyOwnerType": ("A5", "APIKeyOwnerType.nats_pdp_config")} +TUPLE_OPTIONAL_FIELDS = { + "object_id", + "subject_details", + "relation_details", + "object_details", + "tenant_details", +} +NEW_ENUM_MEMBERS = { + "Engine": ("A4", "Engine.GENERIC"), + "APIKeyOwnerType": ("A5", "APIKeyOwnerType.nats_pdp_config"), +} # pydantic 2 method -> (pydantic 1 method, keywords the two share). V2_METHODS: Dict[str, Tuple[str, Set[str]]] = { @@ -149,14 +182,26 @@ ), "model_dump_json": ( "json", - {"include", "exclude", "by_alias", "exclude_unset", "exclude_defaults", "exclude_none", "indent"}, + { + "include", + "exclude", + "by_alias", + "exclude_unset", + "exclude_defaults", + "exclude_none", + "indent", + }, ), "model_validate": ("parse_obj", set()), "model_validate_json": ("parse_raw", set()), "model_copy": ("copy", {"update", "deep"}), "model_json_schema": ("schema", {"by_alias", "ref_template"}), } -V2_ATTRIBUTES = {"model_fields_set": "__fields_set__", "model_fields": "__fields__", "model_config": "__config__"} +V2_ATTRIBUTES = { + "model_fields_set": "__fields_set__", + "model_fields": "__fields__", + "model_config": "__config__", +} REQUEST_MODEL_SUFFIXES = ("Create", "Update", "Remove", "Delete", "Replace") # Packages permit 2.x installed and 3.0.0 does not: httpx and zipp, which it declared, and the @@ -196,7 +241,9 @@ "aiohttp": "aiohttp>=3.14.3,<4", "typing-extensions": "typing-extensions>=4.14.0,<5", "loguru": "loguru>=0.7.3,<1", - "pydantic": "pydantic>=1.10.18,<2 or >=2.4.2 (>=2.8.0 on Python 3.13; >=1.10.25,<2 or >=2.13 on 3.14)", + "pydantic": ( + "pydantic>=1.10.18,<2 or >=2.4.2 (>=2.8.0 on Python 3.13; >=1.10.25,<2 or >=2.13 on 3.14)" + ), } SKIP_DIRS = { @@ -223,6 +270,8 @@ class Finding(NamedTuple): + """One affected site: where it is, which change it is and whether the edit is SAFE.""" + path: str line: int change: str @@ -231,6 +280,7 @@ class Finding(NamedTuple): def normalize_name(name: str) -> str: + """The PEP 503 normalized form of a distribution name.""" return re.sub(r"[-_.]+", "-", name).lower() @@ -240,6 +290,7 @@ def normalize_name(name: str) -> str: def parse_version(text: str) -> Optional[Version]: + """The leading numeric release of a version string, e.g. (3, 10) for "3.10rc1".""" match = re.match(r"\s*v?(\d+(?:\.\d+)*)", text) if match is None: return None @@ -252,11 +303,13 @@ def _padded(left: Version, right: Version) -> Tuple[Version, Version]: def compare(left: Version, right: Version) -> int: + """-1, 0 or 1 as `left` is below, equal to or above `right`; missing parts count as 0.""" left, right = _padded(left, right) return (left > right) - (left < right) def bump(version: Version, index: int) -> Version: + """The version with component `index` raised by one and the components after it dropped.""" index = max(0, min(index, len(version) - 1)) return (*version[:index], version[index] + 1) @@ -332,23 +385,26 @@ def _intersects(bounds: List[Bound], low: Optional[Version], high: Optional[Vers return order < 0 or (order == 0 and lower[1] and upper[1]) -def intersects(alternatives: List[List[Bound]], low: Optional[Version], high: Optional[Version]) -> bool: +def intersects( + alternatives: List[List[Bound]], low: Optional[Version], high: Optional[Version] +) -> bool: """Whether some version in [low, high) satisfies the parsed specifier.""" return any(_intersects(bounds, low, high) for bounds in alternatives) def python_below_310(spec: str) -> bool: + """Whether the specifier allows a Python older than 3.10.""" alternatives = parse_spec(spec) return alternatives is not None and intersects(alternatives, None, (3, 10)) def minors_below_310(text: str) -> List[str]: """Python versions like 3.9 or 3.9.18 in text that are older than 3.10.""" - found = [] - for match in re.finditer(r"(? List[str]: class Requirement(NamedTuple): + """A declared requirement: where it is, its normalized name, specifier and text.""" + path: str line: int name: str @@ -391,11 +449,13 @@ def __init__(self) -> None: self.findings: List[Finding] = [] def add_requirement(self, path: str, line: int, text: str) -> None: + """Record a requirement found at `path:line`, if it parses.""" parsed = split_requirement(text) if parsed is not None: self.requirements.append(Requirement(path, line, parsed[0], parsed[1], text.strip())) def add_python_pin(self, path: str, line: int, text: str, *, below: bool) -> None: + """Record a Python version pin; one below 3.10 is also a C1 finding.""" self.python_pins.append((path, line, text.strip())) if below: self.findings.append( @@ -409,9 +469,11 @@ def add_python_pin(self, path: str, line: int, text: str, *, below: bool) -> Non ) def declared(self) -> Set[str]: + """The normalized names of every declared requirement.""" return {requirement.name for requirement in self.requirements} def pins_pydantic1(self) -> bool: + """Whether some pydantic requirement allows no pydantic 2 release.""" for requirement in self.requirements: if requirement.name != "pydantic": continue @@ -425,6 +487,7 @@ def pins_pydantic1(self) -> bool: def requirement_lines(lines: List[str]) -> Iterator[Tuple[int, str]]: + """(line number, requirement) for each requirement line, without comments or options.""" for number, raw in enumerate(lines, 1): text = re.split(r"\s#", raw, maxsplit=1)[0].strip() if text and not text.startswith(("#", "-")): @@ -432,12 +495,17 @@ def requirement_lines(lines: List[str]) -> Iterator[Tuple[int, str]]: def scan_requirements_txt(facts: ProjectFacts, rel: str, lines: List[str]) -> None: + """Record a requirements file's requirements, or the permit pin of a compiled one.""" if any(_COMPILED_RE.search(line) for line in lines): # pip-compile or `uv pip compile` output is a lock: its pins follow from the requirements it # was compiled from, and `httpx==... # via permit` is not the project declaring httpx. for number, text in requirement_lines(lines): parsed = split_requirement(text) - pinned = re.match(r"^===?\s*([^\s,;]+)$", parsed[1]) if parsed and parsed[0] == "permit" else None + pinned = ( + re.match(r"^===?\s*([^\s,;]+)$", parsed[1]) + if parsed and parsed[0] == "permit" + else None + ) if pinned: _locked_permit(facts, rel, number, pinned.group(1), compiled=True) return @@ -451,7 +519,7 @@ def scan_requirements_txt(facts: ProjectFacts, rel: str, lines: List[str]) -> No def _quoted(text: str) -> List[str]: - return [double if double else single for double, single in _QUOTED_RE.findall(text)] + return [double or single for double, single in _QUOTED_RE.findall(text)] def _unquoted(text: str) -> str: @@ -514,14 +582,21 @@ def close_block() -> None: array_key = key current_key = array_key if array_key is not None else key - if pipfile and table in ("packages", "dev-packages") and key is not None and array_key is None: + if ( + pipfile + and table in ("packages", "dev-packages") + and key is not None + and array_key is None + ): spec = _table_value_spec(value) if spec is not None: facts.add_requirement(rel, number, f"{key} {'' if spec == '*' else spec}") elif not pipfile and _poetry_table(table) and key is not None and array_key is None: spec = _table_value_spec(value) if key == "python" and spec is not None: - facts.add_python_pin(rel, number, f'python = "{spec}"', below=python_below_310(spec)) + facts.add_python_pin( + rel, number, f'python = "{spec}"', below=python_below_310(spec) + ) elif spec is not None: facts.add_requirement(rel, number, f"{key} {'' if spec == '*' else spec}") elif current_key is not None and _requirement_key(table, current_key): @@ -530,7 +605,9 @@ def close_block() -> None: if table == "project" and key == "requires-python": for spec in _quoted(value): - facts.add_python_pin(rel, number, f'requires-python = "{spec}"', below=python_below_310(spec)) + facts.add_python_pin( + rel, number, f'requires-python = "{spec}"', below=python_below_310(spec) + ) if table == "project" and current_key == "classifiers": scan_classifiers(facts, rel, number, text) if (pipfile and table == "requires") or table in ("tool.mypy", "tool.pyright"): @@ -544,6 +621,7 @@ def close_block() -> None: def scan_classifiers(facts: ProjectFacts, rel: str, number: int, text: str) -> None: + """Record a `Programming Language :: Python :: 3.x` classifier as a Python pin.""" match = re.search(r"Programming Language :: Python :: (3\.\d+)", text) if match: version = match.group(1) @@ -553,26 +631,34 @@ def scan_classifiers(facts: ProjectFacts, rel: str, number: int, text: str) -> N def scan_version_setting(facts: ProjectFacts, rel: str, number: int, raw: str) -> None: """python_version (mypy, Pipfile), pythonVersion (pyright) and python_full_version.""" match = re.match( - r"^\s*[\"']?(python_version|pythonVersion|python_full_version)[\"']?\s*[:=]\s*[\"']?(\d+\.\d+)", raw + r"^\s*[\"']?(python_version|pythonVersion|python_full_version)[\"']?\s*[:=]\s*[\"']?(\d+\.\d+)", + raw, ) if match: - facts.add_python_pin(rel, number, raw.strip().rstrip(","), below=bool(minors_below_310(match.group(2)))) + facts.add_python_pin( + rel, number, raw.strip().rstrip(","), below=bool(minors_below_310(match.group(2))) + ) -_ERROR_FILTER_RE = re.compile(r"(?:^|[\s\"',\[=])(?:-W\s*)?error(?:::(?:DeprecationWarning|Warning))?(?=$|[\s\"',\]])") +_ERROR_FILTER_RE = re.compile( + r"(?:^|[\s\"',\[=])(?:-W\s*)?error(?:::(?:DeprecationWarning|Warning))?(?=$|[\s\"',\]])" +) # A warning filter for permit 2.x's deprecation text, "use permit.api.users.get() instead". Filters # match from the start of the message, which in permit 3 is "permit.api.get_user() is deprecated". OLD_D2_TEXT_RE = re.compile(r"(?:^|:)\s*use permit\\?\.(?:api|elements)\b") OLD_D2_FILTER = ( - 'this warning filter matches permit 2.x\'s deprecation text ("use permit.api....() instead"). permit 3 ' - 'warns "permit.api.() is deprecated and will be removed in permit 4.0; ...", which it does not ' - "match: delete it once the calls are migrated, or match `permit\\.api\\.\\w+\\(\\) is deprecated` instead" + 'this warning filter matches permit 2.x\'s deprecation text ("use permit.api....() instead"). ' + "permit 3 " + 'warns "permit.api.() is deprecated and will be removed in permit 4.0; ...", which it ' + "does not match: delete it once the calls are migrated, or match `permit\\.api\\.\\w+\\(\\) is " + "deprecated` instead" ) def scan_pytest_setting(facts: ProjectFacts, rel: str, number: int, text: str) -> None: + """Note warnings-as-errors filters, and filters for permit 2.x's deprecation text.""" if "Support for pydantic 1" in text: facts.pydantic1_filter_present = True if _ERROR_FILTER_RE.search(text): @@ -582,14 +668,23 @@ def scan_pytest_setting(facts: ProjectFacts, rel: str, number: int, text: str) - def scan_mypy_override_block(facts: ProjectFacts, rel: str, block: List[Tuple[int, str]]) -> None: + """Flag a [[tool.mypy.overrides]] block that hides permit's missing types.""" permit_lines = [number for number, raw in block if re.search(r"[\"']permit(\.\*)?[\"']", raw)] hides = any( - re.match(r"^\s*(ignore_missing_imports\s*=\s*true|follow_imports\s*=\s*[\"']skip[\"'])", raw) + re.match( + r"^\s*(ignore_missing_imports\s*=\s*true|follow_imports\s*=\s*[\"']skip[\"'])", raw + ) for _, raw in block ) if permit_lines and hides: facts.findings.append( - Finding(rel, permit_lines[0], "T1", SAFE, "permit ships py.typed now: remove permit from this override") + Finding( + rel, + permit_lines[0], + "T1", + SAFE, + "permit ships py.typed now: remove permit from this override", + ) ) @@ -604,7 +699,13 @@ def scan_ini(facts: ProjectFacts, rel: str, lines: List[str]) -> None: def close_section() -> None: if hides_permit: facts.findings.append( - Finding(rel, section_line, "T1", SAFE, "permit ships py.typed now: remove permit from this section") + Finding( + rel, + section_line, + "T1", + SAFE, + "permit ships py.typed now: remove permit from this section", + ) ) for number, raw in enumerate(lines, 1): @@ -628,13 +729,21 @@ def close_section() -> None: value = stripped modules = ( - [module.strip() for module in section[len("mypy-") :].split(",")] if section.startswith("mypy-") else [] + [module.strip() for module in section[len("mypy-") :].split(",")] + if section.startswith("mypy-") + else [] ) - if any(module == "permit" or module.startswith("permit.") for module in modules) and re.match( - r"^(ignore_missing_imports\s*=\s*true|follow_imports\s*=\s*skip)", stripped, re.IGNORECASE + if any( + module == "permit" or module.startswith("permit.") for module in modules + ) and re.match( + r"^(ignore_missing_imports\s*=\s*true|follow_imports\s*=\s*skip)", + stripped, + re.IGNORECASE, ): hides_permit = True - requirement_value = (section == "options" and key == "install_requires") or section == "options.extras_require" + requirement_value = ( + section == "options" and key == "install_requires" + ) or section == "options.extras_require" if requirement_value and value: facts.add_requirement(rel, number, value) if section == "options" and key == "python_requires" and not continuation: @@ -654,11 +763,18 @@ def close_section() -> None: def scan_setup_py(facts: ProjectFacts, rel: str, tree: ast.AST) -> None: + """Record setup()'s requirements, python_requires and classifiers.""" for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func - name = func.attr if isinstance(func, ast.Attribute) else func.id if isinstance(func, ast.Name) else "" + name = ( + func.attr + if isinstance(func, ast.Attribute) + else func.id + if isinstance(func, ast.Name) + else "" + ) if name != "setup": continue for keyword in node.keywords: @@ -672,7 +788,10 @@ def scan_setup_py(facts: ProjectFacts, rel: str, tree: ast.AST) -> None: elif keyword.arg == "python_requires" and isinstance(keyword.value, ast.Constant): spec = str(keyword.value.value) facts.add_python_pin( - rel, keyword.value.lineno, f'python_requires="{spec}"', below=python_below_310(spec) + rel, + keyword.value.lineno, + f'python_requires="{spec}"', + below=python_below_310(spec), ) elif keyword.arg == "classifiers": for element in _string_elements(keyword.value): @@ -682,13 +801,15 @@ def scan_setup_py(facts: ProjectFacts, rel: str, tree: ast.AST) -> None: def _string_elements(node: ast.AST) -> List[ast.Constant]: if isinstance(node, (ast.List, ast.Tuple)): return [ - element for element in node.elts if isinstance(element, ast.Constant) and isinstance(element.value, str) + element + for element in node.elts + if isinstance(element, ast.Constant) and isinstance(element.value, str) ] return [] def scan_lock(facts: ProjectFacts, rel: str, lines: List[str]) -> None: - """Only the locked permit version; the other pins in a lock file follow from the requirements.""" + """Only the locked permit version: a lock's other pins follow from the requirements.""" for number, raw in enumerate(lines, 1): if re.match(r"^\s*name\s*=\s*\"permit\"\s*$", raw): for offset, following in enumerate(lines[number : number + 3], 1): @@ -704,15 +825,18 @@ def scan_lock(facts: ProjectFacts, rel: str, lines: List[str]) -> None: break -def _locked_permit(facts: ProjectFacts, rel: str, number: int, version_text: str, *, compiled: bool = False) -> None: +def _locked_permit( + facts: ProjectFacts, rel: str, number: int, version_text: str, *, compiled: bool = False +) -> None: facts.locked_permit.append((rel, number, version_text)) version = parse_version(version_text) if version is None or compare(version, (3,)) >= 0: return if compiled: message = ( - f"compiled requirements (a lock) pin permit {version_text}: don't edit this file; raise the " - "requirement it is compiled from, then regenerate it with the command in its header" + f"compiled requirements (a lock) pin permit {version_text}: don't edit this file; " + "raise the requirement it is compiled from, then regenerate it with the command in its " + "header" ) else: message = f"locks permit {version_text}; regenerate the lock after raising the requirement" @@ -732,11 +856,13 @@ def scan_python_version_file(facts: ProjectFacts, rel: str, lines: List[str]) -> _IMAGE_RE = re.compile(r"python:(\d+)\.(\d+)") -_CI_KEY_RE = re.compile(r"^\s*-?\s*[\"']?(python[-_ ]?versions?|python)[\"']?\s*:\s*(.*)$", re.IGNORECASE) +_CI_KEY_RE = re.compile( + r"^\s*-?\s*[\"']?(python[-_ ]?versions?|python)[\"']?\s*:\s*(.*)$", re.IGNORECASE +) def scan_ci_or_dockerfile(facts: ProjectFacts, rel: str, lines: List[str]) -> None: - """Python versions in CI configuration and Dockerfiles: images, python-version keys and lists.""" + """Python versions in CI configuration and Dockerfiles: images, version keys and lists.""" list_indent: Optional[int] = None for number, raw in enumerate(lines, 1): stripped = raw.strip() @@ -746,7 +872,9 @@ def scan_ci_or_dockerfile(facts: ProjectFacts, rel: str, lines: List[str]) -> No if list_indent is not None: if stripped.startswith("-") and indent >= list_indent: if re.search(r"(? No if env: versions.extend(re.findall(r"(? Optional[str]: def is_none(node: Optional[ast.AST]) -> bool: + """Whether `node` is the literal None.""" return isinstance(node, ast.Constant) and node.value is None def bound_name(node: ast.AST) -> Optional[str]: - """The name `node` binds, if it is a binding site: a target, parameter, import, def or except.""" + """The name `node` binds as a binding site: a target, parameter, import, def or except.""" if isinstance(node, ast.Name): return node.id if isinstance(node.ctx, (ast.Store, ast.Del)) else None if isinstance(node, ast.arg): @@ -813,9 +944,11 @@ def is_async_mock(node: ast.AST) -> bool: def async_mock_message(method: str) -> str: + """The finding for an AsyncMock that may stand in for a now-blocking method.""" return ( - f"if this AsyncMock stands in for permit.sync.Permit.{method}(), use Mock or MagicMock with the same " - "return_value: the method returns its result in 3.0, and an AsyncMock hands the code a coroutine" + f"if this AsyncMock stands in for permit.sync.Permit.{method}(), use Mock or MagicMock " + "with the same return_value: the method returns its result in 3.0, and an AsyncMock hands " + "the code a coroutine" ) @@ -858,8 +991,10 @@ def optional_annotation(node: Optional[ast.AST]) -> bool: def guards(test: ast.AST, key: str, *, none_check: bool = True) -> bool: - """Whether `test` being true means the value at `key` is usable: truthy, an isinstance() - match, or (when `none_check`) `is not None`.""" + """Whether `test` being true means the value at `key` is usable. + + That is: truthy, an isinstance() match, or (when `none_check`) `is not None`. + """ if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.And): return any(guards(value, key, none_check=none_check) for value in test.values) if ( @@ -869,7 +1004,12 @@ def guards(test: ast.AST, key: str, *, none_check: bool = True) -> bool: and is_none(test.comparators[0]) ): return none_check and dotted(test.left) == key - if isinstance(test, ast.Call) and isinstance(test.func, ast.Name) and test.func.id == "isinstance" and test.args: + if ( + isinstance(test, ast.Call) + and isinstance(test.func, ast.Name) + and test.func.id == "isinstance" + and test.args + ): return dotted(test.args[0]) == key return dotted(test) == key @@ -918,6 +1058,7 @@ def __init__(self, rel: str, source: bytes, tree: ast.Module, project: "Project" self.mentions_tuples = False def run(self) -> List[Finding]: + """Scan the file and return its findings.""" self.collect_bindings() self.collect_imports() self.trace_values() @@ -939,14 +1080,19 @@ def run(self) -> List[Finding]: # -- helpers --------------------------------------------------------------- def add(self, node: ast.AST, change: str, safety: str, message: str) -> None: + """Record a finding at `node`'s line.""" self.findings.append(Finding(self.rel, getattr(node, "lineno", 1), change, safety, message)) def source_of(self, node: ast.AST) -> str: + """`node`'s source text, or `...` when it cannot be recovered.""" segment = ast.get_source_segment(self.text, node) return segment if segment is not None else "..." def qualname(self, node: ast.AST) -> Optional[str]: - """The permit name `node` refers to, through import aliases: SP.api -> permit.sync.Permit.api.""" + """The permit name `node` refers to through import aliases. + + After `from permit.sync import Permit as SP`, `SP.api` is `permit.sync.Permit.api`. + """ if isinstance(node, ast.Name): slot = self.slot(node) return self.imported.get(slot) if slot is not None else None @@ -974,7 +1120,10 @@ def enclosing_scopes(self, node: ast.AST) -> Iterator[ast.AST]: parent = self.parents.get(node) while parent is not None: if ( - (isinstance(parent, ast.arguments) and (child in parent.defaults or child in parent.kw_defaults)) + ( + isinstance(parent, ast.arguments) + and (child in parent.defaults or child in parent.kw_defaults) + ) or (isinstance(parent, ast.arg) and child is parent.annotation) or ( isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)) @@ -982,7 +1131,11 @@ def enclosing_scopes(self, node: ast.AST) -> Iterator[ast.AST]: ) or ( isinstance(parent, ast.ClassDef) - and (child in parent.bases or child in parent.keywords or child in parent.decorator_list) + and ( + child in parent.bases + or child in parent.keywords + or child in parent.decorator_list + ) ) ): skip = True @@ -997,7 +1150,7 @@ def enclosing_scopes(self, node: ast.AST) -> Iterator[ast.AST]: child, parent = parent, self.parents.get(parent) def innermost_scope(self, site: ast.AST) -> ast.AST: - """The scope a binding site binds in. A walrus in a comprehension binds in the enclosing one.""" + """The scope a binding site binds in; a walrus in a comprehension binds outside it.""" parent = self.parents.get(site) walrus = isinstance(parent, ast.NamedExpr) and parent.target is site for scope in self.enclosing_scopes(site): @@ -1024,13 +1177,14 @@ def resolve(self, node: ast.AST, name: str) -> ast.AST: return self.tree def enclosing_class(self, node: ast.AST) -> Optional[ast.ClassDef]: + """The class `node` is in, if any.""" current = self.parents.get(node) while current is not None and not isinstance(current, ast.ClassDef): current = self.parents.get(current) return current def slot(self, node: ast.AST) -> Optional[Slot]: - """Where the value `node` names is bound: a name's scope, or the class for self.x and cls.x.""" + """Where the value `node` names is bound: its scope, or the class for self.x and cls.x.""" key = dotted(node) if key is None: return None @@ -1057,7 +1211,9 @@ def collect_bindings(self) -> None: """Which names each scope binds, and every site that binds a slot.""" for node in ast.walk(self.tree): if isinstance(node, (ast.Global, ast.Nonlocal)): - declared = self.declared_global if isinstance(node, ast.Global) else self.declared_nonlocal + declared = ( + self.declared_global if isinstance(node, ast.Global) else self.declared_nonlocal + ) declared.setdefault(id(self.innermost_scope(node)), set()).update(node.names) sites: List[Tuple[str, ast.AST]] = [] for node in ast.walk(self.tree): @@ -1066,7 +1222,9 @@ def collect_bindings(self) -> None: continue sites.append((name, node)) scope = id(self.innermost_scope(node)) - elsewhere = self.declared_global.get(scope, set()) | self.declared_nonlocal.get(scope, set()) + elsewhere = self.declared_global.get(scope, set()) | self.declared_nonlocal.get( + scope, set() + ) if name not in elsewhere: self.bound.setdefault(scope, set()).add(name) for name, node in sites: @@ -1077,7 +1235,11 @@ def collect_bindings(self) -> None: for slot in slots: self.sites.setdefault(slot, set()).add(id(node)) for node in ast.walk(self.tree): - if isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Store) and not self.binds_none(node): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.ctx, ast.Store) + and not self.binds_none(node) + ): for slot in self.target_slots(node): self.sites.setdefault(slot, set()).add(id(node)) @@ -1093,21 +1255,28 @@ def binds_none(self, target: ast.AST) -> bool: # -- traced values --------------------------------------------------------- def bind(self, table: Dict[Slot, Set[int]], target: ast.AST) -> None: + """Record in `table` that `target` binds its slots here.""" for slot in self.target_slots(target): table.setdefault(slot, set()).add(id(target)) def bind_client(self, target: ast.AST, kind: str) -> None: + """Record that `target` binds a client of `kind` here.""" for slot in self.target_slots(target): self.client_sites.setdefault(slot, {})[id(target)] = kind def mark(self, table: Set[Slot], target: ast.AST) -> None: + """Add the slot `target` binds to `table`.""" slot = self.slot(target) if slot is not None: table.add(slot) def holds_only(self, slot: Optional[Slot], site_ids: Optional[Set[int]]) -> bool: """Whether the traced sites account for every site that binds the slot.""" - return slot is not None and bool(site_ids) and self.sites.get(slot, set()) <= (site_ids or set()) + return ( + slot is not None + and bool(site_ids) + and self.sites.get(slot, set()) <= (site_ids or set()) + ) def client_kind(self, node: ast.AST) -> Optional[str]: """ASYNC, SYNC, EITHER or MAYBE when `node` is traced to a permit client, otherwise None.""" @@ -1127,9 +1296,11 @@ def client_kind(self, node: ast.AST) -> Optional[str]: return combined(set(assigned.values())) def is_client(self, node: ast.AST) -> bool: + """Whether `node` is traced to a permit client.""" return self.client_kind(node) in CLIENTS def class_kind(self, annotation: Optional[ast.AST]) -> Optional[str]: + """ASYNC, SYNC or EITHER for an annotation naming the permit clients, else None.""" if annotation is None: return None kinds = set() @@ -1142,11 +1313,12 @@ def class_kind(self, annotation: Optional[ast.AST]) -> Optional[str]: return combined(kinds) if kinds else None def is_api_handle(self, node: ast.AST) -> bool: + """Whether `node` holds a client's `.api` wherever it is bound.""" slot = self.slot(node) return slot is not None and self.holds_only(slot, self.handle_sites.get(slot)) def client_behind(self, func: ast.AST) -> Optional[ast.AST]: - """The client expression before `.api`, `.elements`, `.pdp_api` or `.authorized_users` in a chain.""" + """The client before `.api`, `.elements`, `.pdp_api` or `.authorized_users` in a chain.""" node = func while isinstance(node, ast.Attribute): if node.attr in CLIENT_MEMBERS and self.is_client(node.value): @@ -1166,7 +1338,7 @@ def api_receiver(self, func: ast.Attribute) -> Tuple[Optional[ast.AST], bool]: return None, False def is_api_call(self, node: ast.AST) -> bool: - """A call through a traced client that returns an SDK model, such as permit.api.users.get().""" + """A call through a traced client that returns an SDK model, like permit.api.users.get().""" if isinstance(node, ast.Await): node = node.value if not isinstance(node, ast.Call): @@ -1179,13 +1351,19 @@ def is_api_call(self, node: ast.AST) -> bool: return self.is_api_handle(root) def sdk_class(self, node: ast.AST) -> Optional[str]: - """The qualified name when `node` is a class imported from permit, other than the clients.""" + """The qualified name of a class `node` imported from permit, other than the clients.""" name = self.qualname(node) - if name is None or not name.startswith("permit.") or name in ASYNC_CLIENTS or name in SYNC_CLIENTS: + if ( + name is None + or not name.startswith("permit.") + or name in ASYNC_CLIENTS + or name in SYNC_CLIENTS + ): return None return name if name.rsplit(".", 1)[-1][:1].isupper() else None def is_sdk_value(self, node: ast.AST) -> bool: + """Whether `node` is an SDK class, an API call's result, or a name holding one.""" if self.sdk_class(node) is not None or self.is_api_call(node): return True slot = self.slot(node) @@ -1196,11 +1374,13 @@ def is_sdk_value(self, node: ast.AST) -> bool: # -- imports --------------------------------------------------------------- def import_as(self, alias: ast.alias, qualname: str) -> None: + """Record that the name `alias` binds refers to `qualname`.""" name = bound_name(alias) if name is not None: self.imported[(id(self.resolve(alias, name)), name)] = qualname def collect_imports(self) -> None: + """Follow the permit and asyncio imports, and check each permit import.""" for node in ast.walk(self.tree): if isinstance(node, ast.Import): for alias in node.names: @@ -1232,7 +1412,7 @@ def collect_imports(self) -> None: self.check_model_import(node, node.module, alias.name) def check_import_comment(self, node: ast.stmt) -> None: - """An ignore comment on a permit import: SAFE to drop when it only silenced the missing types.""" + """An ignore on a permit import: SAFE to drop when it only silenced the missing types.""" end = node.end_lineno or node.lineno for number in range(node.lineno, end + 1): line = self.lines[number - 1] if number <= len(self.lines) else "" @@ -1242,7 +1422,13 @@ def check_import_comment(self, node: ast.stmt) -> None: codes = {code.strip() for code in (match.group(1) or "").split(",") if code.strip()} if codes <= IMPORT_IGNORE_CODES: self.findings.append( - Finding(self.rel, number, "T1", SAFE, "permit ships py.typed now: remove this ignore comment") + Finding( + self.rel, + number, + "T1", + SAFE, + "permit ships py.typed now: remove this ignore comment", + ) ) else: self.findings.append( @@ -1251,16 +1437,22 @@ def check_import_comment(self, node: ast.stmt) -> None: number, "T1", REVIEW, - f"permit ships py.typed now: drop the import codes from this ignore; check what " - f"{', '.join(sorted(codes - IMPORT_IGNORE_CODES))} hides", + "permit ships py.typed now: drop the import codes from this ignore; check " + f"what {', '.join(sorted(codes - IMPORT_IGNORE_CODES))} hides", ) ) def check_transitive_import(self, node: ast.stmt, top: str) -> None: + """Flag an import of a package permit 2.x installed and 3.0.0 does not.""" if top not in TRANSITIVE_PACKAGES or top in self.project.declared: return if top == "httpx": - self.add(node, "C2", SAFE, "permit 3 no longer installs httpx: declare httpx>=0.24.1,<1 yourself") + self.add( + node, + "C2", + SAFE, + "permit 3 no longer installs httpx: declare httpx>=0.24.1,<1 yourself", + ) else: self.add( node, @@ -1271,16 +1463,19 @@ def check_transitive_import(self, node: ast.stmt, top: str) -> None: ) def check_removed(self, node: ast.AST, module: str, name: str) -> None: + """Flag a name permit 3 removed.""" entry = REMOVED.get((module, name)) if entry is not None: change, safety, message = entry self.add(node, change, safety, f"{module}.{name} does not exist in permit 3: {message}") def check_star_imported_name(self, node: ast.Name) -> None: - """ApiKeyLevel after `from permit.api.context import *`, unless the file binds the name itself.""" + """ApiKeyLevel after `from permit.api.context import *`, unless the file binds it itself.""" if not self.star_imports or not isinstance(node.ctx, ast.Load): return - if self.resolve(node, node.id) is not self.tree or node.id in self.bound.get(id(self.tree), set()): + if self.resolve(node, node.id) is not self.tree or node.id in self.bound.get( + id(self.tree), set() + ): return for module in sorted(self.star_imports): if (module, node.id) in REMOVED: @@ -1288,18 +1483,27 @@ def check_star_imported_name(self, node: ast.Name) -> None: return def check_model_import(self, node: ast.stmt, module: str, name: str) -> None: + """Flag an enum that gained a member in 3.0, unless the file uses it.""" if module not in MODEL_MODULES or name not in NEW_ENUM_MEMBERS: return change, member = NEW_ENUM_MEMBERS[name] new_member = member.rsplit(".", 1)[-1] - if any(isinstance(other, ast.Attribute) and other.attr == new_member for other in ast.walk(self.tree)): + if any( + isinstance(other, ast.Attribute) and other.attr == new_member + for other in ast.walk(self.tree) + ): return - self.add(node, change, REVIEW, f"{member} is new in 3.0: check code that handles every member of {name}") + self.add( + node, + change, + REVIEW, + f"{member} is new in 3.0: check code that handles every member of {name}", + ) # -- tracing --------------------------------------------------------------- def trace_values(self) -> None: - """Record which names hold permit clients, `client.api` handles, SDK models and context stores.""" + """Record the names holding clients, `client.api` handles, models and context stores.""" for node in ast.walk(self.tree): if isinstance(node, ast.Attribute) and node.attr == "relationship_tuples": self.mentions_tuples = True @@ -1312,7 +1516,7 @@ def trace_values(self) -> None: self.annotate(self.target_slots(node.target), node.annotation) if optional_annotation(node.annotation): self.mark(self.optional_names, node.target) - # Assignments after annotations, twice, so that `b = a` sees what `a` holds whatever their order. + # Assignments after annotations, twice, so that `b = a` sees what `a` holds in either order. for _ in range(2): for node in ast.walk(self.tree): if isinstance(node, ast.Assign): @@ -1324,7 +1528,7 @@ def trace_values(self) -> None: self.bind(self.sdk_sites, node.target) def annotate(self, slots: List[Slot], annotation: Optional[ast.AST]) -> None: - """A client or SDK model annotation decides what the name holds, whatever else it is assigned.""" + """A client or SDK model annotation decides what a name holds, whatever it is assigned.""" kind = self.class_kind(annotation) if kind is not None: for slot in slots: @@ -1333,7 +1537,7 @@ def annotate(self, slots: List[Slot], annotation: Optional[ast.AST]) -> None: self.sdk_annotations.update(slots) def model_annotation(self, annotation: Optional[ast.AST]) -> bool: - """UserRead, Optional[UserRead] or "UserRead | None" for a model class imported from permit.""" + """UserRead, Optional[UserRead] or "UserRead | None", for a model imported from permit.""" members = [member for member in union_members(annotation) if not is_none(member)] return bool(members) and all( (self.sdk_class(member) or "").rsplit(".", 1)[0] in MODEL_MODULES for member in members @@ -1354,7 +1558,7 @@ def optional_parameters(self, node: Union[ast.FunctionDef, ast.AsyncFunctionDef] return found def guarded(self, node: ast.AST, *, none_check: bool = True) -> bool: - """Whether an enclosing `if`, conditional expression, `and` or comprehension checks `node` first. + """Whether an enclosing `if`, conditional, `and` or comprehension checks `node` first. With `none_check` false, `is not None` does not count: only truthiness and isinstance() do. """ @@ -1364,7 +1568,11 @@ def guarded(self, node: ast.AST, *, none_check: bool = True) -> bool: child: ast.AST = node parent = self.parents.get(node) while parent is not None and not isinstance(parent, GUARD_LIMITS): - if isinstance(parent, ast.If) and child in parent.body and guards(parent.test, key, none_check=none_check): + if ( + isinstance(parent, ast.If) + and child in parent.body + and guards(parent.test, key, none_check=none_check) + ): return True if ( isinstance(parent, ast.IfExp) @@ -1382,13 +1590,18 @@ def guarded(self, node: ast.AST, *, none_check: bool = True) -> bool: return True if isinstance(parent, COMPREHENSIONS): tests = [test for generator in parent.generators for test in generator.ifs] - if child not in parent.generators and any(guards(test, key, none_check=none_check) for test in tests): + if child not in parent.generators and any( + guards(test, key, none_check=none_check) for test in tests + ): return True child, parent = parent, self.parents.get(parent) return False def maybe_none(self, node: ast.AST) -> bool: - """Whether `node` is visibly None or optional: None, `a if c else None`, `.get(k)`, an Optional name.""" + """Whether `node` is visibly None or optional. + + None, `a if c else None`, `.get(k)` or a name annotated Optional. + """ if isinstance(node, ast.Constant): return node.value is None if isinstance(node, ast.IfExp): @@ -1405,18 +1618,24 @@ def maybe_none(self, node: ast.AST) -> bool: return False def trace_assignment(self, target: ast.AST, value: ast.AST) -> None: + """Record what `target` holds after `target = value`.""" if isinstance(value, ast.Await): value = value.value kind = self.client_kind(value) if kind is not None: self.bind_client(target, kind) - elif isinstance(value, ast.Attribute) and value.attr == "api" and self.is_client(value.value): - self.bind(self.handle_sites, target) - elif (isinstance(value, ast.Call) and self.qualname(value.func) == "permit.utils.context.ContextStore") or ( - isinstance(value, ast.Attribute) and value.attr == "context_store" + elif ( + isinstance(value, ast.Attribute) and value.attr == "api" and self.is_client(value.value) ): + self.bind(self.handle_sites, target) + elif ( + isinstance(value, ast.Call) + and self.qualname(value.func) == "permit.utils.context.ContextStore" + ) or (isinstance(value, ast.Attribute) and value.attr == "context_store"): self.mark(self.context_stores, target) - elif self.is_api_call(value) or (isinstance(value, ast.Call) and self.is_model_construction(value)): + elif self.is_api_call(value) or ( + isinstance(value, ast.Call) and self.is_model_construction(value) + ): self.bind(self.sdk_sites, target) def is_model_construction(self, call: ast.Call) -> bool: @@ -1428,6 +1647,7 @@ def is_model_construction(self, call: ast.Call) -> bool: # -- checks ---------------------------------------------------------------- def check_call(self, node: ast.Call) -> None: + """Run the checks on a call.""" func = node.func self.check_request_model(node) self.check_runner_argument(node) @@ -1435,7 +1655,9 @@ def check_call(self, node: ast.Call) -> None: if not isinstance(func, ast.Attribute): return self.check_deprecated_call(node, func) - relations = isinstance(func.value, ast.Attribute) and func.value.attr == "resource_relations" + relations = ( + isinstance(func.value, ast.Attribute) and func.value.attr == "resource_relations" + ) if func.attr == "list" and relations and not self.reads_page(node): self.add( node, @@ -1449,23 +1671,30 @@ def check_call(self, node: ast.Call) -> None: node, "A3", REVIEW, - "ContextStore.register_transform() is removed, and the SDK never applied a registered transform " - "to a check: delete the call, or apply the transform to the context you pass to check()", + "ContextStore.register_transform() is removed, and the SDK never applied a " + "registered transform to a check: delete the call, or apply the transform to the " + "context you pass to check()", ) context_store = isinstance(func.value, ast.Attribute) and func.value.attr == "context_store" - if func.attr == "transform" and (context_store or self.slot(func.value) in self.context_stores): + if func.attr == "transform" and ( + context_store or self.slot(func.value) in self.context_stores + ): self.add( node, "A3", REVIEW, - "ContextStore.transform() is removed. It applied the functions registered with register_transform() " - "(the SDK itself never called it): call those functions on the context directly", + "ContextStore.transform() is removed. It applied the functions registered with " + "register_transform() (the SDK itself never called it): call those functions on " + "the context directly", ) self.check_v2_method(node, func) self.check_api_dicts(node, func) def reads_page(self, call: ast.Call) -> bool: - """Whether the call's result is read as a page: `(await x.list(r)).data`, or a name later read so.""" + """Whether the call's result is read as a page. + + As in `(await x.list(r)).data`, or through a name that is read that way later. + """ parent = self.parents.get(call) if isinstance(parent, ast.Await): parent = self.parents.get(parent) @@ -1476,11 +1705,14 @@ def reads_page(self, call: ast.Call) -> bool: targets = parent.targets if isinstance(parent, ast.Assign) else [parent.target] assigned = {self.slot(target) for target in targets} - {None} return any( - isinstance(other, ast.Attribute) and other.attr in PAGE_FIELDS and self.slot(other.value) in assigned + isinstance(other, ast.Attribute) + and other.attr in PAGE_FIELDS + and self.slot(other.value) in assigned for other in ast.walk(self.tree) ) def check_deprecated_call(self, node: ast.Call, func: ast.Attribute) -> None: + """Flag a deprecated flat method on `client.api`, with its replacement.""" if func.attr not in DEPRECATED_METHODS: return via_api = False @@ -1492,7 +1724,11 @@ def check_deprecated_call(self, node: ast.Call, func: ast.Attribute) -> None: untraced = f"`{client}` is not traced to a permit client in this file" elif self.is_api_handle(func.value): client = f"" - elif isinstance(func.value, ast.Name) and func.value.id.endswith("api") and self.imports_permit: + elif ( + isinstance(func.value, ast.Name) + and func.value.id.endswith("api") + and self.imports_permit + ): # Named like a `client.api` handle, but bound to something this file doesn't trace. client = f"" untraced = f"`{func.value.id}` is not traced to a permit client's .api in this file" @@ -1512,13 +1748,20 @@ def check_deprecated_call(self, node: ast.Call, func: ast.Attribute) -> None: detail = f"use {target}({argument or '...'})" safe = traced and argument is not None else: - renamed = [f"{kw.arg}= to {renames[kw.arg]}=" for kw in node.keywords if kw.arg in renames] + renamed = [ + f"{kw.arg}= to {renames[kw.arg]}=" for kw in node.keywords if kw.arg in renames + ] detail = f"use {target}(...)" + (f" and rename {', '.join(renamed)}" if renamed else "") safe = traced and not starred if not traced: detail = f"{untraced}; if it is one, {detail}" old = f"permit.api.{func.attr}()" - self.add(node, "D2", SAFE if safe else REVIEW, f"{old} is deprecated and will be removed in 4.0: {detail}") + self.add( + node, + "D2", + SAFE if safe else REVIEW, + f"{old} is deprecated and will be removed in 4.0: {detail}", + ) def assignment_argument(self, node: ast.Call) -> Optional[str]: """The dict that replaces assign_role(user_key, role_key, tenant_key)'s three arguments.""" @@ -1535,6 +1778,7 @@ def assignment_argument(self, node: ast.Call) -> Optional[str]: return "{" + ", ".join(f'"{name}": {values[name]}' for name in order) + "}" def check_await(self, node: ast.Await) -> None: + """Flag an await on a method that permit.sync.Permit now runs to completion.""" call = node.value if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute): return @@ -1548,21 +1792,24 @@ def check_await(self, node: ast.Await) -> None: node, "A2", REVIEW, - f"`{self.source_of(receiver)}` is a permit.sync.Permit, whose {method}() returns its result in " - "3.0 and blocks while it waits. This is async code: switch it to the async permit.Permit and " - "keep the await (recommended), or drop the await and accept a blocking call", + f"`{self.source_of(receiver)}` is a permit.sync.Permit, whose {method}() returns " + "its result in 3.0 and blocks while it waits. This is async code: switch it to " + "the async permit.Permit and keep the await (recommended), or drop the await and " + "accept a blocking call", ) elif kind in (EITHER, MAYBE) or (kind is None and self.project.uses_sync_client): self.add(node, "A2", REVIEW, self.untraced_a2(receiver, method)) def untraced_a2(self, receiver: ast.AST, method: str) -> str: + """The finding for a now-blocking method on a client this file does not trace.""" return ( - f"if `{self.source_of(receiver)}` is a permit.sync.Permit, {method}() returns its result in 3.0: " - "call it without await or a coroutine runner. The async permit.Permit still needs them" + f"if `{self.source_of(receiver)}` is a permit.sync.Permit, {method}() returns its " + "result in 3.0: call it without await or a coroutine runner. The async permit.Permit " + "still needs them" ) def coroutine_runner(self, func: ast.AST) -> Optional[Tuple[str, bool]]: - """(name, whether it runs a coroutine to completion from sync code) for a coroutine runner.""" + """(name, whether it completes a coroutine from sync code) for a coroutine runner.""" name = self.qualname(func) if name is not None and name.startswith("asyncio."): short = name[len("asyncio.") :] @@ -1575,7 +1822,7 @@ def coroutine_runner(self, func: ast.AST) -> Optional[Tuple[str, bool]]: return short, name == "asyncio.run" or short == "run_until_complete" def check_runner_argument(self, node: ast.Call) -> None: - """asyncio.run(client.filter_objects(...)) and other runners given one of the three methods.""" + """asyncio.run(client.filter_objects(...)) and other runners given one of the methods.""" runner = self.coroutine_runner(node.func) if runner is None: return @@ -1592,29 +1839,32 @@ def check_runner_argument(self, node: ast.Call) -> None: arg, "A2", SAFE, - f"permit.sync.Permit.{method}() returns its result in 3.0, and {name}() raises on it: " - "call the method directly", + f"permit.sync.Permit.{method}() returns its result in 3.0, and {name}() " + "raises on it: call the method directly", ) elif kind == SYNC: self.add( arg, "A2", REVIEW, - f"permit.sync.Permit.{method}() returns its result in 3.0 and blocks while it waits, so " - f"{name}() gets no coroutine. This is async code: switch it to the async permit.Permit " - "(recommended), or call the method directly and accept a blocking call", + f"permit.sync.Permit.{method}() returns its result in 3.0 and blocks while it " + f"waits, so {name}() gets no coroutine. This is async code: switch it to the " + "async permit.Permit (recommended), or call the method directly and accept a " + "blocking call", ) elif kind in (EITHER, MAYBE) or (kind is None and self.project.uses_sync_client): self.add(arg, "A2", REVIEW, self.untraced_a2(arg.func.value, method)) def check_async_mock(self, node: ast.Call) -> None: - """patch(..., new_callable=AsyncMock) or setattr(x, "method", AsyncMock()) for the three methods.""" + """patch(..., new_callable=AsyncMock) or setattr(x, "method", AsyncMock()) on a method.""" if not self.project.uses_sync_client: return values = list(node.args) + [keyword.value for keyword in node.keywords] if not any(is_async_mock(value) for value in values): return - if node.args and (self.client_kind(node.args[0]) == ASYNC or self.qualname(node.args[0]) in ASYNC_CLIENTS): + if node.args and ( + self.client_kind(node.args[0]) == ASYNC or self.qualname(node.args[0]) in ASYNC_CLIENTS + ): return for arg in node.args: if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)): @@ -1625,7 +1875,7 @@ def check_async_mock(self, node: ast.Call) -> None: return def check_async_mock_assignment(self, node: ast.Assign) -> None: - """client.authorized_users = AsyncMock(...)""" + """`client.authorized_users = AsyncMock(...)`.""" if not self.project.uses_sync_client or not is_async_mock(node.value): return for target in node.targets: @@ -1638,10 +1888,13 @@ def check_async_mock_assignment(self, node: ast.Assign) -> None: self.add(target, "A2", REVIEW, async_mock_message(target.attr)) def check_v2_method(self, node: ast.Call, func: ast.Attribute) -> None: + """Flag a pydantic 2 method called on an SDK model, with its pydantic 1 name.""" if func.attr not in V2_METHODS or not self.is_sdk_value(func.value): return v1_name, shared = V2_METHODS[func.attr] - unsupported = [keyword.arg or "**" for keyword in node.keywords if keyword.arg not in shared] + unsupported = [ + keyword.arg or "**" for keyword in node.keywords if keyword.arg not in shared + ] if func.attr in ("model_validate", "model_validate_json") and len(node.args) != 1: unsupported.append("the arguments") message = f"SDK models are pydantic v1 models, with no .{func.attr}(): use .{v1_name}()" @@ -1651,6 +1904,7 @@ def check_v2_method(self, node: ast.Call, func: ast.Attribute) -> None: self.add(node, "T2", SAFE, message) def check_attribute(self, node: ast.Attribute) -> None: + """Flag removed names, pydantic 2 attributes and fields that may now be None.""" name = self.qualname(node) if name is not None: module, _, attr = name.rpartition(".") @@ -1658,7 +1912,12 @@ def check_attribute(self, node: ast.Attribute) -> None: if node.attr in V2_ATTRIBUTES and self.is_sdk_value(node.value): safety = SAFE if node.attr == "model_fields_set" else REVIEW replacement = V2_ATTRIBUTES[node.attr] - self.add(node, "T2", safety, f"SDK models are pydantic v1 models: use .{replacement}, not .{node.attr}") + self.add( + node, + "T2", + safety, + f"SDK models are pydantic v1 models: use .{replacement}, not .{node.attr}", + ) inner = node.value if not isinstance(inner, ast.Attribute): return @@ -1669,18 +1928,26 @@ def check_attribute(self, node: ast.Attribute) -> None: node, "A4", REVIEW, - "DetailedAuditLogModel.objects is {} (a plain dict) when a log has no objects, and None when " - "the API sends null: check isinstance(..., AuditLogObjectsModel) before reading it", + "DetailedAuditLogModel.objects is {} (a plain dict) when a log has no " + "objects, and None when the API sends null: check isinstance(..., " + "AuditLogObjectsModel) before reading it", ) return if self.guarded(inner): return if inner.attr == "pdp_config_id" and self.names_imported & AUDIT_LOG_MODELS: - self.add(node, "A4", REVIEW, "pdp_config_id may be None in 3.0: check it before using it") - elif inner.attr in TUPLE_OPTIONAL_FIELDS and (self.mentions_tuples or self.names_imported & TUPLE_MODELS): - self.add(node, "A5", REVIEW, f"{inner.attr} may be None in 3.0: check it before using it") + self.add( + node, "A4", REVIEW, "pdp_config_id may be None in 3.0: check it before using it" + ) + elif inner.attr in TUPLE_OPTIONAL_FIELDS and ( + self.mentions_tuples or self.names_imported & TUPLE_MODELS + ): + self.add( + node, "A5", REVIEW, f"{inner.attr} may be None in 3.0: check it before using it" + ) def check_string(self, node: ast.Constant) -> None: + """Flag a string with permit 2.x's deprecation text or a lowercase `bearer`.""" if OLD_D2_TEXT_RE.search(str(node.value)): self.add(node, "D2", REVIEW, OLD_D2_FILTER) if self.imports_permit and re.match(r"bearer(\s|$)", str(node.value)): @@ -1688,18 +1955,21 @@ def check_string(self, node: ast.Constant) -> None: node, "W5", REVIEW, - "permit 3 sends `Authorization: Bearer ...` with a capital B: update this if it matches " - "permit's header", + "permit 3 sends `Authorization: Bearer ...` with a capital B: update this if it " + "matches permit's header", ) def check_request_model(self, node: ast.Call) -> None: + """Flag a request model built with a field that may be None, which clears it.""" name = self.sdk_class(node.func) if name is None or name.rsplit(".", 1)[0] not in MODEL_MODULES: return short = name.rsplit(".", 1)[-1] if not short.endswith(REQUEST_MODEL_SUFFIXES): return - explicit = [keyword.arg for keyword in node.keywords if keyword.arg and is_none(keyword.value)] + explicit = [ + keyword.arg for keyword in node.keywords if keyword.arg and is_none(keyword.value) + ] optional = [ keyword.arg for keyword in node.keywords @@ -1711,33 +1981,36 @@ def check_request_model(self, node: ast.Call) -> None: node, "W1", REVIEW, - f"{short}({arguments}) now sends null and clears the field: leave the argument out to keep " - "the current value", + f"{short}({arguments}) now sends null and clears the field: leave the argument " + "out to keep the current value", ) elif optional: self.add( node, "W1", REVIEW, - f"{short}: when {', '.join(optional)} is None, permit 3 sends null and clears the field; " - "pass it only when it has a value", + f"{short}: when {', '.join(optional)} is None, permit 3 sends null and clears the " + "field; pass it only when it has a value", ) def check_api_dicts(self, node: ast.Call, func: ast.Attribute) -> None: + """Flag a dict body with a value that may be None, which clears the field.""" holder, traced = self.api_receiver(func) if holder is None or not (traced or self.imports_permit): return for arg in list(node.args) + [keyword.value for keyword in node.keywords]: if not isinstance(arg, ast.Dict): continue - values = [value for index, value in enumerate(arg.values) if arg.keys[index] is not None] + values = [ + value for index, value in enumerate(arg.values) if arg.keys[index] is not None + ] if any(self.maybe_none(value) for value in values): self.add( arg, "W1", REVIEW, - "a None value in this body is now sent as null and clears the field: leave the key out " - "to keep the current value", + "a None value in this body is now sent as null and clears the field: leave " + "the key out to keep the current value", ) @@ -1747,6 +2020,8 @@ def check_api_dicts(self, node: ast.Call, func: ast.Attribute) -> None: class Project: + """A project directory: its files, what they declare and the findings in them.""" + def __init__(self, root: Path) -> None: self.root = root self.facts = ProjectFacts() @@ -1756,6 +2031,7 @@ def __init__(self, root: Path) -> None: self.own_dir = Path(__file__).resolve().parent.parent def files(self) -> Iterator[Path]: + """Every file to scan, skipping virtual environments, build output and this skill.""" for directory, subdirectories, filenames in os.walk(self.root): here = Path(directory) subdirectories[:] = sorted( @@ -1770,9 +2046,11 @@ def files(self) -> Iterator[Path]: yield here / filename def rel(self, path: Path) -> str: + """`path` relative to the project root, with forward slashes.""" return path.relative_to(self.root).as_posix() def read_lines(self, path: Path) -> Optional[List[str]]: + """The file's lines, or None when it cannot be read, which is recorded as skipped.""" try: return path.read_text(encoding="utf-8", errors="replace").splitlines() except OSError as error: @@ -1780,6 +2058,7 @@ def read_lines(self, path: Path) -> Optional[List[str]]: return None def scan(self) -> List[Finding]: + """Read the configuration, then every Python file, and return the sorted findings.""" self.facts = ProjectFacts() self.skipped = [] python_files: List[Path] = [] @@ -1820,7 +2099,9 @@ def scan(self) -> List[Finding]: source = path.read_bytes() tree = ast.parse(source, filename=str(path)) except (OSError, SyntaxError, ValueError) as error: - self.skipped.append({"path": self.rel(path), "reason": f"{type(error).__name__}: {error}"}) + self.skipped.append( + {"path": self.rel(path), "reason": f"{type(error).__name__}: {error}"} + ) continue trees.append((path, source, tree)) if path.name == "setup.py": @@ -1833,9 +2114,13 @@ def scan(self) -> List[Finding]: findings.extend(self.requirement_findings()) for path, source, tree in trees: findings.extend(SourceScan(self.rel(path), source, tree, self).run()) - return sorted(set(findings), key=lambda finding: (finding.path, finding.line, finding.change, finding.message)) + return sorted( + set(findings), + key=lambda finding: (finding.path, finding.line, finding.change, finding.message), + ) def config_kind(self, path: Path) -> Optional[str]: + """Which configuration reader a file needs, or None when it is not one.""" name = path.name parent = path.parent.name if re.match(r"(requirements|constraints).*\.(txt|in)$", name) or ( @@ -1854,7 +2139,11 @@ def config_kind(self, path: Path) -> Optional[str]: return "version-file" if name == "pyrightconfig.json": return "pyright" - if name in ("Dockerfile", "Containerfile") or name.startswith("Dockerfile.") or name.endswith(".Dockerfile"): + if ( + name in ("Dockerfile", "Containerfile") + or name.startswith("Dockerfile.") + or name.endswith(".Dockerfile") + ): return "docker" workflow = parent == "workflows" and path.parent.parent.name == ".github" if (workflow and name.endswith((".yml", ".yaml"))) or name in CI_FILE_NAMES: @@ -1864,6 +2153,7 @@ def config_kind(self, path: Path) -> Optional[str]: return None def requirement_findings(self) -> List[Finding]: + """P1, C3 and D1 findings for the requirements and the pytest filters.""" findings: List[Finding] = [] for requirement in self.facts.requirements: alternatives = parse_spec(requirement.spec) if requirement.spec != "@" else None @@ -1872,11 +2162,23 @@ def requirement_findings(self) -> List[Finding]: if alternatives is None: findings.append( Finding( - *where, "P1", REVIEW, f"`{requirement.text}`: make sure it resolves to permit>=3.0.0,<4" + *where, + "P1", + REVIEW, + f"`{requirement.text}`: make sure it resolves to permit>=3.0.0,<4", + ) + ) + elif intersects(alternatives, None, (3,)) or not intersects( + alternatives, (3,), (4,) + ): + findings.append( + Finding( + *where, + "P1", + SAFE, + f"`{requirement.text}`: change it to permit>=3.0.0,<4", ) ) - elif intersects(alternatives, None, (3,)) or not intersects(alternatives, (3,), (4,)): - findings.append(Finding(*where, "P1", SAFE, f"`{requirement.text}`: change it to permit>=3.0.0,<4")) if alternatives is None: continue ranges = FLOORS.get(requirement.name) @@ -1886,7 +2188,8 @@ def requirement_findings(self) -> List[Finding]: *where, "C3", SAFE, - f"`{requirement.text}` is below permit 3's floor: raise it to {FLOOR_TEXT[requirement.name]}", + f"`{requirement.text}` is below permit 3's floor: raise it to " + f"{FLOOR_TEXT[requirement.name]}", ) ) if requirement.name == "pydantic" and not intersects(alternatives, (2,), None): @@ -1895,8 +2198,8 @@ def requirement_findings(self) -> List[Finding]: *where, "D1", REVIEW, - f"`{requirement.text}` holds pydantic 1, which permit 3 deprecates and permit 4.0 will drop; " - "plan the move to pydantic 2", + f"`{requirement.text}` holds pydantic 1, which permit 3 deprecates and " + "permit 4.0 will drop; plan the move to pydantic 2", ) ) if self.facts.pins_pydantic1() and not self.facts.pydantic1_filter_present: @@ -1914,22 +2217,37 @@ def requirement_findings(self) -> List[Finding]: return findings def summary(self) -> Dict[str, object]: + """The facts the report prints before the findings.""" + def requirement_list(name: str) -> List[str]: - return [f"{item.path}:{item.line}: {item.text}" for item in self.facts.requirements if item.name == name] + return [ + f"{item.path}:{item.line}: {item.text}" + for item in self.facts.requirements + if item.name == name + ] return { "permit_requirements": requirement_list("permit"), - "permit_locked": [f"{path}:{line}: {version}" for path, line, version in self.facts.locked_permit], + "permit_locked": [ + f"{path}:{line}: {version}" for path, line, version in self.facts.locked_permit + ], "pydantic_requirements": requirement_list("pydantic"), - "python_pins": [f"{path}:{line}: {text}" for path, line, text in self.facts.python_pins], + "python_pins": [ + f"{path}:{line}: {text}" for path, line, text in self.facts.python_pins + ], "uses_sync_client": self.uses_sync_client, "httpx_declared": "httpx" in self.declared, } def main(argv: Optional[List[str]] = None) -> int: - parser = argparse.ArgumentParser(description="Find what a permit 2.x -> 3.0.0 upgrade touches in a project.") - parser.add_argument("path", nargs="?", default=".", help="project directory (default: the current directory)") + """Scan a project and print what the upgrade touches; return the exit status.""" + parser = argparse.ArgumentParser( + description="Find what a permit 2.x -> 3.0.0 upgrade touches in a project." + ) + parser.add_argument( + "path", nargs="?", default=".", help="project directory (default: the current directory)" + ) parser.add_argument("--json", action="store_true", help="print JSON instead of text") arguments = parser.parse_args(argv) root = Path(arguments.path) @@ -1938,7 +2256,9 @@ def main(argv: Optional[List[str]] = None) -> int: project = Project(root.resolve()) findings = project.scan() - changes = {change: TITLES[change] for change in sorted({finding.change for finding in findings})} + changes = { + change: TITLES[change] for change in sorted({finding.change for finding in findings}) + } if arguments.json: report = { "root": str(project.root), @@ -1959,7 +2279,8 @@ def main(argv: Optional[List[str]] = None) -> int: out.append(f"{key}: {value}") out.append("") out.extend( - f"{finding.path}:{finding.line}: {finding.change} {finding.safety} {finding.message}" for finding in findings + f"{finding.path}:{finding.line}: {finding.change} {finding.safety} {finding.message}" + for finding in findings ) safe = sum(1 for finding in findings if finding.safety == SAFE) out.append(f"{len(findings)} findings: {safe} SAFE, {len(findings) - safe} NEEDS-REVIEW") diff --git a/skills/tests/pytest.ini b/skills/tests/pytest.ini index b0c5995..454e4be 100644 --- a/skills/tests/pytest.ini +++ b/skills/tests/pytest.ini @@ -2,3 +2,10 @@ # The migration skill's tests run apart from the SDK's suite, with these settings # instead of the [tool.pytest] table in the repository's pyproject.toml. See README.md. testpaths = . +# strict_config, strict_markers, strict_xfail and strict_parametrization_ids. +strict = true +# Any warning fails the test that raised it, as in the SDK's suite, except the warning +# `import permit` issues on pydantic 1 on purpose. +filterwarnings = + error + ignore:Support for pydantic 1 is deprecated:DeprecationWarning diff --git a/skills/tests/test_migration_skill.py b/skills/tests/test_migration_skill.py index 4ec87da..1cf59e5 100644 --- a/skills/tests/test_migration_skill.py +++ b/skills/tests/test_migration_skill.py @@ -33,7 +33,7 @@ import warnings from pathlib import Path from types import ModuleType -from typing import Any, Dict, List, Optional, Set, Tuple +from typing import Any from uuid import UUID import pytest @@ -88,7 +88,7 @@ def config(httpserver: HTTPServer) -> PermitConfig: return PermitConfig(token="test-token", api_url=base_url, pdp=base_url, api_context=api_context) -def sent(request: Request) -> Dict[str, Any]: +def sent(request: Request) -> dict[str, Any]: """What a request put on the wire, in a form two requests can be compared by.""" body = request.get_data() return { @@ -99,7 +99,7 @@ def sent(request: Request) -> Dict[str, Any]: } -Row = Tuple[str, int, str, str] +Row = tuple[str, int, str, str] def load_scanner() -> ModuleType: @@ -121,11 +121,11 @@ def load_scanner() -> ModuleType: scan = load_scanner() -def findings(root: Path) -> List[Row]: +def findings(root: Path) -> list[Row]: return [(item.path, item.line, item.change, item.safety) for item in scan.Project(root).scan()] -@functools.lru_cache(maxsize=None) +@functools.cache def sample_app(name: str) -> Path: """Copy a sample app out of the repo and give its *.fixture files their real names.""" target = Path(tempfile.mkdtemp(prefix="permit-migration-")) / name @@ -136,7 +136,7 @@ def sample_app(name: str) -> Path: return target -def write(root: Path, files: Dict[str, str]) -> Path: +def write(root: Path, files: dict[str, str]) -> Path: for name, content in files.items(): path = root / name path.parent.mkdir(parents=True, exist_ok=True) @@ -148,7 +148,7 @@ def write(root: Path, files: Dict[str, str]) -> Path: # The sample apps # --------------------------------------------------------------------------- -V2_FINDINGS: Set[Row] = { +V2_FINDINGS: set[Row] = { (".gitlab-ci.yml", 2, "C1", REVIEW), (".gitlab-ci.yml", 5, "C1", REVIEW), (".python-version", 1, "C1", REVIEW), @@ -202,8 +202,8 @@ def write(root: Path, files: Dict[str, str]) -> Path: } -def test_git_tracks_every_fixture_file(): - """A fixture file that git ignores is missing from every clone, so the tests below fail in CI.""" +def test_git_tracks_every_fixture_file() -> None: + """A fixture file that git ignores is missing from every clone, so CI fails the tests below.""" if shutil.which("git") is None or not (REPO_ROOT / ".git").exists(): pytest.skip("not a git checkout") files = [ @@ -212,36 +212,50 @@ def test_git_tracks_every_fixture_file(): if path.is_file() and "__pycache__" not in path.parts ] - result = subprocess.run(["git", "check-ignore", *files], cwd=REPO_ROOT, capture_output=True, text=True, check=False) + result = subprocess.run( + ["git", "check-ignore", *files], # noqa: S607 - the git on PATH, as in CI + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) - assert result.returncode == 1, f"git ignores these fixture files:\n{result.stdout}{result.stderr}" + assert result.returncode == 1, ( + f"git ignores these fixture files:\n{result.stdout}{result.stderr}" + ) -def test_no_fixture_file_has_a_name_github_reads_as_a_dependency_manifest(): +def test_no_fixture_file_has_a_name_github_reads_as_a_dependency_manifest() -> None: manifests = re.compile( r"(pyproject\.toml|setup\.py|setup\.cfg|Pipfile(\.lock)?|poetry\.lock|uv\.lock|.*requirements.*\.txt)" ) - named = [path.relative_to(FIXTURES).as_posix() for path in FIXTURES.rglob("*") if manifests.fullmatch(path.name)] + named = [ + path.relative_to(FIXTURES).as_posix() + for path in FIXTURES.rglob("*") + if manifests.fullmatch(path.name) + ] assert named == [], f"store these as .fixture: {named}" -def test_scanner_finds_every_site_in_the_2x_app(): +def test_scanner_finds_every_site_in_the_2x_app() -> None: found = findings(sample_app("v2_app")) assert len(found) == len(set(found)), "a site was reported twice" assert set(found) == V2_FINDINGS -def test_scanner_reports_nothing_in_the_migrated_app(): +def test_scanner_reports_nothing_in_the_migrated_app() -> None: project = scan.Project(sample_app("v3_app")) assert project.scan() == [] assert project.skipped == [] -def test_safe_edits_name_the_replacement(): - messages = {(item.path, item.line): item.message for item in scan.Project(sample_app("v2_app")).scan()} +def test_safe_edits_name_the_replacement() -> None: + messages = { + (item.path, item.line): item.message for item in scan.Project(sample_app("v2_app")).scan() + } assert "use self.permit.api.tenants.get(...)" in messages[("app/aliases.py", 18)] assert "rename tenant= to tenant_data=" in messages[("app/async_app.py", 21)] @@ -255,7 +269,7 @@ def test_safe_edits_name_the_replacement(): assert "switch it to the async permit.Permit" in messages[("app/sync_app.py", 23)] -def test_json_report_matches_the_findings_and_names_the_changes(): +def test_json_report_matches_the_findings_and_names_the_changes() -> None: result = subprocess.run( [sys.executable, str(SCANNER), str(sample_app("v2_app")), "--json"], capture_output=True, @@ -265,7 +279,9 @@ def test_json_report_matches_the_findings_and_names_the_changes(): assert result.returncode == 0, result.stderr report = json.loads(result.stdout) - rows = {(item["path"], item["line"], item["change"], item["safety"]) for item in report["findings"]} + rows = { + (item["path"], item["line"], item["change"], item["safety"]) for item in report["findings"] + } assert rows == V2_FINDINGS assert set(report["changes"]) == {row[2] for row in V2_FINDINGS} assert report["summary"]["uses_sync_client"] is True @@ -281,7 +297,7 @@ def test_json_report_matches_the_findings_and_names_the_changes(): # --------------------------------------------------------------------------- -def test_scanner_follows_every_way_of_importing_the_clients(tmp_path: Path): +def test_scanner_follows_every_way_of_importing_the_clients(tmp_path: Path) -> None: write( tmp_path, { @@ -320,7 +336,7 @@ def run(g: Blocking, h: AsyncPermit) -> None: assert findings(tmp_path) == [("app.py", line, "A2", SAFE) for line in (18, 19, 20, 21, 23, 24)] -def test_awaiting_a_blocking_method_is_a_question_only_in_async_code(tmp_path: Path): +def test_awaiting_a_blocking_method_is_a_question_only_in_async_code(tmp_path: Path) -> None: write( tmp_path, { @@ -367,7 +383,7 @@ async def handler(): assert "(recommended)" in item.message -def test_async_mocks_of_the_three_methods_need_review(tmp_path: Path): +def test_async_mocks_of_the_three_methods_need_review(tmp_path: Path) -> None: write( tmp_path, { @@ -403,11 +419,18 @@ def test_doubles(monkeypatch, mocker): assert "use Mock or MagicMock" in scan.Project(tmp_path).scan()[0].message # Without the blocking client in the project, an AsyncMock of these methods is right. - write(tmp_path, {"test_app.py": "from unittest.mock import AsyncMock\nclient.authorized_users = AsyncMock()\n"}) + write( + tmp_path, + { + "test_app.py": ( + "from unittest.mock import AsyncMock\nclient.authorized_users = AsyncMock()\n" + ) + }, + ) assert findings(tmp_path) == [] -def test_scanner_follows_module_aliases_to_removed_names(tmp_path: Path): +def test_scanner_follows_module_aliases_to_removed_names(tmp_path: Path) -> None: write( tmp_path, { @@ -434,7 +457,7 @@ def test_scanner_follows_module_aliases_to_removed_names(tmp_path: Path): ] -def test_scanner_follows_star_imports_to_removed_names(tmp_path: Path): +def test_scanner_follows_star_imports_to_removed_names(tmp_path: Path) -> None: write( tmp_path, { @@ -455,7 +478,7 @@ def own(JWT): assert findings(tmp_path) == [("app.py", 4, "A3", SAFE), ("app.py", 5, "A3", SAFE)] -def test_context_store_transform_is_described_as_it_behaved(tmp_path: Path): +def test_context_store_transform_is_described_as_it_behaved(tmp_path: Path) -> None: write( tmp_path, { @@ -475,7 +498,7 @@ def test_context_store_transform_is_described_as_it_behaved(tmp_path: Path): assert "It applied the functions registered with register_transform()" in messages[1] -def test_untraced_receivers_are_never_safe(tmp_path: Path): +def test_untraced_receivers_are_never_safe(tmp_path: Path) -> None: write( tmp_path, { @@ -493,7 +516,7 @@ async def load(client, key): assert findings(tmp_path) == [("service.py", 2, "D2", REVIEW), ("service.py", 3, "A2", REVIEW)] -def test_a_name_bound_in_a_function_hides_the_module_level_value(tmp_path: Path): +def test_a_name_bound_in_a_function_hides_the_module_level_value(tmp_path: Path) -> None: write( tmp_path, { @@ -565,7 +588,7 @@ def module_level(): ] -def test_a_name_annotated_with_an_sdk_model_is_one(tmp_path: Path): +def test_a_name_annotated_with_an_sdk_model_is_one(tmp_path: Path) -> None: write( tmp_path, { @@ -587,7 +610,7 @@ def dump( e.model_dump(), loaded.model_copy(), ) - """ + """ # noqa: E501 - sample code as a user writes it }, ) @@ -600,7 +623,7 @@ def dump( ] -def test_a_value_bound_to_something_else_as_well_is_not_traced(tmp_path: Path): +def test_a_value_bound_to_something_else_as_well_is_not_traced(tmp_path: Path) -> None: write( tmp_path, { @@ -662,7 +685,7 @@ def run(): ("app.py", 26, "A2", SAFE), ("app.py", 34, "A2", REVIEW), ("app.py", 41, "A2", SAFE), - # Bound to both clients: .api exists on either, but asyncio.run() is right only on the async one. + # Bound to both clients: .api exists on either, but asyncio.run() suits only the async one. ("app.py", 45, "A2", REVIEW), ("app.py", 46, "D2", SAFE), # Bound to a client and to something else: nothing is safe. @@ -673,7 +696,7 @@ def run(): ] -def test_starred_arguments_make_a_deprecated_call_need_review(tmp_path: Path): +def test_starred_arguments_make_a_deprecated_call_need_review(tmp_path: Path) -> None: write( tmp_path, { @@ -705,7 +728,7 @@ async def run(args, kwargs): # --------------------------------------------------------------------------- -def test_only_visibly_optional_values_are_reported_for_w1(tmp_path: Path): +def test_only_visibly_optional_values_are_reported_for_w1(tmp_path: Path) -> None: write( tmp_path, { @@ -729,14 +752,14 @@ async def update(key: str, name: str, email: Optional[str], data: dict, kwargs: if email is not None: await permit.api.users.update(key, UserUpdate(email=email)) await permit.api.users.update(key, UserUpdate(email=email)) if email else None - """ + """ # noqa: E501 - sample code as a user writes it }, ) assert findings(tmp_path) == [("app.py", line, "W1", REVIEW) for line in (10, 11, 12, 13, 15)] -def test_guarded_optional_fields_are_not_reported(tmp_path: Path): +def test_guarded_optional_fields_are_not_reported(tmp_path: Path) -> None: write( tmp_path, { @@ -762,7 +785,7 @@ async def ids(): assert findings(tmp_path) == [("app.py", 8, "A5", REVIEW)] -def test_audit_log_objects_need_an_isinstance_check_not_a_none_check(tmp_path: Path): +def test_audit_log_objects_need_an_isinstance_check_not_a_none_check(tmp_path: Path) -> None: write( tmp_path, { @@ -780,15 +803,15 @@ def users(raw): truthy = log.objects and log.objects.user_object config = log.pdp_config_id.hex if isinstance(log.pdp_config_id, UUID) else None return unguarded, not_none, typed, truthy, config - """ + """ # noqa: E501 - sample code as a user writes it }, ) assert findings(tmp_path) == [("app.py", 8, "A4", REVIEW), ("app.py", 9, "A4", REVIEW)] -def test_audit_log_objects_default_to_an_empty_dict(): - """What A4 in both docs and the scanner's message say: `is not None` does not guard `objects`.""" +def test_audit_log_objects_default_to_an_empty_dict() -> None: + """What A4 in the docs and the scanner's message say: `is not None` does not guard `objects`.""" field = DetailedAuditLogModel.__fields__["objects"] assert field.required is False @@ -947,7 +970,9 @@ def test_audit_log_objects_default_to_an_empty_dict(): ), ], ) -def test_dependency_files(tmp_path: Path, name: str, content: str, expected: List[Tuple[int, str, str]]): +def test_dependency_files( + tmp_path: Path, name: str, content: str, expected: list[tuple[int, str, str]] +) -> None: write(tmp_path, {name: content}) assert findings(tmp_path) == [(name, line, change, safety) for line, change, safety in expected] @@ -975,33 +1000,52 @@ def test_dependency_files(tmp_path: Path, name: str, content: str, expected: Lis id="github-actions", ), pytest.param( - ".circleci/config.yml", "jobs:\n test:\n docker:\n - image: cimg/python:3.9\n", [4], id="circleci" + ".circleci/config.yml", + "jobs:\n test:\n docker:\n - image: cimg/python:3.9\n", + [4], + id="circleci", ), pytest.param( - "tox.ini", "[tox]\nenvlist = py38, py310\n[testenv:lint]\nbasepython = python3.12\n", [2], id="tox" + "tox.ini", + "[tox]\nenvlist = py38, py310\n[testenv:lint]\nbasepython = python3.12\n", + [2], + id="tox", ), pytest.param( - "Dockerfile.prod", "ARG PYTHON_VERSION=3.9\nFROM python:${PYTHON_VERSION}\n", [1], id="dockerfile-arg" + "Dockerfile.prod", + "ARG PYTHON_VERSION=3.9\nFROM python:${PYTHON_VERSION}\n", + [1], + id="dockerfile-arg", ), pytest.param("runtime.txt", "python-3.9.18\n", [1], id="runtime.txt"), - pytest.param(".tool-versions", "nodejs 20.1.0\npython 3.9.18 3.12.1\n", [2], id="tool-versions"), + pytest.param( + ".tool-versions", "nodejs 20.1.0\npython 3.9.18 3.12.1\n", [2], id="tool-versions" + ), pytest.param("mypy.ini", "[mypy]\npython_version = 3.9\n", [2], id="mypy"), pytest.param("pyrightconfig.json", '{\n "pythonVersion": "3.8"\n}\n', [2], id="pyright"), - pytest.param("pyproject.toml", '[project]\nrequires-python = "~=3.9"\n', [2], id="compatible-release"), - pytest.param("pyproject.toml", '[project]\nrequires-python = ">3.9"\n', [2], id="greater-than"), - pytest.param("pyproject.toml", '[project]\nrequires-python = ">=3.10"\n', [], id="310-floor"), - pytest.param("pyproject.toml", '[project]\nrequires-python = "==3.12.*"\n', [], id="312-wildcard"), + pytest.param( + "pyproject.toml", '[project]\nrequires-python = "~=3.9"\n', [2], id="compatible-release" + ), + pytest.param( + "pyproject.toml", '[project]\nrequires-python = ">3.9"\n', [2], id="greater-than" + ), + pytest.param( + "pyproject.toml", '[project]\nrequires-python = ">=3.10"\n', [], id="310-floor" + ), + pytest.param( + "pyproject.toml", '[project]\nrequires-python = "==3.12.*"\n', [], id="312-wildcard" + ), pytest.param(".python-version", "3.10\n", [], id="python-version-310"), pytest.param("Dockerfile", "FROM python:3.13-slim AS build\n", [], id="dockerfile-313"), ], ) -def test_python_pins_below_310(tmp_path: Path, name: str, content: str, lines: List[int]): +def test_python_pins_below_310(tmp_path: Path, name: str, content: str, lines: list[int]) -> None: write(tmp_path, {name: content}) assert findings(tmp_path) == [(name, line, "C1", REVIEW) for line in lines] -def test_type_checker_settings_that_hide_permit(tmp_path: Path): +def test_type_checker_settings_that_hide_permit(tmp_path: Path) -> None: write( tmp_path, { @@ -1055,7 +1099,7 @@ def test_type_checker_settings_that_hide_permit(tmp_path: Path): ] -def test_warnings_as_errors_matter_only_on_pydantic_1(tmp_path: Path): +def test_warnings_as_errors_matter_only_on_pydantic_1(tmp_path: Path) -> None: config = '[tool.pytest.ini_options]\nfilterwarnings = [\n "error",\n]\n' write(tmp_path, {"pyproject.toml": config, "requirements.txt": "pydantic>=2.8\n"}) assert findings(tmp_path) == [] @@ -1070,7 +1114,7 @@ def test_warnings_as_errors_matter_only_on_pydantic_1(tmp_path: Path): assert ("pytest.ini", 2, "D1", REVIEW) in findings(tmp_path) -def test_warning_filters_written_for_the_2x_text_need_review(tmp_path: Path): +def test_warning_filters_written_for_the_2x_text_need_review(tmp_path: Path) -> None: write( tmp_path, { @@ -1081,7 +1125,10 @@ def test_warning_filters_written_for_the_2x_text_need_review(tmp_path: Path): "ignore:permit\\\\.api\\\\.\\\\w+\\\\(\\\\) is deprecated:DeprecationWarning", ] """, - "setup.cfg": "[tool:pytest]\nfilterwarnings =\n ignore:use permit\\.elements:DeprecationWarning\n", + "setup.cfg": ( + "[tool:pytest]\nfilterwarnings =\n" + " ignore:use permit\\.elements:DeprecationWarning\n" + ), "conftest.py": """ import warnings @@ -1090,11 +1137,11 @@ def test_warning_filters_written_for_the_2x_text_need_review(tmp_path: Path): warnings.filterwarnings("ignore", message=r"use permit\\.api", category=DeprecationWarning) pytest.mark.filterwarnings("ignore:use permit.api.users.get") EXPECTED = "permit.api.get_user() is deprecated ...; use permit.api.users.get() instead." - """, + """, # noqa: E501 - sample code as a user writes it }, ) - # The 3.x filter and the 3.x message itself, where "use permit.api" is not at the start, are fine. + # The 3.x filter, and the 3.x message where "use permit.api" is not at the start, are fine. assert findings(tmp_path) == [ ("conftest.py", 5, "D2", REVIEW), ("conftest.py", 6, "D2", REVIEW), @@ -1103,13 +1150,18 @@ def test_warning_filters_written_for_the_2x_text_need_review(tmp_path: Path): ] -def test_httpx_counts_as_declared_when_any_dependency_file_declares_it(tmp_path: Path): - write(tmp_path, {"app.py": "import httpx\nimport anyio\n", "requirements-dev.txt": "httpx==0.28.1\n"}) +def test_httpx_counts_as_declared_when_any_dependency_file_declares_it(tmp_path: Path) -> None: + write( + tmp_path, + {"app.py": "import httpx\nimport anyio\n", "requirements-dev.txt": "httpx==0.28.1\n"}, + ) assert findings(tmp_path) == [("app.py", 2, "C2", REVIEW)] -def test_every_package_that_left_the_tree_is_reported_when_imported_undeclared(tmp_path: Path): +def test_every_package_that_left_the_tree_is_reported_when_imported_undeclared( + tmp_path: Path, +) -> None: packages = ["httpx", "zipp", "httpcore", "h11", "anyio", "certifi", "sniffio", "exceptiongroup"] write(tmp_path, {"app.py": "".join(f"import {name}\n" for name in packages)}) @@ -1123,7 +1175,7 @@ def test_every_package_that_left_the_tree_is_reported_when_imported_undeclared(t assert {name for name in packages if f"`{name}`" in section} == set(packages), path.name -def test_a_compiled_requirements_file_is_a_lock_not_a_declaration(tmp_path: Path): +def test_a_compiled_requirements_file_is_a_lock_not_a_declaration(tmp_path: Path) -> None: write( tmp_path, { @@ -1157,7 +1209,7 @@ def test_a_compiled_requirements_file_is_a_lock_not_a_declaration(tmp_path: Path # --------------------------------------------------------------------------- -def test_scanner_skips_environments_and_build_output(tmp_path: Path): +def test_scanner_skips_environments_and_build_output(tmp_path: Path) -> None: deprecated_call = "from permit import Permit\nPermit(token='t').api.get_user('u')\n" write( tmp_path, @@ -1176,7 +1228,7 @@ def test_scanner_skips_environments_and_build_output(tmp_path: Path): assert findings(tmp_path) == [("src/app.py", 2, "D2", SAFE)] -def test_a_file_that_does_not_parse_is_skipped_and_reported(tmp_path: Path): +def test_a_file_that_does_not_parse_is_skipped_and_reported(tmp_path: Path) -> None: write(tmp_path, {"broken.py": "def (:\n", "app.py": "import permit\npermit.PYDANTIC_VERSION\n"}) project = scan.Project(tmp_path) @@ -1184,11 +1236,15 @@ def test_a_file_that_does_not_parse_is_skipped_and_reported(tmp_path: Path): assert [item["path"] for item in project.skipped] == ["broken.py"] -def test_scanner_does_not_modify_the_project(): +def test_scanner_does_not_modify_the_project() -> None: root = sample_app("v2_app") - def digest() -> Dict[str, str]: - return {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in root.rglob("*") if path.is_file()} + def digest() -> dict[str, str]: + return { + str(path): hashlib.sha256(path.read_bytes()).hexdigest() + for path in root.rglob("*") + if path.is_file() + } before = digest() subprocess.run([sys.executable, str(SCANNER), str(root)], capture_output=True, check=True) @@ -1204,7 +1260,9 @@ def digest() -> Dict[str, str]: pytest.param(["--unknown-flag"], 2, id="unknown-flag"), ], ) -def test_exit_status_is_non_zero_only_for_usage_errors(tmp_path: Path, arguments: List[str], status: int): +def test_exit_status_is_non_zero_only_for_usage_errors( + tmp_path: Path, arguments: list[str], status: int +) -> None: values = {"fixture": str(sample_app("v2_app")), "missing": str(tmp_path / "missing")} command = [sys.executable, str(SCANNER), *(argument.format(**values) for argument in arguments)] @@ -1213,20 +1271,36 @@ def test_exit_status_is_non_zero_only_for_usage_errors(tmp_path: Path, arguments assert result.returncode == status, result.stderr -def test_scanner_uses_only_the_standard_library_and_python_38_syntax(): +def test_scanner_uses_only_the_standard_library_and_python_38_syntax() -> None: source = SCANNER.read_text() tree = ast.parse(source, feature_version=(3, 8)) imported = { - alias.name.split(".")[0] for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names - } | {node.module.split(".")[0] for node in ast.walk(tree) if isinstance(node, ast.ImportFrom) and node.module} + alias.name.split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } | { + node.module.split(".")[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module + } assert imported <= sys.stdlib_module_names # Standard-library APIs newer than 3.8 that ast.parse's feature_version cannot see. - for newer in (".removeprefix(", ".removesuffix(", "ast.unparse", "strict=", "tomllib", "zoneinfo"): + for newer in ( + ".removeprefix(", + ".removesuffix(", + "ast.unparse", + "strict=", + "tomllib", + "zoneinfo", + ): assert newer not in source, newer for node in ast.walk(tree): if isinstance(node, ast.Subscript) and isinstance(node.value, ast.Name): - assert node.value.id not in ("list", "dict", "set", "tuple", "type"), "builtin generics need 3.9" + assert node.value.id not in ("list", "dict", "set", "tuple", "type"), ( + "builtin generics need 3.9" + ) # --------------------------------------------------------------------------- @@ -1234,7 +1308,7 @@ def test_scanner_uses_only_the_standard_library_and_python_38_syntax(): # --------------------------------------------------------------------------- -def frontmatter() -> Dict[str, str]: +def frontmatter() -> dict[str, str]: text = (SKILL_DIR / "SKILL.md").read_text() match = re.match(r"^---\n(.*?)\n---\n", text, re.DOTALL) assert match, "SKILL.md must start with YAML frontmatter" @@ -1246,7 +1320,7 @@ def frontmatter() -> Dict[str, str]: return fields -def test_skill_frontmatter_has_only_a_valid_name_and_description(): +def test_skill_frontmatter_has_only_a_valid_name_and_description() -> None: fields = frontmatter() assert set(fields) == {"name", "description"} @@ -1262,8 +1336,10 @@ def test_skill_frontmatter_has_only_a_valid_name_and_description(): assert trigger in description -def test_skill_passes_the_skill_creator_validator(): - quick_validate = pytest.importorskip("quick_validate", reason="skill-creator's quick_validate is not on the path") +def test_skill_passes_the_skill_creator_validator() -> None: + quick_validate = pytest.importorskip( + "quick_validate", reason="skill-creator's quick_validate is not on the path" + ) valid, message = quick_validate.validate_skill(SKILL_DIR) @@ -1282,7 +1358,7 @@ def skill_step(number: int) -> str: return flat(match.group(0)) -def test_skill_stops_on_any_python_below_310_before_editing_anything(): +def test_skill_stops_on_any_python_below_310_before_editing_anything() -> None: preflight = skill_step(1) assert "Stop if anything says Python below 3.10:" in preflight @@ -1293,15 +1369,21 @@ def test_skill_stops_on_any_python_below_310_before_editing_anything(): assert "Raise the C1 pins the user approved in step 1" in skill_step(3) -def test_skill_leaves_judgement_calls_and_checks_to_the_project(): +def test_skill_leaves_judgement_calls_and_checks_to_the_project() -> None: assert "Don't guess." in skill_step(5) assert "Remove imports an edit leaves unused" in skill_step(4) verify = skill_step(6) - for check in ("tests", "type checker", "linter", "deprecation warnings as errors", "Re-run the scan"): + for check in ( + "tests", + "type checker", + "linter", + "deprecation warnings as errors", + "Re-run the scan", + ): assert check in verify, check -def test_skill_is_self_contained_and_small(): +def test_skill_is_self_contained_and_small() -> None: files = sorted( path.relative_to(SKILL_DIR).as_posix() for path in SKILL_DIR.rglob("*") @@ -1314,7 +1396,9 @@ def test_skill_is_self_contained_and_small(): # A path out of the skill folder, not the ellipsis in `GET .../resources`. assert not re.search(r"(? str: """The text under a change's `### ID. Title` heading, up to the next heading.""" - match = re.search(rf"^### {change}\. .*?(?=^##)", path.read_text() + "\n## end", re.MULTILINE | re.DOTALL) + match = re.search( + rf"^### {change}\. .*?(?=^##)", path.read_text() + "\n## end", re.MULTILINE | re.DOTALL + ) assert match, f"{path.name} has no section for {change}" return match.group(0) -def change_headings(path: Path) -> Dict[str, str]: +def change_headings(path: Path) -> dict[str, str]: headings = re.findall(r"^#{2,4} ([A-Z]\d+)\. (.+)$", path.read_text(), re.MULTILINE) ids = [change for change, _ in headings] assert len(ids) == len(set(ids)), f"{path.name} has a change ID twice" return dict(headings) -def test_every_change_id_is_in_both_docs_under_the_same_heading(): +def test_every_change_id_is_in_both_docs_under_the_same_heading() -> None: catalogue = change_headings(CHANGES) assert catalogue == change_headings(MIGRATION) @@ -1345,7 +1431,7 @@ def test_every_change_id_is_in_both_docs_under_the_same_heading(): assert catalogue.get(change) == title, change -def test_the_catalogue_contents_list_every_change(): +def test_the_catalogue_contents_list_every_change() -> None: text = CHANGES.read_text() contents = text.split("## Contents", 1)[1].split("\n### ", 1)[0] @@ -1353,7 +1439,7 @@ def test_the_catalogue_contents_list_every_change(): assert f"[{change}" in contents, change -def deprecated_mapping() -> Dict[str, str]: +def deprecated_mapping() -> dict[str, str]: source = (REPO_ROOT / "permit" / "api" / "deprecated.py").read_text() mapping = {} for node in ast.walk(ast.parse(source)): @@ -1363,11 +1449,13 @@ def deprecated_mapping() -> Dict[str, str]: return mapping -def doc_mapping(path: Path) -> Dict[str, Tuple[str, Optional[Dict[str, str]]]]: +def doc_mapping(path: Path) -> dict[str, tuple[str, dict[str, str] | None]]: rows = re.findall( - r"^\| `permit\.api\.(\w+)\(\)` \| `(permit\.[\w.]+)\(\)` \|(.*)\|$", path.read_text(), re.MULTILINE + r"^\| `permit\.api\.(\w+)\(\)` \| `(permit\.[\w.]+)\(\)` \|(.*)\|$", + path.read_text(), + re.MULTILINE, ) - mapping: Dict[str, Tuple[str, Optional[Dict[str, str]]]] = {} + mapping: dict[str, tuple[str, dict[str, str] | None]] = {} for old, new, keywords in rows: assert old not in mapping, f"{path.name} lists {old} twice" renames = dict(re.findall(r"`(\w+)=` (?:to|becomes) `(\w+)=`", keywords)) @@ -1375,9 +1463,11 @@ def doc_mapping(path: Path) -> Dict[str, Tuple[str, Optional[Dict[str, str]]]]: return mapping -def test_the_21_method_mapping_matches_the_sdk_in_both_docs_and_the_scanner(): +def test_the_21_method_mapping_matches_the_sdk_in_both_docs_and_the_scanner() -> None: sdk = deprecated_mapping() - scanner = {old: (f"permit.{new}", renames) for old, (new, renames) in scan.DEPRECATED_METHODS.items()} + scanner = { + old: (f"permit.{new}", renames) for old, (new, renames) in scan.DEPRECATED_METHODS.items() + } assert len(sdk) == 21 assert {old: new for old, (new, _) in scanner.items()} == sdk @@ -1385,11 +1475,11 @@ def test_the_21_method_mapping_matches_the_sdk_in_both_docs_and_the_scanner(): assert doc_mapping(MIGRATION) == scanner -def test_the_keyword_renames_match_the_sdk_signatures(): - """A rename is where the deprecated method and its replacement name the same position differently.""" +def test_the_keyword_renames_match_the_sdk_signatures() -> None: + """A rename: the deprecated method and its replacement name one position differently.""" client = Permit(PermitConfig(token="permit_key_test")) for old, (new, renames) in scan.DEPRECATED_METHODS.items(): - replacement = client + replacement: Any = client for part in new.split("."): replacement = getattr(replacement, part) old_parameters = list(inspect.signature(getattr(client.api, old)).parameters) @@ -1406,19 +1496,19 @@ def test_the_keyword_renames_match_the_sdk_signatures(): assert len(old_parameters) == len(new_parameters), old -def test_the_removed_names_really_are_gone(): +def test_the_removed_names_really_are_gone() -> None: for module_name, name in scan.REMOVED: module = importlib.import_module(module_name) assert not hasattr(module, name), f"{module_name}.{name} still exists" -def removed_rows(path: Path, change: str) -> Dict[Tuple[str, str], Optional[str]]: +def removed_rows(path: Path, change: str) -> dict[tuple[str, str], str | None]: """(module, name) -> the Safety cell, or None, for each removed name a change's table lists. A cell names them as `permit.module.name`, or as `name`, `name` from `module`, `module`, with `;` between groups. Rows that name no module (methods, say) are skipped. """ - rows: Dict[Tuple[str, str], Optional[str]] = {} + rows: dict[tuple[str, str], str | None] = {} for line in doc_section(path, change).splitlines(): cells = [cell.strip() for cell in line.strip().strip("|").split("|")] if not line.lstrip().startswith("|") or len(cells) < 2 or set(cells[0]) <= {"-", " "}: @@ -1433,36 +1523,44 @@ def removed_rows(path: Path, change: str) -> Dict[Tuple[str, str], Optional[str] continue module = "" for item in re.findall(r"`([\w.]+)`", group): + name = item if item.startswith("permit."): - module, _, item = item.rpartition(".") + module, _, name = item.rpartition(".") if module: - rows[(module, item)] = safety + rows[(module, name)] = safety return rows -def test_the_removed_name_tables_match_the_scanner(): +def test_the_removed_name_tables_match_the_scanner() -> None: for change in ("A3", "A6"): - scanner = {key: safety for key, (found, safety, _) in scan.REMOVED.items() if found == change} + scanner = { + key: safety for key, (found, safety, _) in scan.REMOVED.items() if found == change + } assert removed_rows(CHANGES, change) == scanner, change section = doc_section(MIGRATION, change) for _, name in scanner: - assert re.search(rf"`(?:[\w.]+\.)?{name}`", section), f"MIGRATION.md {change} does not name {name}" + assert re.search(rf"`(?:[\w.]+\.)?{name}`", section), ( + f"MIGRATION.md {change} does not name {name}" + ) assert set(removed_rows(MIGRATION, "A6")) == set(removed_rows(CHANGES, "A6")) -def test_the_floor_tables_match_the_runtime_requirements(): +def test_the_floor_tables_match_the_runtime_requirements() -> None: with (REPO_ROOT / "pyproject.toml").open("rb") as file: dependencies = tomllib.load(file)["project"]["dependencies"] - requirements: Dict[str, List[Requirement]] = {} + requirements: dict[str, list[Requirement]] = {} for dependency in dependencies: requirement = Requirement(dependency) requirements.setdefault(requirement.name.lower().replace("_", "-"), []).append(requirement) def allowed(name: str, version: str, python: str) -> bool: for requirement in requirements[name]: - if requirement.marker is None or requirement.marker.evaluate({"python_version": python}): + if requirement.marker is None or requirement.marker.evaluate( + {"python_version": python} + ): return requirement.specifier.contains(version, prereleases=True) - raise AssertionError(f"no {name} requirement applies to Python {python}") + msg = f"no {name} requirement applies to Python {python}" + raise AssertionError(msg) def below(version: str) -> str: """A version just below a floor: 2.8.0 -> 2.7.999, 1.10.18 -> 1.10.17, 2.13 -> 2.12.""" @@ -1471,22 +1569,34 @@ def below(version: str) -> str: return ".".join(str(part) for part in parts) for path in (MIGRATION, CHANGES): - cells = dict(re.findall(r"^\s*\| `([\w-]+)` \| `[^|]*` \| (`.+) \|$", path.read_text(), re.MULTILINE)) + cells = dict( + re.findall(r"^\s*\| `([\w-]+)` \| `[^|]*` \| (`.+) \|$", path.read_text(), re.MULTILINE) + ) assert set(cells) == {"aiohttp", "loguru", "typing-extensions", "pydantic"}, path.name for name in ("aiohttp", "loguru", "typing-extensions"): specifier = SpecifierSet(cells[name].strip("`")) - assert [requirement.specifier for requirement in requirements[name]] == [specifier], name + assert [requirement.specifier for requirement in requirements[name]] == [specifier], ( + name + ) assert scan.FLOOR_TEXT[name] == f"{name}{cells[name].strip('`')}" floor = next(spec.version for spec in specifier if spec.operator == ">=") ceiling = next(spec.version for spec in specifier if spec.operator == "<") assert scan.FLOORS[name] == [(Version(floor).release, Version(ceiling).release)], name - floors = re.findall(r"`>=([\d.]+),<2` or `>=([\d.]+)` on (?:Python )?(3\.\d+)(?:-(3\.\d+))?", cells["pydantic"]) - assert [row[2:] for row in floors] == [("3.10", "3.12"), ("3.13", ""), ("3.14", "")], path.name + floors = re.findall( + r"`>=([\d.]+),<2` or `>=([\d.]+)` on (?:Python )?(3\.\d+)(?:-(3\.\d+))?", + cells["pydantic"], + ) + assert [row[2:] for row in floors] == [("3.10", "3.12"), ("3.13", ""), ("3.14", "")], ( + path.name + ) for v1_floor, v2_floor, first, last in floors: for minor in range(int(first[2:]), int((last or first)[2:]) + 1): for floor in (v1_floor, v2_floor): assert allowed("pydantic", floor, f"3.{minor}"), (floor, minor) - assert not allowed("pydantic", below(floor), f"3.{minor}"), (below(floor), minor) + assert not allowed("pydantic", below(floor), f"3.{minor}"), ( + below(floor), + minor, + ) documented = {version for row in floors for version in row[:2]} assert set(re.findall(r">=([\d.]+)", scan.FLOOR_TEXT["pydantic"])) == documented assert scan.FLOORS["pydantic"] == [ @@ -1495,8 +1605,11 @@ def below(version: str) -> str: ] -def test_the_staying_on_2x_advice_states_what_was_verified(): - for path, heading in ((MIGRATION, "## Staying on 2.x for now"), (SKILL_DIR / "SKILL.md", "## Staying on 2.x")): +def test_the_staying_on_2x_advice_states_what_was_verified() -> None: + for path, heading in ( + (MIGRATION, "## Staying on 2.x for now"), + (SKILL_DIR / "SKILL.md", "## Staying on 2.x"), + ): section = flat(path.read_text().split(heading, 1)[1].split("\n## ", 1)[0]) for fact in ( "aiohttp>=3.14.3", @@ -1509,7 +1622,9 @@ def test_the_staying_on_2x_advice_states_what_was_verified(): ): assert fact in section, (path.name, fact) assert "On Python 3.8 or 3.9 this is not possible." in flat(MIGRATION.read_text()) - assert "the aiohttp and anyio fixes can't be installed" in flat((SKILL_DIR / "SKILL.md").read_text()) + assert "the aiohttp and anyio fixes can't be installed" in flat( + (SKILL_DIR / "SKILL.md").read_text() + ) # --------------------------------------------------------------------------- @@ -1526,23 +1641,44 @@ def test_flat_call(): """ -def run_pytest_with(tmp_path: Path, options: List[str]) -> str: +def run_pytest_with(tmp_path: Path, options: list[str]) -> str: """Run a test that makes one flat permit.api call under the given -W options.""" if PYDANTIC_VERSION < (2, 0): # SKILL.md step 6 and D1: on pydantic 1, `import permit` warns once, so add this filter too. options = [*options, "-W", "ignore:Support for pydantic 1:DeprecationWarning"] (tmp_path / "test_flat.py").write_text(FLAT_CALL_TEST) - command = [sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", *options, "test_flat.py"] + command = [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + *options, + "test_flat.py", + ] # The permit under test, whether or not it is installed, and no warning settings from outside. - env = {name: value for name, value in os.environ.items() if name not in ("PYTHONWARNINGS", "PYTHONDEVMODE")} + env = { + name: value + for name, value in os.environ.items() + if name not in ("PYTHONWARNINGS", "PYTHONDEVMODE") + } env["PYTHONPATH"] = str(REPO_ROOT) - result = subprocess.run(command, cwd=tmp_path, env=env, capture_output=True, text=True, check=False, timeout=120) + result = subprocess.run( + command, cwd=tmp_path, env=env, capture_output=True, text=True, check=False, timeout=120 + ) return result.stdout + result.stderr -@pytest.mark.parametrize("path", [SKILL_DIR / "SKILL.md", MIGRATION], ids=["SKILL.md", "MIGRATION.md"]) -def test_the_documented_warnings_as_errors_run_fails_on_a_flat_call(tmp_path: Path, path: Path): - command = re.search(r"^\s*python -m pytest((?: -W (?:\"[^\"]+\"|\S+))+)\s*$", path.read_text(), re.MULTILINE) +@pytest.mark.parametrize( + "path", [SKILL_DIR / "SKILL.md", MIGRATION], ids=["SKILL.md", "MIGRATION.md"] +) +def test_the_documented_warnings_as_errors_run_fails_on_a_flat_call( + tmp_path: Path, path: Path +) -> None: + command = re.search( + r"^\s*python -m pytest((?: -W (?:\"[^\"]+\"|\S+))+)\s*$", path.read_text(), re.MULTILINE + ) assert command, f"{path.name} has no `python -m pytest -W ...` command" output = run_pytest_with(tmp_path, shlex.split(command.group(1))) @@ -1552,7 +1688,7 @@ def test_the_documented_warnings_as_errors_run_fails_on_a_flat_call(tmp_path: Pa assert "DeprecationWarning: permit.api.get_user() is deprecated" in output, output -def test_the_documented_narrow_filter_fails_only_on_the_flat_methods(tmp_path: Path): +def test_the_documented_narrow_filter_fails_only_on_the_flat_methods(tmp_path: Path) -> None: for path in (SKILL_DIR / "SKILL.md", MIGRATION): assert '-W "error:permit.api.:DeprecationWarning"' in path.read_text(), path.name @@ -1562,30 +1698,36 @@ def test_the_documented_narrow_filter_fails_only_on_the_flat_methods(tmp_path: P assert "DeprecationWarning: permit.api.get_user() is deprecated" in output, output -def test_the_documented_filter_silences_the_flat_methods(httpserver: HTTPServer, config: PermitConfig): +def test_the_documented_filter_silences_the_flat_methods( + httpserver: HTTPServer, config: PermitConfig +) -> None: text = MIGRATION.read_text() code = re.search(r"In code: `(warnings\.filterwarnings\(.+\))`\.", text) assert code, "MIGRATION.md has no in-code filter" ini_filter = re.search(r"^\s*ignore:(permit\\\.api.+):DeprecationWarning$", text, re.MULTILINE) assert ini_filter, "MIGRATION.md has no pytest.ini filter for the flat methods" - assert f'message=r"{ini_filter.group(1)}"' in code.group(1), "the ini and in-code filters differ" - httpserver.expect_request(f"{FACTS}/users/user-1", method="GET").respond_with_json(user_json("user-1")) + assert f'message=r"{ini_filter.group(1)}"' in code.group(1), ( + "the ini and in-code filters differ" + ) + httpserver.expect_request(f"{FACTS}/users/user-1", method="GET").respond_with_json( + user_json("user-1") + ) client = Permit(config) with warnings.catch_warnings(): warnings.simplefilter("error", DeprecationWarning) with pytest.raises(DeprecationWarning): asyncio.run(client.api.get_user("user-1")) - exec(code.group(1), {"warnings": warnings}) + exec(code.group(1), {"warnings": warnings}) # noqa: S102 - the guide's snippet under test assert asyncio.run(client.api.get_user("user-1")).key == "user-1" -def diff_sides(change: str) -> Tuple[str, str]: +def diff_sides(change: str) -> tuple[str, str]: """The code before and after the first diff under a change's heading in MIGRATION.md.""" block = re.search(r"```diff\n(.*?)```", doc_section(MIGRATION, change), re.DOTALL) assert block, f"MIGRATION.md {change} has no diff" - before: List[str] = [] - after: List[str] = [] + before: list[str] = [] + after: list[str] = [] for line in block.group(1).splitlines(): marker, code = line[:2], line[2:] if marker in ("- ", " ", ""): @@ -1595,12 +1737,15 @@ def diff_sides(change: str) -> Tuple[str, str]: return "\n".join(before), "\n".join(after) -def run_snippet(code: str, namespace: Dict[str, Any]) -> Dict[str, Any]: +def run_snippet(code: str, namespace: dict[str, Any]) -> dict[str, Any]: """Run a snippet from the guide, as a coroutine when it awaits, and return what it bound.""" + # The snippets are the guide's own examples, which is what these tests check. if "await " not in code: - exec(code, namespace) + exec(code, namespace) # noqa: S102 return namespace - exec(f"async def _snippet():\n{textwrap.indent(code, ' ')}\n return locals()\n", namespace) + exec( # noqa: S102 + f"async def _snippet():\n{textwrap.indent(code, ' ')}\n return locals()\n", namespace + ) return asyncio.run(namespace["_snippet"]()) @@ -1613,11 +1758,17 @@ def run_snippet(code: str, namespace: Dict[str, Any]) -> Dict[str, Any]: } -def user_json(key: str) -> Dict[str, Any]: - return {**IDS, "key": key, "email": f"{key}@example.com", "created_at": TIMESTAMP, "updated_at": TIMESTAMP} +def user_json(key: str) -> dict[str, Any]: + return { + **IDS, + "key": key, + "email": f"{key}@example.com", + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + } -def test_the_guide_a1_diff_reads_the_page(httpserver: HTTPServer, config: PermitConfig): +def test_the_guide_a1_diff_reads_the_page(httpserver: HTTPServer, config: PermitConfig) -> None: relation = { **IDS, "key": "parent", @@ -1643,7 +1794,7 @@ def test_the_guide_a1_diff_reads_the_page(httpserver: HTTPServer, config: Permit def test_the_guide_a2_diff_calls_the_blocking_method_directly( httpserver: HTTPServer, config: PermitConfig, monkeypatch: pytest.MonkeyPatch -): +) -> None: httpserver.expect_request("/authorized_users", method="POST").respond_with_json( {"resource": "document:1", "tenant": "default", "users": {}} ) @@ -1664,7 +1815,7 @@ def blocking_client(token: str) -> SyncPermit: @pytest.mark.parametrize("change", ["A3", "A6"]) -def test_the_guide_import_diffs_import_what_3_0_has(change: str): +def test_the_guide_import_diffs_import_what_3_0_has(change: str) -> None: before, after = diff_sides(change) run_snippet(after, {}) @@ -1672,23 +1823,30 @@ def test_the_guide_import_diffs_import_what_3_0_has(change: str): run_snippet(before, {}) -def test_the_guide_a4_and_a5_diffs_handle_missing_values(): - log = DetailedAuditLogModel.construct(pdp_config_id=None, objects={}) +def test_the_guide_a4_and_a5_diffs_handle_missing_values() -> None: + # construct() skips validation, so a model can hold only the fields a snippet reads; the + # pydantic plugin types it as if every required field had to be passed. + log = DetailedAuditLogModel.construct(pdp_config_id=None, objects={}) # type: ignore[call-arg, arg-type] before, after = diff_sides("A4") found = run_snippet(after, {"log": log, "AuditLogObjectsModel": AuditLogObjectsModel}) assert (found["config_id"], found["user"]) == (None, None) with pytest.raises(AttributeError): run_snippet(before, {"log": log}) - tuples = [RelationshipTupleRead.construct(object_id=None), RelationshipTupleRead.construct(object_id=UUID(int=1))] + tuples = [ + RelationshipTupleRead.construct(object_id=None), # type: ignore[call-arg] + RelationshipTupleRead.construct(object_id=UUID(int=1)), # type: ignore[call-arg] + ] before, after = diff_sides("A5") assert run_snippet(after, {"tuples": tuples})["ids"] == [UUID(int=1).hex] with pytest.raises(AttributeError): run_snippet(before, {"tuples": tuples}) -def test_the_guide_w1_diff_sends_only_the_fields_that_have_values(httpserver: HTTPServer, config: PermitConfig): - bodies: List[Any] = [] +def test_the_guide_w1_diff_sends_only_the_fields_that_have_values( + httpserver: HTTPServer, config: PermitConfig +) -> None: + bodies: list[Any] = [] def record(request: Request) -> Response: bodies.append(json.loads(request.get_data())) @@ -1705,7 +1863,9 @@ def record(request: Request) -> Response: assert bodies == [{"first_name": "Ada"}, {"first_name": "Ada", "last_name": None}] -def test_the_guide_w5_diff_matches_the_header_permit_sends(httpserver: HTTPServer, config: PermitConfig): +def test_the_guide_w5_diff_matches_the_header_permit_sends( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_request("/allowed", method="POST").respond_with_json({"allow": True}) asyncio.run(Permit(config).check("user-1", "read", "document")) request = httpserver.log[-1][0] @@ -1716,8 +1876,12 @@ def test_the_guide_w5_diff_matches_the_header_permit_sends(httpserver: HTTPServe run_snippet(before, {"request": request, "token": config.token}) -def test_the_guide_t2_diff_uses_the_pydantic_v1_method(httpserver: HTTPServer, config: PermitConfig): - httpserver.expect_request(f"{FACTS}/users/user-1", method="GET").respond_with_json(user_json("user-1")) +def test_the_guide_t2_diff_uses_the_pydantic_v1_method( + httpserver: HTTPServer, config: PermitConfig +) -> None: + httpserver.expect_request(f"{FACTS}/users/user-1", method="GET").respond_with_json( + user_json("user-1") + ) before, after = diff_sides("T2") assert run_snippet(after, {"permit": Permit(config)})["data"]["key"] == "user-1" @@ -1725,7 +1889,9 @@ def test_the_guide_t2_diff_uses_the_pydantic_v1_method(httpserver: HTTPServer, c run_snippet(before, {"permit": Permit(config)}) -def test_the_guide_d2_diff_sends_the_same_requests(httpserver: HTTPServer, config: PermitConfig): +def test_the_guide_d2_diff_sends_the_same_requests( + httpserver: HTTPServer, config: PermitConfig +) -> None: assignment = { **IDS, "user": "user-1", @@ -1736,8 +1902,12 @@ def test_the_guide_d2_diff_sends_the_same_requests(httpserver: HTTPServer, confi "tenant_id": IDS["id"], "created_at": TIMESTAMP, } - httpserver.expect_request(f"{FACTS}/users/user-1", method="GET").respond_with_json(user_json("user-1")) - httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="POST").respond_with_json(assignment) + httpserver.expect_request(f"{FACTS}/users/user-1", method="GET").respond_with_json( + user_json("user-1") + ) + httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="POST").respond_with_json( + assignment + ) before, after = diff_sides("D2") run_snippet(after, {"permit": Permit(config)}) @@ -1747,24 +1917,30 @@ def test_the_guide_d2_diff_sends_the_same_requests(httpserver: HTTPServer, confi run_snippet(before, {"permit": Permit(config)}) assert [sent(request) for request, _ in httpserver.log] == replacement - assert [request["path"] for request in replacement] == [f"{FACTS}/users/user-1", f"{FACTS}/users/user-1/roles"] + assert [request["path"] for request in replacement] == [ + f"{FACTS}/users/user-1", + f"{FACTS}/users/user-1/roles", + ] -def safety_markers(section: str) -> Set[str]: +def safety_markers(section: str) -> set[str]: bold = re.findall(r"\*\*(SAFE|NEEDS-REVIEW)\b", section) cells = re.findall(r"\| (SAFE|NEEDS-REVIEW) \|", section) return set(bold) | set(cells) -def test_the_catalogue_states_the_safety_the_scanner_reports(tmp_path: Path): +def test_the_catalogue_states_the_safety_the_scanner_reports(tmp_path: Path) -> None: write( tmp_path, { "requirements-dev.txt": "permit @ git+https://github.com/permitio/permit-python\n", - "app.py": "import anyio\nfrom permit import Permit # type: ignore[import-untyped, attr-defined]\n", + "app.py": ( + "import anyio\n" + "from permit import Permit # type: ignore[import-untyped, attr-defined]\n" + ), }, ) - reported: Dict[str, Set[str]] = {} + reported: dict[str, set[str]] = {} for _, _, change, safety in findings(sample_app("v2_app")) + findings(tmp_path): reported.setdefault(change, set()).add(safety) @@ -1772,8 +1948,8 @@ def test_the_catalogue_states_the_safety_the_scanner_reports(tmp_path: Path): assert safety_markers(doc_section(CHANGES, change)) == reported.get(change, set()), change -def test_the_catalogue_never_calls_an_untraced_receiver_safe(): - items: List[List[str]] = [] +def test_the_catalogue_never_calls_an_untraced_receiver_safe() -> None: + items: list[list[str]] = [] for line in CHANGES.read_text().splitlines(): if re.match(r"^\s*- ", line): items.append([line.strip()]) diff --git a/tests/conftest.py b/tests/conftest.py index 672cb33..e29f4c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,6 +2,8 @@ import functools import os import random +from collections.abc import Awaitable, Callable, Coroutine, Iterator +from typing import Any, ParamSpec, TypeVar import pytest from loguru import logger @@ -25,6 +27,10 @@ def config(httpserver: HTTPServer) -> PermitConfig: return offline_config(httpserver.url_for("").rstrip("/")) +P = ParamSpec("P") +R = TypeVar("R") + + # The fixtures below need a real API key, the Permit API and a PDP. Every test # that uses them is marked e2e, which the offline CI job deselects. MISSING_KEY = ( @@ -36,9 +42,13 @@ def config(httpserver: HTTPServer) -> PermitConfig: @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) @@ -126,7 +136,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 @@ -136,9 +146,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) @@ -151,16 +163,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 5c0047e..08c3f73 100644 --- a/tests/endpoints/test_bulk_operations.py +++ b/tests/endpoints/test_bulk_operations.py @@ -138,7 +138,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) @@ -228,7 +228,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 fc892c5..651ad22 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 pytestmark = pytest.mark.e2e @@ -74,18 +73,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) @@ -95,15 +94,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 @@ -112,9 +111,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[0].key, environment_data=environment_data ) assert environment is not None assert environment.key == environment_data.key @@ -129,7 +128,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 @@ -139,7 +140,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: @@ -148,7 +149,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) @@ -156,19 +157,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 ) @@ -187,7 +187,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 d95c76e..3352170 100644 --- a/tests/endpoints/test_error_response.py +++ b/tests/endpoints/test_error_response.py @@ -2,23 +2,18 @@ from loguru import logger from permit import Permit -from permit.exceptions import PermitApiError, PermitConnectionError +from permit.exceptions import PermitApiError pytestmark = pytest.mark.e2e -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 6eb8d9f..6630357 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 pytestmark = pytest.mark.e2e @@ -22,7 +20,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 @@ -31,7 +29,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)) @@ -40,7 +38,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) == [] @@ -81,7 +79,13 @@ 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( + { + "key": TEST_RESOURCE_DOC_KEY, + "name": "document2", + "actions": {}, + } + ) assert e.value.status_code == 409 # create empty item @@ -109,7 +113,10 @@ async def test_resources(permit: Permit): # update actions await permit.api.resources.update( TEST_RESOURCE_FOLDER_KEY, - {"description": "wat", "actions": {"pick": {}}}, + { + "description": "wat", + "actions": {"pick": {}}, + }, ) # get diff --git a/tests/endpoints/test_resources_sync.py b/tests/endpoints/test_resources_sync.py index d5829c7..8522f43 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 pytestmark = pytest.mark.e2e @@ -22,7 +20,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 @@ -31,7 +29,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)) @@ -40,7 +38,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 @@ -82,7 +80,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 d1654a7..82de8cd 100644 --- a/tests/endpoints/test_role_assignments.py +++ b/tests/endpoints/test_role_assignments.py @@ -1,9 +1,9 @@ import asyncio -from typing import Awaitable, Callable, List, Sequence, TypeVar, Union +from collections.abc import Awaitable, Callable, Sequence +from typing import TypeVar import pytest from loguru import logger -from tests.utils import handle_cleanup_error, unique_key from permit import ( Permit, @@ -14,6 +14,7 @@ UserCreate, ) from permit.exceptions import PermitApiDetailedError +from tests.utils import handle_cleanup_error, unique_key pytestmark = pytest.mark.e2e @@ -27,7 +28,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)] @@ -74,9 +75,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 @@ -106,7 +107,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" @@ -129,7 +130,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" @@ -143,7 +144,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 7460b1f..40a74a9 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 pytestmark = pytest.mark.e2e @@ -53,7 +54,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, @@ -62,7 +63,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)) @@ -71,7 +72,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) == [] @@ -149,19 +150,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) @@ -170,6 +178,7 @@ 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 @@ -186,6 +195,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 fda4238..322dc3c 100644 --- a/tests/endpoints/test_users_tenants.py +++ b/tests/endpoints/test_users_tenants.py @@ -49,7 +49,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() @@ -93,6 +93,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 @@ -117,6 +119,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 @@ -126,7 +130,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, + { + "description": "t2 update", + }, + ) assert t2.key == TENANT_2.key assert t2.description != TENANT_2.description assert t2.description == "t2 update" @@ -163,13 +172,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) @@ -181,7 +195,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 ae7057a..0429968 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,14 +20,13 @@ UserCreate, ) from permit.exceptions import PermitApiError, PermitConnectionError - -from .utils import handle_api_error, handle_cleanup_error, unique_key +from tests.utils import handle_api_error, handle_cleanup_error, unique_key pytestmark = pytest.mark.e2e -def print_break(): - print("\n\n ----------- \n\n") # noqa: T201 +def print_break() -> None: + print("\n\n ----------- \n\n") PER_PAGE: Final[int] = 100 @@ -66,7 +67,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 @@ -91,7 +102,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}") @@ -103,7 +114,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 @@ -115,7 +126,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("alice"), @@ -201,7 +214,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 @@ -229,6 +244,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 @@ -320,7 +337,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( @@ -352,13 +371,16 @@ async def test_abac_e2e(permit: Permit): # PER-16209. Skipped rather than xfailed so it reports honestly instead # of looking covered. pytest.Skipped derives from BaseException, so it # escapes the `except Exception` below and the `finally` teardown runs. - pytest.skip("ABAC decision assertions are pending PER-16209; " "the control-plane assertions above still run.") + pytest.skip( + "ABAC decision assertions are pending PER-16209; " + "the control-plane assertions above still run." + ) except PermitApiError as error: 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: @@ -375,29 +397,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 c6fa9e9..737f9b3 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 @@ -36,11 +36,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", @@ -50,7 +50,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", @@ -60,13 +60,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", @@ -75,30 +72,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_audit_logs.py b/tests/test_fix_audit_logs.py index ae09058..56c02a2 100644 --- a/tests/test_fix_audit_logs.py +++ b/tests/test_fix_audit_logs.py @@ -66,7 +66,7 @@ ) -def audit_log(**fields: Any) -> dict: +def audit_log(**fields: Any) -> dict[str, Any]: """An audit-log list item as the API returns it, with a known pdp_config_id by default.""" return { "id": str(LOG_ID), @@ -84,17 +84,17 @@ def audit_log(**fields: Any) -> dict: } -def detailed_audit_log(raw_data: dict, **fields: Any) -> dict: +def detailed_audit_log(raw_data: dict[str, Any], **fields: Any) -> dict[str, Any]: """A detailed audit log as the API returns it, with ``objects`` present by default.""" return audit_log(raw_data=raw_data, objects={}, **fields) -def without(payload: dict, key: str) -> dict: +def without(payload: dict[str, Any], key: str) -> dict[str, Any]: return {k: v for k, v in payload.items() if k != key} @pdp_config_id_missing -def test_audit_log_parses_without_pdp_config_id(pdp_config_id_field: dict): +def test_audit_log_parses_without_pdp_config_id(pdp_config_id_field: dict[str, Any]) -> None: payload = {**without(audit_log(), "pdp_config_id"), **pdp_config_id_field} log = AuditLogModel.parse_obj(payload) @@ -104,7 +104,9 @@ def test_audit_log_parses_without_pdp_config_id(pdp_config_id_field: dict): @pdp_config_id_missing -def test_detailed_audit_log_parses_without_pdp_config_id(pdp_config_id_field: dict): +def test_detailed_audit_log_parses_without_pdp_config_id( + pdp_config_id_field: dict[str, Any], +) -> None: payload = {**without(detailed_audit_log(OPA_RAW_DATA), "pdp_config_id"), **pdp_config_id_field} log = DetailedAuditLogModel.parse_obj(payload) @@ -114,7 +116,9 @@ def test_detailed_audit_log_parses_without_pdp_config_id(pdp_config_id_field: di @pdp_config_id_missing -def test_audit_log_page_parses_items_without_pdp_config_id(pdp_config_id_field: dict): +def test_audit_log_page_parses_items_without_pdp_config_id( + pdp_config_id_field: dict[str, Any], +) -> None: page = LimitedPaginatedResultAuditLogModel.parse_obj( { "data": [ @@ -138,7 +142,9 @@ def test_audit_log_page_parses_items_without_pdp_config_id(pdp_config_id_field: ], ids=["list", "detailed"], ) -def test_audit_logs_parse_a_generic_engine_log(model: type, payload: dict): +def test_audit_logs_parse_a_generic_engine_log( + model: type[AuditLogModel | DetailedAuditLogModel], payload: dict[str, Any] +) -> None: log = model.parse_obj(payload) assert isinstance(log.raw_data, GenericEngineDecisionLog) @@ -148,7 +154,7 @@ def test_audit_logs_parse_a_generic_engine_log(model: type, payload: dict): assert log.raw_data.user_key == "alice" -def test_detailed_audit_log_parses_without_objects(): +def test_detailed_audit_log_parses_without_objects() -> None: payload = without(detailed_audit_log(OPA_RAW_DATA), "objects") log = DetailedAuditLogModel.parse_obj(payload) @@ -162,7 +168,9 @@ def test_detailed_audit_log_parses_without_objects(): [(OPA_RAW_DATA, OPAEngineDecisionLog), (AVP_RAW_DATA, AVPEngineDecisionLog)], ids=["opa", "avp"], ) -def test_detailed_audit_log_keeps_parsing_opa_and_avp_logs(raw_data: dict, engine_log_type: type): +def test_detailed_audit_log_keeps_parsing_opa_and_avp_logs( + raw_data: dict[str, Any], engine_log_type: type +) -> None: """Adding the GENERIC engine must not change how existing engine logs parse.""" log = DetailedAuditLogModel.parse_obj(detailed_audit_log(raw_data)) @@ -171,7 +179,7 @@ def test_detailed_audit_log_keeps_parsing_opa_and_avp_logs(raw_data: dict, engin @pytest.mark.parametrize("engine", ["OPA", "AVP"]) -def test_generic_engine_log_does_not_take_other_engines_logs(engine: str): +def test_generic_engine_log_does_not_take_other_engines_logs(engine: str) -> None: """An OPA or AVP log with GENERIC's required fields is not parsed as a GENERIC log.""" raw_data = {"engine": engine, "timestamp": TIMESTAMP, "decision": True} diff --git a/tests/test_fix_deprecated_facade.py b/tests/test_fix_deprecated_facade.py index d4c3086..b698ea2 100644 --- a/tests/test_fix_deprecated_facade.py +++ b/tests/test_fix_deprecated_facade.py @@ -7,17 +7,20 @@ ``/v2/api-key/scope`` lookup are needed. """ +import ast import asyncio import copy import inspect import json import os +import re import subprocess import sys import warnings +from collections.abc import Awaitable, Callable from operator import attrgetter from pathlib import Path -from typing import Any, Callable, Dict, List, NamedTuple, Optional, Tuple, Union +from typing import Any, NamedTuple import pytest from pytest_httpserver import HTTPServer @@ -53,15 +56,27 @@ } -def user(key: str) -> Dict[str, Any]: - return {**IDS, "key": key, "email": f"{key}@example.com", "created_at": TIMESTAMP, "updated_at": TIMESTAMP} +def user(key: str) -> dict[str, Any]: + return { + **IDS, + "key": key, + "email": f"{key}@example.com", + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + } -def role(key: str) -> Dict[str, Any]: - return {**IDS, "key": key, "name": key.title(), "created_at": TIMESTAMP, "updated_at": TIMESTAMP} +def role(key: str) -> dict[str, Any]: + return { + **IDS, + "key": key, + "name": key.title(), + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + } -def tenant(key: str) -> Dict[str, Any]: +def tenant(key: str) -> dict[str, Any]: return { **IDS, "key": key, @@ -72,11 +87,17 @@ def tenant(key: str) -> Dict[str, Any]: } -def resource(key: str) -> Dict[str, Any]: - return {**IDS, "key": key, "name": key.title(), "created_at": TIMESTAMP, "updated_at": TIMESTAMP} +def resource(key: str) -> dict[str, Any]: + return { + **IDS, + "key": key, + "name": key.title(), + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + } -def assignment() -> Dict[str, Any]: +def assignment() -> dict[str, Any]: return { **IDS, "user": "user-1", @@ -102,9 +123,9 @@ class FacadeCase(NamedTuple): facade: Call replacement: Call - request: Tuple[str, str] - response: Union[Dict[str, Any], List[Dict[str, Any]], None] - model: Optional[type] + request: tuple[str, str] + response: dict[str, Any] | list[dict[str, Any]] | None + model: type | None NEW_USER = {"key": "user-1", "email": "user-1@example.com"} @@ -141,7 +162,9 @@ class FacadeCase(NamedTuple): ), FacadeCase( facade=call("permit.api.get_assigned_roles", "user-1", "tenant-1", page=2, per_page=10), - replacement=call("permit.api.users.get_assigned_roles", "user-1", tenant="tenant-1", page=2, per_page=10), + replacement=call( + "permit.api.users.get_assigned_roles", "user-1", tenant="tenant-1", page=2, per_page=10 + ), request=("GET", f"{FACTS}/role_assignments"), response=[assignment()], model=RoleAssignmentRead, @@ -295,7 +318,9 @@ class FacadeCase(NamedTuple): ), FacadeCase( facade=call("permit.api.update_resource", "document", ResourceUpdate(**RESOURCE_CHANGES)), - replacement=call("permit.api.resources.update", "document", ResourceUpdate(**RESOURCE_CHANGES)), + replacement=call( + "permit.api.resources.update", "document", ResourceUpdate(**RESOURCE_CHANGES) + ), request=("PATCH", f"{SCHEMA}/resources/document"), response=resource("document"), model=ResourceRead, @@ -319,38 +344,61 @@ class FacadeCase(NamedTuple): def removal_warning(case: FacadeCase) -> str: return ( - f"{case.facade.path}() is deprecated and will be removed in permit 4.0; use {case.replacement.path}() instead." + f"{case.facade.path}() is deprecated and will be removed in permit 4.0; " + f"use {case.replacement.path}() instead." ) -def deprecations(caught: List[warnings.WarningMessage]) -> List[Tuple[type, str, str, int]]: +def deprecations(caught: list[warnings.WarningMessage]) -> list[tuple[type, str, str, int]]: """Every DeprecationWarning in ``caught``, whoever raised it, and the line it points at. Other categories are left out: a ResourceWarning, for one, comes from garbage collection and can land in whichever test happens to be running. """ return [ - (w.category, str(w.message), w.filename, w.lineno) for w in caught if issubclass(w.category, DeprecationWarning) + (w.category, str(w.message), w.filename, w.lineno) + for w in caught + if issubclass(w.category, DeprecationWarning) ] -def call_blocking(method: Callable[..., Any], args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any: +def call_blocking( + method: Callable[..., object], args: tuple[Any, ...], kwargs: dict[str, Any] +) -> object: return method(*args, **kwargs) -async def call_awaiting(method: Callable[..., Any], args: Tuple[Any, ...], kwargs: Dict[str, Any]) -> Any: +async def call_awaiting( + method: Callable[..., Awaitable[object]], args: tuple[Any, ...], kwargs: dict[str, Any] +) -> object: return await method(*args, **kwargs) +def statement_line(helper: Callable[..., Any]) -> int: + """The line of the one statement in ``helper``'s body, however its signature is laid out.""" + (function,) = ast.parse(inspect.getsource(helper)).body + assert isinstance(function, (ast.FunctionDef, ast.AsyncFunctionDef)) + (statement,) = function.body + return helper.__code__.co_firstlineno + statement.lineno - 1 + + # Where each client's deprecation warning must point: the line in this file that calls -# the method, which is the first line of the helper above that calls it for that client. +# the method, which is the statement in the helper above that calls it for that client. CALL_SITES = { - "sync": (__file__, call_blocking.__code__.co_firstlineno + 1), - "async": (__file__, call_awaiting.__code__.co_firstlineno + 1), + "sync": (__file__, statement_line(call_blocking)), + "async": (__file__, statement_line(call_awaiting)), } -MODEL_INPUTS = (UserCreate, TenantCreate, TenantUpdate, RoleCreate, RoleUpdate, ResourceCreate, ResourceUpdate) +MODEL_INPUTS = ( + UserCreate, + TenantCreate, + TenantUpdate, + RoleCreate, + RoleUpdate, + ResourceCreate, + ResourceUpdate, +) def case_id(case: FacadeCase) -> str: @@ -361,18 +409,21 @@ def case_id(case: FacadeCase) -> str: return name -def assert_parsed(result: Any, case: FacadeCase) -> None: +def assert_parsed(result: object, case: FacadeCase) -> None: if case.model is None: assert result is None elif isinstance(case.response, list): + assert isinstance(result, list) assert [type(item) for item in result] == [case.model] * len(case.response) else: assert type(result) is case.model -def test_the_table_covers_every_deprecated_method(): +def test_the_table_covers_every_deprecated_method() -> None: deprecated = { - f"permit.api.{name}" for name, value in vars(DeprecatedApi).items() if inspect.iscoroutinefunction(value) + f"permit.api.{name}" + for name, value in vars(DeprecatedApi).items() + if inspect.iscoroutinefunction(value) } assert deprecated == {case.facade.path for case in CASES} @@ -383,7 +434,7 @@ def test_the_table_covers_every_deprecated_method(): @pytest.mark.parametrize("case", CASES, ids=[case_id(case) for case in CASES]) def test_deprecated_method_warns_and_matches_its_replacement( httpserver: HTTPServer, config: PermitConfig, case: FacadeCase, flavour: str -): +) -> None: http_method, path = case.request handler = httpserver.expect_request(path, method=http_method) if case.response is None: @@ -393,7 +444,7 @@ def test_deprecated_method_warns_and_matches_its_replacement( client = Permit(config) if flavour == "async" else SyncPermit(config) - def invoke(target: Call) -> Any: + def invoke(target: Call) -> object: # Each call gets its own copy of the inputs, so neither can see what the other did to them. args, kwargs = copy.deepcopy((target.args, target.kwargs)) method = attrgetter(target.path.removeprefix("permit."))(client) @@ -406,11 +457,15 @@ def invoke(target: Call) -> Any: with warnings.catch_warnings(record=True) as replacement_warnings: warnings.simplefilter("always") expected = invoke(case.replacement) - with pytest.warns(DeprecationWarning) as facade_warnings: + with pytest.warns( + DeprecationWarning, match=re.escape(removal_warning(case)) + ) as facade_warnings: result = invoke(case.facade) assert deprecations(replacement_warnings) == [] - assert deprecations(facade_warnings) == [(DeprecationWarning, removal_warning(case), *CALL_SITES[flavour])] + assert deprecations(facade_warnings.list) == [ + (DeprecationWarning, removal_warning(case), *CALL_SITES[flavour]) + ] assert len(httpserver.log) == 2, [sent(request) for request, _ in httpserver.log] replacement_request, facade_request = (sent(request) for request, _ in httpserver.log) @@ -466,8 +521,12 @@ async def call_awaiting(): ] -def test_a_script_gets_one_warning_per_call_at_the_call(httpserver: HTTPServer, tmp_path: Path): - """A script runs as ``__main__``, which has no ``__spec__``, and it is the one module +def test_a_script_gets_one_warning_per_call_at_the_call( + httpserver: HTTPServer, tmp_path: Path +) -> None: + """A script gets each client's warning once, at the line that called the method. + + A script runs as ``__main__``, which has no ``__spec__``, and it is the one module Python's default filters show DeprecationWarnings for. The script calls the method through each client, three times from the same line. The @@ -481,7 +540,11 @@ def test_a_script_gets_one_warning_per_call_at_the_call(httpserver: HTTPServer, httpserver.expect_request(path, method=http_method).respond_with_json(case.response) script = tmp_path / "script.py" script.write_text(SCRIPT) - env = {name: value for name, value in os.environ.items() if name not in ("PYTHONWARNINGS", "PYTHONDEVMODE")} + env = { + name: value + for name, value in os.environ.items() + if name not in ("PYTHONWARNINGS", "PYTHONDEVMODE") + } env["PYTHONPATH"] = os.pathsep.join([str(PERMIT_PARENT), str(TESTS_PARENT)]) result = subprocess.run( diff --git a/tests/test_fix_enforcement.py b/tests/test_fix_enforcement.py index 3e0b91f..f2d5065 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}]}) ) @@ -225,11 +242,13 @@ async def test_filter_objects_keeps_per_resource_context_on_the_resource(httpser ids=["bare", "result.permissions"], ) async def test_get_user_permissions_unwraps_both_pdp_response_shapes( - httpserver: HTTPServer, enforcer: Enforcer, pdp_response: Dict[str, Any] -): + httpserver: HTTPServer, enforcer: Enforcer, pdp_response: dict[str, Any] +) -> None: """The PDP answers with the permissions map itself or with it under ``result.permissions``.""" - bodies: List[Any] = [] - httpserver.expect_request("/user-permissions", method="POST").respond_with_handler(_recorder(bodies, pdp_response)) + bodies: list[Any] = [] + httpserver.expect_request("/user-permissions", method="POST").respond_with_handler( + _recorder(bodies, pdp_response) + ) result = await enforcer.get_user_permissions("user_a", ["t1"], ["document:doc-1"], ["document"]) @@ -247,8 +266,10 @@ async def test_get_user_permissions_unwraps_both_pdp_response_shapes( # --- 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", @@ -262,10 +283,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"}}, @@ -283,8 +308,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}]}) ) @@ -301,3 +328,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 ebb1e36..c5553fa 100644 --- a/tests/test_fix_permissions.py +++ b/tests/test_fix_permissions.py @@ -23,7 +23,7 @@ import json import uuid -from typing import Any, Dict, List +from typing import Any from pytest_httpserver import HTTPServer @@ -63,7 +63,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()), @@ -83,7 +83,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()), @@ -101,14 +101,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"]) @@ -125,7 +130,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 @@ -142,11 +149,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"]) @@ -155,12 +166,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"]) @@ -169,25 +184,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 reads this filter as a resource instance string and answers 400 to @@ -203,7 +228,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_pydantic1_deprecation.py b/tests/test_fix_pydantic1_deprecation.py index 0fb3cab..a3aa910 100644 --- a/tests/test_fix_pydantic1_deprecation.py +++ b/tests/test_fix_pydantic1_deprecation.py @@ -1,8 +1,8 @@ """Offline tests for the pydantic 1 deprecation warning (PER-16236). -A future major release, permit 4.0, will drop pydantic 1. Until then, importing permit on pydantic 1 issues one -DeprecationWarning that names 4.0 and says what to do, attributed to the line that imported -permit. On pydantic 2 it issues none. +A future major release, permit 4.0, will drop pydantic 1. Until then, importing permit on +pydantic 1 issues one DeprecationWarning that names 4.0 and says what to do, attributed to the +line that imported permit. On pydantic 2 it issues none. This process imported permit before any test ran, so each warning test imports it in a fresh interpreter and reports every warning recorded there. @@ -13,6 +13,7 @@ import subprocess import sys from pathlib import Path +from typing import Any import pytest @@ -58,7 +59,7 @@ FIRST_IMPORT_LINENO = CONSUMER.splitlines().index(" {first_import}") + 1 -def pydantic_1_warnings_on_import(consumer: Path, first_import: str) -> list[dict]: +def pydantic_1_warnings_on_import(consumer: Path, first_import: str) -> list[dict[str, Any]]: """Run a script that imports permit in a fresh interpreter, recording every warning. Returns the recorded warnings whose message mentions pydantic 1, of any category. @@ -78,7 +79,9 @@ def pydantic_1_warnings_on_import(consumer: Path, first_import: str) -> list[dic @pytest.mark.skipif(not ON_PYDANTIC_1, reason="pydantic 2 is installed") @pytest.mark.parametrize("first_import", FIRST_IMPORTS) -def test_importing_permit_on_pydantic_1_warns_once_at_the_import(tmp_path: Path, first_import: str): +def test_importing_permit_on_pydantic_1_warns_once_at_the_import( + tmp_path: Path, first_import: str +) -> None: consumer = tmp_path / "consumer.py" warned = pydantic_1_warnings_on_import(consumer, first_import) @@ -94,20 +97,20 @@ def test_importing_permit_on_pydantic_1_warns_once_at_the_import(tmp_path: Path, @pytest.mark.skipif(ON_PYDANTIC_1, reason="pydantic 1 is installed") @pytest.mark.parametrize("first_import", FIRST_IMPORTS) -def test_importing_permit_on_pydantic_2_does_not_warn(tmp_path: Path, first_import: str): +def test_importing_permit_on_pydantic_2_does_not_warn(tmp_path: Path, first_import: str) -> None: warned = pydantic_1_warnings_on_import(tmp_path / "consumer.py", first_import) assert warned == [] -def test_the_pydantic_version_permit_checks_is_not_a_public_name(): - """permit reads the pydantic version to decide whether to warn; the constant is not API. +def test_the_pydantic_version_permit_checks_is_not_a_public_name() -> None: + """Permit reads the pydantic version to decide whether to warn; the constant is not API. permit has no ``__all__``, so any name without a leading underscore is public: it is in ``dir(permit)`` and ``from permit import *`` exports it. """ - exported: dict = {} - exec("from permit import *", exported) + exported: dict[str, object] = {} + exec("from permit import *", exported) # noqa: S102 - what a star import exports is the subject assert "PYDANTIC_VERSION" not in exported, "from permit import * exports PYDANTIC_VERSION" assert not hasattr(permit, "PYDANTIC_VERSION") diff --git a/tests/test_fix_read_models.py b/tests/test_fix_read_models.py index 731c6a3..62375b8 100644 --- a/tests/test_fix_read_models.py +++ b/tests/test_fix_read_models.py @@ -13,7 +13,7 @@ import json from datetime import datetime, timezone -from typing import Any, Dict +from typing import Any from uuid import UUID, uuid4 import pytest @@ -29,18 +29,26 @@ NOW = datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc).isoformat() -def ids(*names: str) -> Dict[str, str]: +def ids(*names: str) -> dict[str, str]: return {name: str(uuid4()) for name in names} -def tuple_payload(**fields: Any) -> Dict[str, Any]: +def tuple_payload(**fields: Any) -> dict[str, Any]: """A relationship tuple read with every field the schema requires, plus ``fields``.""" return { "subject": "folder:f-1", "relation": "parent", "object": "document:*", "tenant": "tenant-1", - **ids("id", "subject_id", "relation_id", "tenant_id", "organization_id", "project_id", "environment_id"), + **ids( + "id", + "subject_id", + "relation_id", + "tenant_id", + "organization_id", + "project_id", + "environment_id", + ), "created_at": NOW, "updated_at": NOW, **fields, @@ -56,8 +64,8 @@ def tuple_payload(**fields: Any) -> Dict[str, Any]: @pytest.mark.parametrize("object_id", WILDCARD_OBJECT_ID) async def test_relationship_tuples_list_parses_a_tuple_without_an_object_id( - httpserver: HTTPServer, config: PermitConfig, object_id: Dict[str, Any] -): + httpserver: HTTPServer, config: PermitConfig, object_id: dict[str, Any] +) -> None: concrete = tuple_payload(object="document:doc-1", object_id=str(uuid4())) httpserver.expect_request(f"{FACTS}/relationship_tuples", method="GET").respond_with_json( [tuple_payload(**object_id), concrete] @@ -72,8 +80,8 @@ async def test_relationship_tuples_list_parses_a_tuple_without_an_object_id( @pytest.mark.parametrize("object_id", WILDCARD_OBJECT_ID) async def test_relationship_tuples_create_parses_a_tuple_without_an_object_id( - httpserver: HTTPServer, config: PermitConfig, object_id: Dict[str, Any] -): + httpserver: HTTPServer, config: PermitConfig, object_id: dict[str, Any] +) -> None: httpserver.expect_request(f"{FACTS}/relationship_tuples", method="POST").respond_with_json( tuple_payload(**object_id) ) @@ -86,16 +94,23 @@ async def test_relationship_tuples_create_parses_a_tuple_without_an_object_id( @pytest.mark.parametrize("object_id", WILDCARD_OBJECT_ID) -def test_detailed_relationship_tuple_parses_without_an_object_id_or_details(object_id: Dict[str, Any]): +def test_detailed_relationship_tuple_parses_without_an_object_id_or_details( + object_id: dict[str, Any], +) -> None: # No SDK method returns this model, so it is parsed directly. detailed = RelationshipTupleDetailedRead.parse_obj(tuple_payload(**object_id)) - details = (detailed.subject_details, detailed.relation_details, detailed.object_details, detailed.tenant_details) + details = ( + detailed.subject_details, + detailed.relation_details, + detailed.object_details, + detailed.tenant_details, + ) assert detailed.object_id is None assert details == (None, None, None, None) -def test_detailed_relationship_tuple_still_parses_its_details(): +def test_detailed_relationship_tuple_still_parses_its_details() -> None: detailed = RelationshipTupleDetailedRead.parse_obj( tuple_payload( object="document:doc-1", @@ -117,7 +132,9 @@ def test_detailed_relationship_tuple_still_parses_its_details(): assert detailed.tenant_details.name == "Tenant 1" -async def test_environments_get_api_key_parses_a_nats_pdp_config_key(httpserver: HTTPServer, config: PermitConfig): +async def test_environments_get_api_key_parses_a_nats_pdp_config_key( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_request("/v2/api-key/project-1/env-1", method="GET").respond_with_json( { **ids("id", "organization_id", "project_id", "environment_id"), @@ -131,7 +148,9 @@ async def test_environments_get_api_key_parses_a_nats_pdp_config_key(httpserver: assert key.owner_type is APIKeyOwnerType.nats_pdp_config -async def test_users_get_keeps_every_attribute_value_and_null_as_sent(httpserver: HTTPServer, config: PermitConfig): +async def test_users_get_keeps_every_attribute_value_and_null_as_sent( + httpserver: HTTPServer, config: PermitConfig +) -> None: """Attribute values keep their JSON types: a bool is not an int, a whole float is not an int.""" attributes = { "true": True, diff --git a/tests/test_fix_relations.py b/tests/test_fix_relations.py index 7e99c85..804f362 100644 --- a/tests/test_fix_relations.py +++ b/tests/test_fix_relations.py @@ -12,7 +12,7 @@ import re import uuid -from typing import Any, Dict +from typing import Any import pytest from pytest_httpserver import HTTPServer @@ -29,7 +29,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()), @@ -69,7 +69,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( @@ -93,7 +93,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} @@ -109,7 +109,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_resource_actions.py b/tests/test_fix_resource_actions.py index 1c78733..ccc933c 100644 --- a/tests/test_fix_resource_actions.py +++ b/tests/test_fix_resource_actions.py @@ -10,7 +10,7 @@ import asyncio import inspect from operator import attrgetter -from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, NamedTuple import pytest from pytest_httpserver import HTTPServer @@ -28,6 +28,15 @@ from permit.api.resource_actions import ResourceActionsApi from permit.config import PermitConfig from permit.sync import Permit as SyncPermit +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 BaseModel +elif PYDANTIC_VERSION < (2, 0): + from pydantic import BaseModel +else: + from pydantic.v1 import BaseModel from tests.utils import SCHEMA, Call, call, sent RESOURCES = f"{SCHEMA}/resources" @@ -39,7 +48,7 @@ SECOND_PAGE = [("page", "2"), ("per_page", "10")] -def common(key: str, object_id: str) -> Dict[str, Any]: +def common(key: str, object_id: str) -> dict[str, Any]: return { "key": key, "name": key.title(), @@ -53,11 +62,11 @@ def common(key: str, object_id: str) -> Dict[str, Any]: } -def action(key: str) -> Dict[str, Any]: +def action(key: str) -> dict[str, Any]: return {**common(key, ACTION_ID), "permission_name": f"document:{key}"} -def group(key: str) -> Dict[str, Any]: +def group(key: str) -> dict[str, Any]: return {**common(key, GROUP_ID), "actions": ["read", "write"]} @@ -71,10 +80,10 @@ class Case(NamedTuple): call: Call method: str path: str - query: List[Tuple[str, str]] + query: list[tuple[str, str]] body: Any - response: Union[Dict[str, Any], List[Dict[str, Any]], None] - model: Optional[type] + response: dict[str, Any] | list[dict[str, Any]] | None + model: type[BaseModel] | None ACTIONS = "permit.api.resource_actions" @@ -136,7 +145,11 @@ class Case(NamedTuple): model=ResourceActionRead, ), "actions.create-from-dict": Case( - call=call(f"{ACTIONS}.create", "document", {"key": "write", "name": "Write", "attributes": {"risk": "high"}}), + call=call( + f"{ACTIONS}.create", + "document", + {"key": "write", "name": "Write", "attributes": {"risk": "high"}}, + ), method="POST", path=f"{RESOURCES}/document/actions", query=[], @@ -145,7 +158,9 @@ class Case(NamedTuple): model=ResourceActionRead, ), "actions.update": Case( - call=call(f"{ACTIONS}.update", "document", "write", ResourceActionUpdate(name="Write access")), + call=call( + f"{ACTIONS}.update", "document", "write", ResourceActionUpdate(name="Write access") + ), method="PATCH", path=f"{RESOURCES}/document/actions/write", query=[], @@ -239,7 +254,9 @@ class Case(NamedTuple): model=ResourceActionGroupRead, ), "action_groups.update": Case( - call=call(f"{GROUPS}.update", "document", "editors", ResourceActionGroupUpdate(actions=["read"])), + call=call( + f"{GROUPS}.update", "document", "editors", ResourceActionGroupUpdate(actions=["read"]) + ), method="PATCH", path=f"{RESOURCES}/document/action_groups/editors", query=[], @@ -248,7 +265,9 @@ class Case(NamedTuple): model=ResourceActionGroupRead, ), "action_groups.update-clears-a-field": Case( - call=call(f"{GROUPS}.update", "document", "editors", {"name": "Editors", "description": None}), + call=call( + f"{GROUPS}.update", "document", "editors", {"name": "Editors", "description": None} + ), method="PATCH", path=f"{RESOURCES}/document/action_groups/editors", query=[], @@ -268,11 +287,13 @@ class Case(NamedTuple): } -def public_methods(api: type) -> set: - return {name for name, value in vars(api).items() if not name.startswith("_") and callable(value)} +def public_methods(api: type) -> set[str]: + return { + name for name, value in vars(api).items() if not name.startswith("_") and callable(value) + } -def test_every_public_method_has_a_case(): +def test_every_public_method_has_a_case() -> None: expected = {f"{ACTIONS}.{name}" for name in public_methods(ResourceActionsApi)} | { f"{GROUPS}.{name}" for name in public_methods(ResourceActionGroupsApi) } @@ -283,7 +304,9 @@ def test_every_public_method_has_a_case(): @pytest.mark.parametrize("flavour", ["async", "sync"]) @pytest.mark.parametrize("case", CASES.values(), ids=CASES.keys()) -def test_request_and_response(httpserver: HTTPServer, config: PermitConfig, case: Case, flavour: str): +def test_request_and_response( + httpserver: HTTPServer, config: PermitConfig, case: Case, flavour: str +) -> None: handler = httpserver.expect_request(case.path, method=case.method) if case.response is None: handler.respond_with_data("", status=204) diff --git a/tests/test_fix_serialization.py b/tests/test_fix_serialization.py index af89b21..206d5d2 100644 --- a/tests/test_fix_serialization.py +++ b/tests/test_fix_serialization.py @@ -16,14 +16,15 @@ import datetime import json +from collections.abc import Callable from decimal import Decimal from enum import Enum -from typing import Any, Callable, Dict, List +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 ( @@ -44,12 +45,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") @@ -72,11 +77,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") @@ -84,7 +89,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 @@ -95,7 +102,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")) @@ -104,7 +111,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 @@ -116,10 +125,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`` @@ -130,7 +143,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 @@ -165,9 +180,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) @@ -175,7 +195,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", @@ -194,11 +214,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") @@ -209,7 +229,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 @@ -226,9 +248,12 @@ async def test_role_assignment_body_unchanged(client: SimpleHttpClient, captured MIXED_TEXT = "emoji ✅🚀 · combining e\u0301 vs \u00e9 · rtl \u202eabc\u202c · tab\tend" -def hostile_attributes() -> Dict[str, Any]: - """Legal attribute values a lossy encoder would change: a bool beside ints, a whole float, - unicode with bidi controls, keys with separators, empty containers, nesting and nulls.""" +def hostile_attributes() -> dict[str, Any]: + """Legal attribute values a lossy encoder would change. + + A bool beside ints, a whole float, unicode with bidi controls, keys with separators, empty + containers, nesting and nulls. + """ return { "unicode": UNICODE_NAME, "mixed": MIXED_TEXT, @@ -249,13 +274,15 @@ def hostile_attributes() -> Dict[str, Any]: "key-with-dashes": "dashes", "cleared": None, "nested": { - "level2": {"level3": [{"flag": False, "cleared": None}, {"name": UNICODE_NAME, "count": 1}]}, + "level2": { + "level3": [{"flag": False, "cleared": None}, {"name": UNICODE_NAME, "count": 1}] + }, "matrix": [[1, 2], [3, 4]], }, } -def user_body() -> Dict[str, Any]: +def user_body() -> dict[str, Any]: return { "key": "user-1", "email": "user-1@example.com", @@ -265,35 +292,57 @@ def user_body() -> Dict[str, Any]: } -def tenant_body() -> Dict[str, Any]: - return {"key": "tenant-1", "name": UNICODE_NAME, "description": MIXED_TEXT, "attributes": hostile_attributes()} +def tenant_body() -> dict[str, Any]: + return { + "key": "tenant-1", + "name": UNICODE_NAME, + "description": MIXED_TEXT, + "attributes": hostile_attributes(), + } -def resource_instance_body() -> Dict[str, Any]: - return {"key": "doc-1", "resource": "document", "tenant": "tenant-1", "attributes": hostile_attributes()} +def resource_instance_body() -> dict[str, Any]: + return { + "key": "doc-1", + "resource": "document", + "tenant": "tenant-1", + "attributes": hostile_attributes(), + } -def resource_body() -> Dict[str, Any]: +def resource_body() -> dict[str, Any]: return { "key": "document", "name": UNICODE_NAME, "description": MIXED_TEXT, "actions": { "read": {}, - "update": {"name": "Update ✓", "description": MIXED_TEXT, "attributes": hostile_attributes()}, + "update": { + "name": "Update ✓", + "description": MIXED_TEXT, + "attributes": hostile_attributes(), + }, + }, + "attributes": { + "private": {"type": "bool"}, + "level": {"type": "number", "description": MIXED_TEXT}, }, - "attributes": {"private": {"type": "bool"}, "level": {"type": "number", "description": MIXED_TEXT}}, } -def relationship_tuple_body() -> Dict[str, Any]: - return {"subject": "folder:f-1", "relation": "parent", "object": "document:doc-1", "tenant": "tenant-1"} +def relationship_tuple_body() -> dict[str, Any]: + return { + "subject": "folder:f-1", + "relation": "parent", + "object": "document:doc-1", + "tenant": "tenant-1", + } # Each model is built inside the test, so a model that fails to build fails its own case # and not the whole module. Each is built from its own copy of the payload, so a # serializer that edited the caller's dicts in place could not also edit the expected body. -WIRE_BODIES: List[Any] = [ +WIRE_BODIES: list[Any] = [ pytest.param(lambda: UserCreate(**user_body()), user_body(), id="UserCreate"), pytest.param(lambda: TenantCreate(**tenant_body()), tenant_body(), id="TenantCreate"), pytest.param( @@ -308,7 +357,9 @@ def relationship_tuple_body() -> Dict[str, Any]: id="RelationshipTupleCreate", ), pytest.param( - lambda: ResourceAttributeCreate(key="level", type=AttributeType.number, description=MIXED_TEXT), + lambda: ResourceAttributeCreate( + key="level", type=AttributeType.number, description=MIXED_TEXT + ), {"key": "level", "type": "number", "description": MIXED_TEXT}, id="ResourceAttributeCreate", ), @@ -318,7 +369,10 @@ def relationship_tuple_body() -> Dict[str, Any]: name=UNICODE_NAME, type=ConditionSetType.userset, conditions={ - "allOf": [{"user.attributes.tier": {"equals": "gold"}}, {"user.attributes.true": {"equals": True}}] + "allOf": [ + {"user.attributes.tier": {"equals": "gold"}}, + {"user.attributes.true": {"equals": True}}, + ] }, ), { @@ -326,7 +380,10 @@ def relationship_tuple_body() -> Dict[str, Any]: "name": UNICODE_NAME, "type": "userset", "conditions": { - "allOf": [{"user.attributes.tier": {"equals": "gold"}}, {"user.attributes.true": {"equals": True}}] + "allOf": [ + {"user.attributes.tier": {"equals": "gold"}}, + {"user.attributes.true": {"equals": True}}, + ] }, }, id="ConditionSetCreate", @@ -359,8 +416,11 @@ def relationship_tuple_body() -> Dict[str, Any]: @pytest.mark.parametrize(("build", "expected"), WIRE_BODIES) async def test_request_body_reaches_the_wire_exactly_as_given( - client: SimpleHttpClient, captured: list, build: Callable[[], Any], expected: Dict[str, Any] -): + client: SimpleHttpClient, + captured: list[Any], + build: Callable[[], Any], + expected: dict[str, Any], +) -> None: """Every value arrives with its JSON type and every key survives, nulls included. Each expected body is a literal and CI runs this file under both pydantic majors, so a diff --git a/tests/test_fix_sync.py b/tests/test_fix_sync.py index 60beaa4..ec15f12 100644 --- a/tests/test_fix_sync.py +++ b/tests/test_fix_sync.py @@ -13,10 +13,11 @@ import sys import threading import warnings +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from pathlib import Path -from typing import Callable, List, Tuple +from typing import Any, cast from uuid import uuid4 import pytest @@ -32,19 +33,20 @@ from tests.utils import FACTS, SCHEMA -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, @@ -61,7 +63,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" @@ -70,7 +72,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" @@ -85,7 +87,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" @@ -99,15 +101,17 @@ 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 + wrapper.__wrapped__ = func # type: ignore[attr-defined] # what functools.wraps records return wrapper class Decorated(metaclass=SyncClass): @@ -119,14 +123,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 @@ -140,23 +144,29 @@ 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): +def test_deprecated_facade_list_roles_issues_a_request( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_oneshot_request(f"{SCHEMA}/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 == [] @@ -166,33 +176,35 @@ def test_deprecated_facade_list_roles_issues_a_request(httpserver: HTTPServer, c # --- warnings from a blocking call's coroutine ------------------------------ -def deprecation_sites(caught: List[warnings.WarningMessage]) -> List[Tuple[str, int]]: +def deprecation_sites(caught: list[warnings.WarningMessage]) -> list[tuple[str, int]]: return [(w.filename, w.lineno) for w in caught if issubclass(w.category, DeprecationWarning)] -def first_line_of(func: Callable) -> Tuple[str, int]: +def first_line_of(func: Callable[..., object]) -> tuple[str, int]: """The file and first body line of ``func``, where each helper below makes its call.""" return func.__code__.co_filename, func.__code__.co_firstlineno + 1 def test_deprecated_facade_warns_at_a_call_made_inside_a_running_event_loop( httpserver: HTTPServer, config: PermitConfig -): +) -> None: """With a loop already running, the call's coroutine runs in a worker thread of its own.""" - httpserver.expect_oneshot_request(f"{FACTS}/users/user-1", method="GET").respond_with_json(user_payload("user-1")) + httpserver.expect_oneshot_request(f"{FACTS}/users/user-1", method="GET").respond_with_json( + user_payload("user-1") + ) client = SyncPermitApiClient(config) async def main() -> None: client.get_user("user-1") - with pytest.warns(DeprecationWarning) as caught: + with pytest.warns(DeprecationWarning, match=r"permit\.api\.get_user\(\)") as caught: asyncio.run(main()) assert deprecation_sites(caught.list) == [first_line_of(main)] httpserver.check_assertions() -def test_concurrent_blocking_calls_each_warn_at_their_own_call(): +def test_concurrent_blocking_calls_each_warn_at_their_own_call() -> None: """A coroutine that runs for a blocking call warns at that call, not another thread's.""" both_calls_running = threading.Barrier(2) @@ -219,7 +231,9 @@ def second_caller() -> None: for future in futures: future.result() - assert sorted(deprecation_sites(caught)) == sorted([first_line_of(first_caller), first_line_of(second_caller)]) + assert sorted(deprecation_sites(caught)) == sorted( + [first_line_of(first_caller), first_line_of(second_caller)] + ) NO_CALLER_SCRIPT = """\ @@ -242,7 +256,9 @@ async def old_fetch(self) -> None: """ -def test_a_blocking_call_with_no_python_caller_warns_where_warnings_warn_would(tmp_path: Path): +def test_a_blocking_call_with_no_python_caller_warns_where_warnings_warn_would( + tmp_path: Path, +) -> None: """C code can call a blocking method with no Python frame above it, as it calls an atexit hook. ``warnings.warn`` blames ````, line 0, when it has no frame to blame, and so does the @@ -250,11 +266,20 @@ def test_a_blocking_call_with_no_python_caller_warns_where_warnings_warn_would(t """ script = tmp_path / "script.py" script.write_text(NO_CALLER_SCRIPT) - env = {name: value for name, value in os.environ.items() if name not in ("PYTHONWARNINGS", "PYTHONDEVMODE")} + env = { + name: value + for name, value in os.environ.items() + if name not in ("PYTHONWARNINGS", "PYTHONDEVMODE") + } env["PYTHONPATH"] = str(Path(permit.__file__).resolve().parents[1]) result = subprocess.run( - [sys.executable, str(script)], env=env, capture_output=True, text=True, timeout=120, check=False + [sys.executable, str(script)], + env=env, + capture_output=True, + text=True, + timeout=120, + check=False, ) assert (result.returncode, result.stdout) == (0, "ran\n"), result.stderr @@ -263,9 +288,12 @@ def test_a_blocking_call_with_no_python_caller_warns_where_warnings_warn_would(t ] -def test_run_coroutine_sync_takes_just_the_coroutine(): - """A public name since 2.x: called directly, it still drives re-entrant awaits of converted - methods, and a deprecated one warns at the line that called it.""" +def test_run_coroutine_sync_takes_just_the_coroutine() -> None: + """run_coroutine_sync, public since 2.x, still works when called directly. + + It drives re-entrant awaits of converted methods, and a deprecated one warns at the line + that called it. + """ class Api(metaclass=SyncClass): @deprecated("old_fetch() is deprecated") @@ -285,7 +313,7 @@ def caller() -> str: assert deprecation_sites(caught) == [first_line_of(caller)] -def test_a_blocking_call_from_code_with_no_module_spec_warns_once(tmp_path: Path): +def test_a_blocking_call_from_code_with_no_module_spec_warns_once(tmp_path: Path) -> None: """runpy.run_path() runs a file whose globals hold neither ``__spec__`` nor ``__loader__``.""" class Api(metaclass=SyncClass): @@ -308,7 +336,7 @@ async def old_fetch(self) -> None: # --- 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") @@ -317,7 +345,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:*", @@ -337,13 +365,15 @@ def test_sync_permit_authorized_users(httpserver: HTTPServer, config: PermitConf result = SyncPermit(config).authorized_users("read", "document") - assert not inspect.iscoroutine(result) + # The blocking client is typed as blocking, so mypy rules a coroutine out already; + # this checks the runtime value. + assert not inspect.iscoroutine(cast("object", result)) assert list(result.users) == ["user-1"] assert result.tenant == "default" 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", @@ -353,18 +383,25 @@ 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") - assert not inspect.iscoroutine(result) + # The blocking client is typed as blocking, so mypy rules a coroutine out already; + # this checks the runtime value. + assert not inspect.iscoroutine(cast("object", result)) assert result["default"]["permissions"] == ["document:read"] 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}]} ) @@ -376,12 +413,14 @@ def test_sync_permit_filter_objects(httpserver: HTTPServer, config: PermitConfig ] result = SyncPermit(config).filter_objects("user-1", "read", {}, resources) - assert not inspect.iscoroutine(result) + # The blocking client is typed as blocking, so mypy rules a coroutine out already; + # this checks the runtime value. + assert not inspect.iscoroutine(cast("object", result)) assert result == [resources[0], resources[2]] 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}]} ) @@ -397,20 +436,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) @@ -422,9 +470,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", diff --git a/tests/test_fix_sync_parity.py b/tests/test_fix_sync_parity.py index 4898c14..16bd195 100644 --- a/tests/test_fix_sync_parity.py +++ b/tests/test_fix_sync_parity.py @@ -98,7 +98,7 @@ def sync_surface() -> Surface: return public_surface(SyncPermit(offline_config(NO_SERVER))) -def test_the_walk_reaches_every_sub_api(async_client: AsyncPermit, async_surface: Surface): +def test_the_walk_reaches_every_sub_api(async_client: AsyncPermit, async_surface: Surface) -> None: """The other tests compare what the walk finds, so it must find the sub-APIs. Only the async walk is checked here: test_sync_client_has_every_async_attribute @@ -107,7 +107,9 @@ def test_the_walk_reaches_every_sub_api(async_client: AsyncPermit, async_surface below it to find. """ api_sub_apis = property_names(async_client.api) - assert len(api_sub_apis) >= API_SUB_API_COUNT, f"permit.Permit().api properties: {sorted(api_sub_apis)}" + assert len(api_sub_apis) >= API_SUB_API_COUNT, ( + f"permit.Permit().api properties: {sorted(api_sub_apis)}" + ) for prefix, ancestors in ( ("", (async_client,)), @@ -125,33 +127,47 @@ def test_the_walk_reaches_every_sub_api(async_client: AsyncPermit, async_surface assert not unwalked, f"the walk did not descend into {unwalked}" -def test_sync_client_has_every_async_attribute(async_surface: Surface, sync_surface: Surface): +def test_sync_client_has_every_async_attribute( + async_surface: Surface, sync_surface: Surface +) -> None: missing = sorted(set(async_surface) - set(sync_surface)) assert not missing, f"on permit.Permit but not on permit.sync.Permit: {missing}" -def test_sync_client_keeps_every_async_method_callable(async_surface: Surface, sync_surface: Surface): +def test_sync_client_keeps_every_async_method_callable( + async_surface: Surface, sync_surface: Surface +) -> None: not_callable = sorted( path for path, value in async_surface.items() if callable(value) and path in sync_surface and not callable(sync_surface[path]) ) - assert not not_callable, f"callable on permit.Permit but not on permit.sync.Permit: {not_callable}" + assert not not_callable, ( + f"callable on permit.Permit but not on permit.sync.Permit: {not_callable}" + ) -def test_nothing_reachable_from_the_sync_client_is_async(sync_surface: Surface): - still_async = sorted(path for path, value in sync_surface.items() if callable(value) and iscoroutine_func(value)) +def test_nothing_reachable_from_the_sync_client_is_async(sync_surface: Surface) -> None: + still_async = sorted( + path for path, value in sync_surface.items() if callable(value) and iscoroutine_func(value) + ) assert not still_async, f"permit.sync.Permit still returns awaitables from: {still_async}" -def test_sync_client_uses_a_sync_class_for_every_async_api(async_surface: Surface, sync_surface: Surface): +def test_sync_client_uses_a_sync_class_for_every_async_api( + async_surface: Surface, sync_surface: Surface +) -> None: not_sync_class = sorted( f"{path} is {type(sync_surface[path]).__qualname__}" for path, value in async_surface.items() - if is_async_api(value) and path in sync_surface and not isinstance(type(sync_surface[path]), SyncClass) + if is_async_api(value) + and path in sync_surface + and not isinstance(type(sync_surface[path]), SyncClass) ) - assert not not_sync_class, f"permit.sync.Permit exposes API objects not built with SyncClass: {not_sync_class}" + assert not not_sync_class, ( + f"permit.sync.Permit exposes API objects not built with SyncClass: {not_sync_class}" + ) diff --git a/tests/test_fix_tenants.py b/tests/test_fix_tenants.py index ef5d9d6..7f1d43e 100644 --- a/tests/test_fix_tenants.py +++ b/tests/test_fix_tenants.py @@ -12,7 +12,7 @@ import re import uuid from operator import attrgetter -from typing import Any, Dict, List, Optional, Tuple +from typing import Any import pytest from pytest_httpserver import HTTPServer @@ -28,11 +28,11 @@ 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, response: Optional[Dict[str, Any]] = None + httpserver: HTTPServer, *, proxy_facts_via_pdp: bool, response: dict[str, Any] | None = None ) -> Permit: """Build a Permit client whose PDP *and* REST API both point at ``httpserver``. @@ -59,7 +59,7 @@ def _make_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: @@ -70,7 +70,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")]) @@ -85,16 +85,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")]) @@ -104,15 +108,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( @@ -130,7 +138,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")]) @@ -144,7 +154,7 @@ async def test_tenants_bulk_create_without_pdp_proxy_targets_the_rest_api(httpse ] -def _read_payload(**fields: Any) -> Dict[str, Any]: +def _read_payload(**fields: Any) -> dict[str, Any]: """A facts read-model response: the ids and timestamps they all require, plus ``fields``.""" return { "id": str(uuid.uuid4()), @@ -159,9 +169,17 @@ def _read_payload(**fields: Any) -> Dict[str, Any]: ROLE_ASSIGNMENT = {"user": "user-1", "role": "admin", "tenant": "tenant-1"} ROLE_ASSIGNMENT_READ = _read_payload( - **ROLE_ASSIGNMENT, user_id=str(uuid.uuid4()), role_id=str(uuid.uuid4()), tenant_id=str(uuid.uuid4()) + **ROLE_ASSIGNMENT, + user_id=str(uuid.uuid4()), + role_id=str(uuid.uuid4()), + tenant_id=str(uuid.uuid4()), ) -RELATIONSHIP_TUPLE = {"subject": "folder:f-1", "relation": "parent", "object": "document:doc-1", "tenant": "tenant-1"} +RELATIONSHIP_TUPLE = { + "subject": "folder:f-1", + "relation": "parent", + "object": "document:doc-1", + "tenant": "tenant-1", +} RESOURCE_INSTANCE = {"key": "doc-1", "resource": "document", "tenant": "tenant-1"} # Each single-object write the SDK proxies through the PDP, called on ``permit.api``; the one @@ -182,7 +200,9 @@ def _read_payload(**fields: Any) -> Dict[str, Any]: pytest.param( call("resource_instances.create", RESOURCE_INSTANCE), ("POST", "/facts/resource_instances", RESOURCE_INSTANCE), - _read_payload(**RESOURCE_INSTANCE, resource_id=str(uuid.uuid4()), tenant_id=str(uuid.uuid4())), + _read_payload( + **RESOURCE_INSTANCE, resource_id=str(uuid.uuid4()), tenant_id=str(uuid.uuid4()) + ), id="resource_instances.create", ), pytest.param( @@ -214,8 +234,8 @@ def _read_payload(**fields: Any) -> Dict[str, Any]: @pytest.mark.parametrize(("target", "expected", "response"), SINGLE_WRITES) async def test_single_fact_writes_target_their_pdp_endpoint( - httpserver: HTTPServer, target: Call, expected: RecordedRequest, response: Dict[str, Any] -): + httpserver: HTTPServer, target: Call, expected: RecordedRequest, response: dict[str, Any] +) -> None: permit = _make_permit(httpserver, proxy_facts_via_pdp=True, response=response) # A copy, so an SDK that edited the caller's dict could not also edit the expected body. args, kwargs = copy.deepcopy((target.args, target.kwargs)) diff --git a/tests/test_offline_regressions.py b/tests/test_offline_regressions.py index 346f0b1..559b5ad 100644 --- a/tests/test_offline_regressions.py +++ b/tests/test_offline_regressions.py @@ -8,11 +8,14 @@ import ast import inspect +import subprocess import sys import warnings +from collections.abc import AsyncIterator, Sequence from datetime import datetime, timezone +from decimal import Decimal from pathlib import Path -from typing import List, Optional, Union, get_type_hints +from typing import Any, get_type_hints from uuid import UUID, uuid4 import aiohttp @@ -25,9 +28,10 @@ from werkzeug import Request import permit -from permit import Permit, Resource, User +from permit import Permit, Resource, User, exceptions from permit.api.context import ApiKeyAccessLevel from permit.api.elements import ElementsApi +from permit.api.encoders import jsonable_encoder from permit.api.environments import EnvironmentsApi from permit.api.models import ( EnvironmentCopy, @@ -52,7 +56,6 @@ PermitConnectionError, PermitContextError, PermitError, - PermitException, handle_api_error, ) from permit.pdp_api.pdp_api_client import SyncPDPApi @@ -67,7 +70,7 @@ import tomli as tomllib -def role_assignment_read_payload() -> dict: +def role_assignment_read_payload() -> dict[str, Any]: now = datetime.now(timezone.utc).isoformat() return { "id": str(uuid4()), @@ -84,7 +87,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, @@ -97,7 +100,7 @@ def user_read_payload(key: str) -> dict: } -def environment_read_payload(key: str) -> dict: +def environment_read_payload(key: str) -> dict[str, Any]: now = datetime.now(timezone.utc).isoformat() return { "key": key, @@ -112,13 +115,15 @@ def environment_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([]) @@ -129,7 +134,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) @@ -137,7 +142,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() @@ -145,21 +152,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) @@ -168,26 +183,38 @@ 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_sends_the_same_body_for_a_dict(httpserver: HTTPServer, config: PermitConfig): +async def test_users_assign_role_sends_the_same_body_for_a_dict( + httpserver: HTTPServer, config: PermitConfig +) -> None: httpserver.expect_request(f"{FACTS}/users/user-1/roles", method="POST").respond_with_json( role_assignment_read_payload() ) @@ -197,24 +224,32 @@ async def test_users_assign_role_sends_the_same_body_for_a_dict(httpserver: HTTP assert single_request(httpserver).get_json() == {"role": "admin", "tenant": "tenant-1"} -async def test_users_unassign_role_sends_the_same_body_for_a_dict(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_sends_the_same_body_for_a_dict( + 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({"user": "user-1", "role": "admin", "tenant": "tenant-1"}) assert single_request(httpserver).get_json() == {"role": "admin", "tenant": "tenant-1"} -def test_model_input_parameters_are_the_bare_model_at_runtime(): +def test_model_input_parameters_are_the_bare_model_at_runtime() -> None: # ModelInput and ModelListInput widen these annotations for type checkers only. # validate_arguments reads the runtime annotation and must still see the model. - assert get_type_hints(UsersApi.create.raw_function)["user_data"] is UserCreate - assert get_type_hints(UsersApi.bulk_create.raw_function)["users"] == List[UserCreate] + # (It records the undecorated function as `raw_function`, which its types omit.) + create = UsersApi.create.raw_function # type: ignore[attr-defined] + bulk_create = UsersApi.bulk_create.raw_function # type: ignore[attr-defined] + sync = UsersApi.sync.raw_function # type: ignore[attr-defined] + assert get_type_hints(create)["user_data"] is UserCreate + assert get_type_hints(bulk_create)["users"] == list[UserCreate] # sync() passes an invalid dict through as it is, which a bare dict keeps doing. - assert get_type_hints(UsersApi.sync.raw_function)["user"] == Union[UserCreate, dict] + assert get_type_hints(sync)["user"] == UserCreate | dict -def test_user_and_resource_aliases_work_with_isinstance(): +def test_user_and_resource_aliases_work_with_isinstance() -> None: # Type checkers see Dict[str, Any] in these aliases. At runtime they keep the # bare dict, because isinstance rejects a parameterized one. assert isinstance({"key": "user-1"}, User) @@ -225,15 +260,19 @@ def test_user_and_resource_aliases_work_with_isinstance(): async def test_users_create_rejects_an_invalid_dict_before_sending_anything( httpserver: HTTPServer, config: PermitConfig -): +) -> None: with pytest.raises(ValidationError, match="email"): await UsersApi(config).create({"key": "user-1", "email": "not-an-email"}) assert httpserver.log == [] -async def test_users_create_validates_a_dict_into_the_model(httpserver: HTTPServer, config: PermitConfig): - httpserver.expect_request(f"{FACTS}/users", method="POST").respond_with_json(user_read_payload("user-1")) +async def test_users_create_validates_a_dict_into_the_model( + httpserver: HTTPServer, config: PermitConfig +) -> None: + httpserver.expect_request(f"{FACTS}/users", method="POST").respond_with_json( + user_read_payload("user-1") + ) await UsersApi(config).create({"key": "user-1", "email": "user@example.com"}) @@ -242,13 +281,15 @@ async def test_users_create_validates_a_dict_into_the_model(httpserver: HTTPServ 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() == { @@ -258,9 +299,13 @@ async def test_users_assign_role_keeps_explicitly_provided_resource_instance( } -async def test_users_update_sends_a_field_set_to_none_as_null(httpserver: HTTPServer, config: PermitConfig): +async def test_users_update_sends_a_field_set_to_none_as_null( + httpserver: HTTPServer, config: PermitConfig +) -> None: """Setting a field to None is how a caller clears it, so the null must reach the API.""" - httpserver.expect_request(f"{FACTS}/users/user-1", method="PATCH").respond_with_json(user_read_payload("user-1")) + httpserver.expect_request(f"{FACTS}/users/user-1", method="PATCH").respond_with_json( + user_read_payload("user-1") + ) await UsersApi(config).update("user-1", UserUpdate(first_name=None)) @@ -275,12 +320,15 @@ async def test_users_update_sends_a_field_set_to_none_as_null(httpserver: HTTPSe (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 @@ -297,7 +345,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 @@ -307,15 +355,15 @@ async def test_ensure_access_level_rejects_a_key_too_narrow_for_the_endpoint( async def test_projects_create_with_an_environment_key_is_refused_before_sending( httpserver: HTTPServer, config: PermitConfig -): - """Creating a project needs an organization key. An environment key must fail here, not at the API.""" +) -> None: + """Creating a project needs an organization key: an environment key fails before sending.""" with pytest.raises(PermitContextError): await ProjectsApi(config).create(ProjectCreate(key="project-1", name="Project 1")) assert httpserver.log == [] -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) @@ -325,7 +373,9 @@ def test_sync_pdp_api_initializes_the_base_client_state(config: PermitConfig): assert client._headers["Content-Type"] == "application/json" -async def test_every_sdk_client_sends_the_standard_bearer_scheme(httpserver: HTTPServer, config: PermitConfig) -> None: +async def test_every_sdk_client_sends_the_standard_bearer_scheme( + httpserver: HTTPServer, config: PermitConfig +) -> None: """The enforcer, REST API client and PDP API client must all send "Bearer ".""" httpserver.expect_request("/allowed", method="POST").respond_with_json({"allow": True}) httpserver.expect_request(f"{FACTS}/users", method="GET").respond_with_json( @@ -348,7 +398,9 @@ async def test_every_sdk_client_sends_the_standard_bearer_scheme(httpserver: HTT } -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"} @@ -365,7 +417,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"} ) @@ -375,10 +429,12 @@ 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"} -async def test_tenants_delete_tenant_user_targets_the_tenant_membership(httpserver: HTTPServer, config: PermitConfig): - httpserver.expect_request(f"{FACTS}/tenants/tenant-1/users/user-1", method="DELETE").respond_with_data( - "", status=204 - ) +async def test_tenants_delete_tenant_user_targets_the_tenant_membership( + httpserver: HTTPServer, config: PermitConfig +) -> None: + httpserver.expect_request( + f"{FACTS}/tenants/tenant-1/users/user-1", method="DELETE" + ).respond_with_data("", status=204) await TenantsApi(config).delete_tenant_user("tenant-1", "user-1") @@ -390,17 +446,21 @@ async def test_tenants_delete_tenant_user_targets_the_tenant_membership(httpserv ) -async def test_environments_copy_sends_the_copy_request_as_given(httpserver: HTTPServer, config: PermitConfig): +async def test_environments_copy_sends_the_copy_request_as_given( + httpserver: HTTPServer, config: PermitConfig +) -> None: config.api_context._permitted_access_level = ApiKeyAccessLevel.PROJECT_LEVEL_API_KEY - httpserver.expect_request("/v2/projects/project-1/envs/env-1/copy", method="POST").respond_with_json( - environment_read_payload("env-copy") - ) + httpserver.expect_request( + "/v2/projects/project-1/envs/env-1/copy", method="POST" + ).respond_with_json(environment_read_payload("env-copy")) await EnvironmentsApi(config).copy( "project-1", "env-1", EnvironmentCopy( - target_env=EnvironmentCopyTarget(new=EnvironmentCreate(key="env-copy", name="Env copy")), + target_env=EnvironmentCopyTarget( + new=EnvironmentCreate(key="env-copy", name="Env copy") + ), conflict_strategy=EnvironmentCopyConflictStrategy.fail, ), ) @@ -411,7 +471,9 @@ async def test_environments_copy_sends_the_copy_request_as_given(httpserver: HTT } -async def test_user_invites_get_raises_not_found_for_an_unknown_invite(httpserver: HTTPServer, config: PermitConfig): +async def test_user_invites_get_raises_not_found_for_an_unknown_invite( + httpserver: HTTPServer, config: PermitConfig +) -> None: invite_id = str(uuid4()) httpserver.expect_request(f"{FACTS}/user_invites/{invite_id}", method="GET").respond_with_json( {"detail": "not found"}, status=404 @@ -423,13 +485,13 @@ async def test_user_invites_get_raises_not_found_for_an_unknown_invite(httpserve assert exc_info.value.status_code == 404 -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"}}) @@ -438,7 +500,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, @@ -447,41 +511,89 @@ 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_importing_the_sdk_emits_no_deprecation_warning() -> None: + # On pydantic 1, importing permit warns on purpose that pydantic 1 support is deprecated + # (see test_fix_pydantic1_deprecation.py); the later -W option takes precedence. + result = subprocess.run( + [ + sys.executable, + "-W", + "error::DeprecationWarning", + "-W", + "ignore:Support for pydantic 1 is deprecated: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") + + +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"} @@ -505,9 +617,12 @@ def runtime_requirement(name: str, python_version: str) -> Requirement: matching = [ requirement for requirement in requirements - if requirement.name == name and (requirement.marker is None or requirement.marker.evaluate(environment)) + if requirement.name == name + and (requirement.marker is None or requirement.marker.evaluate(environment)) ] - assert len(matching) == 1, f"expected one {name} requirement on Python {python_version}, got {matching}" + assert len(matching) == 1, ( + f"expected one {name} requirement on Python {python_version}, got {matching}" + ) return matching[0] @@ -521,8 +636,7 @@ def pydantic_release_candidates() -> list[str]: candidates = ["2.0"] for major, minor_count in ((1, 11), (2, 20)): for minor in range(minor_count): - for patch in range(30): - candidates.append(f"{major}.{minor}.{patch}") + candidates.extend(f"{major}.{minor}.{patch}" for patch in range(30)) return candidates @@ -530,7 +644,9 @@ def pydantic_release_candidates() -> list[str]: @pytest.mark.parametrize("python_version", ["3.10", "3.11", "3.12", "3.13", "3.14"]) -def test_pydantic_requirement_allows_no_release_affected_by_cve_2024_3772(python_version: str): +def test_pydantic_requirement_allows_no_release_affected_by_cve_2024_3772( + python_version: str, +) -> None: # CVE-2024-3772 (ReDoS in email validation) is fixed in pydantic 1.10.13. # Under pydantic 2 permit validates emails with the pydantic.v1 copy pydantic # bundles, which is 1.10.13 or later only from pydantic 2.4.2. @@ -538,7 +654,8 @@ def test_pydantic_requirement_allows_no_release_affected_by_cve_2024_3772(python affected = [ candidate for candidate in PYDANTIC_CANDIDATES - if Version(candidate) < Version("1.10.13") or Version("2") <= Version(candidate) < Version("2.4.2") + if Version(candidate) < Version("1.10.13") + or Version("2") <= Version(candidate) < Version("2.4.2") ] assert list(specifier.filter(affected)) == [] @@ -557,19 +674,23 @@ def test_pydantic_requirement_allows_no_release_affected_by_cve_2024_3772(python ) def test_pydantic_requirement_allows_each_major_from_its_floor_up( python_version: str, pydantic_1_floor: str, pydantic_2_floor: str -): +) -> None: specifier = runtime_requirement("pydantic", python_version).specifier allowed = [Version(candidate) for candidate in specifier.filter(PYDANTIC_CANDIDATES)] candidates = [Version(candidate) for candidate in PYDANTIC_CANDIDATES] for major, floor in ((1, Version(pydantic_1_floor)), (2, Version(pydantic_2_floor))): - expected = [candidate for candidate in candidates if candidate.major == major and candidate >= floor] + expected = [ + candidate for candidate in candidates if candidate.major == major and candidate >= floor + ] assert [version for version in allowed if version.major == major] == expected @pytest.mark.parametrize("python_version", ["3.10", "3.11", "3.12", "3.13"]) @pytest.mark.parametrize("version", ["1.10.13", "1.10.17"]) -def test_pydantic_requirement_before_py314_rejects_1_10_17_and_older(python_version: str, version: str): +def test_pydantic_requirement_before_py314_rejects_1_10_17_and_older( + python_version: str, version: str +) -> None: # Up to 1.10.16 there is no pydantic.v1 package for type checkers to resolve # permit's model imports against, and up to 1.10.17 `import permit` emits # thousands of DeprecationWarnings on Python 3.13. @@ -577,13 +698,13 @@ def test_pydantic_requirement_before_py314_rejects_1_10_17_and_older(python_vers @pytest.mark.parametrize("python_version", ["3.10", "3.11", "3.12", "3.13", "3.14"]) -def test_pydantic_requirement_rejects_2_0(python_version: str): +def test_pydantic_requirement_rejects_2_0(python_version: str) -> None: # pydantic 2.0's pydantic.v1.parse_obj_as builds a pydantic 2 model, so every # API call that parses a response raises TypeError. assert not runtime_requirement("pydantic", python_version).specifier.contains("2.0") -def test_pydantic_requirement_rejects_versions_that_crash_on_py314(): +def test_pydantic_requirement_rejects_versions_that_crash_on_py314() -> None: specifier = runtime_requirement("pydantic", "3.14").specifier for crashing in ("1.10.24", "2.11.10", "2.12.5"): @@ -603,15 +724,17 @@ def test_pydantic_requirement_rejects_versions_that_crash_on_py314(): ("loguru", "3.14", "0.7.2"), ], ) -def test_runtime_floor_excludes_versions_broken_on_a_supported_python(name: str, python_version: str, broken: str): +def test_runtime_floor_excludes_versions_broken_on_a_supported_python( + name: str, python_version: str, broken: str +) -> None: assert not runtime_requirement(name, python_version).specifier.contains(broken) -def test_deprecated_decorator_keeps_async_functions_async(): - async def fetch(): +def test_deprecated_decorator_keeps_async_functions_async() -> None: + async def fetch() -> None: return None - def compute(): + def compute() -> None: return None with warnings.catch_warnings(record=True) as caught: @@ -624,6 +747,31 @@ def compute(): assert [str(w.message) for w in caught if "asyncio.iscoroutinefunction" in str(w.message)] == [] +@pytest.mark.parametrize( + ("value", "expected"), + [ + (Decimal(1), 1), + (Decimal("1E+2"), 100), + (Decimal("1.0"), 1.0), + (Decimal("-2.5"), -2.5), + ], +) +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", ["NaN", "-NaN", "sNaN", "Infinity", "-Infinity"]) +def test_jsonable_encoder_rejects_non_finite_decimals(value: str) -> None: + # JSON has no NaN or Infinity, so the encoder refuses them instead of letting + # an invalid request body reach the API. It used to raise an unrelated + # TypeError from comparing the Decimal's str exponent with 0. + with pytest.raises(TypeError, match="JSON has no NaN or Infinity"): + jsonable_encoder({"value": Decimal(value)}) + + @pytest.mark.parametrize( ("version", "expected"), [ @@ -637,16 +785,18 @@ def compute(): ("2.13.5+local", (2, 13, 5)), ], ) -def test_pydantic_version_parses_release_and_pre_release_versions(version: str, expected: tuple[int, ...]): +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(): +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(): +def test_pydantic_version_constant_is_the_installed_version() -> None: assert pydantic_version._parse(pydantic.__version__) == pydantic_version.PYDANTIC_VERSION @@ -656,11 +806,16 @@ def test_pydantic_version_constant_is_the_installed_version(): PYDANTIC_1_BRANCH_TESTS = {"PYDANTIC_VERSION < (2, 0)", "_PYDANTIC_VERSION < (2, 0)"} -def unguarded_pydantic_imports(node: ast.AST, *, in_pydantic_1_branch: bool = False) -> List[int]: - """Return the lines that import the top-level ``pydantic`` namespace outside a pydantic 1 branch.""" +def unguarded_pydantic_imports(node: ast.AST, *, in_pydantic_1_branch: bool = False) -> list[int]: + """Return the lines that import the ``pydantic`` namespace outside a pydantic 1 branch.""" if isinstance(node, (ast.Import, ast.ImportFrom)): - modules = [node.module] if isinstance(node, ast.ImportFrom) else [alias.name for alias in node.names] + modules = ( + [node.module] + if isinstance(node, ast.ImportFrom) + else [alias.name for alias in node.names] + ) return [node.lineno] if "pydantic" in modules and not in_pydantic_1_branch else [] + branches: list[tuple[Sequence[ast.AST], bool]] if isinstance(node, ast.If): body_branch = in_pydantic_1_branch or ast.unparse(node.test) in PYDANTIC_1_BRANCH_TESTS branches = [(node.body, body_branch), (node.orelse, in_pydantic_1_branch)] @@ -674,10 +829,13 @@ def unguarded_pydantic_imports(node: ast.AST, *, in_pydantic_1_branch: bool = Fa ] -def test_sdk_imports_the_pydantic_namespace_only_in_its_pydantic_1_branches(): - """Under pydantic 2 the SDK's models are pydantic.v1 models. A top-level ``pydantic`` import +def test_sdk_imports_the_pydantic_namespace_only_in_its_pydantic_1_branches() -> None: + """The SDK imports the ``pydantic`` namespace only where pydantic 1 is installed. + + Under pydantic 2 the SDK's models are pydantic.v1 models. A top-level ``pydantic`` import beside them mixes the two APIs and fails under pydantic 2 alone: parse_obj_as on a v1 model - raises TypeError.""" + raises TypeError. + """ offenders = {} for path in sorted(PERMIT_PACKAGE.rglob("*.py")): lines = unguarded_pydantic_imports(ast.parse(path.read_text(encoding="utf-8"))) @@ -687,8 +845,8 @@ def test_sdk_imports_the_pydantic_namespace_only_in_its_pydantic_1_branches(): assert offenders == {} -def test_the_pydantic_import_scan_tells_the_branches_apart(): - source = "\n".join( +def test_the_pydantic_import_scan_tells_the_branches_apart() -> None: + source = "\n".join( # noqa: FLY002 - one item per source line keeps the line numbers readable [ "from pydantic.v1 import BaseModel", "if TYPE_CHECKING:", diff --git a/tests/test_rbac_e2e.py b/tests/test_rbac_e2e.py index 4710205..34827c9 100644 --- a/tests/test_rbac_e2e.py +++ b/tests/test_rbac_e2e.py @@ -2,7 +2,8 @@ import http.client import threading import time -from typing import Any, AsyncIterable, Awaitable, Callable, Final, Iterator, List, Optional +from collections.abc import AsyncIterable, Awaitable, Callable, Iterator +from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar import pytest from loguru import logger @@ -11,13 +12,14 @@ from permit import Permit, ResourceRead, RoleAssignmentRead, RoleRead from permit.exceptions import PermitApiError, PermitConnectionError -from permit.pdp_api.models import RoleAssignment +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.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}") @@ -129,7 +143,7 @@ def handler(request: Request) -> Response: # noqa: ARG001 connection.close() -async def test_api_timeout(httpserver: HTTPServer, sleeping: Callable[[Request], Response]): +async def test_api_timeout(httpserver: HTTPServer, sleeping: Callable[[Request], Response]) -> None: mocked_url = httpserver.url_for("").rstrip("/") permit = Permit( token="mocked", @@ -145,7 +159,7 @@ async def test_api_timeout(httpserver: HTTPServer, sleeping: Callable[[Request], assert time_passed < 3 -async def test_pdp_timeout(httpserver: HTTPServer, sleeping: Callable[[Request], Response]): +async def test_pdp_timeout(httpserver: HTTPServer, sleeping: Callable[[Request], Response]) -> None: mocked_url = httpserver.url_for("").rstrip("/") permit = Permit( token="mocked", @@ -226,7 +240,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 @@ -247,6 +263,7 @@ 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 @@ -266,10 +283,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 @@ -288,7 +308,7 @@ 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") @@ -326,6 +346,7 @@ 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" @@ -341,14 +362,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( @@ -417,7 +439,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 @@ -446,7 +468,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 @@ -486,7 +510,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: @@ -501,14 +525,15 @@ 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( { @@ -542,6 +567,7 @@ 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" @@ -557,10 +583,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 @@ -650,7 +677,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 36ac08d..43a06c4 100644 --- a/tests/test_rbac_e2e_sync.py +++ b/tests/test_rbac_e2e_sync.py @@ -1,21 +1,23 @@ 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 pytestmark = pytest.mark.e2e -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 @@ -47,7 +49,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 @@ -72,7 +82,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}") @@ -84,7 +94,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") @@ -134,7 +144,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 @@ -179,6 +191,7 @@ def test_permission_check_e2e(sync_permit: SyncPermit): assigned_viewer = permit.api.roles.assign_permissions(viewer_role_key, [read_permission]) assert assigned_viewer.key == viewer_role_key + assert assigned_viewer.permissions is not None assert len(assigned_viewer.permissions) == 1 assert read_permission in assigned_viewer.permissions assert create_permission not in assigned_viewer.permissions @@ -216,6 +229,7 @@ def test_permission_check_e2e(sync_permit: SyncPermit): 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" @@ -231,14 +245,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( @@ -291,7 +306,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 @@ -320,7 +335,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 @@ -330,7 +347,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", ) @@ -340,7 +359,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 266b6b4..bd56c0c 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 @@ -55,16 +56,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 ---------------------------------------------------------------- @@ -308,7 +309,7 @@ class PermissionAssertions: f"{DOCUMENT.key}:movie2", ] -ASSIGNMENTS_AND_ASSERTIONS: List[PermissionAssertions] = [ +ASSIGNMENTS_AND_ASSERTIONS: list[PermissionAssertions] = [ # direct access PermissionAssertions( assignments=[ @@ -439,19 +440,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"] @@ -567,7 +572,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 @@ -588,10 +593,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( @@ -622,7 +627,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.") @@ -652,13 +657,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}", ) @@ -685,7 +694,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 @@ -699,11 +708,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 @@ -729,12 +739,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 @@ -753,7 +766,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( @@ -790,33 +804,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: @@ -826,28 +848,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 @@ -901,7 +933,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 8835dd6..67a0c8e 100644 --- a/tests/test_sync_client.py +++ b/tests/test_sync_client.py @@ -9,13 +9,13 @@ pytestmark = pytest.mark.e2e -@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, @@ -27,7 +27,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_typing_surface.py b/tests/test_typing_surface.py index 1b5c8fa..82c2234 100644 --- a/tests/test_typing_surface.py +++ b/tests/test_typing_surface.py @@ -33,7 +33,7 @@ def load_stub_generator() -> ModuleType: return module -def test_consumer_code_type_checks_without_errors(tmp_path: Path): +def test_consumer_code_type_checks_without_errors(tmp_path: Path) -> None: result = subprocess.run( [ sys.executable, @@ -54,7 +54,7 @@ def test_consumer_code_type_checks_without_errors(tmp_path: Path): assert result.returncode == 0, result.stdout + result.stderr -def test_sync_stub_matches_the_async_classes(): +def test_sync_stub_matches_the_async_classes() -> None: generator = load_stub_generator() expected = generator.render_stub() @@ -62,7 +62,10 @@ def test_sync_stub_matches_the_async_classes(): diff = "".join( difflib.unified_diff( - committed.splitlines(keepends=True), expected.splitlines(keepends=True), "committed", "generated" + committed.splitlines(keepends=True), + expected.splitlines(keepends=True), + "committed", + "generated", ) ) regenerate = "uv run python scripts/generate_sync_stubs.py" @@ -91,12 +94,14 @@ def stub_plain_methods() -> dict[str, set[str]]: classes[node.name] = { member.name for member in node.body - if isinstance(member, ast.FunctionDef) and not member.name.startswith("_") and not member.decorator_list + if isinstance(member, ast.FunctionDef) + and not member.name.startswith("_") + and not member.decorator_list } return classes -def test_sync_stub_declares_exactly_the_methods_sync_class_makes_blocking(): +def test_sync_stub_declares_exactly_the_methods_sync_class_makes_blocking() -> None: generator = load_stub_generator() stub = stub_plain_methods() runtime = runtime_sync_classes() @@ -106,7 +111,9 @@ def test_sync_stub_declares_exactly_the_methods_sync_class_makes_blocking(): (async_cls,) = sync_cls.__bases__ # SyncClass's own rule: every public attribute whose call returns an awaitable. converted = { - name for name in dir(async_cls) if not name.startswith("_") and iscoroutine_func(getattr(async_cls, name)) + name + for name in dir(async_cls) + if not name.startswith("_") and iscoroutine_func(getattr(async_cls, name)) } assert stub[generator.stub_name(sync_cls)] == converted, sync_cls for name in converted: diff --git a/tests/test_user_invites_complete_e2e.py b/tests/test_user_invites_complete_e2e.py index 344becd..8950aea 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 @@ -25,8 +25,8 @@ pytestmark = pytest.mark.e2e -def print_break(): - print("\n\n ----------- \n\n") # noqa: T201 +def print_break() -> None: + print("\n\n ----------- \n\n") class SetupUserInvites(NamedTuple): @@ -34,14 +34,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 = { @@ -59,11 +61,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: # ========================================== @@ -77,8 +79,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) @@ -100,7 +106,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}") @@ -109,7 +117,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 @@ -135,10 +146,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: @@ -149,8 +160,10 @@ async def setup_user_invites(permit: Permit): try: # Delete test resource instance first: it belongs to the tenant and the resource below. # The API identifies an instance as "resource:key" (or its id); a bare key is rejected. - if created_resource_instance: - instance_ident = f"{created_resource_instance.resource}:{created_resource_instance.key}" + if created_resource_instance is not None: + instance_ident = ( + f"{created_resource_instance.resource}:{created_resource_instance.key}" + ) try: await permit.api.resource_instances.delete(instance_ident) logger.info(f"Cleaned up resource instance: {instance_ident}") @@ -159,7 +172,7 @@ async def setup_user_invites(permit: Permit): logger.warning(f"Failed to delete resource instance {instance_ident}: {e}") # 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}") @@ -168,7 +181,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}") @@ -177,7 +190,7 @@ async def setup_user_invites(permit: Permit): logger.warning(f"Failed to delete tenant {created_tenant.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}") @@ -196,9 +209,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) @@ -209,7 +221,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 @@ -264,9 +275,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() @@ -281,7 +297,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() @@ -293,7 +311,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( @@ -322,13 +344,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] @@ -346,8 +366,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/type_check/consumer.py b/tests/type_check/consumer.py index 547f0e3..984c5b6 100644 --- a/tests/type_check/consumer.py +++ b/tests/type_check/consumer.py @@ -6,7 +6,8 @@ errors: with warn_unused_ignores, the check fails if one of them stops being reported. """ -from typing import Any, Callable, Dict, List, Optional, TypeVar, Union +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar from typing_extensions import assert_type @@ -25,9 +26,11 @@ ) from permit.enforcement.enforcer import CheckQuery from permit.pdp_api.models import RoleAssignment -from permit.pdp_api.pdp_api_client import SyncRoleAssignmentsApi from permit.sync import Permit as SyncPermit +if TYPE_CHECKING: + from permit.pdp_api.pdp_api_client import SyncRoleAssignmentsApi + CONFIG = PermitConfig(token="permit_key_x", pdp="http://localhost:7766") _Parameter = TypeVar("_Parameter") @@ -50,11 +53,18 @@ async def async_client() -> None: assert_type(await permit.check("user", "read", "document"), bool) assert_type( - await permit.check({"key": "u", "attributes": {"dept": "eng"}}, "read", {"type": "document", "tenant": "t1"}), + await permit.check( + {"key": "u", "attributes": {"dept": "eng"}}, + "read", + {"type": "document", "tenant": "t1"}, + ), bool, ) - assert_type(await permit.bulk_check([{"user": "u", "action": "read", "resource": "document"}]), List[bool]) - assert_type(await permit.get_user_permissions("u"), Dict[str, Any]) + assert_type( + await permit.bulk_check([{"user": "u", "action": "read", "resource": "document"}]), + list[bool], + ) + assert_type(await permit.get_user_permissions("u"), dict[str, Any]) # Optional model fields are optional to the type checker too. user = UserCreate(key="u") @@ -71,14 +81,23 @@ async def async_client() -> None: assert_type(await permit.api.users.create({"key": "u2"}), UserRead) assignment = RoleAssignmentCreate(user="u", role="admin", tenant="t1") assert_type(await permit.api.users.assign_role(assignment), RoleAssignmentRead) - assert_type(await permit.api.users.assign_role({"user": "u", "role": "admin", "tenant": "t1"}), RoleAssignmentRead) - assert_type(await permit.api.users.bulk_create([user, {"key": "u3"}]), UserCreateBulkOperationResult) + assert_type( + await permit.api.users.assign_role({"user": "u", "role": "admin", "tenant": "t1"}), + RoleAssignmentRead, + ) + assert_type( + await permit.api.users.bulk_create([user, {"key": "u3"}]), UserCreateBulkOperationResult + ) assert_type(await permit.api.tenants.create(tenant), TenantRead) await permit.api.tenants.bulk_create([{"key": "t2", "name": "T2"}]) assert_type(await permit.api.roles.create(role), RoleRead) - await permit.api.resources.create({"key": "document", "name": "Document", "actions": {"read": {}}}) + await permit.api.resources.create( + {"key": "document", "name": "Document", "actions": {"read": {}}} + ) assert_type( - await permit.api.role_assignments.bulk_assign([{"user": "u", "role": "admin", "tenant": "t1"}]), + await permit.api.role_assignments.bulk_assign( + [{"user": "u", "role": "admin", "tenant": "t1"}] + ), BulkRoleAssignmentReport, ) await permit.api.users.sync({"key": "u", "email": "u@example.com"}) @@ -86,9 +105,11 @@ async def async_client() -> None: # A list built before a bulk call is accepted too, whether of models or of dicts. users = [UserCreate(key=key) for key in ("u4", "u5")] await permit.api.users.bulk_create(users) - tenant_dicts: List[Dict[str, Any]] = [{"key": "t3", "name": "T3"}] + tenant_dicts: list[dict[str, Any]] = [{"key": "t3", "name": "T3"}] await permit.api.tenants.bulk_create(tenant_dicts) - assignments = [RoleAssignmentCreate(user=key, role="admin", tenant="t1") for key in ("u4", "u5")] + assignments = [ + RoleAssignmentCreate(user=key, role="admin", tenant="t1") for key in ("u4", "u5") + ] await permit.api.role_assignments.bulk_assign(assignments) # The deprecated facade keeps the signatures of the methods it wraps. @@ -97,9 +118,9 @@ async def async_client() -> None: # Results are pydantic v1 models under either pydantic major. fetched = await permit.api.users.get("u") - assert_type(fetched.dict(), Dict[str, Any]) + assert_type(fetched.dict(), dict[str, Any]) assert_type(fetched.key, str) - assert_type(fetched.email, Optional[str]) + assert_type(fetched.email, str | None) try: await permit.api.users.get("missing") @@ -111,31 +132,31 @@ def dict_parameters(query: CheckQuery) -> None: permit = Permit(CONFIG) sync_permit = SyncPermit(CONFIG) - assert_type(query["user"], Union[Dict[str, Any], str]) - assert_type(query["resource"], Union[Dict[str, Any], str]) - assert_type(parameter_type(permit.api.users.sync), Union[UserCreate, Dict[str, Any]]) - assert_type(parameter_type(sync_permit.api.users.sync), Union[UserCreate, Dict[str, Any]]) - assert_type(parameter_type(sync_permit.api.create_tenant), Union[TenantCreate, Dict[str, Any]]) + assert_type(query["user"], dict[str, Any] | str) + assert_type(query["resource"], dict[str, Any] | str) + assert_type(parameter_type(permit.api.users.sync), UserCreate | dict[str, Any]) + assert_type(parameter_type(sync_permit.api.users.sync), UserCreate | dict[str, Any]) + assert_type(parameter_type(sync_permit.api.create_tenant), TenantCreate | dict[str, Any]) def sync_client() -> None: permit = SyncPermit(CONFIG) assert_type(permit.check("user", "read", "document"), bool) - assert_type(permit.get_user_permissions("u"), Dict[str, Any]) + assert_type(permit.get_user_permissions("u"), dict[str, Any]) assert_type(permit.api.users.get("u"), UserRead) assert_type(permit.api.users.list(), PaginatedResultUserRead) assert_type(permit.api.tenants.create(TenantCreate(key="t1", name="T1")), TenantRead) - assert_type(permit.api.tenants.list(), List[TenantRead]) + assert_type(permit.api.tenants.list(), list[TenantRead]) assert_type(permit.api.users.create({"key": "u2"}), UserRead) permit.api.users.assign_role({"user": "u", "role": "admin", "tenant": "t1"}) permit.api.users.bulk_create([UserCreate(key="u3"), {"key": "u4"}]) - users: List[UserCreate] = [UserCreate(key="u5")] + users: list[UserCreate] = [UserCreate(key="u5")] permit.api.users.bulk_replace(users) assert_type(permit.api.get_user("u"), UserRead) assert_type(permit.elements.login_as("u", "t1"), UserLoginAsResponse) pdp_role_assignments: SyncRoleAssignmentsApi = permit.pdp_api.role_assignments - assert_type(pdp_role_assignments.list(), List[RoleAssignment]) + assert_type(pdp_role_assignments.list(), list[RoleAssignment]) for listed in permit.api.users.list().data: assert_type(listed.key, str) diff --git a/tests/utils.py b/tests/utils.py index 1ee7150..7e153a2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,6 +1,6 @@ import json import uuid -from typing import Any, Dict, NamedTuple, Tuple +from typing import Any, NamedTuple import pytest from loguru import logger @@ -39,15 +39,15 @@ class Call(NamedTuple): """A method, by the dotted path a user writes, and the arguments to call it with.""" path: str - args: Tuple[Any, ...] - kwargs: Dict[str, Any] + args: tuple[Any, ...] + kwargs: dict[str, Any] def call(path: str, *args: Any, **kwargs: Any) -> Call: return Call(path, args, kwargs) -def sent(request: Request) -> Dict[str, Any]: +def sent(request: Request) -> dict[str, Any]: """What a request put on the wire, in a form two requests can be compared by.""" body = request.get_data() return { @@ -61,9 +61,10 @@ def sent(request: Request) -> Dict[str, Any]: # --- end-to-end tests --------------------------------------------------------- -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) @@ -80,7 +81,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 @@ -91,7 +92,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 9b55bcb..5c853a5 100644 --- a/uv.lock +++ b/uv.lock @@ -184,6 +184,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" @@ -429,6 +493,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" @@ -710,31 +904,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]] @@ -764,6 +996,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" @@ -786,6 +1027,7 @@ dev = [ { name = "pytest-httpserver" }, { name = "ruff" }, { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "typos" }, { name = "uv" }, { name = "werkzeug" }, ] @@ -808,14 +1050,15 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = "==1.11.2" }, + { name = "mypy", specifier = "==2.3.1" }, { name = "packaging", specifier = "==26.3" }, { 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.8" }, { name = "tomli", marker = "python_full_version == '3.10.*'", specifier = "==2.4.1" }, + { name = "typos", specifier = "==1.50.2" }, { name = "uv", specifier = "==0.12.17" }, { name = "werkzeug", specifier = "==3.1.8" }, ] @@ -1322,27 +1565,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.6.9" +version = "0.16.8" 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/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } 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/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] [[package]] @@ -1420,6 +1663,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 = "uv" version = "0.12.17"