Repository: python-gitlab/python-gitlab

Issue:
Add packages API
https://docs.gitlab.com/ee/api/packages.html


Discussion:

Resolving patch:
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -711,8 +711,14 @@ class ProjectDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager
     _from_parent_attrs = {"project_id": "id"}
     _obj_cls = ProjectDeployToken
     _create_attrs = (
-        ("name", "scopes",),
-        ("expires_at", "username",),
+        (
+            "name",
+            "scopes",
+        ),
+        (
+            "expires_at",
+            "username",
+        ),
     )
 
 
@@ -725,8 +731,14 @@ class GroupDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager):
     _from_parent_attrs = {"group_id": "id"}
     _obj_cls = GroupDeployToken
     _create_attrs = (
-        ("name", "scopes",),
-        ("expires_at", "username",),
+        (
+            "name",
+            "scopes",
+        ),
+        (
+            "expires_at",
+            "username",
+        ),
     )
 
 
@@ -1279,6 +1291,23 @@ class GroupNotificationSettingsManager(NotificationSettingsManager):
     _from_parent_attrs = {"group_id": "id"}
 
 
+class GroupPackage(RESTObject):
+    pass
+
+
+class GroupPackageManager(ListMixin, RESTManager):
+    _path = "/groups/%(group_id)s/packages"
+    _obj_cls = GroupPackage
+    _from_parent_attrs = {"group_id": "id"}
+    _list_filters = (
+        "exclude_subgroups",
+        "order_by",
+        "sort",
+        "package_type",
+        "package_name",
+    )
+
+
 class GroupProject(RESTObject):
     pass
 
@@ -1365,6 +1394,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
         ("mergerequests", "GroupMergeRequestManager"),
         ("milestones", "GroupMilestoneManager"),
         ("notificationsettings", "GroupNotificationSettingsManager"),
+        ("packages", "GroupPackageManager"),
         ("projects", "GroupProjectManager"),
         ("runners", "GroupRunnerManager"),
         ("subgroups", "GroupSubgroupManager"),
@@ -2840,6 +2870,22 @@ class ProjectNotificationSettingsManager(NotificationSettingsManager):
     _from_parent_attrs = {"project_id": "id"}
 
 
+class ProjectPackage(ObjectDeleteMixin, RESTObject):
+    pass
+
+
+class ProjectPackageManager(ListMixin, GetMixin, DeleteMixin, RESTManager):
+    _path = "/projects/%(project_id)s/packages"
+    _obj_cls = ProjectPackage
+    _from_parent_attrs = {"project_id": "id"}
+    _list_filters = (
+        "order_by",
+        "sort",
+        "package_type",
+        "package_name",
+    )
+
+
 class ProjectPagesDomain(SaveMixin, ObjectDeleteMixin, RESTObject):
     _id_attr = "domain"
 
