From ad6f42cbb3bf5eaeaac85c1785f98bbd0955266e Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 3 Sep 2024 17:13:28 +0200 Subject: [PATCH 01/10] feat: add jfrog builds and tests --- pyartifactory/__init__.py | 2 + pyartifactory/exception.py | 15 ++ pyartifactory/models/__init__.py | 32 ++++ pyartifactory/models/build.py | 145 +++++++++++++++ pyartifactory/objects/artifactory.py | 2 + pyartifactory/objects/build.py | 206 ++++++++++++++++++++++ tests/test_build.py | 254 +++++++++++++++++++++++++++ 7 files changed, 656 insertions(+) create mode 100644 pyartifactory/models/build.py create mode 100644 pyartifactory/objects/build.py create mode 100644 tests/test_build.py diff --git a/pyartifactory/__init__.py b/pyartifactory/__init__.py index 35b2183..621e7f7 100644 --- a/pyartifactory/__init__.py +++ b/pyartifactory/__init__.py @@ -9,6 +9,7 @@ from pyartifactory.models.auth import AccessTokenModel from pyartifactory.objects.artifact import ArtifactoryArtifact from pyartifactory.objects.artifactory import Artifactory +from pyartifactory.objects.build import ArtifactoryBuild from pyartifactory.objects.group import ArtifactoryGroup from pyartifactory.objects.permission import ArtifactoryPermission from pyartifactory.objects.repository import ArtifactoryRepository @@ -24,6 +25,7 @@ "ArtifactoryRepository", "ArtifactorySecurity", "ArtifactoryUser", + "ArtifactoryBuild", ] with contextlib.suppress(PackageNotFoundError): diff --git a/pyartifactory/exception.py b/pyartifactory/exception.py index cd6457f..96e7ac1 100644 --- a/pyartifactory/exception.py +++ b/pyartifactory/exception.py @@ -54,3 +54,18 @@ class PropertyNotFoundError(ArtifactoryError): class InvalidTokenDataError(ArtifactoryError): """The token contains invalid data.""" + + +class BuildNotFoundError(ArtifactoryError): + """Requested build were not found""" + + +class ArtifactoryBuildError(ArtifactoryError): + """Artifactory Build Error.""" + + status: int + message: str + + def __init__(self, status: int, message: str): + self.status = status + self.message = message diff --git a/pyartifactory/models/__init__.py b/pyartifactory/models/__init__.py index f83cce4..9470b30 100644 --- a/pyartifactory/models/__init__.py +++ b/pyartifactory/models/__init__.py @@ -13,6 +13,23 @@ ArtifactStatsResponse, ) from .auth import AccessTokenModel, ApiKeyModel, AuthModel, PasswordModel +from .build import ( + BuildAgent, + BuildArtifact, + BuildCreateRequest, + BuildDeleteRequest, + BuildDiffResponse, + BuildDiffResponseDetail, + BuildInfo, + BuildInfoDetail, + BuildListResponse, + BuildModules, + BuildPromotionRequest, + BuildPromotionResult, + BuildRun, + Run, + SimpleBuild, +) from .group import Group, SimpleGroup from .permission import Permission, PermissionV2, SimplePermission from .repository import ( @@ -70,4 +87,19 @@ "AnyRepositoryResponse", "AnyRepository", "AnyPermission", + "SimpleBuild", + "BuildListResponse", + "Run", + "BuildRun", + "BuildArtifact", + "BuildModules", + "BuildAgent", + "BuildInfoDetail", + "BuildInfo", + "BuildPromotionResult", + "BuildPromotionRequest", + "BuildDeleteRequest", + "BuildDiffResponseDetail", + "BuildDiffResponse", + "BuildCreateRequest", ] diff --git a/pyartifactory/models/build.py b/pyartifactory/models/build.py new file mode 100644 index 0000000..6a64cfd --- /dev/null +++ b/pyartifactory/models/build.py @@ -0,0 +1,145 @@ +""" +Definition of all build models. +""" +from __future__ import annotations + +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field + + +class SimpleBuild(BaseModel): + """Models an artifactory single build.""" + + uri: str + lastStarted: str + + +class BuildListResponse(BaseModel): + """Models all artifactory builds.""" + + uri: str = "" + builds: Optional[List[SimpleBuild]] = None + + +class Run(BaseModel): + """Models an artifactory single build run.""" + + uri: str + buildsNumbers: str + + +class BuildRun(BaseModel): + """Models artifactory build runs.""" + + uri: str + buildsNumber: Optional[List[Run]] = None + + +class BuildArtifact(BaseModel): + """Models artifactory build artifact.""" + + type: str + sha1: str + sha256: str + md5: str + name: str + path: str + + +class BuildModules(BaseModel): + """Models artifactory's build modules.""" + + properties: Dict[str, str] + type: str + id: str + artifacts: List[BuildArtifact] + + +class BuildAgent(BaseModel): + name: str = "" + version: str = "" + + +class BuildInfoDetail(BaseModel): + """Models artifactory buildInfo dict.""" + + properties: Optional[Dict[str, str]] = None + version: str = "" + name: str = "" + number: str = "" + buildAgent: BuildAgent = BuildAgent() + agent: BuildAgent = BuildAgent() + started: str = "" + durationMillis: int = 0 + artifactoryPrincipal: str = "" + vcs: Optional[List[Dict[str, str]]] = None + modules: List[BuildModules] = [] + + +class BuildInfo(BaseModel): + """Models artifactory build.""" + + uri: str = "" + buildInfo: BuildInfoDetail = BuildInfoDetail() + + +class BuildPromotionResult(BaseModel): + messages: List[Dict[str, str]] = [] + + +class BuildPromotionRequest(BaseModel): + status: str = "" + comment: str = "" + ciUser: str = "" + timestamp: str = "" + dryRun: bool = False + sourceRepo: str + targetRepo: str + copyArtifact: bool = Field(False, alias="copy") + artifacts: bool = True + dependencies: bool = False + scopes: List[str] = [] + properties: Dict[str, List[str]] = {} + failFast: bool = True + + +class BuildDeleteRequest(BaseModel): + project: str = "" + buildName: str + buildNumbers: List[str] + deleteArtifacts: bool = False + deleteAll: bool = False + + +class BuildDiffResponseDetail(BaseModel): + updated: List[str] = [] + unchanged: List[str] = [] + removed: List[str] = [] + new: List[str] = [] + + +class BuildDiffResponse(BaseModel): + artifacts: BuildDiffResponseDetail = BuildDiffResponseDetail() + dependencies: BuildDiffResponseDetail = BuildDiffResponseDetail() + properties: BuildDiffResponseDetail = BuildDiffResponseDetail() + + +class BuildErrorDetail(BaseModel): + status: int = 0 + message: str = "" + + +class BuildError(BaseModel): + errors: List[BuildErrorDetail] + + +class BuildCreateRequest(BaseModel): + name: str + number: str + agent: BuildAgent = BuildAgent() + buildAgent: BuildAgent = BuildAgent() + started: str = "" + properties: Dict[str, str] = {} + artifactoryPrincipal: str = "" + vcs: List[Dict[str, str]] = [] diff --git a/pyartifactory/objects/artifactory.py b/pyartifactory/objects/artifactory.py index 11eedff..1411199 100644 --- a/pyartifactory/objects/artifactory.py +++ b/pyartifactory/objects/artifactory.py @@ -6,6 +6,7 @@ from pyartifactory.models.auth import AuthModel from pyartifactory.objects.artifact import ArtifactoryArtifact +from pyartifactory.objects.build import ArtifactoryBuild from pyartifactory.objects.group import ArtifactoryGroup from pyartifactory.objects.permission import ArtifactoryPermission from pyartifactory.objects.repository import ArtifactoryRepository @@ -39,3 +40,4 @@ def __init__( self.repositories = ArtifactoryRepository(self.artifactory) self.artifacts = ArtifactoryArtifact(self.artifactory) self.permissions = ArtifactoryPermission(self.artifactory) + self.builds = ArtifactoryBuild(self.artifactory) diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py new file mode 100644 index 0000000..6634143 --- /dev/null +++ b/pyartifactory/objects/build.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import logging +from typing import List, Optional, Union + +import pydantic_core +import requests +from requests import Response + +from pyartifactory.exception import ArtifactoryBuildError, ArtifactoryError, BuildNotFoundError +from pyartifactory.models.build import ( + BuildCreateRequest, + BuildDeleteRequest, + BuildDiffResponse, + BuildError, + BuildInfo, + BuildListResponse, + BuildPromotionRequest, + BuildPromotionResult, +) +from pyartifactory.objects.object import ArtifactoryObject + +logger = logging.getLogger("pyartifactory") + + +class ArtifactoryBuild(ArtifactoryObject): + """Models an artifactory build.""" + + _uri = "build" + + def get_build_info(self, build_name: str, build_number: str, properties: Optional[List[str]] = None) -> BuildInfo: + """ + :param build_name: Build name to be retrieved + :param build_number: Build number to be retrieved + :param properties: List of strings like ["started=...", "diff=..."] , for admitted values + see https://jfrog.com/help/r/jfrog-rest-apis/build-info + :return: BuildInfo model object containing server response + """ + build_name = build_name.lstrip("/") + build_number = build_number.lstrip("/") + _query_string = "" + if properties: + _query_string = "?" + "&".join(properties) + + try: + response = self._get( + f"api/{self._uri}/{build_name}/{build_number}{_query_string}", + ) + self._response_checker(response) + logger.debug("Build Info successfully retrieved") + return BuildInfo(**response.json()) + except requests.exceptions.HTTPError as error: + http_response: Union[Response, None] = error.response + if isinstance(http_response, Response) and http_response.status_code == 404: + raise BuildNotFoundError(f"Build {build_number} were not found on {build_name}") + raise ArtifactoryError from error + except ArtifactoryBuildError as error: + if error.status == 404: + raise BuildNotFoundError(error.message) + raise ArtifactoryError from error + + def create_build(self, create_build_request: BuildCreateRequest): + try: + _existing_build_info = self.get_build_info(create_build_request.name, create_build_request.number) + except BuildNotFoundError: + _resp = self._put(f"api/{self._uri}", json=create_build_request.model_dump()) + if _resp.status_code != 204: + logger.error("Build %s in %s not created", create_build_request.number, create_build_request.name) + raise ArtifactoryError( + f"Build {create_build_request.number} in {create_build_request.name} not created", + ) + logging.debug("Build %s in %s successfully created", create_build_request.number, create_build_request.name) + else: + logger.error("Build %s in %s already exists", create_build_request.number, create_build_request.name) + raise ArtifactoryError(f"Build {create_build_request.number} in {create_build_request.name} already exists") + + def promote_build( + self, + build_name: str, + build_number: str, + promotion_request: BuildPromotionRequest, + ) -> BuildPromotionResult: + """ + :param build_name: Build name to be promoted + :param build_number: Build number to be promoted + :param promotion_request: Model object containing parameters for promotion + :return: BuildPromotionResponse containing server response + """ + try: + response = self._get( + f"api/{self._uri}/{build_name}/{build_number}", + ) + self._response_checker(response) + except requests.exceptions.HTTPError as error: + http_response: Union[Response, None] = error.response + if isinstance(http_response, Response) and http_response.status_code == 404: + raise BuildNotFoundError(f"Build {build_number} were not found on {build_name}") + raise ArtifactoryError from error + except ArtifactoryBuildError as error: + if error.status == 404: + raise BuildNotFoundError(error.message) + raise ArtifactoryError from error + else: + response = self._post( + f"api/{self._uri}/promote/{build_name}/{build_number}", + json=promotion_request.model_dump(), + ) + promotion_result = BuildPromotionResult(**response.json()) + return promotion_result + + def list(self) -> BuildListResponse: + """ + :return: BuildListResponse model object containing server response + """ + response = self._get(f"api/{self._uri}") + logger.debug("List all builds successful") + build_list: BuildListResponse = BuildListResponse.model_validate(response.json()) + return build_list + + def delete(self, delete_build: BuildDeleteRequest) -> None: + """ + :param delete_build: Model object containing required parameters + :return: None + """ + try: + for _build_number in delete_build.buildNumbers: + response = self._get( + f"api/{self._uri}/{delete_build.buildName}/{_build_number}", + ) + self._response_checker(response) + except requests.exceptions.HTTPError as error: + http_response: Union[Response, None] = error.response + if isinstance(http_response, Response) and http_response.status_code == 404: + _http_error = BuildError(**http_response.json()) + raise BuildNotFoundError("\n".join([_error.message for _error in _http_error.errors])) + raise ArtifactoryError from error + except ArtifactoryBuildError as error: + if error.status == 404: + raise BuildNotFoundError(error.message) + # at least one build number does not exist + raise ArtifactoryError from error + else: + # all build numbers exist + _del = self._post(f"api/{self._uri}/delete", json=delete_build.model_dump()) + logger.debug(_del.text) + + def build_rename(self, build_name: str, new_build_name: str) -> None: + """ + :param build_name: Build to be renamed + :param new_build_name: New Build name + :return: None + """ + try: + response = self._get( + f"api/{self._uri}/{build_name}", + ) + self._response_checker(response) + except requests.exceptions.HTTPError as error: + http_response: Union[Response, None] = error.response + if isinstance(http_response, Response) and http_response.status_code == 404: + raise BuildNotFoundError(f"Build {build_name} were not found") + raise ArtifactoryError from error + except ArtifactoryBuildError as error: + raise ArtifactoryError from error + else: + self._post(f"api/{self._uri}/rename/{build_name}?to={new_build_name}") + logger.debug("Build %s successfully renamed to %s", build_name, new_build_name) + + def build_diff(self, build_name: str, build_number: str, older_build_number: str) -> BuildDiffResponse: + """ + :param build_name: Build name to be compared + :param build_number: More recent build to be compared + :param older_build_number: Starting build to be compared + :return: BuildDiffResponse model object containing server response + """ + build_name = build_name.lstrip("/") + build_number = build_number.lstrip("/") + older_build_number = older_build_number.lstrip("/") + # self._check_build_number_value(build_number) + # self._check_build_number_value(older_build_number) + + try: + response = self._get( + f"api/{self._uri}/{build_name}/{build_number}?diff={older_build_number}", + ) + self._response_checker(response) + logger.debug("Build Diff successfully retrieved") + return BuildDiffResponse(**response.json()) + except requests.exceptions.HTTPError as error: + http_response: Union[Response, None] = error.response + if isinstance(http_response, Response) and http_response.status_code == 404: + raise BuildNotFoundError( + f"Build diff {build_number} or {older_build_number} were not found on {build_name}", + ) + raise ArtifactoryError from error + except ArtifactoryBuildError as error: + raise ArtifactoryError from error + + def _response_checker(self, response): + try: + _error = BuildError(**response.json()) + except pydantic_core.ValidationError: + # response does not fit with error model + return + else: + raise ArtifactoryBuildError(_error.errors[0].status, _error.errors[0].message) diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..23c83da --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import pytest +import responses + +from pyartifactory import ArtifactoryBuild +from pyartifactory.exception import ArtifactoryError, BuildNotFoundError +from pyartifactory.models import ( + AuthModel, + BuildCreateRequest, + BuildDeleteRequest, + BuildDiffResponse, + BuildError, + BuildInfo, + BuildListResponse, + BuildPromotionRequest, + BuildPromotionResult, +) + +URL = "http://localhost:8080/artifactory" +AUTH = ("user", "password_or_apiKey") + +BUILD_INFO = BuildInfo(uri=f"{URL}/api/build/build_name/number") +BUILD_LIST_RESPONSE = BuildListResponse(uri=f"{URL}/api/build") +BUILD_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) +BUILD_DIFF = BuildDiffResponse() +BUILD_PROMOTION_REQUEST = BuildPromotionRequest(sourceRepo="repo-abc", targetRepo="repo-def") +BUILD_PROMOTION_RESULT = BuildPromotionResult() +BUILD_DELETE_REQUEST = BuildDeleteRequest(buildName="build", buildNumbers=["abc", "123"]) +BUILD_DELETE_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) +BUILD_CREATE_REQUEST = BuildCreateRequest(name="a-build", number="build-xx") + + +@responses.activate +def test_get_build_info_success(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/build_name/abc", + json=BUILD_INFO.model_dump(), + status=200, + ) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "get_build_info") + get_build = artifactory_build.get_build_info("build_name", "abc") + + assert isinstance(get_build, BuildInfo) + + +@responses.activate +@pytest.mark.parametrize( + "build_num,properties_input,expected_query,raised_exc", + [ + ("11", [], "", BuildNotFoundError), + ("11", ["diff=43"], "?diff=43", BuildNotFoundError), + ("11", ["diff=43", "started=2024-08"], "?diff=43&started=2024-08", BuildNotFoundError), + ("11", ["diff=abc"], "?diff=abc", ArtifactoryError), + ("def", ["diff=22"], "?diff=22", ArtifactoryError), + ], +) +def test_get_build_info_errors(mocker, build_num, properties_input, expected_query, raised_exc): + responses.add(responses.GET, f"{URL}/api/build/build_name/{build_num}{expected_query}", status=404) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "get_build_info") + with pytest.raises(raised_exc): + artifactory_build.get_build_info("build_name", build_num, properties=properties_input) + + +@responses.activate +def test_get_build_info_response_error(mocker): + responses.add(responses.GET, f"{URL}/api/build/build_name/123", json=BUILD_ERROR.model_dump(), status=200) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "get_build_info") + with pytest.raises(ArtifactoryError): + artifactory_build.get_build_info("build_name", "123") + + +@responses.activate +def test_promote_build_success(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/build_proj/build_number", + json=BUILD_INFO.model_dump(), + status=200, + ) + _promotion_request = BUILD_PROMOTION_REQUEST.model_dump() + responses.add( + responses.POST, + f"{URL}/api/build/promote/build_proj/build_number", + json=_promotion_request, + status=200, + ) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "promote_build") + build_promotion = artifactory_build.promote_build("build_proj", "build_number", BUILD_PROMOTION_REQUEST) + + assert isinstance(build_promotion, BuildPromotionResult) + + +@responses.activate +def test_promote_build_errors(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/build_name/build_123", + json=BUILD_PROMOTION_REQUEST.model_dump(), + status=404, + ) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "promote_build") + with pytest.raises(BuildNotFoundError): + artifactory_build.promote_build("build_name", "build_123", BUILD_PROMOTION_REQUEST) + + +@responses.activate +def test_promote_build_error_not_exist(mocker): + responses.add(responses.GET, f"{URL}/api/build/build_name/123", json=BUILD_ERROR.model_dump(), status=200) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "promote_build") + with pytest.raises(ArtifactoryError): + artifactory_build.promote_build("build_name", "123", BUILD_PROMOTION_REQUEST) + + +@responses.activate +def test_list_build(mocker): + responses.add( + responses.GET, + f"{URL}/api/build", + json=BUILD_LIST_RESPONSE.model_dump(), + status=200, + ) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "list") + build_list = artifactory_build.list() + + assert isinstance(build_list, BuildListResponse) + + +@responses.activate +def test_delete_build_success(mocker): + for _build_number in BUILD_DELETE_REQUEST.buildNumbers: + responses.add( + responses.GET, + f"{URL}/api/build/{BUILD_DELETE_REQUEST.buildName}/{_build_number}", + json=BUILD_INFO.model_dump(), + status=200, + ) + + responses.add(responses.POST, f"{URL}/api/build/delete", json=BUILD_DELETE_REQUEST.model_dump(), status=200) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "delete") + artifactory_build.delete(BUILD_DELETE_REQUEST) + + +@responses.activate +def test_delete_build_error_not_exist(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/{BUILD_DELETE_REQUEST.buildName}/{BUILD_DELETE_REQUEST.buildNumbers[0]}", + json=BUILD_INFO.model_dump(), + status=200, + ) + responses.add( + responses.GET, + f"{URL}/api/build/{BUILD_DELETE_REQUEST.buildName}/{BUILD_DELETE_REQUEST.buildNumbers[-1]}", + json=BUILD_ERROR.model_dump(), + status=200, + ) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "delete") + with pytest.raises(ArtifactoryError): + artifactory_build.delete(BUILD_DELETE_REQUEST) + + +@responses.activate +def test_rename_build_success(mocker): + responses.add(responses.GET, f"{URL}/api/build/build_name", json=BUILD_INFO.model_dump(), status=200) + responses.add(responses.POST, f"{URL}/api/build/rename/build_name?to=new_name", status=200) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "build_rename") + artifactory_build.build_rename("build_name", "new_name") + + +@responses.activate +def test_rename_build_error_not_exist(mocker): + responses.add(responses.GET, f"{URL}/api/build/build_name", status=404) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "build_rename") + with pytest.raises(BuildNotFoundError): + artifactory_build.build_rename("build_name", "new_name") + + +@responses.activate +def test_build_diff_success(mocker): + responses.add(responses.GET, f"{URL}/api/build/build_name/123?diff=456", json=BUILD_DIFF.model_dump(), status=200) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "build_diff") + artifactory_build.build_diff("build_name", "123", "456") + + +@responses.activate +def test_create_build_success(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/{BUILD_CREATE_REQUEST.name}/{BUILD_CREATE_REQUEST.number}", + json=BUILD_ERROR.model_dump(), + status=200, + ) + responses.add(responses.PUT, f"{URL}/api/build", status=204) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "create_build") + artifactory_build.create_build(BUILD_CREATE_REQUEST) + + +@responses.activate +def test_create_build_error_already_exist(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/{BUILD_CREATE_REQUEST.name}/{BUILD_CREATE_REQUEST.number}", + json=BUILD_INFO.model_dump(), + status=200, + ) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "create_build") + with pytest.raises(ArtifactoryError): + artifactory_build.create_build(BUILD_CREATE_REQUEST) + + +@responses.activate +def test_create_build_error_not_created(mocker): + responses.add( + responses.GET, + f"{URL}/api/build/{BUILD_CREATE_REQUEST.name}/{BUILD_CREATE_REQUEST.number}", + json=BUILD_ERROR.model_dump(), + status=404, + ) + responses.add(responses.PUT, f"{URL}/api/build", json=BUILD_CREATE_REQUEST.model_dump(), status=200) + + artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) + mocker.spy(artifactory_build, "create_build") + with pytest.raises(ArtifactoryError): + artifactory_build.create_build(BUILD_CREATE_REQUEST) From 06c1448782c8c7466000245476675caf9542fae9 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 17 Sep 2024 12:06:07 +0200 Subject: [PATCH 02/10] feat: add more model about build creation --- pyartifactory/models/build.py | 117 ++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 7 deletions(-) diff --git a/pyartifactory/models/build.py b/pyartifactory/models/build.py index 6a64cfd..3f0c2ff 100644 --- a/pyartifactory/models/build.py +++ b/pyartifactory/models/build.py @@ -8,6 +8,19 @@ from pydantic import BaseModel, Field +class BuildProperties(BaseModel): + started: Optional[str] = None + diff: Optional[str] = None + project: Optional[str] = None + + # Method to convert model to query string + def to_query_string(self) -> str: + # Create a list of key=value pairs for all non-None fields + properties = [f"{key}={value}" for key, value in self.model_dump(exclude_none=True).items()] + + return "?" + "&".join(properties) if properties else "" + + class SimpleBuild(BaseModel): """Models an artifactory single build.""" @@ -61,6 +74,13 @@ class BuildAgent(BaseModel): version: str = "" +class Vcs(BaseModel): + revision: Optional[str] = None + message: Optional[str] = None + branch: Optional[str] = None + url: Optional[str] = None + + class BuildInfoDetail(BaseModel): """Models artifactory buildInfo dict.""" @@ -73,7 +93,7 @@ class BuildInfoDetail(BaseModel): started: str = "" durationMillis: int = 0 artifactoryPrincipal: str = "" - vcs: Optional[List[Dict[str, str]]] = None + vcs: Optional[List[Vcs]] = None modules: List[BuildModules] = [] @@ -134,12 +154,95 @@ class BuildError(BaseModel): errors: List[BuildErrorDetail] +class LicenseControl(BaseModel): + """Build's license.""" + + runChecks: Optional[bool] = None + includePublishedArtifacts: Optional[bool] = None + autoDiscover: Optional[bool] = None + scopesList: Optional[str] = None + licenseViolationsRecipientsList: Optional[str] = None + + +class BuildRetention(BaseModel): + """Build's retention.""" + + deleteBuildArtifacts: Optional[bool] = None + count: Optional[int] = None + minimumBuildDate: Optional[int] = None + buildNumbersNotToBeDiscarded: Optional[List[str]] = None + + +class Artifacts(BaseModel): + """Build's artifacts.""" + + type: Optional[str] = None + sha1: Optional[str] = None + md5: Optional[str] = None + name: Optional[str] = None + + +class Dependencies(BaseModel): + """Build's dependency.""" + + type: Optional[str] = None + sha1: Optional[str] = None + md5: Optional[str] = None + id: Optional[str] = None + scopes: Optional[List[str]] = None + + +class BuildSingleModule(BaseModel): + """Build's module model.""" + + properties: Optional[Dict[str, str]] = None + id: Optional[str] = None + artifacts: Optional[List[Artifacts]] = None + dependencies: Optional[List[Dependencies]] = None + + +class Tracker(BaseModel): + """Build's issue tracker.""" + + name: Optional[str] = None + version: Optional[str] = None + + +class AffectedIssue(BaseModel): + """Build's affected issue.""" + + key: Optional[str] = None + url: Optional[str] = None + summary: Optional[str] = None + aggregated: Optional[bool] = None + + +class Issue(BaseModel): + """Build's issue.""" + + tracker: Optional[Tracker] = None + aggregateBuildIssues: Optional[bool] = None + aggregationBuildStatus: Optional[str] = None + affectedIssues: Optional[List[AffectedIssue]] = None + + class BuildCreateRequest(BaseModel): + """Models artifactory build creation data.""" + + properties: Dict[str, str] = {} + version: str = "1.0.1" name: str number: str - agent: BuildAgent = BuildAgent() - buildAgent: BuildAgent = BuildAgent() - started: str = "" - properties: Dict[str, str] = {} - artifactoryPrincipal: str = "" - vcs: List[Dict[str, str]] = [] + type: Optional[str] = None + buildAgent: Optional[BuildAgent] = None + agent: Optional[BuildAgent] = None + started: str + artifactoryPluginVersion: Optional[str] = None + durationMillis: Optional[int] = None + artifactoryPrincipal: Optional[str] = None + url: Optional[str] = None + vcs: Optional[List[Vcs]] = None + licenseControl: Optional[LicenseControl] = None + buildRetention: Optional[BuildRetention] = None + modules: Optional[List[BuildSingleModule]] = None + issues: Optional[Issue] = None From 202e63c96cb0425fdeb861da78d54189eb3e96f2 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 17 Sep 2024 12:11:58 +0200 Subject: [PATCH 03/10] fix: tests and comments from PR --- pyartifactory/models/__init__.py | 4 ++++ pyartifactory/objects/build.py | 31 ++++++++++++++++--------------- tests/test_build.py | 13 +++++++------ 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pyartifactory/models/__init__.py b/pyartifactory/models/__init__.py index 9470b30..9d2bca8 100644 --- a/pyartifactory/models/__init__.py +++ b/pyartifactory/models/__init__.py @@ -20,12 +20,14 @@ BuildDeleteRequest, BuildDiffResponse, BuildDiffResponseDetail, + BuildError, BuildInfo, BuildInfoDetail, BuildListResponse, BuildModules, BuildPromotionRequest, BuildPromotionResult, + BuildProperties, BuildRun, Run, SimpleBuild, @@ -98,6 +100,8 @@ "BuildInfo", "BuildPromotionResult", "BuildPromotionRequest", + "BuildProperties", + "BuildError", "BuildDeleteRequest", "BuildDiffResponseDetail", "BuildDiffResponse", diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py index 6634143..81774de 100644 --- a/pyartifactory/objects/build.py +++ b/pyartifactory/objects/build.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import List, Optional, Union +from typing import Union import pydantic_core import requests @@ -17,6 +17,7 @@ BuildListResponse, BuildPromotionRequest, BuildPromotionResult, + BuildProperties, ) from pyartifactory.objects.object import ArtifactoryObject @@ -28,23 +29,25 @@ class ArtifactoryBuild(ArtifactoryObject): _uri = "build" - def get_build_info(self, build_name: str, build_number: str, properties: Optional[List[str]] = None) -> BuildInfo: + def get_build_info( + self, + build_name: str, + build_number: str, + properties: BuildProperties = BuildProperties(), + ) -> BuildInfo: """ :param build_name: Build name to be retrieved :param build_number: Build number to be retrieved - :param properties: List of strings like ["started=...", "diff=..."] , for admitted values + :param properties: Build properties model, for admitted values see https://jfrog.com/help/r/jfrog-rest-apis/build-info :return: BuildInfo model object containing server response """ - build_name = build_name.lstrip("/") - build_number = build_number.lstrip("/") - _query_string = "" - if properties: - _query_string = "?" + "&".join(properties) + build_name = build_name.strip("/") + build_number = build_number.strip("/") try: response = self._get( - f"api/{self._uri}/{build_name}/{build_number}{_query_string}", + f"api/{self._uri}/{build_name}/{build_number}{properties.to_query_string()}", ) self._response_checker(response) logger.debug("Build Info successfully retrieved") @@ -61,7 +64,7 @@ def get_build_info(self, build_name: str, build_number: str, properties: Optiona def create_build(self, create_build_request: BuildCreateRequest): try: - _existing_build_info = self.get_build_info(create_build_request.name, create_build_request.number) + self.get_build_info(create_build_request.name, create_build_request.number) except BuildNotFoundError: _resp = self._put(f"api/{self._uri}", json=create_build_request.model_dump()) if _resp.status_code != 204: @@ -173,11 +176,9 @@ def build_diff(self, build_name: str, build_number: str, older_build_number: str :param older_build_number: Starting build to be compared :return: BuildDiffResponse model object containing server response """ - build_name = build_name.lstrip("/") - build_number = build_number.lstrip("/") - older_build_number = older_build_number.lstrip("/") - # self._check_build_number_value(build_number) - # self._check_build_number_value(older_build_number) + build_name = build_name.strip("/") + build_number = build_number.strip("/") + older_build_number = older_build_number.strip("/") try: response = self._get( diff --git a/tests/test_build.py b/tests/test_build.py index 23c83da..78846ac 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -15,6 +15,7 @@ BuildListResponse, BuildPromotionRequest, BuildPromotionResult, + BuildProperties, ) URL = "http://localhost:8080/artifactory" @@ -28,7 +29,7 @@ BUILD_PROMOTION_RESULT = BuildPromotionResult() BUILD_DELETE_REQUEST = BuildDeleteRequest(buildName="build", buildNumbers=["abc", "123"]) BUILD_DELETE_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) -BUILD_CREATE_REQUEST = BuildCreateRequest(name="a-build", number="build-xx") +BUILD_CREATE_REQUEST = BuildCreateRequest(name="a-build", number="build-xx", started="2014-09-30T12:00:19.893+0300") @responses.activate @@ -51,11 +52,11 @@ def test_get_build_info_success(mocker): @pytest.mark.parametrize( "build_num,properties_input,expected_query,raised_exc", [ - ("11", [], "", BuildNotFoundError), - ("11", ["diff=43"], "?diff=43", BuildNotFoundError), - ("11", ["diff=43", "started=2024-08"], "?diff=43&started=2024-08", BuildNotFoundError), - ("11", ["diff=abc"], "?diff=abc", ArtifactoryError), - ("def", ["diff=22"], "?diff=22", ArtifactoryError), + ("11", BuildProperties(), "", BuildNotFoundError), + ("11", BuildProperties(diff="43"), "?diff=43", BuildNotFoundError), + ("11", BuildProperties(diff="43", started="2024-08"), "?diff=43&started=2024-08", BuildNotFoundError), + ("11", BuildProperties(diff="abc"), "?diff=abc", ArtifactoryError), + ("def", BuildProperties(diff="22"), "?diff=22", ArtifactoryError), ], ) def test_get_build_info_errors(mocker, build_num, properties_input, expected_query, raised_exc): From 3abde334eb9f73366a0e9607f217dbde77ee45e4 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 17 Sep 2024 15:22:59 +0200 Subject: [PATCH 04/10] feat: error message from within BuildError model --- pyartifactory/models/build.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyartifactory/models/build.py b/pyartifactory/models/build.py index 3f0c2ff..f58aed9 100644 --- a/pyartifactory/models/build.py +++ b/pyartifactory/models/build.py @@ -153,6 +153,10 @@ class BuildErrorDetail(BaseModel): class BuildError(BaseModel): errors: List[BuildErrorDetail] + # Method to extract error message + def to_error_message(self) -> str: + return "\n".join([f"Error status: {_error.status} - {_error.message}" for _error in self.errors]) + class LicenseControl(BaseModel): """Build's license.""" From 8ba5cefa3ab5f435b66d4b9d56cd0402f2c6f594 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 17 Sep 2024 16:04:19 +0200 Subject: [PATCH 05/10] fix: removed _response_checker, simplified exception management, aligned tests --- pyartifactory/objects/build.py | 134 +++++++++++++++------------------ tests/test_build.py | 48 +++++++++--- 2 files changed, 95 insertions(+), 87 deletions(-) diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py index 81774de..7b7cb7b 100644 --- a/pyartifactory/objects/build.py +++ b/pyartifactory/objects/build.py @@ -3,11 +3,10 @@ import logging from typing import Union -import pydantic_core import requests from requests import Response -from pyartifactory.exception import ArtifactoryBuildError, ArtifactoryError, BuildNotFoundError +from pyartifactory.exception import ArtifactoryError, BuildNotFoundError from pyartifactory.models.build import ( BuildCreateRequest, BuildDeleteRequest, @@ -49,30 +48,32 @@ def get_build_info( response = self._get( f"api/{self._uri}/{build_name}/{build_number}{properties.to_query_string()}", ) - self._response_checker(response) logger.debug("Build Info successfully retrieved") - return BuildInfo(**response.json()) except requests.exceptions.HTTPError as error: - http_response: Union[Response, None] = error.response - if isinstance(http_response, Response) and http_response.status_code == 404: - raise BuildNotFoundError(f"Build {build_number} were not found on {build_name}") - raise ArtifactoryError from error - except ArtifactoryBuildError as error: - if error.status == 404: - raise BuildNotFoundError(error.message) - raise ArtifactoryError from error + self._raise_exception(error) + + return BuildInfo(**response.json()) def create_build(self, create_build_request: BuildCreateRequest): try: self.get_build_info(create_build_request.name, create_build_request.number) except BuildNotFoundError: - _resp = self._put(f"api/{self._uri}", json=create_build_request.model_dump()) - if _resp.status_code != 204: - logger.error("Build %s in %s not created", create_build_request.number, create_build_request.name) - raise ArtifactoryError( - f"Build {create_build_request.number} in {create_build_request.name} not created", + # other exception from get_build_info are forwarded to caller. + try: + # build does not exist, can be created here + _resp = self._put(f"api/{self._uri}", json=create_build_request.model_dump()) + if _resp.status_code != 204: + logger.error("Build %s in %s not created", create_build_request.number, create_build_request.name) + raise ArtifactoryError( + f"Build {create_build_request.number} in {create_build_request.name} not created", + ) + logging.debug( + "Build %s in %s successfully created", + create_build_request.number, + create_build_request.name, ) - logging.debug("Build %s in %s successfully created", create_build_request.number, create_build_request.name) + except requests.exceptions.HTTPError as error: + self._raise_exception(error) else: logger.error("Build %s in %s already exists", create_build_request.number, create_build_request.name) raise ArtifactoryError(f"Build {create_build_request.number} in {create_build_request.name} already exists") @@ -90,26 +91,28 @@ def promote_build( :return: BuildPromotionResponse containing server response """ try: - response = self._get( + self._get( f"api/{self._uri}/{build_name}/{build_number}", ) - self._response_checker(response) except requests.exceptions.HTTPError as error: - http_response: Union[Response, None] = error.response - if isinstance(http_response, Response) and http_response.status_code == 404: - raise BuildNotFoundError(f"Build {build_number} were not found on {build_name}") - raise ArtifactoryError from error - except ArtifactoryBuildError as error: - if error.status == 404: - raise BuildNotFoundError(error.message) - raise ArtifactoryError from error + self._raise_exception(error) else: - response = self._post( - f"api/{self._uri}/promote/{build_name}/{build_number}", - json=promotion_request.model_dump(), - ) - promotion_result = BuildPromotionResult(**response.json()) - return promotion_result + try: + response = self._post( + f"api/{self._uri}/promote/{build_name}/{build_number}", + json=promotion_request.model_dump(), + ) + logging.debug( + "Build %s in %s promoted from %s to %s", + build_number, + build_name, + promotion_request.sourceRepo, + promotion_request.targetRepo, + ) + except requests.exceptions.HTTPError as error: + self._raise_exception(error) + + return BuildPromotionResult(**response.json()) def list(self) -> BuildListResponse: """ @@ -127,25 +130,15 @@ def delete(self, delete_build: BuildDeleteRequest) -> None: """ try: for _build_number in delete_build.buildNumbers: - response = self._get( + self._get( f"api/{self._uri}/{delete_build.buildName}/{_build_number}", ) - self._response_checker(response) except requests.exceptions.HTTPError as error: - http_response: Union[Response, None] = error.response - if isinstance(http_response, Response) and http_response.status_code == 404: - _http_error = BuildError(**http_response.json()) - raise BuildNotFoundError("\n".join([_error.message for _error in _http_error.errors])) - raise ArtifactoryError from error - except ArtifactoryBuildError as error: - if error.status == 404: - raise BuildNotFoundError(error.message) - # at least one build number does not exist - raise ArtifactoryError from error + self._raise_exception(error) else: # all build numbers exist _del = self._post(f"api/{self._uri}/delete", json=delete_build.model_dump()) - logger.debug(_del.text) + logger.debug("Builds %s deleted from %s", ",".join(delete_build.buildNumbers), delete_build.buildName) def build_rename(self, build_name: str, new_build_name: str) -> None: """ @@ -154,20 +147,17 @@ def build_rename(self, build_name: str, new_build_name: str) -> None: :return: None """ try: - response = self._get( + self._get( f"api/{self._uri}/{build_name}", ) - self._response_checker(response) except requests.exceptions.HTTPError as error: - http_response: Union[Response, None] = error.response - if isinstance(http_response, Response) and http_response.status_code == 404: - raise BuildNotFoundError(f"Build {build_name} were not found") - raise ArtifactoryError from error - except ArtifactoryBuildError as error: - raise ArtifactoryError from error + self._raise_exception(error) else: - self._post(f"api/{self._uri}/rename/{build_name}?to={new_build_name}") - logger.debug("Build %s successfully renamed to %s", build_name, new_build_name) + try: + self._post(f"api/{self._uri}/rename/{build_name}?to={new_build_name}") + logger.debug("Build %s successfully renamed to %s", build_name, new_build_name) + except requests.exceptions.HTTPError as error: + self._raise_exception(error) def build_diff(self, build_name: str, build_number: str, older_build_number: str) -> BuildDiffResponse: """ @@ -184,24 +174,18 @@ def build_diff(self, build_name: str, build_number: str, older_build_number: str response = self._get( f"api/{self._uri}/{build_name}/{build_number}?diff={older_build_number}", ) - self._response_checker(response) - logger.debug("Build Diff successfully retrieved") - return BuildDiffResponse(**response.json()) + logger.debug("Build Diff successfully retrieved between %s and %s", build_number, older_build_number) except requests.exceptions.HTTPError as error: - http_response: Union[Response, None] = error.response - if isinstance(http_response, Response) and http_response.status_code == 404: - raise BuildNotFoundError( - f"Build diff {build_number} or {older_build_number} were not found on {build_name}", - ) - raise ArtifactoryError from error - except ArtifactoryBuildError as error: - raise ArtifactoryError from error + self._raise_exception(error) - def _response_checker(self, response): - try: - _error = BuildError(**response.json()) - except pydantic_core.ValidationError: - # response does not fit with error model - return + return BuildDiffResponse(**response.json()) + + def _raise_exception(self, error: requests.exceptions.HTTPError): + http_response: Union[Response, None] = error.response + if isinstance(http_response, Response): + _http_error = BuildError(**http_response.json()) + if http_response.status_code == 404: + raise BuildNotFoundError(_http_error.to_error_message()) from error + raise ArtifactoryError(_http_error.to_error_message()) from error else: - raise ArtifactoryBuildError(_error.errors[0].status, _error.errors[0].message) + raise ArtifactoryError from error diff --git a/tests/test_build.py b/tests/test_build.py index 78846ac..94fd05a 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,7 +1,9 @@ from __future__ import annotations import pytest +import requests import responses +from requests.models import Response from pyartifactory import ArtifactoryBuild from pyartifactory.exception import ArtifactoryError, BuildNotFoundError @@ -23,7 +25,8 @@ BUILD_INFO = BuildInfo(uri=f"{URL}/api/build/build_name/number") BUILD_LIST_RESPONSE = BuildListResponse(uri=f"{URL}/api/build") -BUILD_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) +BUILD_NOT_FOUND_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) +BUILD_GENERIC_ERROR = BuildError(errors=[{"status": 500, "message": "Generic error"}]) BUILD_DIFF = BuildDiffResponse() BUILD_PROMOTION_REQUEST = BuildPromotionRequest(sourceRepo="repo-abc", targetRepo="repo-def") BUILD_PROMOTION_RESULT = BuildPromotionResult() @@ -31,6 +34,15 @@ BUILD_DELETE_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) BUILD_CREATE_REQUEST = BuildCreateRequest(name="a-build", number="build-xx", started="2014-09-30T12:00:19.893+0300") +NOT_FOUND_HTTP_RESPONSE = Response() +NOT_FOUND_HTTP_RESPONSE.status_code = 404 +NOT_FOUND_HTTP_RESPONSE.reason = "Not Found" +NOT_FOUND_HTTP_RESPONSE._content = b'{"errors": [{"status": 404, "message": "Not found ... "}]}' +NOT_FOUND_HTTP_RESPONSE.encoding = "utf-8" +NOT_FOUND_HTTP_RESPONSE.url = "http://jfrog-server..." + +NOT_FOUND_EXCEPTION_BODY = requests.exceptions.HTTPError(response=NOT_FOUND_HTTP_RESPONSE) + @responses.activate def test_get_build_info_success(mocker): @@ -60,7 +72,12 @@ def test_get_build_info_success(mocker): ], ) def test_get_build_info_errors(mocker, build_num, properties_input, expected_query, raised_exc): - responses.add(responses.GET, f"{URL}/api/build/build_name/{build_num}{expected_query}", status=404) + responses.add( + responses.GET, + f"{URL}/api/build/build_name/{build_num}{expected_query}", + body=NOT_FOUND_EXCEPTION_BODY, + status=404, + ) artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) mocker.spy(artifactory_build, "get_build_info") @@ -69,12 +86,19 @@ def test_get_build_info_errors(mocker, build_num, properties_input, expected_que @responses.activate -def test_get_build_info_response_error(mocker): - responses.add(responses.GET, f"{URL}/api/build/build_name/123", json=BUILD_ERROR.model_dump(), status=200) +@pytest.mark.parametrize( + "error_model_dump,raised_exc", + [ + (BUILD_NOT_FOUND_ERROR.model_dump(), BuildNotFoundError), + (BUILD_GENERIC_ERROR.model_dump(), ArtifactoryError), + ], +) +def test_get_build_info_response_error(mocker, error_model_dump, raised_exc): + responses.add(responses.GET, f"{URL}/api/build/build_name/123", body=NOT_FOUND_EXCEPTION_BODY, status=200) artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) mocker.spy(artifactory_build, "get_build_info") - with pytest.raises(ArtifactoryError): + with pytest.raises(raised_exc): artifactory_build.get_build_info("build_name", "123") @@ -106,8 +130,8 @@ def test_promote_build_errors(mocker): responses.add( responses.GET, f"{URL}/api/build/build_name/build_123", - json=BUILD_PROMOTION_REQUEST.model_dump(), - status=404, + body=NOT_FOUND_EXCEPTION_BODY, + status=200, ) artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) @@ -118,7 +142,7 @@ def test_promote_build_errors(mocker): @responses.activate def test_promote_build_error_not_exist(mocker): - responses.add(responses.GET, f"{URL}/api/build/build_name/123", json=BUILD_ERROR.model_dump(), status=200) + responses.add(responses.GET, f"{URL}/api/build/build_name/123", body=NOT_FOUND_EXCEPTION_BODY, status=200) artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) mocker.spy(artifactory_build, "promote_build") @@ -170,7 +194,7 @@ def test_delete_build_error_not_exist(mocker): responses.add( responses.GET, f"{URL}/api/build/{BUILD_DELETE_REQUEST.buildName}/{BUILD_DELETE_REQUEST.buildNumbers[-1]}", - json=BUILD_ERROR.model_dump(), + body=NOT_FOUND_EXCEPTION_BODY, status=200, ) @@ -192,7 +216,7 @@ def test_rename_build_success(mocker): @responses.activate def test_rename_build_error_not_exist(mocker): - responses.add(responses.GET, f"{URL}/api/build/build_name", status=404) + responses.add(responses.GET, f"{URL}/api/build/build_name", json=BUILD_NOT_FOUND_ERROR.model_dump(), status=404) artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) mocker.spy(artifactory_build, "build_rename") @@ -214,7 +238,7 @@ def test_create_build_success(mocker): responses.add( responses.GET, f"{URL}/api/build/{BUILD_CREATE_REQUEST.name}/{BUILD_CREATE_REQUEST.number}", - json=BUILD_ERROR.model_dump(), + body=NOT_FOUND_EXCEPTION_BODY, status=200, ) responses.add(responses.PUT, f"{URL}/api/build", status=204) @@ -244,7 +268,7 @@ def test_create_build_error_not_created(mocker): responses.add( responses.GET, f"{URL}/api/build/{BUILD_CREATE_REQUEST.name}/{BUILD_CREATE_REQUEST.number}", - json=BUILD_ERROR.model_dump(), + json=BUILD_NOT_FOUND_ERROR.model_dump(), status=404, ) responses.add(responses.PUT, f"{URL}/api/build", json=BUILD_CREATE_REQUEST.model_dump(), status=200) From 8e0ffb3eddeb58861bad45744e887a380d77c417 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 17 Sep 2024 16:32:30 +0200 Subject: [PATCH 06/10] docs: updated README.md --- README.md | 55 ++++++++++++++++++++++++++++++++++ pyartifactory/objects/build.py | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7a5bec4..c831a0d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,14 @@ This library enables you to manage Artifactory resources such as users, groups, + [Copy artifact to a new location](#copy-artifact-to-a-new-location) + [Move artifact to a new location](#move-artifact-to-a-new-location) + [Delete an artifact](#delete-an-artifact) + * [Builds](#builds) + + [Get a list of all builds](#get-a-list-of-all-builds) + + [Get the information about a build](#get-the-information-about-a-build) + + [Create build](#create-build) + + [Promote a build](#promote-a-build) + + [Delete one or more builds](#delete-one-or-more-builds) + + [Rename a build](#rename-a-build) + + [Get differences between two builds](#get-differences-between-two-builds) * [Contributing](#contributing) @@ -476,5 +484,52 @@ art.artifacts.delete("") ``` + +### Builds + +#### Get a list of all builds +```python +build_list: BuildListResponse = art.builds.list() +``` + +#### Get the information about a build + +```python +build_info: BuildInfo = art.builds.get_build_info("", "") +``` + +Note: optional BuildProperties can be used to query the correct build info of interest. + +#### Create build + +```python +_build_create_request = BuildCreateRequest(name="", number="", started="") +_create_build = art.builds.create_build(_build_create_request) +``` + + +#### Promote a build +```python +_build_promote_request = BuildPromotionRequest(sourceRepo="", targetRepo="") +_promote_build: BuildPromotionResult = art.builds.promote_build("", "", _build_promote_request) +``` + +#### Delete one or more builds +```python +_build_delete_request = BuildDeleteRequest(buildName="", buildNumbers=["", "", ...]) +art.builds.delete(_build_delete_request) +``` + +#### Rename a build +```python +art.builds.build_rename("", "") +``` + +#### Get differences between two builds +```python +_build_diffs: BuildDiffResponse = art.builds.build_diff("", "", "") +``` + + ### Contributing Please read the [Development - Contributing](./CONTRIBUTING.md) guidelines. diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py index 7b7cb7b..cbbcb45 100644 --- a/pyartifactory/objects/build.py +++ b/pyartifactory/objects/build.py @@ -54,7 +54,7 @@ def get_build_info( return BuildInfo(**response.json()) - def create_build(self, create_build_request: BuildCreateRequest): + def create_build(self, create_build_request: BuildCreateRequest) -> None: try: self.get_build_info(create_build_request.name, create_build_request.number) except BuildNotFoundError: From 3a61493d82451d7a17d65e66bd5a6e9b30cfcb58 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Wed, 18 Sep 2024 09:34:00 +0200 Subject: [PATCH 07/10] fix: suggested changes --- README.md | 27 ++++++++++++++++++++------- pyartifactory/exception.py | 11 ----------- pyartifactory/models/build.py | 2 +- pyartifactory/objects/build.py | 11 +++-------- 4 files changed, 24 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index c831a0d..ad23175 100644 --- a/README.md +++ b/README.md @@ -500,24 +500,37 @@ build_info: BuildInfo = art.builds.get_build_info("", "") +build_info: BuildInfo = art.builds.get_build_info("", "", properties=build_properties) +# build_info contains diff between and +``` + + +```python +# started is the earliest build time to return +build_properties = BuildProperties(started="") +build_info: BuildInfo = art.builds.get_build_info("", "", properties=build_properties) +``` + #### Create build ```python -_build_create_request = BuildCreateRequest(name="", number="", started="") -_create_build = art.builds.create_build(_build_create_request) +build_create_request = BuildCreateRequest(name="", number="", started="") +create_build = art.builds.create_build(build_create_request) ``` #### Promote a build ```python -_build_promote_request = BuildPromotionRequest(sourceRepo="", targetRepo="") -_promote_build: BuildPromotionResult = art.builds.promote_build("", "", _build_promote_request) +build_promote_request = BuildPromotionRequest(sourceRepo="", targetRepo="") +promote_build: BuildPromotionResult = art.builds.promote_build("", "", build_promote_request) ``` #### Delete one or more builds ```python -_build_delete_request = BuildDeleteRequest(buildName="", buildNumbers=["", "", ...]) -art.builds.delete(_build_delete_request) +build_delete_request = BuildDeleteRequest(buildName="", buildNumbers=["", "", ...]) +art.builds.delete(build_delete_request) ``` #### Rename a build @@ -527,7 +540,7 @@ art.builds.build_rename("", "") #### Get differences between two builds ```python -_build_diffs: BuildDiffResponse = art.builds.build_diff("", "", "") +build_diffs: BuildDiffResponse = art.builds.build_diff("", "", "") ``` diff --git a/pyartifactory/exception.py b/pyartifactory/exception.py index 96e7ac1..086ed1e 100644 --- a/pyartifactory/exception.py +++ b/pyartifactory/exception.py @@ -58,14 +58,3 @@ class InvalidTokenDataError(ArtifactoryError): class BuildNotFoundError(ArtifactoryError): """Requested build were not found""" - - -class ArtifactoryBuildError(ArtifactoryError): - """Artifactory Build Error.""" - - status: int - message: str - - def __init__(self, status: int, message: str): - self.status = status - self.message = message diff --git a/pyartifactory/models/build.py b/pyartifactory/models/build.py index f58aed9..2fa08c5 100644 --- a/pyartifactory/models/build.py +++ b/pyartifactory/models/build.py @@ -39,7 +39,7 @@ class Run(BaseModel): """Models an artifactory single build run.""" uri: str - buildsNumbers: str + started: str class BuildRun(BaseModel): diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py index cbbcb45..e7922c8 100644 --- a/pyartifactory/objects/build.py +++ b/pyartifactory/objects/build.py @@ -41,9 +41,6 @@ def get_build_info( see https://jfrog.com/help/r/jfrog-rest-apis/build-info :return: BuildInfo model object containing server response """ - build_name = build_name.strip("/") - build_number = build_number.strip("/") - try: response = self._get( f"api/{self._uri}/{build_name}/{build_number}{properties.to_query_string()}", @@ -120,8 +117,7 @@ def list(self) -> BuildListResponse: """ response = self._get(f"api/{self._uri}") logger.debug("List all builds successful") - build_list: BuildListResponse = BuildListResponse.model_validate(response.json()) - return build_list + return BuildListResponse.model_validate(response.json()) def delete(self, delete_build: BuildDeleteRequest) -> None: """ @@ -133,12 +129,11 @@ def delete(self, delete_build: BuildDeleteRequest) -> None: self._get( f"api/{self._uri}/{delete_build.buildName}/{_build_number}", ) - except requests.exceptions.HTTPError as error: - self._raise_exception(error) - else: # all build numbers exist _del = self._post(f"api/{self._uri}/delete", json=delete_build.model_dump()) logger.debug("Builds %s deleted from %s", ",".join(delete_build.buildNumbers), delete_build.buildName) + except requests.exceptions.HTTPError as error: + self._raise_exception(error) def build_rename(self, build_name: str, new_build_name: str) -> None: """ From 9fee66ea54504f69549d20c9d5da5ce720e27b12 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Wed, 18 Sep 2024 12:14:57 +0200 Subject: [PATCH 08/10] fix: suggested fixes --- pyartifactory/objects/build.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py index e7922c8..6698e34 100644 --- a/pyartifactory/objects/build.py +++ b/pyartifactory/objects/build.py @@ -130,7 +130,7 @@ def delete(self, delete_build: BuildDeleteRequest) -> None: f"api/{self._uri}/{delete_build.buildName}/{_build_number}", ) # all build numbers exist - _del = self._post(f"api/{self._uri}/delete", json=delete_build.model_dump()) + self._post(f"api/{self._uri}/delete", json=delete_build.model_dump()) logger.debug("Builds %s deleted from %s", ",".join(delete_build.buildNumbers), delete_build.buildName) except requests.exceptions.HTTPError as error: self._raise_exception(error) @@ -161,10 +161,6 @@ def build_diff(self, build_name: str, build_number: str, older_build_number: str :param older_build_number: Starting build to be compared :return: BuildDiffResponse model object containing server response """ - build_name = build_name.strip("/") - build_number = build_number.strip("/") - older_build_number = older_build_number.strip("/") - try: response = self._get( f"api/{self._uri}/{build_name}/{build_number}?diff={older_build_number}", From dd8181728251fcb1603cbf734acbaf72ac1ff740 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Wed, 18 Sep 2024 15:01:11 +0200 Subject: [PATCH 09/10] fix: removing not needed check and its test --- pyartifactory/objects/build.py | 7 +------ tests/test_build.py | 16 ---------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/pyartifactory/objects/build.py b/pyartifactory/objects/build.py index 6698e34..68661ac 100644 --- a/pyartifactory/objects/build.py +++ b/pyartifactory/objects/build.py @@ -58,12 +58,7 @@ def create_build(self, create_build_request: BuildCreateRequest) -> None: # other exception from get_build_info are forwarded to caller. try: # build does not exist, can be created here - _resp = self._put(f"api/{self._uri}", json=create_build_request.model_dump()) - if _resp.status_code != 204: - logger.error("Build %s in %s not created", create_build_request.number, create_build_request.name) - raise ArtifactoryError( - f"Build {create_build_request.number} in {create_build_request.name} not created", - ) + self._put(f"api/{self._uri}", json=create_build_request.model_dump()) logging.debug( "Build %s in %s successfully created", create_build_request.number, diff --git a/tests/test_build.py b/tests/test_build.py index 94fd05a..b3056ec 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -261,19 +261,3 @@ def test_create_build_error_already_exist(mocker): mocker.spy(artifactory_build, "create_build") with pytest.raises(ArtifactoryError): artifactory_build.create_build(BUILD_CREATE_REQUEST) - - -@responses.activate -def test_create_build_error_not_created(mocker): - responses.add( - responses.GET, - f"{URL}/api/build/{BUILD_CREATE_REQUEST.name}/{BUILD_CREATE_REQUEST.number}", - json=BUILD_NOT_FOUND_ERROR.model_dump(), - status=404, - ) - responses.add(responses.PUT, f"{URL}/api/build", json=BUILD_CREATE_REQUEST.model_dump(), status=200) - - artifactory_build = ArtifactoryBuild(AuthModel(url=URL, auth=AUTH)) - mocker.spy(artifactory_build, "create_build") - with pytest.raises(ArtifactoryError): - artifactory_build.create_build(BUILD_CREATE_REQUEST) From a836800f80752fd2ac19138295bce051c0f04692 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Thu, 19 Sep 2024 10:58:42 +0200 Subject: [PATCH 10/10] fix: added missing assert --- tests/test_build.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_build.py b/tests/test_build.py index b3056ec..f54744a 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -29,7 +29,7 @@ BUILD_GENERIC_ERROR = BuildError(errors=[{"status": 500, "message": "Generic error"}]) BUILD_DIFF = BuildDiffResponse() BUILD_PROMOTION_REQUEST = BuildPromotionRequest(sourceRepo="repo-abc", targetRepo="repo-def") -BUILD_PROMOTION_RESULT = BuildPromotionResult() +BUILD_PROMOTION_RESPONSE = BuildPromotionResult() BUILD_DELETE_REQUEST = BuildDeleteRequest(buildName="build", buildNumbers=["abc", "123"]) BUILD_DELETE_ERROR = BuildError(errors=[{"status": 404, "message": "Not found"}]) BUILD_CREATE_REQUEST = BuildCreateRequest(name="a-build", number="build-xx", started="2014-09-30T12:00:19.893+0300") @@ -58,6 +58,7 @@ def test_get_build_info_success(mocker): get_build = artifactory_build.get_build_info("build_name", "abc") assert isinstance(get_build, BuildInfo) + assert get_build == BUILD_INFO @responses.activate @@ -123,6 +124,7 @@ def test_promote_build_success(mocker): build_promotion = artifactory_build.promote_build("build_proj", "build_number", BUILD_PROMOTION_REQUEST) assert isinstance(build_promotion, BuildPromotionResult) + assert build_promotion == BUILD_PROMOTION_RESPONSE @responses.activate @@ -164,6 +166,7 @@ def test_list_build(mocker): build_list = artifactory_build.list() assert isinstance(build_list, BuildListResponse) + assert build_list == BUILD_LIST_RESPONSE @responses.activate