From d81e25b11bc87d07842554b02fcd17d6c7d4d494 Mon Sep 17 00:00:00 2001 From: Kamaal Farah Date: Sun, 17 Dec 2023 14:06:30 +0100 Subject: [PATCH] Adding acknowlegements exctractor method --- justfile | 9 + requirements.txt | 1 + .../actions/acknowledgments.py | 307 ++++++++++++++++++ src/xctools_kamaalio/cli.py | 4 + tests/test_acknowledgments.py | 51 +++ 5 files changed, 372 insertions(+) create mode 100644 src/xctools_kamaalio/actions/acknowledgments.py create mode 100644 tests/test_acknowledgments.py diff --git a/justfile b/justfile index 0b01a32..84e2ff5 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,7 @@ build: rm -rf dist . .venv/bin/activate python3 -m build + just install-self upload: #!/bin/bash @@ -18,6 +19,12 @@ upload: . .venv/bin/activate twine upload dist/* +test: + #!/bin/bash + + . .venv/bin/activate + pytest + install-self: #!/bin/bash @@ -25,6 +32,8 @@ install-self: pip install -e . init-venv: + #!/bin/bash + python3 -m venv .venv . .venv/bin/activate just install-deps diff --git a/requirements.txt b/requirements.txt index 7874469..c5b19c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ build==0.10.0 click==8.1.6 +pytest==7.4.3 twine==4.0.2 diff --git a/src/xctools_kamaalio/actions/acknowledgments.py b/src/xctools_kamaalio/actions/acknowledgments.py new file mode 100644 index 0000000..49e38fd --- /dev/null +++ b/src/xctools_kamaalio/actions/acknowledgments.py @@ -0,0 +1,307 @@ +from dataclasses import asdict, dataclass +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import TypedDict + + +def acknowledgments(): + arguments = parse_arguments() + + packages_directory = get_packages_directory(scheme=arguments["scheme"]) + packages_licenses = get_packages_licenses(packages_directory=packages_directory) + + package_file_content = decode_package_file() + packages = package_file_content_to_acknowledgments( + package_file_content=package_file_content, packages_licenses=packages_licenses + ) + + contributors_list = subprocess.getoutput( + f'git log "--pretty=format:%an <%ae>"' + ).splitlines() + formatted_contributors = format_contributors(contributors_list=contributors_list) + acknowledgements = Acknowledgements( + packages=packages, contributors=formatted_contributors + ) + + output_path = Path(arguments["output"]) / "Acknowledgements.json" + output_path.write_text(acknowledgements.to_json(indent=2)) + + print("done writing acknowledgements ✨") + + +def format_contributors(contributors_list: list[str]): + contributor_names_mapped_by_emails: dict[str, list[str]] = {} + for contributor_entry in contributors_list: + splitted_contributor_entry = contributor_entry.split("<") + contributor_name = ( + "".join(splitted_contributor_entry[:-1]) + .strip() + .replace("<", "") + .replace(">", "") + ) + email = splitted_contributor_entry[-1].strip().replace("<", "").replace(">", "") + + contributor_names_mapped_by_emails[ + email + ] = contributor_names_mapped_by_emails.get(email, []) + [contributor_name] + + contributors: list[Contributor] = [] + for email, contributor_names in contributor_names_mapped_by_emails.items(): + longest_contributor_name = "" + + for contributor_name in contributor_names: + if contributor_name == "kamaal111" or contributor_name == "Kamaal": + contributor_name = "Kamaal Farah" + if len(contributor_name) > len(longest_contributor_name): + longest_contributor_name = contributor_name + + contributors.append( + Contributor( + name=longest_contributor_name, + email=email, + contributions=len(contributor_names), + ) + ) + + merged_contributors: list[Contributor] = [] + for contributor in contributors: + contributor_first_names_names = map( + lambda contributor: contributor.first_name, merged_contributors + ) + if contributor.first_name in contributor_first_names_names: + for index, merged_author in enumerate(merged_contributors): + first_name_is_the_same = ( + contributor.first_name == merged_author.first_name + ) + name_is_the_same = contributor.name == merged_author.name + + one_of_authors_has_just_a_single_name = ( + contributor.has_just_a_single_name + or merged_author.has_just_a_single_name + ) and len(contributor.name_components) != len( + merged_author.name_components + ) + + if first_name_is_the_same and ( + one_of_authors_has_just_a_single_name or name_is_the_same + ): + if len(contributor.name) > len(merged_author.name): + longest_author_name = contributor.name + else: + longest_author_name = merged_author.name + + merged_contributors[index] = Contributor( + name=longest_author_name, + email=None, + contributions=contributor.contributions + + merged_author.contributions, + ) + else: + contributor.email = None + merged_contributors.append(contributor) + + return sorted( + sorted(merged_contributors, key=lambda contributor: contributor.name), + key=lambda contributor: contributor.contributions, + reverse=True, + ) + + +def parse_arguments(): + arguments: Arguments = {} + + skip_next_value = False + for index, arg in enumerate(sys.argv[1:]): + if skip_next_value: + skip_next_value = False + continue + + def get_next_value(): + if index + 1 < len(sys.argv): + nonlocal skip_next_value + skip_next_value = True + + return sys.argv[index + 2] + + if arg == "--scheme" and (scheme := get_next_value()): + arguments["scheme"] = scheme + if arg == "--output" and (output := get_next_value()): + arguments["output"] = output + + if arguments.get("scheme") is None: + raise Exception("Please provide a scheme with the --scheme flag") + + if arguments.get("output") is None: + raise Exception("Please provide a output path with the --output flag") + + return arguments + + +def get_path_from_root_ending_with(search_string: str) -> str | None: + current_work_directory = os.getcwd() + root_files = os.listdir(current_work_directory) + + for file in root_files: + if file.endswith(search_string): + return file + + +def get_packages_licenses(packages_directory: str): + licenses: dict[str, str] = {} + for root, _, files in os.walk(packages_directory): + if "LICENSE" not in files: + continue + + package_name = root.split("/")[-1] + + license_path = os.path.join(root, "LICENSE") + with open(license_path, "r") as file: + licenses[package_name] = file.read() + + return licenses + + +def package_file_content_to_acknowledgments( + package_file_content: "PackageFileContent", packages_licenses: dict[str, str] +): + packages: list[AcknowledgementPackage] = [] + if package_object := package_file_content.get("object"): + for pin in package_object["pins"]: + package_name = pin["package"] + url = pin["repositoryURL"] + if url.endswith(".git"): + url = url[:-4] + + author = url.split("/")[-2] + + acknowledgement = AcknowledgementPackage( + name=package_name, + url=url, + author=author, + license=packages_licenses.get(package_name), + ) + packages.append(acknowledgement) + else: + for pin in package_file_content["pins"]: + url = pin["location"] + if url.endswith(".git"): + url = url[:-4] + + url_split_by_separator = url.split("/") + package_name = url_split_by_separator[-1] + author = url_split_by_separator[-2] + acknowledgement = AcknowledgementPackage( + name=package_name, + url=url, + author=author, + license=packages_licenses.get(package_name), + ) + packages.append(acknowledgement) + + return packages + + +def decode_package_file() -> "PackageFileContent": + if workspace_path := get_path_from_root_ending_with(search_string=".xcworkspace"): + path = Path(workspace_path) / "xcshareddata" / "swiftpm" / "Package.resolved" + return json.loads(path.read_text()) + + raise Exception("Workspace not found at root") + + +def get_packages_directory(scheme: str): + project_path = get_path_from_root_ending_with(search_string=".xcodeproj") + if project_path is None: + raise Exception("Project not found at root") + + output = subprocess.getoutput( + f'xcodebuild -project {project_path} -target "{scheme}" -showBuildSettings' + ) + + output_search_line = " BUILD_DIR = " + for line in output.splitlines(): + if output_search_line in line: + return line.replace(output_search_line, "").replace( + "Build/Products", "SourcePackages/checkouts" + ) + + raise Exception(f"Build directory not found from {output=}") + + +@dataclass +class Acknowledgements: + packages: list["AcknowledgementPackage"] + contributors: list["Contributor"] + + def to_dict(self): + dictionary_to_return = {} + for key, value in asdict(self).items(): + dictionary_to_return[key] = value + return dictionary_to_return + + def to_json(self, indent: int | None = None): + return json.dumps(self.to_dict(), indent=indent) + + +@dataclass +class Contributor: + name: str + email: str | None + contributions: int + + @property + def first_name(self): + return self.name_components[0] + + @property + def name_components(self): + return self.name.split(" ") + + @property + def has_just_a_single_name(self): + return len(self.name_components) == 1 + + +@dataclass +class AcknowledgementPackage: + name: str + url: str + author: str | None + license: str | None + + +class Arguments(TypedDict): + scheme: str + output: str + + +class PackageFileContentObjectPinState(TypedDict): + branch: str | None + revision: str + version: str | None + + +class PackageFileContentObjectPin(TypedDict): + package: str + repositoryURL: str + state: PackageFileContentObjectPinState + + +class PackageFileContentObject(TypedDict): + pins: list[PackageFileContentObjectPin] + + +class PackageFileContentPin(TypedDict): + identity: str + kind: str + location: str + state: PackageFileContentObjectPinState + + +class PackageFileContent(TypedDict): + version: int + object: PackageFileContentObject | None + pins: list[PackageFileContentPin] | None diff --git a/src/xctools_kamaalio/cli.py b/src/xctools_kamaalio/cli.py index e7b58ae..d1909c7 100644 --- a/src/xctools_kamaalio/cli.py +++ b/src/xctools_kamaalio/cli.py @@ -2,6 +2,7 @@ from kamaaalpy.lists import removed, find_index +from xctools_kamaalio.actions.acknowledgments import acknowledgments from xctools_kamaalio.actions.upload import upload from xctools_kamaalio.actions.archive import archive from xctools_kamaalio.actions.bump_version import bump_version @@ -19,6 +20,7 @@ "trust-swift-plugins", "test", "build", + "acknowledgments", ] @@ -44,6 +46,8 @@ def cli(): test() if action == "build": build() + if action == "acknowledgments": + acknowledgments() class CLIException(Exception): diff --git a/tests/test_acknowledgments.py b/tests/test_acknowledgments.py new file mode 100644 index 0000000..1502cbb --- /dev/null +++ b/tests/test_acknowledgments.py @@ -0,0 +1,51 @@ +import pytest +from xctools_kamaalio.actions.acknowledgments import Contributor, format_contributors + + +@pytest.mark.parametrize( + "contributors_list_input, expected_contributors", + [ + ( + [ + "John ", + "Kamaal ", + "John Smith ", + "Kamaal Farah ", + ], + [ + Contributor(name="John Smith", email=None, contributions=2), + Contributor(name="Kamaal Farah", email=None, contributions=2), + ], + ), + ( + [ + "John ", + "John Smith ", + "Kamaal Farah ", + "Kamaal ", + ], + [ + Contributor(name="John Smith", email=None, contributions=2), + Contributor(name="Kamaal Farah", email=None, contributions=2), + ], + ), + ( + [ + "Kent Clark ", + "John ", + "John Smith ", + "Kamaal Farah ", + "Kamaal ", + ], + [ + Contributor(name="John Smith", email=None, contributions=2), + Contributor(name="Kamaal Farah", email=None, contributions=2), + Contributor(name="Kent Clark", email=None, contributions=1), + ], + ), + ], +) +def test_format_contributors(contributors_list_input, expected_contributors): + contributors = format_contributors(contributors_list_input) + + assert contributors == expected_contributors