diff --git a/extension/schema.json b/extension/schema.json index 026e5b3..891f495 100644 --- a/extension/schema.json +++ b/extension/schema.json @@ -6,6 +6,38 @@ "namespace": "GH" }, "node_kinds": [ + { + "name": "GH_Enterprise", + "display_name": "GitHub Enterprise", + "description": "A GitHub Enterprise account that contains organizations, enterprise teams, roles, and managed users", + "is_display_kind": true, + "icon": "globe", + "color": "#6EA8FE" + }, + { + "name": "GH_EnterpriseTeam", + "display_name": "GitHub Enterprise Team", + "description": "A team managed at the GitHub Enterprise level and assignable across organizations", + "is_display_kind": true, + "icon": "users-between-lines", + "color": "#9B8CFF" + }, + { + "name": "GH_EnterpriseRole", + "display_name": "GitHub Enterprise Role", + "description": "The role a user or team has at the GitHub Enterprise level", + "is_display_kind": true, + "icon": "user-tie", + "color": "#B8D7FF" + }, + { + "name": "GH_EnterpriseManagedUser", + "display_name": "GitHub Enterprise Managed User", + "description": "A GitHub Enterprise managed user account linked to an enterprise identity provider", + "is_display_kind": true, + "icon": "user-lock", + "color": "#7DD3FC" + }, { "name": "GH_Organization", "display_name": "GitHub Organization", @@ -150,6 +182,30 @@ "icon": "lock-open", "color": "#E89B5C" }, + { + "name": "GH_RunnerGroup", + "display_name": "GitHub Runner Group", + "description": "A GitHub self-hosted runner group that controls runner access and visibility", + "is_display_kind": true, + "icon": "server", + "color": "#94A3B8" + }, + { + "name": "GH_OrgRunner", + "display_name": "GitHub Org Runner", + "description": "An organization-scoped GitHub self-hosted runner available to selected repositories or workflows", + "is_display_kind": true, + "icon": "microchip", + "color": "#22C55E" + }, + { + "name": "GH_RepoRunner", + "display_name": "GitHub Repo Runner", + "description": "A repository-scoped GitHub self-hosted runner available to jobs in a single repository", + "is_display_kind": true, + "icon": "microchip", + "color": "#38BDF8" + }, { "name": "GH_EnvironmentVariable", "display_name": "GitHub Environment Variable", diff --git a/justfile b/justfile index 30b04a1..fa22f3a 100644 --- a/justfile +++ b/justfile @@ -1,16 +1,16 @@ set dotenv-load := true -collect +args: +collect +args='github /tmp/output/raw/': @echo "Collecting data" - uv run src/main.py collect github {{args}} + uv run src/main.py collect {{args}} -preprocess +args: - @echo "Collecting data" - uv run src/main.py preprocess github {{args}} +preprocess +args='github /tmp/output/raw/github': + @echo "Preprocessing data" + uv run openhound preprocess {{args}} -convert +args: +convert +args='github /tmp/output/raw/github /tmp/output/graph/github': @echo "Converting data" - uv run src/main.py convert github {{args}} + uv run openhound convert {{args}} sync: @echo "Syncing dependencies" diff --git a/pyproject.toml b/pyproject.toml index 4e30ebd..9a8b3f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ local_scheme = "no-local-version" [dependency-groups] dev = [ - "openhound", + "openhound==0.1.4", "pre-commit>=4.5.1", "pytest>=9.0.1", "ruff>=0.15.5", diff --git a/src/openhound_github/auth.py b/src/openhound_github/auth.py index 5721fc2..ea6977f 100644 --- a/src/openhound_github/auth.py +++ b/src/openhound_github/auth.py @@ -1,12 +1,17 @@ """GitHub authentication helpers for JWT-based app authentication.""" -import json import base64 -import requests +import json from datetime import datetime, timezone +from typing import Iterator + +import requests from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding -from typing import Optional +from dlt.sources.helpers.rest_client.client import RESTClient +from dlt.sources.helpers.rest_client.paginators import ( + HeaderLinkPaginator, +) class GitHubJwtSession: @@ -14,10 +19,10 @@ class GitHubJwtSession: def __init__( self, - org_name: str, client_id: str, private_key_path: str, - app_id: str, + app_id: str | None = None, + org_name: str | None = None, api_uri: str = "https://api.github.com/", ): """Initialize a GitHub JWT session. @@ -33,15 +38,13 @@ def __init__( FileNotFoundError: If the private key file cannot be found. ValueError: If the private key cannot be loaded or JWT cannot be created. """ - self.org_name = org_name self.client_id = client_id self.private_key_path = private_key_path self.app_id = app_id + self.org_name = org_name self.api_uri = api_uri.rstrip("/") + "/" - self._jwt_token: Optional[str] = None - self._access_token: Optional[str] = None - self._token_expires_at: datetime | None = None + self._access_tokens: dict[int, tuple[str, datetime | None]] = {} self._load_private_key() @@ -92,9 +95,7 @@ def _create_jwt(self) -> str: except Exception as e: raise ValueError(f"Failed to sign JWT: {e}") from e - jwt_token = f"{header_encoded}.{payload_encoded}.{signature_encoded}" - self._jwt_token = jwt_token - return jwt_token + return f"{header_encoded}.{payload_encoded}.{signature_encoded}" @staticmethod def _base64url_encode(data: str | bytes) -> str: @@ -117,41 +118,47 @@ def _base64url_encode(data: str | bytes) -> str: # Remove padding return encoded.rstrip("=") - def get_access_token(self) -> str: - """Get a valid access token for GitHub API requests. + @property + def jwt_headers(self) -> dict: + jwt_token = self._create_jwt() + return { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {jwt_token}", + "X-GitHub-Api-Version": "2022-11-28", + } - Fetches an installation access token using the JWT token. If a valid - access token is already cached and not expired, it returns that. + @property + def jwt_token(self) -> str: + return self._create_jwt() - Returns: - A valid access token string. + def _jwt_client(self) -> RESTClient: + return RESTClient( + base_url=self.api_uri, + headers=self.jwt_headers, + paginator=HeaderLinkPaginator(), + ) - Raises: - requests.RequestException: If the API request fails. - ValueError: If token cannot be obtained. - """ - # Return cached token if still valid - if ( - self._access_token - and self._token_expires_at - and datetime.now(timezone.utc) < self._token_expires_at + def list_installations(self) -> Iterator[dict]: + for page in self._jwt_client().paginate( + "/app/installations", params={"per_page": 100} ): - return self._access_token + yield from page - # Create a fresh JWT - jwt_token = self._create_jwt() + def installation_id_for_org(self, org_login: str) -> int: + response = self._jwt_client().get(f"/orgs/{org_login}/installation").json() + return int(response["id"]) - # Exchange JWT for access token - headers = { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {jwt_token}", - "X-GitHub-Api-Version": "2022-11-28", - } + def installation_token(self, installation_id: int) -> str: + cached = self._access_tokens.get(installation_id) + if cached: + token, expires_at = cached + if expires_at is None or datetime.now(timezone.utc) < expires_at: + return token try: response = requests.post( - f"{self.api_uri}app/installations/{self.app_id}/access_tokens", - headers=headers, + f"{self.api_uri}app/installations/{installation_id}/access_tokens", + headers=self.jwt_headers, timeout=10, ) response.raise_for_status() @@ -164,15 +171,41 @@ def get_access_token(self) -> str: if "token" not in response_data: raise ValueError("No token in GitHub API response. Check app credentials.") - self._access_token = response_data["token"] - - # Parse expiration time if provided + expires_at = None if "expires_at" in response_data: - self._token_expires_at = datetime.fromisoformat( + expires_at = datetime.fromisoformat( response_data["expires_at"].replace("Z", "+00:00") ) - return self._access_token + token = response_data["token"] + self._access_tokens[installation_id] = (token, expires_at) + return token + + def get_access_token( + self, org_name: str | None = None, installation_id: int | None = None + ) -> str: + """Get a valid access token for GitHub API requests. + + Fetches an installation access token using the JWT token. If a valid + access token is already cached and not expired, it returns that. + + Returns: + A valid access token string. + + Raises: + requests.RequestException: If the API request fails. + ValueError: If token cannot be obtained. + """ + if installation_id is None: + org_login = org_name or self.org_name + if org_login: + installation_id = self.installation_id_for_org(org_login) + else: + if not self.app_id: + raise ValueError("org_name or installation_id is required") + installation_id = int(self.app_id) + + return self.installation_token(installation_id) def get_headers(self) -> dict: """Get HTTP headers for authenticated GitHub API requests. @@ -189,7 +222,11 @@ def get_headers(self) -> dict: def create_github_jwt_session( - org_name: str, client_id: str, private_key_path: str, app_id: str + org_name: str | None, + client_id: str, + private_key_path: str, + app_id: str, + api_uri: str = "https://api.github.com", ) -> GitHubJwtSession: """Factory function to create a GitHub JWT session. @@ -217,4 +254,5 @@ def create_github_jwt_session( client_id=client_id, private_key_path=private_key_path, app_id=app_id, + api_uri=api_uri, ) diff --git a/src/openhound_github/graphql.py b/src/openhound_github/graphql.py index 4f0b083..a485f57 100644 --- a/src/openhound_github/graphql.py +++ b/src/openhound_github/graphql.py @@ -22,6 +22,150 @@ } """ +ENTERPRISE_QUERY = """ +query Enterprise($slug: String!, $after: String) { + enterprise(slug: $slug) { + id + databaseId + name + slug + description + location + url + websiteUrl + createdAt + updatedAt + billingEmail + securityContactEmail + viewerIsAdmin + organizations(first: 100, after: $after) { + nodes { + id + login + } + pageInfo { + hasNextPage + endCursor + } + } + } +} +""" + +ENTERPRISE_MEMBERS_QUERY = """ +query EnterpriseMembers($slug: String!, $count: Int = 100, $after: String = null) { + enterprise(slug: $slug) { + members(first: $count, after: $after) { + edges { + node { + __typename + ... on User { + id + databaseId + login + name + email + company + } + ... on EnterpriseUserAccount { + id + login + name + url + createdAt + updatedAt + user { + id + databaseId + login + name + email + company + } + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } +} +""" + +ENTERPRISE_ADMINS_QUERY = """ +query EnterpriseAdmins($slug: String!, $count: Int = 100, $after: String = null) { + enterprise(slug: $slug) { + ownerInfo { + admins(first: $count, after: $after) { + edges { + node { + id + login + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } +} +""" + +ENTERPRISE_SAML_QUERY = """ +query EnterpriseSAML($slug: String!, $count: Int = 100, $after: String = null) { + enterprise(slug: $slug) { + id + name + slug + ownerInfo { + samlIdentityProvider { + id + issuer + ssoUrl + digestMethod + signatureMethod + idpCertificate + externalIdentities(first: $count, after: $after) { + totalCount + nodes { + guid + id + samlIdentity { + familyName + givenName + nameId + username + } + scimIdentity { + username + givenName + familyName + emails { + value + primary + type + } + } + user { + id + login + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + } + } +} +""" + TEAMS_QUERY = """ query Teams($login: String!, $count: Int!, $after: String) { organization(login: $login) { @@ -163,39 +307,94 @@ """ SAML_QUERY = """ -query SAMLProvider($login: String!) { +query SAML($login: String!, $count: Int = 100, $after: String = null) { organization(login: $login) { id name + login samlIdentityProvider { + digestMethod + externalIdentities(first: $count, after: $after) { + nodes { + guid + id + samlIdentity { + attributes { + metadata + name + value + } + familyName + givenName + groups + nameId + username + } + scimIdentity { + emails { + primary + type + value + } + familyName + givenName + groups + username + } + user { + id + login + } + } + pageInfo { + endCursor + hasNextPage + } + totalCount + } id - issuer - ssoUrl idpCertificate + issuer signatureMethod - digestMethod + ssoUrl } } } """ SAML_IDENTITIES_QUERY = """ -query SAMLIdentities($login: String!, $count: Int!, $after: String) { +query SAML($login: String!, $count: Int = 100, $after: String = null) { organization(login: $login) { + id + name + login samlIdentityProvider { + digestMethod externalIdentities(first: $count, after: $after) { nodes { guid id samlIdentity { + attributes { + metadata + name + value + } familyName givenName + groups nameId username } scimIdentity { + emails { + primary + type + value + } familyName givenName + groups username } user { @@ -207,7 +406,13 @@ endCursor hasNextPage } + totalCount } + id + idpCertificate + issuer + signatureMethod + ssoUrl } } } diff --git a/src/openhound_github/helpers.py b/src/openhound_github/helpers.py index a649049..197c709 100644 --- a/src/openhound_github/helpers.py +++ b/src/openhound_github/helpers.py @@ -5,6 +5,10 @@ from requests import Request +class GraphQLPaginationError(RuntimeError): + pass + + class GraphQLCursorPaginator(JSONResponseCursorPaginator): def __init__( self, @@ -12,6 +16,7 @@ def __init__( cursor_variable: str = "after", cursor_field: str = "endCursor", has_next_field: str = "hasNextPage", + allow_missing_page_info: bool = False, ) -> None: super().__init__( @@ -25,12 +30,45 @@ def __init__( self.cursor_variable = cursor_variable self.cursor_field = cursor_field self.has_next_field = has_next_field + self.allow_missing_page_info = allow_missing_page_info + + def init_request(self, request: "Request") -> None: + self._next_reference = None + self._has_next_page = True def update_state(self, response, data=None): response_json = response.json() + errors = response_json.get("errors") + if errors: + messages = [] + for error in errors: + if isinstance(error, dict): + message = error.get("message", "unknown GraphQL error") + extensions = error.get("extensions") or {} + error_type = error.get("type") or extensions.get("type") + path = error.get("path") + details = message + if error_type: + details = f"{details} ({error_type})" + if path: + details = f"{details} at {path}" + messages.append(details) + else: + messages.append(str(error)) + raise GraphQLPaginationError( + f"GraphQL response contained errors while reading {self.page_info_path}: " + + "; ".join(messages) + ) + + self._normalize_page_info(data) + page_info = jsonpath.find_values(self.page_info_path, response_json) if not page_info: + if not self.allow_missing_page_info: + raise GraphQLPaginationError( + f"GraphQL pageInfo not found at {self.page_info_path}" + ) self._next_reference = None self._has_next_page = False return @@ -38,12 +76,50 @@ def update_state(self, response, data=None): page_info_obj = page_info[0] cursor = page_info_obj.get(self.cursor_field) has_next = page_info_obj.get(self.has_next_field) + + if not isinstance(has_next, bool): + raise GraphQLPaginationError( + f"GraphQL {self.page_info_path}.{self.has_next_field} must be a bool" + ) + if has_next and not cursor: + raise GraphQLPaginationError( + f"GraphQL {self.page_info_path}.{self.cursor_field} is required when " + f"{self.has_next_field} is true" + ) + self._next_reference = cursor self._has_next_page = has_next + def _normalize_page_info(self, data): + if isinstance(data, list): + for item in data: + self._normalize_page_info(item) + return + + if not isinstance(data, dict): + return + + page_info = data.get("pageInfo") + if isinstance(page_info, dict): + has_next = page_info.get(self.has_next_field) + if has_next is False and self.cursor_field not in page_info: + page_info[self.cursor_field] = None + + for value in data.values(): + self._normalize_page_info(value) + def update_request(self, request: "Request") -> None: - if not self._next_reference or not hasattr(request, "json"): + if not self._has_next_page: return - if isinstance(request.json, dict) and "variables" in request.json: - request.json["variables"][self.cursor_variable] = self._next_reference + if not self._next_reference: + raise GraphQLPaginationError( + f"GraphQL cursor is missing for variable {self.cursor_variable}" + ) + if not isinstance(request.json, dict): + raise GraphQLPaginationError("GraphQL request body must be a JSON object") + variables = request.json.get("variables") + if not isinstance(variables, dict): + raise GraphQLPaginationError("GraphQL request body must contain variables") + + variables[self.cursor_variable] = self._next_reference diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index cc89c84..28425e2 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -1,11 +1,13 @@ # Generic CONTAINS = "GH_Contains" +ASSIGNED_TO = "GH_AssignedTo" # Administrative edges ADMIN_TO = "GH_AdminTo" OWNS = "GH_Owns" # Role and permission edges +HAS_MEMBER = "GH_HasMember" HAS_ROLE = "GH_HasRole" HAS_BASE_ROLE = "GH_HasBaseRole" ADD_MEMBER = "GH_AddMember" @@ -48,6 +50,13 @@ HAS_SECRET_SCANNING_ALERT = "GH_HasSecretScanningAlert" VALID_TOKEN = "GH_ValidToken" HAS_WORKFLOW = "GH_HasWorkflow" +HAS_JOB = "GH_HasJob" +HAS_STEP = "GH_HasStep" +DEPENDS_ON = "GH_DependsOn" +DEPLOYS_TO = "GH_DeploysTo" +CALLS_WORKFLOW = "GH_CallsWorkflow" +USES_SECRET = "GH_UsesSecret" +USES_VARIABLE = "GH_UsesVariable" HAS_ENVIRONMENT = "GH_HasEnvironment" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index 4eb9355..ce75797 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -14,6 +14,7 @@ # Identity nodes EXTERNAL_IDENTITY = "GH_ExternalIdentity" SAML_IDENTITY_PROVIDER = "GH_SamlIdentityProvider" +ENTERPRISE_MANAGED_USER = "GH_EnterpriseManagedUser" # Organization nodes ORGANIZATION = "GH_Organization" @@ -54,3 +55,11 @@ # Workflow nodes WORKFLOW = "GH_Workflow" +WORKFLOW_JOB = "GH_WorkflowJob" +WORKFLOW_STEP = "GH_WorkflowStep" + + +# Enterprise nodes +ENTERPRISE = "GH_Enterprise" +ENTERPRISE_TEAM = "GH_EnterpriseTeam" +ENTERPRISE_ROLE = "GH_EnterpriseRole" diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index caae50b..e2673c0 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -17,6 +17,13 @@ def org_id(self) -> str | None: ) return res + @lru_cache + def org_id_for_login(self, org_login: str) -> str | None: + return self._find_single_object( + f"""SELECT node_id FROM {self.schema}.organizations WHERE login = ?""", + [org_login], + ) + @lru_cache def org_login(self) -> str | None: res = self._find_single_object( @@ -24,18 +31,44 @@ def org_login(self) -> str | None: ) return res + @lru_cache + def enterprise_id(self) -> str | None: + res = self._find_single_object(f"""SELECT id FROM {self.schema}.enterprise""") + return res + + @lru_cache + def org_login_for_id(self, org_node_id: str) -> str | None: + return self._find_single_object( + f"""SELECT login FROM {self.schema}.organizations WHERE node_id = ?""", + [org_node_id], + ) + @lru_cache def repository_node_ids(self): return self._find_all_objects( f"""SELECT node_id FROM {self.schema}.repositories""", ) + @lru_cache + def repository_node_ids_for_org(self, org_login: str): + return self._find_all_objects( + f"""SELECT node_id FROM {self.schema}.repositories WHERE org_login = ?""", + [org_login], + ) + @lru_cache def private_repository_node_ids(self): return self._find_all_objects( f"""SELECT node_id FROM {self.schema}.repositories WHERE visibility = 'private' or visibility = 'internal'""", ) + @lru_cache + def private_repository_node_ids_for_org(self, org_login: str): + return self._find_all_objects( + f"""SELECT node_id FROM {self.schema}.repositories WHERE org_login = ? AND (visibility = 'private' or visibility = 'internal')""", + [org_login], + ) + @lru_cache def idp(self) -> list: return self._find_all_objects( @@ -43,10 +76,23 @@ def idp(self) -> list: ) @lru_cache - def app_node_id(self, app_slug: str) -> str | None: + def idp_for_org(self, org_login: str) -> list: + return self._find_all_objects( + f"""SELECT id, issuer, sso_url FROM {self.schema}.saml_provider WHERE org_login = ?""", + [org_login], + ) + + @lru_cache + def app_node_id(self, app_slug: str, org_login: str | None = None) -> str | None: + if org_login is None: + return self._find_single_object( + f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ?""", + [app_slug], + ) + return self._find_single_object( - f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ?""", - [app_slug], + f"""SELECT node_id FROM {self.schema}.applications WHERE slug = ? AND org_login = ?""", + [app_slug, org_login], ) @lru_cache @@ -78,10 +124,6 @@ def role_can_create_branch(self, role_id: str, repository_node_id: str): [role_id, repository_node_id], ) - # ROLES TO HERE - - # USERS/TEAMS FROM HERE - ## GH_BypassPullRequestAllowances @lru_cache def bypass_pull_request_allowances(self, actor_id: str): """Returns the node_ids of users/teams that bypass PR review requirements on branches in a repository (GH_BypassPullRequestAllowances)""" @@ -191,3 +233,63 @@ def _write_admin_bypass(self, repo_node_id: str): """, [repo_node_id], ) + + @lru_cache + def org_secret(self, secret_name: str, org_login: str): + return self._find_single_object( + f""" + SELECT name FROM {self.schema}.organization_secrets + WHERE name = ? AND org_login = ? + """, + [secret_name, org_login], + ) + + @lru_cache + def repo_secret(self, secret_name: str, repository_id: str): + return self._find_single_object( + f""" + SELECT name FROM {self.schema}.repository_secrets + WHERE name = ? AND repository_node_id = ? + """, + [secret_name, repository_id], + ) + + @lru_cache + def environment_secret(self, secret_name: str, repository_id: str): + return self._find_single_object( + f""" + SELECT name FROM {self.schema}.environment_secrets + WHERE name = ? AND repository_node_id = ? + """, + [secret_name, repository_id], + ) + + @lru_cache + def org_variable(self, var_name: str, org_login: str): + return self._find_single_object( + f""" + SELECT name FROM {self.schema}.organization_variables + WHERE name = ? AND org_login = ? + """, + [var_name, org_login], + ) + + @lru_cache + def repo_variable(self, var_name: str, repository_id: str): + return self._find_single_object( + f""" + SELECT name FROM {self.schema}.repository_variables + WHERE name = ? AND repository_node_id = ? + """, + [var_name, repository_id], + ) + + @lru_cache + def environment_variable(self, var_name: str, repository_id: str): + return self._find_single_object( + f""" + SELECT name FROM {self.schema}.environment_variables + WHERE name = ? AND repository_node_id = ? + """, + [var_name, repository_id], + ) diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index efe85bd..d6d3b67 100644 --- a/src/openhound_github/main.py +++ b/src/openhound_github/main.py @@ -42,8 +42,8 @@ def convert(ctx: CollectContext) -> Tuple[DltSource, dict]: def preproc(ctx: PreProcContext): """Build a DuckDB lookup database from collected data. - Loads `organizations` and `repositories` tables so the converter can - resolve cross-table references (org node IDs, repo visibility) without + Loads lookup tables so the converter can resolve cross-table references + (org node IDs, repo visibility, workflow secrets/variables) without re-reading the full dataset. Run before convert: @@ -60,4 +60,15 @@ def preproc(ctx: PreProcContext): "repo_roles": "repo_roles", "saml_provider": "saml_provider", "applications": "applications", + "enterprise": "enterprise", + "environment_secrets": "environment_secrets", + "environment_variables": "environment_variables", + "organization_secrets": "organization_secrets", + "organization_variables": "organization_variables", + "repository_secrets": "repository_secrets", + "repository_variables": "repository_variables", + "selected_organization_secrets": "selected_organization_secrets", + "selected_organization_variables": "selected_organization_variables", + "workflow_jobs": "workflow_jobs", + "workflow_steps": "workflow_steps", } diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index b4b1c1d..db79256 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -4,6 +4,22 @@ from .branch_pr_bypass_allowance import BranchPrBypassAllowance from .branch_protection_rule import BranchProtectionRule, BranchProtectionRuleActor from .branch_push_allowance import BranchPushAllowance +from .enterprise import Enterprise +from .enterprise_admin import EnterpriseAdmin +from .enterprise_external_identity import EnterpriseExternalIdentity +from .enterprise_helpers import enterprise_role_node_id, enterprise_team_node_id +from .enterprise_managed_user import EnterpriseManagedUser +from .enterprise_member import BaseUser, flatten_enterprise_member +from .enterprise_organization import EnterpriseOrganization +from .enterprise_role import EnterpriseRole +from .enterprise_role_team import EnterpriseRoleTeam +from .enterprise_role_user import EnterpriseRoleUser +from .enterprise_saml_provider import EnterpriseSamlProvider +from .enterprise_team import EnterpriseTeam +from .enterprise_team_member import EnterpriseTeamMember +from .enterprise_team_organization import EnterpriseTeamOrganization +from .enterprise_team_role import EnterpriseTeamRole +from .enterprise_user import EnterpriseUser from .env_secret import EnvironmentSecret from .env_variable import EnvironmentVariable from .environment import Environment @@ -32,6 +48,8 @@ from .team_role import TeamRole from .user import User from .workflow import Workflow +from .workflow_job import WorkflowJob +from .workflow_step import WorkflowStep __all__ = [ "Organization", @@ -52,6 +70,8 @@ "RepositoryQL", "BranchPushAllowance", "Workflow", + "WorkflowJob", + "WorkflowStep", "Environment", "EnvironmentSecret", "EnvironmentVariable", @@ -78,4 +98,26 @@ "ExternalIdentity", "ScimResource", "RepoRoleAssignment", + "Environment", + "EnvironmentSecret", + "EnvironmentVariable", + "EnvironmentBranchPolicy", + "Enterprise", + "EnterpriseAdmin", + "EnterpriseExternalIdentity", + "EnterpriseManagedUser", + "EnterpriseOrganization", + "EnterpriseRole", + "EnterpriseRoleTeam", + "EnterpriseRoleUser", + "EnterpriseSamlProvider", + "EnterpriseTeam", + "EnterpriseTeamMember", + "EnterpriseTeamOrganization", + "EnterpriseTeamRole", + "EnterpriseUser", + "BaseUser", + "enterprise_role_node_id", + "enterprise_team_node_id", + "flatten_enterprise_member", ] diff --git a/src/openhound_github/models/actions_permission.py b/src/openhound_github/models/actions_permission.py index 591609d..d90ed24 100644 --- a/src/openhound_github/models/actions_permission.py +++ b/src/openhound_github/models/actions_permission.py @@ -4,5 +4,8 @@ class ActionPermission(BaseModel): enabled_repositories: str allowed_actions: str - selected_actions_url: str + selected_actions_url: str | None = None sha_pinning_required: bool | None = None + + # Additional + org_login: str diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index f411a4a..7b92afb 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -15,79 +15,45 @@ @dataclass class GHAppInstallationProperties(GHNodeProperties): - """App installation properties and accordion panel queries.""" - - id: int | None = field( - default=None, metadata={"description": "The GitHub installation ID."} - ) - app_id: int | None = field( - default=None, - metadata={ - "description": "The GitHub App's numeric ID (shared across all installations of the same app)." - }, - ) - app_slug: str = field( - default="", metadata={"description": "The app's URL-friendly slug identifier."} - ) - description: str | None = field( - default=None, metadata={"description": "The app's description."} - ) - html_url: str | None = field( - default=None, metadata={"description": "URL to the app's GitHub page."} - ) - access_tokens_url: str | None = field( - default=None, - metadata={"description": "API URL to create installation access tokens."}, - ) - repositories_url: str | None = field( - default=None, - metadata={ - "description": "API URL to list repositories accessible to this installation." - }, - ) - repository_selection: str | None = field( - default=None, - metadata={ - "description": "Whether the app has access to `all` repositories or `selected` repositories." - }, - ) - target_type: str | None = field( - default=None, - metadata={ - "description": "The target type of the installation (e.g., `Organization`)." - }, - ) - permissions: str | None = field( - default=None, - metadata={ - "description": 'JSON string of the permissions granted to the app (e.g., `{"contents": "read", "metadata": "read"}`).' - }, - ) - events: str | None = field( - default=None, - metadata={ - "description": "JSON string of the webhook events the app subscribes to." - }, - ) - created_at: datetime | None = field( - default=None, metadata={"description": "When the app was installed."} - ) - updated_at: datetime | None = field( - default=None, - metadata={"description": "When the installation was last updated."}, - ) - suspended_at: datetime | None = field( - default=None, - metadata={"description": "When the installation was suspended, if applicable."}, - ) - environment_name: str = field( - default="", - metadata={ - "description": "The name of the environment (GitHub organization) where the app is installed." - }, - ) - query_repositories: str = "" - query_app: str = "" + """App installation properties and accordion panel queries. + + Attributes: + id: The GitHub installation ID. + app_id: The GitHub App's numeric ID (shared across all installations of the same app). + app_slug: The app's URL-friendly slug identifier. + description: The app's description. + html_url: URL to the app's GitHub page. + access_tokens_url: API URL to create installation access tokens. + repositories_url: API URL to list repositories accessible to this installation. + repository_selection: Whether the app has access to `all` repositories or `selected` repositories. + target_type: The target type of the installation (e.g., `Organization`). + permissions: JSON string of the permissions granted to the app (e.g., `{"contents": "read", "metadata": "read"}`). + events: JSON string of the webhook events the app subscribes to. + created_at: When the app was installed. + updated_at: When the installation was last updated. + suspended_at: When the installation was suspended, if applicable. + environment_name: The name of the environment (GitHub organization) where the app is installed. + query_repositories: Query for repositories. + query_app: Query for app. + """ + + id: int | None = None + app_id: int | None = None + app_slug: str | None = None + description: str | None = None + html_url: str | None = None + access_tokens_url: str | None = None + repositories_url: str | None = None + repository_selection: str | None = None + target_type: str | None = None + permissions: str | None = None + events: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + suspended_at: datetime | None = None + environment_name: str | None = None + query_repositories: str | None = None + query_app: str | None = None class Account(BaseModel): @@ -145,10 +111,17 @@ class AppInstallation(BaseAsset): description: str | None = None access_tokens_url: str | None = None + # Additional + org_login: str + @property def node_id(self): return f"GH_AppInstallation_{self.id}" + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def as_node(self) -> GHNode: slug = self.app_slug or str(self.app_id) @@ -174,8 +147,8 @@ def as_node(self) -> GHNode: created_at=self.created_at, updated_at=self.updated_at, suspended_at=self.suspended_at, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_repositories=f"MATCH p=(:GH_AppInstallation {{node_id:'{self.node_id}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p LIMIT 1000", query_app=f"MATCH p=(:GH_App)-[:GH_InstalledAs]->(:GH_AppInstallation {{node_id:'{self.node_id}'}}) RETURN p", ), @@ -184,7 +157,7 @@ def as_node(self) -> GHNode: @property def _app_edges(self): if self.app_slug: - app_node_id = self._lookup.app_node_id(self.app_slug) + app_node_id = self._lookup.app_node_id(self.app_slug, self.org_login) if app_node_id: yield Edge( kind=ek.INSTALLED_AS, @@ -196,7 +169,9 @@ def _app_edges(self): @property def _can_access_edges(self): if self.repository_selection == "all": - for (repo_node_id,) in self._lookup.repository_node_ids(): + for (repo_node_id,) in self._lookup.repository_node_ids_for_org( + self.org_login + ): yield Edge( kind=ek.CAN_ACCESS, start=EdgePath(value=self.node_id, match_by="id"), @@ -208,7 +183,7 @@ def _can_access_edges(self): def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self._lookup.org_id(), match_by="id"), + start=EdgePath(value=self.org_node_id, match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) @@ -219,63 +194,39 @@ def edges(self): @dataclass class GHAppProperties(GHNodeProperties): - """App definition properties and accordion panel queries.""" - - id: int | None = field( - default=None, metadata={"description": "The GitHub App's numeric ID."} - ) - client_id: str | None = field( - default=None, metadata={"description": "The app's OAuth client ID."} - ) - slug: str = field( - default="", metadata={"description": "The app's URL-friendly slug identifier."} - ) - description: str | None = field( - default=None, metadata={"description": "The app's description."} - ) - external_url: str | None = field( - default=None, metadata={"description": "The app's external homepage URL."} - ) - html_url: str | None = field( - default=None, metadata={"description": "URL to the app's GitHub page."} - ) - owner_login: str | None = field( - default=None, - metadata={ - "description": "The login of the user or organization that owns the app." - }, - ) - owner_node_id: str | None = field( - default=None, - metadata={ - "description": "The node_id of the user or organization that owns the app." - }, - ) - owner_type: str | None = field( - default=None, - metadata={ - "description": "The type of the owner (e.g., `User`, `Organization`)." - }, - ) - events: list[str] | None = field( - default=None, - metadata={ - "description": "JSON string of the default webhook events the app subscribes to." - }, - ) - installations_count: int | None = field( - default=None, - metadata={ - "description": "The total number of installations of this app across all organizations." - }, - ) - created_at: datetime | None = field( - default=None, metadata={"description": "When the app was created."} - ) - updated_at: datetime | None = field( - default=None, metadata={"description": "When the app was last updated."} - ) - query_installations: str = "" + """App definition properties and accordion panel queries. + + Attributes: + id: The GitHub App's numeric ID. + client_id: The app's OAuth client ID. + slug: The app's URL-friendly slug identifier. + description: The app's description. + external_url: The app's external homepage URL. + html_url: URL to the app's GitHub page. + owner_login: The login of the user or organization that owns the app. + owner_node_id: The node_id of the user or organization that owns the app. + owner_type: The type of the owner (e.g., `User`, `Organization`). + events: JSON string of the default webhook events the app subscribes to. + installations_count: The total number of installations of this app across all organizations. + created_at: When the app was created. + updated_at: When the app was last updated. + query_installations: Query for installations. + """ + + id: int | None = None + client_id: str | None = None + slug: str | None = None + description: str | None = None + external_url: str | None = None + html_url: str | None = None + owner_login: str | None = None + owner_node_id: str | None = None + owner_type: str | None = None + events: list[str] | None = None + installations_count: int | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + query_installations: str | None = None class Owner(BaseModel): @@ -312,6 +263,16 @@ class App(BaseAsset): created_at: datetime | None = None updated_at: datetime | None = None + # Additional + org_login: str + collected_org_node_id: str | None = Field(default=None, alias="org_node_id") + + @property + def org_node_id(self) -> str | None: + if self.collected_org_node_id: + return self.collected_org_node_id + return self._lookup.org_id_for_login(self.org_login) + @property def as_node(self): aid = self.node_id @@ -321,7 +282,7 @@ def as_node(self): name=self.name, displayname=self.name, node_id=aid, - environmentid=self._lookup.org_id(), + environmentid=self.org_node_id, client_id=self.client_id, slug=self.slug, description=self.description, @@ -331,8 +292,8 @@ def as_node(self): # owner_node_id=self.owner_node_id, # owner_type=self.owner_type, # permissions=self.permissions, - events=self.events, # installations_count=self.installations_count, + events=self.events, created_at=self.created_at, updated_at=self.updated_at, query_installations=f"MATCH p=(:GH_App {{node_id:'{aid}'}})-[:GH_InstalledAs]->(:GH_AppInstallation) RETURN p", diff --git a/src/openhound_github/models/branch.py b/src/openhound_github/models/branch.py index 4c6c290..4de85cb 100644 --- a/src/openhound_github/models/branch.py +++ b/src/openhound_github/models/branch.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -12,27 +12,27 @@ @dataclass class GHBranchProperties(GHNodeProperties): - """Branch-specific properties and accordion panel queries.""" + """Branch-specific properties and accordion panel queries. - collected: bool = field( - default=True, metadata={"description": "Collected/generated by OpenHound"} - ) - short_name: str = field( - default="", - metadata={"description": "The branch reference name (e.g., `main`)."}, - ) + Attributes: + collected: Collected/generated by OpenHound + short_name: The branch reference name (e.g., `main`). + commit_hash: The commit hash property. + protected: Whether the branch has a protection rule. + repository_name: The repository name property. + repository_id: The repository id property. + environment_name: The name of the environment (GitHub organization). + query_branch_write: Query for branch write. + """ + + collected: bool = True + short_name: str | None = None commit_hash: str | None = None - protected: bool = field( - default=False, - metadata={"description": "Whether the branch has a protection rule."}, - ) - repository_name: str = "" - repository_id: str = "" - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - query_branch_write: str = "" + protected: bool = False + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + query_branch_write: str | None = None class ProtectionRule(BaseModel): @@ -76,6 +76,13 @@ class Branch(BaseAsset): alias="branchProtectionRule", default=None ) + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: """The ID is identical to a node_id when using GraphQL""" @@ -94,8 +101,8 @@ def as_node(self) -> GHNode: protected=self.branch_protection_rule is not None, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_branch_write=( f"MATCH p=(:GH_User)-[:GH_CanWriteBranch|GH_CanEditAndWriteBranch]" f"->(:GH_Branch {{node_id:'{self.node_id}'}}) RETURN p" diff --git a/src/openhound_github/models/branch_protection_rule.py b/src/openhound_github/models/branch_protection_rule.py index 8ed6740..89b6e3f 100644 --- a/src/openhound_github/models/branch_protection_rule.py +++ b/src/openhound_github/models/branch_protection_rule.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ClassVar from dlt.common.libs.pydantic import DltConfig @@ -23,89 +23,49 @@ class BranchProtectionRuleActor(BaseModel): @dataclass class GHBranchProtectionRuleProperties(GHNodeProperties): - """Branch protection rule properties and accordion panel queries.""" - - pattern: str = field( - default="", - metadata={ - "description": "The branch name pattern this rule applies to (e.g., `main`, `release/*`)." - }, - ) - repository_name: str = "" - repository_id: str = "" - environment_name: str = field( - default="", metadata={"description": "The GitHub organization login name."} - ) - enforce_admins: bool | None = field( - default=None, - metadata={ - "description": "Whether branch protection rules are enforced for administrators." - }, - ) - lock_branch: bool | None = field( - default=None, - metadata={"description": "Whether the branch is locked (read-only)."}, - ) - blocks_creations: bool | None = field( - default=None, - metadata={ - "description": "Whether creating branches matching this pattern is restricted. Only effective when `push_restrictions` is also `true`; silently reverts to `false` otherwise." - }, - ) - required_pull_request_reviews: bool | None = field( - default=None, - metadata={ - "description": "Whether pull request reviews are required before merging." - }, - ) - required_approving_review_count: int | None = field( - default=None, - metadata={"description": "The number of approving reviews required."}, - ) - require_code_owner_reviews: bool | None = field( - default=None, - metadata={"description": "Whether reviews from code owners are required."}, - ) - require_last_push_approval: bool | None = field( - default=None, - metadata={ - "description": "Whether the last push must be approved by someone other than the pusher." - }, - ) - push_restrictions: bool | None = field( - default=None, - metadata={ - "description": "Whether push access is restricted to specific users/teams." - }, - ) - requires_status_checks: bool | None = field( - default=None, - metadata={"description": "Whether status checks must pass before merging."}, - ) - requires_strict_status_checks: bool | None = field( - default=None, - metadata={ - "description": "Whether branches must be up to date with the base branch before merging." - }, - ) - dismisses_stale_reviews: bool | None = field( - default=None, - metadata={ - "description": "Whether new commits dismiss previously approved reviews." - }, - ) - allows_force_pushes: bool | None = field( - default=None, - metadata={ - "description": "Whether force pushes are allowed to matching branches." - }, - ) - allows_deletions: bool | None = field( - default=None, - metadata={"description": "Whether matching branches can be deleted."}, - ) - query_user_exceptions: str = "" - query_branches: str = "" + """Branch protection rule properties and accordion panel queries. + + Attributes: + pattern: The branch name pattern this rule applies to (e.g., `main`, `release/*`). + repository_name: The repository name property. + repository_id: The repository id property. + environment_name: The GitHub organization login name. + enforce_admins: Whether branch protection rules are enforced for administrators. + lock_branch: Whether the branch is locked (read-only). + blocks_creations: Whether creating branches matching this pattern is restricted. Only effective when `push_restrictions` is also `true`; silently reverts to `false` otherwise. + required_pull_request_reviews: Whether pull request reviews are required before merging. + required_approving_review_count: The number of approving reviews required. + require_code_owner_reviews: Whether reviews from code owners are required. + require_last_push_approval: Whether the last push must be approved by someone other than the pusher. + push_restrictions: Whether push access is restricted to specific users/teams. + requires_status_checks: Whether status checks must pass before merging. + requires_strict_status_checks: Whether branches must be up to date with the base branch before merging. + dismisses_stale_reviews: Whether new commits dismiss previously approved reviews. + allows_force_pushes: Whether force pushes are allowed to matching branches. + allows_deletions: Whether matching branches can be deleted. + query_user_exceptions: Query for user exceptions. + query_branches: Query for branches. + """ + + pattern: str | None = None + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + enforce_admins: bool | None = None + lock_branch: bool | None = None + blocks_creations: bool | None = None + required_pull_request_reviews: bool | None = None + required_approving_review_count: int | None = None + require_code_owner_reviews: bool | None = None + require_last_push_approval: bool | None = None + push_restrictions: bool | None = None + requires_status_checks: bool | None = None + requires_strict_status_checks: bool | None = None + dismisses_stale_reviews: bool | None = None + allows_force_pushes: bool | None = None + allows_deletions: bool | None = None + query_user_exceptions: str | None = None + query_branches: str | None = None class Actor(BaseModel): @@ -173,9 +133,14 @@ class BranchProtectionRule(BaseAsset): push_allowances: PushAllowances | None = Field(alias="pushAllowances", default=None) # Additional + org_login: str repository_node_id: str repository_name: str + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: return self.id @@ -191,8 +156,8 @@ def as_node(self) -> GHNode: pattern=self.pattern, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, enforce_admins=self.is_admin_enforced, lock_branch=self.lock_branch, blocks_creations=self.blocks_creations, diff --git a/src/openhound_github/models/enterprise.py b/src/openhound_github/models/enterprise.py new file mode 100644 index 0000000..dc905ff --- /dev/null +++ b/src/openhound_github/models/enterprise.py @@ -0,0 +1,110 @@ +from dataclasses import dataclass +from typing import ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import BaseAsset, NodeDef +from pydantic import ConfigDict, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +@dataclass +class GHEnterpriseProperties(GHNodeProperties): + """Properties for a GitHub enterprise. + + Attributes: + collected: Whether this node was collected directly. + slug: The enterprise slug. + enterprise_name: The enterprise display name. + description: The enterprise description. + location: The enterprise location. + url: The enterprise GitHub URL. + website_url: The enterprise website URL. + created_at: When the enterprise was created. + updated_at: When the enterprise was last updated. + billing_email: The enterprise billing email. + security_contact_email: The enterprise security contact email. + viewer_is_admin: Whether the authenticated viewer is an enterprise admin. + environment_name: The enterprise environment name. + query_organizations: Query for contained organizations. + """ + + collected: bool = True + slug: str | None = None + enterprise_name: str | None = None + description: str | None = None + location: str | None = None + url: str | None = None + website_url: str | None = None + created_at: str | None = None + updated_at: str | None = None + billing_email: str | None = None + security_contact_email: str | None = None + viewer_is_admin: bool | None = None + environment_name: str | None = None + query_organizations: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.ENTERPRISE, + description="GitHub Enterprise", + icon="globe", + properties=GHEnterpriseProperties, + ), +) +class Enterprise(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + id: str + database_id: int | None = Field(alias="databaseId", default=None) + name: str | None = None + slug: str + description: str | None = None + location: str | None = None + url: str | None = None + website_url: str | None = Field(alias="websiteUrl", default=None) + created_at: str | None = Field(alias="createdAt", default=None) + updated_at: str | None = Field(alias="updatedAt", default=None) + billing_email: str | None = Field(alias="billingEmail", default=None) + security_contact_email: str | None = Field( + alias="securityContactEmail", default=None + ) + viewer_is_admin: bool | None = Field(alias="viewerIsAdmin", default=None) + organizations: dict | None = Field(default_factory=dict) + + @property + def node_id(self) -> str: + return self.id + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.ENTERPRISE], + properties=GHEnterpriseProperties( + name=self.slug, + displayname=self.name or self.slug, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.slug, + slug=self.slug, + enterprise_name=self.name, + description=self.description, + location=self.location, + url=self.url, + website_url=self.website_url, + created_at=self.created_at, + updated_at=self.updated_at, + billing_email=self.billing_email, + security_contact_email=self.security_contact_email, + viewer_is_admin=self.viewer_is_admin, + query_organizations=f"MATCH p=(:GH_Enterprise {{node_id:'{self.node_id}'}})-[:GH_Contains]->(:GH_Organization) RETURN p", + ), + ) + + @property + def edges(self): + return [] diff --git a/src/openhound_github/models/enterprise_admin.py b/src/openhound_github/models/enterprise_admin.py new file mode 100644 index 0000000..6afa24c --- /dev/null +++ b/src/openhound_github/models/enterprise_admin.py @@ -0,0 +1,21 @@ +from openhound.core.asset import EdgeDef + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_role_user import EnterpriseRoleUser + + +@app.asset( + edges=[ + EdgeDef( + start=nk.USER, + end=nk.ENTERPRISE_ROLE, + kind=ek.HAS_ROLE, + description="Enterprise admin has owners role", + traversable=True, + ), + ], +) +class EnterpriseAdmin(EnterpriseRoleUser): + pass diff --git a/src/openhound_github/models/enterprise_external_identity.py b/src/openhound_github/models/enterprise_external_identity.py new file mode 100644 index 0000000..c3e2692 --- /dev/null +++ b/src/openhound_github/models/enterprise_external_identity.py @@ -0,0 +1,202 @@ +from dataclasses import dataclass + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import ( + ConditionalEdgePath, + Edge, + EdgePath, + EdgeProperties, + PropertyMatch, +) +from pydantic import BaseModel, ConfigDict, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_saml_provider import EnterpriseSamlProvider + + +class EnterpriseIdentityName(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + family_name: str | None = Field(alias="familyName", default=None) + given_name: str | None = Field(alias="givenName", default=None) + name_id: str | None = Field(alias="nameId", default=None) + username: str | None = None + + +class EnterpriseIdentityUser(BaseModel): + id: str + login: str + + +@dataclass +class GHEnterpriseExternalIdentityProperties(GHNodeProperties): + """Properties for an enterprise external identity. + + Attributes: + guid: The external identity GUID. + saml_identity_username: The SAML username. + saml_identity_name_id: The SAML NameID. + saml_identity_given_name: The SAML given name. + saml_identity_family_name: The SAML family name. + scim_identity_username: The SCIM username. + scim_identity_given_name: The SCIM given name. + scim_identity_family_name: The SCIM family name. + github_username: The mapped GitHub username. + github_user_id: The mapped GitHub user node ID. + environment_name: The enterprise environment name. + query_mapped_users: Query for mapped users. + """ + + guid: str | None = None + saml_identity_username: str | None = None + saml_identity_name_id: str | None = None + saml_identity_given_name: str | None = None + saml_identity_family_name: str | None = None + scim_identity_username: str | None = None + scim_identity_given_name: str | None = None + scim_identity_family_name: str | None = None + github_username: str | None = None + github_user_id: str | None = None + environment_name: str | None = None + query_mapped_users: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.EXTERNAL_IDENTITY, + description="GitHub Enterprise External Identity", + icon="arrows-left-right", + properties=GHEnterpriseExternalIdentityProperties, + ), + edges=[ + EdgeDef( + start=nk.SAML_IDENTITY_PROVIDER, + end=nk.EXTERNAL_IDENTITY, + kind=ek.HAS_EXTERNAL_IDENTITY, + description="Enterprise IdP has external identity", + traversable=False, + ), + EdgeDef( + start=nk.EXTERNAL_IDENTITY, + end=nk.USER, + kind=ek.MAPS_TO_USER, + description="External identity maps to GitHub user", + traversable=False, + ), + ], +) +class EnterpriseExternalIdentity(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + guid: str | None = None + id: str + saml_identity: EnterpriseIdentityName | None = Field( + alias="samlIdentity", default=None + ) + scim_identity: EnterpriseIdentityName | None = Field( + alias="scimIdentity", default=None + ) + user: EnterpriseIdentityUser | None = None + saml_provider_id: str + saml_provider_issuer: str | None = None + saml_provider_sso_url: str | None = None + enterprise_node_id: str + enterprise_slug: str + + @property + def node_id(self) -> str: + return self.id + + @property + def foreign_user(self) -> tuple[str | None, str | None]: + return EnterpriseSamlProvider.detect_foreign_environment( + self.saml_provider_issuer, self.saml_provider_sso_url + ) + + @property + def foreign_username(self) -> str | None: + if self.saml_identity and self.saml_identity.username: + return self.saml_identity.username + if self.scim_identity and self.scim_identity.username: + return self.scim_identity.username + return None + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.EXTERNAL_IDENTITY], + properties=GHEnterpriseExternalIdentityProperties( + name=self.guid or self.node_id, + displayname=self.guid or self.node_id, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + guid=self.guid, + saml_identity_username=self.saml_identity.username + if self.saml_identity + else None, + saml_identity_name_id=self.saml_identity.name_id + if self.saml_identity + else None, + saml_identity_given_name=self.saml_identity.given_name + if self.saml_identity + else None, + saml_identity_family_name=self.saml_identity.family_name + if self.saml_identity + else None, + scim_identity_username=self.scim_identity.username + if self.scim_identity + else None, + scim_identity_given_name=self.scim_identity.given_name + if self.scim_identity + else None, + scim_identity_family_name=self.scim_identity.family_name + if self.scim_identity + else None, + github_username=self.user.login if self.user else None, + github_user_id=self.user.id if self.user else None, + query_mapped_users=f"MATCH p=(:GH_ExternalIdentity {{node_id:'{self.node_id}'}})-[:GH_MapsToUser]->() RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.HAS_EXTERNAL_IDENTITY, + start=EdgePath(value=self.saml_provider_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + if self.user and self.user.id: + yield Edge( + kind=ek.MAPS_TO_USER, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=self.user.id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + foreign_kind, _ = self.foreign_user + if foreign_kind and self.foreign_username: + match = PropertyMatch(key="name", value=self.foreign_username) + yield Edge( + kind=ek.MAPS_TO_USER, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=foreign_kind, + property_matchers=[match], + ), + properties=EdgeProperties(traversable=False), + ) + if self.user and self.user.id: + yield Edge( + kind=ek.SYNCED_TO_GH_USER, + start=ConditionalEdgePath( + kind=foreign_kind, + property_matchers=[match], + ), + end=EdgePath(value=self.user.id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) diff --git a/src/openhound_github/models/enterprise_helpers.py b/src/openhound_github/models/enterprise_helpers.py new file mode 100644 index 0000000..2b382ac --- /dev/null +++ b/src/openhound_github/models/enterprise_helpers.py @@ -0,0 +1,6 @@ +def enterprise_team_node_id(enterprise_id: str, team_id: str | int) -> str: + return f"GH_EnterpriseTeam_{enterprise_id}_{team_id}" + + +def enterprise_role_node_id(enterprise_id: str, role_id: str | int) -> str: + return f"GH_EnterpriseRole_{enterprise_id}_{role_id}" diff --git a/src/openhound_github/models/enterprise_managed_user.py b/src/openhound_github/models/enterprise_managed_user.py new file mode 100644 index 0000000..129a9e2 --- /dev/null +++ b/src/openhound_github/models/enterprise_managed_user.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass +from datetime import datetime + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import BaseModel, ConfigDict, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +@dataclass +class GHEnterpriseManagedUserProperties(GHNodeProperties): + """Properties for a GitHub enterprise managed user wrapper. + + Attributes: + login: The managed user login. + full_name: The managed user display name. + url: The managed user URL. + created_at: When the managed user was created. + updated_at: When the managed user was last updated. + github_user_id: The backing GitHub user ID. + github_username: The backing GitHub username. + environment_name: The enterprise environment name. + query_enterprises: Query for enterprises containing this managed user. + query_mapped_user: Query for the backing GitHub user. + """ + + login: str | None = None + full_name: str | None = None + url: str | None = None + created_at: str | None = None + updated_at: str | None = None + github_user_id: str | None = None + github_username: str | None = None + environment_name: str | None = None + query_enterprises: str | None = None + query_mapped_user: str | None = None + + +class EnterpriseManagedBackingUser(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + database_id: int | None = Field(alias="databaseId", default=None) + login: str + name: str | None = None + email: str | None = None + company: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.ENTERPRISE_MANAGED_USER, + description="GitHub enterprise managed user", + icon="user-lock", + properties=GHEnterpriseManagedUserProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.ENTERPRISE_MANAGED_USER, + kind=ek.HAS_MEMBER, + description="Enterprise has enterprise managed user member", + traversable=False, + ), + EdgeDef( + start=nk.ENTERPRISE_MANAGED_USER, + end=nk.USER, + kind=ek.MAPS_TO_USER, + description="Enterprise managed user maps to GitHub user", + traversable=False, + ), + ], +) +class EnterpriseManagedUser(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + login: str + name: str | None = None + url: str | None = None + created_at: datetime | None = Field(alias="createdAt", default=None) + updated_at: datetime | None = Field(alias="updatedAt", default=None) + user: EnterpriseManagedBackingUser | None = None + enterprise_node_id: str | None = None + enterprise_slug: str + + @property + def node_id(self) -> str: + return self.id + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.ENTERPRISE_MANAGED_USER], + properties=GHEnterpriseManagedUserProperties( + name=self.login, + displayname=self.name or self.login, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + login=self.login, + full_name=self.name, + url=self.url, + created_at=self.created_at, + updated_at=self.updated_at, + github_user_id=self.user.id if self.user else None, + github_username=self.user.login if self.user else None, + query_enterprises=f"MATCH p=(:GH_Enterprise)-[:GH_HasMember]->(:GH_EnterpriseManagedUser {{node_id:'{self.node_id}'}}) RETURN p", + query_mapped_user=f"MATCH p=(:GH_EnterpriseManagedUser {{node_id:'{self.node_id}'}})-[:GH_MapsToUser]->(:GH_User) RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.HAS_MEMBER, + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + if self.user and self.user.id: + yield Edge( + kind=ek.MAPS_TO_USER, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=self.user.id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/enterprise_member.py b/src/openhound_github/models/enterprise_member.py new file mode 100644 index 0000000..08c2c18 --- /dev/null +++ b/src/openhound_github/models/enterprise_member.py @@ -0,0 +1,63 @@ +from datetime import datetime +from typing import ClassVar + +from dlt.common.libs.pydantic import DltConfig +from pydantic import BaseModel, Field + + +class EmbeddedUser(BaseModel): + id: str + database_id: int = Field(alias="databaseId") + login: str + name: str | None = None + email: str + company: str | None = None + + +class BaseUser(BaseModel): + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + typename: str = Field(alias="__typename") + id: str + login: str + name: str | None = None + url: str | None = None + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + user: EmbeddedUser | None = None + + +def flatten_enterprise_member( + member: dict, enterprise_node_id: str, enterprise_slug: str +) -> tuple[dict | None, dict | None]: + if member.get("__typename") == "EnterpriseUserAccount": + managed_user = { + **member, + "enterprise_node_id": enterprise_node_id, + "enterprise_slug": enterprise_slug, + } + user = member.get("user") + if user and user.get("id"): + return ( + { + **user, + "enterprise_node_id": enterprise_node_id, + "enterprise_slug": enterprise_slug, + "has_direct_enterprise_membership": False, + }, + managed_user, + ) + return None, managed_user + + if member.get("id"): + return ( + { + **member, + "enterprise_node_id": enterprise_node_id, + "enterprise_slug": enterprise_slug, + "has_direct_enterprise_membership": True, + }, + None, + ) + + return None, None diff --git a/src/openhound_github/models/enterprise_organization.py b/src/openhound_github/models/enterprise_organization.py new file mode 100644 index 0000000..0abca03 --- /dev/null +++ b/src/openhound_github/models/enterprise_organization.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +@dataclass +class GHEnterpriseOrganizationProperties(GHNodeProperties): + """Properties for an enterprise-discovered organization stub. + + Attributes: + collected: Whether this organization was collected in full. + login: The organization login. + environment_name: The organization environment name. + query_enterprise: Query for the containing enterprise. + """ + + collected: bool = False + login: str | None = None + environment_name: str | None = None + query_enterprise: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.ORGANIZATION, + description="GitHub Organization discovered from an enterprise", + icon="building", + properties=GHEnterpriseOrganizationProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.ORGANIZATION, + kind=ek.CONTAINS, + description="Enterprise contains organization", + traversable=False, + ), + ], +) +class EnterpriseOrganization(BaseAsset): + id: str + login: str + enterprise_node_id: str + enterprise_slug: str + + @property + def node_id(self) -> str: + return self.id + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.ORGANIZATION], + properties=GHEnterpriseOrganizationProperties( + name=self.login, + displayname=self.login, + node_id=self.node_id, + environmentid=self.node_id, + environment_name=self.login, + login=self.login, + collected=False, + query_enterprise=f"MATCH p=(:GH_Enterprise {{node_id:'{self.enterprise_node_id}'}})-[:GH_Contains]->(:GH_Organization {{node_id:'{self.node_id}'}}) RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/enterprise_role.py b/src/openhound_github/models/enterprise_role.py new file mode 100644 index 0000000..5700056 --- /dev/null +++ b/src/openhound_github/models/enterprise_role.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from typing import ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import ConfigDict, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import enterprise_role_node_id + + +@dataclass +class GHEnterpriseRoleProperties(GHNodeProperties): + """Properties for a GitHub enterprise role. + + Attributes: + github_role_id: The raw GitHub role ID. + short_name: The role short name. + description: The role description. + source: The role source. + type: The role type. + created_at: When the role was created. + updated_at: When the role was last updated. + permissions: Raw enterprise permission strings. + environment_name: The enterprise environment name. + query_enterprise: Query for the containing enterprise. + query_explicit_members: Query for direct user members. + query_team_members: Query for team-assigned members. + """ + + github_role_id: str | int | None = None + short_name: str | None = None + description: str | None = None + source: str | None = None + type: str | None = None + created_at: str | None = None + updated_at: str | None = None + permissions: list[str] | None = None + environment_name: str | None = None + query_enterprise: str | None = None + query_explicit_members: str | None = None + query_team_members: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.ENTERPRISE_ROLE, + description="GitHub Enterprise Role", + icon="user-tie", + properties=GHEnterpriseRoleProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.ENTERPRISE_ROLE, + kind=ek.CONTAINS, + description="Enterprise contains role", + traversable=False, + ), + ], +) +class EnterpriseRole(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + id: str | int + name: str + description: str | None = None + source: str | None = None + created_at: str | None = None + updated_at: str | None = None + permissions: list[str] = Field(default_factory=list) + enterprise_node_id: str + enterprise_slug: str + + @property + def node_id(self) -> str: + return enterprise_role_node_id(self.enterprise_node_id, self.id) + + @property + def role_type(self) -> str: + return "default" if self.source in {"Predefined", "Default"} else "custom" + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.ENTERPRISE_ROLE, "GH_Role"], + properties=GHEnterpriseRoleProperties( + name=f"{self.enterprise_slug}/{self.name}", + displayname=self.name, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + github_role_id=self.id, + short_name=self.name, + description=self.description, + source=self.source, + type=self.role_type, + created_at=self.created_at, + updated_at=self.updated_at, + permissions=self.permissions, + query_enterprise=f"MATCH p=(:GH_Enterprise {{node_id:'{self.enterprise_node_id}'}})-[:GH_Contains]->(:GH_EnterpriseRole {{node_id:'{self.node_id}'}}) RETURN p", + query_explicit_members=f"MATCH p=(:GH_User)-[:GH_HasRole]->(:GH_EnterpriseRole {{node_id:'{self.node_id}'}}) RETURN p", + query_team_members=f"MATCH p=(:GH_User)-[:GH_HasRole]->(:GH_TeamRole)-[:GH_MemberOf]->(:GH_EnterpriseTeam)-[:GH_HasRole]->(:GH_EnterpriseRole {{node_id:'{self.node_id}'}}) RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/enterprise_role_team.py b/src/openhound_github/models/enterprise_role_team.py new file mode 100644 index 0000000..ab8ae6e --- /dev/null +++ b/src/openhound_github/models/enterprise_role_team.py @@ -0,0 +1,47 @@ +from openhound.core.asset import BaseAsset, EdgeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import ( + enterprise_role_node_id, + enterprise_team_node_id, +) + + +@app.asset( + edges=[ + EdgeDef( + start=nk.ENTERPRISE_TEAM, + end=nk.ENTERPRISE_ROLE, + kind=ek.HAS_ROLE, + description="Enterprise team has enterprise role", + traversable=True, + ), + ], +) +class EnterpriseRoleTeam(BaseAsset): + id: int + role_id: int | str + enterprise_node_id: str + enterprise_slug: str + + @property + def as_node(self) -> None: + return None + + @property + def edges(self): + yield Edge( + kind=ek.HAS_ROLE, + start=EdgePath( + value=enterprise_team_node_id(self.enterprise_node_id, self.id), + match_by="id", + ), + end=EdgePath( + value=enterprise_role_node_id(self.enterprise_node_id, self.role_id), + match_by="id", + ), + properties=EdgeProperties(traversable=True), + ) diff --git a/src/openhound_github/models/enterprise_role_user.py b/src/openhound_github/models/enterprise_role_user.py new file mode 100644 index 0000000..347d71a --- /dev/null +++ b/src/openhound_github/models/enterprise_role_user.py @@ -0,0 +1,45 @@ +from openhound.core.asset import BaseAsset, EdgeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import enterprise_role_node_id + + +@app.asset( + edges=[ + EdgeDef( + start=nk.USER, + end=nk.ENTERPRISE_ROLE, + kind=ek.HAS_ROLE, + description="User has enterprise role", + traversable=True, + ), + ], +) +class EnterpriseRoleUser(BaseAsset): + node_id: str + login: str | None = None + assignment: str | None = None + role_id: int | str + enterprise_node_id: str + enterprise_slug: str + + @property + def as_node(self) -> None: + return None + + @property + def role_node_id(self) -> str: + return enterprise_role_node_id(self.enterprise_node_id, self.role_id) + + @property + def edges(self): + if self.assignment == "direct" and self.node_id: + yield Edge( + kind=ek.HAS_ROLE, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=self.role_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) diff --git a/src/openhound_github/models/enterprise_saml_provider.py b/src/openhound_github/models/enterprise_saml_provider.py new file mode 100644 index 0000000..4a30ed0 --- /dev/null +++ b/src/openhound_github/models/enterprise_saml_provider.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from typing import ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import ConfigDict, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +@dataclass +class GHEnterpriseSamlProviderProperties(GHNodeProperties): + """Properties for an enterprise SAML identity provider. + + Attributes: + issuer: The SAML issuer. + sso_url: The SAML SSO URL. + signature_method: The SAML signature method. + digest_method: The SAML digest method. + idp_certificate: The IdP certificate. + foreign_environment_id: The correlated foreign environment ID. + environment_name: The enterprise environment name. + query_environments: Query for owning environments. + query_external_identities: Query for external identities. + """ + + issuer: str | None = None + sso_url: str | None = None + signature_method: str | None = None + digest_method: str | None = None + idp_certificate: str | None = None + foreign_environment_id: str | None = None + environment_name: str | None = None + query_environments: str | None = None + query_external_identities: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.SAML_IDENTITY_PROVIDER, + description="GitHub Enterprise SAML Identity Provider", + icon="id-badge", + properties=GHEnterpriseSamlProviderProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.SAML_IDENTITY_PROVIDER, + kind=ek.HAS_SAML_IDENTITY_PROVIDER, + description="Enterprise uses this SAML IdP", + traversable=False, + ), + ], +) +class EnterpriseSamlProvider(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + issuer: str | None = None + sso_url: str | None = Field(alias="ssoUrl", default=None) + digest_method: str | None = Field(alias="digestMethod", default=None) + signature_method: str | None = Field(alias="signatureMethod", default=None) + idp_certificate: str | None = Field(alias="idpCertificate", default=None) + enterprise_node_id: str + enterprise_slug: str + + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + @property + def node_id(self) -> str: + return self.id + + @staticmethod + def detect_foreign_environment( + issuer: str | None, sso_url: str | None + ) -> tuple[str | None, str | None]: + if not issuer: + return None, None + if issuer.startswith("https://auth.pingone.com/"): + return "PingOneUser", issuer.split("/")[3] + if issuer.startswith("https://sts.windows.net/"): + return "AZUser", issuer.split("/")[3] + if issuer.startswith("http://www.okta.com/"): + return "Okta_User", sso_url.split("/")[2] if sso_url else None + return None, None + + @property + def as_node(self) -> GHNode: + _, foreign_environment_id = self.detect_foreign_environment( + self.issuer, self.sso_url + ) + return GHNode( + kinds=[nk.SAML_IDENTITY_PROVIDER], + properties=GHEnterpriseSamlProviderProperties( + name=self.node_id, + displayname=self.enterprise_slug, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + issuer=self.issuer, + sso_url=self.sso_url, + signature_method=self.signature_method, + digest_method=self.digest_method, + idp_certificate=self.idp_certificate, + foreign_environment_id=foreign_environment_id, + query_environments=f"MATCH p=(:GH_SamlIdentityProvider {{node_id:'{self.node_id}'}})<-[:GH_HasSamlIdentityProvider]-(:GH_Enterprise) RETURN p", + query_external_identities=f"MATCH p=(:GH_SamlIdentityProvider {{node_id:'{self.node_id}'}})-[:GH_HasExternalIdentity]->() RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.HAS_SAML_IDENTITY_PROVIDER, + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/enterprise_team.py b/src/openhound_github/models/enterprise_team.py new file mode 100644 index 0000000..7b283a1 --- /dev/null +++ b/src/openhound_github/models/enterprise_team.py @@ -0,0 +1,116 @@ +from dataclasses import dataclass +from typing import ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import ConfigDict + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import enterprise_team_node_id + + +@dataclass +class GHEnterpriseTeamProperties(GHNodeProperties): + """Properties for a GitHub enterprise team. + + Attributes: + github_team_id: The raw GitHub enterprise team ID. + slug: The enterprise team slug. + projected_slug: The organization-projected team slug. + group_id: The linked SCIM group ID. + description: The team description. + created_at: When the team was created. + updated_at: When the team was last updated. + environment_name: The enterprise environment name. + query_enterprise: Query for the containing enterprise. + query_assigned_organizations: Query for assigned organizations. + query_projected_teams: Query for projected organization teams. + query_members: Query for team members. + """ + + github_team_id: str | int | None = None + slug: str | None = None + projected_slug: str | None = None + group_id: str | None = None + description: str | None = None + created_at: str | None = None + updated_at: str | None = None + environment_name: str | None = None + query_enterprise: str | None = None + query_assigned_organizations: str | None = None + query_projected_teams: str | None = None + query_members: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.ENTERPRISE_TEAM, + description="GitHub Enterprise Team", + icon="users-between-lines", + properties=GHEnterpriseTeamProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.ENTERPRISE_TEAM, + kind=ek.CONTAINS, + description="Enterprise contains team", + traversable=False, + ), + ], +) +class EnterpriseTeam(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + id: int + name: str + slug: str + group_id: str | None = None + description: str | None = None + created_at: str | None = None + updated_at: str | None = None + enterprise_node_id: str + enterprise_slug: str + + @property + def node_id(self) -> str: + return enterprise_team_node_id(self.enterprise_node_id, self.id) + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.ENTERPRISE_TEAM], + properties=GHEnterpriseTeamProperties( + name=self.name, + displayname=self.name, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + github_team_id=self.id, + slug=self.slug, + projected_slug=self.slug, + group_id=self.group_id, + description=self.description, + created_at=self.created_at, + updated_at=self.updated_at, + query_enterprise=f"MATCH p=(:GH_Enterprise {{node_id:'{self.enterprise_node_id}'}})-[:GH_Contains]->(:GH_EnterpriseTeam {{node_id:'{self.node_id}'}}) RETURN p", + query_assigned_organizations=f"MATCH p=(:GH_EnterpriseTeam {{node_id:'{self.node_id}'}})-[:GH_AssignedTo]->(:GH_Organization) RETURN p", + query_projected_teams=f"MATCH p=(:GH_EnterpriseTeam {{node_id:'{self.node_id}'}})-[:GH_MemberOf]->(:GH_Team) RETURN p", + query_members=f"MATCH p=(:GH_User)-[:GH_HasRole]->(:GH_TeamRole)-[:GH_MemberOf]->(:GH_EnterpriseTeam {{node_id:'{self.node_id}'}}) RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.CONTAINS, + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/enterprise_team_member.py b/src/openhound_github/models/enterprise_team_member.py new file mode 100644 index 0000000..5ad2760 --- /dev/null +++ b/src/openhound_github/models/enterprise_team_member.py @@ -0,0 +1,45 @@ +from openhound.core.asset import BaseAsset, EdgeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import enterprise_team_node_id + + +@app.asset( + edges=[ + EdgeDef( + start=nk.USER, + end=nk.TEAM_ROLE, + kind=ek.HAS_ROLE, + description="User has enterprise team role", + traversable=True, + ), + ], +) +class EnterpriseTeamMember(BaseAsset): + node_id: str + login: str | None = None + team_id: int + enterprise_node_id: str + enterprise_slug: str + + @property + def as_node(self) -> None: + return None + + @property + def role_node_id(self) -> str: + return ( + f"{enterprise_team_node_id(self.enterprise_node_id, self.team_id)}_members" + ) + + @property + def edges(self): + yield Edge( + kind=ek.HAS_ROLE, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=self.role_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) diff --git a/src/openhound_github/models/enterprise_team_organization.py b/src/openhound_github/models/enterprise_team_organization.py new file mode 100644 index 0000000..45ec9b5 --- /dev/null +++ b/src/openhound_github/models/enterprise_team_organization.py @@ -0,0 +1,69 @@ +from openhound.core.asset import BaseAsset, EdgeDef +from openhound.core.models.entries_dataclass import ( + ConditionalEdgePath, + Edge, + EdgePath, + EdgeProperties, + PropertyMatch, +) + +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import enterprise_team_node_id + + +@app.asset( + edges=[ + EdgeDef( + start=nk.ENTERPRISE_TEAM, + end=nk.ORGANIZATION, + kind=ek.ASSIGNED_TO, + description="Enterprise team is assigned to organization", + traversable=False, + ), + EdgeDef( + start=nk.ENTERPRISE_TEAM, + end=nk.TEAM, + kind=ek.MEMBER_OF, + description="Enterprise team maps to projected organization team", + traversable=True, + ), + ], +) +class EnterpriseTeamOrganization(BaseAsset): + node_id: str + login: str | None = None + team_id: int + projected_slug: str + enterprise_node_id: str + enterprise_slug: str + + @property + def as_node(self) -> None: + return None + + @property + def enterprise_team_node_id(self) -> str: + return enterprise_team_node_id(self.enterprise_node_id, self.team_id) + + @property + def edges(self): + yield Edge( + kind=ek.ASSIGNED_TO, + start=EdgePath(value=self.enterprise_team_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + yield Edge( + kind=ek.MEMBER_OF, + start=EdgePath(value=self.enterprise_team_node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.TEAM, + property_matchers=[ + PropertyMatch(key="environmentid", value=self.node_id.upper()), + PropertyMatch(key="slug", value=self.projected_slug), + ], + ), + properties=EdgeProperties(traversable=True), + ) diff --git a/src/openhound_github/models/enterprise_team_role.py b/src/openhound_github/models/enterprise_team_role.py new file mode 100644 index 0000000..3c67d1e --- /dev/null +++ b/src/openhound_github/models/enterprise_team_role.py @@ -0,0 +1,97 @@ +from dataclasses import dataclass + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app +from openhound_github.models.enterprise_helpers import enterprise_team_node_id + + +@dataclass +class GHEnterpriseTeamRoleProperties(GHNodeProperties): + """Properties for an enterprise team role. + + Attributes: + enterpriseid: The containing enterprise node ID. + team_name: The team display name. + team_id: The enterprise team node ID. + short_name: The role short name. + type: The role type. + environment_name: The enterprise environment name. + query_team: Query for the team. + query_members: Query for role members. + """ + + enterpriseid: str | None = None + team_name: str | None = None + team_id: str | None = None + short_name: str | None = None + type: str | None = None + environment_name: str | None = None + query_team: str | None = None + query_members: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.TEAM_ROLE, + description="GitHub Enterprise Team Role", + icon="user-tie", + properties=GHEnterpriseTeamRoleProperties, + ), + edges=[ + EdgeDef( + start=nk.TEAM_ROLE, + end=nk.ENTERPRISE_TEAM, + kind=ek.MEMBER_OF, + description="Enterprise team role belongs to enterprise team", + traversable=True, + ), + ], +) +class EnterpriseTeamRole(BaseAsset): + id: int + name: str + slug: str + enterprise_node_id: str + enterprise_slug: str + + @property + def enterprise_team_node_id(self) -> str: + return enterprise_team_node_id(self.enterprise_node_id, self.id) + + @property + def node_id(self) -> str: + return f"{self.enterprise_team_node_id}_members" + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.TEAM_ROLE, "GH_Role"], + properties=GHEnterpriseTeamRoleProperties( + name=f"{self.enterprise_slug}/{self.slug}/members", + displayname="members", + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + enterpriseid=self._lookup.enterprise_id(), + team_name=self.name, + team_id=self.enterprise_team_node_id, + short_name="members", + type="team", + query_team=f"MATCH p=(:GH_TeamRole {{node_id:'{self.node_id}'}})-[:GH_MemberOf]->(:GH_EnterpriseTeam {{node_id:'{self.enterprise_team_node_id}'}}) RETURN p", + query_members=f"MATCH p=(:GH_User)-[:GH_HasRole]->(:GH_TeamRole {{node_id:'{self.node_id}'}}) RETURN p", + ), + ) + + @property + def edges(self): + yield Edge( + kind=ek.MEMBER_OF, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=self.enterprise_team_node_id, match_by="id"), + properties=EdgeProperties(traversable=True), + ) diff --git a/src/openhound_github/models/enterprise_user.py b/src/openhound_github/models/enterprise_user.py new file mode 100644 index 0000000..049db91 --- /dev/null +++ b/src/openhound_github/models/enterprise_user.py @@ -0,0 +1,96 @@ +from dataclasses import dataclass + +from openhound.core.asset import BaseAsset, EdgeDef, NodeDef +from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +from pydantic import ConfigDict, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +@dataclass +class GHEnterpriseUserProperties(GHNodeProperties): + """Properties for a GitHub enterprise member user. + + Attributes: + collected: Whether this user was collected directly. + login: The GitHub login. + full_name: The user's display name. + company: The user's company. + email: The user's public email. + environment_name: The enterprise environment name. + query_enterprises: Query for enterprise memberships. + """ + + collected: bool = True + login: str | None = None + full_name: str | None = None + company: str | None = None + email: str | None = None + environment_name: str | None = None + query_enterprises: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.USER, + description="GitHub enterprise member user", + icon="user", + properties=GHEnterpriseUserProperties, + ), + edges=[ + EdgeDef( + start=nk.ENTERPRISE, + end=nk.USER, + kind=ek.HAS_MEMBER, + description="Enterprise has user member", + traversable=False, + ), + ], +) +class EnterpriseUser(BaseAsset): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + database_id: int | None = Field(alias="databaseId", default=None) + login: str + name: str | None = None + email: str | None = None + company: str | None = None + enterprise_node_id: str | None = None + enterprise_slug: str + has_direct_enterprise_membership: bool = True + + @property + def node_id(self) -> str: + return self.id + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.USER], + properties=GHEnterpriseUserProperties( + name=self.login, + displayname=self.name or self.login, + node_id=self.node_id, + environmentid=self._lookup.enterprise_id(), + environment_name=self.enterprise_slug, + login=self.login, + full_name=self.name, + company=self.company, + email=self.email, + query_enterprises=f"MATCH p=(:GH_Enterprise)-[:GH_HasMember]->(:GH_User {{node_id:'{self.node_id}'}}) RETURN p UNION MATCH p=(:GH_Enterprise)-[:GH_HasMember]->(:GH_EnterpriseManagedUser)-[:GH_MapsToUser]->(:GH_User {{node_id:'{self.node_id}'}}) RETURN p", + ), + ) + + @property + def edges(self): + if self.has_direct_enterprise_membership: + yield Edge( + kind=ek.HAS_MEMBER, + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) diff --git a/src/openhound_github/models/env_secret.py b/src/openhound_github/models/env_secret.py index c10cf69..3945db1 100644 --- a/src/openhound_github/models/env_secret.py +++ b/src/openhound_github/models/env_secret.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from openhound.core.asset import BaseAsset, EdgeDef, NodeDef @@ -12,30 +12,25 @@ @dataclass class GHEnvironmentSecretProperties(GHNodeProperties): - """Environment secret properties.""" + """Environment secret properties. + + Attributes: + deployment_environment_name: The name of the containing deployment environment. + deployment_environmentid: The node_id of the containing deployment environment. + repository_name: The repository name property. + repository_id: The repository id property. + environment_name: The name of the environment (GitHub organization). + created_at: When the secret was created. + updated_at: When the secret was last updated. + """ - deployment_environment_name: str = field( - default="", - metadata={"description": "The name of the containing deployment environment."}, - ) - deployment_environmentid: str = field( - default="", - metadata={ - "description": "The node_id of the containing deployment environment." - }, - ) - repository_name: str = "" - repository_id: str = "" - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - created_at: str | None = field( - default=None, metadata={"description": "When the secret was created."} - ) - updated_at: str | None = field( - default=None, metadata={"description": "When the secret was last updated."} - ) + deployment_environment_name: str | None = None + deployment_environmentid: str | None = None + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + created_at: str | None = None + updated_at: str | None = None @app.asset( @@ -65,11 +60,16 @@ class EnvironmentSecret(BaseAsset): selected_repositories_url: str | None = None # Additional + org_login: str repository_name: str repository_node_id: str environment_name: str environment_node_id: str + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: return f"GH_EnvironmentSecret_{self.environment_node_id}_{self.name}" @@ -87,8 +87,8 @@ def as_node(self) -> GHNode: deployment_environmentid=self.environment_node_id or "", repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, ), diff --git a/src/openhound_github/models/env_variable.py b/src/openhound_github/models/env_variable.py index 794a1d8..d9cfd80 100644 --- a/src/openhound_github/models/env_variable.py +++ b/src/openhound_github/models/env_variable.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from openhound.core.asset import BaseAsset, EdgeDef, NodeDef @@ -14,24 +14,23 @@ @dataclass class GHEnvVariableProperties(GHNodeProperties): - environment_name: str = field( - metadata={"description": "The name of the environment (GitHub organization)."} - ) - deployment_environment_name: str = field( - metadata={ - "description": "The name of the deployment environment (GitHub organization)." - } - ) - value: str = field(metadata={"description": "The plaintext value of the variable."}) - created_at: datetime | None = field( - metadata={"description": "When the variable was created."} - ) - updated_at: datetime | None = field( - metadata={"description": "When the variable was last updated."} - ) - repository_name: str = field( - metadata={"description": "The name of the containing repository."} - ) + """Properties for GHEnvVariableProperties. + + Attributes: + environment_name: The name of the environment (GitHub organization). + deployment_environment_name: The name of the deployment environment (GitHub organization). + value: The plaintext value of the variable. + created_at: When the variable was created. + updated_at: When the variable was last updated. + repository_name: The name of the containing repository. + """ + + environment_name: str + deployment_environment_name: str + value: str + created_at: datetime | None + updated_at: datetime | None + repository_name: str @app.asset( @@ -60,11 +59,16 @@ class EnvironmentVariable(BaseAsset): updated_at: datetime | None = None # Additional + org_login: str environment_node_id: str environment_name: str repository_name: str repository_node_id: str + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: return f"GH_EnvironmentVariable_{self.environment_node_id}_{self.name}" @@ -79,9 +83,9 @@ def as_node(self) -> GHNode: displayname=self.name, node_id=vid, deployment_environment_name=self.environment_name, - environment_name=self._lookup.org_login(), + environment_name=self.org_login, repository_name=self.repository_name, - environmentid=self._lookup.org_id(), + environmentid=self.org_node_id, updated_at=self.updated_at, created_at=self.created_at, value=self.value, diff --git a/src/openhound_github/models/environment.py b/src/openhound_github/models/environment.py index f1ce79d..7191be1 100644 --- a/src/openhound_github/models/environment.py +++ b/src/openhound_github/models/environment.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ClassVar from dlt.common.libs.pydantic import DltConfig @@ -70,34 +70,23 @@ class ProtectionRule(BaseModel): @dataclass class GHEnvironmentProperties(GHNodeProperties): - """Environment-specific properties and accordion panel queries.""" - - collected: bool = field( - default=True, metadata={"description": "Collected/generated by OpenHound"} - ) - short_name: str = field( - default="", - metadata={ - "description": "The environment's display name (e.g., `production`, `staging`)." - }, - ) - can_admins_bypass: bool | None = field( - default=None, - metadata={ - "description": "Whether repository administrators can bypass environment protection rules." - }, - ) - repository_name: str = field( - default="", - metadata={"description": "The full name of the containing repository."}, - ) - repository_id: str = field( - default="", metadata={"description": "The ID of the containing repository."} - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) + """Environment-specific properties and accordion panel queries. + + Attributes: + collected: Collected/generated by OpenHound + short_name: The environment's display name (e.g., `production`, `staging`). + can_admins_bypass: Whether repository administrators can bypass environment protection rules. + repository_name: The full name of the containing repository. + repository_id: The ID of the containing repository. + environment_name: The name of the environment (GitHub organization). + """ + + collected: bool = True + short_name: str | None = None + can_admins_bypass: bool | None = None + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None @app.asset( @@ -137,10 +126,15 @@ class Environment(BaseAsset): deployment_branch_policy: DeploymentBranchPolicy | None = None # Custom fields added + org_login: str repository_name: str repository_full_name: str repository_node_id: str + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def has_custom_branch_policies(self) -> bool: if self.deployment_branch_policy: @@ -160,8 +154,8 @@ def as_node(self) -> GHNode: # can_admins_bypass=self.can_admins_bypass, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, ), ) diff --git a/src/openhound_github/models/environment_branch_policy.py b/src/openhound_github/models/environment_branch_policy.py index 071bab1..5a8bb00 100644 --- a/src/openhound_github/models/environment_branch_policy.py +++ b/src/openhound_github/models/environment_branch_policy.py @@ -30,6 +30,11 @@ class EnvironmentBranchPolicy(BaseAsset): environment_name: str repository_name: str repository_node_id: str + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def policy_id(self) -> str: diff --git a/src/openhound_github/models/external_identity.py b/src/openhound_github/models/external_identity.py index d369fe7..8dbe5f1 100644 --- a/src/openhound_github/models/external_identity.py +++ b/src/openhound_github/models/external_identity.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import ( @@ -24,46 +24,35 @@ @dataclass class GHExternalIdentityProperties(GHNodeProperties): - """External identity properties and accordion panel queries.""" + """External identity properties and accordion panel queries. - guid: str | None = field( - default=None, metadata={"description": "The GUID of the external identity."} - ) - saml_identity_username: str | None = field( - default=None, metadata={"description": "The username from the SAML identity."} - ) - saml_identity_name_id: str | None = field( - default=None, metadata={"description": "The SAML NameID attribute."} - ) - saml_identity_given_name: str | None = field( - default=None, metadata={"description": "The given name from the SAML identity."} - ) - saml_identity_family_name: str | None = field( - default=None, - metadata={"description": "The family name from the SAML identity."}, - ) - scim_identity_username: str | None = field( - default=None, metadata={"description": "The username from the SCIM identity."} - ) - scim_identity_given_name: str | None = field( - default=None, metadata={"description": "The given name from the SCIM identity."} - ) - scim_identity_family_name: str | None = field( - default=None, - metadata={"description": "The family name from the SCIM identity."}, - ) - github_username: str | None = field( - default=None, metadata={"description": "The GitHub login of the linked user."} - ) - github_user_id: str | None = field( - default=None, - metadata={"description": "The GraphQL ID of the linked GitHub user."}, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - query_mapped_users: str = "" + Attributes: + guid: The GUID of the external identity. + saml_identity_username: The username from the SAML identity. + saml_identity_name_id: The SAML NameID attribute. + saml_identity_given_name: The given name from the SAML identity. + saml_identity_family_name: The family name from the SAML identity. + scim_identity_username: The username from the SCIM identity. + scim_identity_given_name: The given name from the SCIM identity. + scim_identity_family_name: The family name from the SCIM identity. + github_username: The GitHub login of the linked user. + github_user_id: The GraphQL ID of the linked GitHub user. + environment_name: The name of the environment (GitHub organization). + query_mapped_users: Query for mapped users. + """ + + guid: str | None = None + saml_identity_username: str | None = None + saml_identity_name_id: str | None = None + saml_identity_given_name: str | None = None + saml_identity_family_name: str | None = None + scim_identity_username: str | None = None + scim_identity_given_name: str | None = None + scim_identity_family_name: str | None = None + github_username: str | None = None + github_user_id: str | None = None + environment_name: str | None = None + query_mapped_users: str | None = None class SCIMIdentity(BaseModel): @@ -118,7 +107,7 @@ class User(BaseModel): class ExternalIdentity(BaseAsset): """One record from `external_identities` → one GH_ExternalIdentity node + mapping edges.""" - model_config = ConfigDict(extra="allow", populate_by_name=True) + model_config = ConfigDict(populate_by_name=True) guid: str id: str @@ -126,6 +115,13 @@ class ExternalIdentity(BaseAsset): scim_identity: SCIMIdentity | None = Field(alias="scimIdentity") user: User | None = None + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: return self.id @@ -163,8 +159,8 @@ def as_node(self) -> GHNode: else None, github_username=self.user.login if self.user else None, github_user_id=self.user.id if self.user else None, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_mapped_users=f"MATCH p=(:GH_ExternalIdentity {{node_id:'{self.node_id.upper()}'}})-[:GH_MapsToUser]->() RETURN p", ), ) @@ -187,7 +183,9 @@ def detect_foreign_idp( @property def idp(self) -> dict: - ext_idp = self._lookup.idp() + ext_idp = self._lookup.idp_for_org(self.org_login) + if not ext_idp: + return {"id": None, "issuer": None, "sso_url": None} id, issuer, sso_url = ext_idp[0] return { "id": id, diff --git a/src/openhound_github/models/org.py b/src/openhound_github/models/org.py index 9ab87e4..91bd3be 100644 --- a/src/openhound_github/models/org.py +++ b/src/openhound_github/models/org.py @@ -1,5 +1,7 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass +from typing import ClassVar +from dlt.common.libs.pydantic import DltConfig from openhound.core.asset import BaseAsset, NodeDef from openhound.core.models.entries_dataclass import Edge @@ -10,277 +12,148 @@ @dataclass class GHOrganizationProperties(GHNodeProperties): - """Organization-specific properties and accordion panel queries.""" + """Organization-specific properties and accordion panel queries. + + Attributes: + login: The organization's login handle (URL slug). + org_name: The organization's display name (from the `name` field in the GitHub API). + description: The organization's description. + company: The company associated with the organization. + blog: The organization's blog URL. + location: The organization's location. + email: The organization's public email address. + is_verified: Whether the organization's domain is verified by GitHub. + has_organization_projects: Whether the organization has projects enabled. + has_repository_projects: Whether repository projects are enabled. + public_repos: Number of public repositories in the organization. + public_gists: Number of public gists. + followers: Number of followers the organization has. + following: Number of accounts the organization is following. + html_url: URL to the organization's GitHub profile page. + created_at: When the organization was created. + updated_at: When the organization was last updated. + type: The account type (e.g., `Organization`). + total_private_repos: Total number of private repositories. + owned_private_repos: Number of private repositories owned directly by the organization. + private_gists: Number of private gists. + collaborators: Number of outside collaborators across the organization. + environment_name: The name of the environment (GitHub organization). + default_repository_permission: Default permission level granted to members on all repositories (e.g., `read`, `write`, `admin`, `none`). Used to associate the Members org role with the appropriate `all_repo_*` role node. + members_can_create_repositories: Whether members can create repositories. + two_factor_requirement_enabled: Whether two-factor authentication is required for all members. + members_can_create_public_repositories: Whether members can create public repositories. + members_can_create_private_repositories: Whether members can create private repositories. + members_can_create_internal_repositories: Whether members can create internal repositories. + members_can_create_pages: Whether members can create GitHub Pages sites. + members_can_fork_private_repositories: Whether members can fork private repositories. + web_commit_signoff_required: Whether web-based commits require sign-off. + deploy_keys_enabled_for_repositories: Which repositories allow deploy keys. + members_can_delete_repositories: Whether members can delete repositories. + members_can_change_repo_visibility: Whether members can change repository visibility. + members_can_invite_outside_collaborators: Whether members can invite outside collaborators. + members_can_delete_issues: Whether members can delete issues. + display_commenter_full_name_setting_enabled: Whether commenter full names are displayed. + readers_can_create_discussions: Whether readers can create discussions. + members_can_create_teams: Whether members can create teams. + members_can_view_dependency_insights: Whether members can view dependency insights. + default_repository_branch: The default branch name for new repositories. + members_can_create_public_pages: Whether members can create public GitHub Pages sites. + members_can_create_private_pages: Whether members can create private GitHub Pages sites. + advanced_security_enabled_for_new_repositories: Whether GitHub Advanced Security is automatically enabled for new repositories. + dependabot_alerts_enabled_for_new_repositories: Whether Dependabot alerts are enabled for new repositories. + dependabot_security_updates_enabled_for_new_repositories: Whether Dependabot security updates are enabled for new repositories. + dependency_graph_enabled_for_new_repositories: Whether the dependency graph is enabled for new repositories. + secret_scanning_enabled_for_new_repositories: Whether secret scanning is enabled for new repositories. + secret_scanning_push_protection_enabled_for_new_repositories: Whether secret scanning push protection is enabled for new repositories. + secret_scanning_push_protection_custom_link_enabled: Whether a custom link is enabled for secret scanning push protection. + secret_scanning_push_protection_custom_link: The custom link for secret scanning push protection. + secret_scanning_validity_checks_enabled: Whether secret scanning validity checks are enabled. + actions_enabled_repositories: Which repositories have GitHub Actions enabled: `all`, `selected`, or `none`. + actions_allowed_actions: Which Actions are allowed to run: `all`, `local_only`, or `selected`. + actions_sha_pinning_required: Whether SHA pinning is required for GitHub Actions. + self_hosted_runners_enabled_repositories: Which repositories may use self-hosted runners: `all`, `selected`, or `none`. + default_workflow_permissions: The default workflow permissions property. + can_approve_pull_request_reviews: The can approve pull request reviews property. + query_organization_roles: Query for organization roles. + query_users: Query for users. + query_teams: Query for teams. + query_repositories: Query for repositories. + query_personal_access_tokens: Query for personal access tokens. + query_secret_scanning_alerts: Query for secret scanning alerts. + query_identity_provider: Query for identity provider. + query_app_installations: Query for app installations. + query_organization_secrets: Query for organization secrets. + collected: The collected property. + """ - login: str = field( - default="", - metadata={"description": "The organization's login handle (URL slug)."}, - ) - org_name: str = field( - default="", - metadata={ - "description": "The organization's display name (from the `name` field in the GitHub API)." - }, - ) - description: str | None = field( - default=None, metadata={"description": "The organization's description."} - ) - company: str = field( - default="", - metadata={"description": "The company associated with the organization."}, - ) - blog: str | None = field( - default=None, metadata={"description": "The organization's blog URL."} - ) - location: str | None = field( - default=None, metadata={"description": "The organization's location."} - ) - email: str | None = field( - default=None, - metadata={"description": "The organization's public email address."}, - ) - is_verified: bool | None = field( - default=None, - metadata={ - "description": "Whether the organization's domain is verified by GitHub." - }, - ) - has_organization_projects: bool | None = field( - default=None, - metadata={"description": "Whether the organization has projects enabled."}, - ) - has_repository_projects: bool | None = field( - default=None, - metadata={"description": "Whether repository projects are enabled."}, - ) - public_repos: int | None = field( - default=None, - metadata={"description": "Number of public repositories in the organization."}, - ) - public_gists: int | None = field( - default=None, metadata={"description": "Number of public gists."} - ) - followers: int | None = field( - default=None, - metadata={"description": "Number of followers the organization has."}, - ) - following: int | None = field( - default=None, - metadata={"description": "Number of accounts the organization is following."}, - ) - html_url: str | None = field( - default=None, - metadata={"description": "URL to the organization's GitHub profile page."}, - ) - created_at: str | None = field( - default=None, metadata={"description": "When the organization was created."} - ) - updated_at: str | None = field( - default=None, - metadata={"description": "When the organization was last updated."}, - ) - type: str | None = field( - default=None, - metadata={"description": "The account type (e.g., `Organization`)."}, - ) - total_private_repos: int | None = field( - default=None, metadata={"description": "Total number of private repositories."} - ) - owned_private_repos: int | None = field( - default=None, - metadata={ - "description": "Number of private repositories owned directly by the organization." - }, - ) - private_gists: int | None = field( - default=None, metadata={"description": "Number of private gists."} - ) - collaborators: int | None = field( - default=None, - metadata={ - "description": "Number of outside collaborators across the organization." - }, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - default_repository_permission: str | None = field( - default=None, - metadata={ - "description": "Default permission level granted to members on all repositories (e.g., `read`, `write`, `admin`, `none`). Used to associate the Members org role with the appropriate `all_repo_*` role node." - }, - ) - members_can_create_repositories: bool | None = field( - default=None, - metadata={"description": "Whether members can create repositories."}, - ) - two_factor_requirement_enabled: bool | None = field( - default=None, - metadata={ - "description": "Whether two-factor authentication is required for all members." - }, - ) - members_can_create_public_repositories: bool | None = field( - default=None, - metadata={"description": "Whether members can create public repositories."}, - ) - members_can_create_private_repositories: bool | None = field( - default=None, - metadata={"description": "Whether members can create private repositories."}, - ) - members_can_create_internal_repositories: bool | None = field( - default=None, - metadata={"description": "Whether members can create internal repositories."}, - ) - members_can_create_pages: bool | None = field( - default=None, - metadata={"description": "Whether members can create GitHub Pages sites."}, - ) - members_can_fork_private_repositories: bool | None = field( - default=None, - metadata={"description": "Whether members can fork private repositories."}, - ) - web_commit_signoff_required: bool | None = field( - default=None, - metadata={"description": "Whether web-based commits require sign-off."}, - ) - deploy_keys_enabled_for_repositories: bool | None = field( - default=None, metadata={"description": "Which repositories allow deploy keys."} - ) - members_can_delete_repositories: bool | None = field( - default=None, - metadata={"description": "Whether members can delete repositories."}, - ) - members_can_change_repo_visibility: bool | None = field( - default=None, - metadata={"description": "Whether members can change repository visibility."}, - ) - members_can_invite_outside_collaborators: bool | None = field( - default=None, - metadata={"description": "Whether members can invite outside collaborators."}, - ) - members_can_delete_issues: bool | None = field( - default=None, metadata={"description": "Whether members can delete issues."} - ) - display_commenter_full_name_setting_enabled: bool | None = field( - default=None, - metadata={"description": "Whether commenter full names are displayed."}, - ) - readers_can_create_discussions: bool | None = field( - default=None, - metadata={"description": "Whether readers can create discussions."}, - ) - members_can_create_teams: bool | None = field( - default=None, metadata={"description": "Whether members can create teams."} - ) - members_can_view_dependency_insights: bool | None = field( - default=None, - metadata={"description": "Whether members can view dependency insights."}, - ) - default_repository_branch: str | None = field( - default=None, - metadata={"description": "The default branch name for new repositories."}, - ) - members_can_create_public_pages: bool | None = field( - default=None, - metadata={ - "description": "Whether members can create public GitHub Pages sites." - }, - ) - members_can_create_private_pages: bool | None = field( - default=None, - metadata={ - "description": "Whether members can create private GitHub Pages sites." - }, - ) - advanced_security_enabled_for_new_repositories: bool | None = field( - default=None, - metadata={ - "description": "Whether GitHub Advanced Security is automatically enabled for new repositories." - }, - ) - dependabot_alerts_enabled_for_new_repositories: bool | None = field( - default=None, - metadata={ - "description": "Whether Dependabot alerts are enabled for new repositories." - }, - ) - dependabot_security_updates_enabled_for_new_repositories: bool | None = field( - default=None, - metadata={ - "description": "Whether Dependabot security updates are enabled for new repositories." - }, - ) - dependency_graph_enabled_for_new_repositories: bool | None = field( - default=None, - metadata={ - "description": "Whether the dependency graph is enabled for new repositories." - }, - ) - secret_scanning_enabled_for_new_repositories: bool | None = field( - default=None, - metadata={ - "description": "Whether secret scanning is enabled for new repositories." - }, - ) - secret_scanning_push_protection_enabled_for_new_repositories: bool | None = field( - default=None, - metadata={ - "description": "Whether secret scanning push protection is enabled for new repositories." - }, - ) - secret_scanning_push_protection_custom_link_enabled: bool | None = field( - default=None, - metadata={ - "description": "Whether a custom link is enabled for secret scanning push protection." - }, - ) - secret_scanning_push_protection_custom_link: str = field( - default="", - metadata={ - "description": "The custom link for secret scanning push protection." - }, - ) - secret_scanning_validity_checks_enabled: bool | None = field( - default=None, - metadata={ - "description": "Whether secret scanning validity checks are enabled." - }, - ) - actions_enabled_repositories: str | None = field( - default=None, - metadata={ - "description": "Which repositories have GitHub Actions enabled: `all`, `selected`, or `none`." - }, - ) - actions_allowed_actions: str | None = field( - default=None, - metadata={ - "description": "Which Actions are allowed to run: `all`, `local_only`, or `selected`." - }, - ) - actions_sha_pinning_required: bool | None = field( - default=None, - metadata={"description": "Whether SHA pinning is required for GitHub Actions."}, - ) - self_hosted_runners_enabled_repositories: str | None = field( - default=None, - metadata={ - "description": "Which repositories may use self-hosted runners: `all`, `selected`, or `none`." - }, - ) + login: str | None = None + org_name: str | None = None + description: str | None = None + company: str | None = None + blog: str | None = None + location: str | None = None + email: str | None = None + is_verified: bool | None = None + has_organization_projects: bool | None = None + has_repository_projects: bool | None = None + public_repos: int | None = None + public_gists: int | None = None + followers: int | None = None + following: int | None = None + html_url: str | None = None + created_at: str | None = None + updated_at: str | None = None + type: str | None = None + total_private_repos: int | None = None + owned_private_repos: int | None = None + private_gists: int | None = None + collaborators: int | None = None + environment_name: str | None = None + default_repository_permission: str | None = None + members_can_create_repositories: bool | None = None + two_factor_requirement_enabled: bool | None = None + members_can_create_public_repositories: bool | None = None + members_can_create_private_repositories: bool | None = None + members_can_create_internal_repositories: bool | None = None + members_can_create_pages: bool | None = None + members_can_fork_private_repositories: bool | None = None + web_commit_signoff_required: bool | None = None + deploy_keys_enabled_for_repositories: bool | None = None + members_can_delete_repositories: bool | None = None + members_can_change_repo_visibility: bool | None = None + members_can_invite_outside_collaborators: bool | None = None + members_can_delete_issues: bool | None = None + display_commenter_full_name_setting_enabled: bool | None = None + readers_can_create_discussions: bool | None = None + members_can_create_teams: bool | None = None + members_can_view_dependency_insights: bool | None = None + default_repository_branch: str | None = None + members_can_create_public_pages: bool | None = None + members_can_create_private_pages: bool | None = None + advanced_security_enabled_for_new_repositories: bool | None = None + dependabot_alerts_enabled_for_new_repositories: bool | None = None + dependabot_security_updates_enabled_for_new_repositories: bool | None = None + dependency_graph_enabled_for_new_repositories: bool | None = None + secret_scanning_enabled_for_new_repositories: bool | None = None + secret_scanning_push_protection_enabled_for_new_repositories: bool | None = None + secret_scanning_push_protection_custom_link_enabled: bool | None = None + secret_scanning_push_protection_custom_link: str | None = None + secret_scanning_validity_checks_enabled: bool | None = None + actions_enabled_repositories: str | None = None + actions_allowed_actions: str | None = None + actions_sha_pinning_required: bool | None = None + self_hosted_runners_enabled_repositories: str | None = None default_workflow_permissions: str | None = None can_approve_pull_request_reviews: bool | None = None - query_organization_roles: str = "" - query_users: str = "" - query_teams: str = "" - query_repositories: str = "" - query_personal_access_tokens: str = "" - query_secret_scanning_alerts: str = "" - query_identity_provider: str = "" - query_app_installations: str = "" - query_organization_secrets: str = "" + query_organization_roles: str | None = None + query_users: str | None = None + query_teams: str | None = None + query_repositories: str | None = None + query_personal_access_tokens: str | None = None + query_secret_scanning_alerts: str | None = None + query_identity_provider: str | None = None + query_app_installations: str | None = None + query_organization_secrets: str | None = None collected: bool = True @@ -295,6 +168,8 @@ class GHOrganizationProperties(GHNodeProperties): class Organization(BaseAsset): """One record from the `organizations` DLT table → one GH_Organization node.""" + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + node_id: str login: str name: str | None = None diff --git a/src/openhound_github/models/org_role.py b/src/openhound_github/models/org_role.py index c99fbc5..d401691 100644 --- a/src/openhound_github/models/org_role.py +++ b/src/openhound_github/models/org_role.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -46,26 +46,25 @@ class Organization(BaseModel): @dataclass class GHOrgRoleProperties(GHNodeProperties): - """Org role properties and accordion panel queries.""" + """Org role properties and accordion panel queries. + + Attributes: + short_name: The short display name of the role (e.g., `Owners`, `Members`, or the custom role name). + type: `default` for built-in roles (Owner, Member) or `custom` for custom organization roles. + environment_name: The name of the environment (GitHub organization). + query_explicit_members: Query for explicit members. + query_unrolled_members: Query for unrolled members. + query_org_permissions: Query for org permissions. + query_repo_permissions: Query for repo permissions. + """ - short_name: str = field( - metadata={ - "description": "The short display name of the role (e.g., `Owners`, `Members`, or the custom role name)." - } - ) - type: str = field( - metadata={ - "description": "`default` for built-in roles (Owner, Member) or `custom` for custom organization roles." - } - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - query_explicit_members: str = "" - query_unrolled_members: str = "" - query_org_permissions: str = "" - query_repo_permissions: str = "" + short_name: str + type: str + environment_name: str | None = None + query_explicit_members: str | None = None + query_unrolled_members: str | None = None + query_org_permissions: str | None = None + query_repo_permissions: str | None = None @app.asset( @@ -176,8 +175,8 @@ def as_node(self) -> GHNode: node_id=self.node_id, short_name=self.name, type="custom", - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_explicit_members=f"MATCH p=(:GH_User)-[:GH_HasRole]->(:GH_OrgRole {{node_id:'{self.node_id}'}}) RETURN p", query_unrolled_members=f"MATCH p=(:GH_User)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf*1..]->(:GH_OrgRole {{node_id:'{self.node_id}'}}) RETURN p", query_org_permissions=f"MATCH p=(:GH_OrgRole {{node_id:'{self.node_id}'}})-[]->(:GH_Organization) RETURN p", diff --git a/src/openhound_github/models/org_role_member.py b/src/openhound_github/models/org_role_member.py index e9bc8f9..004b6d5 100644 --- a/src/openhound_github/models/org_role_member.py +++ b/src/openhound_github/models/org_role_member.py @@ -35,6 +35,8 @@ class OrgRoleMember(BaseAsset): # Additional org_role_id: int org_role_name: str + org_node_id: str + org_login: str @property def as_node(self) -> None: @@ -42,8 +44,7 @@ def as_node(self) -> None: @property def org_role_node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"{org_node_id}_{self.org_role_name}" + return f"{self.org_node_id}_{self.org_role_name}" @property def edges(self): diff --git a/src/openhound_github/models/org_role_team.py b/src/openhound_github/models/org_role_team.py index a01e9f3..08f4165 100644 --- a/src/openhound_github/models/org_role_team.py +++ b/src/openhound_github/models/org_role_team.py @@ -37,11 +37,12 @@ class OrgRoleTeam(BaseAsset): # Additional org_role_id: int org_role_name: str + org_node_id: str + org_login: str @property def org_role_node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"{org_node_id}_{self.org_role_name}" + return f"{self.org_node_id}_{self.org_role_name}" @property def as_node(self) -> None: diff --git a/src/openhound_github/models/org_secret.py b/src/openhound_github/models/org_secret.py index 2040aea..1ec9879 100644 --- a/src/openhound_github/models/org_secret.py +++ b/src/openhound_github/models/org_secret.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -14,25 +14,22 @@ @dataclass class GHOrgSecretProperties(GHNodeProperties): - """Org secret properties and accordion panel queries.""" - - visibility: str = field( - default="", - metadata={ - "description": "The secret's visibility scope: `all` (all repos), `private` (private and internal repos), or `selected` (specific repos)." - }, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - created_at: str | None = field( - default=None, metadata={"description": "When the secret was created."} - ) - updated_at: str | None = field( - default=None, metadata={"description": "When the secret was last updated."} - ) - query_visible_repositories: str = "" + """Org secret properties and accordion panel queries. + + Attributes: + visibility: The secret's visibility scope: `all` (all repos), `private` (private and internal repos), or `selected` (specific repos). + environment_name: The name of the environment (GitHub organization). + created_at: When the secret was created. + updated_at: When the secret was last updated. + query_visible_repositories: Query for visible repositories. + selected_repositories_url: The selected repositories url property. + """ + + visibility: str | None = None + environment_name: str | None = None + created_at: str | None = None + updated_at: str | None = None + query_visible_repositories: str | None = None selected_repositories_url: str | None = None @@ -70,22 +67,16 @@ class OrgSecret(BaseAsset): updated_at: datetime | None = None visibility: str - # node_id: str # synthetic: "GH_OrgSecret_{org_node_id}_{name}" - # org_name: str - # org_node_id: str + # Additional + org_login: str - # node_id: str - # name: str - # org_name: str - # org_node_id: str - # visibility: str - # created_at: str | None = None - # updated_at: str | None = None + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"GH_OrgSecret_{org_node_id}_{self.name}" + return f"GH_OrgSecret_{self.org_node_id}_{self.name}" @property def as_node(self) -> GHNode: @@ -97,8 +88,8 @@ def as_node(self) -> GHNode: displayname=self.name, node_id=sid, visibility=self.visibility, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, query_visible_repositories=f"MATCH p=(:GH_OrgSecret {{node_id:'{sid}'}})<-[:GH_HasSecret]-(:GH_Repository) RETURN p", @@ -108,7 +99,7 @@ def as_node(self) -> GHNode: @property def _all_repo_edges(self): if self.visibility == "all": - for repo in self._lookup.repository_node_ids(): + for repo in self._lookup.repository_node_ids_for_org(self.org_login): for repo_node_id in repo: yield Edge( kind=ek.HAS_SECRET, @@ -120,7 +111,7 @@ def _all_repo_edges(self): @property def _private_repo_edges(self): if self.visibility == "private": - for repo in self._lookup.private_repository_node_ids(): + for repo in self._lookup.private_repository_node_ids_for_org(self.org_login): for repo_node_id in repo: yield Edge( kind=ek.HAS_SECRET, @@ -133,7 +124,7 @@ def _private_repo_edges(self): def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self._lookup.org_id(), match_by="id"), + start=EdgePath(value=self.org_node_id, match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) @@ -158,11 +149,15 @@ class SelectedOrgSecret(BaseAsset): name: str repository_node_id: str repository_full_name: str + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"GH_OrgSecret_{org_node_id}_{self.name}" + return f"GH_OrgSecret_{self.org_node_id}_{self.name}" @property def as_node(self) -> None: @@ -172,7 +167,7 @@ def as_node(self) -> None: def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self._lookup.org_id(), match_by="id"), + start=EdgePath(value=self.org_node_id, match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) diff --git a/src/openhound_github/models/org_variable.py b/src/openhound_github/models/org_variable.py index a0372c7..708b55e 100644 --- a/src/openhound_github/models/org_variable.py +++ b/src/openhound_github/models/org_variable.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -14,28 +14,23 @@ @dataclass class GHOrgVariableProperties(GHNodeProperties): - """Org variable properties and accordion panel queries.""" - - visibility: str = field( - default="", - metadata={ - "description": "The variable's visibility scope: `all` (all repos), `private` (private and internal repos), or `selected` (specific repos)." - }, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - value: str | None = field( - default=None, metadata={"description": "The plaintext value of the variable."} - ) - created_at: str | None = field( - default=None, metadata={"description": "When the variable was created."} - ) - updated_at: str | None = field( - default=None, metadata={"description": "When the variable was last updated."} - ) - query_visible_repositories: str = "" + """Org variable properties and accordion panel queries. + + Attributes: + visibility: The variable's visibility scope: `all` (all repos), `private` (private and internal repos), or `selected` (specific repos). + environment_name: The name of the environment (GitHub organization). + value: The plaintext value of the variable. + created_at: When the variable was created. + updated_at: When the variable was last updated. + query_visible_repositories: Query for visible repositories. + """ + + visibility: str | None = None + environment_name: str | None = None + value: str | None = None + created_at: str | None = None + updated_at: str | None = None + query_visible_repositories: str | None = None @app.asset( @@ -66,10 +61,16 @@ class OrgVariable(BaseAsset): updated_at: datetime | None = None visibility: str + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"GH_OrgVariable_{org_node_id}_{self.name}" + return f"GH_OrgVariable_{self.org_node_id}_{self.name}" @property def as_node(self) -> GHNode: @@ -81,8 +82,8 @@ def as_node(self) -> GHNode: displayname=self.name, node_id=vid, visibility=self.visibility, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, value=self.value, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, @@ -119,11 +120,15 @@ class SelectedOrgVariable(BaseAsset): name: str repository_node_id: str + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"GH_OrgVariable_{org_node_id}_{self.name}" + return f"GH_OrgVariable_{self.org_node_id}_{self.name}" @property def as_node(self): diff --git a/src/openhound_github/models/personal_access_token.py b/src/openhound_github/models/personal_access_token.py index bf78893..58b1a69 100644 --- a/src/openhound_github/models/personal_access_token.py +++ b/src/openhound_github/models/personal_access_token.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime +from typing import ClassVar +from dlt.common.libs.pydantic import DltConfig from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties from pydantic import BaseModel @@ -25,58 +27,39 @@ class Owner(BaseModel): @dataclass class GHPersonalAccessTokenProperties(GHNodeProperties): - """PAT properties and accordion panel queries.""" - - environment_name: str = field( - metadata={ - "description": "The name of the environment (GitHub organization) where the token has access." - } - ) - owner_id: str | None = field( - default=None, metadata={"description": "The GitHub ID of the token owner."} - ) + """PAT properties and accordion panel queries. + + Attributes: + environment_name: The name of the environment (GitHub organization) where the token has access. + owner_id: The GitHub ID of the token owner. + owner_node_id: The GraphQL node ID of the token owner. + token_expires_at: The ISO 8601 timestamp of when the token expires. + token_last_used_at: The ISO 8601 timestamp of when the token was last used. + access_granted_at: The ISO 8601 timestamp of when the token was granted to the organization. | + token_name: The user-assigned display name of the token. + owner_login: The login handle of the user who owns the token. + repository_selection: Whether the token has access to `all`, `subset`, or `none` of the organization's repositories. + token_expired: Whether the token has expired. + query_organization_permissions: Query for organization permissions. + query_user: Query for user. + query_repositories: Query for repositories. + """ + + environment_name: str + owner_id: str | None = None # TODO: owner_node_id? - owner_node_id: str | None = field( - default=None, - metadata={"description": "The GraphQL node ID of the token owner."}, - ) - token_expires_at: datetime | None = field( - default=None, - metadata={"description": "The ISO 8601 timestamp of when the token expires."}, - ) - token_last_used_at: datetime | None = field( - default=None, - metadata={ - "description": "The ISO 8601 timestamp of when the token was last used." - }, - ) + owner_node_id: str | None = None + token_expires_at: datetime | None = None + token_last_used_at: datetime | None = None # TODO: permissions: - access_granted_at: datetime | None = field( - default=None, - metadata={ - "description": "The ISO 8601 timestamp of when the token was granted to the organization. |" - }, - ) - token_name: str = field( - default="", - metadata={"description": "The user-assigned display name of the token."}, - ) - owner_login: str | None = field( - default=None, - metadata={"description": "The login handle of the user who owns the token."}, - ) - repository_selection: str | None = field( - default=None, - metadata={ - "description": "Whether the token has access to `all`, `subset`, or `none` of the organization's repositories." - }, - ) - token_expired: bool | None = field( - default=None, metadata={"description": "Whether the token has expired."} - ) - query_organization_permissions: str = "" - query_user: str = "" - query_repositories: str = "" + access_granted_at: datetime | None = None + token_name: str | None = None + owner_login: str | None = None + repository_selection: str | None = None + token_expired: bool | None = None + query_organization_permissions: str | None = None + query_user: str | None = None + query_repositories: str | None = None @app.asset( @@ -113,6 +96,8 @@ class GHPersonalAccessTokenProperties(GHNodeProperties): class PersonalAccessToken(BaseAsset): """One record from `personal_access_tokens` → one GH_PersonalAccessToken node + edges.""" + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + id: int owner: Owner repository_selection: str | None = None @@ -126,11 +111,17 @@ class PersonalAccessToken(BaseAsset): token_expires_at: datetime | None = None token_last_used_at: datetime | None = None + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: """Construct a synthetic node_id for this PAT based on org node ID and token ID. This is needed to link users to their PATs via edges, since GH doesn't return unique IDs for PATs.""" - org_node_id = self._lookup.org_id() - return f"GH_PAT_{org_node_id}_{self.id}" + return f"GH_PAT_{self.org_node_id}_{self.id}" @property def as_node(self) -> GHNode: @@ -145,8 +136,8 @@ def as_node(self) -> GHNode: owner_login=self.owner.login, repository_selection=self.repository_selection, token_expired=self.token_expired, - environmentid=self._lookup.org_id(), - environment_name=self._lookup.org_login(), + environmentid=self.org_node_id, + environment_name=self.org_login, token_expires_at=self.token_expires_at, owner_id=self.owner.id if self.owner else None, token_last_used_at=self.token_last_used_at, @@ -170,14 +161,14 @@ def _owner_edge(self): def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self._lookup.org_id(), match_by="id"), + start=EdgePath(value=self.org_node_id, match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) yield Edge( kind=ek.CAN_ACCESS, start=EdgePath(value=self.node_id, match_by="id"), - end=EdgePath(value=self._lookup.org_id(), match_by="id"), + end=EdgePath(value=self.org_node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) yield from self._owner_edge diff --git a/src/openhound_github/models/personal_access_token_access.py b/src/openhound_github/models/personal_access_token_access.py index 64a1491..8654ae1 100644 --- a/src/openhound_github/models/personal_access_token_access.py +++ b/src/openhound_github/models/personal_access_token_access.py @@ -38,6 +38,11 @@ class PatRepoAccess(BaseAsset): # Additional pat_id: int + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def as_node(self) -> None: @@ -45,8 +50,7 @@ def as_node(self) -> None: @property def pat_node_id(self) -> str: - org_node_id = self._lookup.org_id() - return f"GH_PAT_{org_node_id}_{self.id}" + return f"GH_PAT_{self.org_node_id}_{self.pat_id}" @property def edges(self): diff --git a/src/openhound_github/models/personal_access_token_request.py b/src/openhound_github/models/personal_access_token_request.py index eca4de7..cfbc634 100644 --- a/src/openhound_github/models/personal_access_token_request.py +++ b/src/openhound_github/models/personal_access_token_request.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from openhound.core.asset import BaseAsset, EdgeDef, NodeDef @@ -21,37 +21,30 @@ class Owner(BaseModel): @dataclass class GHPersonalAccessTokenRequestProperties(GHNodeProperties): - """PAT request properties and accordion panel queries.""" + """PAT request properties and accordion panel queries. + + Attributes: + token_name: The user-assigned display name of the token. + owner_login: The login handle of the user who submitted the request. + repository_selection: Whether the request targets `all`, `subset`, or `none` of the organization's repositories. + reason: The rationale provided by the requester for the access request. + org_name: The org name property. + query_organization_permissions: Query for organization permissions. + query_user: Query for user. + query_repositories: Query for repositories. + """ # TODO: Check for the following fields # owner_id, owner_node_id, toke_id, token_expires_at, token_last_used_at, permissions, and environment_name - token_name: str = field( - default="", - metadata={"description": "The user-assigned display name of the token."}, - ) - owner_login: str | None = field( - default=None, - metadata={ - "description": "The login handle of the user who submitted the request." - }, - ) - repository_selection: str | None = field( - default=None, - metadata={ - "description": "Whether the request targets `all`, `subset`, or `none` of the organization's repositories." - }, - ) - reason: str | None = field( - default=None, - metadata={ - "description": "The rationale provided by the requester for the access request." - }, - ) - org_name: str = "" - query_organization_permissions: str = "" - query_user: str = "" - query_repositories: str = "" + token_name: str | None = None + owner_login: str | None = None + repository_selection: str | None = None + reason: str | None = None + org_name: str | None = None + query_organization_permissions: str | None = None + query_user: str | None = None + query_repositories: str | None = None @app.asset( @@ -94,11 +87,17 @@ class PersonalAccessTokenRequest(BaseAsset): repositories_url: str | None = None repository_selection: str | None = None + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: """Construct a generated node id""" - org_node_id = self._lookup.org_id() - return f"GH_PATRequest_{org_node_id}_{self.id}" + return f"GH_PATRequest_{self.org_node_id}_{self.id}" @property def as_node(self) -> GHNode: @@ -109,12 +108,12 @@ def as_node(self) -> GHNode: name=self.token_name, displayname=self.token_name, node_id=rid, - environmentid=self._lookup.org_id(), + environmentid=self.org_node_id, token_name=self.token_name, owner_login=self.owner.login, repository_selection=self.repository_selection, reason=self.reason, - org_name=self._lookup.org_login(), + org_name=self.org_login, query_organization_permissions=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_CanAccess]->(:GH_Organization) RETURN p", query_user=f"MATCH p=(:GH_User)-[:GH_HasPersonalAccessTokenRequest]->(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_PersonalAccessTokenRequest {{node_id:'{rid}'}})-[:GH_CanAccess]->(:GH_Repository) RETURN p LIMIT 1000", @@ -135,7 +134,7 @@ def _owner_edge(self): def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self._lookup.org_id(), match_by="id"), + start=EdgePath(value=self.org_node_id, match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) diff --git a/src/openhound_github/models/repo_role_assignment.py b/src/openhound_github/models/repo_role_assignment.py index dffd8b8..bb72a6e 100644 --- a/src/openhound_github/models/repo_role_assignment.py +++ b/src/openhound_github/models/repo_role_assignment.py @@ -56,12 +56,17 @@ class RepoRoleAssignment(BaseAsset): role_name: str | None = None # Additional + org_login: str assignee_type: str # "user" or "team" repo_node_id: str repo_name: str base_role: str | None = None role_permissions: list[str] = Field(default_factory=list) + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def as_node(self) -> None: return None diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 5932687..38a0c72 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ClassVar from dlt.common.libs.pydantic import DltConfig @@ -14,112 +14,91 @@ @dataclass class GHRepositoryProperties(GHNodeProperties): - """Repository-specific properties and accordion panel queries.""" + """Repository-specific properties and accordion panel queries. + + Attributes: + collected: Collected/generated by OpenHound + full_name: The fully qualified name (e.g., `org/repo`). + private: Whether the repository is private. + html_url: URL to the repository on GitHub. + description: The repository description. + created_at: When the repository was created. + updated_at: When the repository was last updated. + pushed_at: When the repository last had a push. + archived: Whether the repository is archived. + disabled: Whether the repository is disabled. + visibility: The visibility level: `public`, `private`, or `internal`. + default_branch: The name of the default branch (e.g., `main`). + open_issues_count: Number of open issues. + allow_forking: Whether forking is allowed. + web_commit_signoff_required: Whether web-based commits require sign-off. + forks: Number of forks. + open_issues: Number of open issues (includes pull requests). + watchers: Number of watchers. + owner_name: The login of the repository owner. + owner_id: The owner id property. + environment_name: The name of the environment (GitHub organization). + actions_enabled: Whether GitHub Actions is enabled for this repository. + self_hosted_runners_enabled: Whether the repository may use self-hosted runners. + secret_scanning: Status of secret scanning (e.g., `enabled`, `disabled`). + query_branches: Query for branches. + query_protected_branches: Query for protected branches. + query_branch_protection_rules: Query for branch protection rules. + query_roles: Query for roles. + query_teams: Query for teams. + query_workflows: Query for workflows. + query_runners: Query for runners. + query_environments: Query for environments. + query_secrets: Query for secrets. + query_variables: Query for variables. + query_secret_scanning_alerts: Query for secret scanning alerts. + query_explicit_readers: Query for explicit readers. + query_unrolled_readers: Query for unrolled readers. + query_explicit_writers: Query for explicit writers. + query_unrolled_writers: Query for unrolled writers. + """ - collected: bool = field( - default=True, metadata={"description": "Collected/generated by OpenHound"} - ) + collected: bool = True # TODO: Check owner_node_id - full_name: str = field( - default="", - metadata={"description": "The fully qualified name (e.g., `org/repo`)."}, - ) - private: bool | None = field( - default=None, metadata={"description": "Whether the repository is private."} - ) - html_url: str | None = field( - default=None, metadata={"description": "URL to the repository on GitHub."} - ) - description: str | None = field( - default=None, metadata={"description": "The repository description."} - ) - created_at: str | None = field( - default=None, metadata={"description": "When the repository was created."} - ) - updated_at: str | None = field( - default=None, metadata={"description": "When the repository was last updated."} - ) - pushed_at: str | None = field( - default=None, metadata={"description": "When the repository last had a push."} - ) - archived: bool | None = field( - default=None, metadata={"description": "Whether the repository is archived."} - ) - disabled: bool | None = field( - default=None, metadata={"description": "Whether the repository is disabled."} - ) - visibility: str | None = field( - default=None, - metadata={ - "description": "The visibility level: `public`, `private`, or `internal`." - }, - ) - default_branch: str | None = field( - default=None, - metadata={"description": "The name of the default branch (e.g., `main`)."}, - ) - open_issues_count: int | None = field( - default=None, metadata={"description": "Number of open issues."} - ) - allow_forking: bool | None = field( - default=None, metadata={"description": "Whether forking is allowed."} - ) - web_commit_signoff_required: bool | None = field( - default=None, - metadata={"description": "Whether web-based commits require sign-off."}, - ) - forks: int | None = field( - default=None, metadata={"description": "Number of forks."} - ) - open_issues: int | None = field( - default=None, - metadata={"description": "Number of open issues (includes pull requests)."}, - ) - watchers: int | None = field( - default=None, metadata={"description": "Number of watchers."} - ) - owner_name: str = field( - default="", metadata={"description": "The login of the repository owner."} - ) - owner_id: str = "" - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - actions_enabled: bool | None = field( - default=None, - metadata={ - "description": "Whether GitHub Actions is enabled for this repository." - }, - ) - self_hosted_runners_enabled: bool | None = field( - default=None, - metadata={ - "description": "Whether the repository may use self-hosted runners." - }, - ) - secret_scanning: str | None = field( - default=None, - metadata={ - "description": "Status of secret scanning (e.g., `enabled`, `disabled`)." - }, - ) - query_branches: str = "" - query_protected_branches: str = "" - query_branch_protection_rules: str = "" - query_roles: str = "" - query_teams: str = "" - query_workflows: str = "" - query_runners: str = "" - query_environments: str = "" - query_secrets: str = "" - query_variables: str = "" - query_secret_scanning_alerts: str = "" - query_explicit_readers: str = "" - query_unrolled_readers: str = "" - query_explicit_writers: str = "" - query_unrolled_writers: str = "" + full_name: str | None = None + private: bool | None = None + html_url: str | None = None + description: str | None = None + created_at: str | None = None + updated_at: str | None = None + pushed_at: str | None = None + archived: bool | None = None + disabled: bool | None = None + visibility: str | None = None + default_branch: str | None = None + open_issues_count: int | None = None + allow_forking: bool | None = None + web_commit_signoff_required: bool | None = None + forks: int | None = None + open_issues: int | None = None + watchers: int | None = None + owner_name: str | None = None + owner_id: str | None = None + environment_name: str | None = None + actions_enabled: bool | None = None + self_hosted_runners_enabled: bool | None = None + secret_scanning: str | None = None + query_branches: str | None = None + query_protected_branches: str | None = None + query_branch_protection_rules: str | None = None + query_roles: str | None = None + query_teams: str | None = None + query_workflows: str | None = None + query_runners: str | None = None + query_environments: str | None = None + query_secrets: str | None = None + query_variables: str | None = None + query_secret_scanning_alerts: str | None = None + query_explicit_readers: str | None = None + query_unrolled_readers: str | None = None + query_explicit_writers: str | None = None + query_unrolled_writers: str | None = None class Owner(BaseModel): @@ -163,11 +142,14 @@ class Ref(BaseModel): class RepositoryQL(BaseModel): + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + id: str name: str refs: Ref - dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + # Additional + org_login: str @app.asset( node=NodeDef( @@ -216,6 +198,13 @@ class Repository(BaseAsset): actions_enabled: bool | None = None self_hosted_runners_enabled: bool | None = None + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def owner_id(self) -> str: return self.owner.node_id @@ -252,8 +241,8 @@ def as_node(self) -> GHNode: watchers=self.watchers, owner_name=self.owner_name or "", owner_id=self.owner_id or "", - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, actions_enabled=self.actions_enabled, self_hosted_runners_enabled=self.self_hosted_runners_enabled, # secret_scanning=self.secret_scanning, diff --git a/src/openhound_github/models/repository_role.py b/src/openhound_github/models/repository_role.py index a94dc5a..2e9d0a7 100644 --- a/src/openhound_github/models/repository_role.py +++ b/src/openhound_github/models/repository_role.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -193,34 +193,35 @@ class BaseRepoRole(BaseModel): organization: Organization | None = None base_role: str + # Additional + org_login: str + @dataclass class GHRepoRoleProperties(GHNodeProperties): - """Repository role-specific properties and accordion panel queries.""" - - short_name: str = field( - metadata={ - "description": "The short role name (e.g., `read`, `write`, `admin`, `triage`, `maintain`, or custom role name)." - } - ) - repository_name: str = field( - metadata={"description": "The name of the repository this role belongs to."} - ) - repository_id: str = field( - metadata={"description": "The node_id of the repository this role belongs to."} - ) - environment_name: str = field( - metadata={"description": "The name of the environment (GitHub organization)."} - ) - type: str = field( - metadata={ - "description": "`default` for built-in roles or `custom` for custom repository roles." - } - ) - query_explicit_users: str = "" - query_explicit_teams: str = "" - query_unrolled_members: str = "" - query_repository_permissions: str = "" + """Repository role-specific properties and accordion panel queries. + + Attributes: + short_name: The short role name (e.g., `read`, `write`, `admin`, `triage`, `maintain`, or custom role name). + repository_name: The name of the repository this role belongs to. + repository_id: The node_id of the repository this role belongs to. + environment_name: The name of the environment (GitHub organization). + type: `default` for built-in roles or `custom` for custom repository roles. + query_explicit_users: Query for explicit users. + query_explicit_teams: Query for explicit teams. + query_unrolled_members: Query for unrolled members. + query_repository_permissions: Query for repository permissions. + """ + + short_name: str + repository_name: str + repository_id: str + environment_name: str + type: str + query_explicit_users: str | None = None + query_explicit_teams: str | None = None + query_unrolled_members: str | None = None + query_repository_permissions: str | None = None @app.asset( @@ -680,6 +681,7 @@ class RepoRole(BaseAsset): base_role: str | None = None # Additional + org_login: str type: str repository_node_id: str repository_name: str @@ -693,7 +695,7 @@ def node_id(self) -> str: @property def org_node_id(self) -> str: - return self._lookup.org_id() + return self._lookup.org_id_for_login(self.org_login) @property def as_node(self) -> GHNode: @@ -708,8 +710,8 @@ def as_node(self) -> GHNode: type=self.type, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_explicit_users=f"MATCH p=(:GH_User)-[:GH_HasRole]->(:GH_RepoRole {{node_id:'{rid}'}}) RETURN p", query_explicit_teams=f"MATCH p=(:GH_Team)-[:GH_HasRole]->(:GH_RepoRole {{node_id:'{rid}'}}) RETURN p", query_unrolled_members=( diff --git a/src/openhound_github/models/repository_secret.py b/src/openhound_github/models/repository_secret.py index 64c6176..7190265 100644 --- a/src/openhound_github/models/repository_secret.py +++ b/src/openhound_github/models/repository_secret.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from openhound.core.asset import BaseAsset, EdgeDef, NodeDef @@ -12,29 +12,25 @@ @dataclass class GHRepoSecretProperties(GHNodeProperties): - """Repo secret properties and accordion panel queries.""" + """Repo secret properties and accordion panel queries. + + Attributes: + repository_name: The name of the containing repository. + repository_id: The node_id of the containing repository. + environment_name: The name of the environment (GitHub organization). + created_at: When the secret was created. + updated_at: When the secret was last updated. + visibility: The secret's visibility scope. + query_visible_repositories: Query for visible repositories. + """ - repository_name: str = field( - default="", metadata={"description": "The name of the containing repository."} - ) - repository_id: str = field( - default="", - metadata={"description": "The node_id of the containing repository."}, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - created_at: str | None = field( - default=None, metadata={"description": "When the secret was created."} - ) - updated_at: str | None = field( - default=None, metadata={"description": "When the secret was last updated."} - ) - visibility: str | None = field( - default=None, metadata={"description": "The secret's visibility scope."} - ) - query_visible_repositories: str = "" + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + created_at: str | None = None + updated_at: str | None = None + visibility: str | None = None + query_visible_repositories: str | None = None @app.asset( @@ -71,8 +67,13 @@ class RepoSecret(BaseAsset): selected_repositories_url: str | None = None # Additional - repository_name: str = "" - repository_node_id: str = "" + org_login: str + repository_name: str | None = None + repository_node_id: str | None = None + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def node_id(self) -> str: @@ -89,8 +90,8 @@ def as_node(self) -> GHNode: node_id=sid, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, visibility=self.visibility, diff --git a/src/openhound_github/models/repository_variable.py b/src/openhound_github/models/repository_variable.py index 8eec51e..33398c1 100644 --- a/src/openhound_github/models/repository_variable.py +++ b/src/openhound_github/models/repository_variable.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from openhound.core.asset import BaseAsset, EdgeDef, NodeDef @@ -12,29 +12,25 @@ @dataclass class GHRepoVariableProperties(GHNodeProperties): - """Repo variable properties and accordion panel queries.""" + """Repo variable properties and accordion panel queries. + + Attributes: + repository_name: The name of the containing repository. + repository_id: The node_id of the containing repository. + environment_name: The name of the environment (GitHub organization). + value: The plaintext value of the variable. + created_at: When the variable was created. + updated_at: When the variable was last updated. + query_visible_repositories: Query for visible repositories. + """ - repository_name: str = field( - default="", metadata={"description": "The name of the containing repository."} - ) - repository_id: str = field( - default="", - metadata={"description": "The node_id of the containing repository."}, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - value: str | None = field( - default=None, metadata={"description": "The plaintext value of the variable."} - ) - created_at: str | None = field( - default=None, metadata={"description": "When the variable was created."} - ) - updated_at: str | None = field( - default=None, metadata={"description": "When the variable was last updated."} - ) - query_visible_repositories: str = "" + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + value: str | None = None + created_at: str | None = None + updated_at: str | None = None + query_visible_repositories: str | None = None @app.asset( @@ -70,8 +66,13 @@ class RepoVariable(BaseAsset): updated_at: datetime | None = None # Additional - repository_name: str = "" - repository_node_id: str = "" + org_login: str + repository_name: str | None = None + repository_node_id: str | None = None + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def node_id(self) -> str: @@ -89,8 +90,8 @@ def as_node(self) -> GHNode: node_id=vid, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, value=self.value, created_at=str(self.created_at) if self.created_at else None, updated_at=str(self.updated_at) if self.updated_at else None, diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 07e8e91..857f3c4 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -1,5 +1,5 @@ import json -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -13,46 +13,33 @@ @dataclass class GHRunnerGroupProperties(GHNodeProperties): - group_id: int | None = field( - default=None, metadata={"description": "The GitHub runner group ID."} - ) - group_name: str | None = field( - default=None, metadata={"description": "The runner group display name."} - ) - visibility: str | None = field( - default=None, - metadata={ - "description": "Which repositories can use this group: `all`, `private`, or `selected`." - }, - ) - default: bool | None = field( - default=None, - metadata={"description": "Whether this is the default runner group."}, - ) - inherited: bool | None = field( - default=None, - metadata={"description": "Whether this runner group is inherited."}, - ) - allows_public_repositories: bool | None = field( - default=None, - metadata={"description": "Whether public repositories may use this group."}, - ) - restricted_to_workflows: bool | None = field( - default=None, - metadata={"description": "Whether access is restricted to selected workflows."}, - ) - selected_workflows: str | None = field( - default=None, - metadata={"description": "JSON array of selected workflows, if configured."}, - ) - runners_url: str | None = field( - default=None, - metadata={"description": "API URL for runners in this group."}, - ) - environment_name: str | None = field( - default=None, - metadata={"description": "The name of the environment (GitHub organization)."}, - ) + """Properties for GHRunnerGroupProperties. + + Attributes: + group_id: The GitHub runner group ID. + group_name: The runner group display name. + visibility: Which repositories can use this group: `all`, `private`, or `selected`. + default: Whether this is the default runner group. + inherited: Whether this runner group is inherited. + allows_public_repositories: Whether public repositories may use this group. + restricted_to_workflows: Whether access is restricted to selected workflows. + selected_workflows: JSON array of selected workflows, if configured. + runners_url: API URL for runners in this group. + environment_name: The name of the environment (GitHub organization). + query_runners: Query for runners. + query_repositories: Query for repositories. + """ + + group_id: int | None = None + group_name: str | None = None + visibility: str | None = None + default: bool | None = None + inherited: bool | None = None + allows_public_repositories: bool | None = None + restricted_to_workflows: bool | None = None + selected_workflows: str | None = None + runners_url: str | None = None + environment_name: str | None = None query_runners: str | None = None query_repositories: str | None = None @@ -85,9 +72,16 @@ class RunnerGroup(BaseAsset): selected_workflows: list[str] | None = None runners_url: str | None = None + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: - return f"{self._lookup.org_id()}_runner_group_{self.id}" + return f"{self.org_node_id}_runner_group_{self.id}" @property def as_node(self) -> GHNode: @@ -95,7 +89,7 @@ def as_node(self) -> GHNode: return GHNode( kinds=[nk.RUNNER_GROUP], properties=GHRunnerGroupProperties( - name=f"{self._lookup.org_login()}/{self.name}", + name=f"{self.org_login}/{self.name}", displayname=self.name, node_id=gid, group_id=self.id, @@ -107,8 +101,8 @@ def as_node(self) -> GHNode: restricted_to_workflows=self.restricted_to_workflows, selected_workflows=json.dumps(self.selected_workflows or []), runners_url=self.runners_url, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_runners=f"MATCH p=(:GH_RunnerGroup {{node_id:'{gid}'}})-[:GH_Contains]->(:GH_OrgRunner) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunner)<-[:GH_Contains]-(:GH_RunnerGroup {{node_id:'{gid}'}}) RETURN p", ), @@ -118,7 +112,7 @@ def as_node(self) -> GHNode: def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self._lookup.org_id(), match_by="id"), + start=EdgePath(value=self.org_node_id, match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) @@ -126,61 +120,41 @@ def edges(self): @dataclass class GHRunnerProperties(GHNodeProperties): - scope: str = field( - default="", - metadata={ - "description": "Whether the runner is organization or repository scoped." - }, - ) - runner_id: int | None = field( - default=None, metadata={"description": "The GitHub runner ID."} - ) - os: str | None = field( - default=None, metadata={"description": "The runner operating system."} - ) - status: str | None = field( - default=None, metadata={"description": "The runner status."} - ) - busy: bool | None = field( - default=None, metadata={"description": "Whether the runner is currently busy."} - ) - ephemeral: bool | None = field( - default=None, - metadata={"description": "Whether the runner is ephemeral."}, - ) - labels: str | None = field( - default=None, metadata={"description": "JSON array of runner labels."} - ) - runner_group_id: int | None = field( - default=None, metadata={"description": "The associated runner group ID."} - ) - runner_group_name: str | None = field( - default=None, metadata={"description": "The associated runner group name."} - ) - runner_group_visibility: str | None = field( - default=None, - metadata={"description": "Runner group visibility when organization scoped."}, - ) - repository_name: str | None = field( - default=None, - metadata={"description": "The repository name for repository-scoped runners."}, - ) - repository_id: str | None = field( - default=None, - metadata={ - "description": "The repository node_id for repository-scoped runners." - }, - ) - repository_full_name: str | None = field( - default=None, - metadata={ - "description": "The full repository name for repository-scoped runners." - }, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) + """Properties for GHRunnerProperties. + + Attributes: + scope: Whether the runner is organization or repository scoped. + runner_id: The GitHub runner ID. + os: The runner operating system. + status: The runner status. + busy: Whether the runner is currently busy. + ephemeral: Whether the runner is ephemeral. + labels: JSON array of runner labels. + runner_group_id: The associated runner group ID. + runner_group_name: The associated runner group name. + runner_group_visibility: Runner group visibility when organization scoped. + repository_name: The repository name for repository-scoped runners. + repository_id: The repository node_id for repository-scoped runners. + repository_full_name: The full repository name for repository-scoped runners. + environment_name: The name of the environment (GitHub organization). + query_group: Query for group. + query_repositories: Query for repositories. + """ + + scope: str | None = None + runner_id: int | None = None + os: str | None = None + status: str | None = None + busy: bool | None = None + ephemeral: bool | None = None + labels: str | None = None + runner_group_id: int | None = None + runner_group_name: str | None = None + runner_group_visibility: str | None = None + repository_name: str | None = None + repository_id: str | None = None + repository_full_name: str | None = None + environment_name: str | None = None query_group: str | None = None query_repositories: str | None = None @@ -202,9 +176,16 @@ class OrgRunner(BaseAsset): ephemeral: bool | None = None labels: list[dict] = Field(default_factory=list) + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: - return f"{self._lookup.org_id()}_org_runner_{self.id}" + return f"{self.org_node_id}_org_runner_{self.id}" @property def as_node(self) -> GHNode: @@ -222,8 +203,8 @@ def as_node(self) -> GHNode: busy=self.busy, ephemeral=self.ephemeral, labels=json.dumps(self.labels), - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_group=f"MATCH p=(:GH_RunnerGroup)-[:GH_Contains]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_Repository)-[:GH_CanUseRunner]->(:GH_OrgRunner {{node_id:'{rid}'}}) RETURN p", ), @@ -257,20 +238,27 @@ class OrgRunnerGroupMembership(BaseAsset): runner_id: int accessible_repo_node_ids: list[str] = Field(default_factory=list) + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def as_node(self): return None @property def _runner_node_id(self): - return f"{self._lookup.org_id()}_org_runner_{self.runner_id}" + return f"{self.org_node_id}_org_runner_{self.runner_id}" @property def _contains_edge(self): yield Edge( kind=ek.CONTAINS, start=EdgePath( - value=f"{self._lookup.org_id()}_runner_group_{self.runner_group_id}", + value=f"{self.org_node_id}_runner_group_{self.runner_group_id}", match_by="id", ), end=EdgePath(value=self._runner_node_id, match_by="id"), @@ -329,6 +317,13 @@ class RepoRunner(BaseAsset): repository_node_id: str repository_full_name: str + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: return f"{self.repository_node_id}_repo_runner_{self.id}" @@ -352,8 +347,8 @@ def as_node(self) -> GHNode: repository_name=self.repository_name, repository_id=self.repository_node_id, repository_full_name=self.repository_full_name, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_repositories=f"MATCH p=(:GH_Repository {{node_id:'{self.repository_node_id}'}})-[:GH_CanUseRunner]->(:GH_RepoRunner {{node_id:'{rid}'}}) RETURN p", ), ) diff --git a/src/openhound_github/models/saml_provider.py b/src/openhound_github/models/saml_provider.py index 8bbf983..809af03 100644 --- a/src/openhound_github/models/saml_provider.py +++ b/src/openhound_github/models/saml_provider.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -12,38 +12,29 @@ @dataclass class GHSamlProviderProperties(GHNodeProperties): - """SAML identity provider properties and accordion panel queries.""" + """SAML identity provider properties and accordion panel queries. + + Attributes: + issuer: The SAML issuer URL. + sso_url: The SAML single sign-on URL. + signature_method: The signature method used by the SAML provider. + digest_method: The digest method used by the SAML provider. + idp_certificate: The identity provider's X.509 certificate. + environment_name: The name of the environment (GitHub organization). + foreign_environment_id: The ID of the foreign environment linked to this provider. + query_environments: Query for environments. + query_external_identities: Query for external identities. + """ - issuer: str | None = field( - default=None, metadata={"description": "The SAML issuer URL."} - ) - sso_url: str | None = field( - default=None, metadata={"description": "The SAML single sign-on URL."} - ) - signature_method: str | None = field( - default=None, - metadata={"description": "The signature method used by the SAML provider."}, - ) - digest_method: str | None = field( - default=None, - metadata={"description": "The digest method used by the SAML provider."}, - ) - idp_certificate: str | None = field( - default=None, - metadata={"description": "The identity provider's X.509 certificate."}, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - foreign_environment_id: str | None = field( - default=None, - metadata={ - "description": "The ID of the foreign environment linked to this provider." - }, - ) - query_environments: str = "" - query_external_identities: str = "" + issuer: str | None = None + sso_url: str | None = None + signature_method: str | None = None + digest_method: str | None = None + idp_certificate: str | None = None + environment_name: str | None = None + foreign_environment_id: str | None = None + query_environments: str | None = None + query_external_identities: str | None = None @app.asset( @@ -78,6 +69,7 @@ class SamlProvider(BaseAsset): foreign_environment_id: str | None = None # tenant/org ID in the foreign IdP # Additional + org_login: str org_name: str org_node_id: str # organization.id (GraphQL global ID) diff --git a/src/openhound_github/models/scim_user.py b/src/openhound_github/models/scim_user.py index 2169ea8..5fd988d 100644 --- a/src/openhound_github/models/scim_user.py +++ b/src/openhound_github/models/scim_user.py @@ -1,6 +1,6 @@ from typing import Optional -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, ConfigDict, Field class Name(BaseModel): @@ -20,3 +20,6 @@ class ScimResource(BaseModel): groups: Optional[list[dict]] = Field(default_factory=list) roles: Optional[list[dict]] = Field(default_factory=list) active: bool + + # Additional + org_login: str \ No newline at end of file diff --git a/src/openhound_github/models/secret_scanning_alert.py b/src/openhound_github/models/secret_scanning_alert.py index 2fbb6f4..8adb7b3 100644 --- a/src/openhound_github/models/secret_scanning_alert.py +++ b/src/openhound_github/models/secret_scanning_alert.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from openhound.core.asset import BaseAsset, EdgeDef, NodeDef @@ -36,43 +36,30 @@ class Repository(BaseModel): @dataclass class GHSecretScanningAlertProperties(GHNodeProperties): - """Secret scanning alert properties and accordion panel queries.""" + """Secret scanning alert properties and accordion panel queries. + + Attributes: + repository_name: The name of the repository where the secret was detected. + secret_type: The type of secret detected (e.g., `github_personal_access_token`, `aws_access_key_id`). + secret_type_display_name: A human-readable name for the secret type. + validity: The validity status of the detected secret (e.g., `active`, `inactive`, `unknown`). + state: The alert state (e.g., `open`, `resolved`). + url: The HTML URL to view the alert on GitHub. + query_repository: Query for repository. + query_alert_viewers: Query for alert viewers. + """ # TODO: Check the following fields # repository_id, repository_url, created_at, updated_at - repository_name: str = field( - default="", - metadata={ - "description": "The name of the repository where the secret was detected." - }, - ) - secret_type: str | None = field( - default=None, - metadata={ - "description": "The type of secret detected (e.g., `github_personal_access_token`, `aws_access_key_id`)." - }, - ) - secret_type_display_name: str | None = field( - default=None, - metadata={"description": "A human-readable name for the secret type."}, - ) - validity: str | None = field( - default=None, - metadata={ - "description": "The validity status of the detected secret (e.g., `active`, `inactive`, `unknown`)." - }, - ) - state: str | None = field( - default=None, - metadata={"description": "The alert state (e.g., `open`, `resolved`)."}, - ) - url: str | None = field( - default=None, - metadata={"description": "The HTML URL to view the alert on GitHub."}, - ) - query_repository: str = "" - query_alert_viewers: str = "" + repository_name: str | None = None + secret_type: str | None = None + secret_type_display_name: str | None = None + validity: str | None = None + state: str | None = None + url: str | None = None + query_repository: str | None = None + query_alert_viewers: str | None = None @app.asset( @@ -128,6 +115,7 @@ class SecretScanningAlert(BaseAsset): validity: str | None = None # Additional + org_login: str valid_token_user_node_id: str | None = ( None # based on lookup of users with valid tokens matching the secret ) @@ -142,7 +130,7 @@ def node_id(self) -> str: def org_node_id(self) -> str | None: if self.repository and self.repository.owner: return self.repository.owner.node_id - return None + return self._lookup.org_id_for_login(self.org_login) @property def as_node(self) -> GHNode: @@ -153,7 +141,7 @@ def as_node(self) -> GHNode: name=str(self.number), displayname=self.secret_type_display_name or str(self.number), node_id=aid, - environmentid=self._lookup.org_id(), + environmentid=self.org_node_id, repository_name=self.repository.name if self.repository else "", secret_type=self.secret_type, secret_type_display_name=self.secret_type_display_name, diff --git a/src/openhound_github/models/team.py b/src/openhound_github/models/team.py index 40c7187..313d088 100644 --- a/src/openhound_github/models/team.py +++ b/src/openhound_github/models/team.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import ClassVar from dlt.common.libs.pydantic import DltConfig @@ -14,36 +14,36 @@ @dataclass class GHTeamProperties(GHNodeProperties): - """Team-specific properties and accordion panel queries.""" - - collected: bool = field( - default=True, metadata={"description": "Collected/generated by OpenHound"} - ) + """Team-specific properties and accordion panel queries. + + Attributes: + collected: Collected/generated by OpenHound + slug: The team's URL-safe slug identifier. + description: The team's description. + privacy: The team's privacy level (e.g., `visible`, `secret`). + environment_name: The name of the environment (GitHub organization). + query_first_degree_members: Query for first degree members. + query_unrolled_members: Query for unrolled members. + query_first_degree_maintainers: Query for first degree maintainers. + query_unrolled_maintainers: Query for unrolled maintainers. + query_repositories: Query for repositories. + query_child_teams: Query for child teams. + """ + + collected: bool = True # TODO: Check permissions field - slug: str = field( - default="", metadata={"description": "The team's URL-safe slug identifier."} - ) - description: str | None = field( - default=None, metadata={"description": "The team's description."} - ) - privacy: str | None = field( - default=None, - metadata={ - "description": "The team's privacy level (e.g., `visible`, `secret`)." - }, - ) - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - - query_first_degree_members: str = "" - query_unrolled_members: str = "" - query_first_degree_maintainers: str = "" - query_unrolled_maintainers: str = "" - query_repositories: str = "" - query_child_teams: str = "" + slug: str | None = None + description: str | None = None + privacy: str | None = None + environment_name: str | None = None + + query_first_degree_members: str | None = None + query_unrolled_members: str | None = None + query_first_degree_maintainers: str | None = None + query_unrolled_maintainers: str | None = None + query_repositories: str | None = None + query_child_teams: str | None = None class MemberNode(BaseModel): @@ -57,7 +57,7 @@ class MemberEdge(BaseModel): class PageInfo(BaseModel): - end_cursor: str = Field(alias="endCursor") + end_cursor: str | None = Field(alias="endCursor") has_next_page: bool = Field(alias="hasNextPage") @@ -91,10 +91,11 @@ class Team(BaseAsset): """One record from the `teams` DLT table → one GH_Team node + optional GH_MemberOf edge to parent.""" model_config = ConfigDict(extra="allow", populate_by_name=True) + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} name: str id: str - database_id: int = Field(alias="databaseId") + database_id: int | None = Field(alias="databaseId", default=None) slug: str description: str | None = None privacy: str | None = None @@ -103,7 +104,12 @@ class Team(BaseAsset): # Used for overflow members: Members - dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) @property def node_id(self) -> str: @@ -122,8 +128,8 @@ def as_node(self) -> GHNode: slug=self.slug, description=self.description, privacy=self.privacy, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_first_degree_members=f"MATCH p=(:GH_User)-[:GH_HasRole]->(t:GH_TeamRole)-[:GH_MemberOf]->(:GH_Team {{node_id:'{tid}'}}) RETURN p", query_unrolled_members=f"MATCH p=(teamrole:GH_TeamRole)-[:GH_MemberOf*1..]->(:GH_Team {{node_id:'{tid}'}}) MATCH p1 = (teamrole)<-[:GH_HasRole]-(:GH_User) RETURN p,p1", query_first_degree_maintainers=f"MATCH p=(:GH_User)-[:GH_HasRole]->(t:GH_TeamRole {{short_name: 'maintainers'}})-[:GH_MemberOf]->(:GH_Team {{node_id:'{tid}'}}) RETURN p", diff --git a/src/openhound_github/models/team_member.py b/src/openhound_github/models/team_member.py index 1ee5c23..19d7d78 100644 --- a/src/openhound_github/models/team_member.py +++ b/src/openhound_github/models/team_member.py @@ -25,6 +25,13 @@ class TeamMember(BaseAsset): id: str role: str # "MEMBER" or "MAINTAINER" + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def team_node_id(self) -> str: """The ID from a GraphQL API response is the same as a regular node_id""" diff --git a/src/openhound_github/models/team_role.py b/src/openhound_github/models/team_role.py index 0097fd8..97d23b8 100644 --- a/src/openhound_github/models/team_role.py +++ b/src/openhound_github/models/team_role.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -11,24 +11,27 @@ @dataclass class GHTeamRoleProperties(GHNodeProperties): - """Team role properties and accordion panel queries.""" + """Team role properties and accordion panel queries. + + Attributes: + short_name: The short role name: `member` or `maintainer`. + type: Always `default` for team roles. + team_name: The team name property. + team_id: The team id property. + environment_name: The name of the environment (GitHub organization). + query_team: Query for team. + query_members: Query for members. + query_repositories: Query for repositories. + """ - short_name: str = field( - default="", - metadata={"description": "The short role name: `member` or `maintainer`."}, - ) - type: str = field( - default="", metadata={"description": "Always `default` for team roles."} - ) - team_name: str = "" - team_id: str = "" - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) - query_team: str = "" - query_members: str = "" - query_repositories: str = "" + short_name: str | None = None + type: str | None = None + team_name: str | None = None + team_id: str | None = None + environment_name: str | None = None + query_team: str | None = None + query_members: str | None = None + query_repositories: str | None = None @app.asset( @@ -63,6 +66,13 @@ class TeamRole(BaseAsset): team_name: str team_slug: str + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self): return f"{self.team_node_id}_{self.type}" @@ -73,15 +83,15 @@ def as_node(self) -> GHNode: return GHNode( kinds=["GH_TeamRole", "GH_Role"], properties=GHTeamRoleProperties( - name=f"{self._lookup.org_login()}/{self.team_slug}/{self.type}", + name=f"{self.org_login}/{self.team_slug}/{self.type}", displayname=self.type, node_id=rid, short_name=self.type, type="team", team_name=self.team_name, team_id=self.team_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_team=f"MATCH p=(:GH_TeamRole {{node_id:'{rid}'}})-[:GH_MemberOf]->(:GH_Team) RETURN p", query_members=f"MATCH p=(:GH_User)-[GH_HasRole]->(:GH_TeamRole {{node_id:'{rid}'}}) RETURN p", query_repositories=f"MATCH p=(:GH_TeamRole {{node_id:'{rid}'}})-[:GH_MemberOf]->(:GH_Team)-[:GH_HasRole|GH_HasBaseRole*1..]->(:GH_RepoRole)-[]->(:GH_Repository) RETURN p", diff --git a/src/openhound_github/models/user.py b/src/openhound_github/models/user.py index d84283f..244f869 100644 --- a/src/openhound_github/models/user.py +++ b/src/openhound_github/models/user.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from openhound.core.asset import BaseAsset, EdgeDef, NodeDef from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties @@ -12,36 +12,33 @@ @dataclass class GHUserProperties(GHNodeProperties): - """User-specific properties with profile fields and accordion panel queries.""" - - collected: bool = field( - default=True, metadata={"description": "Collected/generated by OpenHound"} - ) - login: str = field( - default="", metadata={"description": "The user's GitHub login handle."} - ) - full_name: str | None = field( - default=None, - metadata={"description": "The user's full name from their profile."}, - ) - company: str | None = field( - default=None, - metadata={"description": "The company listed on the user's profile."}, - ) - email: str | None = field( - default=None, metadata={"description": "The user's public email address."} - ) - environment_name: str = field( - default="", - metadata={ - "description": "The name of the environment (GitHub organization) the user belongs to." - }, - ) - query_personal_access_tokens: str = "" - query_roles: str = "" - query_teams: str = "" - query_repositories: str = "" - query_branches: str = "" + """User-specific properties with profile fields and accordion panel queries. + + Attributes: + collected: Collected/generated by OpenHound + login: The user's GitHub login handle. + full_name: The user's full name from their profile. + company: The company listed on the user's profile. + email: The user's public email address. + environment_name: The name of the environment (GitHub organization) the user belongs to. + query_personal_access_tokens: Query for personal access tokens. + query_roles: Query for roles. + query_teams: Query for teams. + query_repositories: Query for repositories. + query_branches: Query for branches. + """ + + collected: bool = True + login: str | None = None + full_name: str | None = None + company: str | None = None + email: str | None = None + environment_name: str | None = None + query_personal_access_tokens: str | None = None + query_roles: str | None = None + query_teams: str | None = None + query_repositories: str | None = None + query_branches: str | None = None @app.asset( @@ -72,6 +69,13 @@ class User(BaseAsset): company: str | None = None org_role: str = Field(alias="role") + # Additional + org_login: str + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + @property def node_id(self) -> str: """The ID from a GraphQL API response is the same as a regular node_id""" @@ -79,11 +83,11 @@ def node_id(self) -> str: @property def _org_node_id(self) -> str: - return self._lookup.org_id() + return self.org_node_id @property def _org_login(self) -> str: - return self._lookup.org_login() + return self.org_login @property def as_node(self) -> GHNode: diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index ae29f5e..0c94488 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -1,8 +1,22 @@ -from dataclasses import dataclass, field +import base64 +import re +from dataclasses import dataclass from datetime import datetime +from typing import Any, ClassVar -from openhound.core.asset import BaseAsset, EdgeDef, NodeDef -from openhound.core.models.entries_dataclass import Edge, EdgePath, EdgeProperties +import yaml # type: ignore[import-untyped] +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import ( # type: ignore[import-untyped] + BaseAsset, + EdgeDef, + NodeDef, +) +from openhound.core.models.entries_dataclass import ( # type: ignore[import-untyped] + Edge, + EdgePath, + EdgeProperties, +) +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from openhound_github.graph import GHNode, GHNodeProperties from openhound_github.kinds import edges as ek @@ -10,56 +24,224 @@ from openhound_github.main import app +class GithubActionsLoader(yaml.SafeLoader): + pass + + +GithubActionsLoader.yaml_implicit_resolvers = { + key: list(value) for key, value in yaml.SafeLoader.yaml_implicit_resolvers.items() +} +for first, mappings in list(GithubActionsLoader.yaml_implicit_resolvers.items()): + GithubActionsLoader.yaml_implicit_resolvers[first] = [ + (tag, regexp) for tag, regexp in mappings if tag != "tag:yaml.org,2002:bool" + ] + + +SECRET_REFERENCE_RE = re.compile(r"\$\{\{\s*secrets\.(\w+)\s*\}\}") +VARIABLE_REFERENCE_RE = re.compile(r"\$\{\{\s*vars\.(\w+)\s*\}\}") +ACTION_RE = re.compile(r"^(?P[^/]+)/(?P[^@]+)@(?P.+)$") +PINNED_REF_RE = re.compile(r"^[0-9a-f]{40}$") + + +class WorkflowStepDefinition(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + uses: str | None = None + run: str | None = None + with_: dict[str, Any] = Field(default_factory=dict, alias="with") + env: dict[str, Any] = Field(default_factory=dict) + + @field_validator("with_", "env", mode="before") + @classmethod + def dict_or_empty(cls, value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + @property + def display_name(self) -> str | None: + if self.name: + return self.name + if self.uses: + return self.uses + if self.run: + first_line = self.run.split("\n", 1)[0].strip() + return f"{first_line[:80]}..." if len(first_line) > 80 else first_line + return None + + @property + def type(self) -> str: + if self.uses: + return "uses" + if self.run: + return "run" + return "unknown" + + +class WorkflowJobDefinition(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + runs_on: Any = Field(default=None, alias="runs-on") + needs: Any = None + environment: Any = None + permissions: Any = None + uses: str | None = None + container: Any = None + env: dict[str, Any] = Field(default_factory=dict) + secrets: dict[str, Any] | str | None = None + steps: list[WorkflowStepDefinition] = Field(default_factory=list) + + @field_validator("env", mode="before") + @classmethod + def dict_or_empty(cls, value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + @field_validator("steps", mode="before") + @classmethod + def valid_step_dicts(cls, value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [step for step in value if isinstance(step, dict)] + + @property + def needs_list(self) -> list[str]: + if isinstance(self.needs, str): + return [self.needs] + if isinstance(self.needs, list): + return [str(item) for item in self.needs] + return [] + + @property + def environment_name(self) -> str | None: + if isinstance(self.environment, str): + return self.environment + if ( + isinstance(self.environment, dict) + and self.environment.get("name") is not None + ): + return str(self.environment["name"]) + return None + + @property + def is_self_hosted(self) -> bool: + if isinstance(self.runs_on, str): + return self.runs_on == "self-hosted" + if isinstance(self.runs_on, list): + return "self-hosted" in [str(item) for item in self.runs_on] + return False + + +class WorkflowDocument(BaseModel): + model_config = ConfigDict(extra="allow") + + permissions: Any = None + jobs: dict[str, WorkflowJobDefinition] = Field(default_factory=dict) + + @field_validator("jobs", mode="before") + @classmethod + def jobs_dict_or_empty(cls, value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def workflow_job_node_id(workflow_node_id: str, job_key: str) -> str: + return f"GH_WorkflowJob_{workflow_node_id}_{job_key}" + + +def workflow_step_node_id(workflow_node_id: str, job_key: str, step_index: int) -> str: + return f"GH_WorkflowStep_{workflow_node_id}_{job_key}_{step_index}" + + +def references( + pattern: re.Pattern[str], text: Any, context: str +) -> list[dict[str, str]]: + if text is None: + return [] + return [ + {"name": name, "context": context} + for name in dict.fromkeys( + match.group(1) for match in pattern.finditer(str(text)) + ) + ] + + +def unique_references(items: list[dict[str, str]]) -> list[dict[str, str]]: + unique = [] + seen = set() + for item in items: + key = (item["name"], item.get("context")) + if key not in seen: + unique.append(item) + seen.add(key) + return unique + + +def mapping_references( + pattern: re.Pattern[str], mapping: dict[str, Any], context_prefix: str +) -> list[dict[str, str]]: + refs = [] + for key, value in mapping.items(): + refs.extend(references(pattern, value, f"{context_prefix}:{key}")) + return refs + + +def action_parts(action: str | None) -> dict[str, Any]: + parts: dict[str, Any] = { + "action_owner": None, + "action_name": None, + "action_ref": None, + "action_slug": None, + "is_pinned": False, + } + if not action: + return parts + match = ACTION_RE.match(action) + if not match: + return parts + owner = match.group("owner") + name = match.group("name") + ref = match.group("ref") + parts.update( + { + "action_owner": owner, + "action_name": name, + "action_ref": ref, + "action_slug": f"{owner}/{name}", + "is_pinned": bool(PINNED_REF_RE.match(ref)), + } + ) + return parts + + @dataclass class GHWorkflowProperties(GHNodeProperties): - """Workflow-specific properties and accordion panel queries.""" + """Workflow-specific properties and accordion panel queries. - short_name: str = field( - default="", metadata={"description": "The workflow's display name."} - ) - path: str = field( - default="", - metadata={ - "description": "The file path of the workflow definition (e.g., `.github/workflows/ci.yml`)." - }, - ) - state: str = field( - default="", - metadata={ - "description": "The workflow state (e.g., `active`, `disabled_manually`)." - }, - ) - url: str = field( - default="", metadata={"description": "The API URL for the workflow."} - ) - repository_name: str = field( - default="", - metadata={"description": "The full name of the containing repository."}, - ) - repository_id: str = field( - default="", - metadata={"description": "The node_id of the containing repository."}, - ) - html_url: str = ( - field( - default="", - metadata={"description": " The GitHub web URL for the workflow file."}, - ), - ) - branch: str = field( - default="", - metadata={"description": "The branch where the workflow file was found."}, - ) - contents: str = field( - default="", - metadata={"description": "The content of the workflow file."}, - ) - query_repository: str = "" - query_editors: str = "" - environment_name: str = field( - default="", - metadata={"description": "The name of the environment (GitHub organization)."}, - ) + Attributes: + short_name: The workflow's display name. + path: The file path of the workflow definition (e.g., `.github/workflows/ci.yml`). + state: The workflow state (e.g., `active`, `disabled_manually`). + url: The API URL for the workflow. + repository_name: The full name of the containing repository. + repository_id: The node_id of the containing repository. + html_url: The GitHub web URL for the workflow file. + branch: The branch where the workflow file was found. + contents: The content of the workflow file. + query_repository: Query for repository. + query_editors: Query for editors. + environment_name: The name of the environment (GitHub organization). + """ + + short_name: str | None = None + path: str | None = None + state: str | None = None + url: str | None = None + repository_name: str | None = None + repository_id: str | None = None + html_url: str | None = None + branch: str | None = None + contents: str | None = None + query_repository: str | None = None + query_editors: str | None = None + environment_name: str | None = None @app.asset( @@ -82,6 +264,8 @@ class GHWorkflowProperties(GHNodeProperties): class Workflow(BaseAsset): """One record from `workflows` → one GH_Workflow node + GH_HasWorkflow edge from repo.""" + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + id: int node_id: str name: str @@ -90,23 +274,134 @@ class Workflow(BaseAsset): created_at: datetime updated_at: datetime url: str - # TODO: Check following three html_url: str | None = None branch: str | None = None contents: str | None = None - # query_repository: str # Custom fields added - # short_name: str + org_login: str repository_name: str repository_node_id: str - # environment_name: str = "" - # environmentid: str = field(default="", metadata={"description": "The node_id of the environment (GitHub organization)."},) + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + + @property + def document(self) -> WorkflowDocument | None: + if not self.contents or not self.contents.strip(): + return None + try: + decoded = base64.b64decode(self.contents) + parsed = yaml.load(decoded, Loader=GithubActionsLoader) + if not isinstance(parsed, dict): + return None + + return WorkflowDocument.model_validate(parsed) + except ValidationError: + return None + + def workflow_job_rows(self) -> list[dict[str, Any]]: + document = self.document + if not document: + return [] + + job_ids = { + job_key: workflow_job_node_id(self.node_id, job_key) + for job_key in document.jobs.keys() + } + rows = [] + for job_key, job in document.jobs.items(): + secret_refs = [] + variable_refs = [] + if isinstance(job.secrets, dict): + secret_refs.extend( + mapping_references(SECRET_REFERENCE_RE, job.secrets, "secrets") + ) + secret_refs.extend(mapping_references(SECRET_REFERENCE_RE, job.env, "env")) + variable_refs.extend( + mapping_references(VARIABLE_REFERENCE_RE, job.env, "env") + ) + + rows.append( + { + "node_id": job_ids[job_key], + "name": f"{self.repository_name}\\{job_key}", + "job_key": job_key, + "runs_on": job.runs_on, + "is_self_hosted": job.is_self_hosted, + "container": job.container, + "environment": job.environment_name, + "permissions": job.permissions + if job.permissions is not None + else document.permissions, + "uses_reusable": job.uses, + "workflow_node_id": self.node_id, + "repository_name": self.repository_name, + "repository_node_id": self.repository_node_id, + "org_login": self.org_login, + "dependency_node_ids": [ + job_ids[dep] for dep in job.needs_list if dep in job_ids + ], + "secret_references": unique_references(secret_refs), + "variable_references": unique_references(variable_refs), + } + ) + return rows + + def workflow_step_rows(self) -> list[dict[str, Any]]: + document = self.document + if not document: + return [] + + rows = [] + for job_key, job in document.jobs.items(): + job_node_id = workflow_job_node_id(self.node_id, job_key) + for step_index, step in enumerate(job.steps): + secret_refs = [] + variable_refs = [] + secret_refs.extend( + mapping_references(SECRET_REFERENCE_RE, step.with_, "with") + ) + variable_refs.extend( + mapping_references(VARIABLE_REFERENCE_RE, step.with_, "with") + ) + secret_refs.extend(references(SECRET_REFERENCE_RE, step.run, "run")) + variable_refs.extend(references(VARIABLE_REFERENCE_RE, step.run, "run")) + secret_refs.extend( + mapping_references(SECRET_REFERENCE_RE, step.env, "env") + ) + variable_refs.extend( + mapping_references(VARIABLE_REFERENCE_RE, step.env, "env") + ) + + rows.append( + { + "node_id": workflow_step_node_id( + self.node_id, job_key, step_index + ), + "name": step.display_name, + "step_index": step_index, + "type": step.type, + "action": step.uses, + **action_parts(step.uses), + "run": step.run, + "with_args": step.with_ or None, + "contents": step.model_dump(by_alias=True, exclude_none=True), + "job_node_id": job_node_id, + "workflow_node_id": self.node_id, + "repository_name": self.repository_name, + "repository_node_id": self.repository_node_id, + "org_login": self.org_login, + "secret_references": unique_references(secret_refs), + "variable_references": unique_references(variable_refs), + } + ) + return rows @property def as_node(self) -> GHNode: wid = self.node_id - # short = self.short_name or self.name.rsplit("/", 1)[-1] return GHNode( kinds=[nk.WORKFLOW], properties=GHWorkflowProperties( @@ -122,8 +417,8 @@ def as_node(self) -> GHNode: contents=self.contents, repository_name=self.repository_name, repository_id=self.repository_node_id, - environment_name=self._lookup.org_login(), - environmentid=self._lookup.org_id(), + environment_name=self.org_login, + environmentid=self.org_node_id, query_repository=f"MATCH p=(:GH_Repository)-[:GH_HasWorkflow]->(:GH_Workflow {{node_id:'{wid}'}}) RETURN p", query_editors=( f"MATCH p=(role:GH_Role)-[:GH_HasRole|GH_HasBaseRole|GH_MemberOf|GH_WriteRepoContents|GH_WriteRepoPullRequests*1..]->" diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py new file mode 100644 index 0000000..e250194 --- /dev/null +++ b/src/openhound_github/models/workflow_job.py @@ -0,0 +1,313 @@ +from dataclasses import dataclass +from typing import Any, ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import ( # type: ignore[import-untyped] + BaseAsset, + EdgeDef, + NodeDef, +) +from openhound.core.models.entries_dataclass import ( # type: ignore[import-untyped] + ConditionalEdgePath, + Edge, + EdgePath, + EdgeProperties, + PropertyMatch, +) +from pydantic import BaseModel, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +class WorkflowReference(BaseModel): + name: str + context: str | None = None + + +@dataclass +class GHWorkflowJobProperties(GHNodeProperties): + """Workflow job-specific properties. + + Attributes: + job_key: The YAML key for the job. + runs_on: The runner label expression for the job. + is_self_hosted: Whether the job targets self-hosted runners. + container: The optional container configuration. + environment: The deployment environment name. + permissions: Effective job permissions. + uses_reusable: The reusable workflow reference used by this job. + workflow_node_id: The parent workflow node ID. + repository_name: The containing repository name. + repository_id: The containing repository node ID. + environment_name: The name of the GitHub organization. + """ + + job_key: str | None = None + runs_on: Any = None + is_self_hosted: bool = False + container: Any = None + environment: str | None = None + permissions: list[str] | None = None + uses_reusable: str | None = None + workflow_node_id: str | None = None + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.WORKFLOW_JOB, + description="GitHub Actions Workflow Job", + icon="layer-group", + properties=GHWorkflowJobProperties, + ), + edges=[ + EdgeDef( + start=nk.WORKFLOW, + end=nk.WORKFLOW_JOB, + kind=ek.HAS_JOB, + description="Workflow contains job", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.WORKFLOW_JOB, + kind=ek.DEPENDS_ON, + description="Workflow job depends on another workflow job", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ENVIRONMENT, + kind=ek.DEPLOYS_TO, + description="Workflow job deploys to environment", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.WORKFLOW, + kind=ek.CALLS_WORKFLOW, + description="Workflow job calls reusable workflow", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.REPO_SECRET, + kind=ek.USES_SECRET, + description="Workflow job references repository secret", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ORG_SECRET, + kind=ek.USES_SECRET, + description="Workflow job references organization secret", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.REPO_VARIABLE, + kind=ek.USES_VARIABLE, + description="Workflow job references repository variable", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.ORG_VARIABLE, + kind=ek.USES_VARIABLE, + description="Workflow job references organization variable", + traversable=False, + ), + ], +) +class WorkflowJob(BaseAsset): + """One record from `workflow_jobs` -> one GH_WorkflowJob node.""" + + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + node_id: str + name: str + job_key: str + workflow_node_id: str + repository_name: str + repository_node_id: str + org_login: str + runs_on: Any = None + is_self_hosted: bool = False + container: Any = None + environment: str | None = None + permissions: Any = None + uses_reusable: str | None = None + dependency_node_ids: list[str] = Field(default_factory=list) + secret_references: list[WorkflowReference] = Field(default_factory=list) + variable_references: list[WorkflowReference] = Field(default_factory=list) + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + + @property + def _flatten_permissions(self) -> list[str] | None: + if isinstance(self.permissions, dict): + return [f"{key}:{value}" for key, value in self.permissions.items()] + return None + + @property + def as_node(self) -> GHNode: + return GHNode( + kinds=[nk.WORKFLOW_JOB], + properties=GHWorkflowJobProperties( + name=self.name, + displayname=self.job_key, + node_id=self.node_id, + job_key=self.job_key, + runs_on=self.runs_on, + is_self_hosted=self.is_self_hosted, + container=self.container, + environment=self.environment, + permissions=self._flatten_permissions, + uses_reusable=self.uses_reusable, + workflow_node_id=self.workflow_node_id, + repository_name=self.repository_name, + repository_id=self.repository_node_id, + environment_name=self.org_login, + environmentid=self.org_node_id, + ), + ) + + @property + def _uses_secret_edges(self): + for ref in self.secret_references: + if self._lookup.repo_secret(ref.name, self.repository_node_id): + yield Edge( + kind=ek.USES_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.REPO_SECRET, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + if self._lookup.org_secret(ref.name, self.org_login): + yield Edge( + kind=ek.USES_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.ORG_SECRET, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _uses_variable_edges(self): + for ref in self.variable_references: + if self._lookup.repo_variable(ref.name, self.repository_node_id): + yield Edge( + kind=ek.USES_VARIABLE, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.REPO_VARIABLE, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + if self._lookup.org_variable(ref.name, self.repository_node_id): + yield Edge( + kind=ek.USES_VARIABLE, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.ORG_VARIABLE, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _has_job_edge(self): + yield Edge( + kind=ek.HAS_JOB, + start=EdgePath(value=self.workflow_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + @property + def _dependency_edges(self): + for dependency_node_id in self.dependency_node_ids: + yield Edge( + kind=ek.DEPENDS_ON, + start=EdgePath(value=self.node_id, match_by="id"), + end=EdgePath(value=dependency_node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + @property + def _environment_edges(self): + if self.environment: + yield Edge( + kind=ek.DEPLOYS_TO, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.ENVIRONMENT, + property_matchers=[ + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), + PropertyMatch(key="short_name", value=self.environment), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _calls_workflows_edge(self): + if self.uses_reusable and self.uses_reusable.startswith("./.github/workflows/"): + yield Edge( + kind=ek.CALLS_WORKFLOW, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.WORKFLOW, + property_matchers=[ + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), + PropertyMatch(key="path", value=self.uses_reusable[2:]), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def edges(self): + yield from self._calls_workflows_edge + yield from self._environment_edges + yield from self._dependency_edges + yield from self._has_job_edge + yield from self._uses_secret_edges + yield from self._uses_variable_edges diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py new file mode 100644 index 0000000..ace5c66 --- /dev/null +++ b/src/openhound_github/models/workflow_step.py @@ -0,0 +1,255 @@ +import json +from dataclasses import dataclass +from typing import Any, ClassVar + +from dlt.common.libs.pydantic import DltConfig +from openhound.core.asset import ( # type: ignore[import-untyped] + BaseAsset, + EdgeDef, + NodeDef, +) +from openhound.core.models.entries_dataclass import ( # type: ignore[import-untyped] + ConditionalEdgePath, + Edge, + EdgePath, + EdgeProperties, + PropertyMatch, +) +from pydantic import BaseModel, Field + +from openhound_github.graph import GHNode, GHNodeProperties +from openhound_github.kinds import edges as ek +from openhound_github.kinds import nodes as nk +from openhound_github.main import app + + +class WorkflowReference(BaseModel): + name: str + context: str | None = None + + +@dataclass +class GHWorkflowStepProperties(GHNodeProperties): + """Workflow step-specific properties. + + Attributes: + step_index: The zero-based step index within the parent job. + type: The step type: `uses`, `run`, or `unknown`. + action: The full action reference from `uses`. + action_slug: The action owner/name without ref. + action_owner: The action owner. + action_name: The action name. + action_ref: The action ref. + is_pinned: Whether the action ref is a full commit SHA. + run: The shell command body for `run` steps. + with_args: The step `with` arguments. + contents: The full parsed step definition. + job_node_id: The parent workflow job node ID. + workflow_node_id: The parent workflow node ID. + repository_name: The containing repository name. + repository_id: The containing repository node ID. + environment_name: The name of the GitHub organization. + """ + + step_index: int | None = None + type: str | None = None + action: str | None = None + action_slug: str | None = None + action_owner: str | None = None + action_name: str | None = None + action_ref: str | None = None + is_pinned: bool = False + run: str | None = None + contents: str | None = None + job_node_id: str | None = None + workflow_node_id: str | None = None + repository_name: str | None = None + repository_id: str | None = None + environment_name: str | None = None + + +@app.asset( + node=NodeDef( + kind=nk.WORKFLOW_STEP, + description="GitHub Actions Workflow Step", + icon="circle-dot", + properties=GHWorkflowStepProperties, + ), + edges=[ + EdgeDef( + start=nk.WORKFLOW_JOB, + end=nk.WORKFLOW_STEP, + kind=ek.HAS_STEP, + description="Workflow job contains step", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_STEP, + end=nk.REPO_SECRET, + kind=ek.USES_SECRET, + description="Workflow step references repository secret", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_STEP, + end=nk.ORG_SECRET, + kind=ek.USES_SECRET, + description="Workflow step references organization secret", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_STEP, + end=nk.REPO_VARIABLE, + kind=ek.USES_VARIABLE, + description="Workflow step references repository variable", + traversable=False, + ), + EdgeDef( + start=nk.WORKFLOW_STEP, + end=nk.ORG_VARIABLE, + kind=ek.USES_VARIABLE, + description="Workflow step references organization variable", + traversable=False, + ), + ], +) +class WorkflowStep(BaseAsset): + """One record from `workflow_steps` -> one GH_WorkflowStep node.""" + + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + + node_id: str + name: str | None = None + step_index: int + type: str + job_node_id: str + workflow_node_id: str + repository_name: str + repository_node_id: str + org_login: str + action: str | None = None + action_slug: str | None = None + action_owner: str | None = None + action_name: str | None = None + action_ref: str | None = None + is_pinned: bool = False + run: str | None = None + with_args: dict[str, Any] | None = None + contents: dict[str, Any] | None = None + secret_references: list[WorkflowReference] = Field(default_factory=list) + variable_references: list[WorkflowReference] = Field(default_factory=list) + + @property + def org_node_id(self) -> str | None: + return self._lookup.org_id_for_login(self.org_login) + + @property + def as_node(self) -> GHNode: + name = self.name or f"step-{self.step_index}" + return GHNode( + kinds=[nk.WORKFLOW_STEP], + properties=GHWorkflowStepProperties( + name=name, + displayname=name, + node_id=self.node_id, + step_index=self.step_index, + type=self.type, + action=self.action, + action_slug=self.action_slug, + action_owner=self.action_owner, + action_name=self.action_name, + action_ref=self.action_ref, + is_pinned=self.is_pinned, + run=self.run, + contents=json.dumps(self.contents), + job_node_id=self.job_node_id, + workflow_node_id=self.workflow_node_id, + repository_name=self.repository_name, + repository_id=self.repository_node_id, + environment_name=self.org_login, + environmentid=self.org_node_id, + ), + ) + + @property + def _uses_secret_edges(self): + for ref in self.secret_references: + if self._lookup.repo_secret(ref.name, self.repository_node_id): + yield Edge( + kind=ek.USES_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.REPO_SECRET, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + if self._lookup.org_secret(ref.name, self.org_login): + yield Edge( + kind=ek.USES_SECRET, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.ORG_SECRET, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _uses_variable_edges(self): + for ref in self.variable_references: + if self._lookup.repo_variable(ref.name, self.repository_node_id): + yield Edge( + kind=ek.USES_VARIABLE, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.REPO_VARIABLE, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + if self._lookup.org_variable(ref.name, self.repository_node_id): + yield Edge( + kind=ek.USES_VARIABLE, + start=EdgePath(value=self.node_id, match_by="id"), + end=ConditionalEdgePath( + kind=nk.ORG_VARIABLE, + property_matchers=[ + PropertyMatch(key="name", value=ref.name), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _has_step_edge(self): + yield Edge( + kind=ek.HAS_STEP, + start=EdgePath(value=self.job_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + @property + def edges(self): + yield from self._has_step_edge + yield from self._uses_secret_edges + yield from self._uses_variable_edges diff --git a/src/openhound_github/resources/__init__.py b/src/openhound_github/resources/__init__.py new file mode 100644 index 0000000..b6be076 --- /dev/null +++ b/src/openhound_github/resources/__init__.py @@ -0,0 +1,2 @@ +from .enterprise import enterprise as enterprise_resource +from .enterprise import enterprise_members diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py new file mode 100644 index 0000000..16f9408 --- /dev/null +++ b/src/openhound_github/resources/enterprise.py @@ -0,0 +1,421 @@ +from dataclasses import dataclass + +from dlt.sources.helpers.rest_client.client import RESTClient + +from openhound_github.graphql import ( + ENTERPRISE_ADMINS_QUERY, + ENTERPRISE_MEMBERS_QUERY, + ENTERPRISE_QUERY, + ENTERPRISE_SAML_QUERY, +) +from openhound_github.helpers import GraphQLCursorPaginator +from openhound_github.main import app +from openhound_github.models import ( + BaseUser, + Enterprise, + EnterpriseAdmin, + EnterpriseExternalIdentity, + EnterpriseManagedUser, + EnterpriseOrganization, + EnterpriseRole, + EnterpriseRoleTeam, + EnterpriseRoleUser, + EnterpriseSamlProvider, + EnterpriseTeam, + EnterpriseTeamMember, + EnterpriseTeamOrganization, + EnterpriseTeamRole, + EnterpriseUser, +) + + +@dataclass +class SourceContext: + """Shared context for GitHub API access.""" + + client: RESTClient + org_name: str | None = None + enterprise_name: str | None = None + + +@app.resource(name="enterprise", columns=Enterprise, parallelized=True) +def enterprise(ctx: SourceContext): + + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.organizations.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": ENTERPRISE_QUERY, + "variables": {"slug": ctx.enterprise_name, "after": None}, + } + + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + page_enterprise = page_data[0].get("enterprise") + + if page_enterprise: + yield page_enterprise + + +@app.transformer( + name="enterprise_organizations", columns=EnterpriseOrganization, parallelized=True +) +def enterprise_organizations(enterprise_data: Enterprise, ctx: SourceContext): + orgs = (enterprise_data.organizations or {}).get("nodes", []) + for org in orgs: + yield { + **org, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer(name="enterprise_members", columns=BaseUser, parallelized=True) +def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): + + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.members.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": ENTERPRISE_MEMBERS_QUERY, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, + } + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for enterprise_object in page_data: + es_data = enterprise_object.get("enterprise", {}) + members = es_data.get("members", {}) + for edge in members.get("edges", []): + node = edge.get("node") + if node: + yield { + **node, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer(name="enterprise_users", columns=EnterpriseUser, parallelized=True) +def enterprise_users(base_user: BaseUser, ctx: SourceContext): + if base_user.typename == "EnterpriseUserAccount": + if base_user.user and base_user.user.id: + yield { + **base_user.user.model_dump(), + "enterprise_slug": ctx.enterprise_name, + "has_direct_enterprise_membership": False, + } + + if base_user.id: + yield { + **base_user.model_dump(), + "enterprise_slug": ctx.enterprise_name, + "has_direct_enterprise_membership": True, + } + + +@app.transformer( + name="enterprise_managed_users", columns=EnterpriseManagedUser, parallelized=True +) +def enterprise_managed_users(base_user: BaseUser, ctx: SourceContext): + if base_user.typename == "EnterpriseUserAccount": + yield { + **base_user.model_dump(), + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer(name="enterprise_teams", columns=EnterpriseTeam, parallelized=True) +def enterprise_teams(enterprise_data: Enterprise, ctx: SourceContext): + + for page in ctx.client.paginate( + f"/enterprises/{ctx.enterprise_name}/teams", params={"per_page": 100} + ): + for team in page: + yield { + **team, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer( + name="enterprise_team_roles", columns=EnterpriseTeamRole, parallelized=True +) +def enterprise_team_roles(team: EnterpriseTeam): + yield { + "id": team.id, + "name": team.name, + "slug": team.slug, + "enterprise_node_id": team.enterprise_node_id, + "enterprise_slug": team.enterprise_slug, + } + + +@app.transformer( + name="enterprise_team_members", columns=EnterpriseTeamMember, parallelized=True +) +def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): + + for page in ctx.client.paginate( + f"/enterprises/{ctx.enterprise_name}/teams/{team.id}/memberships", + params={"per_page": 100}, + ): + for member in page: + node_id = member.get("node_id") or member.get("user", {}).get("node_id") + if node_id: + yield { + **member, + "node_id": node_id, + "team_id": team.id, + "enterprise_node_id": team.enterprise_node_id, + "enterprise_slug": team.enterprise_slug, + } + + +@app.transformer( + name="enterprise_team_organizations", + columns=EnterpriseTeamOrganization, + parallelized=True, +) +def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): + + for page in ctx.client.paginate( + f"/enterprises/{ctx.enterprise_name}/teams/{team.id}/organizations", + params={"per_page": 100}, + ): + for org in page: + node_id = org.get("node_id") or org.get("id") + if node_id: + yield { + **org, + "node_id": node_id, + "team_id": team.id, + "projected_slug": team.slug, + "enterprise_node_id": team.enterprise_node_id, + "enterprise_slug": team.enterprise_slug, + } + + +@app.transformer(name="enterprise_roles", columns=EnterpriseRole, parallelized=True) +def enterprise_roles(enterprise_data: Enterprise, ctx: SourceContext): + result = ctx.client.get( + f"/enterprises/{ctx.enterprise_name}/enterprise-roles" + ).json() + + for role in result.get("roles", []): + yield { + **role, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + yield { + "id": "owners", + "name": "owners", + "description": "Enterprise administrators discovered from ownerInfo.admins", + "source": "Default", + "permissions": [], + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer( + name="enterprise_role_teams", columns=EnterpriseRoleTeam, parallelized=True +) +def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): + if role.id == "owners": + return + + for page in ctx.client.paginate( + f"/enterprises/{ctx.enterprise_name}/enterprise-roles/{role.id}/teams", + params={"per_page": 100}, + ): + for team in page: + if team.get("id"): + yield { + **team, + "role_id": role.id, + "enterprise_node_id": role.enterprise_node_id, + "enterprise_slug": role.enterprise_slug, + } + + +@app.transformer( + name="enterprise_role_users", columns=EnterpriseRoleUser, parallelized=True +) +def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): + if role.id == "owners": + return + + for page in ctx.client.paginate( + f"/enterprises/{ctx.enterprise_name}/enterprise-roles/{role.id}/users", + params={"per_page": 100}, + ): + for user in page: + if user.get("node_id"): + yield { + **user, + "role_id": role.id, + "enterprise_node_id": role.enterprise_node_id, + "enterprise_slug": role.enterprise_slug, + } + + +@app.transformer(name="enterprise_admins", columns=EnterpriseAdmin, parallelized=True) +def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext): + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.ownerInfo.admins.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + allow_missing_page_info=True, + ) + data = { + "query": ENTERPRISE_ADMINS_QUERY, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, + } + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for enterprise_object in page_data: + es_data = enterprise_object.get("enterprise", {}) + owner_info = es_data.get("ownerInfo") or {} + for edge in (owner_info.get("admins") or {}).get("edges") or []: + node = edge.get("node") + if node and node.get("id"): + yield { + "node_id": node["id"], + "login": node.get("login"), + "assignment": "direct", + "role_id": "owners", + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer( + name="enterprise_saml_provider", columns=EnterpriseSamlProvider, parallelized=True +) +def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + allow_missing_page_info=True, + ) + data = { + "query": ENTERPRISE_SAML_QUERY, + "variables": {"slug": ctx.enterprise_name, "count": 1, "after": None}, + } + + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for enterprise_object in page_data: + es_data = enterprise_object.get("enterprise", {}) + saml_provider = (es_data.get("ownerInfo") or {}).get("samlIdentityProvider") + if not saml_provider: + return + yield { + **{k: v for k, v in saml_provider.items() if k != "externalIdentities"}, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, + } + + +@app.transformer( + name="enterprise_external_identities", + columns=EnterpriseExternalIdentity, + parallelized=True, +) +def enterprise_external_identities( + saml_provider: EnterpriseSamlProvider, ctx: SourceContext +): + + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + allow_missing_page_info=True, + ) + data = { + "query": ENTERPRISE_SAML_QUERY, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, + } + + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for enterprise_object in page_data: + es_data = enterprise_object.get("enterprise", {}) + page_provider = (es_data.get("ownerInfo") or {}).get("samlIdentityProvider") + if not page_provider: + return + for identity in (page_provider.get("externalIdentities") or {}).get( + "nodes" + ) or []: + yield { + **identity, + "saml_provider_id": saml_provider.id, + "saml_provider_issuer": saml_provider.issuer, + "saml_provider_sso_url": saml_provider.sso_url, + "enterprise_node_id": saml_provider.enterprise_node_id, + "enterprise_slug": saml_provider.enterprise_slug, + } + + +def enterprise_resources(ctx: SourceContext): + enterprise_resource = enterprise(ctx) + organizations_resource = enterprise_organizations(ctx) + members_resource = enterprise_members(ctx) + teams_resource = enterprise_teams(ctx) + roles_resource = enterprise_roles(ctx) + saml_resource = enterprise_saml_provider(ctx) + return ( + enterprise_resource, + enterprise_resource | organizations_resource, + enterprise_resource | members_resource | enterprise_users(ctx), + enterprise_resource | members_resource | enterprise_managed_users(ctx), + enterprise_resource | teams_resource, + enterprise_resource | teams_resource | enterprise_team_roles, + enterprise_resource | teams_resource | enterprise_team_members(ctx), + enterprise_resource | teams_resource | enterprise_team_organizations(ctx), + enterprise_resource | roles_resource, + enterprise_resource | roles_resource | enterprise_role_users(ctx), + enterprise_resource | roles_resource | enterprise_role_teams(ctx), + # enterprise_resource | enterprise_admin_roles(ctx), + enterprise_resource | enterprise_admins(ctx), + enterprise_resource | saml_resource, + enterprise_resource | saml_resource | enterprise_external_identities(ctx), + ) diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py new file mode 100644 index 0000000..1e019a7 --- /dev/null +++ b/src/openhound_github/resources/organization.py @@ -0,0 +1,1612 @@ +import base64 +import binascii +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Iterator + +import dlt +from dlt.sources.helpers import requests +from dlt.sources.helpers.rest_client.client import RESTClient +from dlt.sources.helpers.rest_client.paginators import ( + OffsetPaginator, +) + +from openhound_github.graphql import ( + MEMBERS_WITH_ROLE_QUERY, + PROTECTION_RULES_QUERY, + REF_OVERFLOW_QUERY, + REPO_REFS_QUERY, + SAML_IDENTITIES_QUERY, + SAML_QUERY, + TEAM_MEMBERS_OVERFLOW_QUERY, + TEAMS_QUERY, +) +from openhound_github.helpers import GraphQLCursorPaginator +from openhound_github.main import app +from openhound_github.models import ( + ActionPermission, + App, + AppInstallation, + BaseRepoRole, + Branch, + BranchProtectionRule, + Environment, + EnvironmentBranchPolicy, + EnvironmentSecret, + EnvironmentVariable, + ExternalIdentity, + Organization, + OrgRole, + OrgRoleMember, + OrgRoleTeam, + OrgRunner, + OrgRunnerGroupMembership, + OrgSecret, + OrgVariable, + PatRepoAccess, + PersonalAccessToken, + PersonalAccessTokenRequest, + RepoRole, + RepoRoleAssignment, + RepoRunner, + RepoSecret, + Repository, + RepositoryQL, + RepoVariable, + RunnerGroup, + SamlProvider, + ScimResource, + SecretScanningAlert, + SelectedOrgSecret, + SelectedOrgVariable, + Team, + TeamMember, + TeamRole, + User, + Workflow, + WorkflowJob, + WorkflowStep, +) +from openhound_github.models.repo_role_assignment import TEAM_PERMISSION_MAP +from openhound_github.models.repository_role import DEFAULT_REPO_ROLES + + +@dataclass +class OrgContext: + client: RESTClient + org_name: str + enterprise_name: str | None = None + + +@dataclass +class SourceContext: + client: RESTClient + organizations: list[OrgContext] = field(default_factory=list) + enterprise_name: str | None = None + + +def _client_for_org(ctx: SourceContext, org_login: str) -> RESTClient: + for org in ctx.organizations: + if org.org_name == org_login: + return org.client + return ctx.client + + +def _runner_group_repo_node_ids( + group: dict[str, Any], client: RESTClient, repos: list, org_login: str +) -> list[str]: + visibility = group.get("visibility") + if visibility == "selected": + repo_node_ids: list[str] = [] + try: + for page in client.paginate( + f"/orgs/{org_login}/actions/runner-groups/{group['id']}/repositories", + params={"per_page": 100}, + data_selector="repositories", + ): + repo_node_ids.extend( + repo.get("node_id") for repo in page if repo.get("node_id") + ) + except Exception: + return [] + return repo_node_ids + + repo_node_ids = [] + for repo in repos: + repo_org_login = ( + repo.org_login if isinstance(repo, Repository) else repo.get("org_login") + ) + if repo_org_login != org_login: + continue + repo_node_id = ( + repo.node_id if isinstance(repo, Repository) else repo.get("node_id") + ) + repo_visibility = ( + repo.visibility if isinstance(repo, Repository) else repo.get("visibility") + ) + if visibility == "all": + repo_node_ids.append(repo_node_id) + elif visibility == "private" and repo_visibility in { + "private", + "internal", + }: + repo_node_ids.append(repo_node_id) + return [node_id for node_id in repo_node_ids if node_id] + + +@app.resource(name="organizations", columns=Organization, parallelized=True) +def organizations(ctx: SourceContext): + """Fetch organization details, actions permissions, and org roles from GitHub API. + + Yields organization data to the `organizations` table, custom and default org roles + to the `org_roles` table, and per-role user/team assignments to `org_role_members` + and `org_role_teams` tables via dlt.mark.with_table_name(). + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + Organization (Organization): Organization, org role, member, and team records. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + org_data = client.get(f"/orgs/{org_name}").json() + + actions = client.get(f"/orgs/{org_name}/actions/permissions").json() + self_hosted_runners = client.get( + f"/orgs/{org_name}/actions/permissions/self-hosted-runners" + ).json() + workflow_perms = client.get( + f"/orgs/{org_name}/actions/permissions/workflow" + ).json() + + org_data["actions_enabled_repositories"] = actions.get("enabled_repositories") + org_data["actions_allowed_actions"] = actions.get("allowed_actions") + org_data["actions_sha_pinning_required"] = actions.get("sha_pinning_required") + org_data["self_hosted_runners_enabled_repositories"] = self_hosted_runners.get( + "enabled_repositories" + ) + org_data["default_workflow_permissions"] = workflow_perms.get( + "default_workflow_permissions" + ) + org_data["can_approve_pull_request_reviews"] = workflow_perms.get( + "can_approve_pull_request_reviews" + ) + + yield org_data + + +@app.transformer(name="org_roles", columns=OrgRole, parallelized=True) +def org_roles(org: Organization, ctx: SourceContext): + """Fetch default and custom organization roles from the GitHub API. + + Yields the built-in 'owners' and 'members' roles, then fetches any custom + organization roles from the API. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + orgs (list[dict]): Organization records from the organizations resource. + + Yields: + OrgRole (OrgRole): Organization role record. + """ + + yield { + "id": 1, + "name": "owners", + "type": "default", + "base_role": "admin", + "created_at": datetime.now().isoformat(), + "permissions": [], + "org_node_id": org.node_id, + "org_login": org.login, + } + + yield { + "id": 2, + "name": "members", + "type": "default", + "created_at": datetime.now().isoformat(), + "base_role": org.default_repository_permission, + "permissions": [], + "org_node_id": org.node_id, + "org_login": org.login, + } + + client = _client_for_org(ctx, org.login) + for page in client.paginate( + f"/orgs/{org.login}/organization-roles", params={"per_page": 100} + ): + for role in page: + yield { + **role, + "type": "custom", + "org_node_id": org.node_id, + "org_login": org.login, + } + + +@app.transformer(name="org_role_teams", columns=OrgRoleTeam, parallelized=True) +def org_role_teams(role: OrgRole, ctx: SourceContext): + """Fetch teams assigned to a custom organization role. + + Only fetches team assignments for custom roles (not default built-in roles). + + Args: + role (OrgRole): The organization role to fetch team assignments for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + OrgRoleTeam (OrgRoleTeam): Team-to-org-role assignment record. + """ + + if role.type == "custom": + client = _client_for_org(ctx, role.org_login) + for page in client.paginate( + f"/orgs/{role.org_login}/organization-roles/{role.id}/teams" + ): + for team in page: + yield { + "org_role_id": role.id, + "org_role_name": role.name, + "org_node_id": role.org_node_id, + "org_login": role.org_login, + **team, + } + + +@app.transformer(name="org_role_members", columns=OrgRoleMember, parallelized=True) +def org_role_members(role: OrgRole, ctx: SourceContext): + """Fetch users assigned to a custom organization role. + + Only fetches user assignments for custom roles (not default built-in roles). + + Args: + role (OrgRole): The organization role to fetch user assignments for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + OrgRoleMember (OrgRoleMember): User-to-org-role assignment record. + """ + if role.type == "custom": + client = _client_for_org(ctx, role.org_login) + for page in client.paginate( + f"/orgs/{role.org_login}/organization-roles/{role.id}/users" + ): + for user in page: + yield { + **user, + "org_role_name": role.name, + "org_role_id": role.id, + "org_node_id": role.org_node_id, + "org_login": role.org_login, + } + + +@app.resource(name="app_installations", columns=AppInstallation, parallelized=True) +def app_installations(ctx: SourceContext): + """Fetch GitHub App installations for the organization. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + AppInstallation (AppInstallation): App installation record. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate(f"/orgs/{org_name}/installations"): + for item in page: + yield {**item, "org_login": org_name} + + +@app.transformer(name="applications", columns=App, parallelized=True) +def applications(app_install: AppInstallation, ctx: SourceContext): + """Fetch full GitHub App details for an installed app. + + Args: + app_install (AppInstallation): The app installation to fetch app details for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + App (App): GitHub App record. + """ + app_slug = app_install.app_slug if app_install.app_slug else app_install.app_id + if app_install.id: + client = _client_for_org(ctx, app_install.org_login) + app_data = client.get(f"/apps/{app_slug}").json() + if app_data.get("node_id"): + yield {**app_data, "slug": app_slug, "org_login": app_install.org_login} + + +@app.resource(name="users", columns=User, parallelized=True) +def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: + """Fetch organization members from GitHub GraphQL API, including their org role. + + Uses the membersWithRole connection to retrieve user details (login, name, email, + company) and organization role (ADMIN or MEMBER) in a single paginated query. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + User (User): User record. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + paginator = GraphQLCursorPaginator( + page_info_path="data.organization.membersWithRole.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": MEMBERS_WITH_ROLE_QUERY, + "variables": {"login": org_name, "count": 100, "after": None}, + } + + for page_data in client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for edge in page_data[0]["organization"]["membersWithRole"]["edges"]: + node = edge.get("node", {}) + yield {**node, **edge, "org_login": org_name} + + +@app.resource(name="teams", columns=Team, parallelized=True) +def teams(ctx: SourceContext): + """Fetch teams and team member assignments from GitHub GraphQL API. + + Uses the Organization.teams connection with nested IMMEDIATE members to fetch + team info and member-role assignments in a single paginated query. Teams with + more than 100 members are followed up with additional member pagination queries. + + Yields team records to the `teams` table and member-role records to the + `team_members` table via dlt.mark.with_table_name(). + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + Team (Team): Team record lol!. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + paginator = GraphQLCursorPaginator( + page_info_path="data.organization.teams.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": TEAMS_QUERY, + "variables": {"login": org_name, "count": 100, "after": None}, + } + for page_data in client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + teams_data = page_data[0]["organization"]["teams"] + for team in teams_data["nodes"]: + yield {**team, "org_login": org_name} + + +@app.transformer(name="team_roles", columns=TeamRole, parallelized=True) +def team_roles(team: Team): + """Yield the two built-in team roles (members and maintainers) for a team. + + Args: + team (Team): The team to generate role records for. + + Yields: + TeamRole (TeamRole): Team role records (one for members, one for maintainers). + """ + for role_type in ("members", "maintainers"): + yield { + "type": role_type, + "team_node_id": team.node_id, + "team_name": team.name, + "team_slug": team.slug, + "org_login": team.org_login, + } + + +@app.transformer(name="team_members", columns=TeamMember, parallelized=True) +def team_members(team: Team, ctx: SourceContext): + """Fetch team member assignments including their role (member or maintainer). + + Processes members from the initial team query and handles overflow pagination + for teams with more than 100 members via a follow-up GraphQL query. + + Args: + team (Team): The team to fetch member assignments for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + TeamMember (TeamMember): Team member assignment record. + """ + for member in team.members.edges: + yield { + "team_id": team.id, + "id": member.node.id, + "login": member.node.login, + "role": member.role, + "org_login": team.org_login, + } + + paginator = GraphQLCursorPaginator( + page_info_path="data.organization.team.members.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + + if team.members.page_info.has_next_page: + if not team.members.page_info.end_cursor: + raise RuntimeError( + f"GitHub team {team.org_login}/{team.slug} has more members but no endCursor" + ) + client = _client_for_org(ctx, team.org_login) + data = { + "query": TEAM_MEMBERS_OVERFLOW_QUERY, + "variables": { + "login": team.org_login, + "count": 100, + "after": team.members.page_info.end_cursor, + "slug": team.slug, + }, + } + for page_data in client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for member in page_data[0]["organization"]["team"]["members"]["edges"]: + yield { + "team_id": team.id, + "id": member["node"]["id"], + "login": member["node"]["login"], + "role": member["role"], + "org_login": team.org_login, + } + + +@app.resource(name="actions_permissions", columns=ActionPermission, parallelized=True) +def actions_permissions(ctx): + for org in ctx.organizations: + org_name = org.org_name + client = org.client + actions = client.get(f"/orgs/{org_name}/actions/permissions").json() + yield { + **actions, + "org_login": org_name, + } + + +@app.resource(name="repositories", columns=Repository, parallelized=True) +def repositories(ctx: SourceContext): + """Fetch repositories for the organization. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + Repository (Repository): Repository record. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + actions = client.get(f"/orgs/{org_name}/actions/permissions").json() + runner_settings = client.get( + f"/orgs/{org_name}/actions/permissions/self-hosted-runners" + ).json() + + enabled_repo_ids: set[str] | None = None + if actions.get("enabled_repositories") == "selected": + enabled_repo_ids = set() + for page in client.paginate( + f"/orgs/{org_name}/actions/permissions/repositories", + params={"per_page": 100}, + data_selector="repositories", + ): + enabled_repo_ids.update( + repo["node_id"] for repo in page if repo.get("node_id") + ) + + runner_enabled_repo_ids: set[str] | None = None + if runner_settings.get("enabled_repositories") == "selected": + runner_enabled_repo_ids = set() + for page in client.paginate( + f"/orgs/{org_name}/actions/permissions/self-hosted-runners/repositories", + params={"per_page": 100}, + data_selector="repositories", + ): + runner_enabled_repo_ids.update( + repo["node_id"] for repo in page if repo.get("node_id") + ) + + for page in client.paginate( + f"/orgs/{org_name}/repos", params={"per_page": 100} + ): + for repo in page: + repo_node_id = repo.get("node_id") + actions_enabled = actions.get("enabled_repositories") == "all" or ( + enabled_repo_ids is not None and repo_node_id in enabled_repo_ids + ) + self_hosted_runners_enabled = runner_settings.get( + "enabled_repositories" + ) == "all" or ( + runner_enabled_repo_ids is not None + and repo_node_id in runner_enabled_repo_ids + ) + yield { + **repo, + "actions_enabled": actions_enabled, + "self_hosted_runners_enabled": self_hosted_runners_enabled, + "org_login": org_name, + } + + +@app.transformer( + name="repo_role_assignments", columns=RepoRoleAssignment, parallelized=True +) +def repo_role_assignments( + repo: Repository, ctx: SourceContext, roles: list[dict] +) -> Iterator[dict[str, Any]]: + """Fetch collaborator and team role assignments for each repository. + + For each repo, fetches direct collaborators (affiliation=direct) and team access, + mapping the GitHub permission/role_name to a deterministic repo role node ID + (base64 of "{repo_node_id}_{role_short_name}") that matches the IDs emitted by + the `repositories` resource. + + API endpoints: + - GET /repos/{org}/{repo}/collaborators?affiliation=direct + - GET /repos/{org}/{repo}/teams + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + RepoRoleAssignment (RepoRoleAssignment): Role assignment records for direct collaborators and teams. + """ + + repo_node_id = repo.node_id + repo_name = repo.name + custom_roles = { + role["name"]: BaseRepoRole(**role) + for role in roles + if role.get("org_login") == repo.org_login + } + + client = _client_for_org(ctx, repo.org_login) + for collab_page in client.paginate( + f"/repos/{repo.org_login}/{repo_name}/collaborators", + params={"affiliation": "direct", "per_page": 100}, + ): + for collaborator in collab_page: + role = collaborator.get("role_name", "") + custom_role = custom_roles.get(role) + yield { + **collaborator, + "org_login": repo.org_login, + "assignee_type": "user", + "repo_node_id": repo_node_id, + "repo_name": repo_name, + "role_name": role, + "base_role": custom_role.base_role if custom_role else None, + "role_permissions": custom_role.permissions if custom_role else [], + } + + # Team access + for team_page in client.paginate( + f"/repos/{repo.org_login}/{repo_name}/teams", + params={"per_page": 100}, + ): + for team in team_page: + permission: str = team.get("permission", "") + role = TEAM_PERMISSION_MAP.get(permission, permission) + custom_role = custom_roles.get(role) + yield { + **team, + "assignee_type": "team", + "repo_node_id": repo_node_id, + "org_login": repo.org_login, + "repo_name": repo_name, + "role_name": role, + "base_role": custom_role.base_role if custom_role else None, + "role_permissions": custom_role.permissions if custom_role else [], + } + + +@app.resource(name="repository_roles_base", parallelized=True, columns=BaseRepoRole) +def repository_roles_base(ctx: SourceContext): + """Fetch custom repository roles from GitHub API. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + list[dict] (list[dict]): Pages of raw custom repository role records from the GitHub API. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/custom-repository-roles", params={"per_page": 100} + ): + for item in page: + yield { + **item, + "org_login": org_name, + } + + +@app.transformer(name="repo_roles", columns=RepoRole, parallelized=True) +def repository_roles(repository: Repository, roles: list[dict]): + """Yield default and custom repository role records for a repository. + + Emits the five built-in default roles (read, triage, write, maintain, admin) and + any custom repository roles defined at the organization level. + + Args: + repository (Repository): The repository to generate role records for. + roles (list[dict]): Custom role definitions from repository_roles_base. + + Yields: + RepoRole (RepoRole): Repository role record. + """ + for idx, role in enumerate(DEFAULT_REPO_ROLES): + yield { + "id": idx, + "name": role["name"], + "type": "default", + "base_role": role["base_role"], + "permissions": [], + "repository_full_name": repository.full_name, + "repository_name": repository.name, + "repository_node_id": repository.node_id, + "repository_visibility": repository.visibility, + "org_login": repository.org_login, + } + + for role in roles: + if role.get("org_login") != repository.org_login: + continue + role = BaseRepoRole(**role) + yield { + "id": role.id, + "name": role.name, + "type": "custom", + "base_role": role.base_role, + "permissions": role.permissions, + "repository_full_name": repository.full_name, + "repository_name": repository.name, + "repository_node_id": repository.node_id, + "repository_visibility": repository.visibility, + "org_login": repository.org_login, + } + + +@app.resource(name="repositories_graphql", columns=RepositoryQL, parallelized=True) +def repositories_graphql(ctx: SourceContext): + """Fetch repositories with branch and protection rule metadata via GraphQL. + + Uses the organization repositories connection to retrieve branches with their + associated branch protection rule IDs, used as input for the branches and + branch_protection_rules transformers. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + RepositoryQL (RepositoryQL): Repository record with nested branch ref data. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + paginator = GraphQLCursorPaginator( + page_info_path="data.organization.repositories.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": REPO_REFS_QUERY, + "variables": {"login": org_name, "count": 100, "after": None}, + } + + for page_data in client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + repos_page = page_data[0]["organization"]["repositories"] + for repo in repos_page["nodes"]: + yield {**repo, "org_login": org_name} + + +@app.transformer(name="branches", columns=Branch, parallelized=True) +def branches(repository: RepositoryQL, ctx: SourceContext): + """Yield branches for a repository. + + Processes branches from the initial repository query and handles overflow + pagination for repos with more than 100 branches via a follow-up GraphQL query. + + Args: + repository (RepositoryQL): The repository with refs data. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + Branch (Branch): Branch record. + """ + paginator = GraphQLCursorPaginator( + page_info_path="data.organization.repository.refs.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + + for branch in repository.refs.nodes: + yield { + **branch.model_dump(), + "repository_node_id": repository.id, + "repository_name": repository.name, + "org_login": repository.org_login, + } + + if repository.refs.page_info.has_next_page: + client = _client_for_org(ctx, repository.org_login) + data = { + "query": REF_OVERFLOW_QUERY, + "variables": { + "owner": repository.org_login, + "count": 100, + "after": repository.refs.page_info.end_cursor, + "name": repository.name, + }, + } + + for page_data in client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for branch in page_data[0]["repository"]["refs"]["nodes"]: + yield { + **branch, + "repository_node_id": repository.id, + "repository_name": repository.name, + "org_login": repository.org_login, + } + + +@app.transformer( + name="branch_protection_rules", columns=BranchProtectionRule, parallelized=True +) +def branch_protection_rules(repository: RepositoryQL, ctx: SourceContext): + """Batch-fetch branch protection rule details for a repository. + + For a given repository, extracts all unique protection rule IDs from its branches + (including overflow pagination), batch-fetches the full rule details (settings, allowances), + and yields BranchProtectionRule objects. + + Args: + repository (RepositoryQL): The repository with refs data (can have nested branches). + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + BranchProtectionRule (BranchProtectionRule): Protection rule record with full details. + """ + # Note: multiple branches can reference the same branch protection rule + # so use a set to prevent duplicates + rule_ids_seen: set[str] = set() + for branch in repository.refs.nodes: + if branch.branch_protection_rule: + rule_id = branch.branch_protection_rule.get("id") + if rule_id: + rule_ids_seen.add(rule_id) + + rule_ids_list = list(rule_ids_seen) + client = _client_for_org(ctx, repository.org_login) + for i in range(0, len(rule_ids_list), 100): + rules_chunk = rule_ids_list[i : i + 100] + if rules_chunk: + data = {"query": PROTECTION_RULES_QUERY, "variables": {"ids": rules_chunk}} + response = client.post("/graphql", json=data).json() + for rule in response["data"].get("nodes", []): + yield { + **rule, + "org_login": repository.org_login, + "repository_node_id": repository.id, + "repository_name": repository.name, + } + + +@app.transformer(name="workflows", columns=Workflow, parallelized=True) +def workflows(repo: Repository, ctx: SourceContext): + """Fetch GitHub Actions workflows for a single repository. + + Args: + repo (Repository): The repository to fetch workflows for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + Workflow (Workflow): An active workflow record. + """ + + @dlt.defer + def _workflow_file_contents( + client: RESTClient, repo: Repository, workflow: dict[str, Any] + ) -> dict | None: + path = workflow.get("path") + if not path: + return None + + params = {"ref": repo.default_branch} if repo.default_branch else None + response = client.get( + f"/repos/{repo.full_name}/contents/{path}", params=params + ).json() + + content = response.get("content") + if not content: + return None + + return { + **workflow, + "contents": content, + "branch": repo.default_branch, + "repository_name": repo.name, + "repository_node_id": repo.node_id, + "org_login": repo.org_login, + } + + client = _client_for_org(ctx, repo.org_login) + for page in client.paginate( + f"/repos/{repo.full_name}/actions/workflows", params={"per_page": 100} + ): + for workflow in page: + if workflow.get("state") == "active": + yield _workflow_file_contents(client, repo, workflow) + + +@app.transformer(name="workflow_jobs", columns=WorkflowJob, parallelized=True) +def workflow_jobs(workflow: Workflow): + for row in workflow.workflow_job_rows(): + yield row + + +@app.transformer(name="workflow_steps", columns=WorkflowStep, parallelized=True) +def workflow_steps(workflow: Workflow): + for row in workflow.workflow_step_rows(): + yield row + + +@app.transformer(name="environments", columns=Environment, parallelized=True) +def environments(repo: Repository, ctx: SourceContext): + """Fetch deployment environments for a repository. + + Args: + repo (Repository): The repository to fetch environments for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + Environment (Environment): Deployment environment record. + """ + + full_name = repo.full_name + repo_name = repo.name + repo_node_id = repo.node_id + client = _client_for_org(ctx, repo.org_login) + for page in client.paginate( + f"/repos/{full_name}/environments", + params={"per_page": 100}, + data_selector="environments", + ): + for env in page: + yield { + **env, + "org_login": repo.org_login, + "repository_name": repo_name, + "repository_full_name": full_name, + "repository_node_id": repo_node_id, + } + + +@app.resource(name="runner_groups", columns=RunnerGroup, parallelized=True) +def runner_groups(ctx: SourceContext): + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/actions/runner-groups", + params={"per_page": 100}, + data_selector="runner_groups", + ): + for group in page: + yield { + **group, + "org_login": org_name, + } + + +@app.resource(name="org_runners", columns=OrgRunner, parallelized=True) +def org_runners(ctx: SourceContext): + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/actions/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in page: + yield { + **runner, + "org_login": org_name, + } + + +@app.resource( + name="org_runner_group_memberships", + columns=OrgRunnerGroupMembership, + parallelized=True, +) +def org_runner_group_memberships(ctx: SourceContext, repos: list): + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for group_page in client.paginate( + f"/orgs/{org_name}/actions/runner-groups", + params={"per_page": 100}, + data_selector="runner_groups", + ): + for group in group_page: + accessible_repo_node_ids = _runner_group_repo_node_ids( + group, client, repos, org_name + ) + try: + for runner_page in client.paginate( + f"/orgs/{org_name}/actions/runner-groups/{group['id']}/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in runner_page: + yield { + "runner_group_id": group["id"], + "runner_id": runner["id"], + "accessible_repo_node_ids": accessible_repo_node_ids, + "org_login": org_name, + } + except Exception: + continue + + +@app.transformer(name="repo_runners", columns=RepoRunner, parallelized=True) +def repo_runners(repo: Repository, ctx: SourceContext): + if not repo.self_hosted_runners_enabled: + return + client = _client_for_org(ctx, repo.org_login) + for page in client.paginate( + f"/repos/{repo.full_name}/actions/runners", + params={"per_page": 100}, + data_selector="runners", + ): + for runner in page: + yield { + **runner, + "repository_name": repo.name, + "repository_node_id": repo.node_id, + "repository_full_name": repo.full_name, + "org_login": repo.org_login, + } + + +@app.transformer( + name="environment_variables", columns=EnvironmentVariable, parallelized=True +) +def environment_variables(environment: Environment, ctx: SourceContext): + """Fetch variables for a deployment environment. + + Args: + environment (Environment): The environment to fetch variables for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + EnvironmentVariable (EnvironmentVariable): Environment variable record. + """ + env_name = environment.name + env_node_id = environment.node_id + + full_repo_name = environment.repository_full_name + repo_name = environment.repository_name + repo_node_id = environment.repository_node_id + + client = _client_for_org(ctx, environment.org_login) + for page in client.paginate( + f"/repos/{full_repo_name}/environments/{env_name}/variables" + ): + for item in page: + yield { + **item, + "org_login": environment.org_login, + "environment_node_id": env_node_id, + "environment_name": env_name, + "repository_name": repo_name, + "repository_node_id": repo_node_id, + } + + +@app.transformer( + name="environment_branch_policies", + columns=EnvironmentBranchPolicy, + parallelized=True, +) +def environment_branch_policies(environment: Environment, ctx: SourceContext): + """Fetch deployment branch policies for an environment. + + Only fetches policies when the environment has custom branch policies configured. + + Args: + environment (Environment): The environment to fetch branch policies for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + EnvironmentBranchPolicy (EnvironmentBranchPolicy): Environment branch policy record. + """ + has_custom = environment.deployment_branch_policy + if has_custom: + full_repo_name = environment.repository_full_name + repo_name = environment.repository_name + repo_node_id = environment.repository_node_id + env_name = environment.name + env_node_id = environment.node_id + client = _client_for_org(ctx, environment.org_login) + for page in client.paginate( + f"/repos/{full_repo_name}/environments/{env_name}/deployment-branch-policies" + ): + for policy in page: + yield { + **policy, + "environment_node_id": env_node_id, + "environment_name": env_name, + "repository_name": repo_name, + "repository_node_id": repo_node_id, + "org_login": environment.org_login, + } + + +@app.transformer( + name="environment_secrets", columns=EnvironmentSecret, parallelized=True +) +def environment_secrets(environment: Environment, ctx: SourceContext): + """Fetch secrets for a deployment environment. + + Args: + environment (Environment): The environment to fetch secrets for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + EnvironmentSecret (EnvironmentSecret): Environment secret record. + """ + repo_name = environment.repository_name + repo_node_id = environment.repository_node_id + full_repo_name = environment.repository_full_name + + env_name = environment.name + env_node_id = environment.node_id + client = _client_for_org(ctx, environment.org_login) + for page in client.paginate( + f"/repos/{full_repo_name}/environments/{env_name}/secrets" + ): + for secret in page: + yield { + **secret, + "org_login": environment.org_login, + "repository_name": repo_name, + "repository_node_id": repo_node_id, + "environment_name": env_name, + "environment_node_id": env_node_id, + } + + +@app.resource(name="organization_secrets", columns=OrgSecret, parallelized=True) +def organization_secrets(ctx: SourceContext): + """Fetch organization-level GitHub Actions secrets and variables with repository access. + + Yields records to the following tables: + - organization_secrets: org-level secret metadata + - organization_variables: org-level variable metadata (with values) + - org_secret_repo_access: junction table linking secrets to accessible repos + - org_variable_repo_access: junction table linking variables to accessible repos + + Visibility semantics: + - "all": all org repos have access + - "private": private and internal repos only + - "selected": specific repos fetched via API + + API Reference: + - https://docs.github.com/en/rest/actions/secrets#list-organization-secrets + - https://docs.github.com/en/rest/actions/variables#list-organization-variables + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + OrgSecret (OrgSecret): Organization secret record. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/actions/secrets", params={"per_page": 100} + ): + for item in page: + yield { + **item, + "org_login": org_name, + } + + +@app.transformer( + name="selected_organization_secrets", columns=SelectedOrgSecret, parallelized=True +) +def selected_organization_secrets(secret: OrgSecret, ctx: SourceContext): + """Fetch repositories that a 'selected' visibility organization secret is accessible to. + + Only fetches repository access for secrets with visibility set to 'selected'. + + Args: + secret (OrgSecret): The organization secret to fetch repository access for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + SelectedOrgSecret (SelectedOrgSecret): Secret-to-repository access record. + """ + if secret.visibility == "selected": + client = _client_for_org(ctx, secret.org_login) + for page in client.paginate( + f"/orgs/{secret.org_login}/actions/secrets/{secret.name}/repositories", + params={"per_page": 100}, + ): + for repo in page: + yield { + "name": secret.name, + "repository_full_name": repo["full_name"], + "repository_node_id": repo["node_id"], + "org_login": secret.org_login, + } + + +@app.resource(name="organization_variables", columns=OrgVariable, parallelized=True) +def organization_variables(ctx: SourceContext): + """Fetch organization-level GitHub Actions variables. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + OrgVariable (OrgVariable): Organization variable record. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/actions/variables", params={"per_page": 100} + ): + for item in page: + yield { + **item, + "org_login": org_name, + } + + +@app.transformer( + name="selected_organization_variables", + columns=SelectedOrgVariable, + parallelized=True, +) +def selected_organization_variables(variable: OrgVariable, ctx: SourceContext): + """Fetch repositories that a 'selected' visibility organization variable is accessible to. + + Only fetches repository access for variables with visibility set to 'selected'. + + Args: + variable (OrgVariable): The organization variable to fetch repository access for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + SelectedOrgVariable (SelectedOrgVariable): Variable-to-repository access record. + """ + if variable.visibility == "selected": + client = _client_for_org(ctx, variable.org_login) + for page in client.paginate( + f"/orgs/{variable.org_login}/actions/variables/{variable.name}/repositories", + params={"per_page": 100}, + ): + for repo in page: + yield { + "name": variable.name, + "repository_node_id": repo["node_id"], + "org_login": variable.org_login, + } + + +@app.transformer(name="repository_secrets", columns=RepoSecret, parallelized=True) +def repository_secrets(repo: Repository, ctx: SourceContext): + """Fetch repository-level GitHub Actions secrets for each repository. + + Args: + repo (dict): Individual repository data dictionary from repositories() resource. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + RepoSecret (RepoSecret): Repository secret record. + """ + client = _client_for_org(ctx, repo.org_login) + for page in client.paginate( + f"/repos/{repo.full_name}/actions/secrets", params={"per_page": 100} + ): + for secret in page: + yield { + **secret, + "org_login": repo.org_login, + "repository_name": repo.full_name, + "repository_node_id": repo.node_id, + } + + +@app.transformer(name="repository_variables", columns=RepoVariable, parallelized=True) +def repository_variables(repo: Repository, ctx: SourceContext): + """Fetch repository-level GitHub Actions variables for each repository. + + API Reference: + - https://docs.github.com/en/rest/actions/variables#list-repository-variables + + Args: + repo (Repository): Individual repository data (as model) from repositories() resource. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + RepoVariable (RepoVariable): Repository variable record. + """ + client = _client_for_org(ctx, repo.org_login) + for page in client.paginate( + f"/repos/{repo.full_name}/actions/variables", params={"per_page": 100} + ): + for variable in page: + yield { + **variable, + "org_login": repo.org_login, + "repository_name": repo.full_name, + "repository_node_id": repo.node_id, + } + + +@app.resource( + name="secret_scanning_alerts", columns=SecretScanningAlert, parallelized=True +) +def secret_scanning_alerts(ctx: SourceContext): + """Fetch secret scanning alerts for the organization. + + Produces a flat record per alert with a synthetic node_id. + + API Reference: + - https://docs.github.com/en/rest/secret-scanning/secret-scanning#list-secret-scanning-alerts-for-an-organization + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + SecretScanningAlert (SecretScanningAlert): A secret scanning alert. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/secret-scanning/alerts", params={"per_page": 100} + ): + for alert in page: + valid_token_user_node_id: str | None = None + secret = alert.get("secret") + if ( + alert.get("state") == "open" + and alert.get("secret_type") == "github_personal_access_token" + and secret + ): + try: + resp = requests.get( + "https://api.github.com/user", + headers={"Authorization": f"Bearer {secret}"}, + timeout=10, + ) + if resp.status_code == 200: + valid_token_user_node_id = resp.json().get("node_id") + except Exception: + pass + + yield { + **alert, + "valid_token_user_node_id": valid_token_user_node_id, + "org_login": org_name, + } + + +@app.resource( + name="personal_access_tokens", columns=PersonalAccessToken, parallelized=True +) +def personal_access_tokens(ctx: SourceContext): + """Fetch fine-grained PATs granted access to the organization. + + API Reference: + - https://docs.github.com/en/rest/orgs/personal-access-tokens#list-fine-grained-personal-access-tokens-with-access-to-organization-resources + - https://docs.github.com/en/rest/orgs/personal-access-tokens#list-repositories-a-fine-grained-personal-access-token-has-access-to + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + PersonalAccessToken (PersonalAccessToken): Fine-grained personal access token record. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/personal-access-tokens", params={"per_page": 100} + ): + for pat in page: + yield { + **pat, + "org_login": org_name, + } + + +@app.transformer(name="pat_repo_access", columns=PatRepoAccess, parallelized=True) +def pat_repo_access(pat: PersonalAccessToken, ctx: SourceContext): + """Fetch repositories a fine-grained PAT has access to within the organization. + + Args: + pat (PersonalAccessToken): The personal access token to fetch repository access for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + PatRepoAccess (PatRepoAccess): PAT-to-repository access record. + """ + client = _client_for_org(ctx, pat.org_login) + for page in client.paginate( + f"/orgs/{pat.org_login}/personal-access-tokens/{pat.id}/repositories", + params={"per_page": 100}, + ): + for item in page: + yield {"pat_id": pat.id, **item, "org_login": pat.org_login} + + +@app.resource( + name="personal_access_token_requests", + columns=PersonalAccessTokenRequest, + parallelized=True, +) +def personal_access_token_requests(ctx: SourceContext): + """Fetch pending fine-grained PAT requests for organization resources. + + API Reference: + - https://docs.github.com/en/rest/orgs/personal-access-token-requests#list-requests-to-access-organization-resources-with-fine-grained-personal-access-tokens + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + PersonalAccessTokenRequest (PersonalAccessTokenRequest): PAT request record. + """ + + for org in ctx.organizations: + org_name = org.org_name + client = org.client + for page in client.paginate( + f"/orgs/{org_name}/personal-access-token-requests", + params={"per_page": 100}, + ): + for item in page: + yield { + **item, + "org_login": org_name, + } + + +@app.resource(name="saml_provider", columns=SamlProvider, parallelized=True) +def saml_provider(ctx: SourceContext): + """Fetch the SAML identity provider for the organization. + + Yields records to the saml_provider table only. External identities are yielded + separately by the external_identities transformer. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + SamlProvider (SamlProvider): SAML provider record. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + data = { + "query": SAML_QUERY, + "variables": {"login": org_name, "count": 100, "after": None}, + } + + response = client.post("/graphql", json=data).json() + response_data = response.get("data", {}) + org_data = response_data.get("organization", {}) + if response_data and org_data: + idp = org_data.get("samlIdentityProvider") + if not idp: + continue + + yield { + **idp, + "org_node_id": org_data["id"], + "org_name": org_data["name"], + "org_login": org_name, + } + + +@app.resource(name="external_identities", columns=ExternalIdentity, parallelized=True) +def external_identities(ctx: SourceContext): + """Fetch external identities linked to the SAML provider. + + Args: + saml (SamlProvider): The SAML provider to extract identities for. + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + ExternalIdentity (ExternalIdentity): External identity record. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + paginator = GraphQLCursorPaginator( + page_info_path="data.organization.samlIdentityProvider.externalIdentities.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + allow_missing_page_info=True, + ) + data = { + "query": SAML_IDENTITIES_QUERY, + "variables": {"login": org_name, "count": 100, "after": None}, + } + + for page_data in client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + for org in page_data: + org_data = org.get("organization") + idp = org_data.get("samlIdentityProvider") + if not idp: + continue + for identity in (idp.get("externalIdentities") or {}).get( + "nodes" + ) or []: + yield {**identity, "org_login": org_name} + + +@app.resource(name="scim_users", columns=ScimResource, parallelized=True) +def scim_users(ctx: SourceContext): + """Fetch SCIM users for the organization. + + Args: + ctx (SourceContext): The shared context containing the REST client and organization name. + + Yields: + ScimResource (ScimResource): SCIM user record. + """ + for org in ctx.organizations: + org_name = org.org_name + client = org.client + scim_paginator = OffsetPaginator( + offset_param="startIndex", + limit_param="itemsPerPage", + limit=100, + total_path="totalResults", + ) + for page in client.paginate( + f"/scim/v2/organizations/{org_name}/Users", + params={"startIndex": 1, "itemsPerPage": 100}, + paginator=scim_paginator, + data_selector="Resources", + ): + yield { + **page, + "org_login": org_name, + } + + +def organization_resources(ctx: SourceContext): + org_resource = organizations(ctx) + roles_resource = org_roles(ctx) + repo_roles_base = list(repository_roles_base(ctx)) + repos_resource = repositories(ctx) + workflows_resource = repos_resource | workflows(ctx) + environments_resource = repos_resource | environments(ctx) + personal_access_tokens_resource = personal_access_tokens(ctx) + + teams_resource = teams(ctx) + repositories_graphql_resource = repositories_graphql(ctx) + app_installs_resource = app_installations(ctx) + runner_groups_resource = runner_groups(ctx) + branch_prot_rules_resource = ( + repositories_graphql_resource | branch_protection_rules(ctx) + ) + organization_secrets_resource = organization_secrets(ctx) + organization_vars_resource = organization_variables(ctx) + + return ( + org_resource, + org_resource | roles_resource, + org_resource | roles_resource | org_role_teams(ctx), + org_resource | roles_resource | org_role_members(ctx), + app_installs_resource, + app_installs_resource | applications(ctx), + users(ctx), + actions_permissions(ctx), + repos_resource, + repos_resource | repository_roles(repo_roles_base), + workflows_resource, + workflows_resource | workflow_jobs(), + workflows_resource | workflow_steps(), + repos_resource | repo_runners(ctx), + repos_resource | repository_secrets(ctx), + repos_resource | repository_variables(ctx), + repos_resource | repo_role_assignments(ctx, repo_roles_base), + environments_resource, + environments_resource | environment_variables(ctx), + environments_resource | environment_secrets(ctx), + environments_resource | environment_branch_policies(ctx), + teams_resource, + teams_resource | team_members(ctx), + teams_resource | team_roles(), + runner_groups_resource, + org_runners(ctx), + org_runner_group_memberships(ctx, list(repos_resource)), + personal_access_tokens_resource, + personal_access_tokens_resource | pat_repo_access(ctx), + organization_secrets_resource, + organization_secrets_resource | selected_organization_secrets(ctx), + organization_vars_resource, + organization_vars_resource | selected_organization_variables(ctx), + personal_access_token_requests(ctx), + scim_users(ctx), + repositories_graphql_resource, + repositories_graphql_resource | branches(ctx), + branch_prot_rules_resource, + secret_scanning_alerts(ctx), + saml_provider(ctx), + external_identities(ctx), + ) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index e12cc04..90629db 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -1,6 +1,5 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import Any, Iterator, Optional, Union +from dataclasses import dataclass, field +from typing import Optional, Union import dlt from dlt.common.configuration import configspec @@ -10,110 +9,37 @@ from dlt.sources.helpers.rest_client.client import RESTClient from dlt.sources.helpers.rest_client.paginators import ( HeaderLinkPaginator, - OffsetPaginator, ) from openhound_github.auth import create_github_jwt_session -from openhound_github.graphql import ( - MEMBERS_WITH_ROLE_QUERY, - PROTECTION_RULES_QUERY, - REF_OVERFLOW_QUERY, - REPO_REFS_QUERY, - SAML_IDENTITIES_QUERY, - SAML_QUERY, - TEAM_MEMBERS_OVERFLOW_QUERY, - TEAMS_QUERY, -) -from openhound_github.helpers import GraphQLCursorPaginator from openhound_github.main import app -from openhound_github.models import ( - ActionPermission, - App, - AppInstallation, - BaseRepoRole, - Branch, - BranchProtectionRule, - Environment, - EnvironmentBranchPolicy, - EnvironmentSecret, - EnvironmentVariable, - ExternalIdentity, - Organization, - OrgRole, - OrgRoleMember, - OrgRoleTeam, - OrgRunner, - OrgRunnerGroupMembership, - OrgSecret, - OrgVariable, - PatRepoAccess, - PersonalAccessToken, - PersonalAccessTokenRequest, - RepoRole, - RepoRoleAssignment, - RepoRunner, - RepoSecret, - Repository, - RepositoryQL, - RepoVariable, - RunnerGroup, - SamlProvider, - ScimResource, - SecretScanningAlert, - SelectedOrgSecret, - SelectedOrgVariable, - Team, - TeamMember, - TeamRole, - User, - Workflow, -) -from openhound_github.models.repo_role_assignment import TEAM_PERMISSION_MAP -from openhound_github.models.repository_role import DEFAULT_REPO_ROLES +from .resources.enterprise import enterprise_resources +from .resources.organization import organization_resources -@dataclass -class SourceContext: - """Shared context for GitHub API access.""" +@dataclass +class OrgContext: client: RESTClient org_name: str + enterprise_name: str | None = None -def _runner_group_repo_node_ids( - group: dict[str, Any], ctx: SourceContext, repos: list -) -> list[str]: - visibility = group.get("visibility") - if visibility == "selected": - repo_node_ids: list[str] = [] - try: - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/runner-groups/{group['id']}/repositories", - params={"per_page": 100}, - data_selector="repositories", - ): - repo_node_ids.extend( - repo.get("node_id") for repo in page if repo.get("node_id") - ) - except Exception: - return [] - return repo_node_ids +@dataclass +class SourceContext: + client: RESTClient + organizations: list[OrgContext] | None = field(default_factory=list) + enterprise_name: str | None = None - repo_node_ids = [] - for repo in repos: - if visibility == "all": - repo_node_ids.append(repo["node_id"]) - elif visibility == "private" and repo.get("visibility") in { - "private", - "internal", - }: - repo_node_ids.append(repo["node_id"]) - return repo_node_ids + @property + def org_names(self) -> list[str]: + return [org.org_name for org in self.organizations or []] @configspec class GithubCredentials(CredentialsConfiguration): - org_name: str = None + org_name: str | None = None + enterprise_name: str | None = None def auth(self): pass @@ -124,19 +50,21 @@ class GithubAppCredentials(GithubCredentials): client_id: str = None app_id: str = None key_path: str = None + api_uri: str = "https://api.github.com" def auth(self) -> str: return "app" @property def header(self) -> str: - github_access_token = create_github_jwt_session( + github_app_session = create_github_jwt_session( org_name=self.org_name, client_id=self.client_id, private_key_path=self.key_path, app_id=self.app_id, - ).get_access_token() - return github_access_token + api_uri=self.api_uri, + ) + return github_app_session.get_access_token() @configspec @@ -151,1226 +79,6 @@ def header(self) -> str: return f"{self.token}" -@app.resource(name="organizations", columns=Organization, parallelized=True) -def organizations(ctx: SourceContext): - """Fetch organization details, actions permissions, and org roles from GitHub API. - - Yields organization data to the `organizations` table, custom and default org roles - to the `org_roles` table, and per-role user/team assignments to `org_role_members` - and `org_role_teams` tables via dlt.mark.with_table_name(). - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - Organization (Organization): Organization, org role, member, and team records. - """ - org_data = ctx.client.get(f"/orgs/{ctx.org_name}").json() - - actions = ctx.client.get(f"/orgs/{ctx.org_name}/actions/permissions").json() - self_hosted_runners = ctx.client.get( - f"/orgs/{ctx.org_name}/actions/permissions/self-hosted-runners" - ).json() - workflow_perms = ctx.client.get( - f"/orgs/{ctx.org_name}/actions/permissions/workflow" - ).json() - - org_data["actions_enabled_repositories"] = actions.get("enabled_repositories") - org_data["actions_allowed_actions"] = actions.get("allowed_actions") - org_data["actions_sha_pinning_required"] = actions.get("sha_pinning_required") - org_data["self_hosted_runners_enabled_repositories"] = self_hosted_runners.get( - "enabled_repositories" - ) - org_data["default_workflow_permissions"] = workflow_perms.get( - "default_workflow_permissions" - ) - org_data["can_approve_pull_request_reviews"] = workflow_perms.get( - "can_approve_pull_request_reviews" - ) - - yield org_data - - -@app.resource(name="org_roles", columns=OrgRole, parallelized=True) -def org_roles(ctx: SourceContext, orgs: list[dict]): - """Fetch default and custom organization roles from the GitHub API. - - Yields the built-in 'owners' and 'members' roles, then fetches any custom - organization roles from the API. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - orgs (list[dict]): Organization records from the organizations resource. - - Yields: - OrgRole (OrgRole): Organization role record. - """ - org = Organization(**orgs[0]) - - yield { - "id": 1, - "name": "owners", - "type": "default", - "base_role": "admin", - "created_at": datetime.now().isoformat(), - "permissions": [], - "org_node_id": org.node_id, - "org_login": org.login, - } - - yield { - "id": 2, - "name": "members", - "type": "default", - "created_at": datetime.now().isoformat(), - "base_role": org.default_repository_permission, - "permissions": [], - "org_node_id": org.node_id, - "org_login": org.login, - } - - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/organization-roles", params={"per_page": 100} - ): - for role in page: - yield { - **role, - "type": "custom", - "org_node_id": org.node_id, - "org_login": org.login, - } - - -@app.transformer(name="org_role_teams", columns=OrgRoleTeam, parallelized=True) -def org_role_teams(role: OrgRole, ctx: SourceContext): - """Fetch teams assigned to a custom organization role. - - Only fetches team assignments for custom roles (not default built-in roles). - - Args: - role (OrgRole): The organization role to fetch team assignments for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - OrgRoleTeam (OrgRoleTeam): Team-to-org-role assignment record. - """ - - if role.type == "custom": - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/organization-roles/{role.id}/teams" - ): - for team in page: - yield {"org_role_id": role.id, "org_role_name": role.name, **team} - - -@app.transformer(name="org_role_members", columns=OrgRoleMember, parallelized=True) -def org_role_members(role: OrgRole, ctx: SourceContext): - """Fetch users assigned to a custom organization role. - - Only fetches user assignments for custom roles (not default built-in roles). - - Args: - role (OrgRole): The organization role to fetch user assignments for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - OrgRoleMember (OrgRoleMember): User-to-org-role assignment record. - """ - if role.type == "custom": - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/organization-roles/{role.id}/users" - ): - for user in page: - yield { - **user, - "org_role_name": role.name, - "org_role_id": role.id, - } - - -@app.resource(name="app_installations", columns=AppInstallation, parallelized=True) -def app_installations(ctx: SourceContext): - """Fetch GitHub App installations for the organization. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - AppInstallation (AppInstallation): App installation record. - """ - for page in ctx.client.paginate(f"/orgs/{ctx.org_name}/installations"): - for item in page: - yield item - - -@app.transformer(name="applications", columns=App, parallelized=True) -def applications(app_install: AppInstallation, ctx: SourceContext): - """Fetch full GitHub App details for an installed app. - - Args: - app_install (AppInstallation): The app installation to fetch app details for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - App (App): GitHub App record. - """ - app_slug = app_install.app_slug if app_install.app_slug else app_install.app_id - if app_install.id: - app_data = ctx.client.get(f"/apps/{app_slug}").json() - if app_data.get("node_id"): - yield {**app_data, "slug": app_slug} - - -@app.resource(name="users", columns=User, parallelized=True) -def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: - """Fetch organization members from GitHub GraphQL API, including their org role. - - Uses the membersWithRole connection to retrieve user details (login, name, email, - company) and organization role (ADMIN or MEMBER) in a single paginated query. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - User (User): User record. - """ - paginator = GraphQLCursorPaginator( - page_info_path="data.organization.membersWithRole.pageInfo", - cursor_variable="after", - cursor_field="endCursor", - has_next_field="hasNextPage", - ) - data = { - "query": MEMBERS_WITH_ROLE_QUERY, - "variables": {"login": ctx.org_name, "count": 100, "after": None}, - } - - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - for edge in page_data[0]["organization"]["membersWithRole"]["edges"]: - node = edge.get("node", {}) - yield {**node, **edge} - - -@app.resource(name="teams", columns=Team, parallelized=True) -def teams(ctx: SourceContext): - """Fetch teams and team member assignments from GitHub GraphQL API. - - Uses the Organization.teams connection with nested IMMEDIATE members to fetch - team info and member-role assignments in a single paginated query. Teams with - more than 100 members are followed up with additional member pagination queries. - - Yields team records to the `teams` table and member-role records to the - `team_members` table via dlt.mark.with_table_name(). - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - Team (Team): Team record lol!. - """ - - paginator = GraphQLCursorPaginator( - page_info_path="data.organization.teams.pageInfo", - cursor_variable="after", - cursor_field="endCursor", - has_next_field="hasNextPage", - ) - data = { - "query": TEAMS_QUERY, - "variables": {"login": ctx.org_name, "count": 100, "after": None}, - } - - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - teams_data = page_data[0]["organization"]["teams"] - for team in teams_data["nodes"]: - yield team - - -@app.transformer(name="team_roles", columns=TeamRole, parallelized=True) -def team_roles(team: Team): - """Yield the two built-in team roles (members and maintainers) for a team. - - Args: - team (Team): The team to generate role records for. - - Yields: - TeamRole (TeamRole): Team role records (one for members, one for maintainers). - """ - for role_type in ("members", "maintainers"): - yield { - "type": role_type, - "team_node_id": team.node_id, - "team_name": team.name, - "team_slug": team.slug, - } - - -@app.transformer(name="team_members", columns=TeamMember, parallelized=True) -def team_members(team: Team, ctx: SourceContext): - """Fetch team member assignments including their role (member or maintainer). - - Processes members from the initial team query and handles overflow pagination - for teams with more than 100 members via a follow-up GraphQL query. - - Args: - team (Team): The team to fetch member assignments for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - TeamMember (TeamMember): Team member assignment record. - """ - for member in team.members.edges: - yield { - "team_id": team.id, - "id": member.node.id, - "login": member.node.login, - "role": member.role, - } - - paginator = GraphQLCursorPaginator( - page_info_path="data.organization.team.members.pageInfo", - cursor_variable="after", - cursor_field="endCursor", - has_next_field="hasNextPage", - ) - - if team.members.page_info.has_next_page: - data = { - "query": TEAM_MEMBERS_OVERFLOW_QUERY, - "variables": { - "login": ctx.org_name, - "count": 100, - "after": team.members.page_info.end_cursor, - "slug": team.slug, - }, - } - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - for member in page_data[0]["organization"]["team"]["members"]["edges"]: - yield { - "team_id": team.id, - "id": member["node"]["id"], - "login": member["node"]["login"], - "role": member["role"], - } - - -@app.resource(name="actions_permissions", columns=ActionPermission, parallelized=True) -def actions_permissions(ctx): - actions = ctx.client.get(f"/orgs/{ctx.org_name}/actions/permissions").json() - yield actions - - -@app.resource(name="repositories", columns=Repository, parallelized=True) -def repositories(ctx: SourceContext): - """Fetch repositories for the organization. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - Repository (Repository): Repository record. - """ - actions = ctx.client.get(f"/orgs/{ctx.org_name}/actions/permissions").json() - runner_settings = ctx.client.get( - f"/orgs/{ctx.org_name}/actions/permissions/self-hosted-runners" - ).json() - - enabled_repo_ids: set[str] | None = None - if actions.get("enabled_repositories") == "selected": - enabled_repo_ids = set() - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/permissions/repositories", - params={"per_page": 100}, - data_selector="repositories", - ): - enabled_repo_ids.update( - repo["node_id"] for repo in page if repo.get("node_id") - ) - - runner_enabled_repo_ids: set[str] | None = None - if runner_settings.get("enabled_repositories") == "selected": - runner_enabled_repo_ids = set() - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/permissions/self-hosted-runners/repositories", - params={"per_page": 100}, - data_selector="repositories", - ): - runner_enabled_repo_ids.update( - repo["node_id"] for repo in page if repo.get("node_id") - ) - - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/repos", params={"per_page": 100} - ): - for repo in page: - repo_node_id = repo.get("node_id") - actions_enabled = actions.get("enabled_repositories") == "all" or ( - enabled_repo_ids is not None and repo_node_id in enabled_repo_ids - ) - self_hosted_runners_enabled = runner_settings.get( - "enabled_repositories" - ) == "all" or ( - runner_enabled_repo_ids is not None - and repo_node_id in runner_enabled_repo_ids - ) - yield { - **repo, - "actions_enabled": actions_enabled, - "self_hosted_runners_enabled": self_hosted_runners_enabled, - } - - -@app.transformer( - name="repo_role_assignments", columns=RepoRoleAssignment, parallelized=True -) -def repo_role_assignments( - repo: Repository, ctx: SourceContext, roles: list[dict] -) -> Iterator[dict[str, Any]]: - """Fetch collaborator and team role assignments for each repository. - - For each repo, fetches direct collaborators (affiliation=direct) and team access, - mapping the GitHub permission/role_name to a deterministic repo role node ID - (base64 of "{repo_node_id}_{role_short_name}") that matches the IDs emitted by - the `repositories` resource. - - API endpoints: - - GET /repos/{org}/{repo}/collaborators?affiliation=direct - - GET /repos/{org}/{repo}/teams - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - RepoRoleAssignment (RepoRoleAssignment): Role assignment records for direct collaborators and teams. - """ - - repo_node_id = repo.node_id - repo_name = repo.name - custom_roles = {role["name"]: BaseRepoRole(**role) for role in roles} - - for collab_page in ctx.client.paginate( - f"/repos/{ctx.org_name}/{repo_name}/collaborators", - params={"affiliation": "direct", "per_page": 100}, - ): - for collaborator in collab_page: - role = collaborator.get("role_name", "") - custom_role = custom_roles.get(role) - yield { - **collaborator, - "assignee_type": "user", - "repo_node_id": repo_node_id, - "repo_name": repo_name, - "role_name": role, - "base_role": custom_role.base_role if custom_role else None, - "role_permissions": custom_role.permissions if custom_role else [], - } - - # Team access - for team_page in ctx.client.paginate( - f"/repos/{ctx.org_name}/{repo_name}/teams", - params={"per_page": 100}, - ): - for team in team_page: - permission: str = team.get("permission", "") - role = TEAM_PERMISSION_MAP.get(permission, permission) - custom_role = custom_roles.get(role) - yield { - **team, - "assignee_type": "team", - "repo_node_id": repo_node_id, - "repo_name": repo_name, - "role_name": role, - "base_role": custom_role.base_role if custom_role else None, - "role_permissions": custom_role.permissions if custom_role else [], - } - - -@app.resource(name="repository_roles_base", parallelized=True, columns=BaseRepoRole) -def repository_roles_base(ctx: SourceContext): - """Fetch custom repository roles from GitHub API. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - list[dict] (list[dict]): Pages of raw custom repository role records from the GitHub API. - """ - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/custom-repository-roles", params={"per_page": 100} - ): - yield page - - -@app.transformer(name="repo_roles", columns=RepoRole, parallelized=True) -def repository_roles(repository: Repository, roles: list[dict]): - """Yield default and custom repository role records for a repository. - - Emits the five built-in default roles (read, triage, write, maintain, admin) and - any custom repository roles defined at the organization level. - - Args: - repository (Repository): The repository to generate role records for. - roles (list[dict]): Custom role definitions from repository_roles_base. - - Yields: - RepoRole (RepoRole): Repository role record. - """ - for idx, role in enumerate(DEFAULT_REPO_ROLES): - yield { - "id": idx, - "name": role["name"], - "type": "default", - "base_role": role["base_role"], - "permissions": [], - "repository_full_name": repository.full_name, - "repository_name": repository.name, - "repository_node_id": repository.node_id, - "repository_visibility": repository.visibility, - } - - for role in roles: - role = BaseRepoRole(**role) - yield { - "id": role.id, - "name": role.name, - "type": "custom", - "base_role": role.base_role, - "permissions": role.permissions, - "repository_full_name": repository.full_name, - "repository_name": repository.name, - "repository_node_id": repository.node_id, - "repository_visibility": repository.visibility, - } - - -@app.resource(name="repositories_graphql", columns=RepositoryQL, parallelized=True) -def repositories_graphql(ctx: SourceContext): - """Fetch repositories with branch and protection rule metadata via GraphQL. - - Uses the organization repositories connection to retrieve branches with their - associated branch protection rule IDs, used as input for the branches and - branch_protection_rules transformers. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - RepositoryQL (RepositoryQL): Repository record with nested branch ref data. - """ - paginator = GraphQLCursorPaginator( - page_info_path="data.organization.repositories.pageInfo", - cursor_variable="after", - cursor_field="endCursor", - has_next_field="hasNextPage", - ) - data = { - "query": REPO_REFS_QUERY, - "variables": {"login": ctx.org_name, "count": 100, "after": None}, - } - - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - repos_page = page_data[0]["organization"]["repositories"] - for repo in repos_page["nodes"]: - yield repo - - -@app.transformer(name="branches", columns=Branch, parallelized=True) -def branches(repository: RepositoryQL, ctx: SourceContext): - """Yield branches for a repository. - - Processes branches from the initial repository query and handles overflow - pagination for repos with more than 100 branches via a follow-up GraphQL query. - - Args: - repository (RepositoryQL): The repository with refs data. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - Branch (Branch): Branch record. - """ - paginator = GraphQLCursorPaginator( - page_info_path="data.organization.repository.refs.pageInfo", - cursor_variable="after", - cursor_field="endCursor", - has_next_field="hasNextPage", - ) - - for branch in repository.refs.nodes: - yield { - **branch.model_dump(), - "repository_node_id": repository.id, - "repository_name": repository.name, - } - - if repository.refs.page_info.has_next_page: - data = { - "query": REF_OVERFLOW_QUERY, - "variables": { - "owner": ctx.org_name, - "count": 100, - "after": repository.refs.page_info.end_cursor, - "name": repository.name, - }, - } - - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - for branch in page_data[0]["repository"]["refs"]["nodes"]: - yield { - **branch, - "repository_node_id": repository.id, - "repository_name": repository.name, - } - - -@app.transformer( - name="branch_protection_rules", columns=BranchProtectionRule, parallelized=True -) -def branch_protection_rules(repository: RepositoryQL, ctx: SourceContext): - """Batch-fetch branch protection rule details for a repository. - - For a given repository, extracts all unique protection rule IDs from its branches - (including overflow pagination), batch-fetches the full rule details (settings, allowances), - and yields BranchProtectionRule objects. - - Args: - repository (RepositoryQL): The repository with refs data (can have nested branches). - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - BranchProtectionRule (BranchProtectionRule): Protection rule record with full details. - """ - # Note: multiple branches can reference the same branch protection rule - # so use a set to prevent duplicates - rule_ids_seen: set[str] = set() - for branch in repository.refs.nodes: - if branch.branch_protection_rule: - rule_id = branch.branch_protection_rule.get("id") - if rule_id: - rule_ids_seen.add(rule_id) - - rule_ids_list = list(rule_ids_seen) - for i in range(0, len(rule_ids_list), 100): - rules_chunk = rule_ids_list[i : i + 100] - if rules_chunk: - data = {"query": PROTECTION_RULES_QUERY, "variables": {"ids": rules_chunk}} - response = ctx.client.post( - f"{ctx.client.base_url}/graphql", json=data - ).json() - for rule in response["data"].get("nodes", []): - yield { - **rule, - "repository_node_id": repository.id, - "repository_name": repository.name, - } - - -@app.transformer(name="workflows", columns=Workflow, parallelized=True) -def workflows(repo: Repository, ctx: SourceContext): - """Fetch GitHub Actions workflows for a single repository. - - Args: - repo (Repository): The repository to fetch workflows for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - Workflow (Workflow): An active workflow record. - """ - for page in ctx.client.paginate( - f"/repos/{repo.full_name}/actions/workflows", params={"per_page": 100} - ): - for workflow in page: - # TODO: Check if we should only store active workflows - if workflow.get("state") == "active": - yield { - **workflow, - "repository_name": repo.name, - "repository_node_id": repo.node_id, - } - - -@app.transformer(name="environments", columns=Environment, parallelized=True) -def environments(repo: Repository, ctx: SourceContext): - """Fetch deployment environments for a repository. - - Args: - repo (Repository): The repository to fetch environments for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - Environment (Environment): Deployment environment record. - """ - - full_name = repo.full_name - repo_name = repo.name - repo_node_id = repo.node_id - for page in ctx.client.paginate( - f"/repos/{full_name}/environments", - params={"per_page": 100}, - data_selector="environments", - ): - for env in page: - yield { - **env, - "repository_name": repo_name, - "repository_full_name": full_name, - "repository_node_id": repo_node_id, - } - - -@app.resource(name="runner_groups", columns=RunnerGroup, parallelized=True) -def runner_groups(ctx: SourceContext): - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/runner-groups", - params={"per_page": 100}, - data_selector="runner_groups", - ): - for group in page: - yield group - - -@app.resource(name="org_runners", columns=OrgRunner, parallelized=True) -def org_runners(ctx: SourceContext): - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/runners", - params={"per_page": 100}, - data_selector="runners", - ): - for runner in page: - yield runner - - -@app.resource( - name="org_runner_group_memberships", - columns=OrgRunnerGroupMembership, - parallelized=True, -) -def org_runner_group_memberships(ctx: SourceContext, repos: list): - for group_page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/runner-groups", - params={"per_page": 100}, - data_selector="runner_groups", - ): - for group in group_page: - accessible_repo_node_ids = _runner_group_repo_node_ids(group, ctx, repos) - try: - for runner_page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/runner-groups/{group['id']}/runners", - params={"per_page": 100}, - data_selector="runners", - ): - for runner in runner_page: - yield { - "runner_group_id": group["id"], - "runner_id": runner["id"], - "accessible_repo_node_ids": accessible_repo_node_ids, - } - except Exception: - continue - - -@app.transformer(name="repo_runners", columns=RepoRunner, parallelized=True) -def repo_runners(repo: Repository, ctx: SourceContext): - if not repo.self_hosted_runners_enabled: - return - for page in ctx.client.paginate( - f"/repos/{repo.full_name}/actions/runners", - params={"per_page": 100}, - data_selector="runners", - ): - for runner in page: - yield { - **runner, - "repository_name": repo.name, - "repository_node_id": repo.node_id, - "repository_full_name": repo.full_name, - } - - -@app.transformer( - name="environment_variables", columns=EnvironmentVariable, parallelized=True -) -def environment_variables(environment: Environment, ctx: SourceContext): - """Fetch variables for a deployment environment. - - Args: - environment (Environment): The environment to fetch variables for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - EnvironmentVariable (EnvironmentVariable): Environment variable record. - """ - env_name = environment.name - env_node_id = environment.node_id - - full_repo_name = environment.repository_full_name - repo_name = environment.repository_name - repo_node_id = environment.repository_node_id - - for page in ctx.client.paginate( - f"/repos/{full_repo_name}/environments/{env_name}/variables" - ): - for item in page: - yield { - **item, - "environment_node_id": env_node_id, - "environment_name": env_name, - "repository_name": repo_name, - "repository_node_id": repo_node_id, - } - - -@app.transformer( - name="environment_branch_policies", - columns=EnvironmentBranchPolicy, - parallelized=True, -) -def environment_branch_policies(environment: Environment, ctx: SourceContext): - """Fetch deployment branch policies for an environment. - - Only fetches policies when the environment has custom branch policies configured. - - Args: - environment (Environment): The environment to fetch branch policies for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - EnvironmentBranchPolicy (EnvironmentBranchPolicy): Environment branch policy record. - """ - has_custom = environment.deployment_branch_policy - if has_custom: - full_repo_name = environment.repository_full_name - repo_name = environment.repository_name - repo_node_id = environment.repository_node_id - env_name = environment.name - env_node_id = environment.node_id - for page in ctx.client.paginate( - f"/repos/{full_repo_name}/environments/{env_name}/deployment-branch-policies" - ): - for policy in page: - yield { - **policy, - "environment_node_id": env_node_id, - "environment_name": env_name, - "repository_name": repo_name, - "repository_node_id": repo_node_id, - } - - -@app.transformer( - name="environment_secrets", columns=EnvironmentSecret, parallelized=True -) -def environment_secrets(environment: Environment, ctx: SourceContext): - """Fetch secrets for a deployment environment. - - Args: - environment (Environment): The environment to fetch secrets for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - EnvironmentSecret (EnvironmentSecret): Environment secret record. - """ - repo_name = environment.repository_name - repo_node_id = environment.repository_node_id - full_repo_name = environment.repository_full_name - - env_name = environment.name - env_node_id = environment.node_id - for page in ctx.client.paginate( - f"/repos/{full_repo_name}/environments/{env_name}/secrets" - ): - for secret in page: - yield { - **secret, - "repository_name": repo_name, - "repository_node_id": repo_node_id, - "environment_name": env_name, - "environment_node_id": env_node_id, - } - - -@app.resource(name="organization_secrets", columns=OrgSecret, parallelized=True) -def organization_secrets(ctx: SourceContext): - """Fetch organization-level GitHub Actions secrets and variables with repository access. - - Yields records to the following tables: - - organization_secrets: org-level secret metadata - - organization_variables: org-level variable metadata (with values) - - org_secret_repo_access: junction table linking secrets to accessible repos - - org_variable_repo_access: junction table linking variables to accessible repos - - Visibility semantics: - - "all": all org repos have access - - "private": private and internal repos only - - "selected": specific repos fetched via API - - API Reference: - - https://docs.github.com/en/rest/actions/secrets#list-organization-secrets - - https://docs.github.com/en/rest/actions/variables#list-organization-variables - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - OrgSecret (OrgSecret): Organization secret record. - """ - - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/secrets", params={"per_page": 100} - ): - for item in page: - yield item - - -@app.transformer( - name="selected_organization_secrets", columns=SelectedOrgSecret, parallelized=True -) -def selected_organization_secrets(secret: OrgSecret, ctx: SourceContext): - """Fetch repositories that a 'selected' visibility organization secret is accessible to. - - Only fetches repository access for secrets with visibility set to 'selected'. - - Args: - secret (OrgSecret): The organization secret to fetch repository access for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - SelectedOrgSecret (SelectedOrgSecret): Secret-to-repository access record. - """ - if secret.visibility == "selected": - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/secrets/{secret.name}/repositories", - params={"per_page": 100}, - ): - for repo in page: - yield { - "name": secret.name, - "repository_full_name": repo["full_name"], - "repository_node_id": repo["node_id"], - } - - -@app.resource(name="organization_variables", columns=OrgVariable, parallelized=True) -def organization_variables(ctx: SourceContext): - """Fetch organization-level GitHub Actions variables. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - OrgVariable (OrgVariable): Organization variable record. - """ - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/variables", params={"per_page": 100} - ): - for item in page: - yield item - - -@app.transformer( - name="selected_organization_variables", - columns=SelectedOrgVariable, - parallelized=True, -) -def selected_organization_variables(variable: OrgVariable, ctx: SourceContext): - """Fetch repositories that a 'selected' visibility organization variable is accessible to. - - Only fetches repository access for variables with visibility set to 'selected'. - - Args: - variable (OrgVariable): The organization variable to fetch repository access for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - SelectedOrgVariable (SelectedOrgVariable): Variable-to-repository access record. - """ - if variable.visibility == "selected": - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/variables/{variable.name}/repositories", - params={"per_page": 100}, - ): - for repo in page: - yield { - "name": variable.name, - "repository_node_id": repo["node_id"], - } - - -@app.transformer(name="repository_secrets", columns=RepoSecret, parallelized=True) -def repository_secrets(repo: Repository, ctx: SourceContext): - """Fetch repository-level GitHub Actions secrets for each repository. - - Args: - repo (dict): Individual repository data dictionary from repositories() resource. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - RepoSecret (RepoSecret): Repository secret record. - """ - for page in ctx.client.paginate( - f"/repos/{repo.full_name}/actions/secrets", params={"per_page": 100} - ): - for secret in page: - yield { - **secret, - "repository_name": repo.full_name, - "repository_node_id": repo.node_id, - } - - -@app.transformer(name="repository_variables", columns=RepoVariable, parallelized=True) -def repository_variables(repo: Repository, ctx: SourceContext): - """Fetch repository-level GitHub Actions variables for each repository. - - API Reference: - - https://docs.github.com/en/rest/actions/variables#list-repository-variables - - Args: - repo (Repository): Individual repository data (as model) from repositories() resource. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - RepoVariable (RepoVariable): Repository variable record. - """ - for page in ctx.client.paginate( - f"/repos/{repo.full_name}/actions/variables", params={"per_page": 100} - ): - for variable in page: - yield { - **variable, - "repository_name": repo.full_name, - "repository_node_id": repo.node_id, - } - - -@app.resource( - name="secret_scanning_alerts", columns=SecretScanningAlert, parallelized=True -) -def secret_scanning_alerts(ctx: SourceContext): - """Fetch secret scanning alerts for the organization. - - Produces a flat record per alert with a synthetic node_id. - - API Reference: - - https://docs.github.com/en/rest/secret-scanning/secret-scanning#list-secret-scanning-alerts-for-an-organization - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - SecretScanningAlert (SecretScanningAlert): A secret scanning alert. - """ - - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/secret-scanning/alerts", params={"per_page": 100} - ): - for alert in page: - valid_token_user_node_id: str | None = None - secret = alert.get("secret") - if ( - alert.get("state") == "open" - and alert.get("secret_type") == "github_personal_access_token" - and secret - ): - try: - resp = requests.get( - "https://api.github.com/user", - headers={"Authorization": f"Bearer {secret}"}, - timeout=10, - ) - if resp.status_code == 200: - valid_token_user_node_id = resp.json().get("node_id") - except Exception: - pass - - yield {**alert, "valid_token_user_node_id": valid_token_user_node_id} - - -@app.resource( - name="personal_access_tokens", columns=PersonalAccessToken, parallelized=True -) -def personal_access_tokens(ctx: SourceContext): - """Fetch fine-grained PATs granted access to the organization. - - API Reference: - - https://docs.github.com/en/rest/orgs/personal-access-tokens#list-fine-grained-personal-access-tokens-with-access-to-organization-resources - - https://docs.github.com/en/rest/orgs/personal-access-tokens#list-repositories-a-fine-grained-personal-access-token-has-access-to - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - PersonalAccessToken (PersonalAccessToken): Fine-grained personal access token record. - """ - - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/personal-access-tokens", params={"per_page": 100} - ): - for pat in page: - yield pat - - -@app.transformer(name="pat_repo_access", columns=PatRepoAccess, parallelized=True) -def pat_repo_access(pat: PersonalAccessToken, ctx: SourceContext): - """Fetch repositories a fine-grained PAT has access to within the organization. - - Args: - pat (PersonalAccessToken): The personal access token to fetch repository access for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - PatRepoAccess (PatRepoAccess): PAT-to-repository access record. - """ - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/personal-access-tokens/{pat['id']}/repositories", - params={"per_page": 100}, - ): - for item in page: - yield {"pat_id": pat["id"], **item} - - -@app.resource( - name="personal_access_token_requests", - columns=PersonalAccessTokenRequest, - parallelized=True, -) -def personal_access_token_requests(ctx: SourceContext): - """Fetch pending fine-grained PAT requests for organization resources. - - API Reference: - - https://docs.github.com/en/rest/orgs/personal-access-token-requests#list-requests-to-access-organization-resources-with-fine-grained-personal-access-tokens - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - PersonalAccessTokenRequest (PersonalAccessTokenRequest): PAT request record. - """ - for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/personal-access-token-requests", - params={"per_page": 100}, - ): - yield page - - -@app.resource(name="saml_provider", columns=SamlProvider, parallelized=True) -def saml_provider(ctx: SourceContext): - """Fetch the SAML identity provider for the organization. - - Yields records to the saml_provider table only. External identities are yielded - separately by the external_identities transformer. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - SamlProvider (SamlProvider): SAML provider record. - """ - data = { - "query": SAML_QUERY, - "variables": {"login": ctx.org_name, "count": 100, "after": None}, - } - - response = ctx.client.post("/graphql", json=data).json() - if response["data"]: - org = response["data"]["organization"] - idp = org.get("samlIdentityProvider") - yield { - **idp, - "org_node_id": org["id"], - "org_name": org["name"], - } - - -@app.resource(name="external_identities", columns=ExternalIdentity, parallelized=True) -def external_identities(ctx: SourceContext): - """Fetch external identities linked to the SAML provider. - - Args: - saml (SamlProvider): The SAML provider to extract identities for. - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - ExternalIdentity (ExternalIdentity): External identity record. - """ - paginator = GraphQLCursorPaginator( - page_info_path="data.organization.samlIdentityProvider.externalIdentities.pageInfo", - cursor_variable="after", - cursor_field="endCursor", - has_next_field="hasNextPage", - ) - data = { - "query": SAML_IDENTITIES_QUERY, - "variables": {"login": ctx.org_name, "count": 100, "after": None}, - } - - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - for identity in page_data[0]["organization"]["samlIdentityProvider"][ - "externalIdentities" - ]["nodes"]: - yield identity - - -@app.resource(name="scim_users", columns=ScimResource, parallelized=True) -def scim_users(ctx: SourceContext): - """Fetch SCIM users for the organization. - - Args: - ctx (SourceContext): The shared context containing the REST client and organization name. - - Yields: - ScimResource (ScimResource): SCIM user record. - """ - scim_paginator = OffsetPaginator( - offset_param="startIndex", - limit_param="itemsPerPage", - limit=100, - total_path="totalResults", - ) - for page in ctx.client.paginate( - f"/scim/v2/organizations/{ctx.org_name}/Users", - params={"startIndex": 1, "itemsPerPage": 100}, - paginator=scim_paginator, - data_selector="Resources", - ): - yield page - - @app.source(name="github", max_table_nesting=0) def source( credentials: Union[ @@ -1385,6 +93,9 @@ def source( host (str): The base GitHub API URL used for API calls. """ + if credentials.enterprise_name and credentials.org_name: + raise ValueError("Specify exactly one of enterprise_name and org_name") + def retry_policy( response: Optional[requests.Response], exception: Optional[BaseException] ) -> bool: @@ -1411,62 +122,51 @@ def retry_policy( paginator=HeaderLinkPaginator(), session=requests.Client(retry_condition=retry_policy).session, ), - org_name=credentials.org_name, ) - repo_roles_base = list(repository_roles_base(ctx)) - repos_resource = repositories(ctx) - environments_resource = repos_resource | environments(ctx) - personal_access_tokens_resource = personal_access_tokens(ctx) - org_resource = organizations(ctx) - org_role_resource = org_roles(ctx, list(org_resource)) - teams_resource = teams(ctx) - repositories_graphql_resource = repositories_graphql(ctx) - app_installs_resource = app_installations(ctx) - runner_groups_resource = runner_groups(ctx) - branch_prot_rules_resource = ( - repositories_graphql_resource | branch_protection_rules(ctx) - ) - organization_secrets_resource = organization_secrets(ctx) - organization_vars_resource = organization_variables(ctx) + def org_client(token: str) -> RESTClient: + return RESTClient( + base_url=host, + headers={ + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + auth=BearerTokenAuth(token=token), + paginator=HeaderLinkPaginator(), + session=requests.Client(retry_condition=retry_policy).session, + ) - return ( - org_resource, - users(ctx), - org_role_resource, - org_role_resource | org_role_members(ctx), - org_role_resource | org_role_teams(ctx), - repos_resource, - repos_resource | repository_roles(repo_roles_base), - repos_resource | workflows(ctx), - repos_resource | repo_runners(ctx), - repos_resource | repository_secrets(ctx), - repos_resource | repository_variables(ctx), - repos_resource | repo_role_assignments(ctx, repo_roles_base), - environments_resource, - environments_resource | environment_variables(ctx), - environments_resource | environment_secrets(ctx), - environments_resource | environment_branch_policies(ctx), - teams_resource, - teams_resource | team_members(ctx), - teams_resource | team_roles(), - runner_groups_resource, - org_runners(ctx), - org_runner_group_memberships(ctx, list(repos_resource)), - personal_access_tokens_resource, - personal_access_tokens_resource | pat_repo_access(ctx), - organization_secrets_resource, - organization_secrets_resource | selected_organization_secrets(ctx), - organization_vars_resource, - organization_vars_resource | selected_organization_variables(ctx), - personal_access_token_requests(ctx), - scim_users(ctx), - repositories_graphql_resource, - repositories_graphql_resource | branches(ctx), - branch_prot_rules_resource, - secret_scanning_alerts(ctx), - saml_provider(ctx), - external_identities(ctx), - app_installs_resource, - app_installs_resource | applications(ctx), - ) + # This will run when a single org_name is specified + if credentials.org_name: + ctx.organizations = [ + OrgContext(client=ctx.client, org_name=credentials.org_name) + ] + return organization_resources(ctx) + + # This will run when enterprise collection is used + # We fetch all orgs for the enterprise and create a client for each org using the GitHub App installation token + elif credentials.enterprise_name: + ctx.enterprise_name = credentials.enterprise_name + if isinstance(credentials, GithubAppCredentials): + github_app_session = create_github_jwt_session( + org_name=None, + client_id=credentials.client_id, + private_key_path=credentials.key_path, + app_id=credentials.app_id, + api_uri=credentials.api_uri, + ) + ctx.organizations = [ + OrgContext( + client=org_client( + github_app_session.installation_token(installation["id"]) + ), + org_name=installation["account"]["login"], + enterprise_name=credentials.enterprise_name, + ) + for installation in github_app_session.list_installations() + if installation.get("account", {}).get("type") == "Organization" + ] + return (*enterprise_resources(ctx), *organization_resources(ctx)) + + else: + raise ValueError("Must specify either enterprise_name or org_name") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..657e714 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1680 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'", + "python_full_version == '3.14.*' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'", + "(python_full_version >= '3.15' and platform_python_implementation == 'PyPy') or (python_full_version >= '3.15' and sys_platform == 'emscripten')", + "(python_full_version == '3.14.*' and platform_python_implementation == 'PyPy') or (python_full_version == '3.14.*' and sys_platform == 'emscripten')", + "python_full_version < '3.14' and platform_python_implementation != 'PyPy' and sys_platform != 'emscripten'", + "(python_full_version < '3.14' and platform_python_implementation == 'PyPy') or (python_full_version < '3.14' and sys_platform == 'emscripten')", +] + +[[package]] +name = "about-time" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3f/ccb16bdc53ebb81c1bf837c1ee4b5b0b69584fd2e4a802a2a79936691c0a/about-time-4.2.1.tar.gz", hash = "sha256:6a538862d33ce67d997429d14998310e1dbfda6cb7d9bbfbf799c4709847fece", size = 15380, upload-time = "2022-12-21T04:15:54.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/cd/7ee00d6aa023b1d0551da0da5fee3bc23c3eeea632fbfc5126d1fec52b7e/about_time-4.2.1-py3-none-any.whl", hash = "sha256:8bbf4c75fe13cbd3d72f49a03b02c5c7dca32169b6d49117c257e7eb3eaee341", size = 13295, upload-time = "2022-12-21T04:15:53.613Z" }, +] + +[[package]] +name = "alive-progress" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "about-time" }, + { name = "graphemeu" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/26/d43128764a6f8fe1668c4f87aba6b1fe52bea81d05a35c84a70d3c70b6f7/alive-progress-3.3.0.tar.gz", hash = "sha256:457dd2428b48dacd49854022a46448d236a48f1b7277874071c39395307e830c", size = 116281, upload-time = "2025-07-20T02:10:39.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/85/ec72f6c885703d18f3b09769645e950e14c7d0cc0a0e35d94127983f666f/alive_progress-3.3.0-py3-none-any.whl", hash = "sha256:63dd33bb94cde15ad9e5b666dbba8fedf71b72a4935d6fb9a92931e69402c9ff", size = 78403, upload-time = "2025-07-20T02:10:37.318Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "binaryornot" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/4755b85101f37707c71526a301c1203e413c715a0016ecb592de3d2dcfff/binaryornot-0.6.0.tar.gz", hash = "sha256:cc8d57cfa71d74ff8c28a7726734d53a851d02fad9e3a5581fb807f989f702f0", size = 478718, upload-time = "2026-03-08T16:26:28.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/0c/31cfaa6b56fe23488ecb993bc9fc526c0d84d89607decdf2a10776426c2e/binaryornot-0.6.0-py3-none-any.whl", hash = "sha256:900adfd5e1b821255ba7e63139b0396b14c88b9286e74e03b6f51e0200331337", size = 14185, upload-time = "2026-03-08T16:26:27.466Z" }, +] + +[[package]] +name = "certifi" +version = "2026.4.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cookiecutter" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, + { name = "binaryornot" }, + { name = "click" }, + { name = "jinja2" }, + { name = "python-slugify" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/03/f4c96d8fd4f5e8af0210bf896eb63927f35d3014a8e8f3bf9d2c43ad3332/cookiecutter-2.7.1.tar.gz", hash = "sha256:ca7bb7bc8c6ff441fbf53921b5537668000e38d56e28d763a1b73975c66c6138", size = 142854, upload-time = "2026-03-04T04:06:02.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a9/8c855c14b401dc67d20739345295af5afce5e930a69600ab20f6cfa50b5c/cookiecutter-2.7.1-py3-none-any.whl", hash = "sha256:cee50defc1eaa7ad0071ee9b9893b746c1b3201b66bf4d3686d0f127c8ed6cf9", size = 41317, upload-time = "2026-03-04T04:06:01.221Z" }, +] + +[[package]] +name = "cryptography" +version = "48.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" }, + { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" }, + { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" }, + { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" }, + { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" }, + { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" }, + { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" }, + { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" }, + { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" }, + { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" }, + { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" }, + { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" }, + { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" }, + { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" }, + { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" }, + { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" }, + { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" }, +] + +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dlt" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "fsspec" }, + { name = "gitpython" }, + { name = "giturlparse" }, + { name = "humanize" }, + { name = "jsonpath-ng" }, + { name = "orjson", marker = "(python_full_version >= '3.14' and platform_python_implementation == 'PyPy') or (python_full_version >= '3.14' and sys_platform == 'emscripten') or (platform_python_implementation != 'PyPy' and sys_platform != 'emscripten')" }, + { name = "packaging" }, + { name = "pathvalidate" }, + { name = "pendulum" }, + { name = "pluggy" }, + { name = "pytz" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "requirements-parser" }, + { name = "rich-argparse" }, + { name = "semver" }, + { name = "setuptools" }, + { name = "simplejson" }, + { name = "sqlglot" }, + { name = "tenacity" }, + { name = "tomlkit" }, + { name = "typing-extensions" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/20/c47492cd3c78287133fbba98aa41192c8613a371b8db57738d5096d05b8b/dlt-1.26.0.tar.gz", hash = "sha256:1a066fed8df7ace96a695309d9102046f9e3b9d60692e1cb93e842406aaf55ec", size = 1042686, upload-time = "2026-04-28T17:52:20.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/1a/1430fc5989fe9a476b198a8a9b27e0795c2e6856d2593f7d21e660d99e2d/dlt-1.26.0-py3-none-any.whl", hash = "sha256:81e2d28bdc4d33e97978e3654542bdac95347342d15a35102d09fd79df50c2bd", size = 1312681, upload-time = "2026-04-28T17:52:16.234Z" }, +] + +[[package]] +name = "duckdb" +version = "1.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/66/744b4931b799a42f8cb9bc7a6f169e7b8e51195b62b246db407fd90bf15f/duckdb-1.5.2.tar.gz", hash = "sha256:638da0d5102b6cb6f7d47f83d0600708ac1d3cb46c5e9aaabc845f9ba4d69246", size = 18017166, upload-time = "2026-04-13T11:30:09.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/f2/e3d742808f138d374be4bb516fade3d1f33749b813650810ab7885cdc363/duckdb-1.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4420b3f47027a7849d0e1815532007f377fa95ee5810b47ea717d35525c12f79", size = 30064879, upload-time = "2026-04-13T11:29:30.763Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/f3dc1cf97e1267ca15e4307d456f96ce583961f0703fd75e62b2ad8d64fa/duckdb-1.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bb42e6ed543902e14eae647850da24103a89f0bc2587dec5601b1c1f213bd2ed", size = 15969327, upload-time = "2026-04-13T11:29:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/d5418def53ae4e05a63075705ff44ed5af5a1a5932627eb2b600c5df1c93/duckdb-1.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:98c0535cd6d901f61a5ea3c2e26a1fd28482953d794deb183daf568e3aa5dda6", size = 14225107, upload-time = "2026-04-13T11:29:35.882Z" }, + { url = "https://files.pythonhosted.org/packages/16/a7/15aaa59dbecc35e9711980fcdbf525b32a52470b32d18ef678193a146213/duckdb-1.5.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:486c862bf7f163c0110b6d85b3e5c031d224a671cca468f12ebb1d3a348f6b39", size = 19313433, upload-time = "2026-04-13T11:29:38.367Z" }, + { url = "https://files.pythonhosted.org/packages/bd/21/d903cc63a5140c822b7b62b373a87dc557e60c29b321dfb435061c5e67cf/duckdb-1.5.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70631c847ca918ee710ec874241b00cf9d2e5be90762cbb2a0389f17823c08f7", size = 21429837, upload-time = "2026-04-13T11:29:41.135Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/b770d1f60c70597302130d6247f418549b7094251a02348fbaf1c7e147ae/duckdb-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:52a21823f3fbb52f0f0e5425e20b07391ad882464b955879499b5ff0b45a376b", size = 13107699, upload-time = "2026-04-13T11:29:43.905Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/e200fe431d700962d1a908d2ce89f53ccee1cc8db260174ae663ba09686b/duckdb-1.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:411ad438bd4140f189a10e7f515781335962c5d18bd07837dc6d202e3985253d", size = 13927646, upload-time = "2026-04-13T11:29:46.598Z" }, + { url = "https://files.pythonhosted.org/packages/83/a1/f6286c67726cc1ea60a6e3c0d9fbc66527dde24ae089a51bbe298b13ca78/duckdb-1.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6b0fe75c148000f060aa1a27b293cacc0ea08cc1cad724fbf2143d56070a3785", size = 30078598, upload-time = "2026-04-13T11:29:49.828Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/59febb02f21a4a5c6b0b0099ef7c965fdd5e61e4904cf813809bb792e35f/duckdb-1.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35579b8e3a064b5eaf15b0eafc558056a13f79a0a62e34cc4baf57119daecfec", size = 15975120, upload-time = "2026-04-13T11:29:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/09/70/ce750854d37bb5a45cccbb2c3cb04df4af56aea8fc30a2499bb643b4a9c0/duckdb-1.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea58ff5b0880593a280cf5511734b17711b32ee1f58b47d726e8600848358160", size = 14227762, upload-time = "2026-04-13T11:29:55.564Z" }, + { url = "https://files.pythonhosted.org/packages/28/dc/ad45ac3c0b6c4687dc649e8f6cf01af1c8b0443932a39b2abb4ebcb3babd/duckdb-1.5.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef461bca07313412dc09961c4a4757a851f56b95ac01c58fac6007632b7b94f2", size = 19315668, upload-time = "2026-04-13T11:29:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b1/1464f468d2e5813f5808de95df9d3113a645a5bfa2ffcaecbc542ddae272/duckdb-1.5.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be37680ddb380015cb37318e378c53511c45c4f0d8fac5599d22b7d092b9217a", size = 21434056, upload-time = "2026-04-13T11:30:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/ce/32/6673607e024722473fa7aafdd29c0e3dd231dd528f6cd8b5797fbeeb229d/duckdb-1.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:0b291786014df1133f8f18b9df4d004484613146e858d71a21791e0fcca16cf4", size = 13633667, upload-time = "2026-04-13T11:30:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e3/9d34173ec068631faea3ea6e73050700729363e7e33306a9a3218e5cdc61/duckdb-1.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:c9f3e0b71b8a50fccfb42794899285d9d318ce2503782b9dd54868e5ecd0ad31", size = 14402513, upload-time = "2026-04-13T11:30:06.609Z" }, +] + +[[package]] +name = "fieldz" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/46/7a8d1db959eabd5d3e52bd3c000a8b132184b7651cbd5481c4cbf31ff65d/fieldz-0.2.0.tar.gz", hash = "sha256:e11215188cad5c5371113d2b7707155960efd0d48500b6bf675648bc3f6fc8d6", size = 18222, upload-time = "2026-02-24T19:26:02.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/9d/9034acaf3d80e85deb3d49876cb734479327b9cf89a918815ef67cb6b36c/fieldz-0.2.0-py3-none-any.whl", hash = "sha256:43b5be702816df39f55d08ae392eaabe58d3c69d2e4b61a853252cb2da41931f", size = 17946, upload-time = "2026-02-24T19:26:00.931Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/8d/1c51c094345df128ca4a990d633fe1a0ff28726c9e6b3c41ba65087bba1d/fsspec-2026.4.0.tar.gz", hash = "sha256:301d8ac70ae90ef3ad05dcf94d6c3754a097f9b5fe4667d2787aa359ec7df7e4", size = 312760, upload-time = "2026-04-29T20:42:38.635Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/63/210aaa302d6a0a78daa67c5c15bbac2cad361722841278b0209b6da20855/gitpython-3.1.49.tar.gz", hash = "sha256:42f9399c9eb33fc581014bedd76049dfbaf6375aa2a5754575966387280315e1", size = 219367, upload-time = "2026-04-29T00:31:20.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/6f/b842bfa6f21d6f87c57f9abf7194225e55279d96d869775e19e9f7236fc5/gitpython-3.1.49-py3-none-any.whl", hash = "sha256:024b0422d7f84d15cd794844e029ffebd4c5d42a7eb9b936b458697ef550a02c", size = 212190, upload-time = "2026-04-29T00:31:18.412Z" }, +] + +[[package]] +name = "giturlparse" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/7f25a604a406be7d7d0f849bfcbc1603df084e9e58fe6170980c231138e4/giturlparse-0.14.0.tar.gz", hash = "sha256:0a13208cb3f60e067ee3d09d28e01f9c936065986004fa2d5cd6db7758e9f6e6", size = 15637, upload-time = "2025-10-22T09:21:11.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/f9/9ff5a301459f804a885f237453ba81564bc6ee54740e9f2676c2642043f6/giturlparse-0.14.0-py2.py3-none-any.whl", hash = "sha256:04fd9c262ca9a4db86043d2ef32b2b90bfcbcdefc4f6a260fd9402127880931d", size = 16299, upload-time = "2025-10-22T09:21:10.818Z" }, +] + +[[package]] +name = "graphemeu" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/20/d012f71e7d00e0d5bb25176b9a96c5313d0e30cf947153bfdfa78089f4bb/graphemeu-0.7.2.tar.gz", hash = "sha256:42bbe373d7c146160f286cd5f76b1a8ad29172d7333ce10705c5cc282462a4f8", size = 307626, upload-time = "2025-01-15T09:48:59.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/18/36503ea63e1ecd0a95590d7b6b8b7d227a1e4541a154e1612a231def1bdc/graphemeu-0.7.2-py3-none-any.whl", hash = "sha256:1444520f6899fd30114fc2a39f297d86d10fa0f23bf7579f772f8bc7efaa2542", size = 22670, upload-time = "2025-01-15T09:48:57.241Z" }, +] + +[[package]] +name = "griffe" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/49/eb6d2935e27883af92c930ed40cc4c69bcd32c402be43b8ca4ab20510f67/griffe-2.0.2.tar.gz", hash = "sha256:c5d56326d159f274492e9bf93a9895cec101155d944caa66d0fc4e0c13751b92", size = 293757, upload-time = "2026-03-27T11:34:52.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/c0/2bb018eecf9a83c68db9cd9fffd9dab25f102ad30ed869451046e46d1187/griffe-2.0.2-py3-none-any.whl", hash = "sha256:2b31816460aee1996af26050a1fc6927a2e5936486856707f55508e4c9b5960b", size = 5141, upload-time = "2026-03-27T11:34:47.721Z" }, +] + +[[package]] +name = "griffe-fieldz" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fieldz" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/7f/b57a27807536590f0cabdd2e6c0faac78c7d6a740e096684c51c503b7003/griffe_fieldz-0.5.0.tar.gz", hash = "sha256:dcf237a0def9f5c15e15aa5cf07b3d0a4edf4f7d6a2d713a45afe29c8af9bc8e", size = 14250, upload-time = "2026-02-24T16:22:23.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/66/9daa3118d9bf4443778d6ee31b024c79766eeb116b275eca71597adae43a/griffe_fieldz-0.5.0-py3-none-any.whl", hash = "sha256:d363e56a7468019a7184ea3995277a1f20507bb9a345408c1f7014ae06878ebf", size = 9629, upload-time = "2026-02-24T16:22:22.094Z" }, +] + +[[package]] +name = "griffecli" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/e0/6a7d661d71bb043656a109b91d84a42b5342752542074ec83b16a6eb97f0/griffecli-2.0.2.tar.gz", hash = "sha256:40a1ad4181fc39685d025e119ae2c5b669acdc1f19b705fb9bf971f4e6f6dffb", size = 56281, upload-time = "2026-03-27T11:34:50.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/e8/90d93356c88ac34c20cb5edffca68138df55ca9bbd1a06eccfbcec8fdbe5/griffecli-2.0.2-py3-none-any.whl", hash = "sha256:0d44d39e59afa81e288a3e1c3bf352cc4fa537483326ac06b8bb6a51fd8303a0", size = 9500, upload-time = "2026-03-27T11:34:48.81Z" }, +] + +[[package]] +name = "griffelib" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, +] + +[[package]] +name = "humanize" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/66/a3921783d54be8a6870ac4ccffcd15c4dc0dd7fcce51c6d63b8c63935276/humanize-4.15.0.tar.gz", hash = "sha256:1dd098483eb1c7ee8e32eb2e99ad1910baefa4b75c3aff3a82f4d78688993b10", size = 83599, upload-time = "2025-12-20T20:16:13.19Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/7b/bca5613a0c3b542420cf92bd5e5fb8ebd5435ce1011a091f66bb7693285e/humanize-4.15.0-py3-none-any.whl", hash = "sha256:b1186eb9f5a9749cd9cb8565aee77919dd7c8d076161cf44d70e59e3301e1769", size = 132203, upload-time = "2025-12-20T20:16:11.67Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonpath-ng" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ply" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/86/08646239a313f895186ff0a4573452038eed8c86f54380b3ebac34d32fb2/jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c", size = 37838, upload-time = "2024-10-11T15:41:42.404Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/5a/73ecb3d82f8615f32ccdadeb9356726d6cae3a4bbc840b437ceb95708063/jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6", size = 30105, upload-time = "2024-11-20T17:58:30.418Z" }, +] + +[[package]] +name = "librt" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" }, + { url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" }, + { url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" }, + { url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" }, + { url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" }, + { url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" }, + { url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" }, + { url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" }, + { url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" }, + { url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" }, + { url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "openhound" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alive-progress" }, + { name = "cookiecutter" }, + { name = "dlt" }, + { name = "duckdb" }, + { name = "griffe" }, + { name = "griffe-fieldz" }, + { name = "jinja2" }, + { name = "mkdocstrings", extra = ["python"] }, + { name = "psutil" }, + { name = "pydantic" }, + { name = "pydantic-extra-types" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/e9/3fc99609ab7d4643e69dae0d1ba07138385b1ac78847f56c4515a00efed6/openhound-0.1.4.tar.gz", hash = "sha256:e8a6e86f2ab92aa087374410f14a97ff025329d6ba75d64a54c8f932befd19c7", size = 3539391, upload-time = "2026-05-04T21:40:16.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/06/1cb73a6d46acaddb39e6bce0246ac000a07bea7d3ad5074a702c1d0612bf/openhound-0.1.4-py3-none-any.whl", hash = "sha256:b4f711f928520645aa0ddc559bd7597ac49ae3321c8a9c4fdee812d255bbafde", size = 65132, upload-time = "2026-05-04T21:40:15.695Z" }, +] + +[[package]] +name = "openhound-github" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "requests" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "openhound" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "zensical" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=46.0.6" }, + { name = "requests", specifier = ">=2.33.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.19.1" }, + { name = "openhound", specifier = "==0.1.4" }, + { name = "pre-commit", specifier = ">=4.5.1" }, + { name = "pytest", specifier = ">=9.0.1" }, + { name = "ruff", specifier = ">=0.15.5" }, + { name = "zensical", specifier = ">=0.0.27" }, +] + +[[package]] +name = "orjson" +version = "3.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/1b/2024d06792d0779f9dbc51531b61c24f76c75b9f4ce05e6f3377a1814cea/orjson-3.11.8.tar.gz", hash = "sha256:96163d9cdc5a202703e9ad1b9ae757d5f0ca62f4fa0cc93d1f27b0e180cc404e", size = 5603832, upload-time = "2026-03-31T16:16:27.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/7f/95fba509bb2305fab0073558f1e8c3a2ec4b2afe58ed9fcb7d3b8beafe94/orjson-3.11.8-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3f23426851d98478c8970da5991f84784a76682213cd50eb73a1da56b95239dc", size = 229180, upload-time = "2026-03-31T16:15:36.426Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/b237215c743ca073697d759b5503abd2cb8a0d7b9c9e21f524bcf176ab66/orjson-3.11.8-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ebaed4cef74a045b83e23537b52ef19a367c7e3f536751e355a2a394f8648559", size = 128754, upload-time = "2026-03-31T16:15:38.049Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/27d65b6d11e63f133781425f132807aef793ed25075fec686fc8e46dd528/orjson-3.11.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97c8f5d3b62380b70c36ffacb2a356b7c6becec86099b177f73851ba095ef623", size = 131877, upload-time = "2026-03-31T16:15:39.484Z" }, + { url = "https://files.pythonhosted.org/packages/dd/cc/faee30cd8f00421999e40ef0eba7332e3a625ce91a58200a2f52c7fef235/orjson-3.11.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:436c4922968a619fb7fef1ccd4b8b3a76c13b67d607073914d675026e911a65c", size = 130361, upload-time = "2026-03-31T16:15:41.274Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bb/a6c55896197f97b6d4b4e7c7fd77e7235517c34f5d6ad5aadd43c54c6d7c/orjson-3.11.8-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ab359aff0436d80bfe8a23b46b5fea69f1e18aaf1760a709b4787f1318b317f", size = 135521, upload-time = "2026-03-31T16:15:42.758Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/ca3a3525aa32ff636ebb1778e77e3587b016ab2edb1b618b36ba96f8f2c0/orjson-3.11.8-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f89b6d0b3a8d81e1929d3ab3d92bbc225688bd80a770c49432543928fe09ac55", size = 146862, upload-time = "2026-03-31T16:15:44.341Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0c/18a9d7f18b5edd37344d1fd5be17e94dc652c67826ab749c6e5948a78112/orjson-3.11.8-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c009e7a2ca9ad0ed1376ce20dd692146a5d9fe4310848904b6b4fee5c5c137", size = 132847, upload-time = "2026-03-31T16:15:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/23/91/7e722f352ad67ca573cee44de2a58fb810d0f4eb4e33276c6a557979fd8a/orjson-3.11.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:705b895b781b3e395c067129d8551655642dfe9437273211d5404e87ac752b53", size = 133637, upload-time = "2026-03-31T16:15:48.123Z" }, + { url = "https://files.pythonhosted.org/packages/af/04/32845ce13ac5bd1046ddb02ac9432ba856cc35f6d74dde95864fe0ad5523/orjson-3.11.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88006eda83858a9fdf73985ce3804e885c2befb2f506c9a3723cdeb5a2880e3e", size = 141906, upload-time = "2026-03-31T16:15:49.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/5e/c551387ddf2d7106d9039369862245c85738b828844d13b99ccb8d61fd06/orjson-3.11.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:55120759e61309af7fcf9e961c6f6af3dde5921cdb3ee863ef63fd9db126cae6", size = 423722, upload-time = "2026-03-31T16:15:51.176Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/ecfe62434096f8a794d4976728cb59bcfc4a643977f21c2040545d37eb4c/orjson-3.11.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:98bdc6cb889d19bed01de46e67574a2eab61f5cc6b768ed50e8ac68e9d6ffab6", size = 147801, upload-time = "2026-03-31T16:15:52.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/6d/0dce10b9f6643fdc59d99333871a38fa5a769d8e2fc34a18e5d2bfdee900/orjson-3.11.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:708c95f925a43ab9f34625e45dcdadf09ec8a6e7b664a938f2f8d5650f6c090b", size = 136460, upload-time = "2026-03-31T16:15:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/01/d6/6dde4f31842d87099238f1f07b459d24edc1a774d20687187443ab044191/orjson-3.11.8-cp313-cp313-win32.whl", hash = "sha256:01c4e5a6695dc09098f2e6468a251bc4671c50922d4d745aff1a0a33a0cf5b8d", size = 131956, upload-time = "2026-03-31T16:15:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f9/4e494a56e013db957fb77186b818b916d4695b8fa2aa612364974160e91b/orjson-3.11.8-cp313-cp313-win_amd64.whl", hash = "sha256:c154a35dd1330707450bb4d4e7dd1f17fa6f42267a40c1e8a1daa5e13719b4b8", size = 127410, upload-time = "2026-03-31T16:15:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/803203d00d6edb6e9e7eef421d4e1adbb5ea973e40b3533f3cfd9aeb374e/orjson-3.11.8-cp313-cp313-win_arm64.whl", hash = "sha256:4861bde57f4d253ab041e374f44023460e60e71efaa121f3c5f0ed457c3a701e", size = 127338, upload-time = "2026-03-31T16:15:59.106Z" }, + { url = "https://files.pythonhosted.org/packages/6d/35/b01910c3d6b85dc882442afe5060cbf719c7d1fc85749294beda23d17873/orjson-3.11.8-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ec795530a73c269a55130498842aaa762e4a939f6ce481a7e986eeaa790e9da4", size = 229171, upload-time = "2026-03-31T16:16:00.651Z" }, + { url = "https://files.pythonhosted.org/packages/c2/56/c9ec97bd11240abef39b9e5d99a15462809c45f677420fd148a6c5e6295e/orjson-3.11.8-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:c492a0e011c0f9066e9ceaa896fbc5b068c54d365fea5f3444b697ee01bc8625", size = 128746, upload-time = "2026-03-31T16:16:02.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e4/66d4f30a90de45e2f0cbd9623588e8ae71eef7679dbe2ae954ed6d66a41f/orjson-3.11.8-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:883206d55b1bd5f5679ad5e6ddd3d1a5e3cac5190482927fdb8c78fb699193b5", size = 131867, upload-time = "2026-03-31T16:16:04.342Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/2a645fc9286b928675e43fa2a3a16fb7b6764aa78cc719dc82141e00f30b/orjson-3.11.8-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5774c1fdcc98b2259800b683b19599c133baeb11d60033e2095fd9d4667b82db", size = 124664, upload-time = "2026-03-31T16:16:05.837Z" }, + { url = "https://files.pythonhosted.org/packages/db/44/77b9a86d84a28d52ba3316d77737f6514e17118119ade3f91b639e859029/orjson-3.11.8-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7381c83dd3d4a6347e6635950aa448f54e7b8406a27c7ecb4a37e9f1ae08b", size = 129701, upload-time = "2026-03-31T16:16:07.407Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ea/eff3d9bfe47e9bc6969c9181c58d9f71237f923f9c86a2d2f490cd898c82/orjson-3.11.8-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:14439063aebcb92401c11afc68ee4e407258d2752e62d748b6942dad20d2a70d", size = 141202, upload-time = "2026-03-31T16:16:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/52/c8/90d4b4c60c84d62068d0cf9e4d8f0a4e05e76971d133ac0c60d818d4db20/orjson-3.11.8-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa72e71977bff96567b0f500fc5bfd2fdf915f34052c782a4c6ebbdaa97aa858", size = 127194, upload-time = "2026-03-31T16:16:11.02Z" }, + { url = "https://files.pythonhosted.org/packages/8d/c7/ea9e08d1f0ba981adffb629811148b44774d935171e7b3d780ae43c4c254/orjson-3.11.8-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7679bc2f01bb0d219758f1a5f87bb7c8a81c0a186824a393b366876b4948e14f", size = 133639, upload-time = "2026-03-31T16:16:13.434Z" }, + { url = "https://files.pythonhosted.org/packages/6c/8c/ddbbfd6ba59453c8fc7fe1d0e5983895864e264c37481b2a791db635f046/orjson-3.11.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:14f7b8fcb35ef403b42fa5ecfa4ed032332a91f3dc7368fbce4184d59e1eae0d", size = 141914, upload-time = "2026-03-31T16:16:14.955Z" }, + { url = "https://files.pythonhosted.org/packages/4e/31/dbfbefec9df060d34ef4962cd0afcb6fa7a9ec65884cb78f04a7859526c3/orjson-3.11.8-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c2bdf7b2facc80b5e34f48a2d557727d5c5c57a8a450de122ae81fa26a81c1bc", size = 423800, upload-time = "2026-03-31T16:16:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/87/cf/f74e9ae9803d4ab46b163494adba636c6d7ea955af5cc23b8aaa94cfd528/orjson-3.11.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ccd7ba1b0605813a0715171d39ec4c314cb97a9c85893c2c5c0c3a3729df38bf", size = 147837, upload-time = "2026-03-31T16:16:18.585Z" }, + { url = "https://files.pythonhosted.org/packages/64/e6/9214f017b5db85e84e68602792f742e5dc5249e963503d1b356bee611e01/orjson-3.11.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbc8c9c02463fef4d3c53a9ba3336d05496ec8e1f1c53326a1e4acc11f5c600", size = 136441, upload-time = "2026-03-31T16:16:20.151Z" }, + { url = "https://files.pythonhosted.org/packages/24/dd/3590348818f58f837a75fb969b04cdf187ae197e14d60b5e5a794a38b79d/orjson-3.11.8-cp314-cp314-win32.whl", hash = "sha256:0b57f67710a8cd459e4e54eb96d5f77f3624eba0c661ba19a525807e42eccade", size = 131983, upload-time = "2026-03-31T16:16:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/b6cb692116e05d058f31ceee819c70f097fa9167c82f67fabe7516289abc/orjson-3.11.8-cp314-cp314-win_amd64.whl", hash = "sha256:735e2262363dcbe05c35e3a8869898022af78f89dde9e256924dc02e99fe69ca", size = 127396, upload-time = "2026-03-31T16:16:23.685Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d1/facb5b5051fabb0ef9d26c6544d87ef19a939a9a001198655d0d891062dd/orjson-3.11.8-cp314-cp314-win_arm64.whl", hash = "sha256:6ccdea2c213cf9f3d9490cbd5d427693c870753df41e6cb375bd79bcbafc8817", size = 127330, upload-time = "2026-03-31T16:16:25.496Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "pendulum" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/72/9a51afa0a822b09e286c4cb827ed7b00bc818dac7bd11a5f161e493a217d/pendulum-3.2.0.tar.gz", hash = "sha256:e80feda2d10fa3ff8b1526715f7d33dcb7e08494b3088f2c8a3ac92d4a4331ce", size = 86912, upload-time = "2026-01-30T11:22:24.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/8c/400c8b8dbd7524424f3d9902ded64741e82e5e321d1aabbd68ade89e71cf/pendulum-3.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:addb0512f919fe5b70c8ee534ee71c775630d3efe567ea5763d92acff857cfc3", size = 337820, upload-time = "2026-01-30T11:21:24.305Z" }, + { url = "https://files.pythonhosted.org/packages/59/38/7c16f26cc55d9206d71da294ce6857d0da381e26bc9e0c2a069424c2b173/pendulum-3.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3aaa50342dc174acebdc21089315012e63789353957b39ac83cac9f9fc8d1075", size = 327551, upload-time = "2026-01-30T11:21:25.747Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cd/f36ec5d56d55104232380fdbf84ff53cc05607574af3cbdc8a43991ac8a7/pendulum-3.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:927e9c9ab52ff68e71b76dd410e5f1cd78f5ea6e7f0a9f5eb549aea16a4d5354", size = 339894, upload-time = "2026-01-30T11:21:27.229Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/b9a1e546519c3a92d5bc17787cea925e06a20def2ae344fa136d2fc40338/pendulum-3.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:249d18f5543c9f43aba3bd77b34864ec8cf6f64edbead405f442e23c94fce63d", size = 373766, upload-time = "2026-01-30T11:21:28.642Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a6/6471ab87ae2260594501f071586a765fc894817043b7d2d4b04e2eff4f31/pendulum-3.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c644cc15eec5fb02291f0f193195156780fd5a0affd7a349592403826d1a35e", size = 379837, upload-time = "2026-01-30T11:21:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/79/0ba0c14e862388f7b822626e6e989163c23bebe7f96de5ec4b207cbe7c3d/pendulum-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:063ab61af953bb56ad5bc8e131fd0431c915ed766d90ccecd7549c8090b51004", size = 348904, upload-time = "2026-01-30T11:21:32.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/34/df922c7c0b12719589d4954bfa5bdca9e02bcde220f5c5c1838a87118960/pendulum-3.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:26a3ae26c9dd70a4256f1c2f51addc43641813574c0db6ce5664f9861cd93621", size = 517173, upload-time = "2026-01-30T11:21:34.428Z" }, + { url = "https://files.pythonhosted.org/packages/87/ec/3b9e061eeee97b72a47c1434ee03f6d85f0284d9285d92b12b0fff2d19ac/pendulum-3.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2b10d91dc00f424444a42f47c69e6b3bfd79376f330179dc06bc342184b35f9a", size = 561744, upload-time = "2026-01-30T11:21:35.861Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7e/f12fdb6070b7975c1fcfa5685dbe4ab73c788878a71f4d1d7e3c87979e37/pendulum-3.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:63070ff03e30a57b16c8e793ee27da8dac4123c1d6e0cf74c460ce9ee8a64aa4", size = 258746, upload-time = "2026-01-30T11:21:37.782Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/5abd872056357f069ae34a9b24a75ac58e79092d16201d779a8dd31386bb/pendulum-3.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:c8dde63e2796b62070a49ce813ce200aba9186130307f04ec78affcf6c2e8122", size = 253028, upload-time = "2026-01-30T11:21:39.381Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/5b9cc823862450910bcb2c7cdc6884c0939b268639146d30e4a4f55eb1f1/pendulum-3.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c17ac069e88c5a1e930a5ae0ef17357a14b9cc5a28abadda74eaa8106d241c8e", size = 338281, upload-time = "2026-01-30T11:21:40.812Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3a/64a35260f6ac36c0ad50eeb5f1a465b98b0d7603f79a5c2077c41326d639/pendulum-3.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e1fbb540edecb21f8244aebfb05a1f2333ddc6c7819378c099d4a61cc91ae93c", size = 328030, upload-time = "2026-01-30T11:21:42.778Z" }, + { url = "https://files.pythonhosted.org/packages/da/6b/1140e09310035a2afb05bb90a2b8fbda9d3222e03b92de9533123afe6b65/pendulum-3.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8c67fb9a1fe8fc1adae2cc01b0c292b268c12475b4609ff4aed71c9dd367b4d", size = 340206, upload-time = "2026-01-30T11:21:44.148Z" }, + { url = "https://files.pythonhosted.org/packages/52/4a/a493de56cbc24a64b21ac6ba98513a9ec5c67daa3dba325e39a8e53f30d8/pendulum-3.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:baa9a66c980defda6cfe1275103a94b22e90d83ebd7a84cc961cee6cbd25a244", size = 373976, upload-time = "2026-01-30T11:21:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4c/f083c4fd1a161d4ab218680cc906338c541497b3098373f2241f58c429cb/pendulum-3.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef8f783fa7a14973b0596d8af2a5b2d90858a55030e9b4c6885eb4284b88314f", size = 380075, upload-time = "2026-01-30T11:21:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/333a0fcb33bf15eb879a46a11ce6300c1698a141e689665fe430783ff8d6/pendulum-3.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7d2e9bfb065727d8676e7ada3793b47a24349500a5e9637404355e482c822be", size = 349026, upload-time = "2026-01-30T11:21:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/dfb526ec0cba1e7cd6a5e4f4dd64a6ada7428d1449c54b15f7b295f6e122/pendulum-3.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:55d7ba6bb74171c3ee409bf30076ee3a259a3c2bb147ac87ebb76aaa3cf5d3a2", size = 517395, upload-time = "2026-01-30T11:21:49.643Z" }, + { url = "https://files.pythonhosted.org/packages/c9/37/b4f2b5f1200351c4869b8b46ad5c21019e3dbe0417f5867ae969fad7b5fe/pendulum-3.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:a50d8cf42f06d3d8c3f8bb2a7ac47fa93b5145e69de6a7209be6a47afdd9cf76", size = 561926, upload-time = "2026-01-30T11:21:51.698Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/567376582da58f5fe8e4f579db2bcfbf243cf619a5825bdf1023ad1436b3/pendulum-3.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e5bbb92b155cd5018b3cf70ee49ed3b9c94398caaaa7ed97fe41e5bb5a968418", size = 258817, upload-time = "2026-01-30T11:21:53.074Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/dfffd7eb50d67fa821cd4d92cf71575ead6162930202bc40dfcedf78c38c/pendulum-3.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:d53134418e04335c3029a32e9341cccc9b085a28744fb5ee4e6a8f5039363b1a", size = 253292, upload-time = "2026-01-30T11:21:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/02/fb/d65db067a67df7252f18b0cb7420dda84078b9e8bfb375215469c14a50be/pendulum-3.2.0-py3-none-any.whl", hash = "sha256:f3a9c18a89b4d9ef39c5fa6a78722aaff8d5be2597c129a3b16b9f40a561acf3", size = 114111, upload-time = "2026-01-30T11:22:22.361Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "ply" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/69/882ee5c9d017149285cab114ebeab373308ef0f874fcdac9beb90e0ac4da/ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3", size = 159130, upload-time = "2018-02-15T19:01:31.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/58/35da89ee790598a0700ea49b2a66594140f44dec458c07e8e3d4979137fc/ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce", size = 49567, upload-time = "2018-02-15T19:01:27.172Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, +] + +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "requirements-parser" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/96/fb6dbfebb524d5601d359a47c78fe7ba1eef90fc4096404aa60c9a906fbb/requirements_parser-0.13.0.tar.gz", hash = "sha256:0843119ca2cb2331de4eb31b10d70462e39ace698fd660a915c247d2301a4418", size = 22630, upload-time = "2025-05-21T13:42:05.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/60/50fbb6ffb35f733654466f1a90d162bcbea358adc3b0871339254fbc37b2/requirements_parser-0.13.0-py3-none-any.whl", hash = "sha256:2b3173faecf19ec5501971b7222d38f04cb45bb9d87d0ad629ca71e2e62ded14", size = 14782, upload-time = "2025-05-21T13:42:04.007Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-argparse" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/e5/1064c43203a357d668cd42435f7a15fe6af51512d85b2104fecb937aa861/rich_argparse-1.8.0.tar.gz", hash = "sha256:679df3d832fa94ad6e4bdb07ded088cd7ea2dddc58ae9b2b46346a40b06cbc0c", size = 38940, upload-time = "2026-05-01T15:18:43.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/35/1cceccc5fcb50fa2ed53e2aa278cd032f3902682a73e763fb1ac3be8e6fa/rich_argparse-1.8.0-py3-none-any.whl", hash = "sha256:d2a3ce7854654e2253c578763ab0a32f05016f23a55fadba7b9a91b6c0e92142", size = 25616, upload-time = "2026-05-01T15:18:42.395Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "simplejson" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/2a/54837395a3487c725669428d513293612a48d82b95a0642c936932e5d898/simplejson-4.1.1.tar.gz", hash = "sha256:c08eb9f7a90f77ae470e19a07472e9a79ebc0d1c2315d86a72767665bd5ba79f", size = 118860, upload-time = "2026-04-24T19:24:59.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/a9/47b445eeb559c9593453a0648e0fd6d08e8adff64dd5e5ced66726da8a09/simplejson-4.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dff52fc7af272e84fc21cc5a06c927c823ca6ae00af14f3b0d7707b42775ed98", size = 113160, upload-time = "2026-04-24T19:23:26.033Z" }, + { url = "https://files.pythonhosted.org/packages/4c/65/cb72db31523c164dea5dc55b02dad065a40c478856bc7534b279d2b51906/simplejson-4.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:971aed0647ad6e840a3943bec812fcda5f2d26a5497a4981d1fb49aa4f9a396c", size = 91521, upload-time = "2026-04-24T19:23:27.572Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e5/54cb7c50ad5fdc1e0a86b7df4b135c2cbd5c4623605aa94466659098e8da/simplejson-4.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:249e2e220aa6d9b9d936bde84eb7bf79d5b6c5a8273c6e411f8b1635a9073f2d", size = 91407, upload-time = "2026-04-24T19:23:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/21a3ede87f0bf82d6c7bcb90480d50a6490eb974c6ab20881188e440957c/simplejson-4.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e5cdd6a5d52299f345c15ab5678cc4249e24f383f361d986afbc3c7072a6b6b", size = 192451, upload-time = "2026-04-24T19:23:30.56Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/9903edd3102bf0b5984edfcb90c88612330996efa3b4fbf8a971d6e17839/simplejson-4.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642cec364e0676e2d5a73fa4d31d0c7c55886997caa2fde24e8292ca44d32728", size = 189015, upload-time = "2026-04-24T19:23:32.647Z" }, + { url = "https://files.pythonhosted.org/packages/98/cd/33230927a780e1398b857e3944abb914556994d252b1d765ae40d112cb25/simplejson-4.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76fe296ca1df23d290033f10aaacf534fd1b3e3007e7f9ff8aa68b21413aaa78", size = 196658, upload-time = "2026-04-24T19:23:34.563Z" }, + { url = "https://files.pythonhosted.org/packages/cd/84/2c5a7444eb53e9a86d3738299bffddd9f53aeed799ded2f45368221fdb19/simplejson-4.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f0ad25b7dc4e0fb23858355819f2e994f1a5badcdcde8737eac7921c2f1ed2a", size = 185967, upload-time = "2026-04-24T19:23:36.191Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/454378e06d059cd412a7ed5d87fb6d29fd5b60f13a4d89fc1f764ff434df/simplejson-4.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a59ebd0533f03fd06ff0c42ba0f02d93cbcdd7944922bf3b93911327a95b901f", size = 193940, upload-time = "2026-04-24T19:23:38.151Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d5/a15bf915f623a2c5a079d6e3be8256fdb8ef06f110669493a09b9d6933e0/simplejson-4.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bccbf4419676b517939852e5aeff2af6aee4dc046881c67a1581fa6f1cb01abd", size = 189795, upload-time = "2026-04-24T19:23:40.139Z" }, + { url = "https://files.pythonhosted.org/packages/d2/c9/37212ae7dc4b607f0978c408e8633f05c810884e054c33113184c6c2c8a2/simplejson-4.1.1-cp313-cp313-win32.whl", hash = "sha256:6c845363eb5fd166fb7c72243da38f4fcfde666ede7fdf2cc6fd7762894626f7", size = 88773, upload-time = "2026-04-24T19:23:41.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c7a0a47883a9015b54c9d8a4b62f2aba17bd4335b1787b9b8a0fc2fa6d52/simplejson-4.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:104d8324c34f25b4b90800bc5fa363780cbc3d8496aef061cba7ce1af9162270", size = 90888, upload-time = "2026-04-24T19:23:43.11Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/4a118a6a92eb33bb08c8e2fe7ec85cb96f0673491bb2b829930831ee4fbe/simplejson-4.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ed7473602b6625de793b6acba49aa949f144a475f538792067e4cf2fda2071f5", size = 110492, upload-time = "2026-04-24T19:23:44.957Z" }, + { url = "https://files.pythonhosted.org/packages/07/f4/84d160e9fa8cada1e0a9381cae4fa81eecd573577a5b34366d8ced59bdf7/simplejson-4.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:225c9caa324c5b554d009fb9cac22aee7711e71bd96f487938c659af467e828e", size = 90152, upload-time = "2026-04-24T19:23:46.355Z" }, + { url = "https://files.pythonhosted.org/packages/68/31/9a5432c433a7671107182cdc9a20ea78a70f99c4e5334aa54b6d4d0d79ed/simplejson-4.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:95407269340c7f22f09776ea7b717a52cf56cfcf119b5e45f66faa4a26445bea", size = 90115, upload-time = "2026-04-24T19:23:47.743Z" }, + { url = "https://files.pythonhosted.org/packages/78/91/3635cdb13318cb0a328abaa69e2b91251caad39d6779aa308098f341f6cb/simplejson-4.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3851658d642c1184d2023f0e6c9ce44a21eb1629e74e7c84ef956b128841fe12", size = 184036, upload-time = "2026-04-24T19:23:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/149b6ec5393f6849d98c59cadba888b710a8ef4b805ab91e11a566960d40/simplejson-4.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95a3bb0f78e85f4937f99092239f2011ce06f0f2d803df5c299cc05abbeae008", size = 180543, upload-time = "2026-04-24T19:23:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/df/7c/a5d968d0b527a748b667e62bea94309ccbcb1e2b108e8f0cf8547efaa12b/simplejson-4.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbfdaa7c0603f75b7b14b211b7f2be44696d4e26833ad2d91d5c87bf5fb9a920", size = 188725, upload-time = "2026-04-24T19:23:52.995Z" }, + { url = "https://files.pythonhosted.org/packages/db/e3/6a8d11181d587ef00e2db9112357e6832111e56dd56b01b5c11758a1965d/simplejson-4.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39e3c584071dced8c21b4689f0254303521daeb9b5bc1f4289755d71fa3cb0d3", size = 177492, upload-time = "2026-04-24T19:23:54.581Z" }, + { url = "https://files.pythonhosted.org/packages/67/e3/8b0eb8b06e8198cfbd1270487da163d0093df05cc4f557350cd65e2f7e79/simplejson-4.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:036a27bd0469b9d79557cbddb392969f876cd7f278cfbd0fba81534927a06575", size = 185281, upload-time = "2026-04-24T19:23:56.13Z" }, + { url = "https://files.pythonhosted.org/packages/dc/5f/64990f07ec9e2cb1a814c674e2e21b5693207f74ac70eb72151b847ea4e6/simplejson-4.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b70bfd2f67f3351baba08aa3ae9233c83f21fd95ae5e6b3d0ecb8c647929112f", size = 181848, upload-time = "2026-04-24T19:23:57.92Z" }, + { url = "https://files.pythonhosted.org/packages/61/a5/bbc1bc0447f339f79f99ab8c37f7f037cb2f1f93af75d6a4d553096bb0c3/simplejson-4.1.1-cp314-cp314-win32.whl", hash = "sha256:37233c72ce88d06acb92747347742b3c07871eba6789f060c179c9302dde8efe", size = 88761, upload-time = "2026-04-24T19:23:59.397Z" }, + { url = "https://files.pythonhosted.org/packages/18/72/ec1b5cbdcb140c132e6c7bdf99bd73e4f675439e77126c88f472fcffa09c/simplejson-4.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:cc0442dea71cd9cbf30a0b8b9929ab5aa6c02c0443a3d977351e6ec5bada4388", size = 91018, upload-time = "2026-04-24T19:24:00.85Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/4fa437f68ff72219bac3bf3d050de9c6265691f3a170e16954bd69d7cddd/simplejson-4.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c996a4d38290c515af347740659ce095b425449c164a5c9fa3977caa6eff5dbe", size = 113919, upload-time = "2026-04-24T19:24:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/c2/83/59de041d09eb4a9577f7015d7263c32095dfb7fde49717dff62145d89809/simplejson-4.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c65c763fb20d7ca113c1c14dce2fc04a0fc3a57aceff533d6fdac707c7bffb40", size = 91904, upload-time = "2026-04-24T19:24:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/03/8e/46bb345d540f6eb31427d984a4e518cdb182d0621814fee4fee045e8815b/simplejson-4.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0da5c9f57206ee7ef280ff7f1d924937b0a64f9a271a5ef371a2ecdbebba7421", size = 91752, upload-time = "2026-04-24T19:24:05.622Z" }, + { url = "https://files.pythonhosted.org/packages/83/e2/1b2ce97f068835eb3d253c116a4df7a3f436b7bf2fb5ff1ba29287e8b0ec/simplejson-4.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ea3426e786425d10e9e82f8a6eda74a7d6eb10d99165ac3d0d3bbcb65c0ea343", size = 214021, upload-time = "2026-04-24T19:24:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/48/70/d93e556df6a0786298644a7c08304fcbeddc248325f23f38acbebeb21165/simplejson-4.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d75cea7a1025edd7e439b2966b3d977c45b5b899e2adaf422811b3ac702ed9fb", size = 213530, upload-time = "2026-04-24T19:24:09.289Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/c93bf305b9f00d7259e09e713d60e75bd0f7f53da970f716ab90491770e7/simplejson-4.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63c2ada8e58f266491f19eed2eeeb7c25c6141e52f8f9e820f6bb94156cf8dbc", size = 218282, upload-time = "2026-04-24T19:24:10.991Z" }, + { url = "https://files.pythonhosted.org/packages/0c/20/a9b5d2e27ec44b069ee251bd55544fc76929a067107b1050001566ba86f3/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d1fffb56305c5b475ee746cf9e04f97423ba5aaacd292dc1255bd75b1d3b124b", size = 209249, upload-time = "2026-04-24T19:24:12.662Z" }, + { url = "https://files.pythonhosted.org/packages/97/e4/e06ee682ed5df67592181f5ecb062e35878967e27f5b6e087237d4548d95/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a6525ec733f43d0541206cffa64fd2aad5a7ae3eb76566aff49cd4db6382209a", size = 213963, upload-time = "2026-04-24T19:24:14.302Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9f/1e160e4cd8cdbf062bf6a454cdf814dc7a48eb47e566fdb8f80ccb202605/simplejson-4.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:861e393260508efa64d8805a8e49c416c3484907e3f146ce966c69552b49b9a3", size = 210474, upload-time = "2026-04-24T19:24:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e6/cecd913df322df5bbe7ebb8ba39e0708e505a165553900da8a7761026d6f/simplejson-4.1.1-cp314-cp314t-win32.whl", hash = "sha256:d083b89d30948a751d3d97476c2ed91e4caaa24a1a1459bdbadb8876242c71fe", size = 91134, upload-time = "2026-04-24T19:24:17.635Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/f540dde99cc1d393bd062ab3b5735b777561a5d8f8a5f2e241164444d77a/simplejson-4.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4cbb299d0528ec0447fe366d8c9641860e28f997a62730690fef905f1f41046e", size = 94467, upload-time = "2026-04-24T19:24:19.109Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6a/8b74c52ffd33dbbde00fe7251fee6a0acdc8cea33f7a43805aed258fb79b/simplejson-4.1.1-py3-none-any.whl", hash = "sha256:2ce92b3748f02423e26d2bfb636fb9d7a8f67c8f5854dcae69d350d123b2eee2", size = 69195, upload-time = "2026-04-24T19:24:57.962Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "sqlglot" +version = "30.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/56/56d234bb267009a4b5fbaf56bd11eb5db91e0eaf0235b3ab53759d0cac2f/sqlglot-30.7.0.tar.gz", hash = "sha256:eaf90c7d61978ce98fb52718b7a578054bd0cebcc9ab6f3818ad4391ea9d6b69", size = 5860425, upload-time = "2026-05-04T17:02:23.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/08/bdc2fbd41b61173dade9ebb96b56e072a1cd7bdac24d8195e6dc5005186e/sqlglot-30.7.0-py3-none-any.whl", hash = "sha256:30421efcf3d57f95e57deaa755f65976c8b10735923f79986595dea912dc8206", size = 683613, upload-time = "2026-05-04T17:02:21.623Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[package]] +name = "types-requests" +version = "2.33.0.20260503" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/b8/57e94268c0d82ac3eaa2fc35aa8ca7bbc2542f726b67dcf90b0b00a3b14d/types_requests-2.33.0.20260503.tar.gz", hash = "sha256:9721b2d9dbee7131f2fb39f20f0ebb1999c18cef4b512c9a7932f3722de7c5f4", size = 23931, upload-time = "2026-05-03T05:20:08.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/82/959113a6351f3ca046cd0a8cd2cee071d7ea47473560557a01eeae9a6fe2/types_requests-2.33.0.20260503-py3-none-any.whl", hash = "sha256:02aaa7e3577a13471715bb1bddb693cc985ea514f754b503bf033e6a09a3e528", size = 20736, upload-time = "2026-05-03T05:20:07.858Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "zensical" +version = "0.0.40" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/88062f7e235f58a5f05d82005fc35d9dbaed27c024fe9ffae5bce7f33661/zensical-0.0.40.tar.gz", hash = "sha256:5c294751977a664614cb84e987186ad8e282af77ce0d0d800fe48ee57791279d", size = 3920555, upload-time = "2026-05-04T16:19:07.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/c4/3066f4442923ca1e49269147b70ca7c84467524e8f5228724693b9ac85c2/zensical-0.0.40-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b65a7143c9c6a460880bf3e65b777952bd2dcede9dd17a6c6bac9b4a0686ad9b", size = 12691533, upload-time = "2026-05-04T16:18:31.72Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cb/03e961cbd01620ea91aeb835b0b4e8848c7bcdf5a799a620fb3e57bfc277/zensical-0.0.40-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:045bdcb6d00a11ddcab7d379d0d986cdf78dba8e9287d8e628ef11958241507d", size = 12556486, upload-time = "2026-05-04T16:18:35.278Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7dde50220808bdc5f5e63b97866a684418410b3cae9d00cdae1d449bcc20/zensical-0.0.40-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d48ec476c2e8ce3f8585a1278083aabc35ec80361f2c4fc4a53b9a525778f7fc", size = 12935602, upload-time = "2026-05-04T16:18:38.308Z" }, + { url = "https://files.pythonhosted.org/packages/51/55/6c8ef951c390b42249738f4338498e7a1fd64ff09e44d7cc19f5c948c45b/zensical-0.0.40-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c38e0ae314c25f2e5e64210bbad9be6e970f2d40fe9da106586ad90ce5e85e", size = 12904314, upload-time = "2026-05-04T16:18:41.007Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ae/95008f5dc2ee441efcdc2fab36ff29ce24d7477e53390fc340c8add39342/zensical-0.0.40-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25f62dcd61f6306cab890dfa34c81d2709f5db290b4c3f2675343771db28c90", size = 13269946, upload-time = "2026-05-04T16:18:44.387Z" }, + { url = "https://files.pythonhosted.org/packages/b9/96/cdbb2bf04255ccaaa07861bdda1ee8dd1630d2233fc2f09636abbd5e084c/zensical-0.0.40-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:168fe3489dd93ae92978b4db11d9300c63e10d382b81634232c2872ce9e746c2", size = 12974962, upload-time = "2026-05-04T16:18:47.462Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ce/66e86f89fc15bbe667794ba67d7efc8fa72fe7a1be19e1efb4246ff55442/zensical-0.0.40-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8652ba203bd588ebf2d66bda4457a4a7d8e193c886960859c75081c0e3b946de", size = 13111599, upload-time = "2026-05-04T16:18:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/87/76/3d71ebdabb02d79a5c523b5e646141c362c9559947078c8d56a9f3bd7a30/zensical-0.0.40-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9ffa6cf208b7ab6b771703be827d4d8c7f07f173abeffb35a8015a0b832b2a40", size = 13175406, upload-time = "2026-05-04T16:18:53.209Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/2bb5f730786d590f02cb0fef796c148d5ac0d5c1556f2d78c987ad4e1346/zensical-0.0.40-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7101ba0c739c78bc3a57d22130b59b9e6fdf96c21c8a6b4244070de6b34527d4", size = 13324783, upload-time = "2026-05-04T16:18:56.41Z" }, + { url = "https://files.pythonhosted.org/packages/2f/8c/1d2ba1454360ee948dd0f0807b048c076d9578d0d9ebba2a438ecfa9f82f/zensical-0.0.40-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39bf728a68a5418feeda8f3385cd1063fdb8d896a6812c3dede4267b2868df12", size = 13260045, upload-time = "2026-05-04T16:18:59.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/61/efd51c5c5e15cfd5498d59df250f60294cc44d36d8ce4dc2a76fa3669c2f/zensical-0.0.40-cp310-abi3-win32.whl", hash = "sha256:bc750c3ba8d11833d9b9ac8fc14adc3435225b6d17314a21a91eb60209511ca5", size = 12244913, upload-time = "2026-05-04T16:19:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/f3f2118fbcfd1c2dc705491c8864c596b1a748b67ffe2a024e512b9201ab/zensical-0.0.40-cp310-abi3-win_amd64.whl", hash = "sha256:c5c86ac468df2dfe515ff54ffa97725c38226f1e5c970059b7e88078abab89ab", size = 12475762, upload-time = "2026-05-04T16:19:05.025Z" }, +]