diff --git a/CHANGELOG.md b/CHANGELOG.md index c28bdd1..3e3cc78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added support for teams. - Use asyncer to speed up retrieval of live settings. ([#209](https://github.com/eclipse-csi/otterdog/issues/209)) ### Changed diff --git a/DEPENDENCIES b/DEPENDENCIES index 19af7b9..1c36467 100644 --- a/DEPENDENCIES +++ b/DEPENDENCIES @@ -16,7 +16,7 @@ pypi/pypi/-/attrs/24.3.0 pypi/pypi/-/blinker/1.9.0 pypi/pypi/-/certifi/2024.12.14 pypi/pypi/-/cffi/1.17.1 -pypi/pypi/-/charset-normalizer/3.4.0 +pypi/pypi/-/charset-normalizer/3.4.1 pypi/pypi/-/chevron/0.14.0 pypi/pypi/-/click/8.1.8 pypi/pypi/-/colorama/0.4.6 @@ -78,7 +78,7 @@ pypi/pypi/-/semver/3.0.2 pypi/pypi/-/six/1.17.0 pypi/pypi/-/smmap/5.0.1 pypi/pypi/-/sniffio/1.3.1 -pypi/pypi/-/starlette/0.42.0 +pypi/pypi/-/starlette/0.45.1 pypi/pypi/-/text-unidecode/1.3 pypi/pypi/-/typing-extensions/4.12.2 pypi/pypi/-/url-normalize/1.4.3 diff --git a/README.md b/README.md index 54be03b..2e5ab75 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ Create a `otterdog.json` file with the following content (replace bracketed valu "api_token": "", "username": "", "password": "", - "2fa_seed": "<2FA TOTP seed>" + "twofa_seed": "<2FA TOTP seed>" } } ] @@ -199,7 +199,7 @@ required credential data: "api_token": "", "username": "", "password": "", - "2fa_seed": "" + "twofa_seed": "" } } ] diff --git a/docs/reference/organization/team.md b/docs/reference/organization/team.md new file mode 100644 index 0000000..45cc733 --- /dev/null +++ b/docs/reference/organization/team.md @@ -0,0 +1,42 @@ +Definition of an organization `Team`, the following properties are supported: + +| Key | Value | Description | Note | +|---------------------------------|--------------|--------------------------------------------------------------------------------------|-----------------------| +| _name_ | string | The name of the team | | +| _description_ | string | The description of the team | | +| _privacy_ | string | The level of privacy this team should have | `visible` or `secret` | +| _notifications_ | boolean | Whether the team members receive notifications when the team is @mentioned | | +| _members_ | list[string] | List of users that should be a member of the team | | +| _skip_members_ | boolean | If `true`, team members will be ignored | | +| _skip_non_organization_members_ | boolean | If `true`, users which are not yet organization members can not be added to the team | | + + +## Jsonnet Function + +``` jsonnet +orgs.newTeam('') { + : +} +``` + +## Validation rules + +- setting `privacy` must be one of `visible` or `secret`, any other value triggers an error +- specifying a non-empty list of `members` while `skip_members` is enabled, triggers an error +- specifying a user in `members` that is not yet an organization member while `skip_non_organization_members` is enabled, triggers an error + +## Example usage + +=== "jsonnet" + ``` jsonnet + orgs.newOrg('OtterdogTest') { + ... + teams+: [ + orgs.newTeam('committers') { + description: "The project committers", + privacy: "visible", + }, + ], + ... + } + ``` diff --git a/mkdocs.yml b/mkdocs.yml index 5b00c3f..2c7a587 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -55,6 +55,7 @@ nav: - Organization Variable: reference/organization/variable.md - Organization Ruleset: reference/organization/ruleset.md - Custom Property: reference/organization/custom-property.md + - Team: reference/organization/team.md - Repository: - reference/organization/repository/index.md - Workflow Settings: reference/organization/repository/workflow-settings.md diff --git a/otterdog/config.py b/otterdog/config.py index 3b56766..532e3e4 100644 --- a/otterdog/config.py +++ b/otterdog/config.py @@ -8,10 +8,12 @@ from __future__ import annotations +import dataclasses import json import os import re from abc import abstractmethod +from functools import cached_property from typing import TYPE_CHECKING, Any, Protocol from otterdog.credentials import CredentialProvider @@ -21,6 +23,8 @@ from .utils import deep_merge_dict, query_json if TYPE_CHECKING: + from collections.abc import Mapping + from otterdog.credentials import Credentials _logger = get_logger(__name__) @@ -133,45 +137,83 @@ def is_supported_secret_provider(self, provider_type: str) -> bool: ... def get_secret(self, data: str) -> str: ... -class OtterdogConfig(SecretResolver): - def __init__(self, config_file: str, local_mode: bool, working_dir: str | None = None): - if not os.path.exists(config_file): - raise RuntimeError(f"configuration file '{config_file}' not found") - - self._config_file = os.path.realpath(config_file) - self._config_dir = os.path.dirname(self._config_file) +class CredentialResolver(SecretResolver): + def __init__(self, config: OtterdogConfig) -> None: + self._config = config self._credential_providers: dict[str, CredentialProvider] = {} - self._local_mode = local_mode + def _get_credential_provider(self, provider_type: str) -> CredentialProvider | None: + provider = self._credential_providers.get(provider_type) + if provider is None: + provider = CredentialProvider.create( + provider_type, query_json(f"defaults.{provider_type}", self._config.configuration) or {} + ) + if provider is not None: + self._credential_providers[provider_type] = provider - with open(config_file) as f: - self._configuration = json.load(f) + return provider - if working_dir is None: - override_defaults_file = os.path.join(self._config_dir, ".otterdog-defaults.json") - if os.path.exists(override_defaults_file): - with open(override_defaults_file) as defaults_file: - defaults = json.load(defaults_file) - _logger.trace("loading default overrides from '%s'", override_defaults_file) - self._configuration["defaults"] = deep_merge_dict( - defaults, self._configuration.setdefault("defaults") - ) + def get_credentials(self, org_config: OrganizationConfig, only_token: bool = False) -> Credentials: + provider_type = org_config.credential_data.get("provider") - self._jsonnet_config = query_json("defaults.jsonnet", self._configuration) or {} - self._github_config = query_json("defaults.github", self._configuration) or {} - self._default_credential_provider = query_json("defaults.credentials.provider", self._configuration) or "" + if provider_type is None: + provider_type = self._config.default_credential_provider - if working_dir is None: - self._jsonnet_base_dir = os.path.join(self._config_dir, self._jsonnet_config.get("config_dir", "orgs")) + if not provider_type: + raise RuntimeError(f"no credential provider configured for organization '{org_config.name}'") + + provider = self._get_credential_provider(provider_type) + if provider is not None: + return provider.get_credentials(org_config.name, org_config.credential_data, only_token) + else: + raise RuntimeError(f"unsupported credential provider '{provider_type}'") + + def is_supported_secret_provider(self, provider_type: str) -> bool: + # TODO: make this cleaner + return provider_type in ["pass", "bitwarden"] + + def get_secret(self, secret_data: str) -> str: + if secret_data and ":" in secret_data: + provider_type, data = re.split(":", secret_data) + provider = self._get_credential_provider(provider_type) + if provider is not None: + return provider.get_secret(data) + else: + return secret_data else: - self._jsonnet_base_dir = os.path.join(working_dir, self._jsonnet_config.get("config_dir", "orgs")) - if not os.path.exists(self._jsonnet_base_dir): - os.makedirs(self._jsonnet_base_dir) + return secret_data + + +@dataclasses.dataclass(frozen=True) +class OtterdogConfig: + configuration: Mapping[str, Any] + local_mode: bool + working_dir: str + + _jsonnet_config: Mapping[str, Any] = dataclasses.field(init=False) + _github_config: Mapping[str, Any] = dataclasses.field(init=False) + _default_credential_provider: str = dataclasses.field(init=False) + _jsonnet_base_dir: str = dataclasses.field(init=False) - organizations = self._configuration.get("organizations", []) + _organizations_map: dict[str, OrganizationConfig] = dataclasses.field(init=False, default_factory=dict) + _organizations: list[OrganizationConfig] = dataclasses.field(init=False, default_factory=list) - self._organizations_map = {} - self._organizations = [] + def __post_init__(self): + object.__setattr__(self, "_jsonnet_config", query_json("defaults.jsonnet", self.configuration) or {}) + object.__setattr__(self, "_github_config", query_json("defaults.github", self.configuration) or {}) + object.__setattr__( + self, "_default_credential_provider", query_json("defaults.credentials.provider", self.configuration) or "" + ) + + object.__setattr__( + self, + "_jsonnet_base_dir", + os.path.join(self.working_dir, self._jsonnet_config.get("config_dir", "orgs")), + ) + if not os.path.exists(self._jsonnet_base_dir): + os.makedirs(self._jsonnet_base_dir) + + organizations = self.configuration.get("organizations", []) for org in organizations: org_config = OrganizationConfig.from_dict(org, self) self._organizations.append(org_config) @@ -179,24 +221,28 @@ def __init__(self, config_file: str, local_mode: bool, working_dir: str | None = self._organizations_map[org_config.github_id.lower()] = org_config @property - def config_file(self) -> str: - return self._config_file + def jsonnet_base_dir(self) -> str: + return self._jsonnet_base_dir @property - def config_dir(self) -> str: - return self._config_dir + def default_config_repo(self) -> str: + return self._github_config.get("config_repo", ".otterdog") @property - def jsonnet_base_dir(self) -> str: - return self._jsonnet_base_dir + def default_team_exclusions(self) -> list[str]: + return self._github_config.get("exclude_teams", []) - @property - def local_mode(self) -> bool: - return self._local_mode + @cached_property + def exclude_teams_pattern(self) -> re.Pattern | None: + team_exclusions = self.default_team_exclusions + if len(team_exclusions) == 0: + return None + else: + return re.compile("|".join(team_exclusions)) @property - def default_config_repo(self) -> str: - return self._github_config.get("config_repo", ".otterdog") + def default_credential_provider(self) -> str: + return self._default_credential_provider @property def default_base_template(self) -> str: @@ -231,50 +277,27 @@ def get_organization_config(self, project_or_organization_name: str) -> Organiza raise RuntimeError(f"unknown organization with name / github_id '{project_or_organization_name}'") return org_config - def _get_credential_provider(self, provider_type: str) -> CredentialProvider | None: - provider = self._credential_providers.get(provider_type) - if provider is None: - provider = CredentialProvider.create( - provider_type, query_json(f"defaults.{provider_type}", self._configuration) or {} - ) - if provider is not None: - self._credential_providers[provider_type] = provider - - return provider - - def get_credentials(self, org_config: OrganizationConfig, only_token: bool = False) -> Credentials: - provider_type = org_config.credential_data.get("provider") - - if provider_type is None: - provider_type = self._default_credential_provider - - if not provider_type: - raise RuntimeError(f"no credential provider configured for organization '{org_config.name}'") + @classmethod + def from_file(cls, config_file: str, local_mode: bool, working_dir: str | None = None) -> OtterdogConfig: + if not os.path.exists(config_file): + raise RuntimeError(f"configuration file '{config_file}' not found") - provider = self._get_credential_provider(provider_type) - if provider is not None: - return provider.get_credentials(org_config.name, org_config.credential_data, only_token) - else: - raise RuntimeError(f"unsupported credential provider '{provider_type}'") + config_file_file = os.path.realpath(config_file) + config_file_dir = os.path.dirname(config_file) - def is_supported_secret_provider(self, provider_type: str) -> bool: - # TODO: make this cleaner - return provider_type in ["pass", "bitwarden"] + with open(config_file_file) as f: + configuration = json.load(f) - def get_secret(self, secret_data: str) -> str: - if secret_data and ":" in secret_data: - provider_type, data = re.split(":", secret_data) - provider = self._get_credential_provider(provider_type) - if provider is not None: - return provider.get_secret(data) - else: - return secret_data - else: - return secret_data + if working_dir is None: + override_defaults_file = os.path.join(config_file_dir, ".otterdog-defaults.json") + if os.path.exists(override_defaults_file): + with open(override_defaults_file) as defaults_file: + defaults = json.load(defaults_file) + _logger.trace("loading default overrides from '%s'", override_defaults_file) + configuration["defaults"] = deep_merge_dict(defaults, configuration.setdefault("defaults", {})) - def __repr__(self): - return f"OtterdogConfig('{self.config_file}')" + return cls(configuration, local_mode, working_dir if working_dir is not None else config_file_dir) @classmethod - def from_file(cls, config_file: str, local_mode: bool): - return cls(config_file, local_mode) + def from_dict(cls, configuration: Mapping[str, Any], local_mode: bool, working_dir: str) -> OtterdogConfig: + return cls(configuration, local_mode, working_dir) diff --git a/otterdog/credentials/bitwarden_provider.py b/otterdog/credentials/bitwarden_provider.py index dcd3e2a..93a3042 100644 --- a/otterdog/credentials/bitwarden_provider.py +++ b/otterdog/credentials/bitwarden_provider.py @@ -26,8 +26,10 @@ class BitwardenVault(CredentialProvider): A class to provide convenient access to a bitwarden vault. """ - def __init__(self, api_token_key: str | None): - self._api_token_key = api_token_key or "api_token_admin" + _KEY_API_TOKEN = "api_token_admin" + + def __init__(self, api_token_key: str = _KEY_API_TOKEN): + self._api_token_key = api_token_key _logger.debug("unlocking bitwarden vault") self._status, output = subprocess.getstatusoutput("bw unlock --check") # noqa: S605, S607 diff --git a/otterdog/credentials/pass_provider.py b/otterdog/credentials/pass_provider.py index 89605ca..b44b222 100644 --- a/otterdog/credentials/pass_provider.py +++ b/otterdog/credentials/pass_provider.py @@ -28,7 +28,7 @@ class PassVault(CredentialProvider): KEY_API_TOKEN = "api_token" KEY_USERNAME = "username" KEY_PASSWORD = "password" - KEY_TWOFA_SEED = "2fa_seed" + KEY_TWOFA_SEED = "twofa_seed" def __init__( self, diff --git a/otterdog/credentials/plain_provider.py b/otterdog/credentials/plain_provider.py index 0efbcc6..5d93655 100644 --- a/otterdog/credentials/plain_provider.py +++ b/otterdog/credentials/plain_provider.py @@ -29,7 +29,7 @@ class PlainVault(CredentialProvider): KEY_API_TOKEN = "api_token" KEY_USERNAME = "username" KEY_PASSWORD = "password" - KEY_TWOFA_SEED = "2fa_seed" + KEY_TWOFA_SEED = "twofa_seed" def get_credentials(self, org_name: str, data: dict[str, Any], only_token: bool = False) -> Credentials: github_token = self._retrieve_key(self.KEY_API_TOKEN, data) diff --git a/otterdog/jsonnet.py b/otterdog/jsonnet.py index 1ae01b2..793942c 100644 --- a/otterdog/jsonnet.py +++ b/otterdog/jsonnet.py @@ -28,6 +28,7 @@ class JsonnetConfig: create_org = "newOrg" create_org_role = "newOrgRole" + create_org_team = "newTeam" create_org_custom_property = "newCustomProperty" create_org_webhook = "newOrgWebhook" create_org_secret = "newOrgSecret" @@ -126,6 +127,16 @@ def default_org_role_config(self): _logger.debug("no default org role config found, roles will be skipped") return None + @cached_property + def default_team_config(self): + try: + # load the default team config + team_snippet = f"(import '{self.template_file}').{self.create_org_team}('default')" + return jsonnet_evaluate_snippet(team_snippet) + except RuntimeError: + _logger.debug("no default team config found, teams will be skipped") + return None + @cached_property def default_org_custom_property_config(self): try: diff --git a/otterdog/logging.py b/otterdog/logging.py index 63bb011..a44965c 100644 --- a/otterdog/logging.py +++ b/otterdog/logging.py @@ -117,18 +117,28 @@ def print_exception(exc: Exception) -> None: from rich.traceback import Traceback - rich_tb = Traceback.from_exception( - type(exc), - exc, - exc.__traceback__, - show_locals=True, - suppress=[asyncio], - width=None, - ) - - CONSOLE_STDERR.print(rich_tb) + def get_rich_tb(exception: Exception) -> Traceback: + return Traceback.from_exception( + type(exception), + exception, + exception.__traceback__, + show_locals=is_trace_enabled(), + suppress=[asyncio], + width=None, + ) + + if isinstance(exc, ExceptionGroup): + CONSOLE_STDERR.print(get_rich_tb(exc)) + for nested_exception in exc.exceptions: + CONSOLE_STDERR.print(get_rich_tb(nested_exception)) + else: + CONSOLE_STDERR.print(get_rich_tb(exc)) else: - print_error(str(exc)) + if isinstance(exc, ExceptionGroup): + for nested_exception in exc.exceptions: + print_error(str(nested_exception)) + else: + print_error(str(exc)) def print_info(msg: str, console: Console = CONSOLE_STDOUT) -> None: diff --git a/otterdog/models/__init__.py b/otterdog/models/__init__.py index 97588d3..49ea044 100644 --- a/otterdog/models/__init__.py +++ b/otterdog/models/__init__.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence + from re import Pattern from otterdog.config import SecretResolver from otterdog.jsonnet import JsonnetConfig @@ -53,6 +54,8 @@ class ValidationContext: root_object: Any secret_resolver: SecretResolver template_dir: str + org_members: set[str] + exclude_teams_pattern: Pattern | None validation_failures: list[tuple[FailureType, str]] = dataclasses.field(default_factory=list) def add_failure(self, failure_type: FailureType, message: str): diff --git a/otterdog/models/github_organization.py b/otterdog/models/github_organization.py index bbe35a2..69033c5 100644 --- a/otterdog/models/github_organization.py +++ b/otterdog/models/github_organization.py @@ -44,10 +44,12 @@ from otterdog.models.repo_webhook import RepositoryWebhook from otterdog.models.repo_workflow_settings import RepositoryWorkflowSettings from otterdog.models.repository import Repository +from otterdog.models.team import Team from otterdog.utils import IndentingPrinter, associate_by_key, debug_times, jsonnet_evaluate_file if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable, Iterator + from re import Pattern from otterdog.config import JsonnetConfig, OtterdogConfig, SecretResolver from otterdog.providers.github import GitHubProvider @@ -67,6 +69,7 @@ class GitHubOrganization: github_id: str settings: OrganizationSettings roles: list[OrganizationRole] = dataclasses.field(default_factory=list) + teams: list[Team] = dataclasses.field(default_factory=list) webhooks: list[OrganizationWebhook] = dataclasses.field(default_factory=list) secrets: list[OrganizationSecret] = dataclasses.field(default_factory=list) variables: list[OrganizationVariable] = dataclasses.field(default_factory=list) @@ -88,6 +91,15 @@ def get_role(self, name: str) -> OrganizationRole | None: def set_roles(self, roles: list[OrganizationRole]) -> None: self.roles = roles + def add_team(self, team: Team) -> None: + self.teams.append(team) + + def get_team(self, name: str) -> Team | None: + return next(filter(lambda x: x.name == name, self.teams), None) # type: ignore + + def set_teams(self, teams: list[Team]) -> None: + self.teams = teams + def add_webhook(self, webhook: OrganizationWebhook) -> None: self.webhooks.append(webhook) @@ -133,8 +145,26 @@ def get_repository(self, repo_name: str) -> Repository | None: def set_repositories(self, repos: list[Repository]) -> None: self.repositories = repos - def validate(self, secret_resolver: SecretResolver, template_dir: str) -> ValidationContext: - context = ValidationContext(self, secret_resolver, template_dir) + async def validate( + self, + config: OtterdogConfig, + secret_resolver: SecretResolver, + template_dir: str, + provider: GitHubProvider, + ) -> ValidationContext: + # only retrieve the list of current organization members if there are teams defined + if len(self.teams) > 0: + org_members = {x["login"] for x in await provider.rest_api.org.list_members(self.github_id)} + else: + org_members = set() + + context = ValidationContext( + self, + secret_resolver, + template_dir, + org_members, + config.exclude_teams_pattern, + ) self.settings.validate(context, self) enterprise_plan = self.settings.plan == "enterprise" @@ -149,6 +179,9 @@ def validate(self, secret_resolver: SecretResolver, template_dir: str) -> Valida for role in self.roles: role.validate(context, self) + for team in self.teams: + team.validate(context, self) + for webhook in self.webhooks: webhook.validate(context, self) @@ -198,6 +231,10 @@ def get_model_objects(self) -> Iterator[tuple[ModelObject, ModelObject | None]]: yield role, None yield from role.get_model_objects() + for team in self.teams: + yield team, None + yield from team.get_model_objects() + for webhook in self.webhooks: yield webhook, None yield from webhook.get_model_objects() @@ -228,6 +265,7 @@ def from_model_data(cls, data: dict[str, Any]) -> GitHubOrganization: "github_id": S("github_id"), "settings": S("settings") >> F(lambda x: OrganizationSettings.from_model_data(x)), "roles": OptionalS("roles", default=[]) >> Forall(lambda x: OrganizationRole.from_model_data(x)), + "teams": OptionalS("teams", default=[]) >> Forall(lambda x: Team.from_model_data(x)), "webhooks": OptionalS("webhooks", default=[]) >> Forall(lambda x: OrganizationWebhook.from_model_data(x)), "secrets": OptionalS("secrets", default=[]) >> Forall(lambda x: OrganizationSecret.from_model_data(x)), "variables": OptionalS("variables", default=[]) @@ -304,6 +342,19 @@ def to_jsonnet(self, config: JsonnetConfig, context: PatchContext) -> str: printer.level_down() printer.println("],") + # print teams + if len(self.teams) > 0: + default_team = Team.from_model_data(config.default_team_config) + + printer.println("teams+: [") + printer.level_up() + + for team in self.teams: + team.to_jsonnet(printer, config, context, False, default_team) + + printer.level_down() + printer.println("],") + # print organization webhooks if len(self.webhooks) > 0: default_org_webhook = OrganizationWebhook.from_model_data(config.default_org_webhook_config) @@ -393,6 +444,7 @@ def generate_live_patch( self, current_organization: GitHubOrganization, context: LivePatchContext, handler: LivePatchHandler ) -> None: OrganizationRole.generate_live_patch_of_list(self.roles, current_organization.roles, None, context, handler) + Team.generate_live_patch_of_list(self.teams, current_organization.teams, None, context, handler) OrganizationSettings.generate_live_patch(self.settings, current_organization.settings, None, context, handler) OrganizationWebhook.generate_live_patch_of_list( self.webhooks, current_organization.webhooks, None, context, handler @@ -411,13 +463,7 @@ def generate_live_patch( ) @classmethod - def load_from_file( - cls, - github_id: str, - config_file: str, - config: OtterdogConfig, - resolve_secrets: bool = False, - ) -> GitHubOrganization: + def load_from_file(cls, github_id: str, config_file: str) -> GitHubOrganization: if not os.path.exists(config_file): msg = f"configuration file '{config_file}' for organization '{github_id}' does not exist" raise RuntimeError(msg) @@ -425,12 +471,7 @@ def load_from_file( _logger.debug("loading configuration for organization '%s' from file '%s'", github_id, config_file) data = jsonnet_evaluate_file(config_file) - org = cls.from_model_data(data) - - if resolve_secrets: - org.resolve_secrets(config.get_secret) - - return org + return cls.from_model_data(data) @classmethod async def load_from_provider( @@ -442,6 +483,7 @@ async def load_from_provider( no_web_ui: bool = False, concurrency: int | None = None, repo_filter: str | None = None, + exclude_teams: Pattern | None = None, ) -> GitHubOrganization: import asyncer @@ -479,6 +521,19 @@ async def _load_roles() -> None: else: _logger.debug("not reading org webhooks, no default config available") + @debug_times("teams") + async def _load_teams() -> None: + if jsonnet_config.default_team_config is not None: + github_teams = await provider.get_org_teams(github_id) + for team in github_teams: + if exclude_teams is not None and exclude_teams.match(team["slug"]): + continue + team_members = await provider.get_org_team_members(github_id, team["slug"]) + team["members"] = team_members + org.add_team(Team.from_provider_data(github_id, team)) + else: + _logger.debug("not reading teams, no default config available") + @debug_times("webhooks") async def _load_webhooks() -> None: if jsonnet_config.default_org_webhook_config is not None: @@ -532,6 +587,7 @@ async def _load_repos() -> None: async with asyncer.create_task_group() as task_group: task_group.soonify(_load_roles)() + task_group.soonify(_load_teams)() task_group.soonify(_load_webhooks)() task_group.soonify(_load_secrets)() task_group.soonify(_load_variables)() diff --git a/otterdog/models/team.py b/otterdog/models/team.py new file mode 100644 index 0000000..edf4fb6 --- /dev/null +++ b/otterdog/models/team.py @@ -0,0 +1,162 @@ +# ******************************************************************************* +# Copyright (c) 2023-2024 Eclipse Foundation and others. +# This program and the accompanying materials are made available +# under the terms of the Eclipse Public License 2.0 +# which is available at http://www.eclipse.org/legal/epl-v20.html +# SPDX-License-Identifier: EPL-2.0 +# ******************************************************************************* + +from __future__ import annotations + +import abc +import dataclasses +from typing import TYPE_CHECKING, Any, TypeVar + +from jsonbender import F, Forall, OptionalS, S # type: ignore + +from otterdog.models import ( + FailureType, + LivePatch, + LivePatchType, + ModelObject, + ValidationContext, +) +from otterdog.utils import UNSET, is_set_and_valid, unwrap + +if TYPE_CHECKING: + from otterdog.jsonnet import JsonnetConfig + from otterdog.providers.github import GitHubProvider + +TT = TypeVar("TT", bound="Team") + + +@dataclasses.dataclass +class Team(ModelObject, abc.ABC): + """ + Represents a Team. + """ + + id: int = dataclasses.field(metadata={"external_only": True}) + slug: str = dataclasses.field(metadata={"external_only": True}) + name: str = dataclasses.field(metadata={"key": True}) + description: str + privacy: str + notifications: bool + members: list[str] + skip_members: bool = dataclasses.field(metadata={"model_only": True}, default=False) + skip_non_organization_members: bool = dataclasses.field(metadata={"model_only": True}, default=False) + + @property + def model_object_name(self) -> str: + return "team" + + def get_jsonnet_template_function(self, jsonnet_config: JsonnetConfig, extend: bool) -> str | None: + return f"orgs.{jsonnet_config.create_org_team}" + + def include_field_for_diff_computation(self, field: dataclasses.Field) -> bool: + if field.name == "members": + return not self.skip_members + + return True + + def validate(self, context: ValidationContext, parent_object: Any) -> None: + # execute custom validation rules if present + self.execute_custom_validation_if_present(context, "validate-team.py") + + if context.exclude_teams_pattern is not None and context.exclude_teams_pattern.match(self.name): + context.add_failure( + FailureType.ERROR, + f"{self.get_model_header(parent_object)} has 'name' of value '{self.name}', " + f"which is not allowed due to exclusion pattern '{context.exclude_teams_pattern.pattern}'.", + ) + + if is_set_and_valid(self.privacy): + if self.privacy not in {"secret", "visible"}: + context.add_failure( + FailureType.ERROR, + f"{self.get_model_header(parent_object)} has 'privacy' of value '{self.privacy}', " + f"while only values ('secret' | 'closed') are allowed.", + ) + + if self.skip_members is True and is_set_and_valid(self.members) and len(self.members) > 0: + context.add_failure( + FailureType.ERROR, + f"{self.get_model_header(parent_object)} has 'skip_members' enabled, " + f"but 'members' is set to {self.members}.", + ) + + if is_set_and_valid(self.members) and self.skip_non_organization_members is True: + for member in self.members: + if member not in context.org_members: + context.add_failure( + FailureType.ERROR, + f"{self.get_model_header(parent_object)} has 'skip_non_organization_members' enabled, " + f"but 'members' contains user '{member}' who is not an organization member.", + ) + + @classmethod + def get_mapping_from_provider(cls, org_id: str, data: dict[str, Any]) -> dict[str, Any]: + mapping = super().get_mapping_from_provider(org_id, data) + + def transform_notification_setting(x: str | None): + if x is None: + return UNSET + elif x == "notifications_enabled": + return True + else: + return False + + def transform_team_members(member): + return member["login"] + + mapping.update( + { + "privacy": OptionalS("privacy") >> F(lambda x: "visible" if x == "closed" else x), + "notifications": OptionalS("notification_setting") >> F(transform_notification_setting), + "members": OptionalS("members", default=[]) >> Forall(transform_team_members), + } + ) + return mapping + + @classmethod + async def get_mapping_to_provider( + cls, org_id: str, data: dict[str, Any], provider: GitHubProvider + ) -> dict[str, Any]: + mapping = await super().get_mapping_to_provider(org_id, data, provider) + + if "privacy" in data: + mapping["privacy"] = S("privacy") >> F(lambda x: "closed" if x == "visible" else x) + + if "notifications" in data: + mapping["notification_setting"] = S("notifications") >> F( + lambda x: "notifications_enabled" if x is True else "notifications_disabled" + ) + mapping.pop("notifications") + + return mapping + + @classmethod + async def apply_live_patch( + cls, + patch: LivePatch[Team], + org_id: str, + provider: GitHubProvider, + ) -> None: + match patch.patch_type: + case LivePatchType.ADD: + expected_object = unwrap(patch.expected_object) + await provider.add_org_team( + org_id, + expected_object.name, + await expected_object.to_provider_data(org_id, provider), + ) + + case LivePatchType.REMOVE: + await provider.delete_org_team(org_id, unwrap(patch.current_object).slug) + + case LivePatchType.CHANGE: + await provider.update_org_team( + org_id, + unwrap(patch.current_object).slug, + await cls.changes_to_provider(org_id, unwrap(patch.changes), provider), + ) diff --git a/otterdog/operations/__init__.py b/otterdog/operations/__init__.py index 7531929..f12d348 100644 --- a/otterdog/operations/__init__.py +++ b/otterdog/operations/__init__.py @@ -9,14 +9,17 @@ from __future__ import annotations from abc import ABC, abstractmethod +from functools import cached_property from typing import TYPE_CHECKING +from otterdog.config import CredentialResolver from otterdog.utils import Change, IndentingPrinter, unwrap if TYPE_CHECKING: from typing import Any from otterdog.config import OrganizationConfig, OtterdogConfig + from otterdog.credentials import Credentials class Operation(ABC): @@ -34,6 +37,13 @@ def init(self, config: OtterdogConfig, printer: IndentingPrinter) -> None: def config(self) -> OtterdogConfig: return unwrap(self._config) + @cached_property + def credential_resolver(self) -> CredentialResolver: + return CredentialResolver(self.config) + + def get_credentials(self, org_config: OrganizationConfig, only_token: bool = False) -> Credentials: + return self.credential_resolver.get_credentials(org_config, only_token) + @property def printer(self) -> IndentingPrinter: return unwrap(self._printer) @@ -345,7 +355,8 @@ def _print_modified_list_internal(self, current_value, expected_value, prefix: s for _ in range(i1, min(diff_i, diff_j)): self.printer.println( - f"{prefix}{self._get_value(a[i]).ljust(max_length, ' ')} [{color}]->[/] {self._get_value(b[j])}" + f"{prefix}{self._get_value(a[i]).ljust(max_length, ' ')} [{color}]->[/] " + f"{self._get_value(b[j])}" ) j = j + 1 i = i + 1 diff --git a/otterdog/operations/canonical_diff.py b/otterdog/operations/canonical_diff.py index d531b8f..7170721 100644 --- a/otterdog/operations/canonical_diff.py +++ b/otterdog/operations/canonical_diff.py @@ -52,7 +52,7 @@ async def execute( return 1 try: - organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + organization = GitHubOrganization.load_from_file(github_id, org_file_name) except RuntimeError as ex: self.printer.print_error(f"failed to load configuration: {ex!s}") return 1 diff --git a/otterdog/operations/check_token_permissions.py b/otterdog/operations/check_token_permissions.py index 895cc18..db5302b 100644 --- a/otterdog/operations/check_token_permissions.py +++ b/otterdog/operations/check_token_permissions.py @@ -47,7 +47,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/delete_file.py b/otterdog/operations/delete_file.py index ea1505e..a526e9f 100644 --- a/otterdog/operations/delete_file.py +++ b/otterdog/operations/delete_file.py @@ -65,13 +65,13 @@ async def execute( return 1 try: - organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + organization = GitHubOrganization.load_from_file(github_id, org_file_name) except RuntimeError as ex: self.printer.print_error(f"failed to load configuration: {ex!s}") return 1 try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/diff_operation.py b/otterdog/operations/diff_operation.py index ae18ade..bd146d8 100644 --- a/otterdog/operations/diff_operation.py +++ b/otterdog/operations/diff_operation.py @@ -120,7 +120,7 @@ async def execute( await self._gh_client.close() def setup_github_client(self, org_config: OrganizationConfig) -> GitHubProvider: - return GitHubProvider(self.config.get_credentials(org_config, only_token=self.no_web_ui)) + return GitHubProvider(self.get_credentials(org_config, only_token=self.no_web_ui)) @property def gh_client(self) -> GitHubProvider: @@ -161,7 +161,7 @@ async def generate_diff(self, org_config: OrganizationConfig) -> int: validation_infos, validation_warnings, validation_errors, - ) = self._validator.validate(expected_org, jsonnet_config.template_dir) + ) = await self._validator.validate(expected_org, jsonnet_config.template_dir, self.gh_client) if validation_errors > 0: self.printer.println("Planning aborted due to validation errors.") return validation_errors @@ -222,7 +222,7 @@ def handle(patch: LivePatch) -> None: if self.resolve_secrets(): for live_patch in live_patches: if live_patch.expected_object is not None: - live_patch.expected_object.resolve_secrets(self.config.get_secret) + live_patch.expected_object.resolve_secrets(self.credential_resolver.get_secret) status = await self.handle_finish(github_id, diff_status, live_patches) @@ -232,7 +232,7 @@ def handle(patch: LivePatch) -> None: return status def load_expected_org(self, github_id: str, org_file_name: str) -> GitHubOrganization: - return GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + return GitHubOrganization.load_from_file(github_id, org_file_name) def coerce_current_org(self) -> bool: return False @@ -241,7 +241,14 @@ async def load_current_org( self, project_name: str, github_id: str, jsonnet_config: JsonnetConfig ) -> GitHubOrganization: return await GitHubOrganization.load_from_provider( - project_name, github_id, jsonnet_config, self.gh_client, self.no_web_ui, self.concurrency, self.repo_filter + project_name, + github_id, + jsonnet_config, + self.gh_client, + self.no_web_ui, + self.concurrency, + self.repo_filter, + exclude_teams=self.config.exclude_teams_pattern, ) def preprocess_orgs( diff --git a/otterdog/operations/dispatch_workflow.py b/otterdog/operations/dispatch_workflow.py index 13df63c..da8c9a4 100644 --- a/otterdog/operations/dispatch_workflow.py +++ b/otterdog/operations/dispatch_workflow.py @@ -52,7 +52,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/fetch_config.py b/otterdog/operations/fetch_config.py index 0c0d7a9..04c89b8 100644 --- a/otterdog/operations/fetch_config.py +++ b/otterdog/operations/fetch_config.py @@ -70,7 +70,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/import_configuration.py b/otterdog/operations/import_configuration.py index 04877d3..c4265d6 100644 --- a/otterdog/operations/import_configuration.py +++ b/otterdog/operations/import_configuration.py @@ -75,7 +75,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config) + credentials = self.get_credentials(org_config) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 @@ -88,12 +88,17 @@ async def execute( async with GitHubProvider(credentials) as provider: organization = await GitHubOrganization.load_from_provider( - org_config.name, github_id, jsonnet_config, provider, self.no_web_ui + org_config.name, + github_id, + jsonnet_config, + provider, + self.no_web_ui, + exclude_teams=self.config.exclude_teams_pattern, ) # copy secrets from existing configuration if it is present. if sync_from_previous_config: - previous_organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + previous_organization = GitHubOrganization.load_from_file(github_id, org_file_name) self.printer.println("Copying secrets from previous configuration.") organization.copy_secrets(previous_organization) diff --git a/otterdog/operations/install_app.py b/otterdog/operations/install_app.py index 31ccb89..9974575 100644 --- a/otterdog/operations/install_app.py +++ b/otterdog/operations/install_app.py @@ -52,7 +52,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config) + credentials = self.get_credentials(org_config) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/list_advisories.py b/otterdog/operations/list_advisories.py index 03d01a4..c76cbb8 100644 --- a/otterdog/operations/list_advisories.py +++ b/otterdog/operations/list_advisories.py @@ -65,7 +65,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/list_apps.py b/otterdog/operations/list_apps.py index a9a3d22..fef05ae 100644 --- a/otterdog/operations/list_apps.py +++ b/otterdog/operations/list_apps.py @@ -57,7 +57,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/list_members.py b/otterdog/operations/list_members.py index e6a3eae..bd7be4d 100644 --- a/otterdog/operations/list_members.py +++ b/otterdog/operations/list_members.py @@ -63,13 +63,13 @@ async def execute( return 1 try: - organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + organization = GitHubOrganization.load_from_file(github_id, org_file_name) except RuntimeError as ex: self.printer.print_error(f"failed to load configuration: {ex!s}") return 1 try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/local_apply.py b/otterdog/operations/local_apply.py index 9549208..7a9c947 100644 --- a/otterdog/operations/local_apply.py +++ b/otterdog/operations/local_apply.py @@ -76,7 +76,7 @@ async def load_current_org( if not await ospath.exists(other_org_file_name): raise RuntimeError(f"configuration file '{other_org_file_name}' does not exist") - github_organization = GitHubOrganization.load_from_file(github_id, other_org_file_name, self.config) + github_organization = GitHubOrganization.load_from_file(github_id, other_org_file_name) if self.no_web_ui is True: github_organization.unset_settings_requiring_web_ui() diff --git a/otterdog/operations/local_plan.py b/otterdog/operations/local_plan.py index c601eda..c8be248 100644 --- a/otterdog/operations/local_plan.py +++ b/otterdog/operations/local_plan.py @@ -57,12 +57,12 @@ def verbose_output(self): def resolve_secrets(self) -> bool: return False - def setup_github_client(self, org_config: OrganizationConfig) -> GitHubProvider: - return GitHubProvider(None) - def coerce_current_org(self) -> bool: return True + def setup_github_client(self, org_config: OrganizationConfig) -> GitHubProvider: + return GitHubProvider(self.get_credentials(org_config, only_token=True)) + async def load_current_org( self, project_name: str, github_id: str, jsonnet_config: JsonnetConfig ) -> GitHubOrganization: @@ -71,7 +71,7 @@ async def load_current_org( if not await ospath.exists(other_org_file_name): raise RuntimeError(f"configuration file '{other_org_file_name}' does not exist") - return GitHubOrganization.load_from_file(github_id, other_org_file_name, self.config) + return GitHubOrganization.load_from_file(github_id, other_org_file_name) def preprocess_orgs( self, expected_org: GitHubOrganization, current_org: GitHubOrganization diff --git a/otterdog/operations/open_pull_request.py b/otterdog/operations/open_pull_request.py index edc557c..b8b75d4 100644 --- a/otterdog/operations/open_pull_request.py +++ b/otterdog/operations/open_pull_request.py @@ -67,7 +67,7 @@ async def execute( return 1 try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as ex: self.printer.print_error(f"invalid credentials\n{ex!s}") return 1 diff --git a/otterdog/operations/push_config.py b/otterdog/operations/push_config.py index 2c2c0ad..f51256e 100644 --- a/otterdog/operations/push_config.py +++ b/otterdog/operations/push_config.py @@ -71,7 +71,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 @@ -80,7 +80,7 @@ async def execute( # if no configuration can be found, omit adding author information try: - git_config_reader = Repo(self.config.config_dir).config_reader() + git_config_reader = Repo(self.config.working_dir).config_reader() except InvalidGitRepositoryError: # if the config dir is not a git repo, just read the global config git_config_reader = GitConfigParser(None, read_only=True) diff --git a/otterdog/operations/review_app_permissions.py b/otterdog/operations/review_app_permissions.py index 8ff9334..d3a6a1c 100644 --- a/otterdog/operations/review_app_permissions.py +++ b/otterdog/operations/review_app_permissions.py @@ -60,7 +60,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config, only_token=False) + credentials = self.get_credentials(org_config, only_token=False) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/show.py b/otterdog/operations/show.py index 5ae7bfa..75e0c8f 100644 --- a/otterdog/operations/show.py +++ b/otterdog/operations/show.py @@ -68,7 +68,7 @@ async def execute( return 1 try: - organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + organization = GitHubOrganization.load_from_file(github_id, org_file_name) except RuntimeError as ex: self.printer.print_error(f"failed to load configuration: {ex!s}") return 1 diff --git a/otterdog/operations/show_live.py b/otterdog/operations/show_live.py index 58b71c2..dc8b0be 100644 --- a/otterdog/operations/show_live.py +++ b/otterdog/operations/show_live.py @@ -50,7 +50,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config) + credentials = self.get_credentials(org_config) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 @@ -63,7 +63,12 @@ async def execute( ) organization = await GitHubOrganization.load_from_provider( - org_config.name, github_id, jsonnet_config, provider, self.no_web_ui + org_config.name, + github_id, + jsonnet_config, + provider, + self.no_web_ui, + exclude_teams=self.config.exclude_teams_pattern, ) for model_object, parent_object in organization.get_model_objects(): diff --git a/otterdog/operations/sync_template.py b/otterdog/operations/sync_template.py index 9171f33..7c478cb 100644 --- a/otterdog/operations/sync_template.py +++ b/otterdog/operations/sync_template.py @@ -55,13 +55,13 @@ async def execute( return 1 try: - organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + organization = GitHubOrganization.load_from_file(github_id, org_file_name) except RuntimeError as ex: self.printer.print_error(f"failed to load configuration: {ex!s}") return 1 try: - credentials = self.config.get_credentials(org_config, only_token=True) + credentials = self.get_credentials(org_config, only_token=True) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/uninstall_app.py b/otterdog/operations/uninstall_app.py index 77e0aae..317f4ea 100644 --- a/otterdog/operations/uninstall_app.py +++ b/otterdog/operations/uninstall_app.py @@ -52,7 +52,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config) + credentials = self.get_credentials(org_config) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/operations/validate.py b/otterdog/operations/validate.py index 58ae232..cdd965d 100644 --- a/otterdog/operations/validate.py +++ b/otterdog/operations/validate.py @@ -13,6 +13,7 @@ from otterdog.logging import is_info_enabled from otterdog.models import FailureType from otterdog.models.github_organization import GitHubOrganization +from otterdog.providers.github import GitHubProvider from . import Operation @@ -50,15 +51,22 @@ async def execute( return 1 try: - organization = GitHubOrganization.load_from_file(github_id, org_file_name, self.config) + organization = GitHubOrganization.load_from_file(github_id, org_file_name) except RuntimeError as ex: self.printer.print_error(f"Validation failed\nfailed to load configuration: {ex!s}") return 1 - validation_infos, validation_warnings, validation_errors = self.validate( - organization, jsonnet_config.template_dir - ) - validation_count = validation_infos + validation_warnings + validation_errors + try: + credentials = self.get_credentials(org_config, only_token=True) + except RuntimeError as e: + self.printer.print_error(f"invalid credentials\n{e!s}") + return 1 + + async with GitHubProvider(credentials) as provider: + validation_infos, validation_warnings, validation_errors = await self.validate( + organization, jsonnet_config.template_dir, provider + ) + validation_count = validation_infos + validation_warnings + validation_errors if validation_count == 0: self.printer.println("[green]Validation succeeded[/]") @@ -87,11 +95,16 @@ async def execute( finally: self.printer.level_down() - def validate(self, organization: GitHubOrganization, template_dir: str) -> tuple[int, int, int]: + async def validate( + self, + organization: GitHubOrganization, + template_dir: str, + provider: GitHubProvider, + ) -> tuple[int, int, int]: if organization.secrets_resolved is True: raise RuntimeError("validation requires an unresolved model.") - context = organization.validate(self.config, template_dir) + context = await organization.validate(self.config, self.credential_resolver, template_dir, provider) validation_infos = 0 validation_warnings = 0 diff --git a/otterdog/operations/web_login.py b/otterdog/operations/web_login.py index f3654be..155fb2d 100644 --- a/otterdog/operations/web_login.py +++ b/otterdog/operations/web_login.py @@ -42,7 +42,7 @@ async def execute( try: try: - credentials = self.config.get_credentials(org_config) + credentials = self.get_credentials(org_config) except RuntimeError as e: self.printer.print_error(f"invalid credentials\n{e!s}") return 1 diff --git a/otterdog/providers/github/__init__.py b/otterdog/providers/github/__init__.py index 1ec2dbe..8efb296 100644 --- a/otterdog/providers/github/__init__.py +++ b/otterdog/providers/github/__init__.py @@ -160,6 +160,21 @@ async def update_org_custom_role(self, org_id: str, role_id: int, role_name: str async def delete_org_custom_role(self, org_id: str, role_id: int, role_name: str) -> None: await self.rest_api.org.delete_custom_role(org_id, role_id, role_name) + async def get_org_teams(self, org_id: str) -> list[dict[str, Any]]: + return await self.rest_api.org.get_teams(org_id) + + async def get_org_team_members(self, org_id: str, team_slug: str) -> list[dict[str, Any]]: + return await self.rest_api.org.get_team_members(org_id, team_slug) + + async def add_org_team(self, org_id: str, team_name: str, data: dict[str, str]) -> None: + return await self.rest_api.org.add_team(org_id, team_name, data) + + async def update_org_team(self, org_id: str, team_slug: str, data: dict[str, str]) -> None: + return await self.rest_api.org.update_team(org_id, team_slug, data) + + async def delete_org_team(self, org_id: str, team_slug: str) -> None: + return await self.rest_api.org.delete_team(org_id, team_slug) + async def get_org_custom_properties(self, org_id: str) -> list[dict[str, Any]]: return await self.rest_api.org.get_custom_properties(org_id) diff --git a/otterdog/providers/github/rest/org_client.py b/otterdog/providers/github/rest/org_client.py index 9d57a7f..fc64e24 100644 --- a/otterdog/providers/github/rest/org_client.py +++ b/otterdog/providers/github/rest/org_client.py @@ -491,6 +491,95 @@ async def get_teams(self, org_id: str) -> list[dict[str, Any]]: except GitHubException as ex: raise RuntimeError(f"failed retrieving teams for org '{org_id}':\n{ex}") from ex + async def get_team_members(self, org_id: str, team_slug: str) -> list[dict[str, Any]]: + _logger.debug("retrieving team members for team '%s/%s'", org_id, team_slug) + + try: + return await self.requester.request_paged_json("GET", f"/orgs/{org_id}/teams/{team_slug}/members") + except GitHubException as ex: + raise RuntimeError(f"failed retrieving team members for team '{org_id}/{team_slug}':\n{ex}") from ex + + async def add_team(self, org_id: str, team_name: str, data: dict[str, str]) -> None: + _logger.debug("adding team '%s' for org '%s'", team_name, org_id) + + status, body = await self.requester.request_raw("POST", f"/orgs/{org_id}/teams", json.dumps(data)) + + if status != 201: + raise RuntimeError(f"failed to add team '{team_name}': {body}") + + if "members" in data: + team_data = json.loads(body) + team_slug = team_data["slug"] + members = data["members"] + for user in members: + await self.add_member_to_team(org_id, team_slug, user) + + _logger.debug("added team '%s'", team_name) + + async def update_team(self, org_id: str, team_slug: str, team: dict[str, Any]) -> None: + _logger.debug("updating team '%s' for org '%s'", team_slug, org_id) + + try: + await self.requester.request_json("PATCH", f"/orgs/{org_id}/teams/{team_slug}", team) + + if "members" in team: + await self.update_team_members(org_id, team_slug, team["members"]) + + _logger.debug("updated team '%s'", team_slug) + except GitHubException as ex: + raise RuntimeError(f"failed to update team '{team_slug}':\n{ex}") from ex + + async def update_team_members(self, org_id: str, team_slug: str, members: list[str]) -> None: + _logger.debug("updating team members for team '%s' in org '%s'", team_slug, org_id) + + current_members = {x["login"] for x in await self.get_team_members(org_id, team_slug)} + + # first, add all users that are not members yet. + for member in members: + if member in current_members: + current_members.remove(member) + else: + await self.add_member_to_team(org_id, team_slug, member) + + # second, remove the current members that are remaining. + for member in current_members: + await self.remove_member_from_team(org_id, team_slug, member) + + async def add_member_to_team(self, org_id: str, team_slug: str, user: str) -> None: + _logger.debug("adding user with id '%s' to team '%s' in org '%s'", user, team_slug, org_id) + + status, body = await self.requester.request_raw("PUT", f"/orgs/{org_id}/teams/{team_slug}/memberships/{user}") + + if status == 200: + _logger.debug("added user '%s' to team '%s' for org '%s'", user, team_slug, org_id) + else: + raise RuntimeError( + f"failed adding user '{user}' to team '{team_slug}' in org '{org_id}'" f"\n{status}: {body}" + ) + + async def remove_member_from_team(self, org_id: str, team_slug: str, user: str) -> None: + _logger.debug("removing user '%s' from team '%s' in org '%s'", user, team_slug, org_id) + + status, body = await self.requester.request_raw( + "DELETE", f"/orgs/{org_id}/teams/{team_slug}/memberships/{user}" + ) + if status != 204: + raise RuntimeError( + f"failed removing user '{user}' from team '{team_slug}' in org '{org_id}'" f"\n{status}: {body}" + ) + + _logger.debug("removed user '%s' from team '%s' in org '%s'", user, team_slug, org_id) + + async def delete_team(self, org_id: str, team_slug: str) -> None: + _logger.debug("deleting team '%s' for org '%s'", team_slug, org_id) + + status, body = await self.requester.request_raw("DELETE", f"/orgs/{org_id}/team/{team_slug}") + + if status != 204: + raise RuntimeError(f"failed to delete team '{team_slug}': {body}") + + _logger.debug("removed team '%s'", team_slug) + async def get_membership(self, org_id: str, user_name: str) -> dict[str, Any]: _logger.debug("retrieving membership for user '%s' in org '%s'", user_name, org_id) @@ -658,7 +747,7 @@ async def _disable_default_code_security_configurations(self, org_id: str) -> No f"failed disabling default code security configuration with id {configuration_id}:\n{ex}" ) from ex - async def list_members(self, org_id: str, two_factor_disabled: bool) -> list[dict[str, Any]]: + async def list_members(self, org_id: str, two_factor_disabled: bool = False) -> list[dict[str, Any]]: _logger.debug("retrieving list of org members for org '%s'", org_id) try: diff --git a/otterdog/providers/github/web.py b/otterdog/providers/github/web.py index dfa82d6..62ed357 100644 --- a/otterdog/providers/github/web.py +++ b/otterdog/providers/github/web.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2023-2024 Eclipse Foundation and others. +# Copyright (c) 2023-2025 Eclipse Foundation and others. # This program and the accompanying materials are made available # under the terms of the Eclipse Public License 2.0 # which is available at http://www.eclipse.org/legal/epl-v20.html @@ -31,7 +31,7 @@ class WebClient: # use 10s as default timeout - _DEFAULT_TIMEOUT = 15000 + _DEFAULT_TIMEOUT = 10000 def __init__(self, credentials: Credentials): self.credentials = credentials @@ -405,8 +405,7 @@ async def get_logged_in_page(self): await context_manager.__aexit__() - @staticmethod - async def get_requested_permission_updates(org_id: str, page: Page) -> dict[str, dict[str, str]]: + async def get_requested_permission_updates(self, org_id: str, page: Page) -> dict[str, dict[str, str]]: _logger.debug("getting GitHub app permission updates for '%s'", org_id) await page.goto(f"https://github.com/organizations/{org_id}/settings/installations") @@ -426,6 +425,9 @@ async def get_requested_permission_updates(org_id: str, page: Page) -> dict[str, for url in urls: await page.goto(f"https://github.com{url}") + if await page.title() == "Confirm access": + await page.type("#app_totp", self.credentials.totp) + m = re.search( r"/organizations/([a-zA-Z0-9-]+)/settings/installations/(\d+)/permissions/update", url, @@ -437,10 +439,18 @@ async def get_requested_permission_updates(org_id: str, page: Page) -> dict[str, await page.locator(".Box") .locator(".Box-row") .locator("visible=true") - .filter(has=page.locator("span")) + .filter(has=page.locator("span"), has_not=page.locator("svg")) .all() ) + if len(permissions) == 0: + if is_debug_enabled(): + screenshot_file = f"screenshot_get_permissions_{org_id}.png" + await page.screenshot(path=screenshot_file) + _logger.warning(f"saved page screenshot to file '{screenshot_file}'") + + _logger.warning(f"no permissions found when reviewing requested permission updates at url '{url}'") + permissions_by_app: dict[str, str] = {} for permission in permissions: permission_text = await permission.inner_text() @@ -471,7 +481,7 @@ async def get_requested_permission_updates(org_id: str, page: Page) -> dict[str, if new_access_type: permissions_by_app[permission_type] = new_access_type else: - pass + _logger.debug(f"unmatched permission: {permission_text}, url: {url}") requested_app_permissions[str(installation_id)] = permissions_by_app @@ -484,14 +494,26 @@ async def approve_requested_permission_updates(org_id: str, installation_id: str async def accept_dialog(dialog): await dialog.accept() - page.on("dialog", accept_dialog) + try: + page.on("dialog", accept_dialog) - await page.goto( - f"https://github.com/organizations/{org_id}/settings/installations/{installation_id}/permissions/update" - ) + await page.goto( + f"https://github.com/organizations/{org_id}/settings/installations/{installation_id}/permissions/update" + ) + + button = page.locator('button:text("Accept new permissions")') + await button.wait_for(state="visible") + await button.click() + await page.wait_for_url( + f"https://github.com/organizations/{org_id}/settings/installations/{installation_id}" + ) + except PlaywrightError as e: + if is_debug_enabled(): + screenshot_file = f"screenshot_approve_{org_id}.png" + await page.screenshot(path=screenshot_file) + _logger.warning(f"saved page screenshot to file '{screenshot_file}'") - await page.locator('button:text("Accept new permissions")').click() - await page.wait_for_url(f"https://github.com/organizations/{org_id}/settings/installations/{installation_id}") + raise e async def _login_if_required(self, page: Page) -> None: actor = await self._logged_in_as(page) @@ -575,7 +597,7 @@ async def _logout(self, page: Page) -> None: await page.screenshot(path=screenshot_file) _logger.warning(f"saved page screenshot to file '{screenshot_file}'") - raise RuntimeError(f"failed to logout via web ui: {e!s}") from e + _logger.warning(f"failed to logout via web ui: {e!s}") else: try: selector = 'input[value = "Sign out"]' @@ -586,4 +608,4 @@ async def _logout(self, page: Page) -> None: await page.screenshot(path=screenshot_file) _logger.warning(f"saved page screenshot to file '{screenshot_file}'") - raise RuntimeError(f"failed to logout via web ui: {e!s}") from e + _logger.warning(f"failed to logout via web ui: {e!s}") diff --git a/otterdog/resources/schemas/organization.json b/otterdog/resources/schemas/organization.json index 044c69d..e765c42 100644 --- a/otterdog/resources/schemas/organization.json +++ b/otterdog/resources/schemas/organization.json @@ -10,6 +10,10 @@ "type": "array", "items": { "$ref": "org-role.json" } }, + "teams": { + "type": "array", + "items": { "$ref": "team.json" } + }, "webhooks": { "type": "array", "items": { "$ref": "webhook.json" } diff --git a/otterdog/resources/schemas/ruleset.json b/otterdog/resources/schemas/ruleset.json index a7e5755..e8a0808 100644 --- a/otterdog/resources/schemas/ruleset.json +++ b/otterdog/resources/schemas/ruleset.json @@ -43,12 +43,6 @@ }, "$defs": { - "integer_or_null": { - "anyOf": [ - { "type": "integer" }, - { "type": "null" } - ] - }, "pull_request_or_null": { "anyOf": [ { "$ref": "#/$defs/pull_request" }, diff --git a/otterdog/resources/schemas/team.json b/otterdog/resources/schemas/team.json new file mode 100644 index 0000000..5a2d03b --- /dev/null +++ b/otterdog/resources/schemas/team.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "type": "object", + "properties": { + "name": { "type": "string" }, + "description": { "type": "string" }, + "members": { + "type": "array", + "items": { "type": "string" } + }, + "privacy": { "type": "string" }, + "notifications": { "type": "boolean" }, + "skip_members": { "type": "boolean" }, + "skip_non_organization_members": { "type": "boolean" } + }, + + "required": [ "name", "privacy" ], + "additionalProperties": false +} diff --git a/otterdog/utils.py b/otterdog/utils.py index 4b24801..73eaad8 100644 --- a/otterdog/utils.py +++ b/otterdog/utils.py @@ -22,7 +22,7 @@ if TYPE_CHECKING: from argparse import Namespace - from collections.abc import Callable, Sequence + from collections.abc import Callable, Mapping, Sequence T = TypeVar("T") @@ -560,7 +560,7 @@ def _format_tuple(self, value, indent): return "(%s)" % (",".join(items) + self.lfchar + self.htchar * indent) -def query_json(expr: str, data: dict[str, Any]) -> Any: +def query_json(expr: str, data: Mapping[str, Any]) -> Any: """ Evaluates a jsonata expression on the given dictionary. """ diff --git a/otterdog/webapp/home/routes.py b/otterdog/webapp/home/routes.py index 1bb4733..a37fb4d 100644 --- a/otterdog/webapp/home/routes.py +++ b/otterdog/webapp/home/routes.py @@ -281,6 +281,8 @@ def _get_branch_protection_data(org: GitHubOrganization) -> list[int]: @blueprint.route("/projects//defaults") async def defaults(project_name: str): + import contextlib + import aiofiles installation = await get_installation_by_project_name(project_name) @@ -308,18 +310,19 @@ async def defaults(project_name: str): elements = [ ("org-role", "Organization Role", f"{jsonnet_config.create_org_role}('')"), - ("org-webhook", "Organization Webhook", f"{jsonnet_config.create_org_webhook}('')"), + ("org-ruleset", "Organization Ruleset", f"{jsonnet_config.create_org_ruleset}('')"), + ("org-team", "Organization Team", f"{jsonnet_config.create_org_team}('')"), ("org-secret", "Organization Secret", f"{jsonnet_config.create_org_secret}('')"), ("org-variable", "Organization Variable", f"{jsonnet_config.create_org_variable}('')"), + ("org-webhook", "Organization Webhook", f"{jsonnet_config.create_org_webhook}('')"), ( "org-custom-property", "Organization Custom Property", f"{jsonnet_config.create_org_custom_property}('')", ), - ("org-ruleset", "Organization Ruleset", f"{jsonnet_config.create_org_ruleset}('')"), - ("repo-webhook", "Repository Webhook", f"{jsonnet_config.create_repo_webhook}('')"), ("repo-secret", "Repository Secret", f"{jsonnet_config.create_repo_secret}('')"), ("repo-variable", "Repository Variable", f"{jsonnet_config.create_repo_variable}('')"), + ("repo-webhook", "Repository Webhook", f"{jsonnet_config.create_repo_webhook}('')"), ("environment", "Environment", f"{jsonnet_config.create_environment}('')"), ("bpr", "Branch Protection Rule", f"{jsonnet_config.create_branch_protection_rule}('')"), ("repo-ruleset", "Repository Ruleset", f"{jsonnet_config.create_repo_ruleset}('')"), @@ -329,7 +332,9 @@ async def defaults(project_name: str): ] for element_id, name, function in elements: - default_elements.append(_get_snippet(jsonnet_config, element_id, name, function)) + # if evaluation fails, the default config might not define this resource + with contextlib.suppress(RuntimeError): + default_elements.append(_get_snippet(jsonnet_config, element_id, name, function)) return await render_home_template( "defaults.html", diff --git a/otterdog/webapp/templates/home/organization.html b/otterdog/webapp/templates/home/organization.html index 46a79c1..cf08969 100644 --- a/otterdog/webapp/templates/home/organization.html +++ b/otterdog/webapp/templates/home/organization.html @@ -74,6 +74,9 @@

{{ project_name }}

+ @@ -412,6 +415,38 @@

Status

+
+
+ + + + + + + + + + + + {% for team in config.teams|sort(attribute='name') %} + + + + + + + + {% endfor %} + +
NameDescriptionPrivacyNotificationsMembers
{{ team.name }}{{ team.description }}{{ team.privacy }}{{ team.notifications }} +
    + {% for member in team.members %} +
  • {{ member }}
  • + {% endfor %} +
+
+
+
diff --git a/otterdog/webapp/utils.py b/otterdog/webapp/utils.py index 61f84ec..f2c9a4f 100644 --- a/otterdog/webapp/utils.py +++ b/otterdog/webapp/utils.py @@ -7,6 +7,7 @@ # ******************************************************************************* import asyncio +import json import re import sys from datetime import datetime, timedelta @@ -31,6 +32,9 @@ logger = getLogger(__name__) _OTTERDOG_CONFIG: OtterdogConfig | None = None +_OTTERDOG_CONFIG_RETRIEVED_AT: datetime | None = None +_OTTERDOG_CONFIG_LOCK = asyncio.Lock() + _CREATE_INSTALLATION_TOKEN_LOCK = asyncio.Lock() _GLOBAL_POLICIES: list[Policy] | None = None @@ -130,18 +134,68 @@ def get_temporary_base_directory(app: Quart | None = None) -> str: async def get_otterdog_config() -> OtterdogConfig: - global _OTTERDOG_CONFIG + global _OTTERDOG_CONFIG, _OTTERDOG_CONFIG_RETRIEVED_AT - if _OTTERDOG_CONFIG is None: - _OTTERDOG_CONFIG = await _load_otterdog_config() + redis = get_redis() + + async with _OTTERDOG_CONFIG_LOCK: + key = f"config:{_get_otterdog_config_url()}" + current_data = decode_bytes_dict(await redis.hgetall(key)) + + configuration = current_data.get("configuration", None) + created_at_str = current_data.get("created_at", None) + + if created_at_str is None or configuration is None: + return await refresh_otterdog_config() + + created_at = datetime.fromisoformat(created_at_str) + if ( + _OTTERDOG_CONFIG_RETRIEVED_AT is None + or _OTTERDOG_CONFIG is None + or created_at > _OTTERDOG_CONFIG_RETRIEVED_AT + ): + logger.info("loading otterdog config from cache") + _OTTERDOG_CONFIG = OtterdogConfig.from_dict( + json.loads(configuration), False, current_app.config["APP_ROOT"] + ) + _OTTERDOG_CONFIG_RETRIEVED_AT = created_at + else: + logger.info("re-using locally cached otterdog config") return _OTTERDOG_CONFIG async def refresh_otterdog_config(sha: str | None = None) -> OtterdogConfig: - global _OTTERDOG_CONFIG - _OTTERDOG_CONFIG = await _load_otterdog_config(sha) - return _OTTERDOG_CONFIG + global _OTTERDOG_CONFIG, _OTTERDOG_CONFIG_RETRIEVED_AT + + config_url = _get_otterdog_config_url() + logger.info(f"refreshing otterdog config from config url '{config_url}'") + + redis = get_redis() + config = await _load_otterdog_config(sha) + + created_at = current_utc_time() + + _OTTERDOG_CONFIG = config + _OTTERDOG_CONFIG_RETRIEVED_AT = created_at + + await redis.hset( + f"config:{config_url}", + mapping={ + "configuration": json.dumps(config.configuration), + "created_at": created_at.isoformat(), + }, + ) + + return config + + +def _get_otterdog_config_url() -> str: + config_file_owner = current_app.config["OTTERDOG_CONFIG_OWNER"] + config_file_repo = current_app.config["OTTERDOG_CONFIG_REPO"] + config_file_path = current_app.config["OTTERDOG_CONFIG_PATH"] + + return f"'https://github.com/{config_file_owner}/{config_file_repo}/{config_file_path}'" async def _load_otterdog_config(ref: str | None = None) -> OtterdogConfig: @@ -163,7 +217,7 @@ async def _load_otterdog_config(ref: str | None = None) -> OtterdogConfig: name = cast(str, file.name) await file.write(content) await file.flush() - return OtterdogConfig(name, False, app_root) + return OtterdogConfig.from_file(name, False, app_root) async def refresh_global_policies(sha: str | None = None) -> list[Policy]: diff --git a/poetry.lock b/poetry.lock index 1173e02..b0d14e3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -451,116 +451,103 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.0" +version = "3.4.1" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" +python-versions = ">=3.7" files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, + {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, + {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, + {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, + {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, + {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, + {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, + {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, + {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, + {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, ] [[package]] @@ -1051,13 +1038,13 @@ files = [ [[package]] name = "identify" -version = "2.6.3" +version = "2.6.4" description = "File identification library for Python" optional = false python-versions = ">=3.9" files = [ - {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, - {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, + {file = "identify-2.6.4-py2.py3-none-any.whl", hash = "sha256:993b0f01b97e0568c179bb9196391ff391bfb88a99099dbf5ce392b68f42d0af"}, + {file = "identify-2.6.4.tar.gz", hash = "sha256:285a7d27e397652e8cafe537a6cc97dd470a970f48fb2e9d979aa38eae5513ac"}, ] [package.extras] @@ -1590,43 +1577,49 @@ files = [ [[package]] name = "mypy" -version = "1.14.0" +version = "1.14.1" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e971c1c667007f9f2b397ffa80fa8e1e0adccff336e5e77e74cb5f22868bee87"}, - {file = "mypy-1.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e86aaeaa3221a278c66d3d673b297232947d873773d61ca3ee0e28b2ff027179"}, - {file = "mypy-1.14.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1628c5c3ce823d296e41e2984ff88c5861499041cb416a8809615d0c1f41740e"}, - {file = "mypy-1.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fadb29b77fc14a0dd81304ed73c828c3e5cde0016c7e668a86a3e0dfc9f3af3"}, - {file = "mypy-1.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:3fa76988dc760da377c1e5069200a50d9eaaccf34f4ea18428a3337034ab5a44"}, - {file = "mypy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6e73c8a154eed31db3445fe28f63ad2d97b674b911c00191416cf7f6459fd49a"}, - {file = "mypy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:273e70fcb2e38c5405a188425aa60b984ffdcef65d6c746ea5813024b68c73dc"}, - {file = "mypy-1.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1daca283d732943731a6a9f20fdbcaa927f160bc51602b1d4ef880a6fb252015"}, - {file = "mypy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7e68047bedb04c1c25bba9901ea46ff60d5eaac2d71b1f2161f33107e2b368eb"}, - {file = "mypy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:7a52f26b9c9b1664a60d87675f3bae00b5c7f2806e0c2800545a32c325920bcc"}, - {file = "mypy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d5326ab70a6db8e856d59ad4cb72741124950cbbf32e7b70e30166ba7bbf61dd"}, - {file = "mypy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bf4ec4980bec1e0e24e5075f449d014011527ae0055884c7e3abc6a99cd2c7f1"}, - {file = "mypy-1.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:390dfb898239c25289495500f12fa73aa7f24a4c6d90ccdc165762462b998d63"}, - {file = "mypy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e026d55ddcd76e29e87865c08cbe2d0104e2b3153a523c529de584759379d3d"}, - {file = "mypy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:585ed36031d0b3ee362e5107ef449a8b5dfd4e9c90ccbe36414ee405ee6b32ba"}, - {file = "mypy-1.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9f6f4c0b27401d14c483c622bc5105eff3911634d576bbdf6695b9a7c1ba741"}, - {file = "mypy-1.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b2280cedcb312c7a79f5001ae5325582d0d339bce684e4a529069d0e7ca1e7"}, - {file = "mypy-1.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:342de51c48bab326bfc77ce056ba08c076d82ce4f5a86621f972ed39970f94d8"}, - {file = "mypy-1.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00df23b42e533e02a6f0055e54de9a6ed491cd8b7ea738647364fd3a39ea7efc"}, - {file = "mypy-1.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8c8387e5d9dff80e7daf961df357c80e694e942d9755f3ad77d69b0957b8e3f"}, - {file = "mypy-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b16738b1d80ec4334654e89e798eb705ac0c36c8a5c4798496cd3623aa02286"}, - {file = "mypy-1.14.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:10065fcebb7c66df04b05fc799a854b1ae24d9963c8bb27e9064a9bdb43aa8ad"}, - {file = "mypy-1.14.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbb7d683fa6bdecaa106e8368aa973ecc0ddb79a9eaeb4b821591ecd07e9e03c"}, - {file = "mypy-1.14.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:3498cb55448dc5533e438cd13d6ddd28654559c8c4d1fd4b5ca57a31b81bac01"}, - {file = "mypy-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:c7b243408ea43755f3a21a0a08e5c5ae30eddb4c58a80f415ca6b118816e60aa"}, - {file = "mypy-1.14.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:14117b9da3305b39860d0aa34b8f1ff74d209a368829a584eb77524389a9c13e"}, - {file = "mypy-1.14.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:af98c5a958f9c37404bd4eef2f920b94874507e146ed6ee559f185b8809c44cc"}, - {file = "mypy-1.14.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0b343a1d3989547024377c2ba0dca9c74a2428ad6ed24283c213af8dbb0710b"}, - {file = "mypy-1.14.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cdb5563c1726c85fb201be383168f8c866032db95e1095600806625b3a648cb7"}, - {file = "mypy-1.14.0-cp39-cp39-win_amd64.whl", hash = "sha256:74e925649c1ee0a79aa7448baf2668d81cc287dc5782cff6a04ee93f40fb8d3f"}, - {file = "mypy-1.14.0-py3-none-any.whl", hash = "sha256:2238d7f93fc4027ed1efc944507683df3ba406445a2b6c96e79666a045aadfab"}, - {file = "mypy-1.14.0.tar.gz", hash = "sha256:822dbd184d4a9804df5a7d5335a68cf7662930e70b8c1bc976645d1509f9a9d6"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb"}, + {file = "mypy-1.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d"}, + {file = "mypy-1.14.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b"}, + {file = "mypy-1.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427"}, + {file = "mypy-1.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c"}, + {file = "mypy-1.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8"}, + {file = "mypy-1.14.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f"}, + {file = "mypy-1.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1"}, + {file = "mypy-1.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14"}, + {file = "mypy-1.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11"}, + {file = "mypy-1.14.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e"}, + {file = "mypy-1.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89"}, + {file = "mypy-1.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255"}, + {file = "mypy-1.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a"}, + {file = "mypy-1.14.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9"}, + {file = "mypy-1.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd"}, + {file = "mypy-1.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31"}, + {file = "mypy-1.14.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319"}, + {file = "mypy-1.14.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac"}, + {file = "mypy-1.14.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b"}, + {file = "mypy-1.14.1-cp38-cp38-win_amd64.whl", hash = "sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35"}, + {file = "mypy-1.14.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9"}, + {file = "mypy-1.14.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb"}, + {file = "mypy-1.14.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60"}, + {file = "mypy-1.14.1-cp39-cp39-win_amd64.whl", hash = "sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c"}, + {file = "mypy-1.14.1-py3-none-any.whl", hash = "sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1"}, + {file = "mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6"}, ] [package.dependencies] @@ -2083,13 +2076,13 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pymdown-extensions" -version = "10.12" +version = "10.13" description = "Extension pack for Python Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77"}, - {file = "pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7"}, + {file = "pymdown_extensions-10.13-py3-none-any.whl", hash = "sha256:80bc33d715eec68e683e04298946d47d78c7739e79d808203df278ee8ef89428"}, + {file = "pymdown_extensions-10.13.tar.gz", hash = "sha256:e0b351494dc0d8d14a1f52b39b1499a00ef1566b4ba23dc74f1eba75c736f5dd"}, ] [package.dependencies] @@ -2850,13 +2843,13 @@ files = [ [[package]] name = "starlette" -version = "0.42.0" +version = "0.45.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "starlette-0.42.0-py3-none-any.whl", hash = "sha256:02f877201a3d6d301714b5c72f15cac305ea5cc9e213c4b46a5af7eecad0d625"}, - {file = "starlette-0.42.0.tar.gz", hash = "sha256:91f1fbd612f3e3d821a8a5f46bf381afe2a9722a7b8bbde1c07fb83384c2882a"}, + {file = "starlette-0.45.1-py3-none-any.whl", hash = "sha256:5656c0524f586e9148d9a3c1dd5257fb42a99892fb0dc6877dd76ef4d184aac3"}, + {file = "starlette-0.45.1.tar.gz", hash = "sha256:a8ae1fa3b1ab7ca83a4abd77871921a13fb5aeaf4874436fb96c29dfcd4ecfa3"}, ] [package.dependencies] @@ -2990,13 +2983,13 @@ files = [ [[package]] name = "types-pyyaml" -version = "6.0.12.20241221" +version = "6.0.12.20241230" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" files = [ - {file = "types_PyYAML-6.0.12.20241221-py3-none-any.whl", hash = "sha256:0657a4ff8411a030a2116a196e8e008ea679696b5b1a8e1a6aa8ebb737b34688"}, - {file = "types_pyyaml-6.0.12.20241221.tar.gz", hash = "sha256:4f149aa893ff6a46889a30af4c794b23833014c469cc57cbc3ad77498a58996f"}, + {file = "types_PyYAML-6.0.12.20241230-py3-none-any.whl", hash = "sha256:fa4d32565219b68e6dee5f67534c722e53c00d1cfc09c435ef04d7353e1e96e6"}, + {file = "types_pyyaml-6.0.12.20241230.tar.gz", hash = "sha256:7f07622dbd34bb9c8b264fe860a17e0efcad00d50b5f27e93984909d9363498c"}, ] [[package]] @@ -3030,13 +3023,13 @@ urllib3 = ">=2" [[package]] name = "types-setuptools" -version = "75.6.0.20241126" +version = "75.6.0.20241223" description = "Typing stubs for setuptools" optional = false python-versions = ">=3.8" files = [ - {file = "types_setuptools-75.6.0.20241126-py3-none-any.whl", hash = "sha256:aaae310a0e27033c1da8457d4d26ac673b0c8a0de7272d6d4708e263f2ea3b9b"}, - {file = "types_setuptools-75.6.0.20241126.tar.gz", hash = "sha256:7bf25ad4be39740e469f9268b6beddda6e088891fa5a27e985c6ce68bf62ace0"}, + {file = "types_setuptools-75.6.0.20241223-py3-none-any.whl", hash = "sha256:7cbfd3bf2944f88bbcdd321b86ddd878232a277be95d44c78a53585d78ebc2f6"}, + {file = "types_setuptools-75.6.0.20241223.tar.gz", hash = "sha256:d9478a985057ed48a994c707f548e55aababa85fe1c9b212f43ab5a1fffd3211"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 5394644..1be385f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "otterdog" -version = "0.10.0.dev5" +version = "0.0.0" description = "Tool to manage GitHub organizations and their repositories." authors = ["Thomas Neidhart "] readme = "README.md" diff --git a/tests/models/test_github_organization.py b/tests/models/test_github_organization.py index 9b463fc..0dd5154 100644 --- a/tests/models/test_github_organization.py +++ b/tests/models/test_github_organization.py @@ -21,15 +21,13 @@ async def asyncSetUp(self): base_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "resources") otterdog_config_file = os.path.join(base_dir, "otterdog.json") - self.otterdog_config = OtterdogConfig(otterdog_config_file, True) + self.otterdog_config = OtterdogConfig.from_file(otterdog_config_file, True) self.org_config = self.otterdog_config.get_organization_config(self.TEST_ORG) self.jsonnet_config = self.org_config.jsonnet_config await self.jsonnet_config.init_template() def test_load_from_file(self): - organization = GitHubOrganization.load_from_file( - self.TEST_ORG, self.jsonnet_config.org_config_file, self.otterdog_config - ) + organization = GitHubOrganization.load_from_file(self.TEST_ORG, self.jsonnet_config.org_config_file) assert organization.github_id == "test-org" assert len(organization.webhooks) == 1