diff --git a/gh_org_mgr/__init__.py b/gh_org_mgr/__init__.py index 36c8d9d..a3ad42a 100644 --- a/gh_org_mgr/__init__.py +++ b/gh_org_mgr/__init__.py @@ -4,23 +4,6 @@ """Global init file""" -import logging from importlib.metadata import version __version__ = version("github-org-manager") - - -def configure_logger(debug: bool = False) -> logging.Logger: - """Set logging options""" - log = logging.getLogger() - logging.basicConfig( - encoding="utf-8", - format="[%(asctime)s] %(levelname)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - if debug: - log.setLevel(logging.DEBUG) - else: - log.setLevel(logging.INFO) - - return log diff --git a/gh_org_mgr/_gh_org.py b/gh_org_mgr/_gh_org.py index 556257e..b243042 100644 --- a/gh_org_mgr/_gh_org.py +++ b/gh_org_mgr/_gh_org.py @@ -19,10 +19,18 @@ from github.NamedUser import NamedUser from github.Organization import Organization from github.Repository import Repository +from github.Requester import Requester from github.Team import Team from jwt.exceptions import InvalidKeyError from ._gh_api import get_github_secrets_from_env, run_graphql_query +from ._helpers import ( + compare_two_dicts, + compare_two_lists, + dict_to_pretty_string, + sluggify_teamname, +) +from ._stats import OrgChanges @dataclass @@ -47,6 +55,7 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines configured_repos_collaborators: dict[str, dict[str, str]] = field(default_factory=dict) archived_repos: list[Repository] = field(default_factory=list) unconfigured_team_repo_permissions: dict[str, dict[str, str]] = field(default_factory=dict) + stats: OrgChanges = field(default_factory=OrgChanges) # Re-usable Constants TEAM_CONFIG_FIELDS: dict[str, dict[str, str | None]] = field( # pylint: disable=invalid-name @@ -61,12 +70,6 @@ class GHorg: # pylint: disable=too-many-instance-attributes, too-many-lines # -------------------------------------------------------------------------- # Helper functions # -------------------------------------------------------------------------- - def _sluggify_teamname(self, team: str) -> str: - """Slugify a GitHub team name""" - # TODO: this is very naive, no other special chars are - # supported, or multiple spaces etc. - return team.replace(" ", "-") - # amazonq-ignore-next-line def login( self, orgname: str, token: str = "", app_id: str | int = "", app_private_key: str = "" @@ -117,88 +120,9 @@ def ratelimit(self): "Current rate limit: %s/%s (reset: %s)", core.remaining, core.limit, core.reset ) - def pretty_print_dict(self, dictionary: dict) -> str: - """Convert a dict to a pretty-printed output""" - - # Censor sensible fields - def censor_half_string(string: str) -> str: - """Censor 50% of a string (rounded up)""" - half1 = int(len(string) / 2) - half2 = len(string) - half1 - return string[:half1] + "*" * (half2) - - sensible_keys = ["gh_token", "gh_app_private_key"] - for key in sensible_keys: - if value := dictionary.get(key, ""): - dictionary[key] = censor_half_string(value) - - # Print dict nicely - def pretty(d, indent=0): - string = "" - for key, value in d.items(): - string += " " * indent + str(key) + ":\n" - if isinstance(value, dict): - string += pretty(value, indent + 1) - else: - string += " " * (indent + 1) + str(value) + "\n" - - return string - - return pretty(dictionary) - def pretty_print_dataclass(self) -> str: """Convert this dataclass to a pretty-printed output""" - return self.pretty_print_dict(asdict(self)) - - def compare_two_lists(self, list1: list[str], list2: list[str]): - """ - Compares two lists of strings and returns a tuple containing elements - missing in each list and common elements. - - Args: - list1 (list of str): The first list of strings. - list2 (list of str): The second list of strings. - - Returns: - tuple: A tuple containing three lists: - 1. The first list contains elements in `list2` that are missing in `list1`. - 2. The second list contains elements that are present in both `list1` and `list2`. - 3. The third list contains elements in `list1` that are missing in `list2`. - - Example: - >>> list1 = ["apple", "banana", "cherry"] - >>> list2 = ["banana", "cherry", "date", "fig"] - >>> compare_lists(list1, list2) - (['date', 'fig'], ['banana', 'cherry'], ['apple']) - """ - # Convert lists to sets for easier comparison - set1, set2 = set(list1), set(list2) - - # Elements in list2 that are missing in list1 - missing_in_list1 = list(set2 - set1) - - # Elements present in both lists - common_elements = list(set1 & set2) - - # Elements in list1 that are missing in list2 - missing_in_list2 = list(set1 - set2) - - # Return the result as a tuple - return (missing_in_list1, common_elements, missing_in_list2) - - def compare_two_dicts(self, dict1: dict, dict2: dict) -> dict[str, dict[str, str | int | None]]: - """Compares two dictionaries. Assume that the keys are the same. Output - a dict with keys that have differing values""" - # Create an empty dictionary to store differences - differences = {} - - # Iterate through the keys (assuming both dictionaries have the same keys) - for key in dict1: - # Compare the values for each key - if dict1[key] != dict2[key]: - differences[key] = {"dict1": dict1[key], "dict2": dict2[key]} - - return differences + return dict_to_pretty_string(asdict(self), sensible_keys=["gh_token", "gh_app_private_key"]) def _resolve_gh_username(self, username: str, teamname: str) -> NamedUser | None: """Turn a username into a proper GitHub user object""" @@ -293,7 +217,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None: return # Get differences between the current and configured owners - owners_remove, owners_ok, owners_add = self.compare_two_lists( + owners_remove, owners_ok, owners_add = compare_two_lists( self.configured_org_owners, [user.login for user in self.current_org_owners] ) # Compare configured (lower-cased) owners with lower-cased list of current owners @@ -314,6 +238,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None: for user in owners_add: if gh_user := self._resolve_gh_username(user, ""): logging.info("Adding user '%s' as organization owner", gh_user.login) + self.stats.add_owner(gh_user.login) if not dry: self.org.add_to_members(gh_user, "admin") @@ -325,6 +250,7 @@ def sync_org_owners(self, dry: bool = False, force: bool = False) -> None: "Will make them a normal member", gh_user.login, ) + self.stats.degrade_owner(gh_user.login) # Handle authenticated user being the same as the one you want to degrade if self._is_user_authenticated_user(gh_user): logging.warning( @@ -370,9 +296,10 @@ def create_missing_teams(self, dry: bool = False): for team, attributes in self.configured_teams.items(): if team not in existent_team_names: if parent := attributes.get("parent"): # type: ignore - parent_id = self.org.get_team_by_slug(self._sluggify_teamname(parent)).id + parent_id = self.org.get_team_by_slug(sluggify_teamname(parent)).id logging.info("Creating team '%s' with parent ID '%s'", team, parent_id) + self.stats.create_team(team) # NOTE: We do not specify any team settings (description etc) # here, this will happen later if not dry: @@ -385,6 +312,7 @@ def create_missing_teams(self, dry: bool = False): else: logging.info("Creating team '%s' without parent", team) + self.stats.create_team(team) if not dry: self.org.create_team( team, @@ -410,7 +338,7 @@ def _prepare_team_config_for_sync( # team coming from config, and valid string elif isinstance(parent, str) and parent: team_config["parent_team_id"] = self.org.get_team_by_slug( - self._sluggify_teamname(parent) + sluggify_teamname(parent) ).id # empty from string, so probably default value elif isinstance(parent, str) and not parent: @@ -471,16 +399,17 @@ def sync_current_teams_settings(self, dry: bool = False) -> None: ) # Compare settings and update if necessary - if differences := self.compare_two_dicts(configured_team_configs, current_team_configs): + if differences := compare_two_dicts(configured_team_configs, current_team_configs): # Log differences logging.info( "Team settings for '%s' differ from the configuration. Updating them:", team.name, ) for setting, diff in differences.items(): - logging.info( - "Setting '%s': '%s' --> '%s'", setting, diff["dict2"], diff["dict1"] - ) + change_str = f"Setting '{setting}': '{diff['dict2']}' --> '{diff['dict1']}'" + logging.info(change_str) + self.stats.edit_team_config(team.name, new_config=change_str) + # Execute team setting changes if not dry: try: @@ -489,7 +418,7 @@ def sync_current_teams_settings(self, dry: bool = False) -> None: logging.critical( "Team '%s' settings could not be edited. Error: \n%s", team.name, - self.pretty_print_dict(exc.data), + dict_to_pretty_string(exc.data), ) sys.exit(1) else: @@ -611,6 +540,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too- team.name, config_role, ) + self.stats.pending_team_member(team=team.name, user=gh_user.login) continue logging.info( @@ -619,6 +549,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too- team.name, config_role, ) + self.stats.add_team_member(team=team.name, user=gh_user.login) if not dry: self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role) @@ -633,6 +564,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too- team.name, config_role, ) + self.stats.change_team_member_role(team=team.name, user=gh_user.login) if not dry: self._add_or_update_user_in_team(team=team, user=gh_user, role=config_role) @@ -651,6 +583,7 @@ def sync_teams_members(self, dry: bool = False) -> None: # pylint: disable=too- gh_user.login, team.name, ) + self.stats.remove_team_member(team=team.name, user=gh_user.login) if not dry: team.remove_membership(gh_user) else: @@ -675,6 +608,7 @@ def get_unconfigured_teams( if delete_unconfigured_teams: for team in unconfigured_teams: logging.info("Deleting team '%s' as it is not configured locally", team.name) + self.stats.delete_team(team=team.name, deleted=True) if not dry: team.delete() else: @@ -684,6 +618,8 @@ def get_unconfigured_teams( "configured locally: %s. Taking no action about these teams.", ", ".join(unconfigured_teams_str), ) + for team in unconfigured_teams: + self.stats.delete_team(team=team.name, deleted=False) def get_members_without_team( self, dry: bool = False, remove_members_without_team: bool = False @@ -713,6 +649,7 @@ def get_members_without_team( "Removing user '%s' from organisation as they are not member of any team", user.login, ) + self.stats.remove_member_without_team(user=user.login, removed=True) if not dry: self.org.remove_from_membership(user) else: @@ -722,6 +659,8 @@ def get_members_without_team( "member of any team: %s", ", ".join(members_without_team_str), ) + for user in members_without_team: + self.stats.remove_member_without_team(user=user.login, removed=False) # -------------------------------------------------------------------------- # Repos @@ -754,7 +693,7 @@ def _create_perms_changelist_for_teams( # Convert team name to Team object try: - team = self.org.get_team_by_slug(self._sluggify_teamname(team_name)) + team = self.org.get_team_by_slug(sluggify_teamname(team_name)) # Team not found, probably because a new team should be created, but it's a dry-run except UnknownObjectException: logging.debug( @@ -763,12 +702,21 @@ def _create_perms_changelist_for_teams( ) # Initialise a new Team() object with the name, manually team = Team( - requester=None, # type: ignore + requester=Requester( + auth=None, + base_url="https://api.github.com", + timeout=10, + user_agent="", + per_page=100, + verify=True, + retry=3, + pool_size=200, + ), headers={}, # No headers required attributes={ "id": 0, "name": team_name, - "slug": self._sluggify_teamname(team_name), + "slug": sluggify_teamname(team_name), }, completed=True, # Mark as fully initialized ) @@ -840,6 +788,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False team.name, perm, ) + self.stats.change_repo_team_permissions(repo=repo.name, team=team.name, perm=perm) if not dry: # Update permissions or newly add a team to a repo team.update_team_repository(repo, perm) @@ -865,6 +814,10 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False self._document_unconfigured_team_repo_permissions( team=team, team_permission=teams[team], repo_name=repo.name ) + # Collect this status in the stats + self.stats.document_unconfigured_team_permissions( + team=team.name, repo=repo.name, perm=teams[team] + ) # Abort handling the repo sync as we don't touch unconfigured teams continue # Handle: Team is configured, but contains no config @@ -883,6 +836,7 @@ def sync_repo_permissions(self, dry: bool = False, ignore_archived: bool = False # Remove if any mismatch has been found if remove: logging.info("Removing team '%s' from repository '%s'", team.name, repo.name) + self.stats.remove_team_from_repo(repo=repo.name, team=team.name) if not dry: team.remove_from_repos(repo) @@ -1328,5 +1282,6 @@ def sync_repo_collaborator_permissions(self, dry: bool = False): ) # Remove collaborator + self.stats.remove_repo_collaborator(repo=repo.name, user=username) if not dry: repo.remove_from_collaborators(username) diff --git a/gh_org_mgr/_helpers.py b/gh_org_mgr/_helpers.py new file mode 100644 index 0000000..b0eff0b --- /dev/null +++ b/gh_org_mgr/_helpers.py @@ -0,0 +1,152 @@ +# SPDX-FileCopyrightText: 2025 DB Systel GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Helper functions""" + +import logging +import sys +from dataclasses import asdict + + +def configure_logger(verbose: bool = False, debug: bool = False) -> logging.Logger: + """Set logging options""" + log = logging.getLogger() + logging.basicConfig( + encoding="utf-8", + format="[%(asctime)s] %(levelname)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + if debug: + log.setLevel(logging.DEBUG) + elif verbose: + log.setLevel(logging.INFO) + else: + log.setLevel(logging.WARNING) + + return log + + +def log_progress(message: str) -> None: + """Log progress messages to stderr""" + # Clear line if no message is given + if not message: + sys.stderr.write("\r\033[K") + sys.stderr.flush() + else: + sys.stderr.write(f"\r\033[K⏳ {message}") + sys.stderr.flush() + + +def sluggify_teamname(team: str) -> str: + """Slugify a GitHub team name""" + # TODO: this is very naive, no other special chars are + # supported, or multiple spaces etc. + return team.replace(" ", "-") + + +def compare_two_lists(list1: list[str], list2: list[str]): + """ + Compares two lists of strings and returns a tuple containing elements + missing in each list and common elements. + + Args: + list1 (list of str): The first list of strings. + list2 (list of str): The second list of strings. + + Returns: + tuple: A tuple containing three lists: + 1. The first list contains elements in `list2` that are missing in `list1`. + 2. The second list contains elements that are present in both `list1` and `list2`. + 3. The third list contains elements in `list1` that are missing in `list2`. + + Example: + >>> list1 = ["apple", "banana", "cherry"] + >>> list2 = ["banana", "cherry", "date", "fig"] + >>> compare_lists(list1, list2) + (['date', 'fig'], ['banana', 'cherry'], ['apple']) + """ + # Convert lists to sets for easier comparison + set1, set2 = set(list1), set(list2) + + # Elements in list2 that are missing in list1 + missing_in_list1 = list(set2 - set1) + + # Elements present in both lists + common_elements = list(set1 & set2) + + # Elements in list1 that are missing in list2 + missing_in_list2 = list(set1 - set2) + + # Return the result as a tuple + return (missing_in_list1, common_elements, missing_in_list2) + + +def compare_two_dicts(dict1: dict, dict2: dict) -> dict[str, dict[str, str | int | None]]: + """Compares two dictionaries. Assume that the keys are the same. Output + a dict with keys that have differing values""" + # Create an empty dictionary to store differences + differences = {} + + # Iterate through the keys (assuming both dictionaries have the same keys) + for key in dict1: + # Compare the values for each key + if dict1[key] != dict2[key]: + differences[key] = {"dict1": dict1[key], "dict2": dict2[key]} + + return differences + + +def dict_to_pretty_string(dictionary: dict, sensible_keys: None | list[str] = None) -> str: + """Convert a dict to a pretty-printed output""" + + # Censor sensible fields + def censor_half_string(string: str) -> str: + """Censor 50% of a string (rounded up)""" + half1 = int(len(string) / 2) + half2 = len(string) - half1 + return string[:half1] + "*" * (half2) + + if sensible_keys is None: + sensible_keys = [] + for key in sensible_keys: + if value := dictionary.get(key, ""): + dictionary[key] = censor_half_string(value) + + # Print dict nicely + def pretty(d, indent=0): + string = "" + for key, value in d.items(): + string += " " * indent + str(key) + ":\n" + if isinstance(value, dict): + string += pretty(value, indent + 1) + else: + string += " " * (indent + 1) + str(value) + "\n" + + return string + + return pretty(dictionary) + + +def pretty_print_dataclass(dc): + """Convert dataclass to a pretty-printed output""" + dict_to_pretty_string(asdict(dc)) + + +def implement_changes_into_class(dc_object, **changes: bool | str | list[str]): + """Smartly add changes to a (data)class object""" + for attribute, value in changes.items(): + current_value = getattr(dc_object, attribute) + # attribute is list + if isinstance(current_value, list): + # input change is list + if isinstance(value, list): + current_value.extend(value) + # input change is not list + else: + current_value.append(value) + # All other cases, bool + else: + setattr(dc_object, attribute, value) + + return dc_object diff --git a/gh_org_mgr/_stats.py b/gh_org_mgr/_stats.py new file mode 100644 index 0000000..10a8bca --- /dev/null +++ b/gh_org_mgr/_stats.py @@ -0,0 +1,258 @@ +# SPDX-FileCopyrightText: 2025 DB Systel GmbH +# +# SPDX-License-Identifier: Apache-2.0 + +"""Dataclasses and functions for statistics""" + +import json +from dataclasses import dataclass, field + +from ._helpers import implement_changes_into_class + + +@dataclass +class TeamChanges: # pylint: disable=too-many-instance-attributes + """Dataclass holding information about the changes made to a team""" + + newly_created: bool = False + deleted: bool = False + unconfigured: bool = False + changed_config: list[str] = field(default_factory=list) + added_members: list[str] = field(default_factory=list) + changed_members_role: list[str] = field(default_factory=list) + removed_members: list[str] = field(default_factory=list) + pending_members: list[str] = field(default_factory=list) + + +@dataclass +class RepoChanges: # pylint: disable=too-many-instance-attributes + """Dataclass holding information about the changes made to a repository""" + + changed_permissions_for_teams: list[str] = field(default_factory=list) + removed_teams: list[str] = field(default_factory=list) + unconfigured_teams_with_permissions: list[str] = field(default_factory=list) + removed_collaborators: list[str] = field(default_factory=list) + + +@dataclass +class OrgChanges: # pylint: disable=too-many-instance-attributes + """Dataclass holding general statistics about the changes made to the organization""" + + dry: bool = False + added_owners: list[str] = field(default_factory=list) + degraded_owners: list[str] = field(default_factory=list) + members_without_team: list[str] = field(default_factory=list) + removed_members: list[str] = field(default_factory=list) + teams: dict[str, TeamChanges] = field(default_factory=dict) + repos: dict[str, RepoChanges] = field(default_factory=dict) + + # -------------------------------------------------------------------------- + # Owners + # -------------------------------------------------------------------------- + def add_owner(self, user: str) -> None: + """User has been added as owner""" + self.added_owners.append(user) + + def degrade_owner(self, user: str) -> None: + """User has been degraded from owner to member""" + self.degraded_owners.append(user) + + # -------------------------------------------------------------------------- + # Teams + # -------------------------------------------------------------------------- + def update_team(self, team_name: str, **changes: bool | str | list[str]) -> None: + """Update team changes""" + # Initialise team if not present + if team_name not in self.teams: + self.teams[team_name] = TeamChanges() + + implement_changes_into_class(dc_object=self.teams[team_name], **changes) + + def create_team(self, team: str) -> None: + """Team has been created""" + self.update_team(team_name=team, newly_created=True) + + def edit_team_config(self, team: str, new_config: str) -> None: + """Team config has been changed""" + self.update_team(team_name=team, changed_config=new_config) + + def delete_team(self, team: str, deleted: bool) -> None: + """Teams are not configured""" + self.update_team(team_name=team, unconfigured=True, deleted=deleted) + + # -------------------------------------------------------------------------- + # Members + # -------------------------------------------------------------------------- + def add_team_member(self, team: str, user: str) -> None: + """User has been added to team""" + self.update_team(team_name=team, added_members=user) + + def change_team_member_role(self, team: str, user: str) -> None: + """User role has been changed in team""" + self.update_team(team_name=team, changed_members_role=user) + + def pending_team_member(self, team: str, user: str) -> None: + """User has a pending invitation""" + self.update_team(team_name=team, pending_members=user) + + def remove_team_member(self, team: str, user: str) -> None: + """User has been removed from team""" + self.update_team(team_name=team, removed_members=user) + + def remove_member_without_team(self, user: str, removed: bool) -> None: + """User is not in any team""" + self.members_without_team.append(user) + if removed: + self.removed_members.append(user) + + # -------------------------------------------------------------------------- + # Repos + # -------------------------------------------------------------------------- + def update_repo(self, repo_name: str, **changes: bool | str | list[str]) -> None: + """Update team changes""" + # Initialise repo if not present + if repo_name not in self.teams: + self.repos[repo_name] = RepoChanges() + + implement_changes_into_class(dc_object=self.repos[repo_name], **changes) + + def change_repo_team_permissions(self, repo: str, team: str, perm: str) -> None: + """Team permissions have been changed for a repo""" + self.update_repo(repo_name=repo, changed_permissions_for_teams=f"{team}: {perm}") + + def remove_team_from_repo(self, repo: str, team: str) -> None: + """Team has been removed form a repo""" + self.update_repo(repo_name=repo, removed_teams=team) + + def document_unconfigured_team_permissions(self, repo: str, team: str, perm: str) -> None: + """Unconfigured team has permissions on repo""" + self.update_repo(repo_name=repo, unconfigured_teams_with_permissions=f"{team}: {perm}") + + def remove_repo_collaborator(self, repo: str, user: str) -> None: + """Remove collaborator""" + self.update_repo(repo_name=repo, removed_collaborators=user) + + # -------------------------------------------------------------------------- + # Output + # -------------------------------------------------------------------------- + def changes_into_dict(self) -> dict: + """Convert dataclass to dict, and only use classes that are not empty/False""" + changes_dict: dict[str, list[str] | dict] = { + key: value # type: ignore + for key, value in { + "dry": self.dry, + "added_owners": self.added_owners, + "degraded_owners": self.degraded_owners, + "members_without_team": self.members_without_team, + "removed_members": self.removed_members, + "teams": self.teams, + "repos": self.repos, + }.items() + if value # Exclude empty values + } + + team_changes: dict[str, TeamChanges] = changes_dict.get("teams", {}) # type: ignore + repo_changes: dict[str, RepoChanges] = changes_dict.get("repos", {}) # type: ignore + + for team, tchanges in team_changes.items(): + new_changes = { + key: value + for key, value in tchanges.__dict__.items() + if value # Exclude empty values + } + team_changes[team] = new_changes # type: ignore + changes_dict["teams"] = team_changes + + for repo, rchanges in repo_changes.items(): + new_changes = { + key: value + for key, value in rchanges.__dict__.items() + if value # Exclude empty values + } + repo_changes[repo] = new_changes # type: ignore + changes_dict["repos"] = repo_changes + + return changes_dict + + def print_changes( # pylint: disable=too-many-branches, too-many-statements + self, orgname: str, output: str, dry: bool + ) -> None: + """Print the changes, either in pretty format or as JSON""" + + # Add dry run information to stats dataclass + self.dry = dry + + # Output in the requested format + if output == "json": + changes_dict = self.changes_into_dict() + print(json.dumps(changes_dict, indent=2)) + else: + output = ( + f"#-------------------------------------{len(orgname)*'-'}\n" + f"# Changes made to GitHub organisation {orgname}\n" + f"#-------------------------------------{len(orgname)*'-'}\n\n" + ) + if dry: + output += "⚠️ Dry-run mode, no changes executed\n\n" + if self.added_owners: + output += f"➕ Added owners: {', '.join(self.added_owners)}\n" + if self.degraded_owners: + output += f"🔻 Degraded owners: {', '.join(self.degraded_owners)}\n" + if self.members_without_team: + output += f"⚠️ Members without team: {', '.join(self.members_without_team)}\n" + if self.removed_members: + output += ( + f"❌ Members removed from organisation: {', '.join(self.removed_members)}\n" + ) + if self.teams: + output += "\n🤝 Team Changes:\n" + for team, tchanges in self.teams.items(): + output += f" 🔹 {team}:\n" + if tchanges.unconfigured: + output += " ⚠️ Is/was unconfigured\n" + if tchanges.newly_created: + output += " 🆕 Has been created\n" + if tchanges.deleted: + output += " ❌ Has been deleted\n" + if tchanges.changed_config: + output += " 🔧 Changed config:\n" + for item in tchanges.changed_config: + output += f" - {item}\n" + if tchanges.added_members: + output += " ➕ Added members:\n" + for item in tchanges.added_members: + output += f" - {item}\n" + if tchanges.changed_members_role: + output += ' 🔄 Changed members role:"' + for item in tchanges.changed_members_role: + output += f" - {item}\n" + if tchanges.removed_members: + output += " ❌ Removed members:\n" + for item in tchanges.removed_members: + output += f" - {item}\n" + if tchanges.pending_members: + output += " ⏳ Pending members:\n" + for item in tchanges.pending_members: + output += f" - {item}\n" + if self.repos: + output += "\n📂 Repository Changes:\n" + for repo, rchanges in self.repos.items(): + output += f" 🔹 {repo}:\n" + if rchanges.changed_permissions_for_teams: + output += " 🔧 Changed permissions for teams:\n" + for item in rchanges.changed_permissions_for_teams: + output += f" - {item}\n" + if rchanges.removed_teams: + output += " ❌ Removed teams:\n" + for item in rchanges.removed_teams: + output += f" - {item}\n" + if rchanges.unconfigured_teams_with_permissions: + output += " ⚠️ Unconfigured teams with permissions:\n" + for item in rchanges.unconfigured_teams_with_permissions: + output += f" - {item}\n" + if rchanges.removed_collaborators: + output += " ❌ Removed collaborators:\n" + for item in rchanges.removed_collaborators: + output += f" - {item}\n" + + print(output.strip()) diff --git a/gh_org_mgr/manage.py b/gh_org_mgr/manage.py index 5f04688..09da494 100644 --- a/gh_org_mgr/manage.py +++ b/gh_org_mgr/manage.py @@ -8,9 +8,10 @@ import logging import sys -from . import __version__, configure_logger +from . import __version__ from ._config import parse_config_files from ._gh_org import GHorg +from ._helpers import configure_logger, log_progress from ._setup_team import setup_team # Main parser with root-level flags @@ -26,7 +27,8 @@ # Common flags, usable for all effective subcommands common_flags = argparse.ArgumentParser(add_help=False) # No automatic help to avoid duplication -common_flags.add_argument("--debug", action="store_true", help="Get verbose logging output") +common_flags.add_argument("-v", "--verbose", action="store_true", help="Get INFO logging output") +common_flags.add_argument("-vv", "--debug", action="store_true", help="Get DEBUG logging output") # Sync commands parser_sync = subparsers.add_parser( @@ -40,6 +42,13 @@ required=True, help="Path to the directory in which the configuration of an GitHub organisation is located", ) +parser_sync.add_argument( + "-o", + "--output", + help="Output format for report", + choices=["json", "text"], + default="text", +) parser_sync.add_argument("--dry", action="store_true", help="Do not make any changes at GitHub") parser_sync.add_argument( "-A", @@ -88,14 +97,15 @@ def main(): # Process arguments args = parser.parse_args() - configure_logger(args.debug) + configure_logger(verbose=args.verbose, debug=args.debug) # Sync command if args.command == "sync": + log_progress("Preparing...") if args.dry: logging.info("Dry-run mode activated, will not make any changes at GitHub") if args.force: - logging.info("Force mode activated, will make potentially dangerous actions") + logging.warning("Force mode activated, will make potentially dangerous actions") org = GHorg() @@ -122,33 +132,47 @@ def main(): org.ratelimit() # Synchronise organisation owners + log_progress("Synchronising organisation owners...") org.sync_org_owners(dry=args.dry, force=args.force) # Create teams that aren't present at Github yet + log_progress("Creating missing teams...") org.create_missing_teams(dry=args.dry) # Configure general settings of teams + log_progress("Configuring team settings...") org.sync_current_teams_settings(dry=args.dry) # Synchronise the team memberships + log_progress("Synchronising team memberships...") org.sync_teams_members(dry=args.dry) # Report and act on teams that are not configured locally + log_progress("Checking for unconfigured teams...") org.get_unconfigured_teams( dry=args.dry, delete_unconfigured_teams=cfg_app.get("delete_unconfigured_teams", False), ) # Report and act on organisation members that do not belong to any team + log_progress("Checking for members without team...") org.get_members_without_team( dry=args.dry, remove_members_without_team=cfg_app.get("remove_members_without_team", False), ) # Synchronise the permissions of teams for all repositories + log_progress("Synchronising team permissions...") org.sync_repo_permissions(dry=args.dry, ignore_archived=args.ignore_archived) # Remove individual collaborator permissions if they are higher than the one # from team membership (or if they are in no configured team at all) + log_progress("Synchronising individual collaborator permissions...") org.sync_repo_collaborator_permissions(dry=args.dry) # Debug output + log_progress("") # clear progress logging.debug("Final dataclass:\n%s", org.pretty_print_dataclass()) org.ratelimit() + # Print changes + org.stats.print_changes( + orgname=cfg_org.get("org_name", ""), output=args.output, dry=args.dry + ) + # Setup Team command elif args.command == "setup-team": setup_team(team_name=args.name, config_path=args.config, file_path=args.file) diff --git a/poetry.lock b/poetry.lock index 66dacf9..375fc41 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -6,6 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -17,6 +18,7 @@ version = "4.8.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"}, {file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"}, @@ -30,7 +32,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -39,6 +41,7 @@ version = "3.3.8" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.9.0" +groups = ["dev"] files = [ {file = "astroid-3.3.8-py3-none-any.whl", hash = "sha256:187ccc0c248bfbba564826c26f070494f7bc964fd286b6d9fff4420e55de828c"}, {file = "astroid-3.3.8.tar.gz", hash = "sha256:a88c7994f914a4ea8572fac479459f4955eeccc877be3f2d959a33273b0cf40b"}, @@ -53,6 +56,7 @@ version = "25.1.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"}, {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"}, @@ -99,6 +103,7 @@ version = "2.5.post1" description = "Bash style brace expander." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "bracex-2.5.post1-py3-none-any.whl", hash = "sha256:13e5732fec27828d6af308628285ad358047cec36801598368cb28bc631dbaf6"}, {file = "bracex-2.5.post1.tar.gz", hash = "sha256:12c50952415bfa773d2d9ccb8e79651b8cdb1f31a42f6091b804f6ba2b4a66b6"}, @@ -110,6 +115,7 @@ version = "0.32.2" description = "Version bump your Python project" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "bump_my_version-0.32.2-py3-none-any.whl", hash = "sha256:fb93c5a70a2368354bcac4457d22f282dc66a7bd241241f5a4e3d49002dece36"}, {file = "bump_my_version-0.32.2.tar.gz", hash = "sha256:fc1c686af66c39c44d2f19c4d29597fcc2f938bc19046b8df26bfa44c93f11c0"}, @@ -132,6 +138,7 @@ version = "2025.1.31" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, @@ -143,6 +150,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -222,6 +230,7 @@ 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" +groups = ["main"] files = [ {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"}, @@ -323,6 +332,7 @@ version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, @@ -337,6 +347,8 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -348,6 +360,7 @@ version = "44.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] files = [ {file = "cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009"}, {file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f"}, @@ -386,10 +399,10 @@ files = [ cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0) ; python_version >= \"3.8\""] docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] -nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] -pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""] +pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] @@ -401,6 +414,7 @@ version = "1.2.18" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +groups = ["main"] files = [ {file = "Deprecated-1.2.18-py2.py3-none-any.whl", hash = "sha256:bd5011788200372a32418f888e326a09ff80d0214bd961147cfed01b5c018eec"}, {file = "deprecated-1.2.18.tar.gz", hash = "sha256:422b6f6d859da6f2ef57857761bfb392480502a64c3028ca9bbe86085d72115d"}, @@ -410,7 +424,7 @@ files = [ wrapt = ">=1.10,<2" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "setuptools ; python_version >= \"3.12\"", "tox"] [[package]] name = "dill" @@ -418,6 +432,7 @@ version = "0.3.9" description = "serialize all of Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"}, {file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"}, @@ -433,6 +448,8 @@ version = "1.2.2" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, @@ -447,6 +464,7 @@ version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, @@ -458,6 +476,7 @@ version = "1.0.7" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, @@ -479,6 +498,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -491,7 +511,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -503,6 +523,7 @@ version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -517,6 +538,7 @@ version = "6.0.0" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.9.0" +groups = ["dev"] files = [ {file = "isort-6.0.0-py3-none-any.whl", hash = "sha256:567954102bb47bb12e0fae62606570faacddd441e45683968c8d1734fb1af892"}, {file = "isort-6.0.0.tar.gz", hash = "sha256:75d9d8a1438a9432a7d7b54f2d3b45cad9a4a0fdba43617d9873379704a8bdf1"}, @@ -532,6 +554,7 @@ version = "3.0.0" description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, @@ -556,6 +579,7 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["dev"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -567,6 +591,7 @@ version = "0.1.2" description = "Markdown URL utilities" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -578,6 +603,7 @@ version = "1.15.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, @@ -631,6 +657,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -642,6 +669,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -653,6 +681,7 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -664,6 +693,7 @@ version = "4.3.6" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, @@ -680,6 +710,7 @@ version = "3.0.50" description = "Library for building powerful interactive command lines in Python" optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198"}, {file = "prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab"}, @@ -694,6 +725,7 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -705,6 +737,7 @@ version = "2.10.6" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584"}, {file = "pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236"}, @@ -717,7 +750,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -725,6 +758,7 @@ version = "2.27.2" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa"}, {file = "pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c"}, @@ -837,6 +871,7 @@ version = "2.7.1" description = "Settings management using Pydantic" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd"}, {file = "pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93"}, @@ -857,6 +892,7 @@ version = "2.6.1" description = "Use the full Github API v3" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "PyGithub-2.6.1-py3-none-any.whl", hash = "sha256:6f2fa6d076ccae475f9fc392cc6cdbd54db985d4f69b8833a28397de75ed6ca3"}, {file = "pygithub-2.6.1.tar.gz", hash = "sha256:b5c035392991cca63959e9453286b41b54d83bf2de2daa7d7ff7e4312cebf3bf"}, @@ -876,6 +912,7 @@ version = "2.19.1" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, @@ -890,6 +927,7 @@ version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, @@ -910,6 +948,7 @@ version = "3.3.4" description = "python code static checker" optional = false python-versions = ">=3.9.0" +groups = ["dev"] files = [ {file = "pylint-3.3.4-py3-none-any.whl", hash = "sha256:289e6a1eb27b453b08436478391a48cd53bb0efb824873f949e709350f3de018"}, {file = "pylint-3.3.4.tar.gz", hash = "sha256:74ae7a38b177e69a9b525d0794bd8183820bfa7eb68cc1bee6e8ed22a42be4ce"}, @@ -921,7 +960,7 @@ colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, + {version = ">=0.3.6", markers = "python_version == \"3.11\""}, ] isort = ">=4.2.5,<5.13.0 || >5.13.0,<7" mccabe = ">=0.6,<0.8" @@ -939,6 +978,7 @@ version = "1.5.0" description = "Python binding to the Networking and Cryptography (NaCl) library" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1"}, {file = "PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92"}, @@ -965,6 +1005,7 @@ version = "1.0.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, @@ -979,6 +1020,7 @@ version = "8.0.4" description = "A Python slugify application that also handles Unicode" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856"}, {file = "python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8"}, @@ -996,6 +1038,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -1058,6 +1101,7 @@ version = "2.1.0" description = "Python library to build pretty command line user prompts ⭐️" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "questionary-2.1.0-py3-none-any.whl", hash = "sha256:44174d237b68bc828e4878c763a9ad6790ee61990e0ae72927694ead57bab8ec"}, {file = "questionary-2.1.0.tar.gz", hash = "sha256:6302cdd645b19667d8f6e6634774e9538bfcd1aad9be287e743d96cacaf95587"}, @@ -1072,6 +1116,7 @@ version = "2.32.3" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, @@ -1093,6 +1138,7 @@ version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, @@ -1112,6 +1158,7 @@ version = "1.8.6" description = "Format click help output nicely with rich" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "rich_click-1.8.6-py3-none-any.whl", hash = "sha256:55fb571bad7d3d69ac43ca45f05b44616fd019616161b1815ff053567b9a8e22"}, {file = "rich_click-1.8.6.tar.gz", hash = "sha256:8a2448fd80e3d4e16fcb3815bfbc19be9bae75c9bb6aedf637901e45f3555752"}, @@ -1132,6 +1179,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -1143,6 +1191,7 @@ version = "1.3" description = "The most basic Text::Unidecode port" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93"}, {file = "text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8"}, @@ -1154,6 +1203,8 @@ version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1195,6 +1246,7 @@ version = "0.13.2" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, @@ -1206,6 +1258,7 @@ version = "6.0.12.20241230" description = "Typing stubs for PyYAML" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {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"}, @@ -1217,6 +1270,7 @@ version = "2.32.0.20241016" description = "Typing stubs for requests" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "types-requests-2.32.0.20241016.tar.gz", hash = "sha256:0d9cad2f27515d0e3e3da7134a1b6f28fb97129d86b867f24d9c726452634d95"}, {file = "types_requests-2.32.0.20241016-py3-none-any.whl", hash = "sha256:4195d62d6d3e043a4eaaf08ff8a62184584d2e8684e9d2aa178c7915a7da3747"}, @@ -1231,6 +1285,7 @@ version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, @@ -1242,13 +1297,14 @@ version = "2.3.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df"}, {file = "urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1259,6 +1315,7 @@ version = "10.0" description = "Wildcard/glob file name matcher." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a"}, {file = "wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a"}, @@ -1273,6 +1330,7 @@ version = "0.2.13" description = "Measures the displayed width of unicode strings in a terminal" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, @@ -1284,6 +1342,7 @@ version = "1.17.2" description = "Module for decorators, wrappers and monkey patching." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984"}, {file = "wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22"}, @@ -1367,6 +1426,6 @@ files = [ ] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = "^3.10" content-hash = "5a3b8fd4aa08f841891af43da6c48a30ef7a9ff6512f8b41c53da6a6ad54105e"