@@ -4181,7 +4227,11 @@ class ProjectServiceManager(GetMixin, UpdateMixin, DeleteMixin, ListMixin, RESTM
             ),
         ),
         "jira": (
-            ("url", "username", "password",),
+            (
+                "url",
+                "username",
+                "password",
+            ),
             (
                 "api_url",
                 "active",
@@ -4532,6 +4582,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject):
         ("milestones", "ProjectMilestoneManager"),
         ("notes", "ProjectNoteManager"),
         ("notificationsettings", "ProjectNotificationSettingsManager"),
+        ("packages", "ProjectPackageManager"),
         ("pagesdomains", "ProjectPagesDomainManager"),
         ("pipelines", "ProjectPipelineManager"),
         ("protectedbranches", "ProjectProtectedBranchManager"),


Test patch:
diff --git a/gitlab/tests/objects/conftest.py b/gitlab/tests/objects/conftest.py
--- a/gitlab/tests/objects/conftest.py
+++ b/gitlab/tests/objects/conftest.py
@@ -21,6 +21,11 @@ def created_content():
     return {"message": "201 Created"}
 
 
+@pytest.fixture
+def no_content():
+    return {"message": "204 No Content"}
+
+
 @pytest.fixture
 def resp_export(accepted_content, binary_content):
     """Common fixture for group and project exports."""
diff --git a/gitlab/tests/objects/test_commits.py b/gitlab/tests/objects/test_commits.py
--- a/gitlab/tests/objects/test_commits.py
+++ b/gitlab/tests/objects/test_commits.py
@@ -88,7 +88,13 @@ def test_create_commit(project, resp_create_commit):
     data = {
         "branch": "master",
         "commit_message": "Commit message",
-        "actions": [{"action": "create", "file_path": "README", "content": "",}],
+        "actions": [
+            {
+                "action": "create",
+                "file_path": "README",
+                "content": "",
+            }
+        ],
     }
     commit = project.commits.create(data)
     assert commit.short_id == "ed899a2f"
diff --git a/gitlab/tests/objects/test_packages.py b/gitlab/tests/objects/test_packages.py
new file mode 100644
--- /dev/null
+++ b/gitlab/tests/objects/test_packages.py
@@ -0,0 +1,119 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/packages.html
+"""
+import re
+
+import pytest
+import responses
+
+from gitlab.v4.objects import GroupPackage, ProjectPackage
+
+
+package_content = {
+    "id": 1,
+    "name": "com/mycompany/my-app",
+    "version": "1.0-SNAPSHOT",
+    "package_type": "maven",
+    "_links": {
+        "web_path": "/namespace1/project1/-/packages/1",
+        "delete_api_path": "/namespace1/project1/-/packages/1",
+    },
+    "created_at": "2019-11-27T03:37:38.711Z",
+    "pipeline": {
+        "id": 123,
+        "status": "pending",
+        "ref": "new-pipeline",
+        "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+        "web_url": "https://example.com/foo/bar/pipelines/47",
+        "created_at": "2016-08-11T11:28:34.085Z",
+        "updated_at": "2016-08-11T11:32:35.169Z",
+        "user": {
+            "name": "Administrator",
+            "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+        },
+    },
+    "versions": [
+        {
+            "id": 2,
+            "version": "2.0-SNAPSHOT",
+            "created_at": "2020-04-28T04:42:11.573Z",
+            "pipeline": {
+                "id": 234,
+                "status": "pending",
+                "ref": "new-pipeline",
+                "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a",
+                "web_url": "https://example.com/foo/bar/pipelines/58",
+                "created_at": "2016-08-11T11:28:34.085Z",
+                "updated_at": "2016-08-11T11:32:35.169Z",
+                "user": {
+                    "name": "Administrator",
+                    "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
+                },
+            },
+        }
+    ],
+}
+
+
+@pytest.fixture
+def resp_list_packages():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url=re.compile(r"http://localhost/api/v4/(groups|projects)/1/packages"),
+            json=[package_content],
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_get_package():
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.GET,
+            url="http://localhost/api/v4/projects/1/packages/1",
+            json=package_content,
+            content_type="application/json",
+            status=200,
+        )
+        yield rsps
+
+
+@pytest.fixture
+def resp_delete_package(no_content):
+    with responses.RequestsMock() as rsps:
+        rsps.add(
+            method=responses.DELETE,
+            url="http://localhost/api/v4/projects/1/packages/1",
+            json=no_content,
+            content_type="application/json",
+            status=204,
+        )
+        yield rsps
+
+
+def test_list_project_packages(project, resp_list_packages):
+    packages = project.packages.list()
+    assert isinstance(packages, list)
+    assert isinstance(packages[0], ProjectPackage)
+    assert packages[0].version == "1.0-SNAPSHOT"
+
+
+def test_list_group_packages(group, resp_list_packages):
+    packages = group.packages.list()
+    assert isinstance(packages, list)
+    assert isinstance(packages[0], GroupPackage)
+    assert packages[0].version == "1.0-SNAPSHOT"
+
+
+def test_get_project_package(project, resp_get_package):
+    package = project.packages.get(1)
+    assert isinstance(package, ProjectPackage)
+    assert package.version == "1.0-SNAPSHOT"
+
+
+def test_delete_project_package(project, resp_delete_package):
+    package = project.packages.get(1, lazy=True)
+    package.delete()
diff --git a/gitlab/tests/objects/test_runners.py b/gitlab/tests/objects/test_runners.py
--- a/gitlab/tests/objects/test_runners.py
+++ b/gitlab/tests/objects/test_runners.py
@@ -167,7 +167,9 @@ def resp_runner_delete():
             status=200,
         )
         rsps.add(
-            method=responses.DELETE, url=pattern, status=204,
+            method=responses.DELETE,
+            url=pattern,
+            status=204,
         )
         yield rsps
 
@@ -177,7 +179,9 @@ def resp_runner_disable():
     with responses.RequestsMock() as rsps:
         pattern = re.compile(r".*?/(groups|projects)/1/runners/6")
         rsps.add(
-            method=responses.DELETE, url=pattern, status=204,
+            method=responses.DELETE,
+            url=pattern,
+            status=204,
         )
         yield rsps
 
@@ -187,7 +191,9 @@ def resp_runner_verify():
     with responses.RequestsMock() as rsps:
         pattern = re.compile(r".*?/runners/verify")
         rsps.add(
-            method=responses.POST, url=pattern, status=200,
+            method=responses.POST,
+            url=pattern,
+            status=200,
         )
         yield rsps
 
diff --git a/tools/functional/api/test_packages.py b/tools/functional/api/test_packages.py
new file mode 100644
--- /dev/null
+++ b/tools/functional/api/test_packages.py
@@ -0,0 +1,13 @@
+"""
+GitLab API: https://docs.gitlab.com/ce/api/packages.html
+"""
+
+
+def test_list_project_packages(project):
+    packages = project.packages.list()
+    assert isinstance(packages, list)
+
+
+def test_list_group_packages(group):
+    packages = group.packages.list()
+    assert isinstance(packages, list)
diff --git a/tools/functional/cli/test_cli_packages.py b/tools/functional/cli/test_cli_packages.py
new file mode 100644
--- /dev/null
+++ b/tools/functional/cli/test_cli_packages.py
@@ -0,0 +1,12 @@
+def test_list_project_packages(gitlab_cli, project):
+    cmd = ["project-package", "list", "--project-id", project.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
+
+
+def test_list_group_packages(gitlab_cli, group):
+    cmd = ["group-package", "list", "--group-id", group.id]
+    ret = gitlab_cli(cmd)
+
+    assert ret.success
diff --git a/tools/functional/test_cli_v4.py b/tools/functional/cli/test_cli_v4.py
similarity index 100%
rename from tools/functional/test_cli_v4.py
rename to tools/functional/cli/test_cli_v4.py
diff --git a/tools/functional_tests.sh b/tools/functional_tests.sh
--- a/tools/functional_tests.sh
+++ b/tools/functional_tests.sh
@@ -18,4 +18,4 @@ setenv_script=$(dirname "$0")/build_test_env.sh || exit 1
 BUILD_TEST_ENV_AUTO_CLEANUP=true
 . "$setenv_script" "$@" || exit 1
 
-pytest "$(dirname "$0")/functional/test_cli_v4.py"
+pytest "$(dirname "$0")/functional/cli"
diff --git a/tools/py_functional_tests.sh b/tools/py_functional_tests.sh
--- a/tools/py_functional_tests.sh
+++ b/tools/py_functional_tests.sh
@@ -19,3 +19,4 @@ BUILD_TEST_ENV_AUTO_CLEANUP=true
 . "$setenv_script" "$@" || exit 1
 
 try python "$(dirname "$0")"/python_test_v${API_VER}.py
+pytest "$(dirname "$0")/functional/api"
diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py
--- a/tools/python_test_v4.py
+++ b/tools/python_test_v4.py
@@ -701,7 +701,12 @@
 
 # Remove once https://gitlab.com/gitlab-org/gitlab/-/issues/211878 is fixed
 deploy_token = deploy_token_group.deploytokens.create(
-    {"name": "foo", "username": "", "expires_at": "", "scopes": ["read_repository"],}
+    {
+        "name": "foo",
+        "username": "",
+        "expires_at": "",
+        "scopes": ["read_repository"],
+    }
 )
 
 assert len(deploy_token_group.deploytokens.list()) == 1