From ee7565219a07ecf854dc686e4e8600f22a7b26c6 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 12:30:16 +0200 Subject: [PATCH 01/42] Added enterprise resources --- src/openhound_github/graphql.py | 215 ++++++++- src/openhound_github/resources/enterprise.py | 444 +++++++++++++++++++ 2 files changed, 654 insertions(+), 5 deletions(-) create mode 100644 src/openhound_github/resources/enterprise.py 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/resources/enterprise.py b/src/openhound_github/resources/enterprise.py new file mode 100644 index 0000000..db90b17 --- /dev/null +++ b/src/openhound_github/resources/enterprise.py @@ -0,0 +1,444 @@ +from typing import Any + +from openhound_github.models.enterprise import flatten_enterprise_member +from openhound_github.source_context import SourceContext + +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 ( + Enterprise, + EnterpriseExternalIdentity, + EnterpriseManagedUser, + EnterpriseOrganization, + EnterpriseRole, + EnterpriseRoleTeam, + EnterpriseRoleUser, + EnterpriseSamlProvider, + EnterpriseTeam, + EnterpriseTeamMember, + EnterpriseTeamOrganization, + EnterpriseTeamRole, + EnterpriseUser, +) + + +def _enterprise_required(ctx: SourceContext) -> str: + if not ctx.enterprise_name: + raise ValueError("Enterprise resources require enterprise_name to be set.") + return ctx.enterprise_name + + +def _enterprise_profile_and_orgs( + ctx: SourceContext, +) -> tuple[dict[str, Any], list[dict[str, Any]]]: + enterprise_slug = _enterprise_required(ctx) + 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": enterprise_slug, "after": None}, + } + + enterprise: dict[str, Any] | None = None + organizations: list[dict[str, Any]] = [] + 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 not page_enterprise: + raise ValueError(f"Enterprise '{enterprise_slug}' was not returned by GitHub.") + if enterprise is None: + enterprise = {k: v for k, v in page_enterprise.items() if k != "organizations"} + for org in page_enterprise.get("organizations", {}).get("nodes", []): + organizations.append( + { + **org, + "enterprise_node_id": enterprise["id"], + "enterprise_slug": enterprise_slug, + } + ) + + if enterprise is None: + raise ValueError(f"Enterprise '{enterprise_slug}' was not returned by GitHub.") + return enterprise, organizations + + +@app.resource(name="enterprise", columns=Enterprise, parallelized=True) +def enterprise(data: dict[str, Any]): + yield data + + +@app.resource( + name="enterprise_organizations", columns=EnterpriseOrganization, parallelized=True +) +def enterprise_organizations(orgs: list[dict[str, Any]]): + yield from orgs + + +@app.transformer(name="enterprise_members", parallelized=True) +def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): + enterprise_slug = _enterprise_required(ctx) + 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": enterprise_slug, "count": 100, "after": None}, + } + + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_data_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + for edge in (enterprise_data_page.get("members") or {}).get("edges") or []: + node = edge.get("node") + if node: + yield { + **node, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": enterprise_slug, + } + + +@app.transformer(name="enterprise_users", columns=EnterpriseUser, parallelized=True) +def enterprise_users(member: dict[str, Any]): + user, _ = flatten_enterprise_member( + member, member["enterprise_node_id"], member["enterprise_slug"] + ) + if user: + yield user + + +@app.transformer( + name="enterprise_managed_users", columns=EnterpriseManagedUser, parallelized=True +) +def enterprise_managed_users(member: dict[str, Any]): + _, managed_user = flatten_enterprise_member( + member, member["enterprise_node_id"], member["enterprise_slug"] + ) + if managed_user: + yield managed_user + + +@app.resource(name="enterprise_teams", columns=EnterpriseTeam, parallelized=True) +def enterprise_teams(ctx: SourceContext, enterprise_data: dict[str, Any]): + enterprise_slug = _enterprise_required(ctx) + try: + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/teams", params={"per_page": 100} + ): + for team in page: + yield { + **team, + "enterprise_node_id": enterprise_data["id"], + "enterprise_slug": enterprise_slug, + } + except Exception: + return + + +@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): + enterprise_slug = _enterprise_required(ctx) + try: + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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, + } + except Exception: + return + + +@app.transformer( + name="enterprise_team_organizations", + columns=EnterpriseTeamOrganization, + parallelized=True, +) +def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): + enterprise_slug = _enterprise_required(ctx) + try: + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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, + } + except Exception: + return + + +@app.resource(name="enterprise_roles", columns=EnterpriseRole, parallelized=True) +def enterprise_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): + enterprise_slug = _enterprise_required(ctx) + try: + result = ctx.client.get( + f"/enterprises/{enterprise_slug}/enterprise-roles" + ).json() + except Exception: + return + + for role in result.get("roles", []): + yield { + **role, + "enterprise_node_id": enterprise_data["id"], + "enterprise_slug": enterprise_slug, + } + + +@app.resource(name="enterprise_admin_roles", columns=EnterpriseRole, parallelized=True) +def enterprise_admin_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): + if ctx.auth_type != "token": + return + yield { + "id": "owners", + "name": "owners", + "description": "Enterprise administrators discovered from ownerInfo.admins", + "source": "Default", + "permissions": [], + "enterprise_node_id": enterprise_data["id"], + "enterprise_slug": _enterprise_required(ctx), + } + + +@app.transformer( + name="enterprise_role_users", columns=EnterpriseRoleUser, parallelized=True +) +def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): + if role.id == "owners": + return + enterprise_slug = _enterprise_required(ctx) + try: + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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, + } + except Exception: + return + + +@app.transformer( + name="enterprise_role_teams", columns=EnterpriseRoleTeam, parallelized=True +) +def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): + if role.id == "owners": + return + enterprise_slug = _enterprise_required(ctx) + try: + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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, + } + except Exception: + return + + +@app.resource(name="enterprise_admins", columns=EnterpriseRoleUser, parallelized=True) +def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): + if ctx.auth_type != "token": + return + + enterprise_slug = _enterprise_required(ctx) + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.ownerInfo.admins.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": ENTERPRISE_ADMINS_QUERY, + "variables": {"slug": enterprise_slug, "count": 100, "after": None}, + } + try: + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_data_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + owner_info = enterprise_data_page.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": enterprise_slug, + } + except Exception: + return + + +@app.transformer( + name="enterprise_saml_provider", columns=EnterpriseSamlProvider, parallelized=True +) +def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): + if ctx.auth_type != "token": + return + + enterprise_slug = _enterprise_required(ctx) + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": ENTERPRISE_SAML_QUERY, + "variables": {"slug": enterprise_slug, "count": 1, "after": None}, + } + + try: + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + saml_provider = (enterprise_page.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": enterprise_slug, + } + return + except Exception: + return + + +@app.transformer( + name="enterprise_external_identities", + columns=EnterpriseExternalIdentity, + parallelized=True, +) +def enterprise_external_identities( + saml_provider: EnterpriseSamlProvider, ctx: SourceContext +): + enterprise_slug = _enterprise_required(ctx) + paginator = GraphQLCursorPaginator( + page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", + cursor_variable="after", + cursor_field="endCursor", + has_next_field="hasNextPage", + ) + data = { + "query": ENTERPRISE_SAML_QUERY, + "variables": {"slug": enterprise_slug, "count": 100, "after": None}, + } + + try: + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + page_provider = (enterprise_page.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, + } + except Exception: + return \ No newline at end of file From d3d4c02c09af0861afae9af08cf69f091215c8dd Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 12:48:22 +0200 Subject: [PATCH 02/42] Fixed conditions for collecting external identities and runners --- src/openhound_github/source.py | 39 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index e12cc04..0105d46 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -101,14 +101,20 @@ def _runner_group_repo_node_ids( repo_node_ids = [] for repo in repos: + 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.get("visibility") in { + repo_node_ids.append(repo_node_id) + elif visibility == "private" and repo_visibility in { "private", "internal", }: - repo_node_ids.append(repo["node_id"]) - return repo_node_ids + repo_node_ids.append(repo_node_id) + return [node_id for node_id in repo_node_ids if node_id] @configspec @@ -1301,13 +1307,17 @@ def saml_provider(ctx: SourceContext): } response = ctx.client.post("/graphql", json=data).json() - if response["data"]: - org = response["data"]["organization"] - idp = org.get("samlIdentityProvider") + 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: + return + yield { **idp, - "org_node_id": org["id"], - "org_name": org["name"], + "org_node_id": org_data["id"], + "org_name": org_data["name"], } @@ -1340,10 +1350,13 @@ def external_identities(ctx: SourceContext): paginator=paginator, data_selector="data", ): - for identity in page_data[0]["organization"]["samlIdentityProvider"][ - "externalIdentities" - ]["nodes"]: - yield identity + for org in page_data: + org_data = org.get("organization") + idp = org_data.get("samlIdentityProvider") + if not idp: + return + for identity in (idp.get("externalIdentities") or {}).get("nodes") or []: + yield identity @app.resource(name="scim_users", columns=ScimResource, parallelized=True) From e7e2d7be80b042bae948b8440be9918e4400e3eb Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 12:52:39 +0200 Subject: [PATCH 03/42] Generic try/except is already handled by openhound for resources and transformers --- src/openhound_github/resources/enterprise.py | 297 +++++++++---------- 1 file changed, 135 insertions(+), 162 deletions(-) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index db90b17..677efdc 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -60,9 +60,13 @@ def _enterprise_profile_and_orgs( ): page_enterprise = page_data[0].get("enterprise") if not page_enterprise: - raise ValueError(f"Enterprise '{enterprise_slug}' was not returned by GitHub.") + raise ValueError( + f"Enterprise '{enterprise_slug}' was not returned by GitHub." + ) if enterprise is None: - enterprise = {k: v for k, v in page_enterprise.items() if k != "organizations"} + enterprise = { + k: v for k, v in page_enterprise.items() if k != "organizations" + } for org in page_enterprise.get("organizations", {}).get("nodes", []): organizations.append( { @@ -146,18 +150,15 @@ def enterprise_managed_users(member: dict[str, Any]): @app.resource(name="enterprise_teams", columns=EnterpriseTeam, parallelized=True) def enterprise_teams(ctx: SourceContext, enterprise_data: dict[str, Any]): enterprise_slug = _enterprise_required(ctx) - try: - for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/teams", params={"per_page": 100} - ): - for team in page: - yield { - **team, - "enterprise_node_id": enterprise_data["id"], - "enterprise_slug": enterprise_slug, - } - except Exception: - return + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/teams", params={"per_page": 100} + ): + for team in page: + yield { + **team, + "enterprise_node_id": enterprise_data["id"], + "enterprise_slug": enterprise_slug, + } @app.transformer( @@ -178,23 +179,19 @@ def enterprise_team_roles(team: EnterpriseTeam): ) def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): enterprise_slug = _enterprise_required(ctx) - try: - for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/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, - } - except Exception: - return + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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( @@ -204,35 +201,27 @@ def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): ) def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): enterprise_slug = _enterprise_required(ctx) - try: - for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/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, - } - except Exception: - return + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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.resource(name="enterprise_roles", columns=EnterpriseRole, parallelized=True) def enterprise_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): enterprise_slug = _enterprise_required(ctx) - try: - result = ctx.client.get( - f"/enterprises/{enterprise_slug}/enterprise-roles" - ).json() - except Exception: - return + result = ctx.client.get(f"/enterprises/{enterprise_slug}/enterprise-roles").json() for role in result.get("roles", []): yield { @@ -264,21 +253,18 @@ def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): if role.id == "owners": return enterprise_slug = _enterprise_required(ctx) - try: - for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/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, - } - except Exception: - return + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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( @@ -288,21 +274,18 @@ def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): if role.id == "owners": return enterprise_slug = _enterprise_required(ctx) - try: - for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/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, - } - except Exception: - return + for page in ctx.client.paginate( + f"/enterprises/{enterprise_slug}/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.resource(name="enterprise_admins", columns=EnterpriseRoleUser, parallelized=True) @@ -321,31 +304,28 @@ def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): "query": ENTERPRISE_ADMINS_QUERY, "variables": {"slug": enterprise_slug, "count": 100, "after": None}, } - try: - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - enterprise_data_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - owner_info = enterprise_data_page.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": enterprise_slug, - } - except Exception: - return + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_data_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + owner_info = enterprise_data_page.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": enterprise_slug, + } @app.transformer( @@ -367,30 +347,26 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): "variables": {"slug": enterprise_slug, "count": 1, "after": None}, } - try: - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - enterprise_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - saml_provider = (enterprise_page.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": enterprise_slug, - } + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + saml_provider = (enterprise_page.get("ownerInfo") or {}).get( + "samlIdentityProvider" + ) + if not saml_provider: return - except Exception: - return + yield { + **{k: v for k, v in saml_provider.items() if k != "externalIdentities"}, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": enterprise_slug, + } @app.transformer( @@ -413,32 +389,29 @@ def enterprise_external_identities( "variables": {"slug": enterprise_slug, "count": 100, "after": None}, } - try: - for page_data in ctx.client.paginate( - "/graphql", - method="POST", - json=data, - paginator=paginator, - data_selector="data", - ): - enterprise_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - page_provider = (enterprise_page.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, - } - except Exception: - return \ No newline at end of file + for page_data in ctx.client.paginate( + "/graphql", + method="POST", + json=data, + paginator=paginator, + data_selector="data", + ): + enterprise_page = ((page_data[0] if page_data else {}) or {}).get( + "enterprise" + ) or {} + page_provider = (enterprise_page.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, + } From 621b4f2f47c3beec7f59de309d6108daaafaefdd Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 13:38:48 +0200 Subject: [PATCH 04/42] Added enterprise nodes and edges --- src/openhound_github/kinds/edges.py | 2 ++ src/openhound_github/kinds/nodes.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index cc89c84..1f6b53f 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" diff --git a/src/openhound_github/kinds/nodes.py b/src/openhound_github/kinds/nodes.py index 4eb9355..716b0c7 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,9 @@ # Workflow nodes WORKFLOW = "GH_Workflow" + + +# Enterprise nodes +ENTERPRISE = "GH_Enterprise" +ENTERPRISE_TEAM = "GH_EnterpriseTeam" +ENTERPRISE_ROLE = "GH_EnterpriseRole" From 82577c92ce1be558d1e19ae527719a70692b7a2f Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 13:51:52 +0200 Subject: [PATCH 05/42] Split org/enterprise resource collection --- .../resources/organization.py | 1442 +++++++++++++++++ src/openhound_github/source.py | 1298 +-------------- 2 files changed, 1455 insertions(+), 1285 deletions(-) create mode 100644 src/openhound_github/resources/organization.py diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py new file mode 100644 index 0000000..09f6421 --- /dev/null +++ b/src/openhound_github/resources/organization.py @@ -0,0 +1,1442 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Iterator + +from dlt.common.configuration import configspec +from dlt.common.configuration.specs import CredentialsConfiguration +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.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 + + +@dataclass +class SourceContext: + """Shared context for GitHub API access.""" + + client: RESTClient + org_name: str | None = None + 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 + + repo_node_ids = [] + for repo in repos: + 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] + + +@configspec +class GithubCredentials(CredentialsConfiguration): + org_name: str | None = None + enterprise_name: str | None = None + + def auth(self): + pass + + +@configspec +class GithubAppCredentials(GithubCredentials): + client_id: str = None + app_id: str = None + key_path: str = None + + def auth(self) -> str: + return "app" + + @property + def header(self) -> str: + github_access_token = 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 + + +@configspec +class GithubTokenCredentials(GithubCredentials): + token: str = None + + def auth(self) -> str: + return "token" + + @property + 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() + 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: + return + + yield { + **idp, + "org_node_id": org_data["id"], + "org_name": org_data["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 org in page_data: + org_data = org.get("organization") + idp = org_data.get("samlIdentityProvider") + if not idp: + return + for identity in (idp.get("externalIdentities") or {}).get("nodes") or []: + 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 + + +def organization_resources(ctx: SourceContext): + 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) + + 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), + ) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 0105d46..049d088 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -71,13 +71,17 @@ 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.""" client: RESTClient - org_name: str + org_name: str | None = None + enterprise_name: str | None = None def _runner_group_repo_node_ids( @@ -119,7 +123,8 @@ def _runner_group_repo_node_ids( @configspec class GithubCredentials(CredentialsConfiguration): - org_name: str = None + org_name: str | None = None + enterprise_name: str | None = None def auth(self): pass @@ -157,1233 +162,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() - 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: - return - - yield { - **idp, - "org_node_id": org_data["id"], - "org_name": org_data["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 org in page_data: - org_data = org.get("organization") - idp = org_data.get("samlIdentityProvider") - if not idp: - return - for identity in (idp.get("externalIdentities") or {}).get("nodes") or []: - 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[ @@ -1398,6 +176,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: @@ -1425,61 +206,8 @@ def retry_policy( session=requests.Client(retry_condition=retry_policy).session, ), org_name=credentials.org_name, + enterprise_name=credentials.enterprise_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) - - 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), - ) + if credentials.org_name: + return organization_resources(ctx) From ff95e738d58b4e50b7e0b50de1ef33d023788c13 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 14:24:21 +0200 Subject: [PATCH 06/42] Restructured enterprise + es org collection to use dlt resources and transformers --- src/openhound_github/resources/enterprise.py | 145 +++++++++---------- 1 file changed, 70 insertions(+), 75 deletions(-) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 677efdc..c532109 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -1,7 +1,7 @@ +from dataclasses import dataclass from typing import Any -from openhound_github.models.enterprise import flatten_enterprise_member -from openhound_github.source_context import SourceContext +from dlt.sources.helpers.rest_client.client import RESTClient from openhound_github.graphql import ( ENTERPRISE_ADMINS_QUERY, @@ -26,18 +26,21 @@ EnterpriseTeamRole, EnterpriseUser, ) +from openhound_github.models.enterprise import flatten_enterprise_member -def _enterprise_required(ctx: SourceContext) -> str: - if not ctx.enterprise_name: - raise ValueError("Enterprise resources require enterprise_name to be set.") - return ctx.enterprise_name +@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): -def _enterprise_profile_and_orgs( - ctx: SourceContext, -) -> tuple[dict[str, Any], list[dict[str, Any]]]: - enterprise_slug = _enterprise_required(ctx) paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.organizations.pageInfo", cursor_variable="after", @@ -46,11 +49,9 @@ def _enterprise_profile_and_orgs( ) data = { "query": ENTERPRISE_QUERY, - "variables": {"slug": enterprise_slug, "after": None}, + "variables": {"slug": ctx.enterprise_name, "after": None}, } - enterprise: dict[str, Any] | None = None - organizations: list[dict[str, Any]] = [] for page_data in ctx.client.paginate( "/graphql", method="POST", @@ -59,43 +60,27 @@ def _enterprise_profile_and_orgs( data_selector="data", ): page_enterprise = page_data[0].get("enterprise") - if not page_enterprise: - raise ValueError( - f"Enterprise '{enterprise_slug}' was not returned by GitHub." - ) - if enterprise is None: - enterprise = { - k: v for k, v in page_enterprise.items() if k != "organizations" - } - for org in page_enterprise.get("organizations", {}).get("nodes", []): - organizations.append( - { - **org, - "enterprise_node_id": enterprise["id"], - "enterprise_slug": enterprise_slug, - } - ) - - if enterprise is None: - raise ValueError(f"Enterprise '{enterprise_slug}' was not returned by GitHub.") - return enterprise, organizations - -@app.resource(name="enterprise", columns=Enterprise, parallelized=True) -def enterprise(data: dict[str, Any]): - yield data + if page_enterprise: + yield page_enterprise -@app.resource( +@app.transformer( name="enterprise_organizations", columns=EnterpriseOrganization, parallelized=True ) -def enterprise_organizations(orgs: list[dict[str, Any]]): - yield from orgs +def enterprise_organizations(enterprise: Enterprise, ctx: SourceContext): + orgs = enterprise.organizations.get("nodes", []) + for org in orgs: + yield { + **org, + "enterprise_node_id": enterprise.id, + "enterprise_slug": ctx.enterprise_name, + } @app.transformer(name="enterprise_members", parallelized=True) def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): - enterprise_slug = _enterprise_required(ctx) + paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.members.pageInfo", cursor_variable="after", @@ -104,9 +89,8 @@ def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): ) data = { "query": ENTERPRISE_MEMBERS_QUERY, - "variables": {"slug": enterprise_slug, "count": 100, "after": None}, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( "/graphql", method="POST", @@ -123,14 +107,14 @@ def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): yield { **node, "enterprise_node_id": enterprise_data.id, - "enterprise_slug": enterprise_slug, + "ctx.enterprise_name": ctx.enterprise_name, } @app.transformer(name="enterprise_users", columns=EnterpriseUser, parallelized=True) def enterprise_users(member: dict[str, Any]): user, _ = flatten_enterprise_member( - member, member["enterprise_node_id"], member["enterprise_slug"] + member, member["enterprise_node_id"], member["ctx.enterprise_name"] ) if user: yield user @@ -141,7 +125,7 @@ def enterprise_users(member: dict[str, Any]): ) def enterprise_managed_users(member: dict[str, Any]): _, managed_user = flatten_enterprise_member( - member, member["enterprise_node_id"], member["enterprise_slug"] + member, member["enterprise_node_id"], member["ctx.enterprise_name"] ) if managed_user: yield managed_user @@ -149,15 +133,15 @@ def enterprise_managed_users(member: dict[str, Any]): @app.resource(name="enterprise_teams", columns=EnterpriseTeam, parallelized=True) def enterprise_teams(ctx: SourceContext, enterprise_data: dict[str, Any]): - enterprise_slug = _enterprise_required(ctx) + for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/teams", params={"per_page": 100} + f"/enterprises/{ctx.enterprise_name}/teams", params={"per_page": 100} ): for team in page: yield { **team, "enterprise_node_id": enterprise_data["id"], - "enterprise_slug": enterprise_slug, + "ctx.enterprise_name": ctx.enterprise_name, } @@ -170,7 +154,7 @@ def enterprise_team_roles(team: EnterpriseTeam): "name": team.name, "slug": team.slug, "enterprise_node_id": team.enterprise_node_id, - "enterprise_slug": team.enterprise_slug, + "ctx.enterprise_name": team.ctx.enterprise_name, } @@ -178,9 +162,9 @@ def enterprise_team_roles(team: EnterpriseTeam): name="enterprise_team_members", columns=EnterpriseTeamMember, parallelized=True ) def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): - enterprise_slug = _enterprise_required(ctx) + for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/teams/{team.id}/memberships", + f"/enterprises/{ctx.enterprise_name}/teams/{team.id}/memberships", params={"per_page": 100}, ): for member in page: @@ -191,7 +175,8 @@ def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): "node_id": node_id, "team_id": team.id, "enterprise_node_id": team.enterprise_node_id, - "enterprise_slug": team.enterprise_slug, + "ctx.enterprise_name": team.ctx.enterprise_name, + } @app.transformer( @@ -200,9 +185,9 @@ def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): parallelized=True, ) def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): - enterprise_slug = _enterprise_required(ctx) + for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/teams/{team.id}/organizations", + f"/enterprises/{ctx.enterprise_name}/teams/{team.id}/organizations", params={"per_page": 100}, ): for org in page: @@ -214,20 +199,22 @@ def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): "team_id": team.id, "projected_slug": team.slug, "enterprise_node_id": team.enterprise_node_id, - "enterprise_slug": team.enterprise_slug, + "ctx.enterprise_name": team.ctx.enterprise_name, } @app.resource(name="enterprise_roles", columns=EnterpriseRole, parallelized=True) def enterprise_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): - enterprise_slug = _enterprise_required(ctx) - result = ctx.client.get(f"/enterprises/{enterprise_slug}/enterprise-roles").json() + + 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": enterprise_slug, + "ctx.enterprise_name": ctx.enterprise_name, } @@ -242,7 +229,7 @@ def enterprise_admin_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): "source": "Default", "permissions": [], "enterprise_node_id": enterprise_data["id"], - "enterprise_slug": _enterprise_required(ctx), + "ctx.enterprise_name": _enterprise_required(ctx), } @@ -252,9 +239,9 @@ def enterprise_admin_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): if role.id == "owners": return - enterprise_slug = _enterprise_required(ctx) + for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/enterprise-roles/{role.id}/users", + f"/enterprises/{ctx.enterprise_name}/enterprise-roles/{role.id}/users", params={"per_page": 100}, ): for user in page: @@ -263,7 +250,7 @@ def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): **user, "role_id": role.id, "enterprise_node_id": role.enterprise_node_id, - "enterprise_slug": role.enterprise_slug, + "ctx.enterprise_name": role.ctx.enterprise_name, } @@ -273,9 +260,9 @@ def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): if role.id == "owners": return - enterprise_slug = _enterprise_required(ctx) + for page in ctx.client.paginate( - f"/enterprises/{enterprise_slug}/enterprise-roles/{role.id}/teams", + f"/enterprises/{ctx.enterprise_name}/enterprise-roles/{role.id}/teams", params={"per_page": 100}, ): for team in page: @@ -284,7 +271,7 @@ def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): **team, "role_id": role.id, "enterprise_node_id": role.enterprise_node_id, - "enterprise_slug": role.enterprise_slug, + "ctx.enterprise_name": role.ctx.enterprise_name, } @@ -293,7 +280,6 @@ def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): if ctx.auth_type != "token": return - enterprise_slug = _enterprise_required(ctx) paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.ownerInfo.admins.pageInfo", cursor_variable="after", @@ -302,7 +288,7 @@ def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): ) data = { "query": ENTERPRISE_ADMINS_QUERY, - "variables": {"slug": enterprise_slug, "count": 100, "after": None}, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, } for page_data in ctx.client.paginate( "/graphql", @@ -324,7 +310,7 @@ def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): "assignment": "direct", "role_id": "owners", "enterprise_node_id": enterprise_data["id"], - "enterprise_slug": enterprise_slug, + "ctx.enterprise_name": ctx.enterprise_name, } @@ -335,7 +321,6 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): if ctx.auth_type != "token": return - enterprise_slug = _enterprise_required(ctx) paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", cursor_variable="after", @@ -344,7 +329,7 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): ) data = { "query": ENTERPRISE_SAML_QUERY, - "variables": {"slug": enterprise_slug, "count": 1, "after": None}, + "variables": {"slug": ctx.enterprise_name, "count": 1, "after": None}, } for page_data in ctx.client.paginate( @@ -365,7 +350,7 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): yield { **{k: v for k, v in saml_provider.items() if k != "externalIdentities"}, "enterprise_node_id": enterprise_data.id, - "enterprise_slug": enterprise_slug, + "ctx.enterprise_name": ctx.enterprise_name, } @@ -377,7 +362,7 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): def enterprise_external_identities( saml_provider: EnterpriseSamlProvider, ctx: SourceContext ): - enterprise_slug = _enterprise_required(ctx) + paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", cursor_variable="after", @@ -386,7 +371,7 @@ def enterprise_external_identities( ) data = { "query": ENTERPRISE_SAML_QUERY, - "variables": {"slug": enterprise_slug, "count": 100, "after": None}, + "variables": {"slug": ctx.enterprise_name, "count": 100, "after": None}, } for page_data in ctx.client.paginate( @@ -413,5 +398,15 @@ def enterprise_external_identities( "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, + "ctx.enterprise_name": saml_provider.ctx.enterprise_name, } + + +def enterprise_resources(ctx: SourceContext): + enterprise_resource = enterprise(ctx) + + organizations_resource = enterprise_organizations(ctx) + return ( + enterprise_resource, + enterprise_resource | organizations_resource, + ) From 0f4abc5e410858b91b7811301df31c5c8f68b092 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 14:57:19 +0200 Subject: [PATCH 07/42] Added BaseUser model and removed _flatten_members method in favor of in-resource/transformer parsing of users --- src/openhound_github/models/enterprise.py | 1186 ++++++++++++++++++ src/openhound_github/resources/enterprise.py | 83 +- 2 files changed, 1234 insertions(+), 35 deletions(-) create mode 100644 src/openhound_github/models/enterprise.py diff --git a/src/openhound_github/models/enterprise.py b/src/openhound_github/models/enterprise.py new file mode 100644 index 0000000..5c1da0d --- /dev/null +++ b/src/openhound_github/models/enterprise.py @@ -0,0 +1,1186 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Any, ClassVar + +from dlt.common.libs.pydantic import DltConfig +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 + + +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}" + + +@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.node_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 [] + + +@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.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + +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 + + +@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.enterprise_node_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.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + +@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") + updated_at: datetime | None = Field(alias="updatedAt") + 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.enterprise_node_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.enterprise_node_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), + ) + + +@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.enterprise_node_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.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + +@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.enterprise_node_id, + environment_name=self.enterprise_slug, + enterpriseid=self.enterprise_node_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), + ) + + +@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), + ) + + +@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), + PropertyMatch(key="slug", value=self.projected_slug), + ], + ), + properties=EdgeProperties(traversable=True), + ) + + +@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: int | str + 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.enterprise_node_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.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + +@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), + ) + + +@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), + ) + + +@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.enterprise_node_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.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) + + +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.enterprise_node_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/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index c532109..eea24a0 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -12,6 +12,7 @@ from openhound_github.helpers import GraphQLCursorPaginator from openhound_github.main import app from openhound_github.models import ( + BaseUser, Enterprise, EnterpriseExternalIdentity, EnterpriseManagedUser, @@ -26,7 +27,6 @@ EnterpriseTeamRole, EnterpriseUser, ) -from openhound_github.models.enterprise import flatten_enterprise_member @dataclass @@ -68,17 +68,17 @@ def enterprise(ctx: SourceContext): @app.transformer( name="enterprise_organizations", columns=EnterpriseOrganization, parallelized=True ) -def enterprise_organizations(enterprise: Enterprise, ctx: SourceContext): - orgs = enterprise.organizations.get("nodes", []) +def enterprise_organizations(enterprise_data: Enterprise, ctx: SourceContext): + orgs = enterprise_data.organizations.get("nodes", []) for org in orgs: yield { **org, - "enterprise_node_id": enterprise.id, + "enterprise_node_id": enterprise_data.id, "enterprise_slug": ctx.enterprise_name, } -@app.transformer(name="enterprise_members", parallelized=True) +@app.transformer(name="enterprise_members", columns=BaseUser, parallelized=True) def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): paginator = GraphQLCursorPaginator( @@ -98,37 +98,46 @@ def enterprise_members(enterprise_data: Enterprise, ctx: SourceContext): paginator=paginator, data_selector="data", ): - enterprise_data_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - for edge in (enterprise_data_page.get("members") or {}).get("edges") or []: - node = edge.get("node") - if node: - yield { - **node, - "enterprise_node_id": enterprise_data.id, - "ctx.enterprise_name": ctx.enterprise_name, - } + 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(member: dict[str, Any]): - user, _ = flatten_enterprise_member( - member, member["enterprise_node_id"], member["ctx.enterprise_name"] - ) - if user: - yield user +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(member: dict[str, Any]): - _, managed_user = flatten_enterprise_member( - member, member["enterprise_node_id"], member["ctx.enterprise_name"] - ) - if managed_user: - yield managed_user +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.resource(name="enterprise_teams", columns=EnterpriseTeam, parallelized=True) @@ -203,9 +212,8 @@ def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): } -@app.resource(name="enterprise_roles", columns=EnterpriseRole, parallelized=True) -def enterprise_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): - +@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() @@ -214,12 +222,14 @@ def enterprise_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): yield { **role, "enterprise_node_id": enterprise_data["id"], - "ctx.enterprise_name": ctx.enterprise_name, + "enterprise_slug": ctx.enterprise_name, } -@app.resource(name="enterprise_admin_roles", columns=EnterpriseRole, parallelized=True) -def enterprise_admin_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): +@app.transformer( + name="enterprise_admin_roles", columns=EnterpriseRole, parallelized=True +) +def enterprise_admin_roles(enterprise_data: Enterprise, ctx: SourceContext): if ctx.auth_type != "token": return yield { @@ -229,7 +239,7 @@ def enterprise_admin_roles(ctx: SourceContext, enterprise_data: dict[str, Any]): "source": "Default", "permissions": [], "enterprise_node_id": enterprise_data["id"], - "ctx.enterprise_name": _enterprise_required(ctx), + "enterprise_slug": ctx.enterprise_name, } @@ -406,7 +416,10 @@ def enterprise_resources(ctx: SourceContext): enterprise_resource = enterprise(ctx) organizations_resource = enterprise_organizations(ctx) + members_resource = enterprise_members(ctx) return ( enterprise_resource, enterprise_resource | organizations_resource, + enterprise_resource | members_resource | enterprise_users(ctx), + enterprise_resource | members_resource | enterprise_managed_users(ctx), ) From d100305669ccae763d0e99280d350185d12dd773 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 14:58:55 +0200 Subject: [PATCH 08/42] enterprise_teams should be a transformer, not a resource --- src/openhound_github/resources/enterprise.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index eea24a0..02fc72f 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -140,8 +140,8 @@ def enterprise_managed_users(base_user: BaseUser, ctx: SourceContext): } -@app.resource(name="enterprise_teams", columns=EnterpriseTeam, parallelized=True) -def enterprise_teams(ctx: SourceContext, enterprise_data: dict[str, Any]): +@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} @@ -149,8 +149,8 @@ def enterprise_teams(ctx: SourceContext, enterprise_data: dict[str, Any]): for team in page: yield { **team, - "enterprise_node_id": enterprise_data["id"], - "ctx.enterprise_name": ctx.enterprise_name, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, } @@ -422,4 +422,5 @@ def enterprise_resources(ctx: SourceContext): enterprise_resource | organizations_resource, enterprise_resource | members_resource | enterprise_users(ctx), enterprise_resource | members_resource | enterprise_managed_users(ctx), + enterprise_resource | enterprise_teams(ctx), ) From 76f2b68dd2661098660ec41d5ea6ecf3049265eb Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 15:13:08 +0200 Subject: [PATCH 09/42] Improved and added enterprise_role_users + enterprise_role_teams --- src/openhound_github/models/enterprise.py | 2 +- src/openhound_github/resources/enterprise.py | 27 ++++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/openhound_github/models/enterprise.py b/src/openhound_github/models/enterprise.py index 5c1da0d..8f4ef7f 100644 --- a/src/openhound_github/models/enterprise.py +++ b/src/openhound_github/models/enterprise.py @@ -764,7 +764,7 @@ class EnterpriseRole(BaseAsset): dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} - id: int | str + id: str | int name: str description: str | None = None source: str | None = None diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 02fc72f..6f9676a 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -163,7 +163,7 @@ def enterprise_team_roles(team: EnterpriseTeam): "name": team.name, "slug": team.slug, "enterprise_node_id": team.enterprise_node_id, - "ctx.enterprise_name": team.ctx.enterprise_name, + "enterprise_slug": team.enterprise_slug, } @@ -184,7 +184,7 @@ def enterprise_team_members(team: EnterpriseTeam, ctx: SourceContext): "node_id": node_id, "team_id": team.id, "enterprise_node_id": team.enterprise_node_id, - "ctx.enterprise_name": team.ctx.enterprise_name, + "enterprise_slug": team.enterprise_slug, } @@ -208,7 +208,7 @@ def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): "team_id": team.id, "projected_slug": team.slug, "enterprise_node_id": team.enterprise_node_id, - "ctx.enterprise_name": team.ctx.enterprise_name, + "enterprise_slug": team.enterprise_slug, } @@ -221,7 +221,7 @@ def enterprise_roles(enterprise_data: Enterprise, ctx: SourceContext): for role in result.get("roles", []): yield { **role, - "enterprise_node_id": enterprise_data["id"], + "enterprise_node_id": enterprise_data.id, "enterprise_slug": ctx.enterprise_name, } @@ -230,15 +230,13 @@ def enterprise_roles(enterprise_data: Enterprise, ctx: SourceContext): name="enterprise_admin_roles", columns=EnterpriseRole, parallelized=True ) def enterprise_admin_roles(enterprise_data: Enterprise, ctx: SourceContext): - if ctx.auth_type != "token": - return yield { "id": "owners", "name": "owners", "description": "Enterprise administrators discovered from ownerInfo.admins", "source": "Default", "permissions": [], - "enterprise_node_id": enterprise_data["id"], + "enterprise_node_id": enterprise_data.id, "enterprise_slug": ctx.enterprise_name, } @@ -260,7 +258,7 @@ def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): **user, "role_id": role.id, "enterprise_node_id": role.enterprise_node_id, - "ctx.enterprise_name": role.ctx.enterprise_name, + "enterprise_slug": role.enterprise_slug, } @@ -281,7 +279,7 @@ def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): **team, "role_id": role.id, "enterprise_node_id": role.enterprise_node_id, - "ctx.enterprise_name": role.ctx.enterprise_name, + "enterprise_slug": role.enterprise_slug, } @@ -417,10 +415,19 @@ def enterprise_resources(ctx: SourceContext): organizations_resource = enterprise_organizations(ctx) members_resource = enterprise_members(ctx) + teams_resource = enterprise_teams(ctx) + roles_resource = enterprise_roles(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 | enterprise_teams(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), ) From dbafa7654d9131c8408352c259e1bfff185c0ec9 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 15:21:52 +0200 Subject: [PATCH 10/42] Improved and added enterprise_external_identities + enterprise_saml_provider --- src/openhound_github/resources/enterprise.py | 104 +++++++++---------- 1 file changed, 48 insertions(+), 56 deletions(-) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 6f9676a..43a0313 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -283,11 +283,10 @@ def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): } -@app.resource(name="enterprise_admins", columns=EnterpriseRoleUser, parallelized=True) -def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): - if ctx.auth_type != "token": - return - +@app.transformer( + name="enterprise_admins", columns=EnterpriseRoleUser, parallelized=True +) +def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext): paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.ownerInfo.admins.pageInfo", cursor_variable="after", @@ -305,30 +304,26 @@ def enterprise_admins(ctx: SourceContext, enterprise_data: dict[str, Any]): paginator=paginator, data_selector="data", ): - enterprise_data_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - owner_info = enterprise_data_page.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"], - "ctx.enterprise_name": ctx.enterprise_name, - } + 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"], + "ctx.enterprise_name": ctx.enterprise_name, + } @app.transformer( name="enterprise_saml_provider", columns=EnterpriseSamlProvider, parallelized=True ) def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): - if ctx.auth_type != "token": - return - paginator = GraphQLCursorPaginator( page_info_path="data.enterprise.ownerInfo.samlIdentityProvider.externalIdentities.pageInfo", cursor_variable="after", @@ -347,19 +342,16 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): paginator=paginator, data_selector="data", ): - enterprise_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - saml_provider = (enterprise_page.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, - "ctx.enterprise_name": ctx.enterprise_name, - } + 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( @@ -389,34 +381,31 @@ def enterprise_external_identities( paginator=paginator, data_selector="data", ): - enterprise_page = ((page_data[0] if page_data else {}) or {}).get( - "enterprise" - ) or {} - page_provider = (enterprise_page.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, - "ctx.enterprise_name": saml_provider.ctx.enterprise_name, - } + 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, @@ -430,4 +419,7 @@ def enterprise_resources(ctx: SourceContext): 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), ) From eb5882138ab5a9cb4f8c595a7cdcd37b96688a24 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 16:49:57 +0200 Subject: [PATCH 11/42] Iterate org-specific collection for each org_name in ctx --- src/openhound_github/auth.py | 90 ++- src/openhound_github/models/__init__.py | 34 + .../models/actions_permission.py | 5 +- .../models/app_installation.py | 6 + src/openhound_github/models/branch.py | 3 + .../models/branch_protection_rule.py | 1 + src/openhound_github/models/env_secret.py | 1 + src/openhound_github/models/env_variable.py | 1 + src/openhound_github/models/environment.py | 1 + .../models/external_identity.py | 3 + src/openhound_github/models/org.py | 4 + src/openhound_github/models/org_role_team.py | 2 + src/openhound_github/models/org_secret.py | 14 +- src/openhound_github/models/org_variable.py | 3 + .../models/personal_access_token.py | 7 + .../models/personal_access_token_request.py | 3 + .../models/repo_role_assignment.py | 1 + src/openhound_github/models/repository.py | 13 +- .../models/repository_role.py | 4 + .../models/repository_secret.py | 1 + .../models/repository_variable.py | 1 + src/openhound_github/models/runner.py | 12 + src/openhound_github/models/saml_provider.py | 1 + src/openhound_github/models/scim_user.py | 5 +- .../models/secret_scanning_alert.py | 1 + src/openhound_github/models/team.py | 4 +- src/openhound_github/models/team_member.py | 3 + src/openhound_github/models/team_role.py | 3 + src/openhound_github/models/user.py | 3 + src/openhound_github/models/workflow.py | 4 +- src/openhound_github/resources/__init__.py | 2 + .../resources/organization.py | 672 ++++++++++-------- src/openhound_github/source.py | 108 +-- 33 files changed, 591 insertions(+), 425 deletions(-) create mode 100644 src/openhound_github/resources/__init__.py diff --git a/src/openhound_github/auth.py b/src/openhound_github/auth.py index 5721fc2..4239a83 100644 --- a/src/openhound_github/auth.py +++ b/src/openhound_github/auth.py @@ -1,12 +1,13 @@ """GitHub authentication helpers for JWT-based app authentication.""" -import json import base64 -import requests +import json from datetime import datetime, timezone +from typing import Optional + +import requests from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding -from typing import Optional class GitHubJwtSession: @@ -14,7 +15,7 @@ class GitHubJwtSession: def __init__( self, - org_name: str, + org_name: str | None, client_id: str, private_key_path: str, app_id: str, @@ -117,6 +118,63 @@ def _base64url_encode(data: str | bytes) -> str: # Remove padding return encoded.rstrip("=") + def get_jwt_headers(self) -> dict: + """Get HTTP headers for GitHub App JWT requests.""" + jwt_token = self._create_jwt() + return { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {jwt_token}", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _installation_id(self) -> str: + if not self.org_name: + return self.app_id + + try: + response = requests.get( + f"{self.api_uri}orgs/{self.org_name}/installation", + headers=self.get_jwt_headers(), + timeout=10, + ) + response.raise_for_status() + except requests.RequestException as e: + raise ValueError( + f"Failed to resolve GitHub App installation for org {self.org_name}: {e}" + ) from e + + installation_id = response.json().get("id") + if installation_id is None: + raise ValueError( + f"No installation id returned for GitHub org {self.org_name}." + ) + return str(installation_id) + + def list_installations(self) -> list[dict]: + installations: list[dict] = [] + page = 1 + while True: + try: + response = requests.get( + f"{self.api_uri}app/installations", + headers=self.get_jwt_headers(), + params={"per_page": 100, "page": page}, + timeout=10, + ) + response.raise_for_status() + except requests.RequestException as e: + raise ValueError(f"Failed to list GitHub App installations: {e}") from e + + batch = response.json() + if not batch: + break + installations.extend(batch) + if "next" not in response.links: + break + page += 1 + + return installations + def get_access_token(self) -> str: """Get a valid access token for GitHub API requests. @@ -138,19 +196,13 @@ def get_access_token(self) -> str: ): return self._access_token - # Create a fresh JWT - jwt_token = self._create_jwt() - - # Exchange JWT for access token - headers = { - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {jwt_token}", - "X-GitHub-Api-Version": "2022-11-28", - } + # Exchange JWT for an installation-scoped access token. + headers = self.get_jwt_headers() + installation_id = self._installation_id() try: response = requests.post( - f"{self.api_uri}app/installations/{self.app_id}/access_tokens", + f"{self.api_uri}app/installations/{installation_id}/access_tokens", headers=headers, timeout=10, ) @@ -189,7 +241,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. @@ -198,6 +254,7 @@ def create_github_jwt_session( client_id: The GitHub App client ID. private_key_path: Path to the private key PEM file. app_id: The GitHub App ID. + api_uri: The GitHub API base URI. Returns: A GitHubJwtSession instance. @@ -217,4 +274,5 @@ def create_github_jwt_session( client_id=client_id, private_key_path=private_key_path, app_id=app_id, - ) + api_uri=api_uri, + ) \ No newline at end of file diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index b4b1c1d..8d4b74a 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 ( + BaseUser, + Enterprise, + EnterpriseExternalIdentity, + EnterpriseManagedUser, + EnterpriseOrganization, + EnterpriseRole, + EnterpriseRoleTeam, + EnterpriseRoleUser, + EnterpriseSamlProvider, + EnterpriseTeam, + EnterpriseTeamMember, + EnterpriseTeamOrganization, + EnterpriseTeamRole, + EnterpriseUser, +) from .env_secret import EnvironmentSecret from .env_variable import EnvironmentVariable from .environment import Environment @@ -78,4 +94,22 @@ "ExternalIdentity", "ScimResource", "RepoRoleAssignment", + "Environment", + "EnvironmentSecret", + "EnvironmentVariable", + "EnvironmentBranchPolicy", + "Enterprise", + "EnterpriseExternalIdentity", + "EnterpriseManagedUser", + "EnterpriseOrganization", + "EnterpriseRole", + "EnterpriseRoleTeam", + "EnterpriseRoleUser", + "EnterpriseSamlProvider", + "EnterpriseTeam", + "EnterpriseTeamMember", + "EnterpriseTeamOrganization", + "EnterpriseTeamRole", + "EnterpriseUser", + "BaseUser" ] 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..fc33a48 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -145,6 +145,9 @@ 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}" @@ -312,6 +315,9 @@ class App(BaseAsset): created_at: datetime | None = None updated_at: datetime | None = None + # Additional + org_login: str + @property def as_node(self): aid = self.node_id diff --git a/src/openhound_github/models/branch.py b/src/openhound_github/models/branch.py index 4c6c290..58c6dac 100644 --- a/src/openhound_github/models/branch.py +++ b/src/openhound_github/models/branch.py @@ -76,6 +76,9 @@ class Branch(BaseAsset): alias="branchProtectionRule", default=None ) + # Additional + org_login: str + @property def node_id(self) -> str: """The ID is identical to a node_id when using GraphQL""" diff --git a/src/openhound_github/models/branch_protection_rule.py b/src/openhound_github/models/branch_protection_rule.py index 8ed6740..09b4009 100644 --- a/src/openhound_github/models/branch_protection_rule.py +++ b/src/openhound_github/models/branch_protection_rule.py @@ -173,6 +173,7 @@ class BranchProtectionRule(BaseAsset): push_allowances: PushAllowances | None = Field(alias="pushAllowances", default=None) # Additional + org_login: str repository_node_id: str repository_name: str diff --git a/src/openhound_github/models/env_secret.py b/src/openhound_github/models/env_secret.py index c10cf69..ca10c31 100644 --- a/src/openhound_github/models/env_secret.py +++ b/src/openhound_github/models/env_secret.py @@ -65,6 +65,7 @@ class EnvironmentSecret(BaseAsset): selected_repositories_url: str | None = None # Additional + org_login: str repository_name: str repository_node_id: str environment_name: str diff --git a/src/openhound_github/models/env_variable.py b/src/openhound_github/models/env_variable.py index 794a1d8..1e6c706 100644 --- a/src/openhound_github/models/env_variable.py +++ b/src/openhound_github/models/env_variable.py @@ -60,6 +60,7 @@ class EnvironmentVariable(BaseAsset): updated_at: datetime | None = None # Additional + org_login: str environment_node_id: str environment_name: str repository_name: str diff --git a/src/openhound_github/models/environment.py b/src/openhound_github/models/environment.py index f1ce79d..ec0fa50 100644 --- a/src/openhound_github/models/environment.py +++ b/src/openhound_github/models/environment.py @@ -137,6 +137,7 @@ 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 diff --git a/src/openhound_github/models/external_identity.py b/src/openhound_github/models/external_identity.py index d369fe7..ef2c027 100644 --- a/src/openhound_github/models/external_identity.py +++ b/src/openhound_github/models/external_identity.py @@ -126,6 +126,9 @@ class ExternalIdentity(BaseAsset): scim_identity: SCIMIdentity | None = Field(alias="scimIdentity") user: User | None = None + # Additional + org_login: str + @property def node_id(self) -> str: return self.id diff --git a/src/openhound_github/models/org.py b/src/openhound_github/models/org.py index 9ab87e4..8bd9d20 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 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 @@ -295,6 +297,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_team.py b/src/openhound_github/models/org_role_team.py index a01e9f3..a679099 100644 --- a/src/openhound_github/models/org_role_team.py +++ b/src/openhound_github/models/org_role_team.py @@ -37,6 +37,8 @@ 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: diff --git a/src/openhound_github/models/org_secret.py b/src/openhound_github/models/org_secret.py index 2040aea..1d7a240 100644 --- a/src/openhound_github/models/org_secret.py +++ b/src/openhound_github/models/org_secret.py @@ -70,17 +70,8 @@ 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 - - # node_id: str - # name: str - # org_name: str - # org_node_id: str - # visibility: str - # created_at: str | None = None - # updated_at: str | None = None + # Additional + org_login: str @property def node_id(self) -> str: @@ -158,6 +149,7 @@ class SelectedOrgSecret(BaseAsset): name: str repository_node_id: str repository_full_name: str + org_login: str @property def node_id(self) -> str: diff --git a/src/openhound_github/models/org_variable.py b/src/openhound_github/models/org_variable.py index a0372c7..5943a62 100644 --- a/src/openhound_github/models/org_variable.py +++ b/src/openhound_github/models/org_variable.py @@ -66,6 +66,9 @@ class OrgVariable(BaseAsset): updated_at: datetime | None = None visibility: str + # Additional + org_login: str + @property def node_id(self) -> str: org_node_id = self._lookup.org_id() diff --git a/src/openhound_github/models/personal_access_token.py b/src/openhound_github/models/personal_access_token.py index bf78893..35d6066 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 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 @@ -113,6 +115,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,6 +130,9 @@ class PersonalAccessToken(BaseAsset): token_expires_at: datetime | None = None token_last_used_at: datetime | None = None + # Additional + org_login: str + @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.""" diff --git a/src/openhound_github/models/personal_access_token_request.py b/src/openhound_github/models/personal_access_token_request.py index eca4de7..32c6152 100644 --- a/src/openhound_github/models/personal_access_token_request.py +++ b/src/openhound_github/models/personal_access_token_request.py @@ -94,6 +94,9 @@ class PersonalAccessTokenRequest(BaseAsset): repositories_url: str | None = None repository_selection: str | None = None + # Additional + org_login: str + @property def node_id(self) -> str: """Construct a generated node id""" diff --git a/src/openhound_github/models/repo_role_assignment.py b/src/openhound_github/models/repo_role_assignment.py index dffd8b8..e6eba8e 100644 --- a/src/openhound_github/models/repo_role_assignment.py +++ b/src/openhound_github/models/repo_role_assignment.py @@ -56,6 +56,7 @@ 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 diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 5932687..0172095 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -95,9 +95,7 @@ class GHRepositoryProperties(GHNodeProperties): ) self_hosted_runners_enabled: bool | None = field( default=None, - metadata={ - "description": "Whether the repository may use self-hosted runners." - }, + metadata={"description": "Whether the repository may use self-hosted runners."}, ) secret_scanning: str | None = field( default=None, @@ -163,10 +161,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( @@ -216,6 +218,9 @@ class Repository(BaseAsset): actions_enabled: bool | None = None self_hosted_runners_enabled: bool | None = None + # Additional + org_login: str + @property def owner_id(self) -> str: return self.owner.node_id diff --git a/src/openhound_github/models/repository_role.py b/src/openhound_github/models/repository_role.py index a94dc5a..8eaa28a 100644 --- a/src/openhound_github/models/repository_role.py +++ b/src/openhound_github/models/repository_role.py @@ -193,6 +193,9 @@ class BaseRepoRole(BaseModel): organization: Organization | None = None base_role: str + # Additional + org_login: str + @dataclass class GHRepoRoleProperties(GHNodeProperties): @@ -680,6 +683,7 @@ class RepoRole(BaseAsset): base_role: str | None = None # Additional + org_login: str type: str repository_node_id: str repository_name: str diff --git a/src/openhound_github/models/repository_secret.py b/src/openhound_github/models/repository_secret.py index 64c6176..2e32537 100644 --- a/src/openhound_github/models/repository_secret.py +++ b/src/openhound_github/models/repository_secret.py @@ -71,6 +71,7 @@ class RepoSecret(BaseAsset): selected_repositories_url: str | None = None # Additional + org_login: str repository_name: str = "" repository_node_id: str = "" diff --git a/src/openhound_github/models/repository_variable.py b/src/openhound_github/models/repository_variable.py index 8eec51e..f535d57 100644 --- a/src/openhound_github/models/repository_variable.py +++ b/src/openhound_github/models/repository_variable.py @@ -70,6 +70,7 @@ class RepoVariable(BaseAsset): updated_at: datetime | None = None # Additional + org_login: str repository_name: str = "" repository_node_id: str = "" diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 07e8e91..5d1c046 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -85,6 +85,9 @@ class RunnerGroup(BaseAsset): selected_workflows: list[str] | None = None runners_url: str | None = None + # Additional + org_login: str + @property def node_id(self) -> str: return f"{self._lookup.org_id()}_runner_group_{self.id}" @@ -202,6 +205,9 @@ class OrgRunner(BaseAsset): ephemeral: bool | None = None labels: list[dict] = Field(default_factory=list) + # Additional + org_login: str + @property def node_id(self) -> str: return f"{self._lookup.org_id()}_org_runner_{self.id}" @@ -257,6 +263,9 @@ class OrgRunnerGroupMembership(BaseAsset): runner_id: int accessible_repo_node_ids: list[str] = Field(default_factory=list) + # Additional + org_login: str + @property def as_node(self): return None @@ -329,6 +338,9 @@ class RepoRunner(BaseAsset): repository_node_id: str repository_full_name: str + # Additional + org_login: str + @property def node_id(self) -> str: return f"{self.repository_node_id}_repo_runner_{self.id}" diff --git a/src/openhound_github/models/saml_provider.py b/src/openhound_github/models/saml_provider.py index 8bbf983..dd5fc0f 100644 --- a/src/openhound_github/models/saml_provider.py +++ b/src/openhound_github/models/saml_provider.py @@ -78,6 +78,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..beb2a50 100644 --- a/src/openhound_github/models/secret_scanning_alert.py +++ b/src/openhound_github/models/secret_scanning_alert.py @@ -128,6 +128,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 ) diff --git a/src/openhound_github/models/team.py b/src/openhound_github/models/team.py index 40c7187..63e1c5e 100644 --- a/src/openhound_github/models/team.py +++ b/src/openhound_github/models/team.py @@ -91,6 +91,7 @@ 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 @@ -103,7 +104,8 @@ class Team(BaseAsset): # Used for overflow members: Members - dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} + # Additional + org_login: str @property def node_id(self) -> str: diff --git a/src/openhound_github/models/team_member.py b/src/openhound_github/models/team_member.py index 1ee5c23..d008cb6 100644 --- a/src/openhound_github/models/team_member.py +++ b/src/openhound_github/models/team_member.py @@ -25,6 +25,9 @@ class TeamMember(BaseAsset): id: str role: str # "MEMBER" or "MAINTAINER" + # Additional + org_login: str + @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..f35eb16 100644 --- a/src/openhound_github/models/team_role.py +++ b/src/openhound_github/models/team_role.py @@ -63,6 +63,9 @@ class TeamRole(BaseAsset): team_name: str team_slug: str + # Additional + org_login: str + @property def node_id(self): return f"{self.team_node_id}_{self.type}" diff --git a/src/openhound_github/models/user.py b/src/openhound_github/models/user.py index d84283f..241473a 100644 --- a/src/openhound_github/models/user.py +++ b/src/openhound_github/models/user.py @@ -72,6 +72,9 @@ class User(BaseAsset): company: str | None = None org_role: str = Field(alias="role") + # Additional + org_login: str + @property def 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/workflow.py b/src/openhound_github/models/workflow.py index ae29f5e..b8c34d3 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -97,11 +97,9 @@ class Workflow(BaseAsset): # 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 as_node(self) -> GHNode: 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/organization.py b/src/openhound_github/resources/organization.py index 09f6421..84928de 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from typing import Any, Iterator @@ -74,7 +74,7 @@ class SourceContext: """Shared context for GitHub API access.""" client: RESTClient - org_name: str | None = None + org_names: list[str] = field(default_factory=list) enterprise_name: str | None = None @@ -170,34 +170,36 @@ def organizations(ctx: SourceContext): 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 + for org_name in ctx.org_names: + org_data = ctx.client.get(f"/orgs/{org_name}").json() + + actions = ctx.client.get(f"/orgs/{org_name}/actions/permissions").json() + self_hosted_runners = ctx.client.get( + f"/orgs/{org_name}/actions/permissions/self-hosted-runners" + ).json() + workflow_perms = ctx.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.resource(name="org_roles", columns=OrgRole, parallelized=True) -def org_roles(ctx: SourceContext, orgs: list[dict]): + +@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 @@ -210,7 +212,6 @@ def org_roles(ctx: SourceContext, orgs: list[dict]): Yields: OrgRole (OrgRole): Organization role record. """ - org = Organization(**orgs[0]) yield { "id": 1, @@ -235,7 +236,7 @@ def org_roles(ctx: SourceContext, orgs: list[dict]): } for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/organization-roles", params={"per_page": 100} + f"/orgs/{org.login}/organization-roles", params={"per_page": 100} ): for role in page: yield { @@ -262,10 +263,16 @@ def org_role_teams(role: OrgRole, ctx: SourceContext): if role.type == "custom": for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/organization-roles/{role.id}/teams" + 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, **team} + 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) @@ -283,13 +290,15 @@ def org_role_members(role: OrgRole, ctx: SourceContext): """ if role.type == "custom": for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/organization-roles/{role.id}/users" + 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, } @@ -303,9 +312,10 @@ def app_installations(ctx: SourceContext): Yields: AppInstallation (AppInstallation): App installation record. """ - for page in ctx.client.paginate(f"/orgs/{ctx.org_name}/installations"): - for item in page: - yield item + for org_name in ctx.org_names: + for page in ctx.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) @@ -323,7 +333,7 @@ def applications(app_install: AppInstallation, ctx: SourceContext): 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} + yield {**app_data, "slug": app_slug, "org_login": app_install.org_login} @app.resource(name="users", columns=User, parallelized=True) @@ -339,27 +349,29 @@ def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: 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} + for org_name in ctx.org_names: + 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 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, "org_login": org_name} @app.resource(name="teams", columns=Team, parallelized=True) @@ -380,27 +392,28 @@ def teams(ctx: SourceContext): 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 org_name in ctx.org_names: + 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 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 + 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, "org_login": org_name} @app.transformer(name="team_roles", columns=TeamRole, parallelized=True) @@ -419,6 +432,7 @@ def team_roles(team: Team): "team_node_id": team.node_id, "team_name": team.name, "team_slug": team.slug, + "org_login": team.org_login, } @@ -442,6 +456,7 @@ def team_members(team: Team, ctx: SourceContext): "id": member.node.id, "login": member.node.login, "role": member.role, + "org_login": team.org_login, } paginator = GraphQLCursorPaginator( @@ -474,13 +489,18 @@ def team_members(team: Team, ctx: SourceContext): "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): - actions = ctx.client.get(f"/orgs/{ctx.org_name}/actions/permissions").json() - yield actions + for org_name in ctx.org_names: + actions = ctx.client.get(f"/orgs/{org_name}/actions/permissions").json() + yield { + **actions, + "org_login": org_name, + } @app.resource(name="repositories", columns=Repository, parallelized=True) @@ -493,54 +513,56 @@ def repositories(ctx: SourceContext): 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") - ) + for org_name in ctx.org_names: + actions = ctx.client.get(f"/orgs/{org_name}/actions/permissions").json() + runner_settings = ctx.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 ctx.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 ctx.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") + ) - 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", + f"/orgs/{org_name}/repos", params={"per_page": 100} ): - 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, - } + 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( @@ -572,7 +594,7 @@ def repo_role_assignments( 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", + f"/repos/{repo.org_login}/{repo_name}/collaborators", params={"affiliation": "direct", "per_page": 100}, ): for collaborator in collab_page: @@ -580,6 +602,7 @@ def repo_role_assignments( 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, @@ -590,7 +613,7 @@ def repo_role_assignments( # Team access for team_page in ctx.client.paginate( - f"/repos/{ctx.org_name}/{repo_name}/teams", + f"/repos/{repo.org_login}/{repo_name}/teams", params={"per_page": 100}, ): for team in team_page: @@ -601,6 +624,7 @@ def repo_role_assignments( **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, @@ -618,10 +642,15 @@ def repository_roles_base(ctx: SourceContext): 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 + for org_name in ctx.org_names: + for page in ctx.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) @@ -649,6 +678,7 @@ def repository_roles(repository: Repository, roles: list[dict]): "repository_name": repository.name, "repository_node_id": repository.node_id, "repository_visibility": repository.visibility, + "org_login": repository.org_login, } for role in roles: @@ -663,6 +693,7 @@ def repository_roles(repository: Repository, roles: list[dict]): "repository_name": repository.name, "repository_node_id": repository.node_id, "repository_visibility": repository.visibility, + "org_login": repository.org_login, } @@ -680,27 +711,28 @@ def repositories_graphql(ctx: SourceContext): 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 org_name in ctx.org_names: + 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 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 + 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, "org_login": org_name} @app.transformer(name="branches", columns=Branch, parallelized=True) @@ -729,6 +761,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): **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: @@ -754,6 +787,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): **branch, "repository_node_id": repository.id, "repository_name": repository.name, + "org_login": repository.org_login, } @@ -794,6 +828,7 @@ def branch_protection_rules(repository: RepositoryQL, ctx: SourceContext): for rule in response["data"].get("nodes", []): yield { **rule, + "org_login": repository.org_login, "repository_node_id": repository.id, "repository_name": repository.name, } @@ -820,6 +855,7 @@ def workflows(repo: Repository, ctx: SourceContext): **workflow, "repository_name": repo.name, "repository_node_id": repo.node_id, + "org_login": repo.org_login, } @@ -846,6 +882,7 @@ def environments(repo: Repository, ctx: SourceContext): 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, @@ -854,24 +891,32 @@ def environments(repo: Repository, ctx: SourceContext): @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 + for org_name in ctx.org_names: + for page in ctx.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 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 + for org_name in ctx.org_names: + for page in ctx.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( @@ -880,27 +925,31 @@ def org_runners(ctx: SourceContext): 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 + for org_name in ctx.org_names: + for group_page in ctx.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, ctx, repos + ) + try: + for runner_page in ctx.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) @@ -918,6 +967,7 @@ def repo_runners(repo: Repository, ctx: SourceContext): "repository_name": repo.name, "repository_node_id": repo.node_id, "repository_full_name": repo.full_name, + "org_login": repo.org_login, } @@ -947,6 +997,7 @@ def environment_variables(environment: Environment, ctx: SourceContext): 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, @@ -988,6 +1039,7 @@ def environment_branch_policies(environment: Environment, ctx: SourceContext): "environment_name": env_name, "repository_name": repo_name, "repository_node_id": repo_node_id, + "org_login": environment.org_login, } @@ -1016,6 +1068,7 @@ def environment_secrets(environment: Environment, ctx: SourceContext): 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, @@ -1049,11 +1102,15 @@ def organization_secrets(ctx: SourceContext): 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 + for org_name in ctx.org_names: + for page in ctx.client.paginate( + f"/orgs/{org_name}/actions/secrets", params={"per_page": 100} + ): + for item in page: + yield { + **item, + "org_login": org_name, + } @app.transformer( @@ -1073,7 +1130,7 @@ def selected_organization_secrets(secret: OrgSecret, ctx: SourceContext): """ if secret.visibility == "selected": for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/secrets/{secret.name}/repositories", + f"/orgs/{secret.org_login}/actions/secrets/{secret.name}/repositories", params={"per_page": 100}, ): for repo in page: @@ -1081,6 +1138,7 @@ def selected_organization_secrets(secret: OrgSecret, ctx: SourceContext): "name": secret.name, "repository_full_name": repo["full_name"], "repository_node_id": repo["node_id"], + "org_login": secret.org_login, } @@ -1094,11 +1152,15 @@ def organization_variables(ctx: SourceContext): 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 + for org_name in ctx.org_names: + for page in ctx.client.paginate( + f"/orgs/{org_name}/actions/variables", params={"per_page": 100} + ): + for item in page: + yield { + **item, + "org_login": org_name, + } @app.transformer( @@ -1120,13 +1182,14 @@ def selected_organization_variables(variable: OrgVariable, ctx: SourceContext): """ if variable.visibility == "selected": for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/actions/variables/{variable.name}/repositories", + 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, } @@ -1147,6 +1210,7 @@ def repository_secrets(repo: Repository, ctx: SourceContext): for secret in page: yield { **secret, + "org_login": repo.org_login, "repository_name": repo.full_name, "repository_node_id": repo.node_id, } @@ -1172,6 +1236,7 @@ def repository_variables(repo: Repository, ctx: SourceContext): for variable in page: yield { **variable, + "org_login": repo.org_login, "repository_name": repo.full_name, "repository_node_id": repo.node_id, } @@ -1195,29 +1260,34 @@ def secret_scanning_alerts(ctx: SourceContext): 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 + for org_name in ctx.org_names: + for page in ctx.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} + yield { + **alert, + "valid_token_user_node_id": valid_token_user_node_id, + "org_login": org_name, + } @app.resource( @@ -1237,11 +1307,15 @@ def personal_access_tokens(ctx: SourceContext): 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 + for org_name in ctx.org_names: + for page in ctx.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) @@ -1256,11 +1330,11 @@ def pat_repo_access(pat: PersonalAccessToken, ctx: SourceContext): PatRepoAccess (PatRepoAccess): PAT-to-repository access record. """ for page in ctx.client.paginate( - f"/orgs/{ctx.org_name}/personal-access-tokens/{pat['id']}/repositories", + 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} + yield {"pat_id": pat.id, **item, "org_login": pat.org_login} @app.resource( @@ -1280,11 +1354,17 @@ def personal_access_token_requests(ctx: SourceContext): 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 + + for org_name in ctx.org_names: + for page in ctx.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) @@ -1300,24 +1380,26 @@ def saml_provider(ctx: SourceContext): Yields: SamlProvider (SamlProvider): SAML provider record. """ - data = { - "query": SAML_QUERY, - "variables": {"login": ctx.org_name, "count": 100, "after": None}, - } + for org_name in ctx.org_names: + data = { + "query": SAML_QUERY, + "variables": {"login": org_name, "count": 100, "after": None}, + } - response = ctx.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: - return + response = ctx.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: + return - yield { - **idp, - "org_node_id": org_data["id"], - "org_name": org_data["name"], - } + 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) @@ -1331,31 +1413,34 @@ def external_identities(ctx: SourceContext): 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 org_name in ctx.org_names: + 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": org_name, "count": 100, "after": None}, + } - for page_data in ctx.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: - return - for identity in (idp.get("externalIdentities") or {}).get("nodes") or []: - yield identity + for page_data in ctx.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: + return + 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) @@ -1368,28 +1453,34 @@ def scim_users(ctx: SourceContext): 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 + for org_name in ctx.org_names: + scim_paginator = OffsetPaginator( + offset_param="startIndex", + limit_param="itemsPerPage", + limit=100, + total_path="totalResults", + ) + for page in ctx.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) 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)) + + org_role_resource = org_roles(ctx) teams_resource = teams(ctx) repositories_graphql_resource = repositories_graphql(ctx) app_installs_resource = app_installations(ctx) @@ -1402,10 +1493,13 @@ def organization_resources(ctx: SourceContext): 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), - org_role_resource, - org_role_resource | org_role_members(ctx), - org_role_resource | org_role_teams(ctx), + actions_permissions(ctx), repos_resource, repos_resource | repository_roles(repo_roles_base), repos_resource | workflows(ctx), @@ -1437,6 +1531,4 @@ def organization_resources(ctx: SourceContext): secret_scanning_alerts(ctx), saml_provider(ctx), external_identities(ctx), - app_installs_resource, - app_installs_resource | applications(ctx), ) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 049d088..04d07f2 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 typing import Any, Optional, Union import dlt from dlt.common.configuration import configspec @@ -10,66 +9,10 @@ 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 @@ -80,47 +23,10 @@ class SourceContext: """Shared context for GitHub API access.""" client: RESTClient - org_name: str | None = None + org_names: list[str] | None = None 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 - - repo_node_ids = [] - for repo in repos: - 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] - - @configspec class GithubCredentials(CredentialsConfiguration): org_name: str | None = None @@ -205,9 +111,15 @@ def retry_policy( paginator=HeaderLinkPaginator(), session=requests.Client(retry_condition=retry_policy).session, ), - org_name=credentials.org_name, - enterprise_name=credentials.enterprise_name, ) if credentials.org_name: + ctx.org_names = [credentials.org_name] return organization_resources(ctx) + + elif credentials.enterprise_name: + ctx.enterprise_name = credentials.enterprise_name + return enterprise_resources(ctx) + + else: + raise ValueError("Must specify either enterprise_name or org_name") From d588d948b6eff205e99c1eb17545928138991a9b Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 16:59:55 +0200 Subject: [PATCH 12/42] Refer to org_name instead of ctx.org_name --- src/openhound_github/resources/organization.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 84928de..5bee00e 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -470,7 +470,7 @@ def team_members(team: Team, ctx: SourceContext): data = { "query": TEAM_MEMBERS_OVERFLOW_QUERY, "variables": { - "login": ctx.org_name, + "login": team.org_login, "count": 100, "after": team.members.page_info.end_cursor, "slug": team.slug, @@ -768,7 +768,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): data = { "query": REF_OVERFLOW_QUERY, "variables": { - "owner": ctx.org_name, + "owner": repository.org_login, "count": 100, "after": repository.refs.page_info.end_cursor, "name": repository.name, From 6333a6c0847489e963c59a159bb0deedf6ec1544 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 18:15:26 +0200 Subject: [PATCH 13/42] Add org-specific lookup logic --- src/openhound_github/lookup.py | 47 +++++++++++++++++-- .../models/app_installation.py | 22 ++++++--- src/openhound_github/models/branch.py | 8 +++- .../models/branch_protection_rule.py | 8 +++- src/openhound_github/models/env_secret.py | 8 +++- src/openhound_github/models/env_variable.py | 8 +++- src/openhound_github/models/environment.py | 8 +++- .../models/environment_branch_policy.py | 5 ++ .../models/external_identity.py | 12 +++-- src/openhound_github/models/org_role.py | 4 +- .../models/org_role_member.py | 5 +- src/openhound_github/models/org_role_team.py | 3 +- src/openhound_github/models/org_secret.py | 26 ++++++---- src/openhound_github/models/org_variable.py | 19 +++++--- .../models/personal_access_token.py | 15 +++--- .../models/personal_access_token_access.py | 8 +++- .../models/personal_access_token_request.py | 13 +++-- .../models/repo_role_assignment.py | 4 ++ src/openhound_github/models/repository.py | 9 ++-- .../models/repository_role.py | 6 +-- .../models/repository_secret.py | 8 +++- .../models/repository_variable.py | 8 +++- src/openhound_github/models/runner.py | 40 +++++++++++----- .../models/secret_scanning_alert.py | 4 +- src/openhound_github/models/team.py | 8 +++- src/openhound_github/models/team_member.py | 4 ++ src/openhound_github/models/team_role.py | 10 ++-- src/openhound_github/models/user.py | 8 +++- src/openhound_github/models/workflow.py | 8 +++- .../resources/organization.py | 23 ++++++--- 30 files changed, 263 insertions(+), 96 deletions(-) diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index caae50b..4a741d8 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,39 @@ def org_login(self) -> str | None: ) 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 +71,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 diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index fc33a48..fdcdac2 100644 --- a/src/openhound_github/models/app_installation.py +++ b/src/openhound_github/models/app_installation.py @@ -152,6 +152,10 @@ class AppInstallation(BaseAsset): 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) @@ -177,8 +181,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", ), @@ -187,7 +191,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, @@ -199,7 +203,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"), @@ -211,7 +217,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), ) @@ -318,6 +324,10 @@ class App(BaseAsset): # 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): aid = self.node_id @@ -327,7 +337,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, diff --git a/src/openhound_github/models/branch.py b/src/openhound_github/models/branch.py index 58c6dac..9472980 100644 --- a/src/openhound_github/models/branch.py +++ b/src/openhound_github/models/branch.py @@ -79,6 +79,10 @@ class Branch(BaseAsset): # 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""" @@ -97,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 09b4009..24cc859 100644 --- a/src/openhound_github/models/branch_protection_rule.py +++ b/src/openhound_github/models/branch_protection_rule.py @@ -177,6 +177,10 @@ class BranchProtectionRule(BaseAsset): 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 @@ -192,8 +196,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/env_secret.py b/src/openhound_github/models/env_secret.py index ca10c31..c7aafad 100644 --- a/src/openhound_github/models/env_secret.py +++ b/src/openhound_github/models/env_secret.py @@ -71,6 +71,10 @@ class EnvironmentSecret(BaseAsset): 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}" @@ -88,8 +92,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 1e6c706..1e25307 100644 --- a/src/openhound_github/models/env_variable.py +++ b/src/openhound_github/models/env_variable.py @@ -66,6 +66,10 @@ class EnvironmentVariable(BaseAsset): 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}" @@ -80,9 +84,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 ec0fa50..11d2ca4 100644 --- a/src/openhound_github/models/environment.py +++ b/src/openhound_github/models/environment.py @@ -142,6 +142,10 @@ class Environment(BaseAsset): 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: @@ -161,8 +165,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 ef2c027..d77ca37 100644 --- a/src/openhound_github/models/external_identity.py +++ b/src/openhound_github/models/external_identity.py @@ -129,6 +129,10 @@ class ExternalIdentity(BaseAsset): # 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 @@ -166,8 +170,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", ), ) @@ -190,7 +194,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_role.py b/src/openhound_github/models/org_role.py index c99fbc5..4501462 100644 --- a/src/openhound_github/models/org_role.py +++ b/src/openhound_github/models/org_role.py @@ -176,8 +176,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 a679099..08f4165 100644 --- a/src/openhound_github/models/org_role_team.py +++ b/src/openhound_github/models/org_role_team.py @@ -42,8 +42,7 @@ class OrgRoleTeam(BaseAsset): @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 1d7a240..522abcf 100644 --- a/src/openhound_github/models/org_secret.py +++ b/src/openhound_github/models/org_secret.py @@ -73,10 +73,13 @@ class OrgSecret(BaseAsset): # 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_OrgSecret_{org_node_id}_{self.name}" + return f"GH_OrgSecret_{self.org_node_id}_{self.name}" @property def as_node(self) -> GHNode: @@ -88,8 +91,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", @@ -99,7 +102,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, @@ -111,7 +114,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, @@ -124,7 +127,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), ) @@ -151,10 +154,13 @@ class SelectedOrgSecret(BaseAsset): 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: @@ -164,7 +170,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 5943a62..226d01d 100644 --- a/src/openhound_github/models/org_variable.py +++ b/src/openhound_github/models/org_variable.py @@ -69,10 +69,13 @@ class OrgVariable(BaseAsset): # 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: @@ -84,8 +87,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, @@ -122,11 +125,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 35d6066..7151015 100644 --- a/src/openhound_github/models/personal_access_token.py +++ b/src/openhound_github/models/personal_access_token.py @@ -133,11 +133,14 @@ class PersonalAccessToken(BaseAsset): # 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: @@ -152,8 +155,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, @@ -177,14 +180,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 32c6152..31bdbe1 100644 --- a/src/openhound_github/models/personal_access_token_request.py +++ b/src/openhound_github/models/personal_access_token_request.py @@ -97,11 +97,14 @@ class PersonalAccessTokenRequest(BaseAsset): # 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: @@ -112,12 +115,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", @@ -138,7 +141,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 e6eba8e..bb72a6e 100644 --- a/src/openhound_github/models/repo_role_assignment.py +++ b/src/openhound_github/models/repo_role_assignment.py @@ -63,6 +63,10 @@ class RepoRoleAssignment(BaseAsset): 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 0172095..8aa0a66 100644 --- a/src/openhound_github/models/repository.py +++ b/src/openhound_github/models/repository.py @@ -170,7 +170,6 @@ class RepositoryQL(BaseModel): # Additional org_login: str - @app.asset( node=NodeDef( kind=nk.REPOSITORY, @@ -221,6 +220,10 @@ class Repository(BaseAsset): # 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 @@ -257,8 +260,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 8eaa28a..aa7a09c 100644 --- a/src/openhound_github/models/repository_role.py +++ b/src/openhound_github/models/repository_role.py @@ -697,7 +697,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: @@ -712,8 +712,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 2e32537..33bab42 100644 --- a/src/openhound_github/models/repository_secret.py +++ b/src/openhound_github/models/repository_secret.py @@ -75,6 +75,10 @@ class RepoSecret(BaseAsset): 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_Secret_{self.repository_node_id}_{self.name}" @@ -90,8 +94,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 f535d57..eebfb16 100644 --- a/src/openhound_github/models/repository_variable.py +++ b/src/openhound_github/models/repository_variable.py @@ -74,6 +74,10 @@ class RepoVariable(BaseAsset): 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: """Synthesize a unique node_id for the variable based on repo and variable name.""" @@ -90,8 +94,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 5d1c046..787a2c8 100644 --- a/src/openhound_github/models/runner.py +++ b/src/openhound_github/models/runner.py @@ -88,9 +88,13 @@ class RunnerGroup(BaseAsset): # 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: @@ -98,7 +102,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, @@ -110,8 +114,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", ), @@ -121,7 +125,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), ) @@ -208,9 +212,13 @@ class OrgRunner(BaseAsset): # 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: @@ -228,8 +236,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", ), @@ -266,20 +274,24 @@ class OrgRunnerGroupMembership(BaseAsset): # 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"), @@ -341,6 +353,10 @@ class RepoRunner(BaseAsset): # 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}" @@ -364,8 +380,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/secret_scanning_alert.py b/src/openhound_github/models/secret_scanning_alert.py index beb2a50..c906d31 100644 --- a/src/openhound_github/models/secret_scanning_alert.py +++ b/src/openhound_github/models/secret_scanning_alert.py @@ -143,7 +143,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: @@ -154,7 +154,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 63e1c5e..7b71106 100644 --- a/src/openhound_github/models/team.py +++ b/src/openhound_github/models/team.py @@ -107,6 +107,10 @@ class Team(BaseAsset): # 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""" @@ -124,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 d008cb6..19d7d78 100644 --- a/src/openhound_github/models/team_member.py +++ b/src/openhound_github/models/team_member.py @@ -28,6 +28,10 @@ class TeamMember(BaseAsset): # 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 f35eb16..1f7aab3 100644 --- a/src/openhound_github/models/team_role.py +++ b/src/openhound_github/models/team_role.py @@ -66,6 +66,10 @@ class TeamRole(BaseAsset): # 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}" @@ -76,15 +80,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 241473a..8aeba0d 100644 --- a/src/openhound_github/models/user.py +++ b/src/openhound_github/models/user.py @@ -75,6 +75,10 @@ class User(BaseAsset): # 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""" @@ -82,11 +86,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 b8c34d3..fc0e708 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -101,6 +101,10 @@ class Workflow(BaseAsset): 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 as_node(self) -> GHNode: wid = self.node_id @@ -120,8 +124,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/resources/organization.py b/src/openhound_github/resources/organization.py index 5bee00e..b686959 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -79,14 +79,14 @@ class SourceContext: def _runner_group_repo_node_ids( - group: dict[str, Any], ctx: SourceContext, repos: list + group: dict[str, Any], ctx: SourceContext, repos: list, org_login: str ) -> 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", + f"/orgs/{org_login}/actions/runner-groups/{group['id']}/repositories", params={"per_page": 100}, data_selector="repositories", ): @@ -99,6 +99,11 @@ def _runner_group_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") ) @@ -591,7 +596,11 @@ def repo_role_assignments( repo_node_id = repo.node_id repo_name = repo.name - custom_roles = {role["name"]: BaseRepoRole(**role) for role in roles} + custom_roles = { + role["name"]: BaseRepoRole(**role) + for role in roles + if role.get("org_login") == repo.org_login + } for collab_page in ctx.client.paginate( f"/repos/{repo.org_login}/{repo_name}/collaborators", @@ -682,6 +691,8 @@ def repository_roles(repository: Repository, roles: list[dict]): } for role in roles: + if role.get("org_login") != repository.org_login: + continue role = BaseRepoRole(**role) yield { "id": role.id, @@ -933,7 +944,7 @@ def org_runner_group_memberships(ctx: SourceContext, repos: list): ): for group in group_page: accessible_repo_node_ids = _runner_group_repo_node_ids( - group, ctx, repos + group, ctx, repos, org_name ) try: for runner_page in ctx.client.paginate( @@ -1392,7 +1403,7 @@ def saml_provider(ctx: SourceContext): if response_data and org_data: idp = org_data.get("samlIdentityProvider") if not idp: - return + continue yield { **idp, @@ -1436,7 +1447,7 @@ def external_identities(ctx: SourceContext): org_data = org.get("organization") idp = org_data.get("samlIdentityProvider") if not idp: - return + continue for identity in (idp.get("externalIdentities") or {}).get( "nodes" ) or []: From e65ef57e843cf6d72020c7b15b8e1daa74946a06 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 19:02:36 +0200 Subject: [PATCH 14/42] Updated org collection to use new per-org clients --- .../resources/organization.py | 260 ++++++++++-------- src/openhound_github/source.py | 73 ++++- 2 files changed, 201 insertions(+), 132 deletions(-) diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index b686959..050a921 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -2,15 +2,12 @@ from datetime import datetime from typing import Any, Iterator -from dlt.common.configuration import configspec -from dlt.common.configuration.specs import CredentialsConfiguration 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.auth import create_github_jwt_session from openhound_github.graphql import ( MEMBERS_WITH_ROLE_QUERY, PROTECTION_RULES_QUERY, @@ -70,22 +67,34 @@ @dataclass -class SourceContext: - """Shared context for GitHub API access.""" +class OrgContext: + client: RESTClient + org_name: str + enterprise_name: str | None = None + +@dataclass +class SourceContext: client: RESTClient - org_names: list[str] = field(default_factory=list) + 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], ctx: SourceContext, repos: list, org_login: str + 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 ctx.client.paginate( + for page in client.paginate( f"/orgs/{org_login}/actions/runner-groups/{group['id']}/repositories", params={"per_page": 100}, data_selector="repositories", @@ -120,47 +129,6 @@ def _runner_group_repo_node_ids( return [node_id for node_id in repo_node_ids if node_id] -@configspec -class GithubCredentials(CredentialsConfiguration): - org_name: str | None = None - enterprise_name: str | None = None - - def auth(self): - pass - - -@configspec -class GithubAppCredentials(GithubCredentials): - client_id: str = None - app_id: str = None - key_path: str = None - - def auth(self) -> str: - return "app" - - @property - def header(self) -> str: - github_access_token = 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 - - -@configspec -class GithubTokenCredentials(GithubCredentials): - token: str = None - - def auth(self) -> str: - return "token" - - @property - 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. @@ -176,14 +144,16 @@ def organizations(ctx: SourceContext): Organization (Organization): Organization, org role, member, and team records. """ - for org_name in ctx.org_names: - org_data = ctx.client.get(f"/orgs/{org_name}").json() + for org in ctx.organizations: + org_name = org.org_name + client = org.client + org_data = client.get(f"/orgs/{org_name}").json() - actions = ctx.client.get(f"/orgs/{org_name}/actions/permissions").json() - self_hosted_runners = ctx.client.get( + 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 = ctx.client.get( + workflow_perms = client.get( f"/orgs/{org_name}/actions/permissions/workflow" ).json() @@ -240,7 +210,8 @@ def org_roles(org: Organization, ctx: SourceContext): "org_login": org.login, } - for page in ctx.client.paginate( + 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: @@ -267,7 +238,8 @@ def org_role_teams(role: OrgRole, ctx: SourceContext): """ if role.type == "custom": - for page in ctx.client.paginate( + 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: @@ -294,7 +266,8 @@ def org_role_members(role: OrgRole, ctx: SourceContext): OrgRoleMember (OrgRoleMember): User-to-org-role assignment record. """ if role.type == "custom": - for page in ctx.client.paginate( + 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: @@ -317,8 +290,10 @@ def app_installations(ctx: SourceContext): Yields: AppInstallation (AppInstallation): App installation record. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate(f"/orgs/{org_name}/installations"): + 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} @@ -336,7 +311,8 @@ def applications(app_install: AppInstallation, ctx: SourceContext): """ 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() + 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} @@ -355,7 +331,9 @@ def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: User (User): User record. """ - for org_name in ctx.org_names: + 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", @@ -367,7 +345,7 @@ def users(ctx: SourceContext) -> Iterator[dict[str, Any]]: "variables": {"login": org_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( + for page_data in client.paginate( "/graphql", method="POST", json=data, @@ -397,7 +375,9 @@ def teams(ctx: SourceContext): Team (Team): Team record lol!. """ - for org_name in ctx.org_names: + 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", @@ -409,7 +389,7 @@ def teams(ctx: SourceContext): "variables": {"login": org_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( + for page_data in client.paginate( "/graphql", method="POST", json=data, @@ -472,6 +452,7 @@ def team_members(team: Team, ctx: SourceContext): ) if team.members.page_info.has_next_page: + client = _client_for_org(ctx, team.org_login) data = { "query": TEAM_MEMBERS_OVERFLOW_QUERY, "variables": { @@ -481,7 +462,7 @@ def team_members(team: Team, ctx: SourceContext): "slug": team.slug, }, } - for page_data in ctx.client.paginate( + for page_data in client.paginate( "/graphql", method="POST", json=data, @@ -500,8 +481,10 @@ def team_members(team: Team, ctx: SourceContext): @app.resource(name="actions_permissions", columns=ActionPermission, parallelized=True) def actions_permissions(ctx): - for org_name in ctx.org_names: - actions = ctx.client.get(f"/orgs/{org_name}/actions/permissions").json() + 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, @@ -518,16 +501,18 @@ def repositories(ctx: SourceContext): Yields: Repository (Repository): Repository record. """ - for org_name in ctx.org_names: - actions = ctx.client.get(f"/orgs/{org_name}/actions/permissions").json() - runner_settings = ctx.client.get( + 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 ctx.client.paginate( + for page in client.paginate( f"/orgs/{org_name}/actions/permissions/repositories", params={"per_page": 100}, data_selector="repositories", @@ -539,7 +524,7 @@ def repositories(ctx: SourceContext): 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( + for page in client.paginate( f"/orgs/{org_name}/actions/permissions/self-hosted-runners/repositories", params={"per_page": 100}, data_selector="repositories", @@ -548,7 +533,7 @@ def repositories(ctx: SourceContext): repo["node_id"] for repo in page if repo.get("node_id") ) - for page in ctx.client.paginate( + for page in client.paginate( f"/orgs/{org_name}/repos", params={"per_page": 100} ): for repo in page: @@ -602,7 +587,8 @@ def repo_role_assignments( if role.get("org_login") == repo.org_login } - for collab_page in ctx.client.paginate( + 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}, ): @@ -621,7 +607,7 @@ def repo_role_assignments( } # Team access - for team_page in ctx.client.paginate( + for team_page in client.paginate( f"/repos/{repo.org_login}/{repo_name}/teams", params={"per_page": 100}, ): @@ -651,8 +637,10 @@ def repository_roles_base(ctx: SourceContext): Yields: list[dict] (list[dict]): Pages of raw custom repository role records from the GitHub API. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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: @@ -722,7 +710,9 @@ def repositories_graphql(ctx: SourceContext): Yields: RepositoryQL (RepositoryQL): Repository record with nested branch ref data. """ - for org_name in ctx.org_names: + 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", @@ -734,7 +724,7 @@ def repositories_graphql(ctx: SourceContext): "variables": {"login": org_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( + for page_data in client.paginate( "/graphql", method="POST", json=data, @@ -776,6 +766,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): } if repository.refs.page_info.has_next_page: + client = _client_for_org(ctx, repository.org_login) data = { "query": REF_OVERFLOW_QUERY, "variables": { @@ -786,7 +777,7 @@ def branches(repository: RepositoryQL, ctx: SourceContext): }, } - for page_data in ctx.client.paginate( + for page_data in client.paginate( "/graphql", method="POST", json=data, @@ -829,13 +820,12 @@ def branch_protection_rules(repository: RepositoryQL, ctx: SourceContext): 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 = ctx.client.post( - f"{ctx.client.base_url}/graphql", json=data - ).json() + response = client.post("/graphql", json=data).json() for rule in response["data"].get("nodes", []): yield { **rule, @@ -856,7 +846,8 @@ def workflows(repo: Repository, ctx: SourceContext): Yields: Workflow (Workflow): An active workflow record. """ - for page in ctx.client.paginate( + 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: @@ -885,7 +876,8 @@ def environments(repo: Repository, ctx: SourceContext): full_name = repo.full_name repo_name = repo.name repo_node_id = repo.node_id - for page in ctx.client.paginate( + 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", @@ -902,8 +894,10 @@ def environments(repo: Repository, ctx: SourceContext): @app.resource(name="runner_groups", columns=RunnerGroup, parallelized=True) def runner_groups(ctx: SourceContext): - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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", @@ -917,8 +911,10 @@ def runner_groups(ctx: SourceContext): @app.resource(name="org_runners", columns=OrgRunner, parallelized=True) def org_runners(ctx: SourceContext): - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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", @@ -936,18 +932,20 @@ def org_runners(ctx: SourceContext): parallelized=True, ) def org_runner_group_memberships(ctx: SourceContext, repos: list): - for org_name in ctx.org_names: - for group_page in ctx.client.paginate( + 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, ctx, repos, org_name + group, client, repos, org_name ) try: - for runner_page in ctx.client.paginate( + for runner_page in client.paginate( f"/orgs/{org_name}/actions/runner-groups/{group['id']}/runners", params={"per_page": 100}, data_selector="runners", @@ -967,7 +965,8 @@ def org_runner_group_memberships(ctx: SourceContext, repos: list): def repo_runners(repo: Repository, ctx: SourceContext): if not repo.self_hosted_runners_enabled: return - for page in ctx.client.paginate( + 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", @@ -1002,7 +1001,8 @@ def environment_variables(environment: Environment, ctx: SourceContext): repo_name = environment.repository_name repo_node_id = environment.repository_node_id - for page in ctx.client.paginate( + 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: @@ -1040,7 +1040,8 @@ def environment_branch_policies(environment: Environment, ctx: SourceContext): repo_node_id = environment.repository_node_id env_name = environment.name env_node_id = environment.node_id - for page in ctx.client.paginate( + 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: @@ -1073,7 +1074,8 @@ def environment_secrets(environment: Environment, ctx: SourceContext): env_name = environment.name env_node_id = environment.node_id - for page in ctx.client.paginate( + 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: @@ -1113,8 +1115,10 @@ def organization_secrets(ctx: SourceContext): OrgSecret (OrgSecret): Organization secret record. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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: @@ -1140,7 +1144,8 @@ def selected_organization_secrets(secret: OrgSecret, ctx: SourceContext): SelectedOrgSecret (SelectedOrgSecret): Secret-to-repository access record. """ if secret.visibility == "selected": - for page in ctx.client.paginate( + 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}, ): @@ -1163,8 +1168,10 @@ def organization_variables(ctx: SourceContext): Yields: OrgVariable (OrgVariable): Organization variable record. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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: @@ -1192,7 +1199,8 @@ def selected_organization_variables(variable: OrgVariable, ctx: SourceContext): SelectedOrgVariable (SelectedOrgVariable): Variable-to-repository access record. """ if variable.visibility == "selected": - for page in ctx.client.paginate( + 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}, ): @@ -1215,7 +1223,8 @@ def repository_secrets(repo: Repository, ctx: SourceContext): Yields: RepoSecret (RepoSecret): Repository secret record. """ - for page in ctx.client.paginate( + 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: @@ -1241,7 +1250,8 @@ def repository_variables(repo: Repository, ctx: SourceContext): Yields: RepoVariable (RepoVariable): Repository variable record. """ - for page in ctx.client.paginate( + 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: @@ -1271,8 +1281,10 @@ def secret_scanning_alerts(ctx: SourceContext): SecretScanningAlert (SecretScanningAlert): A secret scanning alert. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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: @@ -1318,8 +1330,10 @@ def personal_access_tokens(ctx: SourceContext): PersonalAccessToken (PersonalAccessToken): Fine-grained personal access token record. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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: @@ -1340,7 +1354,8 @@ def pat_repo_access(pat: PersonalAccessToken, ctx: SourceContext): Yields: PatRepoAccess (PatRepoAccess): PAT-to-repository access record. """ - for page in ctx.client.paginate( + 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}, ): @@ -1366,8 +1381,10 @@ def personal_access_token_requests(ctx: SourceContext): PersonalAccessTokenRequest (PersonalAccessTokenRequest): PAT request record. """ - for org_name in ctx.org_names: - for page in ctx.client.paginate( + 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}, ): @@ -1391,13 +1408,15 @@ def saml_provider(ctx: SourceContext): Yields: SamlProvider (SamlProvider): SAML provider record. """ - for org_name in ctx.org_names: + 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 = ctx.client.post("/graphql", json=data).json() + response = client.post("/graphql", json=data).json() response_data = response.get("data", {}) org_data = response_data.get("organization", {}) if response_data and org_data: @@ -1424,7 +1443,9 @@ def external_identities(ctx: SourceContext): Yields: ExternalIdentity (ExternalIdentity): External identity record. """ - for org_name in ctx.org_names: + 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", @@ -1436,7 +1457,7 @@ def external_identities(ctx: SourceContext): "variables": {"login": org_name, "count": 100, "after": None}, } - for page_data in ctx.client.paginate( + for page_data in client.paginate( "/graphql", method="POST", json=data, @@ -1464,14 +1485,16 @@ def scim_users(ctx: SourceContext): Yields: ScimResource (ScimResource): SCIM user record. """ - for org_name in ctx.org_names: + 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 ctx.client.paginate( + for page in client.paginate( f"/scim/v2/organizations/{org_name}/Users", params={"startIndex": 1, "itemsPerPage": 100}, paginator=scim_paginator, @@ -1491,7 +1514,6 @@ def organization_resources(ctx: SourceContext): environments_resource = repos_resource | environments(ctx) personal_access_tokens_resource = personal_access_tokens(ctx) - org_role_resource = org_roles(ctx) teams_resource = teams(ctx) repositories_graphql_resource = repositories_graphql(ctx) app_installs_resource = app_installations(ctx) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 04d07f2..3ccaf21 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -1,9 +1,10 @@ -from dataclasses import dataclass -from typing import Any, Optional, Union +from dataclasses import dataclass, field +from typing import Optional, Union import dlt from dlt.common.configuration import configspec from dlt.common.configuration.specs import CredentialsConfiguration +from dlt.sources import credentials from dlt.sources.helpers import requests from dlt.sources.helpers.rest_client.auth import BearerTokenAuth from dlt.sources.helpers.rest_client.client import RESTClient @@ -19,13 +20,22 @@ @dataclass -class SourceContext: - """Shared context for GitHub API access.""" +class OrgContext: + client: RESTClient + org_name: str + enterprise_name: str | None = None + +@dataclass +class SourceContext: client: RESTClient - org_names: list[str] | None = None + organizations: list[OrgContext] | None = field(default_factory=list) enterprise_name: str | None = None + @property + def org_names(self) -> list[str]: + return [org.org_name for org in self.organizations or []] + @configspec class GithubCredentials(CredentialsConfiguration): @@ -41,19 +51,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 @@ -113,13 +125,48 @@ def retry_policy( ), ) + 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, + ) + + # This will run when a single org_name is specified if credentials.org_name: - ctx.org_names = [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 - return enterprise_resources(ctx) - - else: - raise ValueError("Must specify either enterprise_name or org_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)) + + raise ValueError("Must specify either enterprise_name or org_name") From 62001b3e057a9d410d73e8d2ab2e1cb56747ae07 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 19:02:46 +0200 Subject: [PATCH 15/42] Updated auth to get per-install token --- src/openhound_github/auth.py | 156 +++++++++++++++-------------------- 1 file changed, 68 insertions(+), 88 deletions(-) diff --git a/src/openhound_github/auth.py b/src/openhound_github/auth.py index 4239a83..ea6977f 100644 --- a/src/openhound_github/auth.py +++ b/src/openhound_github/auth.py @@ -3,11 +3,15 @@ import base64 import json from datetime import datetime, timezone -from typing import Optional +from typing import Iterator import requests from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding +from dlt.sources.helpers.rest_client.client import RESTClient +from dlt.sources.helpers.rest_client.paginators import ( + HeaderLinkPaginator, +) class GitHubJwtSession: @@ -15,10 +19,10 @@ class GitHubJwtSession: def __init__( self, - org_name: str | None, 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. @@ -34,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() @@ -93,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: @@ -118,8 +118,8 @@ def _base64url_encode(data: str | bytes) -> str: # Remove padding return encoded.rstrip("=") - def get_jwt_headers(self) -> dict: - """Get HTTP headers for GitHub App JWT requests.""" + @property + def jwt_headers(self) -> dict: jwt_token = self._create_jwt() return { "Accept": "application/vnd.github+json", @@ -127,55 +127,63 @@ def get_jwt_headers(self) -> dict: "X-GitHub-Api-Version": "2022-11-28", } - def _installation_id(self) -> str: - if not self.org_name: - return self.app_id + @property + def jwt_token(self) -> str: + return self._create_jwt() + + def _jwt_client(self) -> RESTClient: + return RESTClient( + base_url=self.api_uri, + headers=self.jwt_headers, + paginator=HeaderLinkPaginator(), + ) + + def list_installations(self) -> Iterator[dict]: + for page in self._jwt_client().paginate( + "/app/installations", params={"per_page": 100} + ): + yield from page + + 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"]) + + 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.get( - f"{self.api_uri}orgs/{self.org_name}/installation", - headers=self.get_jwt_headers(), + response = requests.post( + f"{self.api_uri}app/installations/{installation_id}/access_tokens", + headers=self.jwt_headers, timeout=10, ) response.raise_for_status() except requests.RequestException as e: raise ValueError( - f"Failed to resolve GitHub App installation for org {self.org_name}: {e}" + f"Failed to obtain access token from GitHub API: {e}" ) from e - installation_id = response.json().get("id") - if installation_id is None: - raise ValueError( - f"No installation id returned for GitHub org {self.org_name}." - ) - return str(installation_id) - - def list_installations(self) -> list[dict]: - installations: list[dict] = [] - page = 1 - while True: - try: - response = requests.get( - f"{self.api_uri}app/installations", - headers=self.get_jwt_headers(), - params={"per_page": 100, "page": page}, - timeout=10, - ) - response.raise_for_status() - except requests.RequestException as e: - raise ValueError(f"Failed to list GitHub App installations: {e}") from e + response_data = response.json() + if "token" not in response_data: + raise ValueError("No token in GitHub API response. Check app credentials.") - batch = response.json() - if not batch: - break - installations.extend(batch) - if "next" not in response.links: - break - page += 1 + expires_at = None + if "expires_at" in response_data: + expires_at = datetime.fromisoformat( + response_data["expires_at"].replace("Z", "+00:00") + ) - return installations + token = response_data["token"] + self._access_tokens[installation_id] = (token, expires_at) + return token - def get_access_token(self) -> str: + 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 @@ -188,43 +196,16 @@ def get_access_token(self) -> str: 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 - ): - return self._access_token - - # Exchange JWT for an installation-scoped access token. - headers = self.get_jwt_headers() - installation_id = self._installation_id() - - try: - response = requests.post( - f"{self.api_uri}app/installations/{installation_id}/access_tokens", - headers=headers, - timeout=10, - ) - response.raise_for_status() - except requests.RequestException as e: - raise ValueError( - f"Failed to obtain access token from GitHub API: {e}" - ) from e - - response_data = response.json() - 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 - if "expires_at" in response_data: - self._token_expires_at = datetime.fromisoformat( - response_data["expires_at"].replace("Z", "+00:00") - ) + 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._access_token + return self.installation_token(installation_id) def get_headers(self) -> dict: """Get HTTP headers for authenticated GitHub API requests. @@ -245,7 +226,7 @@ def create_github_jwt_session( client_id: str, private_key_path: str, app_id: str, - api_uri: str = "https://api.github.com/", + api_uri: str = "https://api.github.com", ) -> GitHubJwtSession: """Factory function to create a GitHub JWT session. @@ -254,7 +235,6 @@ def create_github_jwt_session( client_id: The GitHub App client ID. private_key_path: Path to the private key PEM file. app_id: The GitHub App ID. - api_uri: The GitHub API base URI. Returns: A GitHubJwtSession instance. @@ -275,4 +255,4 @@ def create_github_jwt_session( private_key_path=private_key_path, app_id=app_id, api_uri=api_uri, - ) \ No newline at end of file + ) From 43d61a343c7257a69e3d01b10b56a6ec1ccdc182 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 19:03:48 +0200 Subject: [PATCH 16/42] Else raise error if enterprise_name org org_name are not specified --- src/openhound_github/source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 3ccaf21..9d02aef 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -169,4 +169,5 @@ def org_client(token: str) -> RESTClient: ] return (*enterprise_resources(ctx), *organization_resources(ctx)) - raise ValueError("Must specify either enterprise_name or org_name") + else: + raise ValueError("Must specify either enterprise_name or org_name") From 02acef4e0ae7bfc44127ba30b09b4155752d048f Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 19:04:05 +0200 Subject: [PATCH 17/42] Remove unused imports --- src/openhound_github/source.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/openhound_github/source.py b/src/openhound_github/source.py index 9d02aef..90629db 100644 --- a/src/openhound_github/source.py +++ b/src/openhound_github/source.py @@ -4,7 +4,6 @@ import dlt from dlt.common.configuration import configspec from dlt.common.configuration.specs import CredentialsConfiguration -from dlt.sources import credentials from dlt.sources.helpers import requests from dlt.sources.helpers.rest_client.auth import BearerTokenAuth from dlt.sources.helpers.rest_client.client import RESTClient From fc06796471566ed0b9c0602f4ddbaa5a5b79e48b Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 19:24:22 +0200 Subject: [PATCH 18/42] Split enterprise models --- .../models/enterprise_external_identity.py | 202 ++++++++++++++++++ .../models/enterprise_helpers.py | 6 + .../models/enterprise_managed_user.py | 131 ++++++++++++ .../models/enterprise_member.py | 63 ++++++ .../models/enterprise_organization.py | 79 +++++++ .../models/enterprise_role.py | 120 +++++++++++ .../models/enterprise_role_team.py | 47 ++++ .../models/enterprise_role_user.py | 45 ++++ .../models/enterprise_saml_provider.py | 122 +++++++++++ .../models/enterprise_team.py | 116 ++++++++++ .../models/enterprise_team_member.py | 45 ++++ .../models/enterprise_team_organization.py | 69 ++++++ .../models/enterprise_team_role.py | 97 +++++++++ .../models/enterprise_user.py | 96 +++++++++ 14 files changed, 1238 insertions(+) create mode 100644 src/openhound_github/models/enterprise_external_identity.py create mode 100644 src/openhound_github/models/enterprise_helpers.py create mode 100644 src/openhound_github/models/enterprise_managed_user.py create mode 100644 src/openhound_github/models/enterprise_member.py create mode 100644 src/openhound_github/models/enterprise_organization.py create mode 100644 src/openhound_github/models/enterprise_role.py create mode 100644 src/openhound_github/models/enterprise_role_team.py create mode 100644 src/openhound_github/models/enterprise_role_user.py create mode 100644 src/openhound_github/models/enterprise_saml_provider.py create mode 100644 src/openhound_github/models/enterprise_team.py create mode 100644 src/openhound_github/models/enterprise_team_member.py create mode 100644 src/openhound_github/models/enterprise_team_organization.py create mode 100644 src/openhound_github/models/enterprise_team_role.py create mode 100644 src/openhound_github/models/enterprise_user.py 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..14f1bcf --- /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.enterprise_node_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..e1db7f6 --- /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.enterprise_node_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.enterprise_node_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..83be282 --- /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.enterprise_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/enterprise_role.py b/src/openhound_github/models/enterprise_role.py new file mode 100644 index 0000000..e703418 --- /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.enterprise_node_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.enterprise_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/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..3c97f7c --- /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.enterprise_node_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.enterprise_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/enterprise_team.py b/src/openhound_github/models/enterprise_team.py new file mode 100644 index 0000000..1df68c4 --- /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.enterprise_node_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.enterprise_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/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..12d71ea --- /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), + 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..509574d --- /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.enterprise_node_id, + environment_name=self.enterprise_slug, + enterpriseid=self.enterprise_node_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..d3b5ab5 --- /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.enterprise_node_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.enterprise_node_id, match_by="id"), + end=EdgePath(value=self.node_id, match_by="id"), + properties=EdgeProperties(traversable=False), + ) From 5ae2fc42bdd9d4448eae47301901f688fd535367 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 19:24:54 +0200 Subject: [PATCH 19/42] Split enterprise models --- src/openhound_github/models/__init__.py | 36 +- src/openhound_github/models/enterprise.py | 1082 +----------------- src/openhound_github/resources/enterprise.py | 2 - 3 files changed, 22 insertions(+), 1098 deletions(-) diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 8d4b74a..62f5e1a 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -4,22 +4,21 @@ from .branch_pr_bypass_allowance import BranchPrBypassAllowance from .branch_protection_rule import BranchProtectionRule, BranchProtectionRuleActor from .branch_push_allowance import BranchPushAllowance -from .enterprise import ( - BaseUser, - Enterprise, - EnterpriseExternalIdentity, - EnterpriseManagedUser, - EnterpriseOrganization, - EnterpriseRole, - EnterpriseRoleTeam, - EnterpriseRoleUser, - EnterpriseSamlProvider, - EnterpriseTeam, - EnterpriseTeamMember, - EnterpriseTeamOrganization, - EnterpriseTeamRole, - EnterpriseUser, -) +from .enterprise import Enterprise +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 @@ -111,5 +110,8 @@ "EnterpriseTeamOrganization", "EnterpriseTeamRole", "EnterpriseUser", - "BaseUser" + "BaseUser", + "enterprise_role_node_id", + "enterprise_team_node_id", + "flatten_enterprise_member", ] diff --git a/src/openhound_github/models/enterprise.py b/src/openhound_github/models/enterprise.py index 8f4ef7f..9340d5d 100644 --- a/src/openhound_github/models/enterprise.py +++ b/src/openhound_github/models/enterprise.py @@ -1,32 +1,15 @@ from dataclasses import dataclass -from datetime import datetime -from typing import Any, ClassVar +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 ( - ConditionalEdgePath, - Edge, - EdgePath, - EdgeProperties, - PropertyMatch, -) -from pydantic import BaseModel, ConfigDict, Field +from openhound.core.asset import BaseAsset, NodeDef +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 -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}" - - @dataclass class GHEnterpriseProperties(GHNodeProperties): """Properties for a GitHub enterprise. @@ -125,1062 +108,3 @@ def as_node(self) -> GHNode: @property def edges(self): return [] - - -@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.enterprise_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) - - -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 - - -@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.enterprise_node_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.enterprise_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) - - -@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") - updated_at: datetime | None = Field(alias="updatedAt") - 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.enterprise_node_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.enterprise_node_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), - ) - - -@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.enterprise_node_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.enterprise_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) - - -@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.enterprise_node_id, - environment_name=self.enterprise_slug, - enterpriseid=self.enterprise_node_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), - ) - - -@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), - ) - - -@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), - PropertyMatch(key="slug", value=self.projected_slug), - ], - ), - properties=EdgeProperties(traversable=True), - ) - - -@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.enterprise_node_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.enterprise_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) - - -@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), - ) - - -@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), - ) - - -@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.enterprise_node_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.enterprise_node_id, match_by="id"), - end=EdgePath(value=self.node_id, match_by="id"), - properties=EdgeProperties(traversable=False), - ) - - -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.enterprise_node_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/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 43a0313..4203413 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -1,6 +1,4 @@ from dataclasses import dataclass -from typing import Any - from dlt.sources.helpers.rest_client.client import RESTClient from openhound_github.graphql import ( From 2bb0c7e8a129ffaf1696e5db65ebef9825bd9c26 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 20:33:27 +0200 Subject: [PATCH 20/42] Replaced dataclass field descriptions with attribute descriptions --- .../models/app_installation.py | 209 ++++----- src/openhound_github/models/branch.py | 42 +- .../models/branch_protection_rule.py | 128 ++---- src/openhound_github/models/env_secret.py | 43 +- src/openhound_github/models/env_variable.py | 37 +- src/openhound_github/models/environment.py | 47 +- .../models/external_identity.py | 69 ++- src/openhound_github/models/org.py | 409 ++++++------------ src/openhound_github/models/org_role.py | 39 +- src/openhound_github/models/org_secret.py | 37 +- src/openhound_github/models/org_variable.py | 41 +- .../models/personal_access_token.py | 83 ++-- .../models/personal_access_token_request.py | 49 +-- src/openhound_github/models/repository.py | 185 ++++---- .../models/repository_role.py | 50 +-- .../models/repository_secret.py | 46 +- .../models/repository_variable.py | 46 +- src/openhound_github/models/runner.py | 159 +++---- src/openhound_github/models/saml_provider.py | 55 +-- .../models/secret_scanning_alert.py | 55 +-- src/openhound_github/models/team.py | 58 +-- src/openhound_github/models/team_role.py | 39 +- src/openhound_github/models/user.py | 59 ++- src/openhound_github/models/workflow.py | 77 ++-- 24 files changed, 836 insertions(+), 1226 deletions(-) diff --git a/src/openhound_github/models/app_installation.py b/src/openhound_github/models/app_installation.py index fdcdac2..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): @@ -228,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): @@ -323,9 +265,12 @@ class App(BaseAsset): # 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 @@ -347,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 9472980..98a750a 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.""" - - 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`)."}, - ) + """Branch-specific properties and accordion panel queries. + + 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 + repository_id: str | None = None + environment_name: str | None = None + query_branch_write: str | None = None class ProtectionRule(BaseModel): diff --git a/src/openhound_github/models/branch_protection_rule.py b/src/openhound_github/models/branch_protection_rule.py index 24cc859..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): diff --git a/src/openhound_github/models/env_secret.py b/src/openhound_github/models/env_secret.py index c7aafad..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( diff --git a/src/openhound_github/models/env_variable.py b/src/openhound_github/models/env_variable.py index 1e25307..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( diff --git a/src/openhound_github/models/environment.py b/src/openhound_github/models/environment.py index 11d2ca4..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( diff --git a/src/openhound_github/models/external_identity.py b/src/openhound_github/models/external_identity.py index d77ca37..9bdf648 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. + + 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 = 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 = "" + 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): diff --git a/src/openhound_github/models/org.py b/src/openhound_github/models/org.py index 8bd9d20..91bd3be 100644 --- a/src/openhound_github/models/org.py +++ b/src/openhound_github/models/org.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 @@ -12,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 diff --git a/src/openhound_github/models/org_role.py b/src/openhound_github/models/org_role.py index 4501462..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( diff --git a/src/openhound_github/models/org_secret.py b/src/openhound_github/models/org_secret.py index 522abcf..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 diff --git a/src/openhound_github/models/org_variable.py b/src/openhound_github/models/org_variable.py index 226d01d..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( diff --git a/src/openhound_github/models/personal_access_token.py b/src/openhound_github/models/personal_access_token.py index 7151015..58b1a69 100644 --- a/src/openhound_github/models/personal_access_token.py +++ b/src/openhound_github/models/personal_access_token.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from datetime import datetime from typing import ClassVar @@ -27,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( diff --git a/src/openhound_github/models/personal_access_token_request.py b/src/openhound_github/models/personal_access_token_request.py index 31bdbe1..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( diff --git a/src/openhound_github/models/repository.py b/src/openhound_github/models/repository.py index 8aa0a66..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,110 +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): diff --git a/src/openhound_github/models/repository_role.py b/src/openhound_github/models/repository_role.py index aa7a09c..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 @@ -199,31 +199,29 @@ class BaseRepoRole(BaseModel): @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( diff --git a/src/openhound_github/models/repository_secret.py b/src/openhound_github/models/repository_secret.py index 33bab42..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( @@ -72,8 +68,8 @@ class RepoSecret(BaseAsset): # Additional org_login: str - repository_name: str = "" - repository_node_id: str = "" + repository_name: str | None = None + repository_node_id: str | None = None @property def org_node_id(self) -> str | None: diff --git a/src/openhound_github/models/repository_variable.py b/src/openhound_github/models/repository_variable.py index eebfb16..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( @@ -71,8 +67,8 @@ class RepoVariable(BaseAsset): # Additional org_login: str - repository_name: str = "" - repository_node_id: str = "" + repository_name: str | None = None + repository_node_id: str | None = None @property def org_node_id(self) -> str | None: diff --git a/src/openhound_github/models/runner.py b/src/openhound_github/models/runner.py index 787a2c8..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 @@ -133,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 diff --git a/src/openhound_github/models/saml_provider.py b/src/openhound_github/models/saml_provider.py index dd5fc0f..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( diff --git a/src/openhound_github/models/secret_scanning_alert.py b/src/openhound_github/models/secret_scanning_alert.py index c906d31..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( diff --git a/src/openhound_github/models/team.py b/src/openhound_github/models/team.py index 7b71106..2dadbca 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): diff --git a/src/openhound_github/models/team_role.py b/src/openhound_github/models/team_role.py index 1f7aab3..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( diff --git a/src/openhound_github/models/user.py b/src/openhound_github/models/user.py index 8aeba0d..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( diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index fc0e708..fa1c465 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.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,54 +12,35 @@ @dataclass class GHWorkflowProperties(GHNodeProperties): - """Workflow-specific properties and accordion panel queries.""" + """Workflow-specific properties and accordion panel queries. + + 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 = 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)."}, - ) + 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( From 712ae1037a0b633f3e21a65f18ea7e7645295653 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 20:45:34 +0200 Subject: [PATCH 21/42] Fixed enterprise lookups/edge generation --- src/openhound_github/lookup.py | 5 +++++ src/openhound_github/main.py | 1 + src/openhound_github/models/enterprise.py | 2 +- src/openhound_github/models/enterprise_external_identity.py | 2 +- src/openhound_github/models/enterprise_managed_user.py | 4 ++-- src/openhound_github/models/enterprise_organization.py | 2 +- src/openhound_github/models/enterprise_role.py | 4 ++-- src/openhound_github/models/enterprise_saml_provider.py | 4 ++-- src/openhound_github/models/enterprise_team.py | 4 ++-- src/openhound_github/models/enterprise_team_role.py | 4 ++-- src/openhound_github/models/enterprise_user.py | 4 ++-- 11 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 4a741d8..7dbb9e6 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -31,6 +31,11 @@ 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( diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index efe85bd..f3b6438 100644 --- a/src/openhound_github/main.py +++ b/src/openhound_github/main.py @@ -60,4 +60,5 @@ def preproc(ctx: PreProcContext): "repo_roles": "repo_roles", "saml_provider": "saml_provider", "applications": "applications", + "enterprise": "enterprise", } diff --git a/src/openhound_github/models/enterprise.py b/src/openhound_github/models/enterprise.py index 9340d5d..dc905ff 100644 --- a/src/openhound_github/models/enterprise.py +++ b/src/openhound_github/models/enterprise.py @@ -88,7 +88,7 @@ def as_node(self) -> GHNode: name=self.slug, displayname=self.name or self.slug, node_id=self.node_id, - environmentid=self.node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.slug, slug=self.slug, enterprise_name=self.name, diff --git a/src/openhound_github/models/enterprise_external_identity.py b/src/openhound_github/models/enterprise_external_identity.py index 14f1bcf..c3e2692 100644 --- a/src/openhound_github/models/enterprise_external_identity.py +++ b/src/openhound_github/models/enterprise_external_identity.py @@ -132,7 +132,7 @@ def as_node(self) -> GHNode: name=self.guid or self.node_id, displayname=self.guid or self.node_id, node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, guid=self.guid, saml_identity_username=self.saml_identity.username diff --git a/src/openhound_github/models/enterprise_managed_user.py b/src/openhound_github/models/enterprise_managed_user.py index e1db7f6..129a9e2 100644 --- a/src/openhound_github/models/enterprise_managed_user.py +++ b/src/openhound_github/models/enterprise_managed_user.py @@ -100,7 +100,7 @@ def as_node(self) -> GHNode: name=self.login, displayname=self.name or self.login, node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, login=self.login, full_name=self.name, @@ -118,7 +118,7 @@ def as_node(self) -> GHNode: def edges(self): yield Edge( kind=ek.HAS_MEMBER, - start=EdgePath(value=self.enterprise_node_id, match_by="id"), + 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_organization.py b/src/openhound_github/models/enterprise_organization.py index 83be282..0abca03 100644 --- a/src/openhound_github/models/enterprise_organization.py +++ b/src/openhound_github/models/enterprise_organization.py @@ -73,7 +73,7 @@ def as_node(self) -> GHNode: def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self.enterprise_node_id, match_by="id"), + 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 index e703418..5700056 100644 --- a/src/openhound_github/models/enterprise_role.py +++ b/src/openhound_github/models/enterprise_role.py @@ -94,7 +94,7 @@ def as_node(self) -> GHNode: name=f"{self.enterprise_slug}/{self.name}", displayname=self.name, node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, github_role_id=self.id, short_name=self.name, @@ -114,7 +114,7 @@ def as_node(self) -> GHNode: def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self.enterprise_node_id, match_by="id"), + 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_saml_provider.py b/src/openhound_github/models/enterprise_saml_provider.py index 3c97f7c..4a30ed0 100644 --- a/src/openhound_github/models/enterprise_saml_provider.py +++ b/src/openhound_github/models/enterprise_saml_provider.py @@ -99,7 +99,7 @@ def as_node(self) -> GHNode: name=self.node_id, displayname=self.enterprise_slug, node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, issuer=self.issuer, sso_url=self.sso_url, @@ -116,7 +116,7 @@ def as_node(self) -> GHNode: def edges(self): yield Edge( kind=ek.HAS_SAML_IDENTITY_PROVIDER, - start=EdgePath(value=self.enterprise_node_id, match_by="id"), + 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 index 1df68c4..7b283a1 100644 --- a/src/openhound_github/models/enterprise_team.py +++ b/src/openhound_github/models/enterprise_team.py @@ -90,7 +90,7 @@ def as_node(self) -> GHNode: name=self.name, displayname=self.name, node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, github_team_id=self.id, slug=self.slug, @@ -110,7 +110,7 @@ def as_node(self) -> GHNode: def edges(self): yield Edge( kind=ek.CONTAINS, - start=EdgePath(value=self.enterprise_node_id, match_by="id"), + 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_role.py b/src/openhound_github/models/enterprise_team_role.py index 509574d..3c67d1e 100644 --- a/src/openhound_github/models/enterprise_team_role.py +++ b/src/openhound_github/models/enterprise_team_role.py @@ -75,9 +75,9 @@ def as_node(self) -> GHNode: name=f"{self.enterprise_slug}/{self.slug}/members", displayname="members", node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, - enterpriseid=self.enterprise_node_id, + enterpriseid=self._lookup.enterprise_id(), team_name=self.name, team_id=self.enterprise_team_node_id, short_name="members", diff --git a/src/openhound_github/models/enterprise_user.py b/src/openhound_github/models/enterprise_user.py index d3b5ab5..049db91 100644 --- a/src/openhound_github/models/enterprise_user.py +++ b/src/openhound_github/models/enterprise_user.py @@ -75,7 +75,7 @@ def as_node(self) -> GHNode: name=self.login, displayname=self.name or self.login, node_id=self.node_id, - environmentid=self.enterprise_node_id, + environmentid=self._lookup.enterprise_id(), environment_name=self.enterprise_slug, login=self.login, full_name=self.name, @@ -90,7 +90,7 @@ def edges(self): if self.has_direct_enterprise_membership: yield Edge( kind=ek.HAS_MEMBER, - start=EdgePath(value=self.enterprise_node_id, match_by="id"), + start=EdgePath(value=self._lookup.enterprise_id(), match_by="id"), end=EdgePath(value=self.node_id, match_by="id"), properties=EdgeProperties(traversable=False), ) From 263507f16701c3050b1521b91e544627ea5eb132 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 21:10:11 +0200 Subject: [PATCH 22/42] Fixed EnterpriseRoleUser slug/id --- src/openhound_github/resources/enterprise.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 4203413..67f0cf6 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -1,4 +1,5 @@ from dataclasses import dataclass + from dlt.sources.helpers.rest_client.client import RESTClient from openhound_github.graphql import ( @@ -199,6 +200,7 @@ def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): ): for org in page: node_id = org.get("node_id") or org.get("id") + print(org) if node_id: yield { **org, @@ -313,8 +315,8 @@ def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext): "login": node.get("login"), "assignment": "direct", "role_id": "owners", - "enterprise_node_id": enterprise_data["id"], - "ctx.enterprise_name": ctx.enterprise_name, + "enterprise_node_id": enterprise_data.id, + "enterprise_slug": ctx.enterprise_name, } From 34b1670ed0b34ff0252360aaec1d109448eea348 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Mon, 4 May 2026 21:52:58 +0200 Subject: [PATCH 23/42] Split role and EnterpriseAdmin --- src/openhound_github/models/__init__.py | 2 + .../models/enterprise_admin.py | 21 +++++++++++ src/openhound_github/resources/enterprise.py | 37 ++++++++----------- 3 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 src/openhound_github/models/enterprise_admin.py diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 62f5e1a..82d8855 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -5,6 +5,7 @@ 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 @@ -98,6 +99,7 @@ "EnvironmentVariable", "EnvironmentBranchPolicy", "Enterprise", + "EnterpriseAdmin", "EnterpriseExternalIdentity", "EnterpriseManagedUser", "EnterpriseOrganization", 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/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index 67f0cf6..e72cde6 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -13,6 +13,7 @@ from openhound_github.models import ( BaseUser, Enterprise, + EnterpriseAdmin, EnterpriseExternalIdentity, EnterpriseManagedUser, EnterpriseOrganization, @@ -200,7 +201,6 @@ def enterprise_team_organizations(team: EnterpriseTeam, ctx: SourceContext): ): for org in page: node_id = org.get("node_id") or org.get("id") - print(org) if node_id: yield { **org, @@ -225,11 +225,6 @@ def enterprise_roles(enterprise_data: Enterprise, ctx: SourceContext): "enterprise_slug": ctx.enterprise_name, } - -@app.transformer( - name="enterprise_admin_roles", columns=EnterpriseRole, parallelized=True -) -def enterprise_admin_roles(enterprise_data: Enterprise, ctx: SourceContext): yield { "id": "owners", "name": "owners", @@ -242,20 +237,20 @@ def enterprise_admin_roles(enterprise_data: Enterprise, ctx: SourceContext): @app.transformer( - name="enterprise_role_users", columns=EnterpriseRoleUser, parallelized=True + name="enterprise_role_teams", columns=EnterpriseRoleTeam, parallelized=True ) -def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): +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}/users", + f"/enterprises/{ctx.enterprise_name}/enterprise-roles/{role.id}/teams", params={"per_page": 100}, ): - for user in page: - if user.get("node_id"): + for team in page: + if team.get("id"): yield { - **user, + **team, "role_id": role.id, "enterprise_node_id": role.enterprise_node_id, "enterprise_slug": role.enterprise_slug, @@ -263,29 +258,27 @@ def enterprise_role_users(role: EnterpriseRole, ctx: SourceContext): @app.transformer( - name="enterprise_role_teams", columns=EnterpriseRoleTeam, parallelized=True + name="enterprise_role_users", columns=EnterpriseRoleUser, parallelized=True ) -def enterprise_role_teams(role: EnterpriseRole, ctx: SourceContext): +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}/teams", + f"/enterprises/{ctx.enterprise_name}/enterprise-roles/{role.id}/users", params={"per_page": 100}, ): - for team in page: - if team.get("id"): + for user in page: + if user.get("node_id"): yield { - **team, + **user, "role_id": role.id, "enterprise_node_id": role.enterprise_node_id, "enterprise_slug": role.enterprise_slug, } -@app.transformer( - name="enterprise_admins", columns=EnterpriseRoleUser, parallelized=True -) +@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", @@ -418,7 +411,7 @@ def enterprise_resources(ctx: SourceContext): 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_admin_roles(ctx), enterprise_resource | enterprise_admins(ctx), enterprise_resource | saml_resource, enterprise_resource | saml_resource | enterprise_external_identities(ctx), From 5aecdde436331002731baf2c19c8d7be149bb95b Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Wed, 6 May 2026 02:31:25 +0200 Subject: [PATCH 24/42] Updated justfile for running collect/preproc/convert --- justfile | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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" From 7462e2568457c544321a6df86ee6f12b2f8c9ff0 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Wed, 6 May 2026 02:34:14 +0200 Subject: [PATCH 25/42] Updated lock/deps --- pyproject.toml | 2 +- uv.lock | 1680 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1681 insertions(+), 1 deletion(-) create mode 100644 uv.lock 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/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" }, +] From 3ea773f3fa5719cb5978cd4b9562d407e1b9f855 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Wed, 6 May 2026 03:03:02 +0200 Subject: [PATCH 26/42] Updated schema with missing nodes --- extension/schema.json | 56 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) 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", From c625e0cd70ec6bb606c84012d27fa74cd3bd4c62 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Tue, 12 May 2026 01:42:31 +0200 Subject: [PATCH 27/42] Add GH_WorkflowJob and GH_WorkflowStep nodes and related edges --- src/openhound_github/kinds/edges.py | 7 +++++++ src/openhound_github/kinds/nodes.py | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/openhound_github/kinds/edges.py b/src/openhound_github/kinds/edges.py index 1f6b53f..28425e2 100644 --- a/src/openhound_github/kinds/edges.py +++ b/src/openhound_github/kinds/edges.py @@ -50,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 716b0c7..ce75797 100644 --- a/src/openhound_github/kinds/nodes.py +++ b/src/openhound_github/kinds/nodes.py @@ -55,6 +55,8 @@ # Workflow nodes WORKFLOW = "GH_Workflow" +WORKFLOW_JOB = "GH_WorkflowJob" +WORKFLOW_STEP = "GH_WorkflowStep" # Enterprise nodes From 5e5174163ec9995f1fe148c84c2bbc7cb9608cfb Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Tue, 12 May 2026 01:47:21 +0200 Subject: [PATCH 28/42] Add GH_WorkflowJob and GH_WorkflowStep models --- src/openhound_github/models/workflow_job.py | 294 +++++++++++++++++++ src/openhound_github/models/workflow_step.py | 261 ++++++++++++++++ 2 files changed, 555 insertions(+) create mode 100644 src/openhound_github/models/workflow_job.py create mode 100644 src/openhound_github/models/workflow_step.py diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py new file mode 100644 index 0000000..bd45c8f --- /dev/null +++ b/src/openhound_github/models/workflow_job.py @@ -0,0 +1,294 @@ +from dataclasses import dataclass + +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 as compact JSON. + 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: str | None = None + is_self_hosted: bool = False + container: str | None = None + environment: str | None = None + permissions: 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.""" + + node_id: str + name: str + job_key: str + workflow_node_id: str + repository_name: str + repository_node_id: str + org_login: str + runs_on: str | None = None + is_self_hosted: bool = False + container: str | None = None + environment: str | None = None + permissions: str | None = 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 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.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: + 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), + ) + 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), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _uses_variable_edges(self): + for ref in self.variable_references: + 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), + ) + 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), + ], + ), + 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..e6c42f7 --- /dev/null +++ b/src/openhound_github/models/workflow_step.py @@ -0,0 +1,261 @@ +from dataclasses import dataclass + +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. + auth_provider: The detected auth provider for known login actions. + 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. + has_injection_risk: Whether user-controlled expressions were found. + runs_local_script: Whether a local script path was referenced. + run: The shell command body for `run` steps. + with_args: The compact JSON representation of `with` arguments. + contents: The compact JSON representation of the full step definition. + injection_risks: JSON array of detected injection risk expressions. + local_script_refs: JSON array of referenced local script paths. + 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 + auth_provider: str | None = None + action_owner: str | None = None + action_name: str | None = None + action_ref: str | None = None + is_pinned: bool = False + has_injection_risk: bool = False + runs_local_script: bool = False + run: str | None = None + with_args: str | None = None + contents: str | None = None + injection_risks: str | None = None + local_script_refs: 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.""" + + 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 + auth_provider: str | None = None + action_owner: str | None = None + action_name: str | None = None + action_ref: str | None = None + is_pinned: bool = False + has_injection_risk: bool = False + runs_local_script: bool = False + run: str | None = None + with_args: str | None = None + contents: str | None = None + injection_risks: str | None = None + local_script_refs: str | 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, + auth_provider=self.auth_provider, + action_owner=self.action_owner, + action_name=self.action_name, + action_ref=self.action_ref, + is_pinned=self.is_pinned, + has_injection_risk=self.has_injection_risk, + runs_local_script=self.runs_local_script, + run=self.run, + with_args=self.with_args, + contents=self.contents, + injection_risks=self.injection_risks, + local_script_refs=self.local_script_refs, + 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: + 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), + ) + 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), + ], + ), + properties=EdgeProperties(traversable=False), + ) + + @property + def _uses_variable_edges(self): + for ref in self.variable_references: + 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), + ) + 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), + ], + ), + 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 From 37716c2a05d3247651e3ed33f3492a4131845b02 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Tue, 12 May 2026 02:16:14 +0200 Subject: [PATCH 29/42] Dont allow extra data --- src/openhound_github/models/external_identity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openhound_github/models/external_identity.py b/src/openhound_github/models/external_identity.py index 9bdf648..8dbe5f1 100644 --- a/src/openhound_github/models/external_identity.py +++ b/src/openhound_github/models/external_identity.py @@ -25,7 +25,7 @@ @dataclass class GHExternalIdentityProperties(GHNodeProperties): """External identity properties and accordion panel queries. - + Attributes: guid: The GUID of the external identity. saml_identity_username: The username from the SAML identity. @@ -107,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 From 53aab479e5aee6d0c38fec7feeeecf39c011a2c4 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Tue, 12 May 2026 02:22:27 +0200 Subject: [PATCH 30/42] Collect workflow content + generate jobs/steps --- .../resources/organization.py | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/src/openhound_github/resources/organization.py b/src/openhound_github/resources/organization.py index 050a921..1e019a7 100644 --- a/src/openhound_github/resources/organization.py +++ b/src/openhound_github/resources/organization.py @@ -1,7 +1,10 @@ +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 ( @@ -61,6 +64,8 @@ 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 @@ -388,7 +393,6 @@ def teams(ctx: SourceContext): "query": TEAMS_QUERY, "variables": {"login": org_name, "count": 100, "after": None}, } - for page_data in client.paginate( "/graphql", method="POST", @@ -452,6 +456,10 @@ def team_members(team: Team, ctx: SourceContext): ) 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, @@ -846,19 +854,52 @@ def workflows(repo: Repository, ctx: SourceContext): 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: - # 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, - "org_login": repo.org_login, - } + 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) @@ -1451,6 +1492,7 @@ def external_identities(ctx: SourceContext): cursor_variable="after", cursor_field="endCursor", has_next_field="hasNextPage", + allow_missing_page_info=True, ) data = { "query": SAML_IDENTITIES_QUERY, @@ -1511,6 +1553,7 @@ def organization_resources(ctx: SourceContext): 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) @@ -1535,7 +1578,9 @@ def organization_resources(ctx: SourceContext): actions_permissions(ctx), repos_resource, repos_resource | repository_roles(repo_roles_base), - repos_resource | workflows(ctx), + 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), From 675415a45be47a0bb90cc40cbd2c747b529129f2 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Thu, 14 May 2026 23:04:48 +0200 Subject: [PATCH 31/42] Modified helper to deal with GraphQL errors --- src/openhound_github/helpers.py | 82 +++++++++++++++++++- src/openhound_github/models/team.py | 4 +- src/openhound_github/resources/enterprise.py | 2 + 3 files changed, 83 insertions(+), 5 deletions(-) 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/models/team.py b/src/openhound_github/models/team.py index 2dadbca..313d088 100644 --- a/src/openhound_github/models/team.py +++ b/src/openhound_github/models/team.py @@ -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") @@ -95,7 +95,7 @@ class Team(BaseAsset): 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 diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index e72cde6..e58b8f6 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -322,6 +322,7 @@ def enterprise_saml_provider(enterprise_data: Enterprise, ctx: SourceContext): cursor_variable="after", cursor_field="endCursor", has_next_field="hasNextPage", + allow_missing_page_info=True, ) data = { "query": ENTERPRISE_SAML_QUERY, @@ -361,6 +362,7 @@ def enterprise_external_identities( cursor_variable="after", cursor_field="endCursor", has_next_field="hasNextPage", + allow_missing_page_info=True, ) data = { "query": ENTERPRISE_SAML_QUERY, From 358d8ab1163a8a0c64be7a2af2f1d765ee269b67 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Thu, 14 May 2026 23:07:22 +0200 Subject: [PATCH 32/42] WIP: Workflows --- src/openhound_github/models/__init__.py | 4 + src/openhound_github/models/workflow.py | 320 ++++++++++++++++++- src/openhound_github/models/workflow_job.py | 18 +- src/openhound_github/models/workflow_step.py | 36 +-- 4 files changed, 339 insertions(+), 39 deletions(-) diff --git a/src/openhound_github/models/__init__.py b/src/openhound_github/models/__init__.py index 82d8855..db79256 100644 --- a/src/openhound_github/models/__init__.py +++ b/src/openhound_github/models/__init__.py @@ -48,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", @@ -68,6 +70,8 @@ "RepositoryQL", "BranchPushAllowance", "Workflow", + "WorkflowJob", + "WorkflowStep", "Environment", "EnvironmentSecret", "EnvironmentVariable", diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index fa1c465..3d6953f 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -1,8 +1,21 @@ +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,10 +23,197 @@ 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. - + Attributes: short_name: The workflow's display name. path: The file path of the workflow definition (e.g., `.github/workflows/ci.yml`). @@ -63,6 +263,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 @@ -71,7 +273,6 @@ 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 @@ -86,10 +287,119 @@ class Workflow(BaseAsset): 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 + parsed = yaml.load(self.contents, Loader=GithubActionsLoader) + if not isinstance(parsed, dict): + return None + try: + 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( diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index bd45c8f..5be7163 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -1,5 +1,7 @@ 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, @@ -35,7 +37,7 @@ class GHWorkflowJobProperties(GHNodeProperties): is_self_hosted: Whether the job targets self-hosted runners. container: The optional container configuration. environment: The deployment environment name. - permissions: Effective job permissions as compact JSON. + 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. @@ -44,11 +46,11 @@ class GHWorkflowJobProperties(GHNodeProperties): """ job_key: str | None = None - runs_on: str | None = None + runs_on: Any = None is_self_hosted: bool = False - container: str | None = None + container: Any = None environment: str | None = None - permissions: str | None = None + permissions: Any = None uses_reusable: str | None = None workflow_node_id: str | None = None repository_name: str | None = None @@ -125,6 +127,8 @@ class GHWorkflowJobProperties(GHNodeProperties): 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 @@ -132,11 +136,11 @@ class WorkflowJob(BaseAsset): repository_name: str repository_node_id: str org_login: str - runs_on: str | None = None + runs_on: Any = None is_self_hosted: bool = False - container: str | None = None + container: Any = None environment: str | None = None - permissions: 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) diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py index e6c42f7..2714cf8 100644 --- a/src/openhound_github/models/workflow_step.py +++ b/src/openhound_github/models/workflow_step.py @@ -1,5 +1,7 @@ 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, @@ -34,18 +36,13 @@ class GHWorkflowStepProperties(GHNodeProperties): type: The step type: `uses`, `run`, or `unknown`. action: The full action reference from `uses`. action_slug: The action owner/name without ref. - auth_provider: The detected auth provider for known login actions. 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. - has_injection_risk: Whether user-controlled expressions were found. - runs_local_script: Whether a local script path was referenced. run: The shell command body for `run` steps. - with_args: The compact JSON representation of `with` arguments. - contents: The compact JSON representation of the full step definition. - injection_risks: JSON array of detected injection risk expressions. - local_script_refs: JSON array of referenced local script paths. + 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. @@ -57,18 +54,13 @@ class GHWorkflowStepProperties(GHNodeProperties): type: str | None = None action: str | None = None action_slug: str | None = None - auth_provider: str | None = None action_owner: str | None = None action_name: str | None = None action_ref: str | None = None is_pinned: bool = False - has_injection_risk: bool = False - runs_local_script: bool = False run: str | None = None - with_args: str | None = None - contents: str | None = None - injection_risks: str | None = None - local_script_refs: str | None = None + with_args: dict[str, Any] | None = None + contents: dict[str, Any] | None = None job_node_id: str | None = None workflow_node_id: str | None = None repository_name: str | None = None @@ -123,6 +115,7 @@ class GHWorkflowStepProperties(GHNodeProperties): ) 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 @@ -135,18 +128,13 @@ class WorkflowStep(BaseAsset): org_login: str action: str | None = None action_slug: str | None = None - auth_provider: str | None = None action_owner: str | None = None action_name: str | None = None action_ref: str | None = None is_pinned: bool = False - has_injection_risk: bool = False - runs_local_script: bool = False run: str | None = None - with_args: str | None = None - contents: str | None = None - injection_risks: str | None = None - local_script_refs: 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) @@ -167,18 +155,13 @@ def as_node(self) -> GHNode: type=self.type, action=self.action, action_slug=self.action_slug, - auth_provider=self.auth_provider, action_owner=self.action_owner, action_name=self.action_name, action_ref=self.action_ref, is_pinned=self.is_pinned, - has_injection_risk=self.has_injection_risk, - runs_local_script=self.runs_local_script, run=self.run, with_args=self.with_args, contents=self.contents, - injection_risks=self.injection_risks, - local_script_refs=self.local_script_refs, job_node_id=self.job_node_id, workflow_node_id=self.workflow_node_id, repository_name=self.repository_name, @@ -244,7 +227,6 @@ def _uses_variable_edges(self): properties=EdgeProperties(traversable=False), ) - @property def _has_step_edge(self): yield Edge( From ec7a73467bed1302c90c1d2ff62d9a1e3b6fecf2 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Thu, 14 May 2026 23:55:49 +0200 Subject: [PATCH 33/42] Fixed workflow content parsing --- src/openhound_github/models/workflow.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/openhound_github/models/workflow.py b/src/openhound_github/models/workflow.py index 3d6953f..0c94488 100644 --- a/src/openhound_github/models/workflow.py +++ b/src/openhound_github/models/workflow.py @@ -1,3 +1,4 @@ +import base64 import re from dataclasses import dataclass from datetime import datetime @@ -276,7 +277,6 @@ class Workflow(BaseAsset): html_url: str | None = None branch: str | None = None contents: str | None = None - # query_repository: str # Custom fields added org_login: str @@ -291,10 +291,12 @@ def org_node_id(self) -> str | None: def document(self) -> WorkflowDocument | None: if not self.contents or not self.contents.strip(): return None - parsed = yaml.load(self.contents, Loader=GithubActionsLoader) - if not isinstance(parsed, dict): - 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 From 653e5d141be2eff1e0dd615815a3952ec55180bd Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Thu, 14 May 2026 23:56:08 +0200 Subject: [PATCH 34/42] Change allowed values for repository_none --- src/openhound_github/models/branch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhound_github/models/branch.py b/src/openhound_github/models/branch.py index 98a750a..4de85cb 100644 --- a/src/openhound_github/models/branch.py +++ b/src/openhound_github/models/branch.py @@ -29,7 +29,7 @@ class GHBranchProperties(GHNodeProperties): short_name: str | None = None commit_hash: str | None = None protected: bool = False - repository_name: str = None + repository_name: str | None = None repository_id: str | None = None environment_name: str | None = None query_branch_write: str | None = None From 1b58452769b6696a98aef43343ef81959592cd6f Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Thu, 14 May 2026 23:57:30 +0200 Subject: [PATCH 35/42] Dump workflow step content as raw json --- src/openhound_github/models/workflow_step.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py index 2714cf8..8ff014a 100644 --- a/src/openhound_github/models/workflow_step.py +++ b/src/openhound_github/models/workflow_step.py @@ -1,3 +1,4 @@ +import json from dataclasses import dataclass from typing import Any, ClassVar @@ -59,8 +60,7 @@ class GHWorkflowStepProperties(GHNodeProperties): 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 + contents: str | None = None job_node_id: str | None = None workflow_node_id: str | None = None repository_name: str | None = None @@ -115,6 +115,7 @@ class GHWorkflowStepProperties(GHNodeProperties): ) class WorkflowStep(BaseAsset): """One record from `workflow_steps` -> one GH_WorkflowStep node.""" + dlt_config: ClassVar[DltConfig] = {"return_validated_models": True} node_id: str @@ -160,8 +161,7 @@ def as_node(self) -> GHNode: action_ref=self.action_ref, is_pinned=self.is_pinned, run=self.run, - with_args=self.with_args, - contents=self.contents, + contents=json.dumps(self.contents), job_node_id=self.job_node_id, workflow_node_id=self.workflow_node_id, repository_name=self.repository_name, @@ -181,7 +181,9 @@ def _uses_secret_edges(self): kind=nk.REPO_SECRET, property_matchers=[ PropertyMatch(key="name", value=ref.name), - PropertyMatch(key="repository_id", value=self.repository_node_id), + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), ], ), properties=EdgeProperties(traversable=False), @@ -209,7 +211,9 @@ def _uses_variable_edges(self): kind=nk.REPO_VARIABLE, property_matchers=[ PropertyMatch(key="name", value=ref.name), - PropertyMatch(key="repository_id", value=self.repository_node_id), + PropertyMatch( + key="repository_id", value=self.repository_node_id + ), ], ), properties=EdgeProperties(traversable=False), From 66504290f74fb438cbea1714328548ff2ada926f Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 00:07:45 +0200 Subject: [PATCH 36/42] Flatten workflow job permissions --- src/openhound_github/models/workflow_job.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 5be7163..f884a46 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -50,7 +50,7 @@ class GHWorkflowJobProperties(GHNodeProperties): is_self_hosted: bool = False container: Any = None environment: str | None = None - permissions: Any = None + permissions: list[str] | None = None uses_reusable: str | None = None workflow_node_id: str | None = None repository_name: str | None = None @@ -150,6 +150,12 @@ class WorkflowJob(BaseAsset): 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( @@ -163,7 +169,7 @@ def as_node(self) -> GHNode: is_self_hosted=self.is_self_hosted, container=self.container, environment=self.environment, - permissions=self.permissions, + permissions=self._flatten_permissions, uses_reusable=self.uses_reusable, workflow_node_id=self.workflow_node_id, repository_name=self.repository_name, From 25aa7736f58064f7f0c6c1a2cf17acfb527d2ad4 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 00:29:48 +0200 Subject: [PATCH 37/42] Match with uppercase environmentid --- src/openhound_github/models/enterprise_team_organization.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhound_github/models/enterprise_team_organization.py b/src/openhound_github/models/enterprise_team_organization.py index 12d71ea..45ec9b5 100644 --- a/src/openhound_github/models/enterprise_team_organization.py +++ b/src/openhound_github/models/enterprise_team_organization.py @@ -61,7 +61,7 @@ def edges(self): end=ConditionalEdgePath( kind=nk.TEAM, property_matchers=[ - PropertyMatch(key="environmentid", value=self.node_id), + PropertyMatch(key="environmentid", value=self.node_id.upper()), PropertyMatch(key="slug", value=self.projected_slug), ], ), From e53d8d99e0fcfa65c66aacde930ab1bb5e931ce1 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 00:30:31 +0200 Subject: [PATCH 38/42] Match with uppercase environmentid --- src/openhound_github/models/workflow_job.py | 8 ++++++-- src/openhound_github/models/workflow_step.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/openhound_github/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index f884a46..f37bccc 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -203,7 +203,9 @@ def _uses_secret_edges(self): kind=nk.ORG_SECRET, property_matchers=[ PropertyMatch(key="name", value=ref.name), - PropertyMatch(key="environmentid", value=self.org_node_id), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), ], ), properties=EdgeProperties(traversable=False), @@ -233,7 +235,9 @@ def _uses_variable_edges(self): kind=nk.ORG_VARIABLE, property_matchers=[ PropertyMatch(key="name", value=ref.name), - PropertyMatch(key="environmentid", value=self.org_node_id), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), ], ), properties=EdgeProperties(traversable=False), diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py index 8ff014a..77cc7cd 100644 --- a/src/openhound_github/models/workflow_step.py +++ b/src/openhound_github/models/workflow_step.py @@ -195,7 +195,9 @@ def _uses_secret_edges(self): kind=nk.ORG_SECRET, property_matchers=[ PropertyMatch(key="name", value=ref.name), - PropertyMatch(key="environmentid", value=self.org_node_id), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), ], ), properties=EdgeProperties(traversable=False), @@ -225,7 +227,9 @@ def _uses_variable_edges(self): kind=nk.ORG_VARIABLE, property_matchers=[ PropertyMatch(key="name", value=ref.name), - PropertyMatch(key="environmentid", value=self.org_node_id), + PropertyMatch( + key="environmentid", value=self.org_node_id.upper() + ), ], ), properties=EdgeProperties(traversable=False), From feb1e463321abfb03bc846902881327e7dfa5ff1 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 02:08:58 +0200 Subject: [PATCH 39/42] Add conditions when to create secret edges --- src/openhound_github/lookup.py | 34 +++++++++-- src/openhound_github/main.py | 14 ++++- src/openhound_github/models/workflow_job.py | 59 ++++++++++---------- src/openhound_github/models/workflow_step.py | 58 +++++++++---------- 4 files changed, 103 insertions(+), 62 deletions(-) diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index 7dbb9e6..d01a775 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -124,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)""" @@ -237,3 +233,33 @@ 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], + ) diff --git a/src/openhound_github/main.py b/src/openhound_github/main.py index f3b6438..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: @@ -61,4 +61,14 @@ def preproc(ctx: PreProcContext): "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/workflow_job.py b/src/openhound_github/models/workflow_job.py index f37bccc..95c6d93 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -182,34 +182,37 @@ def as_node(self) -> GHNode: @property def _uses_secret_edges(self): for ref in self.secret_references: - 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), - ) - 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), - ) + 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): diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py index 77cc7cd..82e4f90 100644 --- a/src/openhound_github/models/workflow_step.py +++ b/src/openhound_github/models/workflow_step.py @@ -174,34 +174,36 @@ def as_node(self) -> GHNode: @property def _uses_secret_edges(self): for ref in self.secret_references: - 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), - ) - 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), - ) + 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): From 39db913b3cb1317e2cb58def63e1cf3fad964eb7 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 02:09:15 +0200 Subject: [PATCH 40/42] allow_missing_page_info=True --- src/openhound_github/resources/enterprise.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index e58b8f6..e980cc8 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -285,6 +285,7 @@ def enterprise_admins(enterprise_data: Enterprise, ctx: SourceContext): cursor_variable="after", cursor_field="endCursor", has_next_field="hasNextPage", + allow_missing_page_info=True, ) data = { "query": ENTERPRISE_ADMINS_QUERY, From aead4a0bed6d65a03e8cae566cc07b4377c442b2 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 02:18:37 +0200 Subject: [PATCH 41/42] Add conditions for variable matching --- src/openhound_github/lookup.py | 30 ++++++++++ src/openhound_github/models/workflow_job.py | 58 ++++++++++---------- src/openhound_github/models/workflow_step.py | 58 ++++++++++---------- 3 files changed, 90 insertions(+), 56 deletions(-) diff --git a/src/openhound_github/lookup.py b/src/openhound_github/lookup.py index d01a775..e2673c0 100644 --- a/src/openhound_github/lookup.py +++ b/src/openhound_github/lookup.py @@ -263,3 +263,33 @@ def environment_secret(self, secret_name: str, repository_id: str): """, [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/models/workflow_job.py b/src/openhound_github/models/workflow_job.py index 95c6d93..e250194 100644 --- a/src/openhound_github/models/workflow_job.py +++ b/src/openhound_github/models/workflow_job.py @@ -217,34 +217,36 @@ def _uses_secret_edges(self): @property def _uses_variable_edges(self): for ref in self.variable_references: - 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), - ) - 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), - ) + 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): diff --git a/src/openhound_github/models/workflow_step.py b/src/openhound_github/models/workflow_step.py index 82e4f90..ace5c66 100644 --- a/src/openhound_github/models/workflow_step.py +++ b/src/openhound_github/models/workflow_step.py @@ -208,34 +208,36 @@ def _uses_secret_edges(self): @property def _uses_variable_edges(self): for ref in self.variable_references: - 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), - ) - 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), - ) + 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): From 03f7899003b3fe8267580df7349d125ad592c029 Mon Sep 17 00:00:00 2001 From: Joey Dreijer Date: Fri, 15 May 2026 02:21:42 +0200 Subject: [PATCH 42/42] Handle no orgs when GraphQL returns None --- src/openhound_github/resources/enterprise.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openhound_github/resources/enterprise.py b/src/openhound_github/resources/enterprise.py index e980cc8..16f9408 100644 --- a/src/openhound_github/resources/enterprise.py +++ b/src/openhound_github/resources/enterprise.py @@ -69,7 +69,7 @@ def enterprise(ctx: SourceContext): name="enterprise_organizations", columns=EnterpriseOrganization, parallelized=True ) def enterprise_organizations(enterprise_data: Enterprise, ctx: SourceContext): - orgs = enterprise_data.organizations.get("nodes", []) + orgs = (enterprise_data.organizations or {}).get("nodes", []) for org in orgs: yield { **org,