diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6282c4c32..031fb2c8c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -34,7 +34,7 @@ jobs: TOXENV: docs run: tox - name: Archive generated docs - uses: actions/upload-artifact@v4.4.3 + uses: actions/upload-artifact@v4.6.0 with: name: html-docs path: build/sphinx/html/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 29de997ea..a9be4bea8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Python Semantic Release id: release - uses: python-semantic-release/python-semantic-release@v9.15.1 + uses: python-semantic-release/python-semantic-release@v9.17.0 with: github_token: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 1015601e6..cdfaee27b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -15,7 +15,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9.0.0 + - uses: actions/stale@v9.1.0 with: stale-issue-label: "stale" stale-pr-label: "stale" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7f514f28..c448fce2b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,6 +61,7 @@ jobs: run: tox --skip-missing-interpreters false functional: + timeout-minutes: 30 runs-on: ubuntu-24.04 strategy: matrix: @@ -78,7 +79,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox -- --override-ini='log_cli=True' - name: Upload codecov coverage - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.3.1 with: files: ./coverage.xml flags: ${{ matrix.toxenv }} @@ -101,7 +102,7 @@ jobs: TOXENV: cover run: tox - name: Upload codecov coverage - uses: codecov/codecov-action@v5.1.1 + uses: codecov/codecov-action@v5.3.1 with: files: ./coverage.xml flags: unit @@ -121,7 +122,7 @@ jobs: pip install -r requirements-test.txt - name: Build package run: python -m build -o dist/ - - uses: actions/upload-artifact@v4.4.3 + - uses: actions/upload-artifact@v4.6.0 with: name: dist path: dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 285d2cf2a..7b324807e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: hooks: - id: black - repo: https://github.com/commitizen-tools/commitizen - rev: v4.1.0 + rev: v4.1.1 hooks: - id: commitizen stages: [commit-msg] @@ -20,7 +20,7 @@ repos: hooks: - id: isort - repo: https://github.com/pycqa/pylint - rev: v3.3.2 + rev: v3.3.3 hooks: - id: pylint additional_dependencies: @@ -32,7 +32,7 @@ repos: - requests-toolbelt==1.0.0 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v1.14.1 hooks: - id: mypy args: [] @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 39.69.2 + rev: 39.134.0 hooks: - id: renovate-config-validator diff --git a/.renovaterc.json b/.renovaterc.json index ea63c6cef..29fffb8f5 100644 --- a/.renovaterc.json +++ b/.renovaterc.json @@ -23,6 +23,17 @@ "depNameTemplate": "gitlab/gitlab-ee", "datasourceTemplate": "docker", "versioningTemplate": "loose" + }, + { + "fileMatch": [ + "(^|/)tests\\/functional\\/fixtures\\/\\.env$" + ], + "matchStrings": [ + "GITLAB_RUNNER_TAG=(?.*?)\n" + ], + "depNameTemplate": "gitlab/gitlab-runner", + "datasourceTemplate": "docker", + "versioningTemplate": "loose" } ], "packageRules": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 842aacf74..4b0ac9040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,149 @@ # CHANGELOG +## v5.4.0 (2025-01-28) + +### Bug Fixes + +- **api**: Make type ignores more specific where possible + ([`e3cb806`](https://github.com/python-gitlab/python-gitlab/commit/e3cb806dc368af0a495087531ee94892d3f240ce)) + +Instead of using absolute ignore `# type: ignore` use a more specific ignores like `# type: + ignore[override]`. This might help in the future where a new bug might be introduced and get + ignored by a general ignore comment but not a more specific one. + +Signed-off-by: Igor Ponomarev + +- **api**: Return the new commit when calling cherry_pick + ([`de29503`](https://github.com/python-gitlab/python-gitlab/commit/de29503262b7626421f3bffeea3ff073e63e3865)) + +- **files**: Add optional ref parameter for cli project-file raw (python-gitlab#3032) + ([`22f03bd`](https://github.com/python-gitlab/python-gitlab/commit/22f03bdc2bac92138225563415f5cf6fa36a5644)) + +The ef parameter was removed in python-gitlab v4.8.0. This will add ef back as an optional parameter + for the project-file raw cli command. + +### Chores + +- Fix missing space in deprecation message + ([`ba75c31`](https://github.com/python-gitlab/python-gitlab/commit/ba75c31e4d13927b6a3ab0ce427800d94e5eefb4)) + +- Fix pytest deprecation + ([`95db680`](https://github.com/python-gitlab/python-gitlab/commit/95db680d012d73e7e505ee85db7128050ff0db6e)) + +pytest has changed the function argument name to `start_path` + +- Fix warning being generated + ([`0eb5eb0`](https://github.com/python-gitlab/python-gitlab/commit/0eb5eb0505c5b837a2d767cfa256a25b64ceb48b)) + +The CI shows a warning. Use `get_all=False` to resolve issue. + +- Resolve DeprecationWarning message in CI run + ([`accd5aa`](https://github.com/python-gitlab/python-gitlab/commit/accd5aa757ba5215497c278da50d48f10ea5a258)) + +Catch the DeprecationWarning in our test, as we expect it. + +- **ci**: Set a 30 minute timeout for 'functional' tests + ([`e8d6953`](https://github.com/python-gitlab/python-gitlab/commit/e8d6953ec06dbbd817852207abbbc74eab8a27cf)) + +Currently the functional API test takes around 17 minutes to run. And the functional CLI test takes + around 12 minutes to run. + +Occasionally a job gets stuck and will sit until the default 360 minutes job timeout occurs. + +Now have a 30 minute timeout for the 'functional' tests. + +- **deps**: Update all non-major dependencies + ([`939505b`](https://github.com/python-gitlab/python-gitlab/commit/939505b9c143939ba1e52c5cb920d8aa36596e19)) + +- **deps**: Update all non-major dependencies + ([`cbd4263`](https://github.com/python-gitlab/python-gitlab/commit/cbd4263194fcbad9d6c11926862691f8df0dea6d)) + +- **deps**: Update gitlab ([#3088](https://github.com/python-gitlab/python-gitlab/pull/3088), + [`9214b83`](https://github.com/python-gitlab/python-gitlab/commit/9214b8371652be2371823b6f3d531eeea78364c7)) + +Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> + +- **deps**: Update gitlab/gitlab-ee docker tag to v17.7.1-ee.0 + ([#3082](https://github.com/python-gitlab/python-gitlab/pull/3082), + [`1e95944`](https://github.com/python-gitlab/python-gitlab/commit/1e95944119455875bd239752cdf0fe5cc27707ea)) + +Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> + +- **deps**: Update mypy to 1.14 and resolve issues + ([`671e711`](https://github.com/python-gitlab/python-gitlab/commit/671e711c341d28ae0bc61ccb12d2e986353473fd)) + +mypy 1.14 has a change to Enum Membership Semantics: + https://mypy.readthedocs.io/en/latest/changelog.html + +Resolve the issues with Enum and typing, and update mypy to 1.14 + +- **test**: Prevent 'job_with_artifact' fixture running forever + ([`e4673d8`](https://github.com/python-gitlab/python-gitlab/commit/e4673d8aeaf97b9ad5d2500e459526b4cf494547)) + +Previously the 'job_with_artifact' fixture could run forever. Now give it up to 60 seconds to + complete before failing. + +### Continuous Integration + +- Use gitlab-runner:v17.7.1 for the CI + ([`2dda9dc`](https://github.com/python-gitlab/python-gitlab/commit/2dda9dc149668a99211daaa1981bb1f422c63880)) + +The `latest` gitlab-runner image does not have the `gitlab-runner` user and it causes our tests to + fail. + +Closes: #3091 + +### Features + +- **api**: Add argument that appends extra HTTP headers to a request + ([`fb07b5c`](https://github.com/python-gitlab/python-gitlab/commit/fb07b5cfe1d986c3a7cd7879b11ecc43c75542b7)) + +Currently the only way to manipulate the headers for a request is to use `Gitlab.headers` attribute. + However, this makes it very concurrently unsafe because the `Gitlab` object can be shared between + multiple requests at the same time. + +Instead add a new keyword argument `extra_headers` which will update the headers dictionary with new + values just before the request is sent. + +For example, this can be used to download a part of a artifacts file using the `Range` header: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests + +Signed-off-by: Igor Ponomarev + +- **api**: Add support for external status check + ([`175b355`](https://github.com/python-gitlab/python-gitlab/commit/175b355d84d54a71f15fe3601c5275dc35984b9b)) + +- **api**: Narrow down return type of download methods using typing.overload + ([`44fd9dc`](https://github.com/python-gitlab/python-gitlab/commit/44fd9dc1176a2c5529c45cc3186c0e775026175e)) + +Currently the download methods such as `ProjectJob.artifacts` have return type set to + `Optional[Union[bytes, Iterator[Any]]]` which means they return either `None` or `bytes` or + `Iterator[Any]`. + +However, the actual return type is determined by the passed `streamed` and `iterator` arguments. + Using `@typing.overload` decorator it is possible to return a single type based on the passed + arguments. + +Add overloads in the following order to all download methods: + +1. If `streamed=False` and `iterator=False` return `bytes`. This is the default argument values + therefore it should be first as it will be used to lookup default arguments. 2. If `iterator=True` + return `Iterator[Any]`. This can be combined with both `streamed=True` and `streamed=False`. 3. If + `streamed=True` and `iterator=False` return `None`. In this case `action` argument can be set to a + callable that accepts `bytes`. + +Signed-off-by: Igor Ponomarev + +- **api**: Narrow down return type of ProjectFileManager.raw using typing.overload + ([`36d9b24`](https://github.com/python-gitlab/python-gitlab/commit/36d9b24ff27d8df514c1beebd0fff8ad000369b7)) + +This is equivalent to the changes in 44fd9dc1176a2c5529c45cc3186c0e775026175e but for + `ProjectFileManager.raw` method that I must have missed in the original commit. + +Signed-off-by: Igor Ponomarev + + ## v5.3.1 (2025-01-07) ### Bug Fixes diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 4868983e6..c8d4b7891 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -63,6 +63,7 @@ API examples gl_objects/settings gl_objects/snippets gl_objects/statistics + gl_objects/status_checks gl_objects/system_hooks gl_objects/templates gl_objects/todos diff --git a/docs/api-usage-advanced.rst b/docs/api-usage-advanced.rst index ce18fd1e8..331d3446b 100644 --- a/docs/api-usage-advanced.rst +++ b/docs/api-usage-advanced.rst @@ -211,3 +211,20 @@ on your own, such as for nested API responses and ``Union`` return types. For ex if TYPE_CHECKING: assert isinstance(license["plan"], str) + +Per request HTTP headers override +--------------------------------- + +The ``extra_headers`` keyword argument can be used to add and override +the HTTP headers for a specific request. For example, it can be used do add ``Range`` +header to download a part of artifacts archive: + +.. code-block:: python + + import gitlab + + gl = gitlab.Gitlab(url, token) + project = gl.projects.get(1) + job = project.jobs.get(123) + + artifacts = job.artifacts(extra_headers={"Range": "bytes=0-9"}) diff --git a/docs/gl_objects/status_checks.rst b/docs/gl_objects/status_checks.rst new file mode 100644 index 000000000..71d0c1abf --- /dev/null +++ b/docs/gl_objects/status_checks.rst @@ -0,0 +1,57 @@ +####################### +External Status Checks +####################### + +Manage external status checks for projects and merge requests. + + +Project external status checks +=============================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectExternalStatusCheck` + + :class:`gitlab.v4.objects.ProjectExternalStatusCheckManager` + + :attr:`gitlab.v4.objects.Project.external_status_checks` + +* GitLab API: https://docs.gitlab.com/ee/api/status_checks.html + +Examples +--------- + +List external status checks for a project:: + + status_checks = project.external_status_checks.list() + +Create an external status check with shared secret:: + + status_checks = project.external_status_checks.create({ + "name": "mr_blocker", + "external_url": "https://example.com/mr-status-check", + "shared_secret": "secret-string" + }) + +Create an external status check with shared secret for protected branches:: + + protected_branch = project.protectedbranches.get('main') + + status_check = project.external_status_checks.create({ + "name": "mr_blocker", + "external_url": "https://example.com/mr-status-check", + "shared_secret": "secret-string", + "protected_branch_ids": [protected_branch.id] + }) + + +Update an external status check:: + + status_check.external_url = "https://example.com/mr-blocker" + status_check.save() + +Delete an external status check:: + + status_check.delete(status_check_id) + diff --git a/gitlab/_version.py b/gitlab/_version.py index b772bcf4f..f4415c059 100644 --- a/gitlab/_version.py +++ b/gitlab/_version.py @@ -3,4 +3,4 @@ __email__ = "gauvainpocentek@gmail.com" __license__ = "LGPL3" __title__ = "python-gitlab" -__version__ = "5.3.1" +__version__ = "5.4.0" diff --git a/gitlab/client.py b/gitlab/client.py index bf3ffbafc..87b324c34 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -654,6 +654,7 @@ def http_request( obey_rate_limit: bool = True, retry_transient_errors: Optional[bool] = None, max_retries: int = 10, + extra_headers: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> requests.Response: """Make an HTTP request to the Gitlab server. @@ -675,6 +676,7 @@ def http_request( or 52x responses. Defaults to False. max_retries: Max retries after 429 or transient errors, set to -1 to retry forever. Defaults to 10. + extra_headers: Add and override HTTP headers for the request. **kwargs: Extra options to send to the server (e.g. sudo) Returns: @@ -721,6 +723,9 @@ def http_request( send_data = self._backend.prepare_send_data(files, post_data, raw) opts["headers"]["Content-type"] = send_data.content_type + if extra_headers is not None: + opts["headers"].update(extra_headers) + retry = utils.Retry( max_retries=max_retries, obey_rate_limit=obey_rate_limit, diff --git a/gitlab/const.py b/gitlab/const.py index b01ebd3c9..9e0b766ea 100644 --- a/gitlab/const.py +++ b/gitlab/const.py @@ -9,83 +9,83 @@ class GitlabEnum(str, Enum): # https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/lib/gitlab/access.rb#L12-18 class AccessLevel(IntEnum): - NO_ACCESS: int = 0 - MINIMAL_ACCESS: int = 5 - GUEST: int = 10 - PLANNER: int = 15 - REPORTER: int = 20 - DEVELOPER: int = 30 - MAINTAINER: int = 40 - OWNER: int = 50 - ADMIN: int = 60 + NO_ACCESS = 0 + MINIMAL_ACCESS = 5 + GUEST = 10 + PLANNER = 15 + REPORTER = 20 + DEVELOPER = 30 + MAINTAINER = 40 + OWNER = 50 + ADMIN = 60 # https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/lib/gitlab/visibility_level.rb#L23-25 class Visibility(GitlabEnum): - PRIVATE: str = "private" - INTERNAL: str = "internal" - PUBLIC: str = "public" + PRIVATE = "private" + INTERNAL = "internal" + PUBLIC = "public" class NotificationLevel(GitlabEnum): - DISABLED: str = "disabled" - PARTICIPATING: str = "participating" - WATCH: str = "watch" - GLOBAL: str = "global" - MENTION: str = "mention" - CUSTOM: str = "custom" + DISABLED = "disabled" + PARTICIPATING = "participating" + WATCH = "watch" + GLOBAL = "global" + MENTION = "mention" + CUSTOM = "custom" # https://gitlab.com/gitlab-org/gitlab/-/blob/e97357824bedf007e75f8782259fe07435b64fbb/app/views/search/_category.html.haml#L10-37 class SearchScope(GitlabEnum): # all scopes (global, group and project) - PROJECTS: str = "projects" - ISSUES: str = "issues" - MERGE_REQUESTS: str = "merge_requests" - MILESTONES: str = "milestones" - WIKI_BLOBS: str = "wiki_blobs" - COMMITS: str = "commits" - BLOBS: str = "blobs" - USERS: str = "users" + PROJECTS = "projects" + ISSUES = "issues" + MERGE_REQUESTS = "merge_requests" + MILESTONES = "milestones" + WIKI_BLOBS = "wiki_blobs" + COMMITS = "commits" + BLOBS = "blobs" + USERS = "users" # specific global scope - GLOBAL_SNIPPET_TITLES: str = "snippet_titles" + GLOBAL_SNIPPET_TITLES = "snippet_titles" # specific project scope - PROJECT_NOTES: str = "notes" + PROJECT_NOTES = "notes" # https://docs.gitlab.com/ee/api/merge_requests.html#merge-status class DetailedMergeStatus(GitlabEnum): # possible values for the detailed_merge_status field of Merge Requests - BLOCKED_STATUS: str = "blocked_status" - BROKEN_STATUS: str = "broken_status" - CHECKING: str = "checking" - UNCHECKED: str = "unchecked" - CI_MUST_PASS: str = "ci_must_pass" - CI_STILL_RUNNING: str = "ci_still_running" - DISCUSSIONS_NOT_RESOLVED: str = "discussions_not_resolved" - DRAFT_STATUS: str = "draft_status" - EXTERNAL_STATUS_CHECKS: str = "external_status_checks" - MERGEABLE: str = "mergeable" - NOT_APPROVED: str = "not_approved" - NOT_OPEN: str = "not_open" - POLICIES_DENIED: str = "policies_denied" + BLOCKED_STATUS = "blocked_status" + BROKEN_STATUS = "broken_status" + CHECKING = "checking" + UNCHECKED = "unchecked" + CI_MUST_PASS = "ci_must_pass" + CI_STILL_RUNNING = "ci_still_running" + DISCUSSIONS_NOT_RESOLVED = "discussions_not_resolved" + DRAFT_STATUS = "draft_status" + EXTERNAL_STATUS_CHECKS = "external_status_checks" + MERGEABLE = "mergeable" + NOT_APPROVED = "not_approved" + NOT_OPEN = "not_open" + POLICIES_DENIED = "policies_denied" # https://docs.gitlab.com/ee/api/pipelines.html class PipelineStatus(GitlabEnum): - CREATED: str = "created" - WAITING_FOR_RESOURCE: str = "waiting_for_resource" - PREPARING: str = "preparing" - PENDING: str = "pending" - RUNNING: str = "running" - SUCCESS: str = "success" - FAILED: str = "failed" - CANCELED: str = "canceled" - SKIPPED: str = "skipped" - MANUAL: str = "manual" - SCHEDULED: str = "scheduled" + CREATED = "created" + WAITING_FOR_RESOURCE = "waiting_for_resource" + PREPARING = "preparing" + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + CANCELED = "canceled" + SKIPPED = "skipped" + MANUAL = "manual" + SCHEDULED = "scheduled" DEFAULT_URL: str = "https://gitlab.com" diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 2a05278e0..d2e1e0d5e 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -6,7 +6,9 @@ Dict, Iterator, List, + Literal, Optional, + overload, Tuple, Type, TYPE_CHECKING, @@ -612,6 +614,39 @@ class DownloadMixin(_RestObjectBase): _updated_attrs: Dict[str, Any] manager: base.RESTManager + @overload + def download( + self, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def download( + self, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def download( + self, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names=("GroupExport", "ProjectExport")) @exc.on_http_error(exc.GitlabGetError) def download( diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index 3aa992203..7932080ac 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -68,6 +68,7 @@ from .sidekiq import * from .snippets import * from .statistics import * +from .status_checks import * from .tags import * from .templates import * from .todos import * diff --git a/gitlab/v4/objects/artifacts.py b/gitlab/v4/objects/artifacts.py index 4643ad3b1..ce6f90b99 100644 --- a/gitlab/v4/objects/artifacts.py +++ b/gitlab/v4/objects/artifacts.py @@ -3,7 +3,16 @@ https://docs.gitlab.com/ee/api/job_artifacts.html """ -from typing import Any, Callable, Iterator, Optional, TYPE_CHECKING, Union +from typing import ( + Any, + Callable, + Iterator, + Literal, + Optional, + overload, + TYPE_CHECKING, + Union, +) import requests @@ -43,6 +52,45 @@ def delete(self, **kwargs: Any) -> None: assert path is not None self.gitlab.http_delete(path, **kwargs) + @overload + def download( + self, + ref_name: str, + job: str, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def download( + self, + ref_name: str, + job: str, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def download( + self, + ref_name: str, + job: str, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action( cls_names="ProjectArtifactManager", required=("ref_name", "job"), @@ -94,6 +142,48 @@ def download( result, streamed, action, chunk_size, iterator=iterator ) + @overload + def raw( + self, + ref_name: str, + artifact_path: str, + job: str, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def raw( + self, + ref_name: str, + artifact_path: str, + job: str, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def raw( + self, + ref_name: str, + artifact_path: str, + job: str, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action( cls_names="ProjectArtifactManager", required=("ref_name", "artifact_path", "job"), diff --git a/gitlab/v4/objects/commits.py b/gitlab/v4/objects/commits.py index 0cb0a127a..e7c8164b7 100644 --- a/gitlab/v4/objects/commits.py +++ b/gitlab/v4/objects/commits.py @@ -48,7 +48,9 @@ def diff(self, **kwargs: Any) -> Union[gitlab.GitlabList, List[Dict[str, Any]]]: @cli.register_custom_action(cls_names="ProjectCommit", required=("branch",)) @exc.on_http_error(exc.GitlabCherryPickError) - def cherry_pick(self, branch: str, **kwargs: Any) -> None: + def cherry_pick( + self, branch: str, **kwargs: Any + ) -> Union[Dict[str, Any], requests.Response]: """Cherry-pick a commit into a branch. Args: @@ -58,10 +60,13 @@ def cherry_pick(self, branch: str, **kwargs: Any) -> None: Raises: GitlabAuthenticationError: If authentication is not correct GitlabCherryPickError: If the cherry-pick could not be performed + + Returns: + The new commit data (*not* a RESTObject) """ path = f"{self.manager.path}/{self.encoded_id}/cherry_pick" post_data = {"branch": branch} - self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) + return self.manager.gitlab.http_post(path, post_data=post_data, **kwargs) @cli.register_custom_action(cls_names="ProjectCommit", optional=("type",)) @exc.on_http_error(exc.GitlabGetError) diff --git a/gitlab/v4/objects/files.py b/gitlab/v4/objects/files.py index b880bc9dd..ce2193c2c 100644 --- a/gitlab/v4/objects/files.py +++ b/gitlab/v4/objects/files.py @@ -5,7 +5,9 @@ Dict, Iterator, List, + Literal, Optional, + overload, Tuple, TYPE_CHECKING, Union, @@ -51,7 +53,7 @@ def decode(self) -> bytes: # NOTE(jlvillal): Signature doesn't match SaveMixin.save() so ignore # type error - def save( # type: ignore + def save( # type: ignore[override] self, branch: str, commit_message: str, **kwargs: Any ) -> None: """Save the changes made to the file to the server. @@ -75,7 +77,7 @@ def save( # type: ignore @exc.on_http_error(exc.GitlabDeleteError) # NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore # type error - def delete( # type: ignore + def delete( # type: ignore[override] self, branch: str, commit_message: str, **kwargs: Any ) -> None: """Delete the file from the server. @@ -219,7 +221,7 @@ def create( @exc.on_http_error(exc.GitlabUpdateError) # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore # type error - def update( # type: ignore + def update( # type: ignore[override] self, file_path: str, new_data: Optional[Dict[str, Any]] = None, **kwargs: Any ) -> Dict[str, Any]: """Update an object on the server. @@ -254,7 +256,7 @@ def update( # type: ignore @exc.on_http_error(exc.GitlabDeleteError) # NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore # type error - def delete( # type: ignore + def delete( # type: ignore[override] self, file_path: str, branch: str, commit_message: str, **kwargs: Any ) -> None: """Delete a file on the server. @@ -274,9 +276,49 @@ def delete( # type: ignore data = {"branch": branch, "commit_message": commit_message} self.gitlab.http_delete(path, query_data=data, **kwargs) + @overload + def raw( + self, + file_path: str, + ref: Optional[str] = None, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def raw( + self, + file_path: str, + ref: Optional[str] = None, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def raw( + self, + file_path: str, + ref: Optional[str] = None, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action( cls_names="ProjectFileManager", required=("file_path",), + optional=("ref",), ) @exc.on_http_error(exc.GitlabGetError) def raw( diff --git a/gitlab/v4/objects/issues.py b/gitlab/v4/objects/issues.py index 867deec03..df6cf7a5a 100644 --- a/gitlab/v4/objects/issues.py +++ b/gitlab/v4/objects/issues.py @@ -302,7 +302,7 @@ class ProjectIssueLinkManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): @exc.on_http_error(exc.GitlabCreateError) # NOTE(jlvillal): Signature doesn't match CreateMixin.create() so ignore # type error - def create( # type: ignore + def create( # type: ignore[override] self, data: Dict[str, Any], **kwargs: Any ) -> Tuple[RESTObject, RESTObject]: """Create a new object. diff --git a/gitlab/v4/objects/jobs.py b/gitlab/v4/objects/jobs.py index 28a46d775..0c77d76a7 100644 --- a/gitlab/v4/objects/jobs.py +++ b/gitlab/v4/objects/jobs.py @@ -1,4 +1,15 @@ -from typing import Any, Callable, cast, Dict, Iterator, Optional, TYPE_CHECKING, Union +from typing import ( + Any, + Callable, + cast, + Dict, + Iterator, + Literal, + Optional, + overload, + TYPE_CHECKING, + Union, +) import requests @@ -115,6 +126,39 @@ def delete_artifacts(self, **kwargs: Any) -> None: path = f"{self.manager.path}/{self.encoded_id}/artifacts" self.manager.gitlab.http_delete(path, **kwargs) + @overload + def artifacts( + self, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def artifacts( + self, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def artifacts( + self, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="ProjectJob") @exc.on_http_error(exc.GitlabGetError) def artifacts( @@ -156,6 +200,42 @@ def artifacts( result, streamed, action, chunk_size, iterator=iterator ) + @overload + def artifact( + self, + path: str, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def artifact( + self, + path: str, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def artifact( + self, + path: str, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="ProjectJob") @exc.on_http_error(exc.GitlabGetError) def artifact( @@ -199,6 +279,39 @@ def artifact( result, streamed, action, chunk_size, iterator=iterator ) + @overload + def trace( + self, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def trace( + self, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def trace( + self, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="ProjectJob") @exc.on_http_error(exc.GitlabGetError) def trace( diff --git a/gitlab/v4/objects/labels.py b/gitlab/v4/objects/labels.py index 32d4f6ba0..b23062ec9 100644 --- a/gitlab/v4/objects/labels.py +++ b/gitlab/v4/objects/labels.py @@ -66,7 +66,7 @@ def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> GroupLa # Update without ID. # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore # type error - def update( # type: ignore + def update( # type: ignore[override] self, name: Optional[str], new_data: Optional[Dict[str, Any]] = None, @@ -132,7 +132,7 @@ def get( # Update without ID. # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore # type error - def update( # type: ignore + def update( # type: ignore[override] self, name: Optional[str], new_data: Optional[Dict[str, Any]] = None, diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py index e29ab2b28..30ddc11f5 100644 --- a/gitlab/v4/objects/merge_requests.py +++ b/gitlab/v4/objects/merge_requests.py @@ -44,6 +44,7 @@ from .notes import ProjectMergeRequestNoteManager # noqa: F401 from .pipelines import ProjectMergeRequestPipelineManager # noqa: F401 from .reviewers import ProjectMergeRequestReviewerDetailManager +from .status_checks import ProjectMergeRequestStatusCheckManager __all__ = [ "MergeRequest", @@ -167,6 +168,7 @@ class ProjectMergeRequest( resourcemilestoneevents: ProjectMergeRequestResourceMilestoneEventManager resourcestateevents: ProjectMergeRequestResourceStateEventManager reviewer_details: ProjectMergeRequestReviewerDetailManager + status_checks: ProjectMergeRequestStatusCheckManager @cli.register_custom_action(cls_names="ProjectMergeRequest") @exc.on_http_error(exc.GitlabMROnBuildSuccessError) diff --git a/gitlab/v4/objects/packages.py b/gitlab/v4/objects/packages.py index 8dcc3bdc4..c31809d80 100644 --- a/gitlab/v4/objects/packages.py +++ b/gitlab/v4/objects/packages.py @@ -11,7 +11,9 @@ Callable, cast, Iterator, + Literal, Optional, + overload, TYPE_CHECKING, Union, ) @@ -122,6 +124,48 @@ def upload( attrs.update(server_data) return self._obj_cls(self, attrs=attrs) + @overload + def download( + self, + package_name: str, + package_version: str, + file_name: str, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def download( + self, + package_name: str, + package_version: str, + file_name: str, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def download( + self, + package_name: str, + package_version: str, + file_name: str, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action( cls_names="GenericPackageManager", required=("package_name", "package_version", "file_name"), diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 1fb36a01e..b2e86a65d 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -11,7 +11,9 @@ Dict, Iterator, List, + Literal, Optional, + overload, TYPE_CHECKING, Union, ) @@ -102,6 +104,7 @@ ProjectAdditionalStatisticsManager, ProjectIssuesStatisticsManager, ) +from .status_checks import ProjectExternalStatusCheckManager # noqa: F401 from .tags import ProjectProtectedTagManager, ProjectTagManager # noqa: F401 from .templates import ( # noqa: F401 ProjectDockerfileTemplateManager, @@ -251,6 +254,7 @@ class Project( secure_files: ProjectSecureFileManager services: ProjectServiceManager snippets: ProjectSnippetManager + external_status_checks: ProjectExternalStatusCheckManager storage: "ProjectStorageManager" tags: ProjectTagManager triggers: ProjectTriggerManager @@ -487,6 +491,42 @@ def restore(self, **kwargs: Any) -> None: path = f"/projects/{self.encoded_id}/restore" self.manager.gitlab.http_post(path, **kwargs) + @overload + def snapshot( + self, + wiki: bool = False, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def snapshot( + self, + wiki: bool = False, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def snapshot( + self, + wiki: bool = False, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="Project", optional=("wiki",)) @exc.on_http_error(exc.GitlabGetError) def snapshot( diff --git a/gitlab/v4/objects/repositories.py b/gitlab/v4/objects/repositories.py index 7d5b79df4..85dba4b4d 100644 --- a/gitlab/v4/objects/repositories.py +++ b/gitlab/v4/objects/repositories.py @@ -4,7 +4,18 @@ Currently this module only contains repository-related methods for projects. """ -from typing import Any, Callable, Dict, Iterator, List, Optional, TYPE_CHECKING, Union +from typing import ( + Any, + Callable, + Dict, + Iterator, + List, + Literal, + Optional, + overload, + TYPE_CHECKING, + Union, +) import requests @@ -106,6 +117,42 @@ def repository_blob( path = f"/projects/{self.encoded_id}/repository/blobs/{sha}" return self.manager.gitlab.http_get(path, **kwargs) + @overload + def repository_raw_blob( + self, + sha: str, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def repository_raw_blob( + self, + sha: str, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def repository_raw_blob( + self, + sha: str, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="Project", required=("sha",)) @exc.on_http_error(exc.GitlabGetError) def repository_raw_blob( @@ -197,6 +244,42 @@ def repository_contributors( path = f"/projects/{self.encoded_id}/repository/contributors" return self.manager.gitlab.http_list(path, **kwargs) + @overload + def repository_archive( + self, + sha: Optional[str] = None, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def repository_archive( + self, + sha: Optional[str] = None, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def repository_archive( + self, + sha: Optional[str] = None, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="Project", optional=("sha", "format")) @exc.on_http_error(exc.GitlabListError) def repository_archive( diff --git a/gitlab/v4/objects/secure_files.py b/gitlab/v4/objects/secure_files.py index d96c129e4..b329756d3 100644 --- a/gitlab/v4/objects/secure_files.py +++ b/gitlab/v4/objects/secure_files.py @@ -3,7 +3,17 @@ https://docs.gitlab.com/ee/api/secure_files.html """ -from typing import Any, Callable, cast, Iterator, Optional, TYPE_CHECKING, Union +from typing import ( + Any, + Callable, + cast, + Iterator, + Literal, + Optional, + overload, + TYPE_CHECKING, + Union, +) import requests @@ -18,6 +28,39 @@ class ProjectSecureFile(ObjectDeleteMixin, RESTObject): + @overload + def download( + self, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def download( + self, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def download( + self, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="ProjectSecureFile") @exc.on_http_error(exc.GitlabGetError) def download( diff --git a/gitlab/v4/objects/snippets.py b/gitlab/v4/objects/snippets.py index 8d0fc06fc..46c618e33 100644 --- a/gitlab/v4/objects/snippets.py +++ b/gitlab/v4/objects/snippets.py @@ -1,4 +1,15 @@ -from typing import Any, Callable, cast, Iterator, List, Optional, TYPE_CHECKING, Union +from typing import ( + Any, + Callable, + cast, + Iterator, + List, + Literal, + Optional, + overload, + TYPE_CHECKING, + Union, +) import requests @@ -24,6 +35,39 @@ class Snippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObject): _repr_attr = "title" + @overload + def content( + self, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def content( + self, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def content( + self, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="Snippet") @exc.on_http_error(exc.GitlabGetError) def content( @@ -148,7 +192,7 @@ def public(self, **kwargs: Any) -> Union[RESTObjectList, List[RESTObject]]: """ utils.warn( message=( - "Gitlab.snippets.public() is deprecated and will be removed in a" + "Gitlab.snippets.public() is deprecated and will be removed in a " "future major version. Use Gitlab.snippets.list_public() instead." ), category=DeprecationWarning, @@ -167,6 +211,39 @@ class ProjectSnippet(UserAgentDetailMixin, SaveMixin, ObjectDeleteMixin, RESTObj discussions: ProjectSnippetDiscussionManager notes: ProjectSnippetNoteManager + @overload + def content( + self, + streamed: Literal[False] = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> bytes: ... + + @overload + def content( + self, + streamed: bool = False, + action: None = None, + chunk_size: int = 1024, + *, + iterator: Literal[True] = True, + **kwargs: Any, + ) -> Iterator[Any]: ... + + @overload + def content( + self, + streamed: Literal[True] = True, + action: Optional[Callable[[bytes], None]] = None, + chunk_size: int = 1024, + *, + iterator: Literal[False] = False, + **kwargs: Any, + ) -> None: ... + @cli.register_custom_action(cls_names="ProjectSnippet") @exc.on_http_error(exc.GitlabGetError) def content( diff --git a/gitlab/v4/objects/status_checks.py b/gitlab/v4/objects/status_checks.py new file mode 100644 index 000000000..045b57260 --- /dev/null +++ b/gitlab/v4/objects/status_checks.py @@ -0,0 +1,52 @@ +from gitlab.base import RESTManager, RESTObject +from gitlab.mixins import ( + CreateMixin, + DeleteMixin, + ListMixin, + ObjectDeleteMixin, + SaveMixin, + UpdateMethod, + UpdateMixin, +) +from gitlab.types import ArrayAttribute, RequiredOptional + +__all__ = [ + "ProjectExternalStatusCheck", + "ProjectExternalStatusCheckManager", + "ProjectMergeRequestStatusCheck", + "ProjectMergeRequestStatusCheckManager", +] + + +class ProjectExternalStatusCheck(SaveMixin, ObjectDeleteMixin, RESTObject): + pass + + +class ProjectExternalStatusCheckManager( + ListMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTManager +): + _path = "/projects/{project_id}/external_status_checks" + _obj_cls = ProjectExternalStatusCheck + _from_parent_attrs = {"project_id": "id"} + _create_attrs = RequiredOptional( + required=("name", "external_url"), + optional=("shared_secret", "protected_branch_ids"), + ) + _update_attrs = RequiredOptional( + optional=("name", "external_url", "shared_secret", "protected_branch_ids") + ) + _types = {"protected_branch_ids": ArrayAttribute} + + +class ProjectMergeRequestStatusCheck(SaveMixin, RESTObject): + pass + + +class ProjectMergeRequestStatusCheckManager(ListMixin, RESTManager): + _path = "/projects/{project_id}/merge_requests/{merge_request_iid}/status_checks" + _obj_cls = ProjectMergeRequestStatusCheck + _from_parent_attrs = {"project_id": "project_id", "merge_request_iid": "iid"} + _update_attrs = RequiredOptional( + required=("sha", "external_status_check_id", "status") + ) + _update_method = UpdateMethod.POST diff --git a/requirements-lint.txt b/requirements-lint.txt index c220740db..876ff644b 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,14 +1,14 @@ -r requirements.txt argcomplete==2.0.0 black==24.10.0 -commitizen==4.1.0 +commitizen==4.1.1 flake8==7.1.1 isort==5.13.2 -mypy==1.13.0 -pylint==3.3.2 +mypy==1.14.1 +pylint==3.3.3 pytest==8.3.4 -responses==0.25.3 -respx==0.21.1 -types-PyYAML==6.0.12.20240917 +responses==0.25.6 +respx==0.22.0 +types-PyYAML==6.0.12.20241230 types-requests==2.32.0.20241016 -types-setuptools==75.6.0.20241126 +types-setuptools==75.8.0.20250110 diff --git a/requirements-precommit.txt b/requirements-precommit.txt index e88d27155..40a16fa94 100644 --- a/requirements-precommit.txt +++ b/requirements-precommit.txt @@ -1 +1 @@ -pre-commit==4.0.1 +pre-commit==4.1.0 diff --git a/requirements-test.txt b/requirements-test.txt index 629795a6d..9beda8f64 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,13 +1,13 @@ -r requirements.txt -anyio==4.7.0 +anyio==4.8.0 build==1.2.2.post1 -coverage==7.6.9 +coverage==7.6.10 pytest-console-scripts==1.4.1 pytest-cov==6.0.0 -pytest-github-actions-annotate-failures==0.2.0 +pytest-github-actions-annotate-failures==0.3.0 pytest==8.3.4 PyYaml==6.0.2 -responses==0.25.3 -respx==0.21.1 -trio==0.27.0 +responses==0.25.6 +respx==0.22.0 +trio==0.28.0 wheel==0.45.1 diff --git a/requirements.txt b/requirements.txt index aef5bc56d..21069f74f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ gql==3.5.0 -httpx==0.27.2 +httpx==0.28.1 requests==2.32.3 requests-toolbelt==1.0.0 diff --git a/tests/functional/api/test_gitlab.py b/tests/functional/api/test_gitlab.py index af505c73b..50c6badd6 100644 --- a/tests/functional/api/test_gitlab.py +++ b/tests/functional/api/test_gitlab.py @@ -138,7 +138,7 @@ def test_template_gitlabciyml(gl, get_all_kwargs): def test_template_license(gl): - assert gl.licenses.list() + assert gl.licenses.list(get_all=False) license = gl.licenses.get( "bsd-2-clause", project="mytestproject", fullname="mytestfullname" ) diff --git a/tests/functional/api/test_import_export.py b/tests/functional/api/test_import_export.py index 6f70a810a..e8d9c9abc 100644 --- a/tests/functional/api/test_import_export.py +++ b/tests/functional/api/test_import_export.py @@ -50,7 +50,7 @@ def test_project_import_export(gl, project, temp_dir): raise Exception("Project export taking too much time") with open(temp_dir / "gitlab-export.tgz", "wb") as f: - export.download(streamed=True, action=f.write) # type: ignore[arg-type] + export.download(streamed=True, action=f.write) # type: ignore[call-overload] output = gl.projects.import_project( open(temp_dir / "gitlab-export.tgz", "rb"), diff --git a/tests/functional/api/test_projects.py b/tests/functional/api/test_projects.py index c96d93a13..18c850680 100644 --- a/tests/functional/api/test_projects.py +++ b/tests/functional/api/test_projects.py @@ -399,3 +399,19 @@ def test_project_transfer(gl, project, group): project = gl.projects.get(project.id) assert project.namespace["path"] == gl.user.username + + +@pytest.mark.gitlab_premium +def test_project_external_status_check_create(gl, project): + status_check = project.external_status_checks.create( + {"name": "MR blocker", "external_url": "https://example.com/mr-blocker"} + ) + assert status_check.name == "MR blocker" + assert status_check.external_url == "https://example.com/mr-blocker" + + +@pytest.mark.gitlab_premium +def test_project_external_status_check_list(gl, project): + status_checks = project.external_status_checks.list() + + assert len(status_checks) == 1 diff --git a/tests/functional/api/test_repository.py b/tests/functional/api/test_repository.py index b4e80c9f8..4376c64c5 100644 --- a/tests/functional/api/test_repository.py +++ b/tests/functional/api/test_repository.py @@ -165,6 +165,28 @@ def test_commit_discussion(project): note_from_get.delete() +def test_cherry_pick_commit(project): + commits = project.commits.list() + commit = commits[1] + parent_commit = commit.parent_ids[0] + + # create a branch to cherry pick onto + project.branches.create( + { + "branch": "test", + "ref": parent_commit, + } + ) + cherry_pick_commit = commit.cherry_pick(branch="test") + + expected_message = f"{commit.message}\n\n(cherry picked from commit {commit.id})" + assert cherry_pick_commit["message"].startswith(expected_message) + + with pytest.raises(gitlab.GitlabCherryPickError): + # Two cherry pick attempts should raise GitlabCherryPickError + commit.cherry_pick(branch="test") + + def test_revert_commit(project): commit = project.commits.list()[0] revert_commit = commit.revert(branch="main") diff --git a/tests/functional/api/test_snippets.py b/tests/functional/api/test_snippets.py index 7a71716fe..41a888d7d 100644 --- a/tests/functional/api/test_snippets.py +++ b/tests/functional/api/test_snippets.py @@ -24,7 +24,10 @@ def test_snippets(gl): assert content.decode() == "import gitlab" all_snippets = gl.snippets.list_all(get_all=True) - public_snippets = gl.snippets.public(get_all=True) + with pytest.warns( + DeprecationWarning, match=r"Gitlab.snippets.public\(\) is deprecated" + ): + public_snippets = gl.snippets.public(get_all=True) list_public_snippets = gl.snippets.list_public(get_all=True) assert isinstance(all_snippets, list) assert isinstance(list_public_snippets, list) diff --git a/tests/functional/cli/test_cli_artifacts.py b/tests/functional/cli/test_cli_artifacts.py index f0e6f213f..589486844 100644 --- a/tests/functional/cli/test_cli_artifacts.py +++ b/tests/functional/cli/test_cli_artifacts.py @@ -1,3 +1,4 @@ +import logging import subprocess import textwrap import time @@ -24,12 +25,22 @@ @pytest.fixture(scope="module") def job_with_artifacts(gitlab_runner, project): + start_time = time.time() + project.files.create(data) jobs = None while not jobs: time.sleep(0.5) jobs = project.jobs.list(scope="success") + if time.time() - start_time < 60: + continue + logging.error("job never succeeded") + for job in project.jobs.list(): + job = project.jobs.get(job.id) + logging.info(f"{job.status} job: {job.pformat()}") + logging.info(f"job log:\n{job.trace()}\n") + pytest.fail("Fixture 'job_with_artifact' failed") return project.jobs.get(jobs[0].id) diff --git a/tests/functional/cli/test_cli_files.py b/tests/functional/cli/test_cli_files.py new file mode 100644 index 000000000..405fbb21b --- /dev/null +++ b/tests/functional/cli/test_cli_files.py @@ -0,0 +1,21 @@ +def test_project_file_raw(gitlab_cli, project, project_file): + cmd = ["project-file", "raw", "--project-id", project.id, "--file-path", "README"] + ret = gitlab_cli(cmd) + assert ret.success + assert "Initial content" in ret.stdout + + +def test_project_file_raw_ref(gitlab_cli, project, project_file): + cmd = [ + "project-file", + "raw", + "--project-id", + project.id, + "--file-path", + "README", + "--ref", + "main", + ] + ret = gitlab_cli(cmd) + assert ret.success + assert "Initial content" in ret.stdout diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index f2f31e52f..2d2815547 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -6,7 +6,7 @@ import time import uuid from subprocess import check_output -from typing import Optional +from typing import Optional, Sequence import pytest import requests @@ -145,7 +145,9 @@ def set_token(container: str, fixture_dir: pathlib.Path) -> str: return output -def pytest_report_collectionfinish(config, startdir, items): +def pytest_report_collectionfinish( + config: pytest.Config, start_path: pathlib.Path, items: Sequence[pytest.Item] +): return [ "", "Starting GitLab container.", diff --git a/tests/functional/fixtures/.env b/tests/functional/fixtures/.env index d8b0d2a33..a25baaa07 100644 --- a/tests/functional/fixtures/.env +++ b/tests/functional/fixtures/.env @@ -1,2 +1,4 @@ GITLAB_IMAGE=gitlab/gitlab-ee -GITLAB_TAG=17.7.0-ee.0 +GITLAB_TAG=17.8.1-ee.0 +GITLAB_RUNNER_IMAGE=gitlab/gitlab-runner +GITLAB_RUNNER_TAG=v17.8.3 diff --git a/tests/functional/fixtures/docker-compose.yml b/tests/functional/fixtures/docker-compose.yml index e79fdf06a..550ec156c 100644 --- a/tests/functional/fixtures/docker-compose.yml +++ b/tests/functional/fixtures/docker-compose.yml @@ -45,7 +45,7 @@ services: - gitlab-network gitlab-runner: - image: gitlab/gitlab-runner:latest + image: '${GITLAB_RUNNER_IMAGE}:${GITLAB_RUNNER_TAG}' container_name: 'gitlab-runner-test' depends_on: - gitlab diff --git a/tests/unit/objects/test_commits.py b/tests/unit/objects/test_commits.py index 5b0270c6e..b9aa92a6d 100644 --- a/tests/unit/objects/test_commits.py +++ b/tests/unit/objects/test_commits.py @@ -37,6 +37,12 @@ def resp_commit(): "short_id": "8b090c1b", "title": 'Revert "Initial commit"', } + cherry_pick_content = { + "id": "8b090c1b79a14f2bd9e8a738f717824ff53aebad", + "short_id": "8b090c1b", + "title": "Initial commit", + "message": "Initial commit\n\n\n(cherry picked from commit 6b2257eabcec3db1f59dafbd84935e3caea04235)", + } with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: rsps.add( @@ -53,6 +59,13 @@ def resp_commit(): content_type="application/json", status=200, ) + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/repository/commits/6b2257ea/cherry_pick", + json=cherry_pick_content, + content_type="application/json", + status=200, + ) yield rsps @@ -118,6 +131,18 @@ def test_create_commit(project, resp_create_commit): assert commit.title == data["commit_message"] +def test_cherry_pick_commit(project, resp_commit): + commit = project.commits.get("6b2257ea", lazy=True) + cherry_pick_commit = commit.cherry_pick(branch="main") + + assert cherry_pick_commit["short_id"] == "8b090c1b" + assert cherry_pick_commit["title"] == "Initial commit" + assert ( + cherry_pick_commit["message"] + == "Initial commit\n\n\n(cherry picked from commit 6b2257eabcec3db1f59dafbd84935e3caea04235)" + ) + + def test_revert_commit(project, resp_commit): commit = project.commits.get("6b2257ea", lazy=True) revert_commit = commit.revert(branch="main") diff --git a/tests/unit/objects/test_job_artifacts.py b/tests/unit/objects/test_job_artifacts.py index 8adcf8847..e7fd06f9e 100644 --- a/tests/unit/objects/test_job_artifacts.py +++ b/tests/unit/objects/test_job_artifacts.py @@ -35,6 +35,20 @@ def resp_project_artifacts_delete(): yield rsps +@pytest.fixture +def resp_job_artifact_bytes_range(binary_content): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/jobs/123/artifacts", + body=binary_content[:10], + content_type="application/octet-stream", + status=206, + match=[responses.matchers.header_matcher({"Range": "bytes=0-9"})], + ) + yield rsps + + def test_project_artifacts_delete(gl, resp_project_artifacts_delete): project = gl.projects.get(1, lazy=True) project.artifacts.delete() @@ -46,3 +60,13 @@ def test_project_artifacts_download_by_ref_name( project = gl.projects.get(1, lazy=True) artifacts = project.artifacts.download(ref_name=ref_name, job=job) assert artifacts == binary_content + + +def test_job_artifact_download_bytes_range( + gl, binary_content, resp_job_artifact_bytes_range +): + project = gl.projects.get(1, lazy=True) + job = project.jobs.get(123, lazy=True) + + artifacts = job.artifacts(extra_headers={"Range": "bytes=0-9"}) + assert len(artifacts) == 10 diff --git a/tests/unit/objects/test_status_checks.py b/tests/unit/objects/test_status_checks.py new file mode 100644 index 000000000..14d1e73d4 --- /dev/null +++ b/tests/unit/objects/test_status_checks.py @@ -0,0 +1,127 @@ +""" +GitLab API: https://docs.gitlab.com/ee/api/status_checks.html +""" + +import pytest +import responses + + +@pytest.fixture +def external_status_check(): + return { + "id": 1, + "name": "MR blocker", + "project_id": 1, + "external_url": "https://example.com/mr-blocker", + "hmac": True, + "protected_branches": [ + { + "id": 1, + "project_id": 1, + "name": "main", + "created_at": "2020-10-12T14:04:50.787Z", + "updated_at": "2020-10-12T14:04:50.787Z", + "code_owner_approval_required": False, + } + ], + } + + +@pytest.fixture +def updated_external_status_check(): + return { + "id": 1, + "name": "Updated MR blocker", + "project_id": 1, + "external_url": "https://example.com/mr-blocker", + "hmac": True, + "protected_branches": [ + { + "id": 1, + "project_id": 1, + "name": "main", + "created_at": "2020-10-12T14:04:50.787Z", + "updated_at": "2020-10-12T14:04:50.787Z", + "code_owner_approval_required": False, + } + ], + } + + +@pytest.fixture +def resp_list_external_status_checks(external_status_check): + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/external_status_checks", + json=[external_status_check], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_external_status_checks(external_status_check): + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/external_status_checks", + json=external_status_check, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_update_external_status_checks(updated_external_status_check): + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add( + method=responses.PUT, + url="http://localhost/api/v4/groups/1/external_status_checks", + json=updated_external_status_check, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_delete_external_status_checks(): + content = [] + + with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/external_status_checks/1", + status=204, + ) + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/external_status_checks", + json=content, + content_type="application/json", + status=200, + ) + yield rsps + + +def test_list_external_status_checks(gl, resp_list_external_status_checks): + status_checks = gl.projects.get(1, lazy=True).external_status_checks.list() + assert len(status_checks) == 1 + assert status_checks[0].name == "MR blocker" + + +def test_create_external_status_checks(gl, resp_create_external_status_checks): + access_token = gl.projects.get(1, lazy=True).external_status_checks.create( + {"name": "MR blocker", "external_url": "https://example.com/mr-blocker"} + ) + assert access_token.name == "MR blocker" + assert access_token.external_url == "https://example.com/mr-blocker" + + +def test_delete_external_status_checks(gl, resp_delete_external_status_checks): + gl.projects.get(1, lazy=True).external_status_checks.delete(1) + status_checks = gl.projects.get(1, lazy=True).external_status_checks.list() + assert len(status_checks) == 0 diff --git a/tests/unit/test_gitlab_http_methods.py b/tests/unit/test_gitlab_http_methods.py index fc8cd2d71..829643d99 100644 --- a/tests/unit/test_gitlab_http_methods.py +++ b/tests/unit/test_gitlab_http_methods.py @@ -117,6 +117,29 @@ def request_callback(request): assert len(responses.calls) == calls_before_success +@responses.activate +def test_http_request_extra_headers(gl): + path = "/projects/123/jobs/123456" + url = "http://localhost/api/v4" + path + + range_headers = {"Range": "bytes=0-99"} + + responses.add( + method=responses.GET, + url=url, + body=b"a" * 100, + status=206, + content_type="application/octet-stream", + match=helpers.MATCH_EMPTY_QUERY_PARAMS + + [responses.matchers.header_matcher(range_headers)], + ) + + http_r = gl.http_request("get", path, extra_headers=range_headers) + + assert http_r.status_code == 206 + assert len(http_r.content) == 100 + + @responses.activate @pytest.mark.parametrize( "exception", diff --git a/tox.ini b/tox.ini index dd691879f..c8b0f71ec 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,8 @@ passenv = GITHUB_WORKSPACE GITLAB_IMAGE GITLAB_TAG + GITLAB_RUNNER_IMAGE + GITLAB_RUNNER_TAG NO_COLOR PWD PY_COLORS