From 88bca49b831f09721e1e45ff5cfd932f5f6b1f6b Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Fri, 26 Jun 2026 12:46:23 -0400 Subject: [PATCH 1/5] Add scripts to create OIDC apps and decompose PEM certs --- README.md | 85 ++++- bin/azoidcapp | 907 +++++++++++++++++++++++++++++++++++++++++++++++ bin/pemdecompose | 334 +++++++++++++++++ 3 files changed, 1324 insertions(+), 2 deletions(-) create mode 100755 bin/azoidcapp create mode 100755 bin/pemdecompose diff --git a/README.md b/README.md index 14c57b0..12d7961 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,17 @@ implement. | Script | Purpose | | --- | --- | +| `azoidcapp` | Create an Entra ID OIDC app + SP with MS Graph permissions | | `bwclaude` | Run the Claude CLI inside a bubblewrap sandbox | | `bwcopilot` | Run the GitHub Copilot CLI inside a bubblewrap sandbox | | `bwopencode` | Run OpenCode inside a bubblewrap sandbox | | `bwcodex` | Run Codex inside a bubblewrap sandbox | +| `pemdecompose` | List and verify certificates in a PEM file | -All four wrappers share `lib/bwrap_sandbox_lib.sh` for sandbox construction. -See each script's `--help` for tool-specific options. +The four `bw*` wrappers share `lib/bwrap_sandbox_lib.sh` for sandbox +construction. `azoidcapp` and `pemdecompose` are standalone utilities +with no sandbox dependency. See each script's `--help` for tool-specific +options. ### `lib/` @@ -79,6 +83,9 @@ stow -t ~/.local -S n2snscripts --ignore='etc' --ignore=README.md - 0.5.0+ enables `--clearenv` - 0.6.3+ enables bind-over-ro-bind binary masking - `gpg(1)` for `gpg-passwd.sh` +- `openssl(1)` for `pemdecompose` +- `python3 >= 3.9` (stdlib only, no pip dependencies) for `azoidcapp` +- `az` (Azure CLI) logged in via `az login` for `azoidcapp` ## Usage @@ -122,3 +129,77 @@ Behaviour: fails. See the library header for the full contract. + +### `azoidcapp` + +Create or reconcile an Azure Entra ID OIDC web application and its +service principal. Sets an owner on both objects, ensures Microsoft +Graph API permissions (`User.Read.All` application app-role plus the +standard OIDC delegated scopes `offline_access`, `email`, `openid`, +and `profile`), and grants tenant-wide admin consent. + +The script is **idempotent**: re-running with the same `--name` is +safe. Existing objects are reused; redirect URIs are unioned (never +removed); permissions and consent are reconciled to the desired state. + +Authentication reuses the current `az` CLI session — no additional +credentials or Python packages are required. + +```text +azoidcapp --name NAME --owner UPN --redirect-uri URI [options] +``` + +| Option | Effect | +| --- | --- | +| `-n`, `--name NAME` | Display name of the app registration (required) | +| `-o`, `--owner UPN` | Owner UPN/email resolved to an object ID (required) | +| `-r`, `--redirect-uri URI` | Web redirect URI; repeatable, minimum one | +| `-t`, `--tenant TENANT` | Tenant ID or domain for the `az` token request | +| `--json` | Emit a JSON result instead of a human-readable summary | +| `-h`, `--help` | Show help and exit | + +Example: + +```bash +azoidcapp \ + --name "My OIDC App" \ + --owner alice@example.com \ + --redirect-uri https://app.example.com/callback \ + --redirect-uri https://app.example.com/silent-callback +``` + +Exit codes: `0` success, `1` usage error, `2` `az` missing or not logged +in, `3` owner UPN not found, `4` a Microsoft Graph call failed. + +Requires: `az` (Azure CLI, logged in via `az login`), `python3 >= 3.9` +(stdlib only — no pip dependencies). + +### `pemdecompose` + +List certificates in a combined PEM file. Useful for inspecting outputs of +the `acme_certificates` Ansible role (`server.pem`, `server.combined.pem`, +`server_chain.pem`). + +```bash +pemdecompose server.combined.pem # subject, issuer, sigalg per cert +pemdecompose --verify server.pem # also check chain order +pemdecompose server.pem server_chain.pem # multiple files, with banners +``` + +Blank lines and non-PEM text between PEM blocks are tolerated. Private-key +blocks in a combined file are skipped automatically (only certificate +blocks are inspected). + +`--verify` checks that each cert (except the last in the file) is issued +by the next cert in the file, using `openssl verify -partial_chain` and a +DN match between issuer(N) and subject(N+1). Validity dates are not part +of the structural check, so expired roots do not cause `--verify` to fail. + +Output is colourised when stdout is a TTY. Set `NO_COLOR=1` or pass +`--no-color` to suppress ANSI escapes. + +Exit codes: `0` success, `1` usage error, `2` no certificates found in +some file, `3` openssl parse error, `4` `--verify` failed for at least +one chain link. + +Requires only `openssl`. diff --git a/bin/azoidcapp b/bin/azoidcapp new file mode 100755 index 0000000..4934c21 --- /dev/null +++ b/bin/azoidcapp @@ -0,0 +1,907 @@ +#!/usr/bin/env python3 +# azoidcapp — Create or reconcile an Entra ID OIDC web application. +# +# Registers a new Azure AD application for OIDC authentication, creates +# the corresponding service principal, sets an owner on both objects, +# adds Microsoft Graph API permissions (User.Read and the standard OIDC +# delegated scopes), and grants tenant-wide admin +# consent for all assigned permissions. +# +# The script is idempotent: re-running with the same --name is safe. +# Existing objects are reused; redirect URIs are unioned (never removed); +# permissions and consent are reconciled to the desired state. +# +# Authentication reuses the current 'az' CLI session; no extra credentials +# or pip dependencies are required. +# +# Requires: az (Azure CLI, logged in via 'az login'), python3 >= 3.9 +# +# Usage: +# azoidcapp --name NAME --owner UPN --redirect-uri URI [options] +# azoidcapp --help +# +# Required options: +# -n, --name NAME Display name of the app registration +# -o, --owner UPN Owner UPN/email (e.g. user@example.com) +# -r, --redirect-uri URI Web redirect URI (repeatable, at least one) +# +# Optional options: +# -t, --tenant TENANT Tenant ID or domain for the az token request +# --json Emit a JSON result instead of human-readable +# summary +# -h, --help Show this help and exit +# +# Exit codes: +# 0 success (all resources in desired state) +# 1 usage error +# 2 az CLI missing or not logged in +# 3 owner UPN could not be resolved to an object ID +# 4 a Microsoft Graph call failed, or multiple apps share --name + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +# --------------------------------------------------------------------------- +# Python version guard +# --------------------------------------------------------------------------- + +if sys.version_info < (3, 9): + sys.exit("azoidcapp: requires Python 3.9+") + +# --------------------------------------------------------------------------- +# Well-known Microsoft Graph identifiers +# These are stable, tenant-independent values published by Microsoft. +# --------------------------------------------------------------------------- + +GRAPH_BASE = "https://graph.microsoft.com/v1.0" + +# Microsoft Graph resource application ID (same in every tenant) +GRAPH_APP_ID = "00000003-0000-0000-c000-000000000000" + +# Delegated OAuth2 scope permissions (OIDC claims + User.Read) +DELEGATED_SCOPES: dict[str, str] = { + "openid": "37f7f235-527c-4136-accd-4a02d197296e", + "email": "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0", + "profile": "14dad69e-099b-42c9-810b-d002981feec1", + "offline_access": "7427e0e9-2fba-42fe-b0c0-848c9e6a8182", + "User.Read": "e1fe6dd8-ba31-4d61-89e7-88639da4683d", +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +PROG = "azoidcapp" + + +def die(msg: str, code: int = 1) -> None: + print(f"{PROG}: {msg}", file=sys.stderr) + sys.exit(code) + + +def warn(msg: str) -> None: + print(f"{PROG}: warning: {msg}", file=sys.stderr) + + +def info(msg: str) -> None: + print(f" {msg}", file=sys.stderr) + + +def step(msg: str) -> None: + print(msg, file=sys.stderr) + + +def _run(args: list[str]) -> str: + """Run a subprocess, return stdout. Die with code 2 on failure.""" + try: + result = subprocess.run( + args, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + except FileNotFoundError: + die(f"command not found: {args[0]}", code=2) + except subprocess.CalledProcessError as exc: + die( + f"command failed: {' '.join(args)}\n{exc.stderr.strip()}", + code=2, + ) + + +# --------------------------------------------------------------------------- +# Azure CLI helpers +# --------------------------------------------------------------------------- + + +def check_az_login() -> None: + """Verify that 'az' is on PATH and a session is active.""" + try: + subprocess.run( + ["az", "account", "show"], + check=True, + capture_output=True, + ) + except FileNotFoundError: + die( + "'az' CLI not found on PATH. " + "Install the Azure CLI: https://aka.ms/azure-cli", + code=2, + ) + except subprocess.CalledProcessError: + die( + "No active az session. Run 'az login' first.", + code=2, + ) + + +def get_graph_token(tenant: str | None) -> str: + """Obtain a Microsoft Graph bearer token via the az CLI.""" + cmd = [ + "az", + "account", + "get-access-token", + "--resource", + "https://graph.microsoft.com", + "--query", + "accessToken", + "--output", + "tsv", + ] + if tenant: + cmd += ["--tenant", tenant] + token = _run(cmd) + if not token: + die("Failed to obtain a Microsoft Graph access token.", code=2) + return token + + +# --------------------------------------------------------------------------- +# Microsoft Graph REST helpers +# --------------------------------------------------------------------------- + + +def _filter_path(resource: str, filter_expr: str, select: str | None = None) -> str: + """ + Build a Graph list path with a properly encoded $filter (and $select). + + Encoding the filter value prevents urllib from rejecting URLs that + contain spaces or single-quotes (e.g. OData 'eq' expressions). + """ + qs = f"$filter={urllib.parse.quote(filter_expr)}" + if select: + qs += f"&$select={urllib.parse.quote(select)}" + return f"/{resource}?{qs}" + + +def _parse_error(exc: urllib.error.HTTPError) -> tuple[int, str]: + """Return (http_code, message_string) from a Graph HTTPError.""" + try: + body_bytes = exc.read() + except Exception: + body_bytes = b"" + try: + detail = json.loads(body_bytes).get("error", {}).get("message", "") + except Exception: + detail = body_bytes.decode(errors="replace") + return exc.code, detail or f"HTTP {exc.code}" + + +def _graph_request( + method: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + *, + expect_no_content: bool = False, +) -> dict[str, Any] | None: + """ + Make a single Microsoft Graph REST call. + + Returns the parsed JSON body on success, or None when the server + returns 204 No Content (or expect_no_content is True). + Raises urllib.error.HTTPError on HTTP failure. + """ + url = f"{GRAPH_BASE}{path}" + data = json.dumps(body).encode() if body is not None else None + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + } + req = urllib.request.Request(url, data=data, headers=headers, method=method) + with urllib.request.urlopen(req) as resp: + raw = resp.read() + if expect_no_content or not raw: + return None + return json.loads(raw) + + +def graph( + method: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + *, + expect_no_content: bool = False, + description: str = "", +) -> dict[str, Any] | None: + """Graph call; dies with code 4 on HTTP failure.""" + label = description or f"{method} {path}" + try: + return _graph_request( + method, path, token, body, expect_no_content=expect_no_content + ) + except urllib.error.HTTPError as exc: + code, detail = _parse_error(exc) + die(f"{label} failed ({code}): {detail}", code=4) + + +def _is_already_exists_error(http_code: int, message: str) -> bool: + """ + Return True when Graph is telling us the resource already exists. + + Graph returns 409 for most duplicate-object attempts, but uses 400 + with specific message text for duplicate owner refs and app-role + assignments. + """ + if http_code == 409: + return True + if http_code == 400: + msg_lower = message.lower() + return any( + phrase in msg_lower + for phrase in ( + # "already exist" (no trailing 's') matches both: + # "One or more added object references already exist + # for the following modified properties: 'owners'." + # "Permission being assigned already exists." + "already exist", + "object references already exist", + ) + ) + return False + + +def graph_or_exists( + method: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + *, + expect_no_content: bool = False, + description: str = "", +) -> tuple[dict[str, Any] | None, bool]: + """ + Graph call that treats 'already exists' responses as success. + + Returns (response_body_or_None, already_existed). + Dies with code 4 on any other HTTP failure. + """ + label = description or f"{method} {path}" + try: + resp = _graph_request( + method, path, token, body, expect_no_content=expect_no_content + ) + return resp, False + except urllib.error.HTTPError as exc: + code, detail = _parse_error(exc) + if _is_already_exists_error(code, detail): + return None, True + die(f"{label} failed ({code}): {detail}", code=4) + + +def graph_with_retry( + method: str, + path: str, + token: str, + body: dict[str, Any] | None = None, + *, + expect_no_content: bool = False, + description: str = "", + retries: int = 6, + backoff: float = 10.0, + swallow_exists: bool = False, +) -> tuple[dict[str, Any] | None, bool]: + """ + Graph call with exponential backoff on transient errors, optionally + treating 'already exists' as success. + + After creating a service principal, Microsoft Graph can take up to + ~60 s to make the new SP visible to all endpoints (admin-consent, + role assignment, etc.). Retrying with a linear backoff handles this. + + Returns (response_body_or_None, already_existed). + """ + label = description or f"{method} {path}" + for attempt in range(1, retries + 1): + try: + resp = _graph_request( + method, path, token, body, expect_no_content=expect_no_content + ) + return resp, False + except urllib.error.HTTPError as exc: + code, detail = _parse_error(exc) + + if swallow_exists and _is_already_exists_error(code, detail): + return None, True + + if code in (400, 404, 503) and attempt < retries: + wait = backoff * attempt + warn( + f"{label}: HTTP {code} (attempt {attempt}/{retries}), " + f"retrying in {wait:.0f}s\u2026" + ) + time.sleep(wait) + continue + + die(f"{label} failed ({code}): {detail}", code=4) + + die(f"{label}: all {retries} attempts failed", code=4) + + +def graph_list( + resource: str, + filter_expr: str, + token: str, + select: str | None = None, + description: str = "", +) -> list[dict[str, Any]]: + """ + Return all items from a Graph list endpoint matching an OData filter. + + Uses a properly encoded URL to avoid InvalidURL errors on spaces/quotes. + """ + path = _filter_path(resource, filter_expr, select) + resp = graph("GET", path, token, description=description or f"list {resource}") + return resp.get("value", []) if resp else [] # type: ignore[union-attr] + + +# --------------------------------------------------------------------------- +# Idempotency helpers +# --------------------------------------------------------------------------- + + +def _desired_resource_access() -> list[dict[str, Any]]: + """Build the requiredResourceAccess block for the Graph API permissions.""" + return [ + { + "resourceAppId": GRAPH_APP_ID, + "resourceAccess": [ + {"id": scope_id, "type": "Scope"} + for scope_id in DELEGATED_SCOPES.values() + ], + } + ] + + +def _merge_resource_access( + existing: list[dict[str, Any]], + desired: list[dict[str, Any]], +) -> tuple[list[dict[str, Any]], bool]: + """ + Union desired resourceAccess entries into existing ones. + + For the Graph API block specifically, ensures all desired Role/Scope + entries are present while preserving any other blocks untouched. + Returns (merged_list, changed). + """ + # Index existing entries by resourceAppId for easy lookup + by_app: dict[str, dict[str, Any]] = {} + for block in existing: + by_app[block["resourceAppId"]] = { + "resourceAppId": block["resourceAppId"], + "resourceAccess": list(block.get("resourceAccess", [])), + } + + changed = False + for desired_block in desired: + app_id = desired_block["resourceAppId"] + if app_id not in by_app: + by_app[app_id] = { + "resourceAppId": app_id, + "resourceAccess": [], + } + changed = True + existing_ids = {e["id"] for e in by_app[app_id]["resourceAccess"]} + for entry in desired_block.get("resourceAccess", []): + if entry["id"] not in existing_ids: + by_app[app_id]["resourceAccess"].append(entry) + changed = True + + return list(by_app.values()), changed + + +def _graph_block_scope_ids(rra: list[dict[str, Any]]) -> set[str]: + """ + Return the set of resourceAccess entry IDs in the MS Graph block of a + requiredResourceAccess list (only Scope-type entries). + + Used to verify that all desired delegated scopes are present in the + app manifest after a PATCH. + """ + for block in rra: + if block.get("resourceAppId") == GRAPH_APP_ID: + return { + e["id"] + for e in block.get("resourceAccess", []) + if e.get("type") == "Scope" + } + return set() + + +def _merge_redirect_uris( + existing: list[str], desired: list[str] +) -> tuple[list[str], bool]: + """Union desired URIs into existing ones (add only). Returns (merged, changed).""" + existing_set = set(existing) + to_add = [u for u in desired if u not in existing_set] + if not to_add: + return existing, False + return existing + to_add, True + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog=PROG, + description=( + "Create or reconcile an Entra ID OIDC web application and " + "service principal. Idempotent: re-running with the same " + "--name is safe." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + add_help=True, + ) + parser.add_argument( + "-n", + "--name", + required=True, + metavar="NAME", + help="Display name of the app registration", + ) + parser.add_argument( + "-o", + "--owner", + required=True, + metavar="UPN", + help="Owner UPN/email (e.g. user@example.com)", + ) + parser.add_argument( + "-r", + "--redirect-uri", + dest="redirect_uris", + required=True, + action="append", + metavar="URI", + help="Web redirect URI (repeatable; at least one required)", + ) + parser.add_argument( + "-t", + "--tenant", + default=None, + metavar="TENANT", + help="Tenant ID or domain to pass to the az token request", + ) + parser.add_argument( + "--json", + action="store_true", + default=False, + help="Emit a JSON result instead of a human-readable summary", + ) + return parser.parse_args() + + +# --------------------------------------------------------------------------- +# Colour helpers (suppressed when stdout is not a TTY or NO_COLOR is set) +# --------------------------------------------------------------------------- + + +def _setup_colour() -> dict[str, str]: + import os + + if sys.stdout.isatty() and not os.environ.get("NO_COLOR"): + return { + "reset": "\033[0m", + "bold": "\033[1m", + "dim": "\033[2m", + "green": "\033[32m", + "yellow": "\033[33m", + "cyan": "\033[36m", + } + return {k: "" for k in ("reset", "bold", "dim", "green", "yellow", "cyan")} + + +def _status_tag(status: str, c: dict[str, str]) -> str: + """Coloured status tag for human output.""" + if status == "created": + return f"{c['green']}created{c['reset']}" + if status == "updated": + return f"{c['yellow']}updated{c['reset']}" + if status in ("existed", "unchanged"): + return f"{c['dim']}{status}{c['reset']}" + return status + + +# --------------------------------------------------------------------------- +# Main provisioning logic +# --------------------------------------------------------------------------- + + +def main() -> None: # noqa: C901 — long but linear + args = parse_args() + + # -- preflight ---------------------------------------------------------- + check_az_login() + token = get_graph_token(args.tenant) + + # Tracks whether anything changed this run + any_changed = False + statuses: dict[str, str] = {} + + # -- resolve owner UPN -> object ID ------------------------------------ + step(f"Resolving owner '{args.owner}'\u2026") + try: + owner_obj = _graph_request( + "GET", + f"/users/{urllib.parse.quote(args.owner)}", + token, + ) + except urllib.error.HTTPError as exc: + code, detail = _parse_error(exc) + if code == 404: + die(f"Owner '{args.owner}' not found in directory.", code=3) + die(f"Owner lookup failed ({code}): {detail}", code=3) + + owner_id: str = owner_obj["id"] # type: ignore[index] + info(f"owner object ID: {owner_id}") + + # -- step 1: app registration (find-or-create + reconcile) ------------- + step(f"Ensuring app registration '{args.name}'\u2026") + desired_rra = _desired_resource_access() + + apps = graph_list( + "applications", + f"displayName eq '{args.name}'", + token, + select="id,appId,web,requiredResourceAccess,displayName", + description="Look up application by displayName", + ) + + if len(apps) > 1: + ids = ", ".join(a["appId"] for a in apps) + die( + f"Multiple app registrations named '{args.name}' exist ({ids}). " + "Rename or delete duplicates before re-running.", + code=4, + ) + + if not apps: + # Create fresh + app_body = { + "displayName": args.name, + "signInAudience": "AzureADMyOrg", + "web": {"redirectUris": args.redirect_uris}, + "requiredResourceAccess": desired_rra, + } + app = graph( + "POST", + "/applications", + token, + app_body, + description="Create application", + ) + app_id: str = app["appId"] # type: ignore[index] + app_obj_id: str = app["id"] # type: ignore[index] + statuses["app"] = "created" + any_changed = True + info(f"appId: {app_id}") + info(f"object ID: {app_obj_id}") + else: + # Reconcile existing app + existing_app = apps[0] + app_id = existing_app["appId"] + app_obj_id = existing_app["id"] + info(f"found existing app — appId: {app_id}, object ID: {app_obj_id}") + + patch: dict[str, Any] = {} + + # Reconcile redirect URIs (union / add only) + existing_uris: list[str] = (existing_app.get("web") or {}).get( + "redirectUris", [] + ) + merged_uris, uris_changed = _merge_redirect_uris( + existing_uris, args.redirect_uris + ) + if uris_changed: + patch["web"] = {"redirectUris": merged_uris} + + # Reconcile requiredResourceAccess (union) + existing_rra: list[dict[str, Any]] = existing_app.get( + "requiredResourceAccess", [] + ) + merged_rra, rra_changed = _merge_resource_access(existing_rra, desired_rra) + if rra_changed: + patch["requiredResourceAccess"] = merged_rra + + if patch: + graph( + "PATCH", + f"/applications/{app_obj_id}", + token, + patch, + expect_no_content=True, + description="Reconcile application", + ) + statuses["app"] = "updated" + any_changed = True + else: + statuses["app"] = "unchanged" + + # -- read-back verification ----------------------------------------- + # Confirm every desired scope ID is present in requiredResourceAccess. + # This catches drift between independent runs (e.g. grant set in step 5 + # before the manifest was updated) and corrects it with one extra PATCH. + desired_scope_ids = set(DELEGATED_SCOPES.values()) + live_app = graph( + "GET", + f"/applications/{app_obj_id}?$select=requiredResourceAccess", + token, + description="Read back requiredResourceAccess", + ) + live_rra: list[dict[str, Any]] = (live_app or {}).get( + "requiredResourceAccess", [] + ) + live_scope_ids = _graph_block_scope_ids(live_rra) + still_missing = desired_scope_ids - live_scope_ids + + if still_missing: + missing_names = [ + name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing + ] + info( + f"read-back: {len(still_missing)} scope(s) missing from " + f"requiredResourceAccess after PATCH " + f"({', '.join(missing_names)}) — applying corrective PATCH" + ) + corrective_rra, _ = _merge_resource_access(live_rra, desired_rra) + graph( + "PATCH", + f"/applications/{app_obj_id}", + token, + {"requiredResourceAccess": corrective_rra}, + expect_no_content=True, + description="Corrective requiredResourceAccess PATCH", + ) + # Final verification — error loudly if still broken + live_app2 = graph( + "GET", + f"/applications/{app_obj_id}?$select=requiredResourceAccess", + token, + description="Re-read requiredResourceAccess after corrective PATCH", + ) + live_rra2: list[dict[str, Any]] = (live_app2 or {}).get( + "requiredResourceAccess", [] + ) + still_missing2 = desired_scope_ids - _graph_block_scope_ids(live_rra2) + if still_missing2: + missing_names2 = [ + name + for name, sid in DELEGATED_SCOPES.items() + if sid in still_missing2 + ] + die( + "requiredResourceAccess still missing scope(s) after " + f"corrective PATCH: {', '.join(missing_names2)}. " + "Check app manifest in the portal.", + code=4, + ) + statuses["app"] = "updated" + any_changed = True + info("read-back: corrective PATCH confirmed — all scopes now configured") + else: + info("read-back: all desired scopes present in requiredResourceAccess") + + info(f"app: {statuses['app']}") + + # -- step 2: service principal (find-or-create) ------------------------ + step("Ensuring service principal\u2026") + sps = graph_list( + "servicePrincipals", + f"appId eq '{app_id}'", + token, + select="id", + description="Look up service principal by appId", + ) + + if sps: + sp_obj_id: str = sps[0]["id"] + statuses["sp"] = "existed" + info(f"found existing SP — object ID: {sp_obj_id}") + else: + sp = graph( + "POST", + "/servicePrincipals", + token, + {"appId": app_id}, + description="Create service principal", + ) + sp_obj_id = sp["id"] # type: ignore[index] + statuses["sp"] = "created" + any_changed = True + info(f"SP object ID: {sp_obj_id}") + + # -- step 3: owners (app + SP) — swallow "already exists" -------------- + owner_ref = {"@odata.id": f"{GRAPH_BASE}/directoryObjects/{owner_id}"} + + step("Ensuring owner on application\u2026") + _, app_owner_existed = graph_or_exists( + "POST", + f"/applications/{app_obj_id}/owners/$ref", + token, + owner_ref, + expect_no_content=True, + description="Add application owner", + ) + statuses["app_owner"] = "existed" if app_owner_existed else "added" + if not app_owner_existed: + any_changed = True + info(f"app owner: {statuses['app_owner']}") + + step("Ensuring owner on service principal\u2026") + _, sp_owner_existed = graph_or_exists( + "POST", + f"/servicePrincipals/{sp_obj_id}/owners/$ref", + token, + owner_ref, + expect_no_content=True, + description="Add service principal owner", + ) + statuses["sp_owner"] = "existed" if sp_owner_existed else "added" + if not sp_owner_existed: + any_changed = True + info(f"SP owner: {statuses['sp_owner']}") + + # -- step 4: look up the Microsoft Graph SP in tenant ------------------ + step("Looking up Microsoft Graph service principal in tenant\u2026") + graph_sps = graph_list( + "servicePrincipals", + f"appId eq '{GRAPH_APP_ID}'", + token, + select="id", + description="Get Graph service principal", + ) + if not graph_sps: + die( + "Microsoft Graph service principal not found in this tenant.", + code=4, + ) + graph_sp_id: str = graph_sps[0]["id"] + info(f"Graph SP object ID: {graph_sp_id}") + + # -- step 5: delegated permission grant (OIDC scopes + User.Read) ------ + scope_string = " ".join(DELEGATED_SCOPES.keys()) + step(f"Ensuring delegated scope grant ({scope_string})\u2026") + + grants = graph_list( + "oauth2PermissionGrants", + f"clientId eq '{sp_obj_id}' and resourceId eq '{graph_sp_id}'", + token, + select="id,scope", + description="Look up delegated permission grant", + ) + + if not grants: + graph_with_retry( + "POST", + "/oauth2PermissionGrants", + token, + { + "clientId": sp_obj_id, + "consentType": "AllPrincipals", + "resourceId": graph_sp_id, + "scope": scope_string, + }, + description="Grant delegated scopes", + ) + statuses["delegated_grant"] = "created" + any_changed = True + else: + grant = grants[0] + if len(grants) > 1: + warn( + f"{len(grants)} delegated grants found; reconciling the first " + f"({grant['id']}) and ignoring extras." + ) + + # Union: add any missing desired scopes; never remove existing ones. + existing_scopes = set((grant.get("scope") or "").split()) + desired_scopes = set(DELEGATED_SCOPES.keys()) + missing = desired_scopes - existing_scopes + + if not missing: + statuses["delegated_grant"] = "unchanged" + else: + merged_scopes = " ".join(sorted(existing_scopes | desired_scopes)) + graph( + "PATCH", + f"/oauth2PermissionGrants/{grant['id']}", + token, + {"scope": merged_scopes}, + expect_no_content=True, + description="Add missing delegated scopes", + ) + statuses["delegated_grant"] = "updated" + any_changed = True + + info(f"delegated grant: {statuses['delegated_grant']}") + + # -- output ------------------------------------------------------------ + result: dict[str, Any] = { + "appId": app_id, + "appObjectId": app_obj_id, + "spObjectId": sp_obj_id, + "displayName": args.name, + "owner": { + "upn": args.owner, + "objectId": owner_id, + }, + "redirectUris": args.redirect_uris, + "permissions": { + "delegatedScopes": list(DELEGATED_SCOPES.keys()), + }, + "adminConsentGranted": True, + "changed": any_changed, + "statuses": statuses, + } + + if args.json: + print(json.dumps(result, indent=2)) + return + + c = _setup_colour() + print() + heading = ( + "App registration updated" if any_changed else "App registration up to date" + ) + print(f"{c['bold']}{c['cyan']}{heading}{c['reset']}") + print(f" {c['bold']}Display name :{c['reset']} {result['displayName']}") + print(f" {c['bold']}App ID (client){c['reset']} {result['appId']}") + print(f" {c['bold']}App object ID :{c['reset']} {result['appObjectId']}") + print(f" {c['bold']}SP object ID :{c['reset']} {result['spObjectId']}") + print() + print(f"{c['bold']}Owner{c['reset']}") + print(f" UPN: {result['owner']['upn']}") + print(f" Object ID: {result['owner']['objectId']}") + print() + print(f"{c['bold']}Redirect URIs{c['reset']}") + for uri in result["redirectUris"]: + print(f" {uri}") + print() + print(f"{c['bold']}API permissions{c['reset']}") + for scope in result["permissions"]["delegatedScopes"]: + print(f" {c['dim']}[Delegated]{c['reset']} {scope}") + print() + print(f"{c['bold']}Step statuses{c['reset']}") + for key, val in statuses.items(): + print(f" {key:<18} {_status_tag(val, c)}") + print() + print(f"{c['green']}Done.{c['reset']}") + + +if __name__ == "__main__": + main() diff --git a/bin/pemdecompose b/bin/pemdecompose new file mode 100755 index 0000000..099023f --- /dev/null +++ b/bin/pemdecompose @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# pemdecompose — List certificates in a combined PEM file. +# +# Walks a PEM file containing one or more X.509 certificates (with or +# without blank lines or other text between PEM blocks) and prints the +# subject, issuer, and signature algorithm of each. Optionally verifies +# that adjacent certs form a chain in file order. +# +# Private-key blocks in a combined file (e.g. server.combined.pem from +# the acme_certificates Ansible role) are skipped automatically; only +# CERTIFICATE blocks are inspected. +# +# Requires: openssl +# +# Usage: +# pemdecompose [options] FILE [FILE...] +# pemdecompose --help +# +# Options: +# -h, --help Show this help and exit +# --verify Verify each cert is issued by the next cert in the +# file (signature + issuer/subject DN match). +# -q, --quiet Suppress the per-file banner and inter-file blank +# line when multiple files are given. +# --no-color Disable ANSI colour. Colour is also disabled +# automatically when stdout is not a TTY or when the +# NO_COLOR environment variable is set. +# +# Exit codes: +# 0 success +# 1 usage error +# 2 no certificates found in at least one input file +# 3 openssl parse error on at least one input file +# 4 --verify failed for at least one chain link + +set -euo pipefail + +PROG="pemdecompose" + +usage() { + sed -n '2,/^$/p' "$0" | sed -e 's/^# \{0,1\}//' +} + +die() { + printf '%s: %s\n' "$PROG" "$*" >&2 + exit 1 +} + +warn() { + printf '%s: %s\n' "$PROG" "$*" >&2 +} + +# --- arg parsing -------------------------------------------------------- + +VERIFY=0 +QUIET=0 +NO_COLOR_FLAG=0 +FILES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --verify) + VERIFY=1 + shift + ;; + -q|--quiet) + QUIET=1 + shift + ;; + --no-color|--no-colour) + NO_COLOR_FLAG=1 + shift + ;; + --) + shift + while [[ $# -gt 0 ]]; do + FILES+=("$1") + shift + done + ;; + -*) + die "unknown option: $1 (try --help)" + ;; + *) + FILES+=("$1") + shift + ;; + esac +done + +if [[ ${#FILES[@]} -eq 0 ]]; then + die "no input file (try --help)" +fi + +# --- colour setup ------------------------------------------------------- + +if [[ $NO_COLOR_FLAG -eq 0 ]] && [[ -z "${NO_COLOR:-}" ]] && [[ -t 1 ]]; then + C_RESET=$'\e[0m' + C_BOLD=$'\e[1m' + C_DIM=$'\e[2m' + C_CYAN=$'\e[36m' + C_GREEN=$'\e[32m' + C_RED=$'\e[31m' +else + C_RESET="" + C_BOLD="" + C_DIM="" + C_CYAN="" + C_GREEN="" + C_RED="" +fi + +# --- temp dir for --verify ---------------------------------------------- + +TMPDIR_="" +# shellcheck disable=SC2329 # invoked via trap below +cleanup() { + if [[ -n "$TMPDIR_" ]]; then + rm -rf -- "$TMPDIR_" + fi + return 0 +} +trap cleanup EXIT INT TERM HUP + +ensure_tmpdir() { + if [[ -z "$TMPDIR_" ]]; then + TMPDIR_=$(mktemp -d -t pemdecompose.XXXXXX) + fi +} + +# --- helpers ------------------------------------------------------------ + +# Split a PEM file into NUL-separated CERTIFICATE blocks on stdout. +split_certs() { + local file="$1" + awk ' + /-----BEGIN CERTIFICATE-----/ { in_cert = 1; buf = "" } + in_cert { buf = buf $0 ORS } + /-----END CERTIFICATE-----/ { + if (in_cert) { + printf "%s%c", buf, 0 + in_cert = 0 + buf = "" + } + } + ' "$file" +} + +# Print just the subject DN (everything after "subject="). +cert_subject_only() { + openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null \ + | sed -e 's/^subject=//' +} + +# Print just the issuer DN. +cert_issuer_only() { + openssl x509 -noout -issuer -nameopt RFC2253 2>/dev/null \ + | sed -e 's/^issuer=//' +} + +# Print the top-level Signature Algorithm name (e.g. ecdsa-with-SHA384, +# sha256WithRSAEncryption, ED25519). Returns nonzero if openssl can't +# parse the block. +cert_sigalg() { + openssl x509 -noout -text 2>/dev/null \ + | awk '/Signature Algorithm:/ { + sub(/^.*Signature Algorithm:[[:space:]]*/, "") + print; exit + }' +} + +# --- per-file processing ------------------------------------------------ + +process_file() { + local file="$1" + local show_banner="$2" + + if [[ ! -r "$file" ]]; then + warn "cannot read: $file" + return 3 + fi + + if [[ "$show_banner" -eq 1 ]]; then + printf '%s== %s ==%s\n' "$C_BOLD$C_CYAN" "$file" "$C_RESET" + fi + + # Read all cert blocks into an array. + local -a blocks=() + local block + while IFS= read -r -d '' block; do + blocks+=("$block") + done < <(split_certs "$file") + + local count=${#blocks[@]} + + if [[ $count -eq 0 ]]; then + warn "no certificates found in $file" + return 2 + fi + + # Emit subject + issuer + sigalg per cert. + local i + for ((i = 0; i < count; i++)); do + local idx=$((i + 1)) + local sub iss sigalg + if ! sub=$(printf '%s' "${blocks[$i]}" | cert_subject_only); then + warn "openssl failed to parse certificate #$idx in $file" + return 3 + fi + if ! iss=$(printf '%s' "${blocks[$i]}" | cert_issuer_only); then + warn "openssl failed to parse certificate #$idx in $file" + return 3 + fi + if ! sigalg=$(printf '%s' "${blocks[$i]}" | cert_sigalg); then + warn "openssl failed to parse certificate #$idx in $file" + return 3 + fi + [[ -z "$sigalg" ]] && sigalg="" + printf '%s%2d%s %ssubject:%s %s\n' \ + "$C_BOLD" "$idx" "$C_RESET" "$C_DIM" "$C_RESET" "$sub" + printf ' %sissuer :%s %s\n' \ + "$C_DIM" "$C_RESET" "$iss" + printf ' %ssigalg :%s %s\n' \ + "$C_DIM" "$C_RESET" "$sigalg" + done + + printf '%s%d certificate(s)%s\n' "$C_DIM" "$count" "$C_RESET" + + if [[ $VERIFY -eq 1 ]]; then + printf '\n%sverify:%s\n' "$C_BOLD" "$C_RESET" + if [[ $count -eq 1 ]]; then + printf ' chain: %sN/A (single certificate)%s\n' \ + "$C_DIM" "$C_RESET" + return 0 + fi + + ensure_tmpdir + local link_ok=1 + + # Write each block to a temp file once. + local -a cert_files=() + local j + for ((j = 0; j < count; j++)); do + local f + f="$TMPDIR_/$(basename -- "$file").cert.$((j + 1)).pem" + printf '%s' "${blocks[$j]}" > "$f" + cert_files+=("$f") + done + + for ((j = 0; j < count - 1; j++)); do + local cur_idx=$((j + 1)) + local nxt_idx=$((j + 2)) + local cur="${cert_files[$j]}" + local nxt="${cert_files[$((j + 1))]}" + + local cur_issuer nxt_subject + cur_issuer=$(printf '%s' "${blocks[$j]}" | cert_issuer_only) || true + nxt_subject=$(printf '%s' "${blocks[$((j + 1))]}" \ + | cert_subject_only) || true + + local dn_ok=0 + if [[ -n "$cur_issuer" && "$cur_issuer" == "$nxt_subject" ]]; then + dn_ok=1 + fi + + local sig_ok=0 + if openssl verify -no_check_time -partial_chain \ + -CAfile "$nxt" "$cur" >/dev/null 2>&1; then + sig_ok=1 + fi + + if [[ $dn_ok -eq 1 && $sig_ok -eq 1 ]]; then + printf ' %d -> %d %sOK%s (signature + issuer DN match)\n' \ + "$cur_idx" "$nxt_idx" "$C_GREEN" "$C_RESET" + else + link_ok=0 + local reason="" + if [[ $dn_ok -eq 0 && $sig_ok -eq 0 ]]; then + reason="DN mismatch and signature mismatch" + elif [[ $dn_ok -eq 0 ]]; then + reason="issuer DN mismatch" + else + reason="signature mismatch" + fi + printf ' %d -> %d %sFAIL%s (%s)\n' \ + "$cur_idx" "$nxt_idx" "$C_RED" "$C_RESET" "$reason" + fi + done + + # Last cert: informational only. + printf ' %d %stail (no parent in file)%s\n' \ + "$count" "$C_DIM" "$C_RESET" + + if [[ $link_ok -eq 1 ]]; then + printf 'chain: %sPASS%s\n' "$C_GREEN" "$C_RESET" + return 0 + else + printf 'chain: %sFAIL%s\n' "$C_RED" "$C_RESET" + return 4 + fi + fi + + return 0 +} + +# --- main loop ---------------------------------------------------------- + +show_banner=0 +if [[ ${#FILES[@]} -gt 1 && $QUIET -eq 0 ]]; then + show_banner=1 +fi + +worst_rc=0 +first=1 +for file in "${FILES[@]}"; do + if [[ $first -eq 0 && $QUIET -eq 0 && $show_banner -eq 1 ]]; then + printf '\n' + fi + first=0 + + rc=0 + process_file "$file" "$show_banner" || rc=$? + + # Propagate the worst exit code seen. + if [[ $rc -gt $worst_rc ]]; then + worst_rc=$rc + fi +done + +exit "$worst_rc" From 59611e749980e8f8eaab1bc3e22271ef0e5059e1 Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Fri, 26 Jun 2026 13:18:12 -0400 Subject: [PATCH 2/5] Add python ignores --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7aa4781 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# Python bytecode / caches +__pycache__/ +*.py[cod] +*$py.class From 744c85d99f0866b12e1229f6625b7e5af90fdbae Mon Sep 17 00:00:00 2001 From: "Stuart B. Wilkins" Date: Fri, 26 Jun 2026 14:08:22 -0400 Subject: [PATCH 3/5] Move linters to using precommit --- .github/linters/.codespellrc => .codespellrc | 0 .github/linters/.editorconfig-checker.json | 6 - .github/pull_request_template.md | 3 +- .github/workflows/lint.yml | 133 ++ .github/workflows/super-linter.yml | 52 - .gitignore | 7 + .gitleaks.toml | 12 + .markdownlint.yaml | 4 + .pre-commit-config.yaml | 117 ++ .ruff.toml | 6 + .../linters/.shellcheckrc => .shellcheckrc | 0 .../linters/.yaml-lint.yml => .yamllint.yaml | 0 bin/azoidcapp | 78 +- bin/pemdecompose | 24 +- pixi.lock | 1735 +++++++++++++++++ pixi.toml | 36 + .github/linters/zizmor.yaml => zizmor.yaml | 0 17 files changed, 2083 insertions(+), 130 deletions(-) rename .github/linters/.codespellrc => .codespellrc (100%) delete mode 100644 .github/linters/.editorconfig-checker.json create mode 100644 .github/workflows/lint.yml delete mode 100644 .github/workflows/super-linter.yml create mode 100644 .gitleaks.toml create mode 100644 .markdownlint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .ruff.toml rename .github/linters/.shellcheckrc => .shellcheckrc (100%) rename .github/linters/.yaml-lint.yml => .yamllint.yaml (100%) create mode 100644 pixi.lock create mode 100644 pixi.toml rename .github/linters/zizmor.yaml => zizmor.yaml (100%) diff --git a/.github/linters/.codespellrc b/.codespellrc similarity index 100% rename from .github/linters/.codespellrc rename to .codespellrc diff --git a/.github/linters/.editorconfig-checker.json b/.github/linters/.editorconfig-checker.json deleted file mode 100644 index 8209514..0000000 --- a/.github/linters/.editorconfig-checker.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "Exclude": [ - "^bin/", - "\\.sh$" - ] -} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 481f2cb..b8a335c 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -15,7 +15,8 @@ _Include or link to test output here._ _DO NOT POST SENSITIVE INFORMATION HERE._ ## Checklist + - [ ] Edit the sections above with relevant information - [ ] Update in-repo documentation, if applicable -- [ ] Run super-linter locally (or wait for CI) — see `.github/workflows/super-linter.yml` +- [ ] Run `pixi run -e dev lint` locally (or wait for CI) — see `.github/workflows/lint.yml` - [ ] Test the changes on a representative managed host (or `runansible` deployment) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..68f3be7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,133 @@ +--- +# Canonical CI lint gate. Runs the committed .pre-commit-config.yaml +# (the same hook set developers run locally) against changed files only. +# +# Changed-file scope: +# pull_request -> files changed vs. the PR base (origin/main). +# push to main -> files changed in the push (github.event.before..HEAD). +# workflow_dispatch -> all files (no push range available; safe fallback). + +name: Lint + +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + lint: + name: Lint (changed files) + runs-on: ubuntu-24.04 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Pixi + uses: prefix-dev/setup-pixi@a0af7a228712d6121d37aba47adf55c1332c9c2e # v0.9.4 + with: + pixi-version: v0.67.2 + cache: false + frozen: true + + - name: Install dev environment + run: pixi install -e dev + + - name: Determine changed-file ref range + id: refs + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + SHA: ${{ github.sha }} + BEFORE: ${{ github.event.before }} + run: | + if [[ "$EVENT_NAME" == "pull_request" ]]; then + echo "from_ref=origin/${BASE_REF}" >> "$GITHUB_OUTPUT" + echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" + elif [[ "$EVENT_NAME" == "push" ]]; then + echo "from_ref=${BEFORE}" >> "$GITHUB_OUTPUT" + echo "to_ref=${SHA}" >> "$GITHUB_OUTPUT" + else + # workflow_dispatch: lint everything + echo "from_ref=" >> "$GITHUB_OUTPUT" + echo "to_ref=" >> "$GITHUB_OUTPUT" + fi + + - name: Run pre-commit (changed files) + id: pre_commit + env: + FROM_REF: ${{ steps.refs.outputs.from_ref }} + TO_REF: ${{ steps.refs.outputs.to_ref }} + run: | + if [[ -n "$FROM_REF" ]]; then + pixi run -e dev pre-commit run \ + --from-ref "$FROM_REF" \ + --to-ref "$TO_REF" \ + --show-diff-on-failure \ + --color always \ + 2>&1 | tee /tmp/pre-commit-output.txt + else + pixi run -e dev pre-commit run \ + --all-files \ + --show-diff-on-failure \ + --color always \ + 2>&1 | tee /tmp/pre-commit-output.txt + fi + + - name: Write job summary + if: always() + env: + PRE_COMMIT_OUTCOME: ${{ steps.pre_commit.outcome }} + FROM_REF: ${{ steps.refs.outputs.from_ref }} + TO_REF: ${{ steps.refs.outputs.to_ref }} + run: | + if [[ "$PRE_COMMIT_OUTCOME" == "success" ]]; then + BADGE="All linting hooks passed" + else + BADGE="One or more linting hooks failed" + fi + + if [[ -n "$FROM_REF" ]]; then + SCOPE="\`${FROM_REF}\`..\`${TO_REF}\`" + else + SCOPE="all files (workflow_dispatch)" + fi + + { + echo "## Lint results" + echo "" + echo "$BADGE" + echo "" + echo "**Scope:** changed files — ${SCOPE}" + echo "" + echo "### Hooks run" + echo "" + echo "| Hook | Tool | Version |" + echo "| ---- | ---- | ------- |" + echo "| trailing-whitespace / end-of-file-fixer / check-* | pre-commit-hooks | v5.0.0 |" + echo "| ruff check + ruff format | ruff | 0.15.8 |" + echo "| mypy | mypy | 2.1.0 |" + echo "| codespell | codespell | 2.4.2 |" + echo "| markdownlint | markdownlint-cli | v0.48.0 |" + echo "| yamllint | yamllint | 1.38.0 |" + echo "| actionlint | actionlint | v1.7.12 |" + echo "| zizmor | zizmor | 1.23.1 |" + echo "| shfmt | shfmt | v3.12.0 |" + echo "| shellcheck | shellcheck | v0.11.0 |" + echo "| gitleaks | gitleaks | v8.30.1 |" + echo "" + if [[ -s /tmp/pre-commit-output.txt ]]; then + echo "### Output" + echo "" + echo '```' + sed 's/\x1b\[[0-9;]*m//g' /tmp/pre-commit-output.txt + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml deleted file mode 100644 index 0694299..0000000 --- a/.github/workflows/super-linter.yml +++ /dev/null @@ -1,52 +0,0 @@ ---- - -# You can adjust the behavior by modifying this file. -# For more information, see: -# https://github.com/github/super-linter - -name: Lint Code Base -permissions: - contents: read - -on: - push: - branches: [main] - pull_request: - branches: [main] -jobs: - run_superlinter: - name: Run super-linter - runs-on: ubuntu-24.04 - steps: - - name: Checkout code - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Run super linter - # Pin to commit; not an official github action - uses: super-linter/super-linter@61abc07d755095a68f4987d1c2c3d1d64408f1f9 # v8.5.0 - env: - DEFAULT_BRANCH: origin/main - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VALIDATE_BASH_EXEC: false # lib/*.sh are sourced libraries, not executable scripts - VALIDATE_BIOME_FORMAT: false # No biome.json config; needs discussion on whether to add or keep disabled - VALIDATE_CHECKOV: false # Too many failures; enable this in a later PR - VALIDATE_CSS_PRETTIER: false # CI messages have no info - VALIDATE_GRAPHQL_PRETTIER: false # CI messages have no info - VALIDATE_HTML_PRETTIER: false # CI messages have no info - VALIDATE_JAVASCRIPT_PRETTIER: false # CI messages have no info - VALIDATE_JSCPD: false - VALIDATE_JSON_PRETTIER: false # CI messages have no info - VALIDATE_JSONC_PRETTIER: false # CI messages have no info - VALIDATE_JSX_PRETTIER: false # CI messages have no info - VALIDATE_MARKDOWN_PRETTIER: false # CI messages have no info - VALIDATE_NATURAL_LANGUAGE: false - VALIDATE_PYTHON_BLACK: false - VALIDATE_PYTHON_FLAKE8: false - VALIDATE_PYTHON_ISORT: false - VALIDATE_PYTHON_PYLINT: false - VALIDATE_TYPESCRIPT_PRETTIER: false # CI messages have no info - VALIDATE_VUE_PRETTIER: false # CI messages have no info - VALIDATE_YAML_PRETTIER: false # CI messages have no info diff --git a/.gitignore b/.gitignore index 7aa4781..2755923 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ __pycache__/ *.py[cod] *$py.class + +# pixi environments and caches +.pixi/ + +# Tool caches +.ruff_cache/ +.mypy_cache/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..8d0aa3a --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,12 @@ +[extend] +useDefault = true + +[[allowlists]] +description = "Public MS Graph delegated-scope GUIDs in azoidcapp (not secrets)" +paths = ['''bin/azoidcapp'''] +regexes = ['''[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'''] + +[[allowlists]] +description = "Example AIFAPIM_HOST value in bwclaude help text (not a secret)" +paths = ['''bin/bwclaude'''] +regexes = ['''AIFAPIM_HOST=hermes\.nsls2\.bnl\.gov'''] diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..71a6b56 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,4 @@ +--- +# MD013 (line-length) is disabled: table rows and long URLs in docs/ +# legitimately exceed 80 chars and reflowing them adds no value. +MD013: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b8f0c24 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,117 @@ +--- +# Canonical lint configuration. The same hook set runs locally on +# commit and in CI via the Lint workflow +# (.github/workflows/lint.yml). Tool versions are pinned in +# pixi.toml [feature.dev.dependencies] for the pixi-dispatched hooks +# and via `rev:` for the upstream pre-commit-managed hooks. +# +# Linter configs live at the repository root and are auto-discovered +# by each tool by its conventional dotfile name. The only exception is +# zizmor, which has no standard auto-discovery and takes an explicit +# --config argument. + +repos: + # ──────────────────────────────────────────────────────────── + # Generic hygiene + syntax checks + # ──────────────────────────────────────────────────────────── + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-added-large-files + + # ──────────────────────────────────────────────────────────── + # Python + # ──────────────────────────────────────────────────────────── + # Python-based hooks dispatch through pixi to avoid pip wheel-build + # races inside pre-commit's own env machinery and to keep tool + # versions deterministic via pins in pixi.toml + # [feature.dev.dependencies]. + - repo: local + hooks: + - id: ruff-check + name: ruff check (pixi dev) + language: system + entry: pixi run --frozen -e dev ruff check --force-exclude + types_or: [python, pyi] + - id: ruff-format + name: ruff format (pixi dev) + language: system + entry: pixi run --frozen -e dev ruff format --force-exclude + types_or: [python, pyi] + - id: mypy + name: mypy (pixi dev) + language: system + entry: pixi run --frozen -e dev mypy --python-version 3.10 bin/azoidcapp + pass_filenames: false + always_run: true + + # ──────────────────────────────────────────────────────────── + # Spelling + # ──────────────────────────────────────────────────────────── + - repo: local + hooks: + - id: codespell + name: codespell (pixi dev) + language: system + entry: pixi run --frozen -e dev codespell + types: [text] + + # ──────────────────────────────────────────────────────────── + # Markdown + # ──────────────────────────────────────────────────────────── + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.48.0 + hooks: + - id: markdownlint + name: Markdownlint + entry: markdownlint --fix + + # ──────────────────────────────────────────────────────────── + # YAML / GitHub Actions + # ──────────────────────────────────────────────────────────── + - repo: local + hooks: + - id: yamllint + name: yamllint (pixi dev) + language: system + entry: pixi run --frozen -e dev yamllint + types: [yaml] + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint + + - repo: local + hooks: + - id: zizmor + name: zizmor (pixi dev) + language: system + entry: pixi run --frozen -e dev zizmor --config zizmor.yaml + files: '^\.github/workflows/.*\.ya?ml$' + + # ──────────────────────────────────────────────────────────── + # Shell + # ──────────────────────────────────────────────────────────── + - repo: https://github.com/scop/pre-commit-shfmt + rev: v3.12.0-2 + hooks: + - id: shfmt + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + + # ──────────────────────────────────────────────────────────── + # Secrets + # ──────────────────────────────────────────────────────────── + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..7d3e231 --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,6 @@ +# Project Python style. 120-col line length kept from the prior super-linter +# baseline; matched by ruff format and ruff check. +line-length = 120 + +[lint] +ignore = ["E203"] diff --git a/.github/linters/.shellcheckrc b/.shellcheckrc similarity index 100% rename from .github/linters/.shellcheckrc rename to .shellcheckrc diff --git a/.github/linters/.yaml-lint.yml b/.yamllint.yaml similarity index 100% rename from .github/linters/.yaml-lint.yml rename to .yamllint.yaml diff --git a/bin/azoidcapp b/bin/azoidcapp index 4934c21..3d18332 100755 --- a/bin/azoidcapp +++ b/bin/azoidcapp @@ -48,7 +48,7 @@ import time import urllib.error import urllib.parse import urllib.request -from typing import Any +from typing import Any, NoReturn # --------------------------------------------------------------------------- # Python version guard @@ -83,7 +83,7 @@ DELEGATED_SCOPES: dict[str, str] = { PROG = "azoidcapp" -def die(msg: str, code: int = 1) -> None: +def die(msg: str, code: int = 1) -> NoReturn: print(f"{PROG}: {msg}", file=sys.stderr) sys.exit(code) @@ -134,8 +134,7 @@ def check_az_login() -> None: ) except FileNotFoundError: die( - "'az' CLI not found on PATH. " - "Install the Azure CLI: https://aka.ms/azure-cli", + "'az' CLI not found on PATH. Install the Azure CLI: https://aka.ms/azure-cli", code=2, ) except subprocess.CalledProcessError: @@ -239,9 +238,7 @@ def graph( """Graph call; dies with code 4 on HTTP failure.""" label = description or f"{method} {path}" try: - return _graph_request( - method, path, token, body, expect_no_content=expect_no_content - ) + return _graph_request(method, path, token, body, expect_no_content=expect_no_content) except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) die(f"{label} failed ({code}): {detail}", code=4) @@ -290,9 +287,7 @@ def graph_or_exists( """ label = description or f"{method} {path}" try: - resp = _graph_request( - method, path, token, body, expect_no_content=expect_no_content - ) + resp = _graph_request(method, path, token, body, expect_no_content=expect_no_content) return resp, False except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) @@ -326,9 +321,7 @@ def graph_with_retry( label = description or f"{method} {path}" for attempt in range(1, retries + 1): try: - resp = _graph_request( - method, path, token, body, expect_no_content=expect_no_content - ) + resp = _graph_request(method, path, token, body, expect_no_content=expect_no_content) return resp, False except urllib.error.HTTPError as exc: code, detail = _parse_error(exc) @@ -338,10 +331,7 @@ def graph_with_retry( if code in (400, 404, 503) and attempt < retries: wait = backoff * attempt - warn( - f"{label}: HTTP {code} (attempt {attempt}/{retries}), " - f"retrying in {wait:.0f}s\u2026" - ) + warn(f"{label}: HTTP {code} (attempt {attempt}/{retries}), retrying in {wait:.0f}s\u2026") time.sleep(wait) continue @@ -377,10 +367,7 @@ def _desired_resource_access() -> list[dict[str, Any]]: return [ { "resourceAppId": GRAPH_APP_ID, - "resourceAccess": [ - {"id": scope_id, "type": "Scope"} - for scope_id in DELEGATED_SCOPES.values() - ], + "resourceAccess": [{"id": scope_id, "type": "Scope"} for scope_id in DELEGATED_SCOPES.values()], } ] @@ -432,17 +419,11 @@ def _graph_block_scope_ids(rra: list[dict[str, Any]]) -> set[str]: """ for block in rra: if block.get("resourceAppId") == GRAPH_APP_ID: - return { - e["id"] - for e in block.get("resourceAccess", []) - if e.get("type") == "Scope" - } + return {e["id"] for e in block.get("resourceAccess", []) if e.get("type") == "Scope"} return set() -def _merge_redirect_uris( - existing: list[str], desired: list[str] -) -> tuple[list[str], bool]: +def _merge_redirect_uris(existing: list[str], desired: list[str]) -> tuple[list[str], bool]: """Union desired URIs into existing ones (add only). Returns (merged, changed).""" existing_set = set(existing) to_add = [u for u in desired if u not in existing_set] @@ -621,19 +602,13 @@ def main() -> None: # noqa: C901 — long but linear patch: dict[str, Any] = {} # Reconcile redirect URIs (union / add only) - existing_uris: list[str] = (existing_app.get("web") or {}).get( - "redirectUris", [] - ) - merged_uris, uris_changed = _merge_redirect_uris( - existing_uris, args.redirect_uris - ) + existing_uris: list[str] = (existing_app.get("web") or {}).get("redirectUris", []) + merged_uris, uris_changed = _merge_redirect_uris(existing_uris, args.redirect_uris) if uris_changed: patch["web"] = {"redirectUris": merged_uris} # Reconcile requiredResourceAccess (union) - existing_rra: list[dict[str, Any]] = existing_app.get( - "requiredResourceAccess", [] - ) + existing_rra: list[dict[str, Any]] = existing_app.get("requiredResourceAccess", []) merged_rra, rra_changed = _merge_resource_access(existing_rra, desired_rra) if rra_changed: patch["requiredResourceAccess"] = merged_rra @@ -663,16 +638,12 @@ def main() -> None: # noqa: C901 — long but linear token, description="Read back requiredResourceAccess", ) - live_rra: list[dict[str, Any]] = (live_app or {}).get( - "requiredResourceAccess", [] - ) + live_rra: list[dict[str, Any]] = (live_app or {}).get("requiredResourceAccess", []) live_scope_ids = _graph_block_scope_ids(live_rra) still_missing = desired_scope_ids - live_scope_ids if still_missing: - missing_names = [ - name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing - ] + missing_names = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing] info( f"read-back: {len(still_missing)} scope(s) missing from " f"requiredResourceAccess after PATCH " @@ -694,16 +665,10 @@ def main() -> None: # noqa: C901 — long but linear token, description="Re-read requiredResourceAccess after corrective PATCH", ) - live_rra2: list[dict[str, Any]] = (live_app2 or {}).get( - "requiredResourceAccess", [] - ) + live_rra2: list[dict[str, Any]] = (live_app2 or {}).get("requiredResourceAccess", []) still_missing2 = desired_scope_ids - _graph_block_scope_ids(live_rra2) if still_missing2: - missing_names2 = [ - name - for name, sid in DELEGATED_SCOPES.items() - if sid in still_missing2 - ] + missing_names2 = [name for name, sid in DELEGATED_SCOPES.items() if sid in still_missing2] die( "requiredResourceAccess still missing scope(s) after " f"corrective PATCH: {', '.join(missing_names2)}. " @@ -823,10 +788,7 @@ def main() -> None: # noqa: C901 — long but linear else: grant = grants[0] if len(grants) > 1: - warn( - f"{len(grants)} delegated grants found; reconciling the first " - f"({grant['id']}) and ignoring extras." - ) + warn(f"{len(grants)} delegated grants found; reconciling the first ({grant['id']}) and ignoring extras.") # Union: add any missing desired scopes; never remove existing ones. existing_scopes = set((grant.get("scope") or "").split()) @@ -875,9 +837,7 @@ def main() -> None: # noqa: C901 — long but linear c = _setup_colour() print() - heading = ( - "App registration updated" if any_changed else "App registration up to date" - ) + heading = "App registration updated" if any_changed else "App registration up to date" print(f"{c['bold']}{c['cyan']}{heading}{c['reset']}") print(f" {c['bold']}Display name :{c['reset']} {result['displayName']}") print(f" {c['bold']}App ID (client){c['reset']} {result['appId']}") diff --git a/bin/pemdecompose b/bin/pemdecompose index 099023f..c21ffcd 100755 --- a/bin/pemdecompose +++ b/bin/pemdecompose @@ -59,7 +59,7 @@ FILES=() while [[ $# -gt 0 ]]; do case "$1" in - -h|--help) + -h | --help) usage exit 0 ;; @@ -67,11 +67,11 @@ while [[ $# -gt 0 ]]; do VERIFY=1 shift ;; - -q|--quiet) + -q | --quiet) QUIET=1 shift ;; - --no-color|--no-colour) + --no-color | --no-colour) NO_COLOR_FLAG=1 shift ;; @@ -152,22 +152,22 @@ split_certs() { # Print just the subject DN (everything after "subject="). cert_subject_only() { - openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null \ - | sed -e 's/^subject=//' + openssl x509 -noout -subject -nameopt RFC2253 2> /dev/null | + sed -e 's/^subject=//' } # Print just the issuer DN. cert_issuer_only() { - openssl x509 -noout -issuer -nameopt RFC2253 2>/dev/null \ - | sed -e 's/^issuer=//' + openssl x509 -noout -issuer -nameopt RFC2253 2> /dev/null | + sed -e 's/^issuer=//' } # Print the top-level Signature Algorithm name (e.g. ecdsa-with-SHA384, # sha256WithRSAEncryption, ED25519). Returns nonzero if openssl can't # parse the block. cert_sigalg() { - openssl x509 -noout -text 2>/dev/null \ - | awk '/Signature Algorithm:/ { + openssl x509 -noout -text 2> /dev/null | + awk '/Signature Algorithm:/ { sub(/^.*Signature Algorithm:[[:space:]]*/, "") print; exit }' @@ -259,8 +259,8 @@ process_file() { local cur_issuer nxt_subject cur_issuer=$(printf '%s' "${blocks[$j]}" | cert_issuer_only) || true - nxt_subject=$(printf '%s' "${blocks[$((j + 1))]}" \ - | cert_subject_only) || true + nxt_subject=$(printf '%s' "${blocks[$((j + 1))]}" | + cert_subject_only) || true local dn_ok=0 if [[ -n "$cur_issuer" && "$cur_issuer" == "$nxt_subject" ]]; then @@ -269,7 +269,7 @@ process_file() { local sig_ok=0 if openssl verify -no_check_time -partial_chain \ - -CAfile "$nxt" "$cur" >/dev/null 2>&1; then + -CAfile "$nxt" "$cur" > /dev/null 2>&1; then sig_ok=1 fi diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 0000000..a55ee5f --- /dev/null +++ b/pixi.lock @@ -0,0 +1,1735 @@ +version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-hf4e2dac_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.2-h77d7759_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.14-h3d5d122_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.2-h1ae2325_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.14-h448ec07_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + win-64: + - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.2-hf5d6505_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.14-h09917c8_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + dev: + channels: + - url: https://conda.anaconda.org/conda-forge/ + options: + pypi-prerelease-mode: if-necessary-or-explicit + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ast-serialize-0.5.0-py310hd8a072f_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-hf4e2dac_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-2.1.0-py313hc25a8b5_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313h54dd161_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.11.0-py313h54dd161_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.8-h7805a7d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zizmor-1.23.1-hb17b654_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + osx-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ast-serialize-0.5.0-py310hb9b2626_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py313hf57695f_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.2-h77d7759_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/mypy-2.1.0-py313h0b0ee5e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py313h16366db_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.14-h3d5d122_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/python-librt-0.11.0-py313h22ab4a2_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.15.8-h16586dd_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py313h252b9d7_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-64/zizmor-1.23.1-h19f9e61_0.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ast-serialize-0.5.0-py310h3b8a9b8_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h224173a_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.2-h1ae2325_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/mypy-2.1.0-py313h3e91af1_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py313h6688731_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.14-h448ec07_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-librt-0.11.0-py313h6688731_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.15.8-hc5c3a1d_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py313h5c29297_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zizmor-1.23.1-h6fdd925_0.conda + win-64: + - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ast-serialize-0.5.0-py310ha413424_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.2-hf5d6505_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/mypy-2.1.0-py313h4ac8b90_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py313h5fd188c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.14-h09917c8_100_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/python-librt-0.11.0-py313h5fd188c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py313hd650c13_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ruff-0.15.8-h02f8532_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py313hf069bd2_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/win-64/zizmor-1.23.1-h18a1a76_0.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda + build_number: 20 + sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9 + md5: a9f577daf3de00bca7c3c76c0ecbd1de + depends: + - __glibc >=2.17,<3.0.a0 + - libgomp >=7.5.0 + constrains: + - openmp_impl <0.0a0 + license: BSD-3-Clause + license_family: BSD + size: 28948 + timestamp: 1770939786096 +- conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda + sha256: a3967b937b9abf0f2a99f3173fa4630293979bd1644709d89580e7c62a544661 + md5: aaa2a381ccc56eac91d63b6c1240312f + depends: + - cpython + - python-gil + license: MIT + license_family: MIT + size: 8191 + timestamp: 1744137672556 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ast-serialize-0.5.0-py310hd8a072f_1.conda + noarch: python + sha256: cf1cf3d0fa59fe0ab6bc3af722d820c1a85a9233c786f614f377c651fec6a7f9 + md5: 6adea4814147f458d6278d053850b0ac + depends: + - python >=3.10 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - _python_abi3_support 1.* + - cpython >=3.10 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 1125371 + timestamp: 1780396651124 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ast-serialize-0.5.0-py310hb9b2626_1.conda + noarch: python + sha256: 9d38da0aa9f1034eecf97151a26686f8a6261cbfe27eb486e80e0d86e0169d33 + md5: 839c5895cae30ab26a5d561955b9db25 + depends: + - python >=3.10 + - __osx >=11.0 + - _python_abi3_support 1.* + - cpython >=3.10 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 1122556 + timestamp: 1780396847113 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ast-serialize-0.5.0-py310h3b8a9b8_1.conda + noarch: python + sha256: 6c6e47ef2a85e7ade5c94d2388f43bd8197129e6b6305f9e8a49380b7dfbc427 + md5: 480f5277fd3ed13ea6ea8b5d74563815 + depends: + - python >=3.10 + - __osx >=11.0 + - _python_abi3_support 1.* + - cpython >=3.10 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 1086020 + timestamp: 1780396657560 +- conda: https://conda.anaconda.org/conda-forge/win-64/ast-serialize-0.5.0-py310ha413424_1.conda + noarch: python + sha256: 6045fef43d0e5c9ce898dc89da2d4d5f81f5da5c10ca25fcc3592f663dbbec5d + md5: 3a08e9b5d3bbdb909799bb061cab5e67 + depends: + - python >=3.10 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - _python_abi3_support 1.* + - cpython >=3.10 + license: MIT + license_family: MIT + size: 1080282 + timestamp: 1780396676915 +- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda + sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6 + md5: d2ffd7602c02f2b316fd921d39876885 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: bzip2-1.0.6 + license_family: BSD + size: 260182 + timestamp: 1771350215188 +- conda: https://conda.anaconda.org/conda-forge/osx-64/bzip2-1.0.8-h500dc9f_9.conda + sha256: 9f242f13537ef1ce195f93f0cc162965d6cc79da578568d6d8e50f70dd025c42 + md5: 4173ac3b19ec0a4f400b4f782910368b + depends: + - __osx >=10.13 + license: bzip2-1.0.6 + license_family: BSD + size: 133427 + timestamp: 1771350680709 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-hd037594_9.conda + sha256: 540fe54be35fac0c17feefbdc3e29725cce05d7367ffedfaaa1bdda234b019df + md5: 620b85a3f45526a8bc4d23fd78fc22f0 + depends: + - __osx >=11.0 + license: bzip2-1.0.6 + license_family: BSD + size: 124834 + timestamp: 1771350416561 +- conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda + sha256: 76dfb71df5e8d1c4eded2dbb5ba15bb8fb2e2b0fe42d94145d5eed4c75c35902 + md5: 4cb8e6b48f67de0b018719cdf1136306 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: bzip2-1.0.6 + license_family: BSD + size: 56115 + timestamp: 1771350256444 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-h4c7d964_0.conda + sha256: 7f458e4a82514d7bebbfef23d92817794a16aaf1c748a15f04870d4fb49aeab2 + md5: b9696b2cf00dfeec138c70cee38ed192 + depends: + - __win + license: ISC + size: 129352 + timestamp: 1781709016515 +- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.6.17-hbd8a1cb_0.conda + sha256: f8e3c730fa14ee3f170493779f06522c4acf89169f43db4f039727709b6419cf + md5: a9965dd99f683c5f444428f896635716 + depends: + - __unix + license: ISC + size: 128866 + timestamp: 1781708962055 +- conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda + sha256: 2162a91819945c826c6ef5efe379e88b1df0fe9a387eeba23ddcf7ebeacd5bd6 + md5: d0616e7935acab407d1543b28c446f6f + depends: + - __glibc >=2.17,<3.0.a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - pycparser + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 298357 + timestamp: 1761202966461 +- conda: https://conda.anaconda.org/conda-forge/osx-64/cffi-2.0.0-py313hf57695f_1.conda + sha256: 16c8c80bebe1c3d671382a64beaa16996e632f5b75963379e2b084eb6bc02053 + md5: b10f64f2e725afc9bf2d9b30eff6d0ea + depends: + - __osx >=10.13 + - libffi >=3.5.2,<3.6.0a0 + - pycparser + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 290946 + timestamp: 1761203173891 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-2.0.0-py313h224173a_1.conda + sha256: 1fa69651f5e81c25d48ac42064db825ed1a3e53039629db69f86b952f5ce603c + md5: 050374657d1c7a4f2ea443c0d0cbd9a0 + depends: + - __osx >=11.0 + - libffi >=3.5.2,<3.6.0a0 + - pycparser + - python >=3.13,<3.14.0a0 + - python >=3.13,<3.14.0a0 *_cp313 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 291376 + timestamp: 1761203583358 +- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.0.0-py313h5ea7bf4_1.conda + sha256: f867a11f42bb64a09b232e3decf10f8a8fe5194d7e3a216c6bac9f40483bd1c6 + md5: 55b44664f66a2caf584d72196aa98af9 + depends: + - pycparser + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 292681 + timestamp: 1761203203673 +- conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda + sha256: aa589352e61bb221351a79e5946d56916e3c595783994884accdb3b97fe9d449 + md5: 381bd45fb7aa032691f3063aff47e3a1 + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 13589 + timestamp: 1763607964133 +- conda: https://conda.anaconda.org/conda-forge/noarch/codespell-2.4.2-pyhd8ed1ab_0.conda + sha256: d09a16087e14faaa92af87a8dcb30e7f85eed2ad1e1f994bb228a482d104d94c + md5: dc82cad3ba612ecccbabeb988ca49eae + depends: + - python >=3.10 + license: GPL-2.0-only + license_family: GPL + size: 311547 + timestamp: 1772791729816 +- conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.14-py313hd8ed1ab_100.conda + noarch: generic + sha256: 42995d97d7e83d2bedbe8173bc9aa022ea412bf33dd2ff0e3db2c01a5242cd0a + md5: 22ff6a23190a29024b0df04b4caa0c66 + depends: + - python >=3.13,<3.14.0a0 + - python_abi * *_cp313 + license: Python-2.0 + size: 48337 + timestamp: 1781257766256 +- conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda + sha256: e2753997b8bd34205f42be01b8bab8037423dc30c02a1ec12de23e5b4c0b0a2e + md5: 58638f77697c4f6726753eb8be34818b + depends: + - python >=3.10 + - python + license: Apache-2.0 + license_family: APACHE + size: 303705 + timestamp: 1781320269259 +- conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.4-pyhd8ed1ab_0.conda + sha256: feb5c13cc8f256212a979783a7645abd7e27925c51ee5431babbc0efc661cdfd + md5: 66f138d7a6dffb5c959cc4bf6dc2b797 + depends: + - python >=3.10 + license: Unlicense + size: 36989 + timestamp: 1781381078337 +- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda + sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a + md5: c80d8a3b84358cb967fa81e7075fbc8a + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libstdcxx >=14 + license: MIT + license_family: MIT + size: 12723451 + timestamp: 1773822285671 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-hef89b57_0.conda + sha256: 3a7907a17e9937d3a46dfd41cffaf815abad59a569440d1e25177c15fd0684e5 + md5: f1182c91c0de31a7abd40cedf6a5ebef + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 12361647 + timestamp: 1773822915649 +- conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda + sha256: 381cedccf0866babfc135d65ee40b778bd20e927d2a5ec810f750c5860a7c5b8 + md5: 84a3233b709a289a4ddd7a2fd27dd988 + depends: + - python >=3.10 + - ukkonen + license: MIT + license_family: MIT + size: 79757 + timestamp: 1776455344188 +- conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda + sha256: 43e2a5497cad1598ff88a3e69f69bc88b7b8f141fa63c60eab5db296317318b8 + md5: ffc17e785d64e12fc311af9184221839 + depends: + - python >=3.10 + - zipp >=3.20 + - python + license: Apache-2.0 + license_family: APACHE + size: 34766 + timestamp: 1779714582554 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda + sha256: 3d584956604909ff5df353767f3a2a2f60e07d070b328d109f30ac40cd62df6c + md5: 18335a698559cdbcd86150a48bf54ba6 + depends: + - __glibc >=2.17,<3.0.a0 + - zstd >=1.5.7,<1.6.0a0 + constrains: + - binutils_impl_linux-64 2.45.1 + license: GPL-3.0-only + license_family: GPL + size: 728002 + timestamp: 1774197446916 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libcxx-22.1.8-h19cb2f5_0.conda + sha256: 57ee997f1f800cf38abc743c0f0a9ddfe6a101c697c35510452ce6f4ddf96361 + md5: 0f600157f28fc7bc9549ecafdfa5bc12 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 566717 + timestamp: 1781672189697 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-22.1.8-h55c6f16_0.conda + sha256: a2e7abab5add9750fab064c024394de48e49f97631c605ad5db5c8ac3fc769ef + md5: 89f76a2a21a3ec3ec983b5eb237c4113 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 569349 + timestamp: 1781670209146 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_1.conda + sha256: 16feffd9ddbbe5b718515d38ee376c685ba95491cd901244e24671d20b952a77 + md5: b24d3c612f71e7aa74158d92106318b2 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + size: 77856 + timestamp: 1781203599810 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libexpat-2.8.1-hcc62823_1.conda + sha256: 9c96cc05e056e1bba5b545cbbd57b6e01db622dc2c82934caaaa25cfb22fe666 + md5: dcfdea7b7013beef0a4d744d776ea38f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + size: 76020 + timestamp: 1781204303305 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.1-hf6b4638_1.conda + sha256: 5af74261101e3c777399c6294b2b5d290e508153268eb2e9ff99c4d69834612f + md5: a915151d5d3c5bf039f5ccc8402a436f + depends: + - __osx >=11.0 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + size: 69362 + timestamp: 1781203631990 +- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda + sha256: 1a54d874addda73b6f7164d5f3905821277a1831bcc05edd74b3085391688571 + md5: ccc490c81ffe14181861beac0e8f3169 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - expat 2.8.1.* + license: MIT + license_family: MIT + size: 71631 + timestamp: 1781203724164 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda + sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6 + md5: a360c33a5abe61c07959e449fa1453eb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: MIT + license_family: MIT + size: 58592 + timestamp: 1769456073053 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libffi-3.5.2-hd1f9c09_0.conda + sha256: 951958d1792238006fdc6fce7f71f1b559534743b26cc1333497d46e5903a2d6 + md5: 66a0dc7464927d0853b590b6f53ba3ea + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 53583 + timestamp: 1769456300951 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.5.2-hcf2aa1b_0.conda + sha256: 6686a26466a527585e6a75cc2a242bf4a3d97d6d6c86424a441677917f28bec7 + md5: 43c04d9cb46ef176bb2a4c77e324d599 + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 40979 + timestamp: 1769456747661 +- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda + sha256: 59d01f2dfa8b77491b5888a5ab88ff4e1574c9359f7e229da254cdfe27ddc190 + md5: 720b39f5ec0610457b725eb3f396219a + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 45831 + timestamp: 1769456418774 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda + sha256: 8e0a3b5e41272e5678499b5dfc4cddb673f9e935de01eb0767ce857001229f46 + md5: 57736f29cc2b0ec0b6c2952d3f101b6a + depends: + - __glibc >=2.17,<3.0.a0 + - _openmp_mutex >=4.5 + constrains: + - libgcc-ng ==15.2.0=*_19 + - libgomp 15.2.0 he0feb66_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 1041084 + timestamp: 1778269013026 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda + sha256: 5abe4ab9d93f6c9757d654f1969ae2267d4505315c1f2f8fe705fd60af084f1b + md5: faac990cb7aedc7f3a2224f2c9b0c26c + depends: + - __glibc >=2.17,<3.0.a0 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 603817 + timestamp: 1778268942614 +- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda + sha256: ec30e52a3c1bf7d0425380a189d209a52baa03f22fb66dd3eb587acaa765bd6d + md5: b88d90cad08e6bc8ad540cb310a761fb + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - xz 5.8.3.* + license: 0BSD + size: 113478 + timestamp: 1775825492909 +- conda: https://conda.anaconda.org/conda-forge/osx-64/liblzma-5.8.3-hbb4bfdb_0.conda + sha256: d9e2006051529aec5578c6efeb13bb6a7200a014b2d5a77a579e83a8049d5f3c + md5: becdfbfe7049fa248e52aa37a9df09e2 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + size: 105724 + timestamp: 1775826029494 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_0.conda + sha256: 34878d87275c298f1a732c6806349125cebbf340d24c6c23727268184bba051e + md5: b1fd823b5ae54fbec272cea0811bd8a9 + depends: + - __osx >=11.0 + constrains: + - xz 5.8.3.* + license: 0BSD + size: 92472 + timestamp: 1775825802659 +- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda + sha256: d636d1a25234063642f9c531a7bb58d84c1c496411280a36ea000bd122f078f1 + md5: 8f83619ab1588b98dd99c90b0bfc5c6d + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - xz 5.8.3.* + license: 0BSD + size: 106486 + timestamp: 1775825663227 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda + sha256: fe171ed5cf5959993d43ff72de7596e8ac2853e9021dec0344e583734f1e0843 + md5: 2c21e66f50753a083cbe6b80f38268fa + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: BSD-2-Clause + license_family: BSD + size: 92400 + timestamp: 1769482286018 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libmpdec-4.0.0-hf3981d6_1.conda + sha256: 1096c740109386607938ab9f09a7e9bca06d86770a284777586d6c378b8fb3fd + md5: ec88ba8a245855935b871a7324373105 + depends: + - __osx >=10.13 + license: BSD-2-Clause + license_family: BSD + size: 79899 + timestamp: 1769482558610 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_1.conda + sha256: 1089c7f15d5b62c622625ec6700732ece83be8b705da8c6607f4dabb0c4bd6d2 + md5: 57c4be259f5e0b99a5983799a228ae55 + depends: + - __osx >=11.0 + license: BSD-2-Clause + license_family: BSD + size: 73690 + timestamp: 1769482560514 +- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda + sha256: 40dcd0b9522a6e0af72a9db0ced619176e7cfdb114855c7a64f278e73f8a7514 + md5: e4a9fc2bba3b022dad998c78856afe47 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-2-Clause + license_family: BSD + size: 89411 + timestamp: 1769482314283 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-hf4e2dac_1.conda + sha256: f2ad4d3abd4ed7a6a0d28c0ff153e27cb45c406814faa2570bc2581804283674 + md5: f283e98005089bf29ac5774c2e209fbf + depends: + - __glibc >=2.17,<3.0.a0 + - icu >=78.3,<79.0a0 + - libgcc >=14 + - libzlib >=1.3.2,<2.0a0 + license: blessing + size: 964141 + timestamp: 1782406552467 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libsqlite-3.53.2-h77d7759_1.conda + sha256: d6cced99517569e8a71bd5d90859343e18def7a1fb18e224671b54dfb531b387 + md5: 7dd6b4bcbd437bba3358914e8598ecc0 + depends: + - __osx >=11.0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + size: 1006612 + timestamp: 1782407335003 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.53.2-h1ae2325_1.conda + sha256: f3b0289e03883f5ac3d646c318fefebc77f25897f2200459658f4273f901df45 + md5: 2749be4dc52f3a6699e9dea29b78410a + depends: + - __osx >=11.0 + - icu >=78.3,<79.0a0 + - libzlib >=1.3.2,<2.0a0 + license: blessing + size: 927043 + timestamp: 1782407511620 +- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.2-hf5d6505_1.conda + sha256: a643cbc7593b443967093afb14bb892b94094e9135e30772fd1a9dfda8bb08b6 + md5: b510cd61047daf53a68be8daeb56b426 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: blessing + size: 1314192 + timestamp: 1782406655419 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda + sha256: dff1058c76ec6b8759e41cefa2508162d00e4a5e6721aa68ec3fd10094e702dc + md5: 5794b3bdc38177caf969dabd3af08549 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc 15.2.0 he0feb66_19 + constrains: + - libstdcxx-ng ==15.2.0=*_19 + license: GPL-3.0-only WITH GCC-exception-3.1 + license_family: GPL + size: 5852044 + timestamp: 1778269036376 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.2-h5347b49_0.conda + sha256: 9b1bdce27a7e31f7d241aeecff67a1f3101d52a2b1e33ccc2cdf2613072bf81f + md5: 01bb81d12c957de066ea7362007df642 + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: BSD-3-Clause + license_family: BSD + size: 40017 + timestamp: 1781625522462 +- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda + sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9 + md5: d87ff7921124eccd67248aa483c23fec + depends: + - __glibc >=2.17,<3.0.a0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 63629 + timestamp: 1774072609062 +- conda: https://conda.anaconda.org/conda-forge/osx-64/libzlib-1.3.2-hbb4bfdb_2.conda + sha256: 4c6da089952b2d70150c74234679d6f7ac04f4a98f9432dec724968f912691e7 + md5: 30439ff30578e504ee5e0b390afc8c65 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 59000 + timestamp: 1774073052242 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_2.conda + sha256: 361415a698514b19a852f5d1123c5da746d4642139904156ddfca7c922d23a05 + md5: bc5a5721b6439f2f62a84f2548136082 + depends: + - __osx >=11.0 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 47759 + timestamp: 1774072956767 +- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda + sha256: 88609816e0cc7452bac637aaf65783e5edf4fee8a9f8e22bdc3a75882c536061 + md5: dbabbd6234dea34040e631f87676292f + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + constrains: + - zlib 1.3.2 *_2 + license: Zlib + license_family: Other + size: 58347 + timestamp: 1774072851498 +- conda: https://conda.anaconda.org/conda-forge/linux-64/mypy-2.1.0-py313hc25a8b5_0.conda + sha256: 3666f4a3ecea885be8ee0e7fda391104e0b9da064e395a72ce94fa672304f484 + md5: dbfcb53c34482ece0664e53cb975c585 + depends: + - ast-serialize >=0.3.0,<1.0.0 + - mypy_extensions >=1.0.0 + - pathspec >=1.0.0 + - python + - python-librt >=0.11.0 + - typing_extensions >=4.6.0 + - psutil >=4.0 + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 22456484 + timestamp: 1780315600133 +- conda: https://conda.anaconda.org/conda-forge/osx-64/mypy-2.1.0-py313h0b0ee5e_0.conda + sha256: 1899f3fc96f089926e1016428afff151e603889199ff6e240fc46e70879b1423 + md5: 2643b0ecf22591cbf3aef1a13eb4db80 + depends: + - ast-serialize >=0.3.0,<1.0.0 + - mypy_extensions >=1.0.0 + - pathspec >=1.0.0 + - python + - python-librt >=0.11.0 + - typing_extensions >=4.6.0 + - psutil >=4.0 + - __osx >=11.0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 14575684 + timestamp: 1780315774440 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/mypy-2.1.0-py313h3e91af1_0.conda + sha256: 22d438b2a38b9dd494f2ba377a83da76890b80383484f8bd9c7028b7cccb4928 + md5: dd7addb5e48460aac9bec8a655b5fcba + depends: + - ast-serialize >=0.3.0,<1.0.0 + - mypy_extensions >=1.0.0 + - pathspec >=1.0.0 + - python + - python-librt >=0.11.0 + - typing_extensions >=4.6.0 + - psutil >=4.0 + - __osx >=11.0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 13296591 + timestamp: 1780315672240 +- conda: https://conda.anaconda.org/conda-forge/win-64/mypy-2.1.0-py313h4ac8b90_0.conda + sha256: d17f2cfb1e17bee564e25a78cb825903806c6c8a8240ee47de58a4aab106e66f + md5: 8f39a2296c22ccc44e40c0eab88f412f + depends: + - ast-serialize >=0.3.0,<1.0.0 + - mypy_extensions >=1.0.0 + - pathspec >=1.0.0 + - python + - python-librt >=0.11.0 + - typing_extensions >=4.6.0 + - psutil >=4.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 10592735 + timestamp: 1780315651300 +- conda: https://conda.anaconda.org/conda-forge/noarch/mypy_extensions-1.1.0-pyha770c72_0.conda + sha256: 6ed158e4e5dd8f6a10ad9e525631e35cee8557718f83de7a4e3966b1f772c4b1 + md5: e9c622e0d00fa24a6292279af3ab6d06 + depends: + - python >=3.9 + license: MIT + license_family: MIT + size: 11766 + timestamp: 1745776666688 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda + sha256: fc89f74bbe362fb29fa3c037697a89bec140b346a2469a90f7936d1d7ea4d8a3 + md5: fc21868a1a5aacc937e7a18747acb8a5 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + license: X11 AND BSD-3-Clause + size: 918956 + timestamp: 1777422145199 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ncurses-6.6-hcc0dc9a_0.conda + sha256: f5f7e006ff4271305ab4cc08eedd855c67a571793c3d18aff73f645f088a8cae + md5: 31b8740cf1b2588d4e61c81191004061 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + size: 831711 + timestamp: 1777423052277 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-h1d4f5a5_0.conda + sha256: 4ea6c620b87bd1d42bb2ccc2c87cd2483fa2d7f9e905b14c223f11ff3f4c455d + md5: 343d10ed5b44030a2f67193905aea159 + depends: + - __osx >=11.0 + license: X11 AND BSD-3-Clause + size: 805509 + timestamp: 1777423252320 +- conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda + sha256: 4fa40e3e13fc6ea0a93f67dfc76c96190afd7ea4ffc1bac2612d954b42cdc3ee + md5: eb52d14a901e23c39e9e7b4a1a5c015f + depends: + - python >=3.10 + - setuptools + license: BSD-3-Clause + license_family: BSD + size: 40866 + timestamp: 1766261270149 +- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.3-h35e630c_0.conda + sha256: d48f5c22b9897c01e4dff3680f1f57ceb02711ab9c62f74339b080419dfad34b + md5: 79dd2074b5cd5c5c6b2930514a11e22d + depends: + - __glibc >=2.17,<3.0.a0 + - ca-certificates + - libgcc >=14 + license: Apache-2.0 + license_family: Apache + size: 3159683 + timestamp: 1781069855778 +- conda: https://conda.anaconda.org/conda-forge/osx-64/openssl-3.6.3-hc881268_0.conda + sha256: 819d4368d6b5b298fa40d4bc836c1250842489002cacf3fb918a13ee2033b7c6 + md5: 46be42ab403712fd349d007d763bf767 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + size: 2775300 + timestamp: 1781071391999 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.3-hd24854e_0.conda + sha256: b3e3ca895c336d4eb91c5d2f244a312bdb59a0de8cfa0cc4c179225ab2f6bbfb + md5: 8187a86242741725bfa74785fe812979 + depends: + - __osx >=11.0 + - ca-certificates + license: Apache-2.0 + license_family: Apache + size: 3102584 + timestamp: 1781069820667 +- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda + sha256: cb6e7ba0d010ee0d3249ce9886de3d7613d26d9965d4c95666fa66b9c4c31001 + md5: e99f95734a326c0fd4d02bbd995150d4 + depends: + - ca-certificates + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Apache-2.0 + license_family: Apache + size: 9414790 + timestamp: 1781071745579 +- conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda + sha256: 6eaee417d33f298db79bc7185ab1208604c0e6cf51dade34cd513c6f9db9c6f3 + md5: 11adc78451c998c0fd162584abfa3559 + depends: + - python >=3.10 + license: MPL-2.0 + license_family: MOZILLA + size: 56559 + timestamp: 1777271601895 +- conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda + sha256: 9e5e1fd3506ccfc4d444fc4d2d39b0ed097d5d0e3bd3d4bdf6bcc81aaf66860d + md5: 2c5ef45db85d34799771629bd5860fd7 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 26308 + timestamp: 1779972894916 +- conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda + sha256: 716960bf0a9eb334458a26b3bdcb17b8d0786062138a4f48c7f335c8418c5d0b + md5: 7859736b4f8ebe6c8481bf48d91c9a1e + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.10 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + size: 201606 + timestamp: 1776858157327 +- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py313h54dd161_0.conda + sha256: f19fd682d874689dfde20bf46d7ec1a28084af34583e0405685981363af47c91 + md5: 25fe6e02c2083497b3239e21b49d8093 + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python_abi 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + size: 228663 + timestamp: 1769678153829 +- conda: https://conda.anaconda.org/conda-forge/osx-64/psutil-7.2.2-py313h16366db_0.conda + sha256: b50a9d64aabd30c05e405cc1166f21fd7dee8d1b42ef38116701883d3bd4d5fa + md5: c8185e1891ace76e565b4c28dd50ed5d + depends: + - python + - __osx >=10.13 + - python_abi 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + size: 239894 + timestamp: 1769678319684 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py313h6688731_0.conda + sha256: 1d2a6039fb71d61134b1d6816202529f2f6286c83b59bc1491fd288f5c08046e + md5: ba2d89e51a855963c767648f44c03871 + depends: + - python + - __osx >=11.0 + - python 3.13.* *_cp313 + - python_abi 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + size: 242596 + timestamp: 1769678288893 +- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py313h5fd188c_0.conda + sha256: 3ec3373748f83069bef93b540de416e637ee30231b222d5df8f712e93f2f9195 + md5: 761b299a6289c77459defea3563f8fc0 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + size: 246062 + timestamp: 1769678176886 +- conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda + sha256: e27e0473fc6723311a0bd48b89b616fa1b996a2f7a2b555338cbbcfb9c640568 + md5: 9c5491066224083c41b6d5635ed7107b + depends: + - python >=3.10 + - python + license: BSD-3-Clause + license_family: BSD + size: 55886 + timestamp: 1779293633166 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.13.14-h6add32d_100_cp313.conda + build_number: 100 + sha256: f2146aff59ce4b571a8f1d1acf94f9bed6cc18ab5632d7dcc940fb48ecdeef99 + md5: 93762cd272814a142cf21d794f8fb0c1 + depends: + - __glibc >=2.17,<3.0.a0 + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - libgcc >=14 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libuuid >=2.42.1,<3.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + size: 37398694 + timestamp: 1781258934574 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-3.13.14-h3d5d122_100_cp313.conda + build_number: 100 + sha256: a92dd0f84a8a715dc7ac45bacbfdfca9f06eadd2b327cbe915fff9d386ae9ffb + md5: a8798b5d0bc70f11e1e1183b6795c007 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + size: 17520209 + timestamp: 1781260014001 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.13.14-h448ec07_100_cp313.conda + build_number: 100 + sha256: c89eedab6b293fae654d75483d8f3e5eb3ff9ce2478134d902676c1dd20c7dfd + md5: e556c07deaa168043f8430bb046092e2 + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - ncurses >=6.6,<7.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.13.* *_cp313 + - readline >=8.3,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + license: Python-2.0 + size: 17017633 + timestamp: 1781257915644 + python_site_packages_path: lib/python3.13/site-packages +- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.13.14-h09917c8_100_cp313.conda + build_number: 100 + sha256: 26442b2878df89f27cc9efd54c1322d111653683abf256b657dbefe089857b40 + md5: 12e0de38e6bb7f7745ec0d19a20b8270 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.8.1,<3.0a0 + - libffi >=3.5.2,<3.6.0a0 + - liblzma >=5.8.3,<6.0a0 + - libmpdec >=4.0.0,<5.0a0 + - libsqlite >=3.53.2,<4.0a0 + - libzlib >=1.3.2,<2.0a0 + - openssl >=3.5.7,<4.0a0 + - python_abi 3.13.* *_cp313 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: Python-2.0 + size: 16792315 + timestamp: 1781257712940 + python_site_packages_path: Lib/site-packages +- conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.2-pyhcf101f3_0.conda + sha256: 6914da740f6e3ec44ffb2f687dbc9c33abf084e42f34e3a8bb8235e475850619 + md5: 7a9095c9300d1b50b1785ca9bc4cadae + depends: + - python >=3.10 + - filelock >=3.15.4 + - platformdirs <5,>=4.3.6 + - python + license: MIT + license_family: MIT + size: 35514 + timestamp: 1781257630962 +- conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.14-h4df99d1_100.conda + sha256: c7a8f98ea1cda5a84377c236ccd4bf1b6e2212c5a258d60bba295fb9f0260235 + md5: 200323d73f85b9c5c411db8c8c4942db + depends: + - cpython 3.13.14.* + - python_abi * *_cp313 + license: Python-2.0 + size: 48307 + timestamp: 1781257788601 +- conda: https://conda.anaconda.org/conda-forge/linux-64/python-librt-0.11.0-py313h54dd161_0.conda + sha256: 5b08c4caeced10f7354db4abd10e14c03634edb2bb6bc39695329cbfe0ca48a9 + md5: 268aef8a914b61322ca2b51500652a7a + depends: + - python + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 157367 + timestamp: 1778511621681 +- conda: https://conda.anaconda.org/conda-forge/osx-64/python-librt-0.11.0-py313h22ab4a2_0.conda + sha256: a7c6f98907f18375e8290d54e79e9b86d48d2da5be1dc98cf323ac9789c2fac4 + md5: 458bad13bfb5595071dc3274487c8542 + depends: + - python + - __osx >=11.0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 129318 + timestamp: 1778511870497 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-librt-0.11.0-py313h6688731_0.conda + sha256: 28a5b244c0e43339efa3e61e989c3aceb0136483dfbffbd8991366bc24f42f54 + md5: 2952fb0d154a199f0ce6c752e4e223b6 + depends: + - python + - python 3.13.* *_cp313 + - __osx >=11.0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 130144 + timestamp: 1778511862746 +- conda: https://conda.anaconda.org/conda-forge/win-64/python-librt-0.11.0-py313h5fd188c_0.conda + sha256: d9d2c19b96b518688eae308f6f8f3701a02c33ace0f12c310ac67e0701c29ed7 + md5: 68c80ed04e086794346542e38d880da4 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 108334 + timestamp: 1778511739044 +- conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda + build_number: 8 + sha256: 210bffe7b121e651419cb196a2a63687b087497595c9be9d20ebe97dd06060a7 + md5: 94305520c52a4aa3f6c2b1ff6008d9f8 + constrains: + - python 3.13.* *_cp313 + license: BSD-3-Clause + license_family: BSD + size: 7002 + timestamp: 1752805902938 +- conda: https://conda.anaconda.org/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_1.conda + sha256: 5f938d208c284cc1f910fd84f2adffe59d01de73f62d8448ae311eb4f0340bd3 + md5: 1fd14e3bb9bd8dac4caa30da123b8a93 + depends: + - python >=3.10.* + - yaml + track_features: + - pyyaml_no_compile + license: MIT + license_family: MIT + size: 45525 + timestamp: 1770223374054 +- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py313hd650c13_1.conda + sha256: dfaed50de8ee72a51096163b87631921688851001e38c78a841eba1ae8b35889 + md5: c1bdb8dd255c79fb9c428ad25cc6ee54 + depends: + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + size: 180992 + timestamp: 1770223457761 +- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002 + md5: d7d95fc8287ea7bf33e0e7116d2b95ec + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 345073 + timestamp: 1765813471974 +- conda: https://conda.anaconda.org/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + sha256: 4614af680aa0920e82b953fece85a03007e0719c3399f13d7de64176874b80d5 + md5: eefd65452dfe7cce476a519bece46704 + depends: + - __osx >=10.13 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 317819 + timestamp: 1765813692798 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + sha256: a77010528efb4b548ac2a4484eaf7e1c3907f2aec86123ed9c5212ae44502477 + md5: f8381319127120ce51e081dce4865cf4 + depends: + - __osx >=11.0 + - ncurses >=6.5,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 313930 + timestamp: 1765813902568 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.15.8-h7805a7d_0.conda + noarch: python + sha256: c4778a348a99b781585f8ef47f134a5ebfe205f9f403ecd601fc602efa7e54ce + md5: 67bed7148b00f10bd30766cb58fb8f6f + depends: + - python + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 9233459 + timestamp: 1774602134378 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ruff-0.15.8-h16586dd_0.conda + noarch: python + sha256: 461374440f51a529f7a6d98b375e7275a718c0b01eceeddd09e4686628af1694 + md5: c2264b6843ddd72daafb99bf7f418206 + depends: + - python + - __osx >=11.0 + constrains: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 9198854 + timestamp: 1774602365946 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.15.8-hc5c3a1d_0.conda + noarch: python + sha256: c26c0536190e0211582cfc1c35cddd231678c130ed178edc37b37fabf0a050de + md5: fa921b1314935be129087490fce6b8d7 + depends: + - python + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 8389699 + timestamp: 1774602454769 +- conda: https://conda.anaconda.org/conda-forge/win-64/ruff-0.15.8-h02f8532_0.conda + noarch: python + sha256: 6b3e2568b8023cc5862b530693c7219adb924645aa14d0e81e5498cbb4498f3a + md5: d1d4299b79941e62f233eac40cc01945 + depends: + - python + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 9681714 + timestamp: 1774602197472 +- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda + sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1 + md5: 8e194e7b992f99a5015edbd4ebd38efd + depends: + - python >=3.10 + license: MIT + license_family: MIT + size: 639697 + timestamp: 1773074868565 +- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda + sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac + md5: cffd3bdd58090148f4cfcd831f4b26ab + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + - libzlib >=1.3.1,<2.0a0 + constrains: + - xorg-libx11 >=1.8.12,<2.0a0 + license: TCL + license_family: BSD + size: 3301196 + timestamp: 1769460227866 +- conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-h7142dee_3.conda + sha256: 7f0d9c320288532873e2d8486c331ec6d87919c9028208d3f6ac91dc8f99a67b + md5: 6e6efb7463f8cef69dbcb4c2205bf60e + depends: + - __osx >=10.13 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + size: 3282953 + timestamp: 1769460532442 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h010d191_3.conda + sha256: 799cab4b6cde62f91f750149995d149bc9db525ec12595e8a1d91b9317f038b3 + md5: a9d86bc62f39b94c4661716624eb21b0 + depends: + - __osx >=11.0 + - libzlib >=1.3.1,<2.0a0 + license: TCL + license_family: BSD + size: 3127137 + timestamp: 1769460817696 +- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h6ed50ae_3.conda + sha256: 0e79810fae28f3b69fe7391b0d43f5474d6bd91d451d5f2bde02f55ae481d5e3 + md5: 0481bfd9814bf525bd4b3ee4b51494c4 + depends: + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: TCL + license_family: BSD + size: 3526350 + timestamp: 1769460339384 +- conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda + sha256: 032271135bca55aeb156cee361c81350c6f3fb203f57d024d7e5a1fc9ef18731 + md5: 0caa1af407ecff61170c9437a808404d + depends: + - python >=3.10 + - python + license: PSF-2.0 + license_family: PSF + size: 51692 + timestamp: 1756220668932 +- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda + sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c + md5: ad659d0a2b3e47e38d829aa8cad2d610 + license: LicenseRef-Public-Domain + size: 119135 + timestamp: 1767016325805 +- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda + sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 + md5: 71b24316859acd00bdb8b38f5e2ce328 + constrains: + - vc14_runtime >=14.29.30037 + - vs2015_runtime >=14.29.30037 + license: LicenseRef-MicrosoftWindowsSDK10 + size: 694692 + timestamp: 1756385147981 +- conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda + sha256: 7f2e4f38e57c17858c644259a1be868d6e98780239fd93bfa057cb5cfc24a928 + md5: cb423e0853b3dde2b3738db4dedf5ba2 + depends: + - __glibc >=2.17,<3.0.a0 + - cffi + - libgcc >=14 + - libstdcxx >=14 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 14910 + timestamp: 1769438729201 +- conda: https://conda.anaconda.org/conda-forge/osx-64/ukkonen-1.1.0-py313h252b9d7_0.conda + sha256: 201d026c60bbbdd7c9bf9b3c61f807711ba24a9899a1b7f8a978b507d44d7efa + md5: e6ab56e180655e23353afea13caebc44 + depends: + - __osx >=10.13 + - cffi + - libcxx >=19 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 14202 + timestamp: 1769439075795 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.1.0-py313h5c29297_0.conda + sha256: d28d0242d3fa23784630c775d5b628ce25e2d45f5d3f1cfcdc3815bc954073fa + md5: 43b1eb729bd1cd9ea595548eb8100b65 + depends: + - __osx >=11.0 + - cffi + - libcxx >=19 + - python >=3.13,<3.14.0a0 + - python >=3.13,<3.14.0a0 *_cp313 + - python_abi 3.13.* *_cp313 + license: MIT + license_family: MIT + size: 14773 + timestamp: 1769439197815 +- conda: https://conda.anaconda.org/conda-forge/win-64/ukkonen-1.1.0-py313hf069bd2_0.conda + sha256: 09f3bb587199361774612f4e70226d8688eda264b452ec401e1ce904633dde43 + md5: bfa075d1cd7bf341b8189af9616ce537 + depends: + - cffi + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: MIT + license_family: MIT + size: 18441 + timestamp: 1769438882754 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda + sha256: 17693b60cb54f80c60275f003f3bfc1b128af56dbfd65c4fae37c64eeb755ce1 + md5: 2eacea63f545b97342da520df6854276 + depends: + - vc14_runtime >=14.51.36231 + track_features: + - vc14 + license: BSD-3-Clause + license_family: BSD + size: 20362 + timestamp: 1781320968457 +- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda + sha256: 8153ed849c92e891eacac0f2f8d7ecb79f9b5fd7f7917fbb896f252a60a40390 + md5: 06a5bf5a1ca16cce0df6eaa91fc42bc2 + depends: + - ucrt >=10.0.20348.0 + - vcomp14 14.51.36231 h1b9f54f_39 + constrains: + - vs2015_runtime 14.51.36231.* *_39 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + size: 737434 + timestamp: 1781320964561 +- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda + sha256: 07fb14713c4bc62e2533a2e23a363abfb0e65650681fba0ae4c840e2219350f3 + md5: 8b53a83fda40ec679e4d63fa32fae989 + depends: + - ucrt >=10.0.20348.0 + constrains: + - vs2015_runtime 14.51.36231.* *_39 + license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime + license_family: Proprietary + size: 120684 + timestamp: 1781320948530 +- conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.5.1-pyhcf101f3_0.conda + sha256: 0a7b0a2ada7ad719f9d4f8874eb10911e1fcfdecefc86456105eb806ebd60ac4 + md5: e449fb99b714be1e13fa5564dacd1af5 + depends: + - python >=3.10 + - distlib >=0.3.7,<1 + - filelock <4,>=3.24.2 + - importlib-metadata >=6.6 + - platformdirs >=3.9.1,<5 + - python-discovery >=1.4.2 + - typing_extensions >=4.13.2 + - python + license: MIT + license_family: MIT + size: 3111990 + timestamp: 1781651033074 +- conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda + sha256: 6d9ea2f731e284e9316d95fa61869fe7bbba33df7929f82693c121022810f4ad + md5: a77f85f77be52ff59391544bfe73390a + depends: + - libgcc >=14 + - __glibc >=2.17,<3.0.a0 + license: MIT + license_family: MIT + size: 85189 + timestamp: 1753484064210 +- conda: https://conda.anaconda.org/conda-forge/osx-64/yaml-0.2.5-h4132b18_3.conda + sha256: a335161bfa57b64e6794c3c354e7d49449b28b8d8a7c4ed02bf04c3f009953f9 + md5: a645bb90997d3fc2aea0adf6517059bd + depends: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 79419 + timestamp: 1753484072608 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h925e9cb_3.conda + sha256: b03433b13d89f5567e828ea9f1a7d5c5d697bf374c28a4168d71e9464f5dafac + md5: 78a0fe9e9c50d2c381e8ee47e3ea437d + depends: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 83386 + timestamp: 1753484079473 +- conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda + sha256: 80ee68c1e7683a35295232ea79bcc87279d31ffeda04a1665efdb43cbd50a309 + md5: 433699cba6602098ae8957a323da2664 + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 63944 + timestamp: 1753484092156 +- conda: https://conda.anaconda.org/conda-forge/noarch/yamllint-1.38.0-pyhcf101f3_1.conda + sha256: 7f5bcc0f059c607ccd65fa1b82d8b369c2598a6e86c31f7a4995bc2f2753e2eb + md5: db30cb6c0910cc35e35d3955cf373df2 + depends: + - pathspec >=1.0.0 + - python >=3.10 + - pyyaml + - python + license: GPL-3.0-or-later + license_family: GPL + size: 111901 + timestamp: 1770937575481 +- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda + sha256: 210bd31c22bb88f5e2a167df24c95bb5f152b2ada7502f9b8c49d1f5366db423 + md5: ba3dcdc8584155c97c648ae9c044b7a3 + depends: + - python >=3.10 + - python + license: MIT + license_family: MIT + size: 24190 + timestamp: 1779159948016 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zizmor-1.23.1-hb17b654_0.conda + sha256: 80188255dbd17fba8b2f2d84dd0f5fc67c3e4c62ea40618fb561f409488b8355 + md5: 07a8efd9657796663bb4eb4ac821c012 + depends: + - __glibc >=2.17,<3.0.a0 + - libgcc >=14 + constrains: + - __glibc >=2.17 + license: MIT + license_family: MIT + size: 6958373 + timestamp: 1773569800269 +- conda: https://conda.anaconda.org/conda-forge/osx-64/zizmor-1.23.1-h19f9e61_0.conda + sha256: 85e76d3ef85f6c0049d2f455d39bd04dfd4353f39c2ce14532114eb204fa5bde + md5: 9be6a08f5eb24b3aac0bc69ced805add + depends: + - __osx >=11.0 + constrains: + - __osx >=10.13 + license: MIT + license_family: MIT + size: 6927704 + timestamp: 1773569854961 +- conda: https://conda.anaconda.org/conda-forge/osx-arm64/zizmor-1.23.1-h6fdd925_0.conda + sha256: 244ef691a4c3d7fca126f330055591c99ecfbe1972f0d50609ec5366529a9831 + md5: acc150c81d4a508198e921ab3789835b + depends: + - __osx >=11.0 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + size: 6443999 + timestamp: 1773569857372 +- conda: https://conda.anaconda.org/conda-forge/win-64/zizmor-1.23.1-h18a1a76_0.conda + sha256: e5e14dc15203bb4f691ee148cf9f422057b3082a7f2c446054f1a14ae0be5715 + md5: b338f9d73052ff4c329b98d7acbea17d + depends: + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + - ucrt >=10.0.20348.0 + license: MIT + license_family: MIT + size: 7183577 + timestamp: 1773569843787 +- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda + sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7 + md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829 + depends: + - __glibc >=2.17,<3.0.a0 + - libzlib >=1.3.1,<2.0a0 + license: BSD-3-Clause + license_family: BSD + size: 601375 + timestamp: 1764777111296 diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 0000000..59e9a30 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,36 @@ +[workspace] +authors = [] +channels = ["conda-forge"] +description = "NSLS-II system scripts and shell libraries" +name = "n2snscripts" +platforms = ["linux-64", "osx-64", "osx-arm64", "win-64"] +version = "0.1.0" + +[dependencies] +# Runtime interpreter for azoidcapp (stdlib only; no extra packages needed). +# Capped at <3.14 to avoid conda-forge pre-commit packaging issues with the +# Python 3.14 free-threaded (3.14t) variant. +python = ">=3.9,<3.14" + +[environments] +dev = { features = ["dev"] } + +[feature.dev.dependencies] +pre-commit = "*" +# Tool versions are pinned so developer environments are deterministic +# and stay in lockstep with the .pre-commit-config.yaml hook set +# (both locally and in CI via the Lint workflow). +# When upgrading any of these, also update the matching `rev:` pins +# in .pre-commit-config.yaml if applicable. +ruff = "==0.15.8" +codespell = "==2.4.2" +yamllint = "==1.38.0" +zizmor = "==1.23.1" +mypy = "*" +# identify >= 2.x is required by pre-commit-shfmt v3.12+ which uses the +# 'csh' type tag; conda-forge's pre-commit pulls an older identify. +identify = ">=2.5" + +[feature.dev.tasks] +lint = "pre-commit run --all-files" +lint-files = "pre-commit run --files" diff --git a/.github/linters/zizmor.yaml b/zizmor.yaml similarity index 100% rename from .github/linters/zizmor.yaml rename to zizmor.yaml From beea0c80f76d5d54762f827564baf4240556f1e9 Mon Sep 17 00:00:00 2001 From: Stuart Wilkins Date: Fri, 26 Jun 2026 14:21:34 -0400 Subject: [PATCH 4/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/lint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 68f3be7..0ca9729 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -66,6 +66,7 @@ jobs: FROM_REF: ${{ steps.refs.outputs.from_ref }} TO_REF: ${{ steps.refs.outputs.to_ref }} run: | + set -o pipefail if [[ -n "$FROM_REF" ]]; then pixi run -e dev pre-commit run \ --from-ref "$FROM_REF" \ From 07c6f8a8b1a3378d68f0dabd9bfd113913856e80 Mon Sep 17 00:00:00 2001 From: Stuart Wilkins Date: Fri, 26 Jun 2026 20:29:58 -0400 Subject: [PATCH 5/5] Update .github/workflows/lint.yml Co-authored-by: Padraic Shafer <76011594+padraic-shafer@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0ca9729..0b6c2f7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 persist-credentials: false