diff --git a/README.md b/README.md index b5cb9d2..d5c1b7d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This library enables you to manage Artifactory resources such as users, groups, + [Repository](#repository) + [Permission](#permission) * [Artifacts](#artifacts) + + [Get the information about a file or folder](#get-the-information-about-a-file-or-folder) + [Deploy an artifact](#deploy-an-artifact) + [Download an artifact](#download-an-artifact) + [Retrieve artifact properties](#retrieve-artifact-properties) @@ -262,6 +263,13 @@ art.permissions.delete("test_permission") ### Artifacts +#### Get the information about a file or folder +```python +artifact_info = art.artifacts.info("") +# file_info = art.artifacts.info("my-repository/my/artifact/directory/file.txt") +# folder_info = art.artifacts.info("my-repository/my/artifact/directory") +``` + #### Deploy an artifact ```python artifact = art.artifacts.deploy("", "") @@ -278,16 +286,16 @@ artifact = art.artifacts.download("", "") +artifact_properties = art.artifacts.properties("") # returns all properties # artifact_properties = art.artifacts.properties("my-repository/my/new/artifact/directory/file.txt") ->>> print(artifact_properties.json) +artifact_properties = art.artifacts.properties("", ["prop1", "prop2"]) # returns specific properties +artifact_properties.properties["prop1"] # ["value1", "value1-bis"] ``` #### Retrieve artifact stats ```python artifact_stats = art.artifacts.stats("") # artifact_stats = art.artifacts.stats("my-repository/my/new/artifact/directory/file.txt") ->>> print(artifact_stats.json) ``` #### Copy artifact to a new location diff --git a/mypy.ini b/mypy.ini index 0f9e5b8..5dfd96f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,7 +1,12 @@ [mypy] +files = pyartifactory plugins = pydantic.mypy warn_return_any = True warn_unused_configs = True +pretty = True [mypy-requests_toolbelt.multipart] ignore_missing_imports = True + +[pydantic-mypy] +init_forbid_extra = True diff --git a/pyartifactory/exception.py b/pyartifactory/exception.py index 8f7e531..bb5d591 100644 --- a/pyartifactory/exception.py +++ b/pyartifactory/exception.py @@ -39,5 +39,13 @@ class PermissionNotFoundException(ArtifactoryException): """A permission object was not found.""" +class ArtifactNotFoundException(ArtifactoryException): + """An artifact was not found""" + + +class PropertyNotFoundException(ArtifactoryException): + """All requested properties were not found""" + + class InvalidTokenDataException(ArtifactoryException): """The token contains invalid data.""" diff --git a/pyartifactory/models/__init__.py b/pyartifactory/models/__init__.py index 97a7e77..1d7cccc 100644 --- a/pyartifactory/models/__init__.py +++ b/pyartifactory/models/__init__.py @@ -1,7 +1,7 @@ """ Import all models here. """ -from typing import Union +from typing import Union, Type from .auth import AuthModel, ApiKeyModel, PasswordModel, AccessTokenModel from .group import Group, SimpleGroup @@ -16,7 +16,13 @@ SimpleRepository, ) -from .artifact import ArtifactPropertiesResponse, ArtifactStatsResponse +from .artifact import ( + ArtifactPropertiesResponse, + ArtifactStatsResponse, + ArtifactFileInfoResponse, + ArtifactFolderInfoResponse, + ArtifactInfoResponse, +) from .permission import Permission, SimplePermission diff --git a/pyartifactory/models/artifact.py b/pyartifactory/models/artifact.py index defb6e3..b4623c5 100644 --- a/pyartifactory/models/artifact.py +++ b/pyartifactory/models/artifact.py @@ -4,7 +4,7 @@ from datetime import datetime -from typing import Optional, List +from typing import Optional, List, Dict, Union from pydantic import BaseModel @@ -26,27 +26,44 @@ class Child(BaseModel): """Models a child folder.""" uri: str - folder: str + folder: bool class ArtifactPropertiesResponse(BaseModel): - """Models an artifact properties response.""" + """ Models an artifact properties response.""" + + uri: str + properties: Dict[str, List[str]] + + +class ArtifactInfoResponseBase(BaseModel): + """The base information available for both file and folder""" repo: str path: str created: Optional[datetime] = None - createdBy: str + createdBy: Optional[str] = None lastModified: Optional[datetime] = None modifiedBy: Optional[str] = None lastUpdated: Optional[datetime] = None + uri: str + + +class ArtifactFolderInfoResponse(ArtifactInfoResponseBase): + """Models an artifact folder info response.""" + + children: List[Child] + + +class ArtifactFileInfoResponse(ArtifactInfoResponseBase): + """Models an artifact file info response.""" + downloadUri: Optional[str] = None remoteUrl: Optional[str] = None mimeType: Optional[str] = None - size: Optional[str] = None + size: Optional[int] = None checksums: Optional[Checksums] = None originalChecksums: Optional[OriginalChecksums] = None - children: Optional[List[Child]] = None - uri: str class ArtifactStatsResponse(BaseModel): @@ -58,3 +75,6 @@ class ArtifactStatsResponse(BaseModel): lastDownloadedBy: Optional[str] remoteDownloadCount: int remoteLastDownloaded: int + + +ArtifactInfoResponse = Union[ArtifactFileInfoResponse, ArtifactFolderInfoResponse] diff --git a/pyartifactory/objects.py b/pyartifactory/objects.py index df99e48..1bde63a 100644 --- a/pyartifactory/objects.py +++ b/pyartifactory/objects.py @@ -22,6 +22,8 @@ PermissionAlreadyExistsException, PermissionNotFoundException, InvalidTokenDataException, + PropertyNotFoundException, + ArtifactNotFoundException, ) from pyartifactory.models import ( @@ -47,6 +49,11 @@ SimplePermission, ArtifactPropertiesResponse, ArtifactStatsResponse, + ArtifactInfoResponse, +) +from pyartifactory.models.artifact import ( + ArtifactFileInfoResponse, + ArtifactFolderInfoResponse, ) logger = logging.getLogger("pyartifactory") @@ -56,11 +63,7 @@ class Artifactory: """Models artifactory.""" def __init__( - self, - url: str, - auth: Tuple[str, str] = None, - verify: bool = True, - cert: str = None, + self, url: str, auth: Tuple[str, str], verify: bool = True, cert: str = None, ): self.artifactory = AuthModel(url=url, auth=auth, verify=verify, cert=cert) self.users = ArtifactoryUser(self.artifactory) @@ -745,9 +748,34 @@ def delete(self, permission_name: str) -> None: class ArtifactoryArtifact(ArtifactoryObject): """Models an artifactory artifact.""" + def info(self, artifact_path: str) -> ArtifactInfoResponse: + """ + Retrieve information about a file or a folder + + See https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-FolderInfo + and https://www.jfrog.com/confluence/display/JFROG/Artifactory+REST+API#ArtifactoryRESTAPI-FileInfo + + :param artifact_path: Path to file or folder in Artifactory + """ + artifact_path = artifact_path.lstrip("/") + try: + response = self._get(f"api/storage/{artifact_path}") + artifact_info: ArtifactInfoResponse = parse_obj_as( + Union[ArtifactFolderInfoResponse, ArtifactFileInfoResponse], + response.json(), + ) + return artifact_info + except requests.exceptions.HTTPError as error: + if error.response.status_code == 404: + logger.error("Artifact %s does not exist", artifact_path) + raise ArtifactNotFoundException( + f"Artifact {artifact_path} does not exist" + ) + raise ArtifactoryException from error + def deploy( self, local_file_location: str, artifact_path: str - ) -> ArtifactPropertiesResponse: + ) -> ArtifactInfoResponse: """ :param artifact_path: Path to file in Artifactory :param local_file_location: Location of the file to deploy @@ -764,7 +792,7 @@ def deploy( headers = {"Prefer": "respond-async", "Content-Type": form.content_type} self._put(f"{artifact_path}", headers=headers, data=form) logger.debug("Artifact %s successfully deployed", local_filename) - return self.properties(artifact_path) + return self.info(artifact_path) def download(self, artifact_path: str, local_directory_path: str = None) -> str: """ @@ -789,15 +817,30 @@ def download(self, artifact_path: str, local_directory_path: str = None) -> str: logger.debug("Artifact %s successfully downloaded", local_filename) return local_file_full_path - def properties(self, artifact_path: str) -> ArtifactPropertiesResponse: + def properties( + self, artifact_path: str, properties: Optional[List[str]] = None + ) -> ArtifactPropertiesResponse: """ :param artifact_path: Path to file in Artifactory + :param properties: List of properties to retrieve :return: Artifact properties """ + if properties is None: + properties = [] artifact_path = artifact_path.lstrip("/") - response = self._get(f"api/storage/{artifact_path}?properties[=x[,y]]") - logger.debug("Artifact Properties successfully retrieved") - return ArtifactPropertiesResponse(**response.json()) + try: + response = self._get( + f"api/storage/{artifact_path}", + params={"properties": ",".join(properties)}, + ) + logger.debug("Artifact Properties successfully retrieved") + return ArtifactPropertiesResponse(**response.json()) + except requests.exceptions.HTTPError as error: + if error.response.status_code == 404: + raise PropertyNotFoundException( + f"Properties {properties} were not found on artifact {artifact_path}" + ) + raise ArtifactoryException from error def stats(self, artifact_path: str) -> ArtifactStatsResponse: """ @@ -811,12 +854,12 @@ def stats(self, artifact_path: str) -> ArtifactStatsResponse: def copy( self, artifact_current_path: str, artifact_new_path: str, dryrun: bool = False - ) -> ArtifactPropertiesResponse: + ) -> ArtifactInfoResponse: """ :param artifact_current_path: Current path to file :param artifact_new_path: New path to file :param dryrun: Dry run - :return: ArtifactPropertiesResponse: properties of the copied artifact + :return: ArtifactInfoResponse: info of the copied artifact """ artifact_current_path = artifact_current_path.lstrip("/") artifact_new_path = artifact_new_path.lstrip("/") @@ -827,16 +870,16 @@ def copy( self._post(f"api/copy/{artifact_current_path}?to={artifact_new_path}&dry={dry}") logger.debug("Artifact %s successfully copied", artifact_current_path) - return self.properties(artifact_new_path) + return self.info(artifact_new_path) def move( self, artifact_current_path: str, artifact_new_path: str, dryrun: bool = False - ) -> ArtifactPropertiesResponse: + ) -> ArtifactInfoResponse: """ :param artifact_current_path: Current path to file :param artifact_new_path: New path to file :param dryrun: Dry run - :return: ArtifactPropertiesResponse: properties of the moved artifact + :return: ArtifactInfoResponse: info of the moved artifact """ artifact_current_path = artifact_current_path.lstrip("/") artifact_new_path = artifact_new_path.lstrip("/") @@ -848,7 +891,7 @@ def move( self._post(f"api/move/{artifact_current_path}?to={artifact_new_path}&dry={dry}") logger.debug("Artifact %s successfully moved", artifact_current_path) - return self.properties(artifact_new_path) + return self.info(artifact_new_path) def delete(self, artifact_path: str) -> None: """ diff --git a/tests/test_artifacts.py b/tests/test_artifacts.py index 638d463..d042a16 100644 --- a/tests/test_artifacts.py +++ b/tests/test_artifacts.py @@ -1,27 +1,69 @@ +import pytest import responses from pyartifactory import ArtifactoryArtifact +from pyartifactory.exception import PropertyNotFoundException from pyartifactory.models import ( ArtifactPropertiesResponse, ArtifactStatsResponse, AuthModel, ) +from pyartifactory.models.artifact import ( + ArtifactFolderInfoResponse, + ArtifactFileInfoResponse, +) URL = "http://localhost:8080/artifactory" AUTH = ("user", "password_or_apiKey") -ARTIFACT_PATH = "my-repository/file.txt" +ARTIFACT_FOLDER = "my_repository" +ARTIFACT_PATH = f"{ARTIFACT_FOLDER}/file.txt" ARTIFACT_NEW_PATH = "my-second-repository/file.txt" ARTIFACT_SHORT_PATH = "/file.txt" LOCAL_FILE_LOCATION = "tests/test_artifacts.py" -ARTIFACT_PROPERTIES = ArtifactPropertiesResponse( - repo="my-repository", path=ARTIFACT_SHORT_PATH, createdBy="myself", uri="my_uri" +ARTIFACT_ONE_PROPERTY = ArtifactPropertiesResponse( + uri=f"{URL}/api/storage/{ARTIFACT_PATH}", properties={"prop1": ["value"]} ) -NEW_ARTIFACT_PROPERTIES = ArtifactPropertiesResponse( - repo="my-second-repository", - path=ARTIFACT_SHORT_PATH, - createdBy="myself", - uri="my_uri", +ARTIFACT_MULTIPLE_PROPERTIES = ArtifactPropertiesResponse( + uri=f"{URL}/api/storage/{ARTIFACT_PATH}", + properties={"prop1": ["value"], "prop2": ["another value", "with multiple parts"]}, ) +FOLDER_INFO_RESPONSE = { + "uri": f"{URL}/api/storage/{ARTIFACT_FOLDER}", + "repo": ARTIFACT_FOLDER, + "path": "/", + "created": "2019-06-06T13:19:14.514Z", + "createdBy": "userY", + "lastModified": "2019-06-06T13:19:14.514Z", + "modifiedBy": "userX", + "lastUpdated": "2019-06-06T13:19:14.514Z", + "children": [ + {"uri": "/child1", "folder": "true"}, + {"uri": "/child2", "folder": "false"}, + ], +} +FOLDER_INFO = ArtifactFolderInfoResponse(**FOLDER_INFO_RESPONSE) +FILE_INFO_RESPONSE = { + "repo": ARTIFACT_FOLDER, + "path": ARTIFACT_PATH, + "created": "2019-06-06T13:19:14.514Z", + "createdBy": "userY", + "lastModified": "2019-06-06T13:19:14.514Z", + "modifiedBy": "userX", + "lastUpdated": "2019-06-06T13:19:14.514Z", + "downloadUri": f"{URL}/api/storage/{ARTIFACT_PATH}", + "mimeType": "application/json", + "size": "3454", + "checksums": { + "sha1": "962c287c760e03b03c17eb920f5358d05f44dd3b", + "md5": "4cf609e0fe1267df8815bc650f5851e9", + "sha256": "396cf16e8ce000342c95ffc7feb2a15701d0994b70c1b13fea7112f85ac8e858", + }, + "originalChecksums": { + "sha256": "396cf16e8ce000342c95ffc7feb2a15701d0994b70c1b13fea7112f85ac8e858" + }, + "uri": f"{URL}/api/storage/{ARTIFACT_PATH}", +} +FILE_INFO = ArtifactFileInfoResponse(**FILE_INFO_RESPONSE) ARTIFACT_STATS = ArtifactStatsResponse( uri="my_uri", @@ -32,22 +74,49 @@ ) +@responses.activate +def test_get_artifact_folder_info_success(): + responses.add( + responses.GET, + f"{URL}/api/storage/{ARTIFACT_FOLDER}", + status=200, + json=FOLDER_INFO_RESPONSE, + ) + artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) + artifact = artifactory.info(ARTIFACT_FOLDER) + assert isinstance(artifact, ArtifactFolderInfoResponse) + assert artifact.dict() == FOLDER_INFO.dict() + + +@responses.activate +def test_get_artifact_file_info_success(): + responses.add( + responses.GET, + f"{URL}/api/storage/{ARTIFACT_PATH}", + status=200, + json=FILE_INFO_RESPONSE, + ) + artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) + artifact = artifactory.info(ARTIFACT_PATH) + assert artifact.dict() == FILE_INFO.dict() + + @responses.activate def test_deploy_artifact_success(mocker): responses.add(responses.PUT, f"{URL}/{ARTIFACT_PATH}", status=200) responses.add( responses.GET, - f"{URL}/api/storage/{ARTIFACT_PATH}?properties[=x[,y]]", - json=ARTIFACT_PROPERTIES.dict(), + f"{URL}/api/storage/{ARTIFACT_PATH}", + json=FILE_INFO_RESPONSE, status=200, ) artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) - mocker.spy(artifactory, "properties") + mocker.spy(artifactory, "info") artifact = artifactory.deploy(LOCAL_FILE_LOCATION, ARTIFACT_PATH) - artifactory.properties.assert_called_once_with(ARTIFACT_PATH) - assert artifact.dict() == ARTIFACT_PROPERTIES.dict() + artifactory.info.assert_called_once_with(ARTIFACT_PATH) + assert artifact.dict() == FILE_INFO.dict() @responses.activate @@ -65,17 +134,75 @@ def test_download_artifact_success(tmp_path): @responses.activate -def test_get_artifact_properties_success(): +def test_get_artifact_single_property_success(): + responses.add( + responses.GET, + f"{URL}/api/storage/{ARTIFACT_PATH}?properties=prop1", + json=ARTIFACT_ONE_PROPERTY.dict(), + status=200, + ) + + artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) + artifact_properties = artifactory.properties(ARTIFACT_PATH, ["prop1"]) + assert artifact_properties.dict() == ARTIFACT_ONE_PROPERTY.dict() + + +@responses.activate +def test_get_artifact_multiple_properties_success(): responses.add( responses.GET, - f"{URL}/api/storage/{ARTIFACT_PATH}?properties[=x[,y]]", - json=ARTIFACT_PROPERTIES.dict(), + f"{URL}/api/storage/{ARTIFACT_PATH}?properties=prop1,prop2", + json=ARTIFACT_MULTIPLE_PROPERTIES.dict(), + status=200, + ) + + artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) + artifact_properties = artifactory.properties(ARTIFACT_PATH, ["prop1", "prop2"]) + assert artifact_properties.dict() == ARTIFACT_MULTIPLE_PROPERTIES.dict() + + +@responses.activate +def test_get_artifact_multiple_properties_with_non_existing_properties_success(): + responses.add( + responses.GET, + f"{URL}/api/storage/{ARTIFACT_PATH}?properties=prop1,prop2,non_existing_prop", + json=ARTIFACT_MULTIPLE_PROPERTIES.dict(), + status=200, + ) + + artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) + artifact_properties = artifactory.properties( + ARTIFACT_PATH, ["prop1", "prop2", "non_existing_prop"] + ) + assert artifact_properties.dict() == ARTIFACT_MULTIPLE_PROPERTIES.dict() + + +@responses.activate +def test_get_artifact_all_properties_success(): + responses.add( + responses.GET, + f"{URL}/api/storage/{ARTIFACT_PATH}?properties", + json=ARTIFACT_MULTIPLE_PROPERTIES.dict(), status=200, ) artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) artifact_properties = artifactory.properties(ARTIFACT_PATH) - assert artifact_properties.dict() == ARTIFACT_PROPERTIES.dict() + assert artifact_properties.dict() == ARTIFACT_MULTIPLE_PROPERTIES.dict() + + +@responses.activate +def test_get_artifact_property_not_found_error(): + responses.add( + responses.GET, + f"{URL}/api/storage/{ARTIFACT_PATH}?properties=a_property_not_found", + json={"errors": [{"status": 404, "message": "No properties could be found."}]}, + status=404, + ) + + artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) + with pytest.raises(PropertyNotFoundException): + artifactory.properties(ARTIFACT_PATH, properties=["a_property_not_found"]) @responses.activate @@ -101,14 +228,14 @@ def test_copy_artifact_success(): ) responses.add( responses.GET, - f"{URL}/api/storage/{ARTIFACT_NEW_PATH}?properties[=x[,y]]", + f"{URL}/api/storage/{ARTIFACT_NEW_PATH}", status=200, - json=NEW_ARTIFACT_PROPERTIES.dict(), + json=FILE_INFO_RESPONSE, ) artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) artifact_copied = artifactory.copy(ARTIFACT_PATH, ARTIFACT_NEW_PATH) - assert artifact_copied.dict() == NEW_ARTIFACT_PROPERTIES.dict() + assert artifact_copied.dict() == FILE_INFO.dict() @responses.activate @@ -120,14 +247,14 @@ def test_move_artifact_success(): ) responses.add( responses.GET, - f"{URL}/api/storage/{ARTIFACT_NEW_PATH}?properties[=x[,y]]", + f"{URL}/api/storage/{ARTIFACT_NEW_PATH}", status=200, - json=NEW_ARTIFACT_PROPERTIES.dict(), + json=FILE_INFO_RESPONSE, ) artifactory = ArtifactoryArtifact(AuthModel(url=URL, auth=AUTH)) artifact_moved = artifactory.move(ARTIFACT_PATH, ARTIFACT_NEW_PATH) - assert artifact_moved.dict() == NEW_ARTIFACT_PROPERTIES.dict() + assert artifact_moved.dict() == FILE_INFO.dict() @responses.activate