diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index e8d2d0e3f..b4a29a187 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -141,6 +141,7 @@ jobs: timeout-minutes: 5 env: GITHUB_WORKSPACE: $GITHUB_WORKSPACE + GITHUB_TOKEN: ${{ secrets.GHA_PAT }} run: | uv run pytest \ -n auto \ diff --git a/pyproject.toml b/pyproject.toml index 26002dd0b..59a720f6a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,6 +143,7 @@ dev-dependencies = [ "isort>=5.13.2", "emoji>=2.14.0", "pytest-benchmark[histogram]>=5.1.0", + "pytest-asyncio<1.0.0,>=0.21.1", "loguru>=0.7.3", "httpx<0.28.2,>=0.28.1", ] diff --git a/src/codegen/git/clients/git_integration_client.py b/src/codegen/git/clients/git_integration_client.py deleted file mode 100644 index 411e1ac97..000000000 --- a/src/codegen/git/clients/git_integration_client.py +++ /dev/null @@ -1,56 +0,0 @@ -import logging -from functools import cached_property - -from github.GithubException import UnknownObjectException -from github.GithubIntegration import GithubIntegration -from github.Installation import Installation -from github.InstallationAuthorization import InstallationAuthorization - -from codegen.git.schemas.github import GithubType - -logger = logging.getLogger(__name__) - - -class GitIntegrationClient: - """Wrapper around PyGithub's GithubIntegration.""" - - github_type: GithubType = GithubType.GithubEnterprise - client: GithubIntegration # PyGithub's GithubIntegration that this class wraps - - def __init__( - self, - github_app_id: str, - github_app_id_private_key: str, - base_url: str | None = None, - ) -> None: - """Initialize a safe wrapper around PyGithub's GithubIntegration. Used for calling Github's integration APIs. (e.g. GitHub Apps)""" - if base_url: - self.client = GithubIntegration(integration_id=github_app_id, private_key=github_app_id_private_key, base_url=base_url) - else: - self.client = GithubIntegration(integration_id=github_app_id, private_key=github_app_id_private_key) - - @cached_property - def name(self) -> str: - return self.client.get_app().name - - def get_org_installation(self, org_name: str) -> Installation | None: - try: - return self.client.get_org_installation(org_name) - except UnknownObjectException as e: - return None - except Exception as e: - logger.warning(f"Error getting org installation with org_name: {org_name}\n\t{e}") - return None - - def get_app_installation(self, installation_id: int) -> Installation | None: - try: - return self.client.get_app_installation(installation_id) - except UnknownObjectException as e: - return None - except Exception as e: - logger.warning(f"Error getting app installation with installation_id: {installation_id}\n\t{e}") - return None - - def get_access_token(self, installation_id: int, permissions: dict[str, str] | None = None) -> InstallationAuthorization: - # TODO: add try/catch error handling around this - return self.client.get_access_token(installation_id=installation_id, permissions=permissions) diff --git a/src/codegen/git/clients/git_repo_client.py b/src/codegen/git/clients/git_repo_client.py index b50320073..a4af33846 100644 --- a/src/codegen/git/clients/git_repo_client.py +++ b/src/codegen/git/clients/git_repo_client.py @@ -14,9 +14,7 @@ from github.Tag import Tag from github.Workflow import Workflow -from codegen.git.clients.github_client_factory import GithubClientFactory -from codegen.git.clients.types import GithubClientType -from codegen.git.schemas.github import GithubScope, GithubType +from codegen.git.clients.github_client import GithubClient from codegen.git.schemas.repo_config import RepoConfig from codegen.git.utils.format import format_comparison @@ -27,33 +25,27 @@ class GitRepoClient: """Wrapper around PyGithub's Remote Repository.""" repo_config: RepoConfig - github_type: GithubType = GithubType.GithubEnterprise - gh_client: GithubClientType - read_client: Repository - access_scope: GithubScope - __write_client: Repository | None # Will not be initialized if access scope is read-only + gh_client: GithubClient + _repo: Repository - def __init__(self, repo_config: RepoConfig, github_type: GithubType = GithubType.GithubEnterprise, access_scope: GithubScope = GithubScope.READ) -> None: + def __init__(self, repo_config: RepoConfig) -> None: self.repo_config = repo_config - self.github_type = github_type - self.gh_client = GithubClientFactory.create_from_repo(self.repo_config, github_type) - self.read_client = self._create_client(GithubScope.READ) - self.__write_client = self._create_client(GithubScope.WRITE) if access_scope == GithubScope.WRITE else None - self.access_scope = access_scope - - def _create_client(self, github_scope: GithubScope = GithubScope.READ) -> Repository: - client = self.gh_client.get_repo_by_full_name(self.repo_config.full_name, github_scope=github_scope) + self.gh_client = self._create_github_client() + self._repo = self._create_client() + + def _create_github_client(self) -> GithubClient: + return GithubClient() + + def _create_client(self) -> Repository: + client = self.gh_client.get_repo_by_full_name(self.repo_config.full_name) if not client: - msg = f"Repo {self.repo_config.full_name} not found in {self.github_type.value}!" + msg = f"Repo {self.repo_config.full_name} not found!" raise ValueError(msg) return client @property - def _write_client(self) -> Repository: - if self.__write_client is None: - msg = "Cannot perform write operations with read-only client! Try setting github_scope to GithubScope.WRITE." - raise ValueError(msg) - return self.__write_client + def repo(self) -> Repository: + return self._repo #################################################################################################################### # PROPERTIES @@ -65,7 +57,7 @@ def id(self) -> int: @property def default_branch(self) -> str: - return self.read_client.default_branch + return self.repo.default_branch #################################################################################################################### # CONTENTS @@ -76,7 +68,7 @@ def get_contents(self, file_path: str, ref: str | None = None) -> str | None: if not ref: ref = self.default_branch try: - file = self.read_client.get_contents(file_path, ref=ref) + file = self.repo.get_contents(file_path, ref=ref) file_contents = file.decoded_content.decode("utf-8") # type: ignore[union-attr] return file_contents except UnknownObjectException: @@ -100,7 +92,7 @@ def get_last_modified_date_of_path(self, path: str) -> datetime: str: The last modified date of the directory in ISO format (YYYY-MM-DDTHH:MM:SSZ). """ - commits = self.read_client.get_commits(path=path) + commits = self.repo.get_commits(path=path) if commits.totalCount > 0: # Get the date of the latest commit last_modified_date = commits[0].commit.committer.date @@ -124,7 +116,7 @@ def create_review_comment( start_line: Opt[int] = NotSet, ) -> None: # TODO: add protections (ex: can write to PR) - writeable_pr = self._write_client.get_pull(pull.number) + writeable_pr = self.repo.get_pull(pull.number) writeable_pr.create_review_comment( body=body, commit=commit, @@ -140,7 +132,7 @@ def create_issue_comment( body: str, ) -> None: # TODO: add protections (ex: can write to PR) - writeable_pr = self._write_client.get_pull(pull.number) + writeable_pr = self.repo.get_pull(pull.number) writeable_pr.create_issue_comment(body=body) #################################################################################################################### @@ -163,7 +155,7 @@ def get_pull_by_branch_and_state( head_branch_name = f"{self.repo_config.organization_name}:{head_branch_name}" # retrieve all pulls ordered by created descending - prs = self.read_client.get_pulls(base=base_branch_name, head=head_branch_name, state=state, sort="created", direction="desc") + prs = self.repo.get_pulls(base=base_branch_name, head=head_branch_name, state=state, sort="created", direction="desc") if prs.totalCount > 0: return prs[0] else: @@ -174,7 +166,7 @@ def get_pull_safe(self, number: int) -> PullRequest | None: TODO: catching UnknownObjectException is common enough to create a decorator """ try: - pr = self.read_client.get_pull(number) + pr = self.repo.get_pull(number) return pr except UnknownObjectException as e: return None @@ -209,10 +201,10 @@ def create_pull( if base_branch_name is None: base_branch_name = self.default_branch try: - pr = self._write_client.create_pull(title=title or f"Draft PR for {head_branch_name}", body=body or "", head=head_branch_name, base=base_branch_name, draft=draft) + pr = self.repo.create_pull(title=title or f"Draft PR for {head_branch_name}", body=body or "", head=head_branch_name, base=base_branch_name, draft=draft) logger.info(f"Created pull request for head branch: {head_branch_name} at {pr.html_url}") # NOTE: return a read-only copy to prevent people from editing it - return self.read_client.get_pull(pr.number) + return self.repo.get_pull(pr.number) except GithubException as ge: logger.warning(f"Failed to create PR got GithubException\n\t{ge}") except Exception as e: @@ -235,15 +227,15 @@ def squash_and_merge(self, base_branch_name: str, head_branch_name: str, squash_ merge = squash_pr.merge(commit_message=squash_commit_msg, commit_title=squash_commit_title, merge_method="squash") # type: ignore[arg-type] def edit_pull(self, pull: PullRequest, title: Opt[str] = NotSet, body: Opt[str] = NotSet, state: Opt[str] = NotSet) -> None: - writable_pr = self._write_client.get_pull(pull.number) + writable_pr = self.repo.get_pull(pull.number) writable_pr.edit(title=title, body=body, state=state) def add_label_to_pull(self, pull: PullRequest, label: Label) -> None: - writeable_pr = self._write_client.get_pull(pull.number) + writeable_pr = self.repo.get_pull(pull.number) writeable_pr.add_to_labels(label) def remove_label_from_pull(self, pull: PullRequest, label: Label) -> None: - writeable_pr = self._write_client.get_pull(pull.number) + writeable_pr = self.repo.get_pull(pull.number) writeable_pr.remove_from_labels(label) #################################################################################################################### @@ -264,7 +256,7 @@ def get_or_create_branch(self, new_branch_name: str, base_branch_name: str | Non def get_branch_safe(self, branch_name: str, attempts: int = 1, wait_seconds: int = 1) -> Branch | None: for i in range(attempts): try: - return self.read_client.get_branch(branch_name) + return self.repo.get_branch(branch_name) except GithubException as e: if e.status == 404 and i < attempts - 1: time.sleep(wait_seconds) @@ -276,14 +268,14 @@ def create_branch(self, new_branch_name: str, base_branch_name: str | None = Non if base_branch_name is None: base_branch_name = self.default_branch - base_branch = self.read_client.get_branch(base_branch_name) + base_branch = self.repo.get_branch(base_branch_name) # TODO: also wrap git ref. low pri b/c the only write operation on refs is creating one - self._write_client.create_git_ref(sha=base_branch.commit.sha, ref=f"refs/heads/{new_branch_name}") + self.repo.create_git_ref(sha=base_branch.commit.sha, ref=f"refs/heads/{new_branch_name}") branch = self.get_branch_safe(new_branch_name) return branch def create_branch_from_sha(self, new_branch_name: str, base_sha: str) -> Branch | None: - self._write_client.create_git_ref(ref=f"refs/heads/{new_branch_name}", sha=base_sha) + self.repo.create_git_ref(ref=f"refs/heads/{new_branch_name}", sha=base_sha) branch = self.get_branch_safe(new_branch_name) return branch @@ -295,7 +287,7 @@ def delete_branch(self, branch_name: str) -> None: branch_to_delete = self.get_branch_safe(branch_name) if branch_to_delete: - ref_to_delete = self._write_client.get_git_ref(f"heads/{branch_name}") + ref_to_delete = self.repo.get_git_ref(f"heads/{branch_name}") ref_to_delete.delete() logger.info(f"Branch: {branch_name} deleted successfully!") else: @@ -307,7 +299,7 @@ def delete_branch(self, branch_name: str) -> None: def get_commit_safe(self, commit_sha: str) -> Commit | None: try: - return self.read_client.get_commit(commit_sha) + return self.repo.get_commit(commit_sha) except UnknownObjectException as e: logger.warning(f"Commit {commit_sha} not found:\n\t{e}") return None @@ -338,7 +330,7 @@ def compare_branches(self, base_branch_name: str | None, head_branch_name: str, # NOTE: base utility that other compare functions should try to use def compare(self, base: str, head: str, show_commits: bool = False) -> str: - comparison = self.read_client.compare(base, head) + comparison = self.repo.compare(base, head) return format_comparison(comparison, show_commits=show_commits) #################################################################################################################### @@ -349,7 +341,7 @@ def compare(self, base: str, head: str, show_commits: bool = False) -> str: def get_label_safe(self, label_name: str) -> Label | None: try: label_name = label_name.strip() - label = self.read_client.get_label(label_name) + label = self.repo.get_label(label_name) return label except UnknownObjectException as e: return None @@ -360,10 +352,10 @@ def get_label_safe(self, label_name: str) -> Label | None: def create_label(self, label_name: str, color: str) -> Label: # TODO: also offer description field label_name = label_name.strip() - self._write_client.create_label(label_name, color) + self.repo.create_label(label_name, color) # TODO: is there a way to convert new_label to a read-only label without making another API call? # NOTE: return a read-only label to prevent people from editing it - return self.read_client.get_label(label_name) + return self.repo.get_label(label_name) def get_or_create_label(self, label_name: str, color: str) -> Label: existing_label = self.get_label_safe(label_name) @@ -377,7 +369,7 @@ def get_or_create_label(self, label_name: str, color: str) -> Label: def get_check_suite_safe(self, check_suite_id: int) -> CheckSuite | None: try: - return self.read_client.get_check_suite(check_suite_id) + return self.repo.get_check_suite(check_suite_id) except UnknownObjectException as e: return None except Exception as e: @@ -390,7 +382,7 @@ def get_check_suite_safe(self, check_suite_id: int) -> CheckSuite | None: def get_check_run_safe(self, check_run_id: int) -> CheckRun | None: try: - return self.read_client.get_check_run(check_run_id) + return self.repo.get_check_run(check_run_id) except UnknownObjectException as e: return None except Exception as e: @@ -406,8 +398,8 @@ def create_check_run( conclusion: Opt[str] = NotSet, output: Opt[dict[str, str | list[dict[str, str | int]]]] = NotSet, ) -> CheckRun: - new_check_run = self._write_client.create_check_run(name=name, head_sha=head_sha, details_url=details_url, status=status, conclusion=conclusion, output=output) - return self.read_client.get_check_run(new_check_run.id) + new_check_run = self.repo.create_check_run(name=name, head_sha=head_sha, details_url=details_url, status=status, conclusion=conclusion, output=output) + return self.repo.get_check_run(new_check_run.id) #################################################################################################################### # WORKFLOW @@ -415,7 +407,7 @@ def create_check_run( def get_workflow_safe(self, file_name: str) -> Workflow | None: try: - return self.read_client.get_workflow(file_name) + return self.repo.get_workflow(file_name) except UnknownObjectException as e: return None except Exception as e: @@ -423,7 +415,7 @@ def get_workflow_safe(self, file_name: str) -> Workflow | None: return None def create_workflow_dispatch(self, workflow: Workflow, ref: Branch | Tag | Commit | str, inputs: Opt[dict] = NotSet): - writeable_workflow = self._write_client.get_workflow(workflow.id) + writeable_workflow = self.repo.get_workflow(workflow.id) writeable_workflow.create_dispatch(ref=ref, inputs=inputs) #################################################################################################################### @@ -439,5 +431,5 @@ def merge_upstream(self, branch_name: str) -> bool: """ assert isinstance(branch_name, str), branch_name post_parameters = {"branch": branch_name} - status, _, _ = self._write_client._requester.requestJson("POST", f"{self._write_client.url}/merge-upstream", input=post_parameters) + status, _, _ = self.repo._requester.requestJson("POST", f"{self.repo.url}/merge-upstream", input=post_parameters) return status == 200 diff --git a/src/codegen/git/clients/github_client.py b/src/codegen/git/clients/github_client.py index 099342fa0..095a96b97 100644 --- a/src/codegen/git/clients/github_client.py +++ b/src/codegen/git/clients/github_client.py @@ -7,9 +7,7 @@ from github.Organization import Organization from github.Repository import Repository -from codegen.git.configs.token import get_token_for_repo_config -from codegen.git.schemas.github import GithubScope, GithubType -from codegen.git.schemas.repo_config import RepoConfig +from codegen.git.configs.config import config logger = logging.getLogger(__name__) @@ -17,54 +15,40 @@ class GithubClient: """Manages interaction with GitHub""" - type: GithubType = GithubType.Github - base_url: str = Consts.DEFAULT_BASE_URL - read_client: Github - _write_client: Github + base_url: str + _client: Github - @classmethod - def from_repo_config(cls, repo_config: RepoConfig) -> Self: - gh_wrapper = cls() - gh_wrapper.read_client = gh_wrapper._create_client_for_repo_config(repo_config, github_scope=GithubScope.READ) - gh_wrapper._write_client = gh_wrapper._create_client_for_repo_config(repo_config, github_scope=GithubScope.WRITE) - return gh_wrapper + def __init__(self, base_url: str = Consts.DEFAULT_BASE_URL): + self.base_url = base_url + self._client = Github(config.GITHUB_TOKEN, base_url=base_url) @classmethod def from_token(cls, token: str | None = None) -> Self: """Option to create a git client from a token""" gh_wrapper = cls() - gh_wrapper.read_client = Github(token, base_url=cls.base_url) - gh_wrapper._write_client = Github(token, base_url=cls.base_url) + gh_wrapper._client = Github(token, base_url=cls.base_url) return gh_wrapper - def _create_client_for_repo_config(self, repo_config: RepoConfig, github_scope: GithubScope = GithubScope.READ) -> Github: - token = get_token_for_repo_config(repo_config=repo_config, github_type=self.type, github_scope=github_scope) - return Github(token, base_url=self.base_url) - - def _get_client_for_scope(self, github_scope: GithubScope) -> Github: - if github_scope is GithubScope.READ: - return self.read_client - elif github_scope is GithubScope.WRITE: - return self._write_client - msg = f"Invalid github scope: {github_scope}" - raise ValueError(msg) + @property + def client(self) -> Github: + return self._client #################################################################################################################### # CHECK RUNS #################################################################################################################### - def get_repo_by_full_name(self, full_name: str, github_scope: GithubScope = GithubScope.READ) -> Repository | None: + def get_repo_by_full_name(self, full_name: str) -> Repository | None: try: - return self._get_client_for_scope(github_scope).get_repo(full_name) + return self._client.get_repo(full_name) except UnknownObjectException as e: return None except Exception as e: logger.warning(f"Error getting repo {full_name}:\n\t{e}") return None - def get_organization(self, org_name: str, github_scope: GithubScope = GithubScope.READ) -> Organization | None: + def get_organization(self, org_name: str) -> Organization | None: try: - return self._get_client_for_scope(github_scope).get_organization(org_name) + return self._client.get_organization(org_name) except UnknownObjectException as e: return None except Exception as e: diff --git a/src/codegen/git/clients/github_client_factory.py b/src/codegen/git/clients/github_client_factory.py deleted file mode 100644 index 12139c8db..000000000 --- a/src/codegen/git/clients/github_client_factory.py +++ /dev/null @@ -1,52 +0,0 @@ -from codegen.git.clients.github_client import GithubClient -from codegen.git.clients.github_enterprise_client import GithubEnterpriseClient -from codegen.git.clients.types import GithubClientType -from codegen.git.schemas.github import GithubType -from codegen.git.schemas.repo_config import RepoConfig - - -class GithubClientFactory: - """Factory for creating GithubClients""" - - # TODO: also allow creating from a organization model - @classmethod - def create_from_repo(cls, repo_config: RepoConfig, github_type: GithubType = GithubType.GithubEnterprise) -> GithubClientType: - """Factory method for creating an instance of a subclass of GithubClientType. - - This method creates and returns an instance of either GithubEnterpriseClient or GithubClient, depending on the specified github_type. It is designed to abstract the instantiation process, - allowing for easy creation of the appropriate GithubClient subclass. - - Defaults to GHE b/c for most cases we should be operating in GHE (i.e. the lowside) and only lowside/highside utils should sync between lowside and highside (i.e. sync between GHE and Github). - - Parameters - ---------- - - repo (RepoModel): The repository model instance which contains necessary data for the GitHub wrapper. - - github_type (GithubType, optional): An enum value specifying the type of GitHub instance. - Defaults to GithubType.GithubEnterprise. - - Returns: - ------- - - GithubClientType: An instance of either GithubEnterpriseClient or GithubClient, depending on the github_type. - - Raises: - ------ - - Exception: If an unknown github_type is provided, the method raises an exception with a message indicating the invalid type. - - """ - if github_type == GithubType.GithubEnterprise: - return GithubEnterpriseClient.from_repo_config(repo_config=repo_config) - elif github_type == GithubType.Github: - return GithubClient.from_repo_config(repo_config=repo_config) - else: - msg = f"Unknown GithubType: {github_type}" - raise Exception(msg) - - @classmethod - def create_from_token(cls, token: str | None = None, github_type: GithubType = GithubType.GithubEnterprise) -> GithubClientType: - if github_type == GithubType.GithubEnterprise: - return GithubEnterpriseClient.from_token(token=token) - elif github_type == GithubType.Github: - return GithubClient.from_token(token=token) - else: - msg = f"Unknown GithubType: {github_type}" - raise Exception(msg) diff --git a/src/codegen/git/clients/github_enterprise_client.py b/src/codegen/git/clients/github_enterprise_client.py deleted file mode 100644 index 4519a02c4..000000000 --- a/src/codegen/git/clients/github_enterprise_client.py +++ /dev/null @@ -1,10 +0,0 @@ -from codegen.git.clients.github_client import GithubClient -from codegen.git.configs.config import config -from codegen.git.schemas.github import GithubType - - -class GithubEnterpriseClient(GithubClient): - """Manages interaction with GitHub Enterprise""" - - type = GithubType.GithubEnterprise - base_url = config.GITHUB_ENTERPRISE_URL diff --git a/src/codegen/git/clients/types.py b/src/codegen/git/clients/types.py deleted file mode 100644 index c0e6f2f37..000000000 --- a/src/codegen/git/clients/types.py +++ /dev/null @@ -1,4 +0,0 @@ -from codegen.git.clients.github_client import GithubClient -from codegen.git.clients.github_enterprise_client import GithubEnterpriseClient - -GithubClientType = GithubClient | GithubEnterpriseClient diff --git a/src/codegen/git/configs/config.py b/src/codegen/git/configs/config.py index 305b68697..db0aaabc3 100644 --- a/src/codegen/git/configs/config.py +++ b/src/codegen/git/configs/config.py @@ -3,17 +3,14 @@ class Config: def __init__(self) -> None: - self.ENV = os.environ.get("ENV", "sandbox") - self.GITHUB_ENTERPRISE_URL = self._get_env_var("GITHUB_ENTERPRISE_URL") - self.LOWSIDE_TOKEN = self._get_env_var("LOWSIDE_TOKEN") - self.HIGHSIDE_TOKEN = self._get_env_var("HIGHSIDE_TOKEN") + self.GITHUB_TOKEN = self._get_env_var("GITHUB_TOKEN") def _get_env_var(self, var_name, required: bool = False) -> str | None: value = os.environ.get(var_name) if value: return value if required: - msg = f"Environment variable {var_name} is not set with ENV={self.ENV}!" + msg = f"Environment variable {var_name} is not set!" raise ValueError(msg) return None diff --git a/src/codegen/git/configs/token.py b/src/codegen/git/configs/token.py deleted file mode 100644 index 61283c2e8..000000000 --- a/src/codegen/git/configs/token.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging - -from codegen.git.configs.config import config -from codegen.git.schemas.github import GithubScope, GithubType -from codegen.git.schemas.repo_config import RepoConfig - -logger = logging.getLogger(__name__) - - -def get_token_for_repo_config( - repo_config: RepoConfig, - github_type: GithubType = GithubType.GithubEnterprise, - github_scope: GithubScope = GithubScope.READ, -) -> str: - # TODO: implement config such that we can retrieve tokens for different repos + read/write scopes - if github_type == GithubType.GithubEnterprise: - return config.LOWSIDE_TOKEN - elif github_type == GithubType.Github: - return config.HIGHSIDE_TOKEN diff --git a/src/codegen/git/models/codemod_context.py b/src/codegen/git/models/codemod_context.py index 543b1b25e..ab97e5b12 100644 --- a/src/codegen/git/models/codemod_context.py +++ b/src/codegen/git/models/codemod_context.py @@ -1,4 +1,5 @@ import logging +from importlib.metadata import version from typing import Any from pydantic import BaseModel @@ -10,7 +11,7 @@ class CodemodContext(BaseModel): - # TODO: add back CODEGEN_VESRION + CODEGEN_VERSION: str = version("codegen") CODEMOD_ID: int | None = None CODEMOD_LINK: str | None = None CODEMOD_AUTHOR: str | None = None diff --git a/src/codegen/git/models/pull_request_context.py b/src/codegen/git/models/pull_request_context.py index 1621abb6b..6729acf63 100644 --- a/src/codegen/git/models/pull_request_context.py +++ b/src/codegen/git/models/pull_request_context.py @@ -2,7 +2,6 @@ from codegen.git.models.github_named_user_context import GithubNamedUserContext from codegen.git.models.pr_part_context import PRPartContext -from codegen.git.schemas.github import GithubType class PullRequestContext(BaseModel): @@ -24,7 +23,6 @@ class PullRequestContext(BaseModel): additions: int | None = None deletions: int | None = None changed_files: int | None = None - github_type: GithubType | None = None webhook_data: dict | None = None @classmethod @@ -47,6 +45,5 @@ def from_payload(cls, webhook_payload: dict) -> "PullRequestContext": additions=webhook_data.get("additions"), deletions=webhook_data.get("deletions"), changed_files=webhook_data.get("changed_files"), - github_type=GithubType.from_url(webhook_data.get("html_url")), webhook_data=webhook_data, ) diff --git a/src/codegen/git/repo_operator/local_repo_operator.py b/src/codegen/git/repo_operator/local_repo_operator.py index e16714d0a..dadc76bc7 100644 --- a/src/codegen/git/repo_operator/local_repo_operator.py +++ b/src/codegen/git/repo_operator/local_repo_operator.py @@ -13,7 +13,6 @@ from codegen.git.clients.git_repo_client import GitRepoClient from codegen.git.repo_operator.repo_operator import RepoOperator from codegen.git.schemas.enums import FetchResult -from codegen.git.schemas.github import GithubType from codegen.git.schemas.repo_config import BaseRepoConfig from codegen.git.utils.clone_url import url_to_github from codegen.git.utils.file_utils import create_files @@ -49,7 +48,6 @@ def __init__( self._repo_path = repo_path self._repo_name = os.path.basename(repo_path) self._github_api_key = github_api_key - self.github_type = GithubType.Github self._remote_git_repo = None os.makedirs(self.repo_path, exist_ok=True) GitCLI.init(self.repo_path) diff --git a/src/codegen/git/repo_operator/remote_repo_operator.py b/src/codegen/git/repo_operator/remote_repo_operator.py index e0d526236..88e3ad008 100644 --- a/src/codegen/git/repo_operator/remote_repo_operator.py +++ b/src/codegen/git/repo_operator/remote_repo_operator.py @@ -10,10 +10,9 @@ from codegen.git.clients.git_repo_client import GitRepoClient from codegen.git.repo_operator.repo_operator import RepoOperator from codegen.git.schemas.enums import CheckoutResult, FetchResult, SetupOption -from codegen.git.schemas.github import GithubScope, GithubType from codegen.git.schemas.repo_config import RepoConfig from codegen.git.utils.clone import clone_or_pull_repo, clone_repo, pull_repo -from codegen.git.utils.clone_url import get_clone_url_for_repo_config, url_to_github +from codegen.git.utils.clone_url import get_authenticated_clone_url_for_repo_config, get_clone_url_for_repo_config, url_to_github from codegen.git.utils.codeowner_utils import create_codeowners_parser_for_repo from codegen.git.utils.remote_progress import CustomRemoteProgress from codegen.shared.performance.stopwatch_utils import stopwatch @@ -27,7 +26,6 @@ class RemoteRepoOperator(RepoOperator): # __init__ attributes repo_config: RepoConfig base_dir: str - github_type: GithubType # lazy attributes _remote_git_repo: GitRepoClient | None = None @@ -41,21 +39,26 @@ def __init__( base_dir: str = "/tmp", setup_option: SetupOption = SetupOption.PULL_OR_CLONE, shallow: bool = True, - github_type: GithubType = GithubType.GithubEnterprise, bot_commit: bool = True, + access_token: str | None = None, ) -> None: - super().__init__(repo_config=repo_config, base_dir=base_dir, bot_commit=bot_commit) - self.github_type = github_type + super().__init__(repo_config=repo_config, base_dir=base_dir, bot_commit=bot_commit, access_token=access_token) self.setup_repo_dir(setup_option=setup_option, shallow=shallow) #################################################################################################################### # PROPERTIES #################################################################################################################### + @property + def clone_url(self) -> str: + if self.access_token: + return get_authenticated_clone_url_for_repo_config(repo=self.repo_config, token=self.access_token) + return super().clone_url + @property def remote_git_repo(self) -> GitRepoClient: if not self._remote_git_repo: - self._remote_git_repo = GitRepoClient(self.repo_config, github_type=self.github_type, access_scope=GithubScope.WRITE) + self._remote_git_repo = GitRepoClient(self.repo_config) return self._remote_git_repo @property @@ -77,24 +80,24 @@ def codeowners_parser(self) -> CodeOwnersParser | None: @override def pull_repo(self) -> None: """Pull the latest commit down to an existing local repo""" - pull_repo(repo=self.repo_config, path=self.base_dir, github_type=self.github_type) + pull_repo(repo_path=self.repo_path, clone_url=self.clone_url) def clone_repo(self, shallow: bool = True) -> None: - clone_repo(repo=self.repo_config, path=self.base_dir, shallow=shallow, github_type=self.github_type) + clone_repo(repo_path=self.repo_path, clone_url=self.clone_url, shallow=shallow) def clone_or_pull_repo(self, shallow: bool = True) -> None: """If repo exists, pulls changes. otherwise, clones the repo.""" # TODO(CG-7804): if repo is not valid we should delete it and re-clone. maybe we can create a pull_repo util + use the existing clone_repo util if self.repo_exists(): self.clean_repo() - clone_or_pull_repo(self.repo_config, path=self.base_dir, shallow=shallow, github_type=self.github_type) + clone_or_pull_repo(repo_path=self.repo_path, clone_url=self.clone_url, shallow=shallow) def setup_repo_dir(self, setup_option: SetupOption = SetupOption.PULL_OR_CLONE, shallow: bool = True) -> None: os.makedirs(self.base_dir, exist_ok=True) os.chdir(self.base_dir) if setup_option is SetupOption.CLONE: # if repo exists delete, then clone, else clone - clone_repo(shallow=shallow) + clone_repo(shallow=shallow, repo_path=self.repo_path, clone_url=self.clone_url) elif setup_option is SetupOption.PULL_OR_CLONE: # if repo exists, pull changes, else clone self.clone_or_pull_repo(shallow=shallow) @@ -185,6 +188,6 @@ def push_changes(self, remote: Remote | None = None, refspec: str | None = None, @cached_property def base_url(self) -> str | None: repo_config = self.repo_config - clone_url = get_clone_url_for_repo_config(repo_config, github_type=GithubType.Github) + clone_url = get_clone_url_for_repo_config(repo_config) branch = self.get_active_branch_or_commit() return url_to_github(clone_url, branch) diff --git a/src/codegen/git/repo_operator/repo_operator.py b/src/codegen/git/repo_operator/repo_operator.py index e48cdf46c..02853dee4 100644 --- a/src/codegen/git/repo_operator/repo_operator.py +++ b/src/codegen/git/repo_operator/repo_operator.py @@ -28,20 +28,23 @@ class RepoOperator(ABC): repo_config: BaseRepoConfig base_dir: str + bot_commit: bool = True + access_token: str | None = None _codeowners_parser: CodeOwnersParser | None = None _default_branch: str | None = None - bot_commit: bool = True def __init__( self, repo_config: BaseRepoConfig, base_dir: str = "/tmp", bot_commit: bool = True, + access_token: str | None = None, ) -> None: assert repo_config is not None self.repo_config = repo_config self.base_dir = base_dir self.bot_commit = bot_commit + self.access_token = access_token #################################################################################################################### # PROPERTIES @@ -55,6 +58,10 @@ def repo_name(self) -> str: def repo_path(self) -> str: return os.path.join(self.base_dir, self.repo_name) + @property + def clone_url(self) -> str: + return f"https://github.com/{self.repo_config.full_name}.git" + @property def viz_path(self) -> str: return os.path.join(self.base_dir, "codegen-graphviz") diff --git a/src/codegen/git/schemas/github.py b/src/codegen/git/schemas/github.py deleted file mode 100644 index 5f04f94ca..000000000 --- a/src/codegen/git/schemas/github.py +++ /dev/null @@ -1,45 +0,0 @@ -from enum import StrEnum, auto -from typing import Self - - -class GithubScope(StrEnum): - READ = "read" - WRITE = "write" - - -class GithubType(StrEnum): - Github = auto() # aka public Github - GithubEnterprise = auto() - - def __str__(self) -> str: - return self.name - - @property - def hostname(self) -> str: - if self == GithubType.Github: - return "github.com" - elif self == GithubType.GithubEnterprise: - return "github.codegen.app" - else: - msg = f"Invalid GithubType: {self}" - raise ValueError(msg) - - @property - def base_url(self) -> str: - return f"https://{self.hostname}" - - @classmethod - def from_url(cls, url: str) -> Self: - for github_type in cls: - if github_type.hostname in url: - return github_type - msg = f"Could not find GithubType from url: {url}" - raise ValueError(msg) - - @classmethod - def from_string(cls, value: str) -> Self: - try: - return cls[value] # This will match the exact name - except KeyError: - msg = f"'{value}' is not a valid GithubType. Valid values are: {[e.name for e in cls]}" - raise ValueError(msg) diff --git a/src/codegen/git/utils/clone.py b/src/codegen/git/utils/clone.py index 2c524ff1a..c427b5aa1 100644 --- a/src/codegen/git/utils/clone.py +++ b/src/codegen/git/utils/clone.py @@ -2,90 +2,72 @@ import os import subprocess -from codegen.git.schemas.github import GithubType -from codegen.git.schemas.repo_config import RepoConfig -from codegen.git.utils.clone_url import get_authenticated_clone_url_for_repo_config from codegen.shared.performance.stopwatch_utils import subprocess_with_stopwatch logger = logging.getLogger(__name__) -def _get_path_to_repo( - repo: RepoConfig, - path: str, - github_type: GithubType = GithubType.GithubEnterprise, -) -> tuple[str, str]: - authenticated_git_url = get_authenticated_clone_url_for_repo_config(repo=repo, github_type=github_type) - repo_name = repo.name - return os.path.join(path, repo_name), authenticated_git_url +# return os.path.join(repo_path, repo_name), clone_url # TODO: update to use GitPython instead + move into LocalRepoOperator def clone_repo( - repo: RepoConfig, - path: str, + repo_path: str, + clone_url: str, shallow: bool = True, - github_type: GithubType = GithubType.GithubEnterprise, ): """TODO: re-use this code in clone_or_pull_repo. create separate pull_repo util""" - path_to_repo, authenticated_git_url = _get_path_to_repo(repo=repo, path=path, github_type=github_type) - - if os.path.exists(path_to_repo) and os.listdir(path_to_repo): + if os.path.exists(repo_path) and os.listdir(repo_path): # NOTE: if someone calls the current working directory is the repo directory then we need to move up one level - if os.getcwd() == os.path.realpath(path_to_repo): - repo_parent_dir = os.path.dirname(path_to_repo) + if os.getcwd() == os.path.realpath(repo_path): + repo_parent_dir = os.path.dirname(repo_path) os.chdir(repo_parent_dir) - delete_command = f"rm -rf {path_to_repo}" + delete_command = f"rm -rf {repo_path}" logger.info(f"Deleting existing clone with command: {delete_command}") subprocess.run(delete_command, shell=True, capture_output=True) if shallow: - clone_command = f"""git clone --depth 1 {authenticated_git_url} {path_to_repo}""" + clone_command = f"""git clone --depth 1 {clone_url} {repo_path}""" else: - clone_command = f"""git clone {authenticated_git_url} {path_to_repo}""" + clone_command = f"""git clone {clone_url} {repo_path}""" logger.info(f"Cloning with command: {clone_command} ...") subprocess_with_stopwatch(clone_command, shell=True, capture_output=True) # TODO: if an error raise or return None rather than silently failing - return path_to_repo + return repo_path # TODO: update to use GitPython instead + move into LocalRepoOperator def clone_or_pull_repo( - repo: RepoConfig, - path: str, + repo_path: str, + clone_url: str, shallow: bool = True, - github_type: GithubType = GithubType.GithubEnterprise, ): - path_to_repo, authenticated_git_url = _get_path_to_repo(repo=repo, path=path, github_type=github_type) - - if os.path.exists(path_to_repo) and os.listdir(path_to_repo): - logger.info(f"{path_to_repo} directory already exists. Pulling instead of cloning ...") - pull_repo(repo=repo, path=path, github_type=github_type) + if os.path.exists(repo_path) and os.listdir(repo_path): + logger.info(f"{repo_path} directory already exists. Pulling instead of cloning ...") + pull_repo(clone_url=clone_url, repo_path=repo_path) else: - logger.info(f"{path_to_repo} directory does not exist running git clone ...") + logger.info(f"{repo_path} directory does not exist running git clone ...") if shallow: - clone_command = f"""git clone --depth 1 {authenticated_git_url} {path_to_repo}""" + clone_command = f"""git clone --depth 1 {clone_url} {repo_path}""" else: - clone_command = f"""git clone {authenticated_git_url} {path_to_repo}""" + clone_command = f"""git clone {clone_url} {repo_path}""" logger.info(f"Cloning with command: {clone_command} ...") - subprocess_with_stopwatch(command=clone_command, command_desc=f"clone {repo.name}", shell=True, capture_output=True) - return path_to_repo + subprocess_with_stopwatch(command=clone_command, command_desc=f"clone {repo_path}", shell=True, capture_output=True) + return repo_path # TODO: update to use GitPython instead + move into LocalRepoOperators def pull_repo( - repo: RepoConfig, - path: str, - github_type: GithubType = GithubType.GithubEnterprise, + repo_path: str, + clone_url: str, ) -> None: - path_to_repo, authenticated_git_url = _get_path_to_repo(repo=repo, path=path, github_type=github_type) - if not os.path.exists(path_to_repo): - logger.info(f"{path_to_repo} directory does not exist. Unable to git pull.") + if not os.path.exists(repo_path): + logger.info(f"{repo_path} directory does not exist. Unable to git pull.") return - logger.info(f"Refreshing token for repo: {repo.full_name} ...") - subprocess.run(f"git -C {path_to_repo} remote set-url origin {authenticated_git_url}", shell=True, capture_output=True) + logger.info(f"Refreshing token for repo at {repo_path} ...") + subprocess.run(f"git -C {repo_path} remote set-url origin {clone_url}", shell=True, capture_output=True) - pull_command = f"git -C {path_to_repo} pull {authenticated_git_url}" + pull_command = f"git -C {repo_path} pull {clone_url}" logger.info(f"Pulling with command: {pull_command} ...") - subprocess_with_stopwatch(command=pull_command, command_desc=f"pull {repo.name}", shell=True, capture_output=True) + subprocess_with_stopwatch(command=pull_command, command_desc=f"pull {repo_path}", shell=True, capture_output=True) diff --git a/src/codegen/git/utils/clone_url.py b/src/codegen/git/utils/clone_url.py index d4c446133..21cd80cfb 100644 --- a/src/codegen/git/utils/clone_url.py +++ b/src/codegen/git/utils/clone_url.py @@ -1,7 +1,5 @@ from urllib.parse import urlparse -from codegen.git.configs.token import get_token_for_repo_config -from codegen.git.schemas.github import GithubType from codegen.git.schemas.repo_config import RepoConfig @@ -11,19 +9,12 @@ def url_to_github(url: str, branch: str) -> str: return f"{clone_url}/blob/{branch}" -def get_clone_url_for_repo_config(repo_config: RepoConfig, github_type: GithubType = GithubType.GithubEnterprise) -> str: - if github_type is GithubType.GithubEnterprise: - return f"https://github.codegen.app/{repo_config.full_name}.git" - elif github_type is GithubType.Github: - return f"https://github.com/{repo_config.full_name}.git" +def get_clone_url_for_repo_config(repo_config: RepoConfig) -> str: + return f"https://github.com/{repo_config.full_name}.git" -def get_authenticated_clone_url_for_repo_config( - repo: RepoConfig, - github_type: GithubType = GithubType.GithubEnterprise, -) -> str: - git_url = get_clone_url_for_repo_config(repo, github_type) - token = get_token_for_repo_config(repo_config=repo, github_type=github_type) +def get_authenticated_clone_url_for_repo_config(repo: RepoConfig, token: str) -> str: + git_url = get_clone_url_for_repo_config(repo) return add_access_token_to_url(git_url, token) diff --git a/src/codegen/runner/clients/sandbox_client.py b/src/codegen/runner/clients/sandbox_client.py new file mode 100644 index 000000000..7860f1566 --- /dev/null +++ b/src/codegen/runner/clients/sandbox_client.py @@ -0,0 +1,91 @@ +"""Client used to abstract the weird stdin/stdout communication we have with the sandbox""" + +import logging +import os +import subprocess +import time + +import requests +from fastapi import params + +from codegen.git.schemas.repo_config import RepoConfig +from codegen.runner.constants.envvars import FEATURE_FLAGS_BASE64, GITHUB_TOKEN, REPO_CONFIG_BASE64 +from codegen.runner.models.apis import SANDBOX_SERVER_PORT +from codegen.runner.models.configs import RunnerFeatureFlags + +logger = logging.getLogger(__name__) + + +class SandboxClient: + """Client for interacting with the locally hosted sandbox server.""" + + host: str + port: int + base_url: str + _process: subprocess.Popen | None + + def __init__(self, repo_config: RepoConfig, git_access_token: str | None, host: str = "127.0.0.1", port: int = SANDBOX_SERVER_PORT): + self.host = host + self.port = port + self.base_url = f"http://{host}:{port}" + self._process = None + self._start_server(repo_config, git_access_token) + + def _start_server(self, repo_config: RepoConfig, git_access_token: str | None) -> None: + """Start the FastAPI server in a subprocess""" + # encoded_flags = runner_flags_from_posthog(repo_config.name).encoded_json() # TODO: once migrated to dockerized image, uncomment this line + encoded_flags = RunnerFeatureFlags().encoded_json() + env = os.environ.copy() + env.update( + { + REPO_CONFIG_BASE64: repo_config.encoded_json(), + FEATURE_FLAGS_BASE64: encoded_flags, + "OPENAI_PASS": "open-ai-password", + GITHUB_TOKEN: git_access_token, + } + ) + + logger.info(f"Starting local sandbox server on {self.base_url} with repo setup in base_dir {repo_config.base_dir}") + self._process = subprocess.Popen( + [ + "uvicorn", + "codegen.runner.sandbox.server:app", + "--host", + self.host, + "--port", + str(self.port), + ], + env=env, + ) + self._wait_for_server() + + def _wait_for_server(self, timeout: int = 60, interval: float = 0.1) -> None: + """Wait for the server to start by polling the health endpoint""" + start_time = time.time() + while (time.time() - start_time) < timeout: + try: + self.get("/") + return + except requests.ConnectionError: + time.sleep(interval) + msg = "Server failed to start within timeout period" + raise TimeoutError(msg) + + def __del__(self): + """Cleanup the subprocess when the client is destroyed""" + if self._process is not None: + self._process.terminate() + self._process.wait() + + def get(self, endpoint: str, data: dict | None = None) -> requests.Response: + url = f"{self.base_url}{endpoint}" + response = requests.get(url, json=data) + response.raise_for_status() + return response + + def post(self, endpoint: str, data: dict | None = None, authorization: str | params.Header | None = None) -> requests.Response: + url = f"{self.base_url}{endpoint}" + headers = {"Authorization": str(authorization)} if authorization else None + response = requests.post(url, json=data, headers=headers) + response.raise_for_status() + return response diff --git a/src/codegen/runner/constants/envvars.py b/src/codegen/runner/constants/envvars.py index 16b62f79b..8d47fd6a4 100644 --- a/src/codegen/runner/constants/envvars.py +++ b/src/codegen/runner/constants/envvars.py @@ -1,9 +1,6 @@ """Environment variables used in the sandbox.""" # ==== [ Environment variable names ] ==== -CUSTOMER_REPO_ID = "CUSTOMER_REPO_ID" FEATURE_FLAGS_BASE64 = "FEATURE_FLAGS_BASE64" REPO_CONFIG_BASE64 = "REPO_CONFIG_BASE64" -LOWSIDE_TOKEN = "LOWSIDE_TOKEN" -HIGHSIDE_TOKEN = "HIGHSIDE_TOKEN" -IS_SANDBOX = "IS_SANDBOX" +GITHUB_TOKEN = "GITHUB_TOKEN" diff --git a/src/codegen/runner/models/apis.py b/src/codegen/runner/models/apis.py index 9ff4c4ddf..17e125200 100644 --- a/src/codegen/runner/models/apis.py +++ b/src/codegen/runner/models/apis.py @@ -49,6 +49,7 @@ class GetDiffResponse(BaseModel): class CreateBranchRequest(BaseModel): codemod: Codemod + commit_msg: str grouping_config: GroupingConfig branch_config: BranchConfig diff --git a/src/codegen/runner/models/codemod.py b/src/codegen/runner/models/codemod.py index 94f2668ce..ac15389a1 100644 --- a/src/codegen/runner/models/codemod.py +++ b/src/codegen/runner/models/codemod.py @@ -10,15 +10,8 @@ class Codemod(BaseModel): - run_id: int - version_id: int - epic_title: str user_code: str - codemod_context: CodemodContext - - # Sentry tags - epic_id: int - is_admin: bool = False + codemod_context: CodemodContext = CodemodContext() class GroupingConfig(BaseModel): @@ -28,7 +21,8 @@ class GroupingConfig(BaseModel): class BranchConfig(BaseModel): - base_branch: str | None = None + branch_name: str | None = None + custom_base_branch: str | None = None custom_head_branch: str | None = None force_push_head_branch: bool = False diff --git a/src/codegen/runner/sandbox/executor.py b/src/codegen/runner/sandbox/executor.py index b47d0e4ad..596275d86 100644 --- a/src/codegen/runner/sandbox/executor.py +++ b/src/codegen/runner/sandbox/executor.py @@ -6,7 +6,7 @@ from codegen.git.models.pr_options import PROptions from codegen.runner.diff.get_raw_diff import get_raw_diff -from codegen.runner.models.codemod import BranchConfig, Codemod, CodemodRunResult, CreatedBranch, GroupingConfig +from codegen.runner.models.codemod import BranchConfig, CodemodRunResult, CreatedBranch, GroupingConfig from codegen.runner.sandbox.repo import SandboxRepo from codegen.runner.utils.branch_name import get_head_branch_name from codegen.runner.utils.exception_utils import update_observation_meta @@ -54,7 +54,7 @@ async def find_flag_groups(self, code_flags: list[CodeFlag], grouping_config: Gr logger.info(f"> Created {len(groups)} groups") return groups - async def execute_flag_groups(self, codemod: Codemod, execute_func: Callable, flag_groups: list[Group], branch_config: BranchConfig) -> tuple[list[CodemodRunResult], list[CreatedBranch]]: + async def execute_flag_groups(self, commit_msg: str, execute_func: Callable, flag_groups: list[Group], branch_config: BranchConfig) -> tuple[list[CodemodRunResult], list[CreatedBranch]]: run_results = [] head_branches = [] for idx, group in enumerate(flag_groups): @@ -64,13 +64,13 @@ async def execute_flag_groups(self, codemod: Codemod, execute_func: Callable, fl if group: logger.info(f"Running group {group.segment} ({idx + 1} out of {len(flag_groups)})...") - head_branch = branch_config.custom_head_branch or get_head_branch_name(codemod, group) + head_branch = branch_config.custom_head_branch or get_head_branch_name(branch_config.branch_name, group) logger.info(f"Running with head branch: {head_branch}") - self.remote_repo.reset_branch(branch_config.base_branch, head_branch) + self.remote_repo.reset_branch(branch_config.custom_base_branch, head_branch) run_result = await self.execute(execute_func, group=group) - created_branch = CreatedBranch(base_branch=branch_config.base_branch, head_ref=None) - if self.remote_repo.push_changes_to_remote(codemod, head_branch, branch_config.force_push_head_branch): + created_branch = CreatedBranch(base_branch=branch_config.custom_base_branch, head_ref=None) + if self.remote_repo.push_changes_to_remote(commit_msg, head_branch, branch_config.force_push_head_branch): created_branch.head_ref = head_branch self.codebase.reset() diff --git a/src/codegen/runner/sandbox/repo.py b/src/codegen/runner/sandbox/repo.py index b3d06c37f..e3b5cc97f 100644 --- a/src/codegen/runner/sandbox/repo.py +++ b/src/codegen/runner/sandbox/repo.py @@ -1,8 +1,5 @@ import logging -from codegen.git.schemas.github import GithubType -from codegen.runner.models.codemod import Codemod -from codegen.runner.utils.branch_sync import get_remote_for_github_type from codegen.sdk.codebase.factory.codebase_factory import CodebaseType logger = logging.getLogger(__name__) @@ -23,7 +20,7 @@ def set_up_base_branch(self, base_branch: str | None) -> None: return # fetch the base branch from highside (do not checkout yet) - highside_remote = get_remote_for_github_type(op=self.codebase.op, github_type=GithubType.Github) + highside_remote = self.codebase.op.git_cli.remote(name="origin") self.codebase.op.fetch_remote(highside_remote.name, refspec=f"{base_branch}:{base_branch}") # checkout the base branch (and possibly sync graph) @@ -47,7 +44,7 @@ def set_up_head_branch(self, head_branch: str, force_push_head_branch: bool): return # fetch the head branch from highside (do not checkout yet) - highside_remote = get_remote_for_github_type(op=self.codebase.op, github_type=GithubType.Github) + highside_remote = self.codebase.op.git_cli.remote(name="origin") self.codebase.op.fetch_remote(highside_remote.name, refspec=f"{head_branch}:{head_branch}") def reset_branch(self, base_branch: str, head_branch: str) -> None: @@ -57,16 +54,16 @@ def reset_branch(self, base_branch: str, head_branch: str) -> None: logger.info(f"Checking out head branch {head_branch} ...") self.codebase.checkout(branch=head_branch, create_if_missing=True) - def push_changes_to_remote(self, codemod: Codemod, head_branch: str, force_push: bool) -> bool: + def push_changes_to_remote(self, commit_msg: str, head_branch: str, force_push: bool) -> bool: """Takes current state of repo and pushes it""" # =====[ Stage changes ]===== - has_staged_commit = self.codebase.git_commit(f"[Codegen] {codemod.epic_title}") + has_staged_commit = self.codebase.git_commit(f"[Codegen] {commit_msg}") if not has_staged_commit: - logger.info(f"Skipping opening pull request for cm_run {codemod.run_id} b/c the codemod produced no changes") + logger.info("Skipping opening pull request for cm_run b/c the codemod produced no changes") return False # =====[ Push changes highside ]===== - highside_remote = get_remote_for_github_type(op=self.codebase.op, github_type=GithubType.Github) + highside_remote = self.codebase.op.git_cli.remote(name="origin") highside_res = self.codebase.op.push_changes(remote=highside_remote, refspec=f"{head_branch}:{head_branch}", force=force_push) return not any(push_info.flags & push_info.ERROR for push_info in highside_res) diff --git a/src/codegen/runner/sandbox/runner.py b/src/codegen/runner/sandbox/runner.py index b3c07bf10..5440df1cf 100644 --- a/src/codegen/runner/sandbox/runner.py +++ b/src/codegen/runner/sandbox/runner.py @@ -1,11 +1,10 @@ import logging import sys -import sentry_sdk from git import Commit as GitCommit +from codegen.git.configs.config import config from codegen.git.repo_operator.remote_repo_operator import RemoteRepoOperator -from codegen.git.schemas.github import GithubType from codegen.git.schemas.repo_config import RepoConfig from codegen.runner.models.apis import CreateBranchRequest, CreateBranchResponse, GetDiffRequest, GetDiffResponse from codegen.runner.models.configs import get_codebase_config @@ -37,7 +36,7 @@ def __init__( repo_config: RepoConfig, ) -> None: self.repo = repo_config - self.op = RemoteRepoOperator(repo_config, base_dir=repo_config.base_dir, github_type=GithubType.Github) + self.op = RemoteRepoOperator(repo_config=repo_config, base_dir=repo_config.base_dir, access_token=config.GITHUB_TOKEN) self.commit = self.op.git_cli.head.commit async def warmup(self) -> None: @@ -50,7 +49,7 @@ async def warmup(self) -> None: async def _build_graph(self) -> Codebase: logger.info("> Building graph...") - programming_language = ProgrammingLanguage[self.op.repo_config.language.upper()] + programming_language = ProgrammingLanguage(self.op.repo_config.language.upper()) projects = [ProjectConfig(programming_language=programming_language, repo_operator=self.op, base_path=self.op.repo_config.base_path, subdirectories=self.op.repo_config.subdirectories)] return Codebase(projects=projects, config=get_codebase_config()) @@ -73,14 +72,7 @@ def reset_runner(self) -> None: self.codebase.clean_repo() self.codebase.checkout(branch=self.codebase.default_branch, create_if_missing=True) - @staticmethod - def _set_sentry_tags(epic_id: int, is_admin: bool) -> None: - """Set the sentry tags for a CodemodRun""" - sentry_sdk.set_tag("epic_id", epic_id) # To easily get to the epic in the UI - sentry_sdk.set_tag("is_admin", is_admin) # To filter "prod" level errors, ex if customer hits an error vs an admin - async def get_diff(self, request: GetDiffRequest) -> GetDiffResponse: - self._set_sentry_tags(epic_id=request.codemod.epic_id, is_admin=request.codemod.is_admin) custom_scope = {"context": request.codemod.codemod_context} if request.codemod.codemod_context else {} code_to_exec = create_execute_function_from_codeblock(codeblock=request.codemod.user_code, custom_scope=custom_scope) session_options = SessionOptions(max_transactions=request.max_transactions, max_seconds=request.max_seconds) @@ -90,13 +82,12 @@ async def get_diff(self, request: GetDiffRequest) -> GetDiffResponse: return GetDiffResponse(result=res) async def create_branch(self, request: CreateBranchRequest) -> CreateBranchResponse: - self._set_sentry_tags(epic_id=request.codemod.epic_id, is_admin=request.codemod.is_admin) custom_scope = {"context": request.codemod.codemod_context} if request.codemod.codemod_context else {} code_to_exec = create_execute_function_from_codeblock(codeblock=request.codemod.user_code, custom_scope=custom_scope) branch_config = request.branch_config - branch_config.base_branch = branch_config.base_branch or self.codebase.default_branch - self.executor.remote_repo.set_up_base_branch(branch_config.base_branch) + branch_config.custom_base_branch = branch_config.custom_base_branch or self.codebase.default_branch + self.executor.remote_repo.set_up_base_branch(branch_config.custom_base_branch) self.executor.remote_repo.set_up_head_branch(branch_config.custom_head_branch, branch_config.force_push_head_branch) response = CreateBranchResponse() @@ -117,7 +108,7 @@ async def create_branch(self, request: CreateBranchRequest) -> CreateBranchRespo logger.info(f"Max PRs limit reached: {max_prs}. Skipping remaining groups.") flag_groups = flag_groups[:max_prs] - run_results, branches = await self.executor.execute_flag_groups(request.codemod, code_to_exec, flag_groups, branch_config) + run_results, branches = await self.executor.execute_flag_groups(request.commit_msg, code_to_exec, flag_groups, branch_config) response.results = run_results response.branches = branches diff --git a/src/codegen/runner/utils/branch_name.py b/src/codegen/runner/utils/branch_name.py index 9711410a3..2b31db709 100644 --- a/src/codegen/runner/utils/branch_name.py +++ b/src/codegen/runner/utils/branch_name.py @@ -1,28 +1,11 @@ -import re +from uuid import uuid4 -from codegen.runner.models.codemod import Codemod -from codegen.sdk.codebase.flagging.group import DEFAULT_GROUP_ID, Group +from codegen.sdk.codebase.flagging.group import Group -# Codegen branches are of the format: codegen-codemod--version--run--group- -CODEGEN_BRANCH_PATTERN = r"codegen-codemod-(\d+)-version-(\d+)-run-(\d+)-group-(\d+)" -# Regex used for parsing DB IDs from Codegen branch names -CODEGEN_BRANCH_REGEX = re.compile(f"^{CODEGEN_BRANCH_PATTERN}$") - -# Template used to create a Codegen branch name -CODEGEN_BRANCH_TEMPLATE = CODEGEN_BRANCH_PATTERN.replace("(\\d+)", "{}") - - -def get_head_branch_name(codemod: Codemod, group: Group | None = None) -> str: - if not codemod.version_id: - msg = f"CodemodRun: {codemod.run_id} does not have a codemod version!" - raise ValueError(msg) - if not codemod.epic_id: - msg = f"CodemodRun: {codemod.run_id} does not have an epic!" - raise ValueError(msg) - if group and group.id is None: - msg = "Group ID is required to create a branch name" - raise ValueError(msg) - - group_id = group.id if group else DEFAULT_GROUP_ID - return CODEGEN_BRANCH_TEMPLATE.format(codemod.epic_id, codemod.version_id, codemod.run_id, group_id) +def get_head_branch_name(branch_name: str | None, group: Group | None = None) -> str: + if branch_name is None: + branch_name = f"codegen-{uuid4()}" + if group: + return f"{branch_name}-group-{group.id}" + return branch_name diff --git a/src/codegen/runner/utils/branch_sync.py b/src/codegen/runner/utils/branch_sync.py deleted file mode 100644 index d6d5930fb..000000000 --- a/src/codegen/runner/utils/branch_sync.py +++ /dev/null @@ -1,21 +0,0 @@ -from git.remote import Remote - -from codegen.git.configs.constants import HIGHSIDE_REMOTE_NAME, LOWSIDE_REMOTE_NAME -from codegen.git.repo_operator.remote_repo_operator import RemoteRepoOperator -from codegen.git.schemas.github import GithubType -from codegen.git.utils.clone_url import get_authenticated_clone_url_for_repo_config - - -def get_remote_for_github_type(op: RemoteRepoOperator, github_type: GithubType = GithubType.GithubEnterprise) -> Remote: - if op.github_type == github_type: - return op.git_cli.remote(name="origin") - - remote_name = HIGHSIDE_REMOTE_NAME if github_type == GithubType.Github else LOWSIDE_REMOTE_NAME - remote_url = get_authenticated_clone_url_for_repo_config(repo=op.repo_config, github_type=github_type) - - if remote_name in op.git_cli.remotes: - remote = op.git_cli.remote(remote_name) - remote.set_url(remote_url) - else: - remote = op.git_cli.create_remote(remote_name, remote_url) - return remote diff --git a/tests/integration/codegen/git/clients/test_github_client_factory.py b/tests/integration/codegen/git/clients/test_github_client_factory.py deleted file mode 100644 index 668faae0e..000000000 --- a/tests/integration/codegen/git/clients/test_github_client_factory.py +++ /dev/null @@ -1,17 +0,0 @@ -from codegen.git.clients.github_client_factory import GithubClientFactory -from codegen.git.schemas.github import GithubType - - -def test_github_client_factory_create_from_token_no_token(): - github_client = GithubClientFactory.create_from_token(github_type=GithubType.Github) - assert github_client.base_url == "https://api.github.com" - repo = github_client.read_client.get_repo("python-lsp/python-lsp-server") - assert repo.full_name == "python-lsp/python-lsp-server" - assert repo.name == "python-lsp-server" - - -def test_github_client_factory_create_from_repo(repo_config): - github_client = GithubClientFactory.create_from_repo(repo_config=repo_config, github_type=GithubType.Github) - repo = github_client.read_client.get_repo("codegen-sh/Kevin-s-Adventure-Game") - assert repo.full_name == "codegen-sh/Kevin-s-Adventure-Game" - assert repo.name == "Kevin-s-Adventure-Game" diff --git a/tests/integration/codegen/git/conftest.py b/tests/integration/codegen/git/conftest.py index 7158095f0..a12afc108 100644 --- a/tests/integration/codegen/git/conftest.py +++ b/tests/integration/codegen/git/conftest.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest @@ -9,23 +9,18 @@ def mock_config(): """Mock Config instance to prevent actual environment variable access during tests.""" mock_config = MagicMock() - mock_config.ENV = "test" - mock_config.GITHUB_ENTERPRISE_URL = "https://github.test" - mock_config.LOWSIDE_TOKEN = "test-lowside-token" - mock_config.HIGHSIDE_TOKEN = "test-highside-token" + mock_config.GITHUB_TOKEN = "test-highside-token" yield mock_config @pytest.fixture(autouse=True) def repo_config(): - with patch("codegen.git.utils.clone.get_authenticated_clone_url_for_repo_config") as mock_clone_url: - mock_clone_url.return_value = "https://github.com/codegen-sh/Kevin-s-Adventure-Game.git" - repo_config = RepoConfig( - id=321, - name="Kevin-s-Adventure-Game", - full_name="codegen-sh/Kevin-s-Adventure-Game", - organization_id="123", - organization_name="codegen-sh", - ) - yield repo_config + repo_config = RepoConfig( + id=321, + name="Kevin-s-Adventure-Game", + full_name="codegen-sh/Kevin-s-Adventure-Game", + organization_id=123, + organization_name="codegen-sh", + ) + yield repo_config diff --git a/tests/integration/codegen/runner/conftest.py b/tests/integration/codegen/runner/conftest.py new file mode 100644 index 000000000..4981c1549 --- /dev/null +++ b/tests/integration/codegen/runner/conftest.py @@ -0,0 +1,53 @@ +import socket +from collections.abc import Generator +from contextlib import closing +from unittest.mock import Mock + +import pytest + +from codegen.git.clients.git_repo_client import GitRepoClient +from codegen.git.configs.config import config +from codegen.git.repo_operator.remote_repo_operator import RemoteRepoOperator +from codegen.git.schemas.repo_config import RepoConfig +from codegen.runner.clients.sandbox_client import SandboxClient + + +@pytest.fixture +def get_free_port(): + """Find and return a free port on localhost""" + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(("", 0)) + s.listen(1) + port = s.getsockname()[1] + return port + + +@pytest.fixture(autouse=True) +def repo_config() -> RepoConfig: + yield RepoConfig( + id=321, + name="Kevin-s-Adventure-Game", + full_name="codegen-sh/Kevin-s-Adventure-Game", + organization_id=123, + organization_name="codegen-sh", + language="PYTHON", + ) + + +@pytest.fixture(autouse=True) +def op(repo_config: RepoConfig) -> Generator[RemoteRepoOperator, None, None]: + yield RemoteRepoOperator(repo_config=repo_config, access_token=config.GITHUB_TOKEN) + + +@pytest.fixture(autouse=True) +def git_repo_client(repo_config: RepoConfig) -> GitRepoClient: + yield GitRepoClient(repo_config=repo_config) + + +@pytest.fixture(autouse=True) +def sandbox_client(repo_config: RepoConfig, get_free_port, tmpdir) -> Generator[SandboxClient, None, None]: + # Use the pre-determined free port and a temporary directory + repo_config.base_dir = str(tmpdir) + sb_client = SandboxClient(repo_config=repo_config, port=get_free_port, git_access_token=config.GITHUB_TOKEN) + sb_client.runner = Mock() + yield sb_client diff --git a/tests/integration/codegen/runner/test_create_branch.py b/tests/integration/codegen/runner/test_create_branch.py new file mode 100644 index 000000000..f93a94ae5 --- /dev/null +++ b/tests/integration/codegen/runner/test_create_branch.py @@ -0,0 +1,63 @@ +import uuid +from http import HTTPStatus + +import pytest + +from codegen.git.clients.git_repo_client import GitRepoClient +from codegen.git.repo_operator.remote_repo_operator import RemoteRepoOperator +from codegen.runner.clients.sandbox_client import SandboxClient +from codegen.runner.models.apis import BRANCH_ENDPOINT, CreateBranchRequest, CreateBranchResponse +from codegen.runner.models.codemod import BranchConfig, Codemod, GroupingConfig + + +@pytest.mark.asyncio +@pytest.mark.timeout(60) +async def test_create_branch(sandbox_client: SandboxClient, git_repo_client: GitRepoClient, op: RemoteRepoOperator): + # set-up + codemod_source = """ +for file in codebase.files: + new_content = "🌈" + "\\n" + file.content + file.edit(new_content) + break +""" + test_branch_name = f"codegen-test-create-branch-{uuid.uuid1()}" + request = CreateBranchRequest( + codemod=Codemod(user_code=codemod_source), + commit_msg="Create branch test", + grouping_config=GroupingConfig(), + branch_config=BranchConfig(branch_name=test_branch_name), + ) + + # execute + response = sandbox_client.post(endpoint=BRANCH_ENDPOINT, data=request.model_dump()) + assert response.status_code == HTTPStatus.OK + + # verify + result = CreateBranchResponse.model_validate(response.json()) + assert len(result.results) == 1 + assert result.results[0].is_complete + assert result.results[0].error is None + assert result.results[0].logs == "" + assert result.results[0].observation is not None + + # verify changed files + patch = result.results[0].observation + lines = patch.split("\n") + added_lines = [line[1:] for line in lines if line.startswith("+") and len(line) > 1] + assert "🌈" in added_lines + + # verify returned branch + assert len(result.branches) == 1 + branch = result.branches[0] + assert branch.base_branch == "main" + assert branch.head_ref == test_branch_name + + # verify remote branch + remote_branch = git_repo_client.repo.get_branch(test_branch_name) + assert remote_branch is not None + assert remote_branch.name == test_branch_name + assert remote_branch.commit.commit.message == "[Codegen] Create branch test" + + # clean-up + remote = op.git_cli.remote(name="origin") + remote.push([f":refs/heads/{test_branch_name}"]) # The colon prefix means delete diff --git a/tests/integration/codegen/runner/test_create_branch_with_grouping.py b/tests/integration/codegen/runner/test_create_branch_with_grouping.py new file mode 100644 index 000000000..a41b4be51 --- /dev/null +++ b/tests/integration/codegen/runner/test_create_branch_with_grouping.py @@ -0,0 +1,58 @@ +import uuid +from http import HTTPStatus + +import pytest + +from codegen.git.clients.git_repo_client import GitRepoClient +from codegen.git.repo_operator.remote_repo_operator import RemoteRepoOperator +from codegen.runner.clients.sandbox_client import SandboxClient +from codegen.runner.models.apis import BRANCH_ENDPOINT, CreateBranchRequest, CreateBranchResponse +from codegen.runner.models.codemod import BranchConfig, Codemod, GroupingConfig +from codegen.sdk.codebase.flagging.groupers.enums import GroupBy + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize("group_by", [GroupBy.INSTANCE, GroupBy.FILE]) +def test_create_branch_with_grouping(sandbox_client: SandboxClient, git_repo_client: GitRepoClient, op: RemoteRepoOperator, group_by: GroupBy): + codemod_source = """ +for file in codebase.files[:5]: + flag = codebase.flag_instance(file) + if codebase.should_fix(flag): + new_content = "🌈" + "\\n" + file.content + file.edit(new_content) +""" + commit_msg = "Create branch with grouping test" + test_branch_name = f"codegen-{uuid.uuid1()}" + request = CreateBranchRequest( + codemod=Codemod(user_code=codemod_source), + commit_msg=commit_msg, + grouping_config=GroupingConfig(group_by=group_by), + branch_config=BranchConfig(branch_name=test_branch_name), + ) + + # execute + response = sandbox_client.post(endpoint=BRANCH_ENDPOINT, data=request.model_dump()) + assert response.status_code == HTTPStatus.OK + + # verify + result = CreateBranchResponse.model_validate(response.json()) + assert len(result.results) == 5 + assert len(result.branches) == 5 + + for i, branch in enumerate(result.branches): + actual_branch_suffix = "-".join(branch.head_ref.split("-")[-2:]) + expected_branch_suffix = f"group-{i}" + assert expected_branch_suffix == actual_branch_suffix + + remote_branch = git_repo_client.repo.get_branch(branch.head_ref) + assert remote_branch is not None + assert remote_branch.name == branch.head_ref + assert remote_branch.commit.commit.message == f"[Codegen] {commit_msg}" + assert remote_branch.commit.commit.author.name == "codegen-bot" + + comparison = git_repo_client.repo.compare(base=branch.base_branch, head=branch.head_ref) + assert "+🌈" in comparison.files[0].patch + + # clean-up + remote = op.git_cli.remote(name="origin") + remote.push([f":refs/heads/{branch.head_ref}"]) diff --git a/tests/unit/codegen/git/clients/test_git_repo_client.py b/tests/unit/codegen/git/clients/test_git_repo_client.py deleted file mode 100644 index c729dc9a6..000000000 --- a/tests/unit/codegen/git/clients/test_git_repo_client.py +++ /dev/null @@ -1,38 +0,0 @@ -from unittest.mock import MagicMock, patch - -from codegen.git.clients.git_repo_client import GitRepoClient -from codegen.git.schemas.github import GithubScope - - -@patch("codegen.git.clients.git_repo_client.GithubClientFactory") -def test_delete_branch_default( - mock_github_client_factory, -): - git_repo_client = GitRepoClient(repo_config=MagicMock(), access_scope=GithubScope.WRITE) - git_repo_client.read_client = MagicMock(default_branch="default-branch") - git_repo_client.delete_branch(branch_name="default-branch") - # assert write client is never accessed to delete the default branch - assert git_repo_client._write_client.call_count == 0 - - -@patch("codegen.git.clients.git_repo_client.GithubClientFactory") -def test_delete_branch_non_default_branch( - mock_github_client_factory, -): - git_repo_client = GitRepoClient(repo_config=MagicMock(), access_scope=GithubScope.WRITE) - git_repo_client.read_client = MagicMock(default_branch="default-branch") - mock_ref = MagicMock() - git_repo_client._write_client.get_git_ref.return_value = mock_ref - git_repo_client.delete_branch(branch_name="non-default-branch") - assert mock_ref.delete.call_count == 1 - - -@patch("codegen.git.clients.git_repo_client.GithubClientFactory") -def test_delete_branch_cannot_write_branch( - mock_github_client_factory, -): - git_repo_client = GitRepoClient(repo_config=MagicMock(), access_scope=GithubScope.WRITE) - git_repo_client.read_client = MagicMock(default_branch="default-branch") - git_repo_client.delete_branch(branch_name="not-default-branch") - # assert write client is never accessed to delete the default branch - assert git_repo_client._write_client.call_count == 0 diff --git a/tests/unit/codegen/git/schemas/test_github.py b/tests/unit/codegen/git/schemas/test_github.py deleted file mode 100644 index 26d2b4d3a..000000000 --- a/tests/unit/codegen/git/schemas/test_github.py +++ /dev/null @@ -1,6 +0,0 @@ -from codegen.git.schemas.github import GithubType - - -def test_github_type_base_url(): - assert GithubType.Github.base_url == "https://github.com" - assert GithubType.GithubEnterprise.base_url == "https://github.codegen.app" diff --git a/tests/unit/codegen/runner/utils/test_branch_name.py b/tests/unit/codegen/runner/utils/test_branch_name.py index 6b3d807a5..916b6cdbd 100644 --- a/tests/unit/codegen/runner/utils/test_branch_name.py +++ b/tests/unit/codegen/runner/utils/test_branch_name.py @@ -3,14 +3,24 @@ from codegen.runner.utils.branch_name import get_head_branch_name -def test_get_head_branch_name_no_group(): - codemod = MagicMock(epic_id=123, version_id=456, run_id=789) - branch_name = get_head_branch_name(codemod=codemod, group=None) - assert branch_name == "codegen-codemod-123-version-456-run-789-group-0" +def test_get_head_branch_name_no_name(): + branch_name = get_head_branch_name(branch_name=None, group=None) + assert branch_name.startswith("codegen-") + + +def test_get_head_branch_name_with_name(): + branch_name = get_head_branch_name(branch_name="test", group=None) + assert branch_name == "test" def test_get_head_branch_name_with_group(): - codemod = MagicMock(epic_id=123, version_id=456, run_id=789) group = MagicMock(id=2) - branch_name = get_head_branch_name(codemod=codemod, group=group) - assert branch_name == "codegen-codemod-123-version-456-run-789-group-2" + branch_name = get_head_branch_name(branch_name=None, group=group) + assert branch_name.startswith("codegen-") + assert branch_name.endswith("group-2") + + +def test_get_head_branch_name_with_name_and_group(): + group = MagicMock(id=2) + branch_name = get_head_branch_name(branch_name="test", group=group) + assert branch_name == "test-group-2" diff --git a/uv.lock b/uv.lock index 80ea901d3..b8990d537 100644 --- a/uv.lock +++ b/uv.lock @@ -441,6 +441,7 @@ dev = [ { name = "pre-commit" }, { name = "pre-commit-uv" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-benchmark", extra = ["histogram"] }, { name = "pytest-cov" }, { name = "pytest-mock" }, @@ -539,6 +540,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pre-commit-uv", specifier = ">=4.1.4" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=0.21.1,<1.0.0" }, { name = "pytest-benchmark", extras = ["histogram"], specifier = ">=5.1.0" }, { name = "pytest-cov", specifier = ">=6.0.0,<6.0.1" }, { name = "pytest-mock", specifier = ">=3.14.0,<4.0.0" }, @@ -1932,6 +1934,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, ] +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, +] + [[package]] name = "pytest-benchmark" version = "5.1.0"