diff --git a/.commitlintrc.json b/.commitlintrc.json deleted file mode 100644 index 0073e93bd..000000000 --- a/.commitlintrc.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": ["@commitlint/config-conventional"], - "rules": { - "footer-max-line-length": [2, "always", 200] - } -} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 05ccb9065..3ffb061fb 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,9 +22,9 @@ jobs: sphinx: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.10" - name: Install dependencies @@ -34,7 +34,7 @@ jobs: TOXENV: docs run: tox - name: Archive generated docs - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: html-docs path: build/sphinx/html/ @@ -42,9 +42,9 @@ jobs: twine-check: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.10" - name: Install dependencies diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 840909dcf..92ba2f29b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,20 +19,16 @@ env: PY_COLORS: 1 jobs: - commitlint: + lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: wagoid/commitlint-github-action@v4 - - linters: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v3 - run: pip install --upgrade tox + - name: Run commitizen + run: tox -e cz - name: Run black code formatter (https://black.readthedocs.io/en/stable/) run: tox -e black -- --check - name: Run flake8 (https://flake8.pycqa.org/en/latest/) diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index d109e5d6a..ab15949bd 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -29,8 +29,8 @@ jobs: pre_commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 - run: pip install --upgrade -r requirements.txt -r requirements-lint.txt pre-commit - name: Run pre-commit install run: pre-commit install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ade71efe5..a266662e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,10 @@ on: jobs: release: + if: github.repository == 'python-gitlab/python-gitlab' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 token: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 09d8dc827..1d5e94afb 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@v4 + - uses: actions/stale@v5 with: any-of-labels: 'need info,Waiting for response' stale-issue-message: > diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57322ab68..5b597bf1a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -45,9 +45,9 @@ jobs: version: "3.10" toxenv: py310,smoke steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: ${{ matrix.python.version }} - name: Install dependencies @@ -55,7 +55,7 @@ jobs: - name: Run tests env: TOXENV: ${{ matrix.python.toxenv }} - run: tox + run: tox --skip-missing-interpreters false functional: runs-on: ubuntu-20.04 @@ -63,9 +63,9 @@ jobs: matrix: toxenv: [py_func_v4, cli_func_v4] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.10" - name: Install dependencies @@ -75,7 +75,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox -- --override-ini='log_cli=True' - name: Upload codecov coverage - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: files: ./coverage.xml flags: ${{ matrix.toxenv }} @@ -84,9 +84,9 @@ jobs: coverage: runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v3 with: python-version: "3.10" - name: Install dependencies @@ -97,7 +97,7 @@ jobs: TOXENV: cover run: tox - name: Upload codecov coverage - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 with: files: ./coverage.xml flags: unit diff --git a/.gitignore b/.gitignore index a395a5608..849ca6e85 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ docs/_build venv/ # Include tracked hidden files and directories in search and diff tools -!.commitlintrc.json !.dockerignore !.env !.github/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8fd3c252c..d67ab99d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,14 +3,13 @@ default_language_version: repos: - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.3.0 hooks: - id: black - - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook - rev: v6.0.0 + - repo: https://github.com/commitizen-tools/commitizen + rev: v2.24.0 hooks: - - id: commitlint - additional_dependencies: ['@commitlint/config-conventional'] + - id: commitizen stages: [commit-msg] - repo: https://github.com/pycqa/flake8 rev: 4.0.1 @@ -21,13 +20,13 @@ repos: hooks: - id: isort - repo: https://github.com/pycqa/pylint - rev: v2.12.2 + rev: v2.13.7 hooks: - id: pylint additional_dependencies: - argcomplete==2.0.0 - - pytest==6.2.5 - - requests==2.27.0 + - pytest==7.1.2 + - requests==2.27.1 - requests-toolbelt==0.9.1 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy @@ -36,6 +35,6 @@ repos: - id: mypy args: [] additional_dependencies: - - types-PyYAML==6.0.1 - - types-requests==2.26.3 - - types-setuptools==57.4.5 + - types-PyYAML==6.0.7 + - types-requests==2.27.25 + - types-setuptools==57.4.14 diff --git a/.renovaterc.json b/.renovaterc.json index 12c738ae2..a06ccd123 100644 --- a/.renovaterc.json +++ b/.renovaterc.json @@ -1,7 +1,8 @@ { "extends": [ "config:base", - ":enablePreCommit" + ":enablePreCommit", + "schedule:weekly" ], "pip_requirements": { "fileMatch": ["^requirements(-[\\w]*)?\\.txt$"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 3072879c3..245e53c0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,65 @@ +## v3.4.0 (2022-04-28) +### Feature +* Emit a warning when using a `list()` method returns max ([`1339d64`](https://github.com/python-gitlab/python-gitlab/commit/1339d645ce58a2e1198b898b9549ba5917b1ff12)) +* **objects:** Support getting project/group deploy tokens by id ([`fcd37fe`](https://github.com/python-gitlab/python-gitlab/commit/fcd37feff132bd5b225cde9d5f9c88e62b3f1fd6)) +* **user:** Support getting user SSH key by id ([`6f93c05`](https://github.com/python-gitlab/python-gitlab/commit/6f93c0520f738950a7c67dbeca8d1ac8257e2661)) +* **api:** Re-add topic delete endpoint ([`d1d96bd`](https://github.com/python-gitlab/python-gitlab/commit/d1d96bda5f1c6991c8ea61dca8f261e5b74b5ab6)) + +### Fix +* Add ChunkedEncodingError to list of retryable exceptions ([`7beb20f`](https://github.com/python-gitlab/python-gitlab/commit/7beb20ff7b7b85fb92fc6b647d9c1bdb7568f27c)) +* Avoid passing redundant arguments to API ([`3431887`](https://github.com/python-gitlab/python-gitlab/commit/34318871347b9c563d01a13796431c83b3b1d58c)) +* **cli:** Add missing filters for project commit list ([`149d244`](https://github.com/python-gitlab/python-gitlab/commit/149d2446fcc79b31d3acde6e6d51adaf37cbb5d3)) +* Add 52x range to retry transient failures and tests ([`c3ef1b5`](https://github.com/python-gitlab/python-gitlab/commit/c3ef1b5c1eaf1348a18d753dbf7bda3c129e3262)) +* Also retry HTTP-based transient errors ([`3b49e4d`](https://github.com/python-gitlab/python-gitlab/commit/3b49e4d61e6f360f1c787aa048edf584aec55278)) + +### Documentation +* **api-docs:** Docs fix for application scopes ([`e1ad93d`](https://github.com/python-gitlab/python-gitlab/commit/e1ad93df90e80643866611fe52bd5c59428e7a88)) + +## v3.3.0 (2022-03-28) +### Feature +* **object:** Add pipeline test report summary support ([`a97e0cf`](https://github.com/python-gitlab/python-gitlab/commit/a97e0cf81b5394b3a2b73d927b4efe675bc85208)) + +### Fix +* Support RateLimit-Reset header ([`4060146`](https://github.com/python-gitlab/python-gitlab/commit/40601463c78a6f5d45081700164899b2559b7e55)) + +### Documentation +* Fix typo and incorrect style ([`2828b10`](https://github.com/python-gitlab/python-gitlab/commit/2828b10505611194bebda59a0e9eb41faf24b77b)) +* Add pipeline test report summary support ([`d78afb3`](https://github.com/python-gitlab/python-gitlab/commit/d78afb36e26f41d727dee7b0952d53166e0df850)) +* **chore:** Include docs .js files in sdist ([`3010b40`](https://github.com/python-gitlab/python-gitlab/commit/3010b407bc9baabc6cef071507e8fa47c0f1624d)) + +## v3.2.0 (2022-02-28) +### Feature +* **merge_request_approvals:** Add support for deleting MR approval rules ([`85a734f`](https://github.com/python-gitlab/python-gitlab/commit/85a734fec3111a4a5c4f0ddd7cb36eead96215e9)) +* **artifacts:** Add support for project artifacts delete API ([`c01c034`](https://github.com/python-gitlab/python-gitlab/commit/c01c034169789e1d20fd27a0f39f4c3c3628a2bb)) +* **mixins:** Allow deleting resources without IDs ([`0717517`](https://github.com/python-gitlab/python-gitlab/commit/0717517212b616cfd52cfd38dd5c587ff8f9c47c)) +* **objects:** Add a complete artifacts manager ([`c8c2fa7`](https://github.com/python-gitlab/python-gitlab/commit/c8c2fa763558c4d9906e68031a6602e007fec930)) + +### Fix +* **services:** Use slug for id_attr instead of custom methods ([`e30f39d`](https://github.com/python-gitlab/python-gitlab/commit/e30f39dff5726266222b0f56c94f4ccfe38ba527)) +* Remove custom `delete` method for labels ([`0841a2a`](https://github.com/python-gitlab/python-gitlab/commit/0841a2a686c6808e2f3f90960e529b26c26b268f)) + +### Documentation +* Enable gitter chat directly in docs ([`bd1ecdd`](https://github.com/python-gitlab/python-gitlab/commit/bd1ecdd5ad654b01b34e7a7a96821cc280b3ca67)) +* Add delete methods for runners and project artifacts ([`5e711fd`](https://github.com/python-gitlab/python-gitlab/commit/5e711fdb747fb3dcde1f5879c64dfd37bf25f3c0)) +* Add retry_transient infos ([`bb1f054`](https://github.com/python-gitlab/python-gitlab/commit/bb1f05402887c78f9898fbd5bd66e149eff134d9)) +* Add transient errors retry info ([`b7a1266`](https://github.com/python-gitlab/python-gitlab/commit/b7a126661175a3b9b73dbb4cb88709868d6d871c)) +* **artifacts:** Deprecate artifacts() and artifact() methods ([`64d01ef`](https://github.com/python-gitlab/python-gitlab/commit/64d01ef23b1269b705350106d8ddc2962a780dce)) +* Revert "chore: add temporary banner for v3" ([#1864](https://github.com/python-gitlab/python-gitlab/issues/1864)) ([`7a13b9b`](https://github.com/python-gitlab/python-gitlab/commit/7a13b9bfa4aead6c731f9a92e0946dba7577c61b)) + +## v3.1.1 (2022-01-28) +### Fix +* **cli:** Make 'per_page' and 'page' type explicit ([`d493a5e`](https://github.com/python-gitlab/python-gitlab/commit/d493a5e8685018daa69c92e5942cbe763e5dac62)) +* **cli:** Make 'timeout' type explicit ([`bbb7df5`](https://github.com/python-gitlab/python-gitlab/commit/bbb7df526f4375c438be97d8cfa0d9ea9d604e7d)) +* **cli:** Allow custom methods in managers ([`8dfed0c`](https://github.com/python-gitlab/python-gitlab/commit/8dfed0c362af2c5e936011fd0b488b8b05e8a8a0)) +* **objects:** Make resource access tokens and repos available in CLI ([`e0a3a41`](https://github.com/python-gitlab/python-gitlab/commit/e0a3a41ce60503a25fa5c26cf125364db481b207)) + +### Documentation +* Enhance release docs for CI_JOB_TOKEN usage ([`5d973de`](https://github.com/python-gitlab/python-gitlab/commit/5d973de8a5edd08f38031cf9be2636b0e12f008d)) +* **changelog:** Add missing changelog items ([`01755fb`](https://github.com/python-gitlab/python-gitlab/commit/01755fb56a5330aa6fa4525086e49990e57ce50b)) + ## v3.1.0 (2022-01-14) ### Feature * add support for Group Access Token API ([`c01b7c4`](https://github.com/python-gitlab/python-gitlab/commit/c01b7c494192c5462ec673848287ef2a5c9bd737)) diff --git a/MANIFEST.in b/MANIFEST.in index 5ce43ec78..d74bc04de 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include COPYING AUTHORS CHANGELOG.md requirements*.txt include tox.ini recursive-include tests * -recursive-include docs *j2 *.md *.py *.rst api/*.rst Makefile make.bat +recursive-include docs *j2 *.js *.md *.py *.rst api/*.rst Makefile make.bat diff --git a/README.rst b/README.rst index 838943c4e..751c283ec 100644 --- a/README.rst +++ b/README.rst @@ -98,8 +98,14 @@ https://github.com/python-gitlab/python-gitlab/issues. Gitter Community Chat --------------------- -There is a `gitter `_ community chat -available at https://gitter.im/python-gitlab/Lobby +We have a `gitter `_ community chat +available at https://gitter.im/python-gitlab/Lobby, which you can also +directly access via the Open Chat button below. + +If you have a simple question, the community might be able to help already, +without you opening an issue. If you regularly use python-gitlab, we also +encourage you to join and participate. You might discover new ideas and +use cases yourself! Documentation ------------- diff --git a/docs/_static/js/gitter.js b/docs/_static/js/gitter.js new file mode 100644 index 000000000..1340cb483 --- /dev/null +++ b/docs/_static/js/gitter.js @@ -0,0 +1,3 @@ +((window.gitter = {}).chat = {}).options = { + room: 'python-gitlab/Lobby' +}; diff --git a/docs/api-usage.rst b/docs/api-usage.rst index 8befc5633..e39082d2b 100644 --- a/docs/api-usage.rst +++ b/docs/api-usage.rst @@ -421,8 +421,9 @@ GitLab server can sometimes return a transient HTTP error. python-gitlab can automatically retry in such case, when ``retry_transient_errors`` argument is set to ``True``. When enabled, HTTP error codes 500 (Internal Server Error), 502 (502 Bad Gateway), -503 (Service Unavailable), and 504 (Gateway Timeout) are retried. By -default an exception is raised for these errors. +503 (Service Unavailable), and 504 (Gateway Timeout) are retried. It will retry until reaching +the `max_retries` value. By default, `retry_transient_errors` is set to `False` and an exception +is raised for these errors. .. code-block:: python diff --git a/docs/conf.py b/docs/conf.py index 465f4fc02..e94d2f5d3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -121,10 +121,7 @@ # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -html_theme_options = { - "announcement": "⚠ python-gitlab 3.0.0 has been released with several " - "breaking changes.", -} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] @@ -148,7 +145,15 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -# html_static_path = ['_static'] +html_static_path = ["_static"] + +html_js_files = [ + "js/gitter.js", + ( + "https://sidecar.gitter.im/dist/sidecar.v1.js", + {"async": "async", "defer": "defer"}, + ), +] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied diff --git a/docs/gl_objects/applications.rst b/docs/gl_objects/applications.rst index 146b6e801..6264e531f 100644 --- a/docs/gl_objects/applications.rst +++ b/docs/gl_objects/applications.rst @@ -22,7 +22,7 @@ List all OAuth applications:: Create an application:: - gl.applications.create({'name': 'your_app', 'redirect_uri': 'http://application.url', 'scopes': ['api']}) + gl.applications.create({'name': 'your_app', 'redirect_uri': 'http://application.url', 'scopes': 'read_user openid profile email'}) Delete an applications:: diff --git a/docs/gl_objects/deploy_tokens.rst b/docs/gl_objects/deploy_tokens.rst index 302cb9c9a..c7c138975 100644 --- a/docs/gl_objects/deploy_tokens.rst +++ b/docs/gl_objects/deploy_tokens.rst @@ -54,6 +54,10 @@ List the deploy tokens for a project:: deploy_tokens = project.deploytokens.list() +Get a deploy token for a project by id:: + + deploy_token = project.deploytokens.get(deploy_token_id) + Create a new deploy token to access registry images of a project: In addition to required parameters ``name`` and ``scopes``, this method accepts @@ -107,6 +111,10 @@ List the deploy tokens for a group:: deploy_tokens = group.deploytokens.list() +Get a deploy token for a group by id:: + + deploy_token = group.deploytokens.get(deploy_token_id) + Create a new deploy token to access all repositories of all projects in a group: In addition to required parameters ``name`` and ``scopes``, this method accepts diff --git a/docs/gl_objects/merge_request_approvals.rst b/docs/gl_objects/merge_request_approvals.rst index 2c1b8404d..661e0c16e 100644 --- a/docs/gl_objects/merge_request_approvals.rst +++ b/docs/gl_objects/merge_request_approvals.rst @@ -75,6 +75,14 @@ List MR-level MR approval rules:: mr.approval_rules.list() +Delete MR-level MR approval rule:: + + rules = mr.approval_rules.list() + rules[0].delete() + + # or + mr.approval_rules.delete(approval_id) + Change MR-level MR approval rule:: mr_approvalrule.user_ids = [105] diff --git a/docs/gl_objects/merge_requests.rst b/docs/gl_objects/merge_requests.rst index 45ccc83f7..473160a58 100644 --- a/docs/gl_objects/merge_requests.rst +++ b/docs/gl_objects/merge_requests.rst @@ -78,11 +78,14 @@ List MRs for a project:: You can filter and sort the returned list with the following parameters: -* ``state``: state of the MR. It can be one of ``all``, ``merged``, ``opened`` - or ``closed`` +* ``state``: state of the MR. It can be one of ``all``, ``merged``, ``opened``, + ``closed`` or ``locked`` * ``order_by``: sort by ``created_at`` or ``updated_at`` * ``sort``: sort order (``asc`` or ``desc``) +You can find a full updated list of parameters here: +https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests + For example:: mrs = project.mergerequests.list(state='merged', order_by='updated_at') diff --git a/docs/gl_objects/pipelines_and_jobs.rst b/docs/gl_objects/pipelines_and_jobs.rst index b4761b024..a05d968a4 100644 --- a/docs/gl_objects/pipelines_and_jobs.rst +++ b/docs/gl_objects/pipelines_and_jobs.rst @@ -245,9 +245,14 @@ Get the artifacts of a job:: build_or_job.artifacts() Get the artifacts of a job by its name from the latest successful pipeline of -a branch or tag: +a branch or tag:: - project.artifacts(ref_name='main', job='build') + project.artifacts.download(ref_name='main', job='build') + +.. attention:: + + An older method ``project.artifacts()`` is deprecated and will be + removed in a future version. .. warning:: @@ -269,13 +274,22 @@ You can also directly stream the output into a file, and unzip it afterwards:: subprocess.run(["unzip", "-bo", zipfn]) os.unlink(zipfn) +Delete all artifacts of a project that can be deleted:: + + project.artifacts.delete() + Get a single artifact file:: build_or_job.artifact('path/to/file') Get a single artifact file by branch and job:: - project.artifact('branch', 'path/to/file', 'job') + project.artifacts.raw('branch', 'path/to/file', 'job') + +.. attention:: + + An older method ``project.artifact()`` is deprecated and will be + removed in a future version. Mark a job artifact as kept when expiration is set:: @@ -353,3 +367,27 @@ Examples Get the test report for a pipeline:: test_report = pipeline.test_report.get() + +Pipeline test report summary +============================ + +Get a pipeline’s test report summary. + +Reference +--------- + +* v4 API + + + :class:`gitlab.v4.objects.ProjectPipelineTestReportSummary` + + :class:`gitlab.v4.objects.ProjectPipelineTestReportSummaryManager` + + :attr:`gitlab.v4.objects.ProjectPipeline.test_report_summary` + +* GitLab API: https://docs.gitlab.com/ee/api/pipelines.html#get-a-pipelines-test-report-summary + +Examples +-------- + +Get the test report summary for a pipeline:: + + test_report_summary = pipeline.test_report_summary.get() + diff --git a/docs/gl_objects/runners.rst b/docs/gl_objects/runners.rst index 191997573..1a64c0169 100644 --- a/docs/gl_objects/runners.rst +++ b/docs/gl_objects/runners.rst @@ -70,6 +70,10 @@ Remove a runner:: # or runner.delete() +Remove a runner by its authentication token:: + + gl.runners.delete(token="runner-auth-token") + Verify a registered runner token:: try: diff --git a/docs/gl_objects/topics.rst b/docs/gl_objects/topics.rst index 5765d63a4..0ca46d7f0 100644 --- a/docs/gl_objects/topics.rst +++ b/docs/gl_objects/topics.rst @@ -39,3 +39,10 @@ Update a topic:: # or gl.topics.update(topic_id, {"description": "My new topic"}) + +Delete a topic:: + + topic.delete() + + # or + gl.topics.delete(topic_id) diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst index aa3a66093..7a169dc43 100644 --- a/docs/gl_objects/users.rst +++ b/docs/gl_objects/users.rst @@ -299,9 +299,13 @@ List SSH keys for a user:: Create an SSH key for a user:: - k = user.keys.create({'title': 'my_key', + key = user.keys.create({'title': 'my_key', 'key': open('/home/me/.ssh/id_rsa.pub').read()}) +Get an SSH key for a user by id:: + + key = user.keys.get(key_id) + Delete an SSH key for a user:: user.keys.delete(key_id) diff --git a/gitlab/__init__.py b/gitlab/__init__.py index 5f168acb2..8cffecd62 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -20,6 +20,7 @@ from typing import Any import gitlab.config # noqa: F401 +from gitlab import utils as _utils from gitlab._version import ( # noqa: F401 __author__, __copyright__, @@ -40,11 +41,13 @@ def __getattr__(name: str) -> Any: # Deprecate direct access to constants without namespace if name in gitlab.const._DEPRECATED: - warnings.warn( - f"\nDirect access to 'gitlab.{name}' is deprecated and will be " - f"removed in a future major python-gitlab release. Please " - f"use 'gitlab.const.{name}' instead.", - DeprecationWarning, + _utils.warn( + message=( + f"\nDirect access to 'gitlab.{name}' is deprecated and will be " + f"removed in a future major python-gitlab release. Please " + f"use 'gitlab.const.{name}' instead." + ), + category=DeprecationWarning, ) return getattr(gitlab.const, name) raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/gitlab/_version.py b/gitlab/_version.py index a1fb3cd06..8949179af 100644 --- a/gitlab/_version.py +++ b/gitlab/_version.py @@ -3,4 +3,4 @@ __email__ = "gauvainpocentek@gmail.com" __license__ = "LGPL3" __title__ = "python-gitlab" -__version__ = "3.1.0" +__version__ = "3.4.0" diff --git a/gitlab/base.py b/gitlab/base.py index aa18dcfd7..7f685425a 100644 --- a/gitlab/base.py +++ b/gitlab/base.py @@ -302,7 +302,7 @@ def next_page(self) -> Optional[int]: return self._list.next_page @property - def per_page(self) -> int: + def per_page(self) -> Optional[int]: """The number of items per page.""" return self._list.per_page diff --git a/gitlab/cli.py b/gitlab/cli.py index c4af4b8db..f06f49d94 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -181,6 +181,7 @@ def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser: "[env var: GITLAB_TIMEOUT]" ), required=False, + type=int, default=os.getenv("GITLAB_TIMEOUT"), ) parser.add_argument( @@ -196,6 +197,7 @@ def _get_base_parser(add_help: bool = True) -> argparse.ArgumentParser: "[env var: GITLAB_PER_PAGE]" ), required=False, + type=int, default=os.getenv("GITLAB_PER_PAGE"), ) parser.add_argument( diff --git a/gitlab/client.py b/gitlab/client.py index 46ddd9db6..b8ac22223 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -24,6 +24,7 @@ import requests.utils from requests_toolbelt.multipart.encoder import MultipartEncoder # type: ignore +import gitlab import gitlab.config import gitlab.const import gitlab.exceptions @@ -35,6 +36,14 @@ "{source!r} to {target!r}" ) +RETRYABLE_TRANSIENT_ERROR_CODES = [500, 502, 503, 504] + list(range(520, 531)) + +# https://docs.gitlab.com/ee/api/#offset-based-pagination +_PAGINATION_URL = ( + f"https://python-gitlab.readthedocs.io/en/v{gitlab.__version__}/" + f"api-usage.html#pagination" +) + class Gitlab: """Represents a GitLab server connection. @@ -54,8 +63,8 @@ class Gitlab: pagination: Can be set to 'keyset' to use keyset pagination order_by: Set order_by globally user_agent: A custom user agent to use for making HTTP requests. - retry_transient_errors: Whether to retry after 500, 502, 503, or - 504 responses. Defaults to False. + retry_transient_errors: Whether to retry after 500, 502, 503, 504 + or 52x responses. Defaults to False. """ def __init__( @@ -615,6 +624,7 @@ def http_request( files: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, obey_rate_limit: bool = True, + retry_transient_errors: Optional[bool] = None, max_retries: int = 10, **kwargs: Any, ) -> requests.Response: @@ -633,6 +643,8 @@ def http_request( timeout: The timeout, in seconds, for the request obey_rate_limit: Whether to obey 429 Too Many Request responses. Defaults to True. + retry_transient_errors: Whether to retry after 500, 502, 503, 504 + or 52x responses. Defaults to False. max_retries: Max retries after 429 or transient errors, set to -1 to retry forever. Defaults to 10. **kwargs: Extra options to send to the server (e.g. sudo) @@ -647,7 +659,7 @@ def http_request( url = self._build_url(path) params: Dict[str, Any] = {} - utils.copy_dict(params, query_data) + utils.copy_dict(src=query_data, dest=params) # Deal with kwargs: by default a user uses kwargs to send data to the # gitlab server, but this generates problems (python keyword conflicts @@ -656,12 +668,12 @@ def http_request( # value as arguments for the gitlab server, and ignore the other # arguments, except pagination ones (per_page and page) if "query_parameters" in kwargs: - utils.copy_dict(params, kwargs["query_parameters"]) + utils.copy_dict(src=kwargs["query_parameters"], dest=params) for arg in ("per_page", "page"): if arg in kwargs: params[arg] = kwargs[arg] else: - utils.copy_dict(params, kwargs) + utils.copy_dict(src=kwargs, dest=params) opts = self._get_session_opts() @@ -670,6 +682,8 @@ def http_request( # If timeout was passed into kwargs, allow it to override the default if timeout is None: timeout = opts_timeout + if retry_transient_errors is None: + retry_transient_errors = self.retry_transient_errors # We need to deal with json vs. data when uploading files json, data, content_type = self._prepare_send_data(files, post_data, raw) @@ -677,33 +691,46 @@ def http_request( cur_retries = 0 while True: - result = self.session.request( - method=verb, - url=url, - json=json, - data=data, - params=params, - timeout=timeout, - verify=verify, - stream=streamed, - **opts, - ) + try: + result = self.session.request( + method=verb, + url=url, + json=json, + data=data, + params=params, + timeout=timeout, + verify=verify, + stream=streamed, + **opts, + ) + except (requests.ConnectionError, requests.exceptions.ChunkedEncodingError): + if retry_transient_errors and ( + max_retries == -1 or cur_retries < max_retries + ): + wait_time = 2**cur_retries * 0.1 + cur_retries += 1 + time.sleep(wait_time) + continue + + raise self._check_redirects(result) if 200 <= result.status_code < 300: return result - retry_transient_errors = kwargs.get( - "retry_transient_errors", self.retry_transient_errors - ) if (429 == result.status_code and obey_rate_limit) or ( - result.status_code in [500, 502, 503, 504] and retry_transient_errors + result.status_code in RETRYABLE_TRANSIENT_ERROR_CODES + and retry_transient_errors ): + # Response headers documentation: + # https://docs.gitlab.com/ee/user/admin_area/settings/user_and_ip_rate_limits.html#response-headers if max_retries == -1 or cur_retries < max_retries: - wait_time = 2 ** cur_retries * 0.1 + wait_time = 2**cur_retries * 0.1 if "Retry-After" in result.headers: wait_time = int(result.headers["Retry-After"]) + elif "RateLimit-Reset" in result.headers: + wait_time = int(result.headers["RateLimit-Reset"]) - time.time() cur_retries += 1 time.sleep(wait_time) continue @@ -808,20 +835,59 @@ def http_list( # In case we want to change the default behavior at some point as_list = True if as_list is None else as_list - get_all = kwargs.pop("all", False) + get_all = kwargs.pop("all", None) url = self._build_url(path) page = kwargs.get("page") - if get_all is True and as_list is True: - return list(GitlabList(self, url, query_data, **kwargs)) + if as_list is False: + # Generator requested + return GitlabList(self, url, query_data, **kwargs) - if page or as_list is True: - # pagination requested, we return a list - return list(GitlabList(self, url, query_data, get_next=False, **kwargs)) + if get_all is True: + return list(GitlabList(self, url, query_data, **kwargs)) - # No pagination, generator requested - return GitlabList(self, url, query_data, **kwargs) + # pagination requested, we return a list + gl_list = GitlabList(self, url, query_data, get_next=False, **kwargs) + items = list(gl_list) + + def should_emit_warning() -> bool: + # No warning is emitted if any of the following conditions apply: + # * `all=False` was set in the `list()` call. + # * `page` was set in the `list()` call. + # * GitLab did not return the `x-per-page` header. + # * Number of items received is less than per-page value. + # * Number of items received is >= total available. + if get_all is False: + return False + if page is not None: + return False + if gl_list.per_page is None: + return False + if len(items) < gl_list.per_page: + return False + if gl_list.total is not None and len(items) >= gl_list.total: + return False + return True + + if not should_emit_warning(): + return items + + # Warn the user that they are only going to retrieve `per_page` + # maximum items. This is a common cause of issues filed. + total_items = "many" if gl_list.total is None else gl_list.total + utils.warn( + message=( + f"Calling a `list()` method without specifying `all=True` or " + f"`as_list=False` will return a maximum of {gl_list.per_page} items. " + f"Your query returned {len(items)} of {total_items} items. See " + f"{_PAGINATION_URL} for more details. If this was done intentionally, " + f"then this warning can be supressed by adding the argument " + f"`all=False` to the `list()` call." + ), + category=UserWarning, + ) + return items def http_post( self, @@ -1039,11 +1105,9 @@ def next_page(self) -> Optional[int]: return int(self._next_page) if self._next_page else None @property - def per_page(self) -> int: + def per_page(self) -> Optional[int]: """The number of items per page.""" - if TYPE_CHECKING: - assert self._per_page is not None - return int(self._per_page) + return int(self._per_page) if self._per_page is not None else None # NOTE(jlvillal): When a query returns more than 10,000 items, GitLab doesn't return # the headers 'x-total-pages' and 'x-total'. In those cases we return None. diff --git a/gitlab/const.py b/gitlab/const.py index 2ed4fa7d4..0d35045c2 100644 --- a/gitlab/const.py +++ b/gitlab/const.py @@ -61,6 +61,7 @@ DEVELOPER_ACCESS: int = 30 MAINTAINER_ACCESS: int = 40 OWNER_ACCESS: int = 50 +ADMIN_ACCESS: int = 60 VISIBILITY_PRIVATE: str = "private" VISIBILITY_INTERNAL: str = "internal" diff --git a/gitlab/mixins.py b/gitlab/mixins.py index c6d1f7adc..1a3ff4dbf 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -463,7 +463,7 @@ class DeleteMixin(_RestManagerBase): gitlab: gitlab.Gitlab @exc.on_http_error(exc.GitlabDeleteError) - def delete(self, id: Union[str, int], **kwargs: Any) -> None: + def delete(self, id: Optional[Union[str, int]] = None, **kwargs: Any) -> None: """Delete an object on the server. Args: @@ -478,6 +478,9 @@ def delete(self, id: Union[str, int], **kwargs: Any) -> None: path = self.path else: path = f"{self.path}/{utils.EncodedId(id)}" + + if TYPE_CHECKING: + assert path is not None self.gitlab.http_delete(path, **kwargs) diff --git a/gitlab/types.py b/gitlab/types.py index 2dc812114..bf74f2e8a 100644 --- a/gitlab/types.py +++ b/gitlab/types.py @@ -32,7 +32,9 @@ def get_for_api(self) -> Any: return self._value -class CommaSeparatedListAttribute(GitlabAttribute): +class _ListArrayAttribute(GitlabAttribute): + """Helper class to support `list` / `array` types.""" + def set_from_cli(self, cli_value: str) -> None: if not cli_value.strip(): self._value = [] @@ -49,6 +51,17 @@ def get_for_api(self) -> str: return ",".join([str(x) for x in self._value]) +class ArrayAttribute(_ListArrayAttribute): + """To support `array` types as documented in + https://docs.gitlab.com/ee/api/#array""" + + +class CommaSeparatedListAttribute(_ListArrayAttribute): + """For values which are sent to the server as a Comma Separated Values + (CSV) string. We allow them to be specified as a list and we convert it + into a CSV""" + + class LowercaseStringAttribute(GitlabAttribute): def get_for_api(self) -> str: return str(self._value).lower() diff --git a/gitlab/utils.py b/gitlab/utils.py index f54904206..197935549 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -15,8 +15,11 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . +import pathlib +import traceback import urllib.parse -from typing import Any, Callable, Dict, Optional, Union +import warnings +from typing import Any, Callable, Dict, Optional, Type, Union import requests @@ -44,7 +47,11 @@ def response_content( return None -def copy_dict(dest: Dict[str, Any], src: Dict[str, Any]) -> None: +def copy_dict( + *, + src: Dict[str, Any], + dest: Dict[str, Any], +) -> None: for k, v in src.items(): if isinstance(v, dict): # Transform dict values to new attributes. For example: @@ -86,3 +93,36 @@ def __new__( # type: ignore def remove_none_from_dict(data: Dict[str, Any]) -> Dict[str, Any]: return {k: v for k, v in data.items() if v is not None} + + +def warn( + message: str, + *, + category: Optional[Type] = None, + source: Optional[Any] = None, +) -> None: + """This `warnings.warn` wrapper function attempts to show the location causing the + warning in the user code that called the library. + + It does this by walking up the stack trace to find the first frame located outside + the `gitlab/` directory. This is helpful to users as it shows them their code that + is causing the warning. + """ + # Get `stacklevel` for user code so we indicate where issue is in + # their code. + pg_dir = pathlib.Path(__file__).parent.resolve() + stack = traceback.extract_stack() + stacklevel = 1 + warning_from = "" + for stacklevel, frame in enumerate(reversed(stack), start=1): + if stacklevel == 2: + warning_from = f" (python-gitlab: {frame.filename}:{frame.lineno})" + frame_dir = str(pathlib.Path(frame.filename).parent.resolve()) + if not frame_dir.startswith(str(pg_dir)): + break + warnings.warn( + message=message + warning_from, + category=category, + stacklevel=stacklevel, + source=source, + ) diff --git a/gitlab/v4/cli.py b/gitlab/v4/cli.py index 7d8eab7f9..6830b0874 100644 --- a/gitlab/v4/cli.py +++ b/gitlab/v4/cli.py @@ -218,8 +218,8 @@ def _populate_sub_parser_by_class( f"--{x.replace('_', '-')}", required=False ) - sub_parser_action.add_argument("--page", required=False) - sub_parser_action.add_argument("--per-page", required=False) + sub_parser_action.add_argument("--page", required=False, type=int) + sub_parser_action.add_argument("--per-page", required=False, type=int) sub_parser_action.add_argument("--all", required=False, action="store_true") if action_name == "delete": diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index ac118c0ed..40f9bf3fb 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -18,6 +18,7 @@ from .access_requests import * from .appearance import * from .applications import * +from .artifacts import * from .audit_events import * from .award_emojis import * from .badges import * diff --git a/gitlab/v4/objects/artifacts.py b/gitlab/v4/objects/artifacts.py new file mode 100644 index 000000000..541e5e2f4 --- /dev/null +++ b/gitlab/v4/objects/artifacts.py @@ -0,0 +1,151 @@ +""" +GitLab API: +https://docs.gitlab.com/ee/api/job_artifacts.html +""" +from typing import Any, Callable, Optional, TYPE_CHECKING + +import requests + +from gitlab import cli +from gitlab import exceptions as exc +from gitlab import utils +from gitlab.base import RESTManager, RESTObject + +__all__ = ["ProjectArtifact", "ProjectArtifactManager"] + + +class ProjectArtifact(RESTObject): + """Dummy object to manage custom actions on artifacts""" + + _id_attr = "ref_name" + + +class ProjectArtifactManager(RESTManager): + _obj_cls = ProjectArtifact + _path = "/projects/{project_id}/jobs/artifacts" + _from_parent_attrs = {"project_id": "id"} + + @cli.register_custom_action( + "Project", ("ref_name", "job"), ("job_token",), custom_action="artifacts" + ) + def __call__( + self, + *args: Any, + **kwargs: Any, + ) -> Optional[bytes]: + utils.warn( + message=( + "The project.artifacts() method is deprecated and will be removed in a " + "future version. Use project.artifacts.download() instead.\n" + ), + category=DeprecationWarning, + ) + return self.download( + *args, + **kwargs, + ) + + @exc.on_http_error(exc.GitlabDeleteError) + def delete(self, **kwargs: Any) -> None: + """Delete the project's artifacts on the server. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabDeleteError: If the server cannot perform the request + """ + path = self._compute_path("/projects/{project_id}/artifacts") + + if TYPE_CHECKING: + assert path is not None + self.gitlab.http_delete(path, **kwargs) + + @cli.register_custom_action( + "ProjectArtifactManager", ("ref_name", "job"), ("job_token",) + ) + @exc.on_http_error(exc.GitlabGetError) + def download( + self, + ref_name: str, + job: str, + streamed: bool = False, + action: Optional[Callable] = None, + chunk_size: int = 1024, + **kwargs: Any, + ) -> Optional[bytes]: + """Get the job artifacts archive from a specific tag or branch. + + Args: + ref_name: Branch or tag name in repository. HEAD or SHA references + are not supported. + job: The name of the job. + job_token: Job token for multi-project pipeline triggers. + streamed: If True the data will be processed by chunks of + `chunk_size` and each chunk is passed to `action` for + treatment + action: Callable responsible of dealing with chunk of + data + chunk_size: Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the artifacts could not be retrieved + + Returns: + The artifacts if `streamed` is False, None otherwise. + """ + path = f"{self.path}/{ref_name}/download" + result = self.gitlab.http_get( + path, job=job, streamed=streamed, raw=True, **kwargs + ) + if TYPE_CHECKING: + assert isinstance(result, requests.Response) + return utils.response_content(result, streamed, action, chunk_size) + + @cli.register_custom_action( + "ProjectArtifactManager", ("ref_name", "artifact_path", "job") + ) + @exc.on_http_error(exc.GitlabGetError) + def raw( + self, + ref_name: str, + artifact_path: str, + job: str, + streamed: bool = False, + action: Optional[Callable] = None, + chunk_size: int = 1024, + **kwargs: Any, + ) -> Optional[bytes]: + """Download a single artifact file from a specific tag or branch from + within the job's artifacts archive. + + Args: + ref_name: Branch or tag name in repository. HEAD or SHA references + are not supported. + artifact_path: Path to a file inside the artifacts archive. + job: The name of the job. + streamed: If True the data will be processed by chunks of + `chunk_size` and each chunk is passed to `action` for + treatment + action: Callable responsible of dealing with chunk of + data + chunk_size: Size of each chunk + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabGetError: If the artifacts could not be retrieved + + Returns: + The artifact if `streamed` is False, None otherwise. + """ + path = f"{self.path}/{ref_name}/raw/{artifact_path}" + result = self.gitlab.http_get( + path, streamed=streamed, raw=True, job=job, **kwargs + ) + if TYPE_CHECKING: + assert isinstance(result, requests.Response) + return utils.response_content(result, streamed, action, chunk_size) diff --git a/gitlab/v4/objects/commits.py b/gitlab/v4/objects/commits.py index fa08ef0a4..5f13f5c73 100644 --- a/gitlab/v4/objects/commits.py +++ b/gitlab/v4/objects/commits.py @@ -153,6 +153,16 @@ class ProjectCommitManager(RetrieveMixin, CreateMixin, RESTManager): required=("branch", "commit_message", "actions"), optional=("author_email", "author_name"), ) + _list_filters = ( + "ref_name", + "since", + "until", + "path", + "with_stats", + "first_parent", + "order", + "trailers", + ) def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/deploy_tokens.py b/gitlab/v4/objects/deploy_tokens.py index 563c1d63a..9fcfc2314 100644 --- a/gitlab/v4/objects/deploy_tokens.py +++ b/gitlab/v4/objects/deploy_tokens.py @@ -1,6 +1,14 @@ +from typing import Any, cast, Union + from gitlab import types from gitlab.base import RequiredOptional, RESTManager, RESTObject -from gitlab.mixins import CreateMixin, DeleteMixin, ListMixin, ObjectDeleteMixin +from gitlab.mixins import ( + CreateMixin, + DeleteMixin, + ListMixin, + ObjectDeleteMixin, + RetrieveMixin, +) __all__ = [ "DeployToken", @@ -25,7 +33,7 @@ class GroupDeployToken(ObjectDeleteMixin, RESTObject): pass -class GroupDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): +class GroupDeployTokenManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = "/groups/{group_id}/deploy_tokens" _from_parent_attrs = {"group_id": "id"} _obj_cls = GroupDeployToken @@ -41,12 +49,17 @@ class GroupDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): ) _types = {"scopes": types.CommaSeparatedListAttribute} + def get( + self, id: Union[str, int], lazy: bool = False, **kwargs: Any + ) -> GroupDeployToken: + return cast(GroupDeployToken, super().get(id=id, lazy=lazy, **kwargs)) + class ProjectDeployToken(ObjectDeleteMixin, RESTObject): pass -class ProjectDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): +class ProjectDeployTokenManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = "/projects/{project_id}/deploy_tokens" _from_parent_attrs = {"project_id": "id"} _obj_cls = ProjectDeployToken @@ -61,3 +74,8 @@ class ProjectDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager ), ) _types = {"scopes": types.CommaSeparatedListAttribute} + + def get( + self, id: Union[str, int], lazy: bool = False, **kwargs: Any + ) -> ProjectDeployToken: + return cast(ProjectDeployToken, super().get(id=id, lazy=lazy, **kwargs)) diff --git a/gitlab/v4/objects/groups.py b/gitlab/v4/objects/groups.py index 5e2ac00b9..a3a1051b0 100644 --- a/gitlab/v4/objects/groups.py +++ b/gitlab/v4/objects/groups.py @@ -314,10 +314,7 @@ class GroupManager(CRUDMixin, RESTManager): "shared_runners_setting", ), ) - _types = { - "avatar": types.ImageAttribute, - "skip_groups": types.CommaSeparatedListAttribute, - } + _types = {"avatar": types.ImageAttribute, "skip_groups": types.ArrayAttribute} def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Group: return cast(Group, super().get(id=id, lazy=lazy, **kwargs)) @@ -377,7 +374,7 @@ class GroupSubgroupManager(ListMixin, RESTManager): "with_custom_attributes", "min_access_level", ) - _types = {"skip_groups": types.CommaSeparatedListAttribute} + _types = {"skip_groups": types.ArrayAttribute} class GroupDescendantGroup(RESTObject): diff --git a/gitlab/v4/objects/issues.py b/gitlab/v4/objects/issues.py index 3452daf91..f20252bd1 100644 --- a/gitlab/v4/objects/issues.py +++ b/gitlab/v4/objects/issues.py @@ -65,10 +65,7 @@ class IssueManager(RetrieveMixin, RESTManager): "updated_after", "updated_before", ) - _types = { - "iids": types.CommaSeparatedListAttribute, - "labels": types.CommaSeparatedListAttribute, - } + _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute} def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> Issue: return cast(Issue, super().get(id=id, lazy=lazy, **kwargs)) @@ -98,10 +95,7 @@ class GroupIssueManager(ListMixin, RESTManager): "updated_after", "updated_before", ) - _types = { - "iids": types.CommaSeparatedListAttribute, - "labels": types.CommaSeparatedListAttribute, - } + _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute} class ProjectIssue( @@ -239,10 +233,7 @@ class ProjectIssueManager(CRUDMixin, RESTManager): "discussion_locked", ), ) - _types = { - "iids": types.CommaSeparatedListAttribute, - "labels": types.CommaSeparatedListAttribute, - } + _types = {"iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/labels.py b/gitlab/v4/objects/labels.py index f89985213..165bdb9b2 100644 --- a/gitlab/v4/objects/labels.py +++ b/gitlab/v4/objects/labels.py @@ -1,11 +1,10 @@ -from typing import Any, cast, Dict, Optional, TYPE_CHECKING, Union +from typing import Any, cast, Dict, Optional, Union from gitlab import exceptions as exc from gitlab.base import RequiredOptional, RESTManager, RESTObject from gitlab.mixins import ( CreateMixin, DeleteMixin, - ListMixin, ObjectDeleteMixin, PromoteMixin, RetrieveMixin, @@ -47,7 +46,9 @@ def save(self, **kwargs: Any) -> None: self._update_attrs(server_data) -class GroupLabelManager(ListMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTManager): +class GroupLabelManager( + RetrieveMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTManager +): _path = "/groups/{group_id}/labels" _obj_cls = GroupLabel _from_parent_attrs = {"group_id": "id"} @@ -58,6 +59,9 @@ class GroupLabelManager(ListMixin, CreateMixin, UpdateMixin, DeleteMixin, RESTMa required=("name",), optional=("new_name", "color", "description", "priority") ) + def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> GroupLabel: + return cast(GroupLabel, super().get(id=id, lazy=lazy, **kwargs)) + # Update without ID. # NOTE(jlvillal): Signature doesn't match UpdateMixin.update() so ignore # type error @@ -78,25 +82,6 @@ def update( # type: ignore new_data["name"] = name return super().update(id=None, new_data=new_data, **kwargs) - # Delete without ID. - @exc.on_http_error(exc.GitlabDeleteError) - # NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore - # type error - def delete(self, name: str, **kwargs: Any) -> None: # type: ignore - """Delete a Label on the server. - - Args: - name: The name of the label - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabDeleteError: If the server cannot perform the request - """ - if TYPE_CHECKING: - assert self.path is not None - self.gitlab.http_delete(self.path, query_data={"name": name}, **kwargs) - class ProjectLabel( PromoteMixin, SubscribableMixin, SaveMixin, ObjectDeleteMixin, RESTObject @@ -162,22 +147,3 @@ def update( # type: ignore if name: new_data["name"] = name return super().update(id=None, new_data=new_data, **kwargs) - - # Delete without ID. - @exc.on_http_error(exc.GitlabDeleteError) - # NOTE(jlvillal): Signature doesn't match DeleteMixin.delete() so ignore - # type error - def delete(self, name: str, **kwargs: Any) -> None: # type: ignore - """Delete a Label on the server. - - Args: - name: The name of the label - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabDeleteError: If the server cannot perform the request - """ - if TYPE_CHECKING: - assert self.path is not None - self.gitlab.http_delete(self.path, query_data={"name": name}, **kwargs) diff --git a/gitlab/v4/objects/members.py b/gitlab/v4/objects/members.py index 16fb92521..5ee0b0e4e 100644 --- a/gitlab/v4/objects/members.py +++ b/gitlab/v4/objects/members.py @@ -41,7 +41,7 @@ class GroupMemberManager(CRUDMixin, RESTManager): _update_attrs = RequiredOptional( required=("access_level",), optional=("expires_at",) ) - _types = {"user_ids": types.CommaSeparatedListAttribute} + _types = {"user_ids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any @@ -101,7 +101,7 @@ class ProjectMemberManager(CRUDMixin, RESTManager): _update_attrs = RequiredOptional( required=("access_level",), optional=("expires_at",) ) - _types = {"user_ids": types.CommaSeparatedListAttribute} + _types = {"user_ids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py index 45016d522..d34484b2e 100644 --- a/gitlab/v4/objects/merge_request_approvals.py +++ b/gitlab/v4/objects/merge_request_approvals.py @@ -163,7 +163,7 @@ def set_approvers( return approval_rules.create(data=data) -class ProjectMergeRequestApprovalRule(SaveMixin, RESTObject): +class ProjectMergeRequestApprovalRule(SaveMixin, ObjectDeleteMixin, RESTObject): _id_attr = "approval_rule_id" _short_print_attr = "approval_rule" id: int @@ -192,7 +192,7 @@ def save(self, **kwargs: Any) -> None: class ProjectMergeRequestApprovalRuleManager( - ListMixin, UpdateMixin, CreateMixin, RESTManager + ListMixin, UpdateMixin, CreateMixin, DeleteMixin, RESTManager ): _path = "/projects/{project_id}/merge_requests/{mr_iid}/approval_rules" _obj_cls = ProjectMergeRequestApprovalRule diff --git a/gitlab/v4/objects/merge_requests.py b/gitlab/v4/objects/merge_requests.py index 7f0be4bc1..edd7d0195 100644 --- a/gitlab/v4/objects/merge_requests.py +++ b/gitlab/v4/objects/merge_requests.py @@ -95,8 +95,8 @@ class MergeRequestManager(ListMixin, RESTManager): "deployed_after", ) _types = { - "approver_ids": types.CommaSeparatedListAttribute, - "approved_by_ids": types.CommaSeparatedListAttribute, + "approver_ids": types.ArrayAttribute, + "approved_by_ids": types.ArrayAttribute, "in": types.CommaSeparatedListAttribute, "labels": types.CommaSeparatedListAttribute, } @@ -133,8 +133,8 @@ class GroupMergeRequestManager(ListMixin, RESTManager): "wip", ) _types = { - "approver_ids": types.CommaSeparatedListAttribute, - "approved_by_ids": types.CommaSeparatedListAttribute, + "approver_ids": types.ArrayAttribute, + "approved_by_ids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute, } @@ -455,9 +455,9 @@ class ProjectMergeRequestManager(CRUDMixin, RESTManager): "wip", ) _types = { - "approver_ids": types.CommaSeparatedListAttribute, - "approved_by_ids": types.CommaSeparatedListAttribute, - "iids": types.CommaSeparatedListAttribute, + "approver_ids": types.ArrayAttribute, + "approved_by_ids": types.ArrayAttribute, + "iids": types.ArrayAttribute, "labels": types.CommaSeparatedListAttribute, } diff --git a/gitlab/v4/objects/milestones.py b/gitlab/v4/objects/milestones.py index dc6266ada..da75826db 100644 --- a/gitlab/v4/objects/milestones.py +++ b/gitlab/v4/objects/milestones.py @@ -93,7 +93,7 @@ class GroupMilestoneManager(CRUDMixin, RESTManager): optional=("title", "description", "due_date", "start_date", "state_event"), ) _list_filters = ("iids", "state", "search") - _types = {"iids": types.CommaSeparatedListAttribute} + _types = {"iids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any @@ -177,7 +177,7 @@ class ProjectMilestoneManager(CRUDMixin, RESTManager): optional=("title", "description", "due_date", "start_date", "state_event"), ) _list_filters = ("iids", "state", "search") - _types = {"iids": types.CommaSeparatedListAttribute} + _types = {"iids": types.ArrayAttribute} def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any diff --git a/gitlab/v4/objects/pipelines.py b/gitlab/v4/objects/pipelines.py index ec4e8e45e..0c2f22eae 100644 --- a/gitlab/v4/objects/pipelines.py +++ b/gitlab/v4/objects/pipelines.py @@ -35,6 +35,8 @@ "ProjectPipelineScheduleManager", "ProjectPipelineTestReport", "ProjectPipelineTestReportManager", + "ProjectPipelineTestReportSummary", + "ProjectPipelineTestReportSummaryManager", ] @@ -52,6 +54,7 @@ class ProjectPipeline(RefreshMixin, ObjectDeleteMixin, RESTObject): bridges: "ProjectPipelineBridgeManager" jobs: "ProjectPipelineJobManager" test_report: "ProjectPipelineTestReportManager" + test_report_summary: "ProjectPipelineTestReportSummaryManager" variables: "ProjectPipelineVariableManager" @cli.register_custom_action("ProjectPipeline") @@ -251,3 +254,20 @@ def get( self, id: Optional[Union[int, str]] = None, **kwargs: Any ) -> Optional[ProjectPipelineTestReport]: return cast(Optional[ProjectPipelineTestReport], super().get(id=id, **kwargs)) + + +class ProjectPipelineTestReportSummary(RESTObject): + _id_attr = None + + +class ProjectPipelineTestReportSummaryManager(GetWithoutIdMixin, RESTManager): + _path = "/projects/{project_id}/pipelines/{pipeline_id}/test_report_summary" + _obj_cls = ProjectPipelineTestReportSummary + _from_parent_attrs = {"project_id": "project_id", "pipeline_id": "id"} + + def get( + self, id: Optional[Union[int, str]] = None, **kwargs: Any + ) -> Optional[ProjectPipelineTestReportSummary]: + return cast( + Optional[ProjectPipelineTestReportSummary], super().get(id=id, **kwargs) + ) diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 354e56efa..81eb62496 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -1,4 +1,3 @@ -import warnings from typing import Any, Callable, cast, Dict, List, Optional, TYPE_CHECKING, Union import requests @@ -18,6 +17,7 @@ ) from .access_requests import ProjectAccessRequestManager # noqa: F401 +from .artifacts import ProjectArtifactManager # noqa: F401 from .audit_events import ProjectAuditEventManager # noqa: F401 from .badges import ProjectBadgeManager # noqa: F401 from .boards import ProjectBoardManager # noqa: F401 @@ -125,7 +125,7 @@ class ProjectGroupManager(ListMixin, RESTManager): "shared_min_access_level", "shared_visible_only", ) - _types = {"skip_groups": types.CommaSeparatedListAttribute} + _types = {"skip_groups": types.ArrayAttribute} class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTObject): @@ -136,6 +136,7 @@ class Project(RefreshMixin, SaveMixin, ObjectDeleteMixin, RepositoryMixin, RESTO additionalstatistics: ProjectAdditionalStatisticsManager approvalrules: ProjectApprovalRuleManager approvals: ProjectApprovalManager + artifacts: ProjectArtifactManager audit_events: ProjectAuditEventManager badges: ProjectBadgeManager boards: ProjectBoardManager @@ -546,101 +547,30 @@ def transfer(self, to_namespace: Union[int, str], **kwargs: Any) -> None: @cli.register_custom_action("Project", ("to_namespace",)) def transfer_project(self, *args: Any, **kwargs: Any) -> None: - warnings.warn( - "The project.transfer_project() method is deprecated and will be " - "removed in a future version. Use project.transfer() instead.", - DeprecationWarning, + utils.warn( + message=( + "The project.transfer_project() method is deprecated and will be " + "removed in a future version. Use project.transfer() instead." + ), + category=DeprecationWarning, ) return self.transfer(*args, **kwargs) - @cli.register_custom_action("Project", ("ref_name", "job"), ("job_token",)) - @exc.on_http_error(exc.GitlabGetError) - def artifacts( - self, - ref_name: str, - job: str, - streamed: bool = False, - action: Optional[Callable] = None, - chunk_size: int = 1024, - **kwargs: Any, - ) -> Optional[bytes]: - """Get the job artifacts archive from a specific tag or branch. - - Args: - ref_name: Branch or tag name in repository. HEAD or SHA references - are not supported. - artifact_path: Path to a file inside the artifacts archive. - job: The name of the job. - job_token: Job token for multi-project pipeline triggers. - streamed: If True the data will be processed by chunks of - `chunk_size` and each chunk is passed to `action` for - treatment - action: Callable responsible of dealing with chunk of - data - chunk_size: Size of each chunk - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabGetError: If the artifacts could not be retrieved - - Returns: - The artifacts if `streamed` is False, None otherwise. - """ - path = f"/projects/{self.encoded_id}/jobs/artifacts/{ref_name}/download" - result = self.manager.gitlab.http_get( - path, job=job, streamed=streamed, raw=True, **kwargs - ) - if TYPE_CHECKING: - assert isinstance(result, requests.Response) - return utils.response_content(result, streamed, action, chunk_size) - @cli.register_custom_action("Project", ("ref_name", "artifact_path", "job")) @exc.on_http_error(exc.GitlabGetError) def artifact( self, - ref_name: str, - artifact_path: str, - job: str, - streamed: bool = False, - action: Optional[Callable] = None, - chunk_size: int = 1024, + *args: Any, **kwargs: Any, ) -> Optional[bytes]: - """Download a single artifact file from a specific tag or branch from - within the job’s artifacts archive. - - Args: - ref_name: Branch or tag name in repository. HEAD or SHA references - are not supported. - artifact_path: Path to a file inside the artifacts archive. - job: The name of the job. - streamed: If True the data will be processed by chunks of - `chunk_size` and each chunk is passed to `action` for - treatment - action: Callable responsible of dealing with chunk of - data - chunk_size: Size of each chunk - **kwargs: Extra options to send to the server (e.g. sudo) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabGetError: If the artifacts could not be retrieved - - Returns: - The artifacts if `streamed` is False, None otherwise. - """ - - path = ( - f"/projects/{self.encoded_id}/jobs/artifacts/{ref_name}/raw/" - f"{artifact_path}?job={job}" + utils.warn( + message=( + "The project.artifact() method is deprecated and will be " + "removed in a future version. Use project.artifacts.raw() instead." + ), + category=DeprecationWarning, ) - result = self.manager.gitlab.http_get( - path, streamed=streamed, raw=True, **kwargs - ) - if TYPE_CHECKING: - assert isinstance(result, requests.Response) - return utils.response_content(result, streamed, action, chunk_size) + return self.artifacts.raw(*args, **kwargs) class ProjectManager(CRUDMixin, RESTManager): diff --git a/gitlab/v4/objects/services.py b/gitlab/v4/objects/services.py index 9b8e7f3a0..424d08563 100644 --- a/gitlab/v4/objects/services.py +++ b/gitlab/v4/objects/services.py @@ -3,7 +3,7 @@ https://docs.gitlab.com/ee/api/integrations.html """ -from typing import Any, cast, Dict, List, Optional, Union +from typing import Any, cast, List, Union from gitlab import cli from gitlab.base import RESTManager, RESTObject @@ -23,7 +23,7 @@ class ProjectService(SaveMixin, ObjectDeleteMixin, RESTObject): - pass + _id_attr = "slug" class ProjectServiceManager(GetMixin, UpdateMixin, DeleteMixin, ListMixin, RESTManager): @@ -264,53 +264,7 @@ class ProjectServiceManager(GetMixin, UpdateMixin, DeleteMixin, ListMixin, RESTM def get( self, id: Union[str, int], lazy: bool = False, **kwargs: Any ) -> ProjectService: - """Retrieve a single object. - - Args: - id: ID of the object to retrieve - lazy: If True, don't request the server, but create a - shallow object giving access to the managers. This is - useful if you want to avoid useless calls to the API. - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - The generated RESTObject. - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabGetError: If the server cannot perform the request - """ - obj = cast( - ProjectService, - super().get(id, lazy=lazy, **kwargs), - ) - obj.id = id - return obj - - def update( - self, - id: Optional[Union[str, int]] = None, - new_data: Optional[Dict[str, Any]] = None, - **kwargs: Any - ) -> Dict[str, Any]: - """Update an object on the server. - - Args: - id: ID of the object to update (can be None if not required) - new_data: the update data for the object - **kwargs: Extra options to send to the server (e.g. sudo) - - Returns: - The new object data (*not* a RESTObject) - - Raises: - GitlabAuthenticationError: If authentication is not correct - GitlabUpdateError: If the server cannot perform the request - """ - new_data = new_data or {} - result = super().update(id, new_data, **kwargs) - self.id = id - return result + return cast(ProjectService, super().get(id=id, lazy=lazy, **kwargs)) @cli.register_custom_action("ProjectServiceManager") def available(self, **kwargs: Any) -> List[str]: diff --git a/gitlab/v4/objects/settings.py b/gitlab/v4/objects/settings.py index 3075d9ce2..9be545c12 100644 --- a/gitlab/v4/objects/settings.py +++ b/gitlab/v4/objects/settings.py @@ -80,12 +80,12 @@ class ApplicationSettingsManager(GetWithoutIdMixin, UpdateMixin, RESTManager): ), ) _types = { - "asset_proxy_allowlist": types.CommaSeparatedListAttribute, - "disabled_oauth_sign_in_sources": types.CommaSeparatedListAttribute, - "domain_allowlist": types.CommaSeparatedListAttribute, - "domain_denylist": types.CommaSeparatedListAttribute, - "import_sources": types.CommaSeparatedListAttribute, - "restricted_visibility_levels": types.CommaSeparatedListAttribute, + "asset_proxy_allowlist": types.ArrayAttribute, + "disabled_oauth_sign_in_sources": types.ArrayAttribute, + "domain_allowlist": types.ArrayAttribute, + "domain_denylist": types.ArrayAttribute, + "import_sources": types.ArrayAttribute, + "restricted_visibility_levels": types.ArrayAttribute, } @exc.on_http_error(exc.GitlabUpdateError) diff --git a/gitlab/v4/objects/topics.py b/gitlab/v4/objects/topics.py index 71f66076c..76208ed82 100644 --- a/gitlab/v4/objects/topics.py +++ b/gitlab/v4/objects/topics.py @@ -2,7 +2,7 @@ from gitlab import types from gitlab.base import RequiredOptional, RESTManager, RESTObject -from gitlab.mixins import CreateMixin, RetrieveMixin, SaveMixin, UpdateMixin +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin __all__ = [ "Topic", @@ -10,11 +10,11 @@ ] -class Topic(SaveMixin, RESTObject): +class Topic(SaveMixin, ObjectDeleteMixin, RESTObject): pass -class TopicManager(CreateMixin, RetrieveMixin, UpdateMixin, RESTManager): +class TopicManager(CRUDMixin, RESTManager): _path = "/topics" _obj_cls = Topic _create_attrs = RequiredOptional( diff --git a/gitlab/v4/objects/users.py b/gitlab/v4/objects/users.py index e3553b0e5..ddcee707a 100644 --- a/gitlab/v4/objects/users.py +++ b/gitlab/v4/objects/users.py @@ -369,7 +369,7 @@ class ProjectUserManager(ListMixin, RESTManager): _obj_cls = ProjectUser _from_parent_attrs = {"project_id": "id"} _list_filters = ("search", "skip_users") - _types = {"skip_users": types.CommaSeparatedListAttribute} + _types = {"skip_users": types.ArrayAttribute} class UserEmail(ObjectDeleteMixin, RESTObject): @@ -429,12 +429,15 @@ class UserKey(ObjectDeleteMixin, RESTObject): pass -class UserKeyManager(ListMixin, CreateMixin, DeleteMixin, RESTManager): +class UserKeyManager(RetrieveMixin, CreateMixin, DeleteMixin, RESTManager): _path = "/users/{user_id}/keys" _obj_cls = UserKey _from_parent_attrs = {"user_id": "id"} _create_attrs = RequiredOptional(required=("title", "key")) + def get(self, id: Union[str, int], lazy: bool = False, **kwargs: Any) -> UserKey: + return cast(UserKey, super().get(id=id, lazy=lazy, **kwargs)) + class UserIdentityProviderManager(DeleteMixin, RESTManager): """Manager for user identities. diff --git a/pyproject.toml b/pyproject.toml index f05a44e3e..0480feba3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ order_by_type = false [tool.mypy] files = "." +exclude = "build/.*" # 'strict = true' is equivalent to the following: check_untyped_defs = true diff --git a/requirements-docs.txt b/requirements-docs.txt index 1fa1e7ea9..d35169648 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -2,6 +2,6 @@ furo jinja2 myst-parser -sphinx==4.3.2 +sphinx==4.5.0 sphinx_rtd_theme sphinxcontrib-autoprogram diff --git a/requirements-lint.txt b/requirements-lint.txt index 2722cdd6a..77fcf92fc 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,10 +1,11 @@ -argcomplete==2.0.0 -black==21.12b0 +argcomplete<=2.0.0 +black==22.3.0 +commitizen==2.24.0 flake8==4.0.1 isort==5.10.1 -mypy==0.930 -pylint==2.12.2 -pytest==6.2.5 -types-PyYAML==6.0.1 -types-requests==2.26.3 -types-setuptools==57.4.5 +mypy==0.950 +pylint==2.13.7 +pytest==7.1.2 +types-PyYAML==6.0.7 +types-requests==2.27.25 +types-setuptools==57.4.14 diff --git a/requirements-test.txt b/requirements-test.txt index 277ca6d68..4eb43be4e 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,5 +1,5 @@ coverage -pytest==6.2.5 -pytest-console-scripts==1.2.1 +pytest==7.1.2 +pytest-console-scripts==1.3.1 pytest-cov responses diff --git a/requirements.txt b/requirements.txt index 9b2c37808..c94a1d220 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -requests==2.27.0 +requests==2.27.1 requests-toolbelt==0.9.1 diff --git a/tests/functional/api/test_deploy_tokens.py b/tests/functional/api/test_deploy_tokens.py index efcf8b1b3..9824af5d2 100644 --- a/tests/functional/api/test_deploy_tokens.py +++ b/tests/functional/api/test_deploy_tokens.py @@ -10,10 +10,11 @@ def test_project_deploy_tokens(gl, project): assert len(project.deploytokens.list()) == 1 assert gl.deploytokens.list() == project.deploytokens.list() - assert project.deploytokens.list()[0].name == "foo" - assert project.deploytokens.list()[0].expires_at == "2022-01-01T00:00:00.000Z" - assert project.deploytokens.list()[0].scopes == ["read_registry"] - assert project.deploytokens.list()[0].username == "bar" + deploy_token = project.deploytokens.get(deploy_token.id) + assert deploy_token.name == "foo" + assert deploy_token.expires_at == "2022-01-01T00:00:00.000Z" + assert deploy_token.scopes == ["read_registry"] + assert deploy_token.username == "bar" deploy_token.delete() assert len(project.deploytokens.list()) == 0 @@ -31,6 +32,10 @@ def test_group_deploy_tokens(gl, group): assert len(group.deploytokens.list()) == 1 assert gl.deploytokens.list() == group.deploytokens.list() + deploy_token = group.deploytokens.get(deploy_token.id) + assert deploy_token.name == "foo" + assert deploy_token.scopes == ["read_registry"] + deploy_token.delete() assert len(group.deploytokens.list()) == 0 assert len(gl.deploytokens.list()) == 0 diff --git a/tests/functional/api/test_gitlab.py b/tests/functional/api/test_gitlab.py index b0711280e..4684e433b 100644 --- a/tests/functional/api/test_gitlab.py +++ b/tests/functional/api/test_gitlab.py @@ -1,3 +1,5 @@ +import warnings + import pytest import gitlab @@ -81,13 +83,13 @@ def test_template_dockerfile(gl): def test_template_gitignore(gl): - assert gl.gitignores.list() + assert gl.gitignores.list(all=True) gitignore = gl.gitignores.get("Node") assert gitignore.content is not None def test_template_gitlabciyml(gl): - assert gl.gitlabciymls.list() + assert gl.gitlabciymls.list(all=True) gitlabciyml = gl.gitlabciymls.get("Nodejs") assert gitlabciyml.content is not None @@ -181,3 +183,46 @@ def test_rate_limits(gl): settings.throttle_authenticated_api_enabled = False settings.save() [project.delete() for project in projects] + + +def test_list_default_warning(gl): + """When there are more than 20 items and use default `list()` then warning is + generated""" + with warnings.catch_warnings(record=True) as caught_warnings: + gl.gitlabciymls.list() + assert len(caught_warnings) == 1 + warning = caught_warnings[0] + assert isinstance(warning.message, UserWarning) + message = str(warning.message) + assert "python-gitlab.readthedocs.io" in message + assert __file__ == warning.filename + + +def test_list_page_nowarning(gl): + """Using `page=X` will disable the warning""" + with warnings.catch_warnings(record=True) as caught_warnings: + gl.gitlabciymls.list(page=1) + assert len(caught_warnings) == 0 + + +def test_list_all_false_nowarning(gl): + """Using `all=False` will disable the warning""" + with warnings.catch_warnings(record=True) as caught_warnings: + gl.gitlabciymls.list(all=False) + assert len(caught_warnings) == 0 + + +def test_list_all_true_nowarning(gl): + """Using `all=True` will disable the warning""" + with warnings.catch_warnings(record=True) as caught_warnings: + items = gl.gitlabciymls.list(all=True) + assert len(caught_warnings) == 0 + assert len(items) > 20 + + +def test_list_as_list_false_nowarning(gl): + """Using `as_list=False` will disable the warning""" + with warnings.catch_warnings(record=True) as caught_warnings: + items = gl.gitlabciymls.list(as_list=False) + assert len(caught_warnings) == 0 + assert len(list(items)) > 20 diff --git a/tests/functional/api/test_groups.py b/tests/functional/api/test_groups.py index b61305569..6525a5b91 100644 --- a/tests/functional/api/test_groups.py +++ b/tests/functional/api/test_groups.py @@ -104,7 +104,6 @@ def test_groups(gl): group2.members.delete(gl.user.id) -@pytest.mark.skip(reason="Commented out in legacy test") def test_group_labels(group): group.labels.create({"name": "foo", "description": "bar", "color": "#112233"}) label = group.labels.get("foo") @@ -116,6 +115,12 @@ def test_group_labels(group): assert label.description == "baz" assert len(group.labels.list()) == 1 + label.new_name = "Label:that requires:encoding" + label.save() + assert label.name == "Label:that requires:encoding" + label = group.labels.get("Label:that requires:encoding") + assert label.name == "Label:that requires:encoding" + label.delete() assert len(group.labels.list()) == 0 diff --git a/tests/functional/api/test_projects.py b/tests/functional/api/test_projects.py index 44241d44e..8f8abbe86 100644 --- a/tests/functional/api/test_projects.py +++ b/tests/functional/api/test_projects.py @@ -146,9 +146,11 @@ def test_project_labels(project): label = project.labels.get("label") assert label == labels[0] - label.new_name = "labelupdated" + label.new_name = "Label:that requires:encoding" label.save() - assert label.name == "labelupdated" + assert label.name == "Label:that requires:encoding" + label = project.labels.get("Label:that requires:encoding") + assert label.name == "Label:that requires:encoding" label.subscribe() assert label.subscribed is True @@ -242,7 +244,7 @@ def test_project_protected_branches(project): def test_project_remote_mirrors(project): - mirror_url = "http://gitlab.test/root/mirror.git" + mirror_url = "https://gitlab.example.com/root/mirror.git" mirror = project.remote_mirrors.create({"url": mirror_url}) assert mirror.url == mirror_url diff --git a/tests/functional/api/test_services.py b/tests/functional/api/test_services.py index 100c0c9e5..51805ef37 100644 --- a/tests/functional/api/test_services.py +++ b/tests/functional/api/test_services.py @@ -6,6 +6,33 @@ import gitlab -def test_services(project): +def test_get_service_lazy(project): service = project.services.get("jira", lazy=True) assert isinstance(service, gitlab.v4.objects.ProjectService) + + +def test_update_service(project): + service_dict = project.services.update( + "emails-on-push", {"recipients": "email@example.com"} + ) + assert service_dict["active"] + + +def test_list_services(project, service): + services = project.services.list() + assert isinstance(services[0], gitlab.v4.objects.ProjectService) + assert services[0].active + + +def test_get_service(project, service): + service_object = project.services.get(service["slug"]) + assert isinstance(service_object, gitlab.v4.objects.ProjectService) + assert service_object.active + + +def test_delete_service(project, service): + service_object = project.services.get(service["slug"]) + service_object.delete() + + service_object = project.services.get(service["slug"]) + assert not service_object.active diff --git a/tests/functional/api/test_topics.py b/tests/functional/api/test_topics.py index dea457c30..7ad71a524 100644 --- a/tests/functional/api/test_topics.py +++ b/tests/functional/api/test_topics.py @@ -16,3 +16,6 @@ def test_topics(gl): updated_topic = gl.topics.get(topic.id) assert updated_topic.description == topic.description + + topic.delete() + assert not gl.topics.list() diff --git a/tests/functional/api/test_users.py b/tests/functional/api/test_users.py index 9945aa68e..0c5803408 100644 --- a/tests/functional/api/test_users.py +++ b/tests/functional/api/test_users.py @@ -106,6 +106,9 @@ def test_user_ssh_keys(gl, user, SSH_KEY): key = user.keys.create({"title": "testkey", "key": SSH_KEY}) assert len(user.keys.list()) == 1 + get_key = user.keys.get(key.id) + assert get_key.key == key.key + key.delete() assert len(user.keys.list()) == 0 diff --git a/tests/functional/cli/test_cli_artifacts.py b/tests/functional/cli/test_cli_artifacts.py index 76eb9f2fb..b3122cd47 100644 --- a/tests/functional/cli/test_cli_artifacts.py +++ b/tests/functional/cli/test_cli_artifacts.py @@ -4,6 +4,8 @@ from io import BytesIO from zipfile import is_zipfile +import pytest + content = textwrap.dedent( """\ test-artifact: @@ -20,15 +22,19 @@ } -def test_cli_artifacts(capsysbinary, gitlab_config, gitlab_runner, project): +@pytest.fixture(scope="module") +def job_with_artifacts(gitlab_runner, project): project.files.create(data) jobs = None while not jobs: - jobs = project.jobs.list(scope="success") time.sleep(0.5) + jobs = project.jobs.list(scope="success") - job = project.jobs.get(jobs[0].id) + return project.jobs.get(jobs[0].id) + + +def test_cli_job_artifacts(capsysbinary, gitlab_config, job_with_artifacts): cmd = [ "gitlab", "--config-file", @@ -36,9 +42,9 @@ def test_cli_artifacts(capsysbinary, gitlab_config, gitlab_runner, project): "project-job", "artifacts", "--id", - str(job.id), + str(job_with_artifacts.id), "--project-id", - str(project.id), + str(job_with_artifacts.pipeline["project_id"]), ] with capsysbinary.disabled(): @@ -47,3 +53,93 @@ def test_cli_artifacts(capsysbinary, gitlab_config, gitlab_runner, project): artifacts_zip = BytesIO(artifacts) assert is_zipfile(artifacts_zip) + + +def test_cli_project_artifact_download(gitlab_config, job_with_artifacts): + cmd = [ + "gitlab", + "--config-file", + gitlab_config, + "project-artifact", + "download", + "--project-id", + str(job_with_artifacts.pipeline["project_id"]), + "--ref-name", + job_with_artifacts.ref, + "--job", + job_with_artifacts.name, + ] + + artifacts = subprocess.run(cmd, capture_output=True, check=True) + assert isinstance(artifacts.stdout, bytes) + + artifacts_zip = BytesIO(artifacts.stdout) + assert is_zipfile(artifacts_zip) + + +def test_cli_project_artifacts_warns_deprecated(gitlab_config, job_with_artifacts): + cmd = [ + "gitlab", + "--config-file", + gitlab_config, + "project", + "artifacts", + "--id", + str(job_with_artifacts.pipeline["project_id"]), + "--ref-name", + job_with_artifacts.ref, + "--job", + job_with_artifacts.name, + ] + + artifacts = subprocess.run(cmd, capture_output=True, check=True) + assert isinstance(artifacts.stdout, bytes) + assert b"DeprecationWarning" in artifacts.stderr + + artifacts_zip = BytesIO(artifacts.stdout) + assert is_zipfile(artifacts_zip) + + +def test_cli_project_artifact_raw(gitlab_config, job_with_artifacts): + cmd = [ + "gitlab", + "--config-file", + gitlab_config, + "project-artifact", + "raw", + "--project-id", + str(job_with_artifacts.pipeline["project_id"]), + "--ref-name", + job_with_artifacts.ref, + "--job", + job_with_artifacts.name, + "--artifact-path", + "artifact.txt", + ] + + artifacts = subprocess.run(cmd, capture_output=True, check=True) + assert isinstance(artifacts.stdout, bytes) + assert artifacts.stdout == b"test\n" + + +def test_cli_project_artifact_warns_deprecated(gitlab_config, job_with_artifacts): + cmd = [ + "gitlab", + "--config-file", + gitlab_config, + "project", + "artifact", + "--id", + str(job_with_artifacts.pipeline["project_id"]), + "--ref-name", + job_with_artifacts.ref, + "--job", + job_with_artifacts.name, + "--artifact-path", + "artifact.txt", + ] + + artifacts = subprocess.run(cmd, capture_output=True, check=True) + assert isinstance(artifacts.stdout, bytes) + assert b"DeprecationWarning" in artifacts.stderr + assert artifacts.stdout == b"test\n" diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index d34c87e67..e43b53bf4 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -39,6 +39,8 @@ def reset_gitlab(gl): ) deploy_token.delete() group.delete() + for topic in gl.topics.list(): + topic.delete() for variable in gl.variables.list(): logging.info(f"Marking for deletion variable: {variable.key!r}") variable.delete() @@ -392,6 +394,21 @@ def release(project, project_file): return release +@pytest.fixture(scope="function") +def service(project): + """This is just a convenience fixture to make test cases slightly prettier. Project + services are not idempotent. A service cannot be retrieved until it is enabled. + After it is enabled the first time, it can never be fully deleted, only disabled.""" + service = project.services.update("asana", {"api_key": "api_key"}) + + yield service + + try: + project.services.delete("asana") + except gitlab.exceptions.GitlabDeleteError as e: + print(f"Service already disabled: {e}") + + @pytest.fixture(scope="module") def user(gl): """User fixture for user API resource tests.""" diff --git a/tests/functional/fixtures/.env b/tests/functional/fixtures/.env index bcfd35713..da9332fd7 100644 --- a/tests/functional/fixtures/.env +++ b/tests/functional/fixtures/.env @@ -1,2 +1,2 @@ GITLAB_IMAGE=gitlab/gitlab-ce -GITLAB_TAG=14.6.2-ce.0 +GITLAB_TAG=14.9.2-ce.0 diff --git a/tests/functional/fixtures/docker-compose.yml b/tests/functional/fixtures/docker-compose.yml index e4869fbe0..ae1d77655 100644 --- a/tests/functional/fixtures/docker-compose.yml +++ b/tests/functional/fixtures/docker-compose.yml @@ -14,7 +14,7 @@ services: GITLAB_ROOT_PASSWORD: 5iveL!fe GITLAB_SHARED_RUNNERS_REGISTRATION_TOKEN: registration-token GITLAB_OMNIBUS_CONFIG: | - external_url 'http://gitlab.test' + external_url 'http://127.0.0.1:8080' registry['enable'] = false nginx['redirect_http_to_https'] = false nginx['listen_port'] = 80 diff --git a/tests/unit/objects/test_job_artifacts.py b/tests/unit/objects/test_job_artifacts.py index 0d455fecc..4d47db8da 100644 --- a/tests/unit/objects/test_job_artifacts.py +++ b/tests/unit/objects/test_job_artifacts.py @@ -24,7 +24,36 @@ def resp_artifacts_by_ref_name(binary_content): yield rsps -def test_download_artifacts_by_ref_name(gl, binary_content, resp_artifacts_by_ref_name): +@pytest.fixture +def resp_project_artifacts_delete(no_content): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/artifacts", + json=no_content, + content_type="application/json", + status=204, + ) + yield rsps + + +def test_project_artifacts_delete(gl, resp_project_artifacts_delete): + project = gl.projects.get(1, lazy=True) + project.artifacts.delete() + + +def test_project_artifacts_download_by_ref_name( + gl, binary_content, resp_artifacts_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_project_artifacts_by_ref_name_warns( + gl, binary_content, resp_artifacts_by_ref_name +): project = gl.projects.get(1, lazy=True) - artifacts = project.artifacts(ref_name=ref_name, job=job) + with pytest.warns(DeprecationWarning): + artifacts = project.artifacts(ref_name=ref_name, job=job) assert artifacts == binary_content diff --git a/tests/unit/objects/test_pipelines.py b/tests/unit/objects/test_pipelines.py index 3412f6d7a..e4d2b9e7f 100644 --- a/tests/unit/objects/test_pipelines.py +++ b/tests/unit/objects/test_pipelines.py @@ -4,7 +4,11 @@ import pytest import responses -from gitlab.v4.objects import ProjectPipeline, ProjectPipelineTestReport +from gitlab.v4.objects import ( + ProjectPipeline, + ProjectPipelineTestReport, + ProjectPipelineTestReportSummary, +) pipeline_content = { "id": 46, @@ -66,6 +70,32 @@ } +test_report_summary_content = { + "total": { + "time": 1904, + "count": 3363, + "success": 3351, + "failed": 0, + "skipped": 12, + "error": 0, + "suite_error": None, + }, + "test_suites": [ + { + "name": "test", + "total_time": 1904, + "total_count": 3363, + "success_count": 3351, + "failed_count": 0, + "skipped_count": 12, + "error_count": 0, + "build_ids": [66004], + "suite_error": None, + } + ], +} + + @pytest.fixture def resp_get_pipeline(): with responses.RequestsMock() as rsps: @@ -118,6 +148,19 @@ def resp_get_pipeline_test_report(): yield rsps +@pytest.fixture +def resp_get_pipeline_test_report_summary(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/pipelines/1/test_report_summary", + json=test_report_summary_content, + content_type="application/json", + status=200, + ) + yield rsps + + def test_get_project_pipeline(project, resp_get_pipeline): pipeline = project.pipelines.get(1) assert isinstance(pipeline, ProjectPipeline) @@ -144,3 +187,13 @@ def test_get_project_pipeline_test_report(project, resp_get_pipeline_test_report assert isinstance(test_report, ProjectPipelineTestReport) assert test_report.total_time == 5 assert test_report.test_suites[0]["name"] == "Secure" + + +def test_get_project_pipeline_test_report_summary( + project, resp_get_pipeline_test_report_summary +): + pipeline = project.pipelines.get(1, lazy=True) + test_report_summary = pipeline.test_report_summary.get() + assert isinstance(test_report_summary, ProjectPipelineTestReportSummary) + assert test_report_summary.total["count"] == 3363 + assert test_report_summary.test_suites[0]["name"] == "test" diff --git a/tests/unit/objects/test_project_merge_request_approvals.py b/tests/unit/objects/test_project_merge_request_approvals.py index 8c2920df4..5a87552c3 100644 --- a/tests/unit/objects/test_project_merge_request_approvals.py +++ b/tests/unit/objects/test_project_merge_request_approvals.py @@ -24,102 +24,7 @@ @pytest.fixture -def resp_snippet(): - merge_request_content = [ - { - "id": 1, - "iid": 1, - "project_id": 1, - "title": "test1", - "description": "fixed login page css paddings", - "state": "merged", - "merged_by": { - "id": 87854, - "name": "Douwe Maan", - "username": "DouweM", - "state": "active", - "avatar_url": "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", - "web_url": "https://gitlab.com/DouweM", - }, - "merged_at": "2018-09-07T11:16:17.520Z", - "closed_by": None, - "closed_at": None, - "created_at": "2017-04-29T08:46:00Z", - "updated_at": "2017-04-29T08:46:00Z", - "target_branch": "main", - "source_branch": "test1", - "upvotes": 0, - "downvotes": 0, - "author": { - "id": 1, - "name": "Administrator", - "username": "admin", - "state": "active", - "avatar_url": None, - "web_url": "https://gitlab.example.com/admin", - }, - "assignee": { - "id": 1, - "name": "Administrator", - "username": "admin", - "state": "active", - "avatar_url": None, - "web_url": "https://gitlab.example.com/admin", - }, - "assignees": [ - { - "name": "Miss Monserrate Beier", - "username": "axel.block", - "id": 12, - "state": "active", - "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", - "web_url": "https://gitlab.example.com/axel.block", - } - ], - "source_project_id": 2, - "target_project_id": 3, - "labels": ["Community contribution", "Manage"], - "work_in_progress": None, - "milestone": { - "id": 5, - "iid": 1, - "project_id": 3, - "title": "v2.0", - "description": "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", - "state": "closed", - "created_at": "2015-02-02T19:49:26.013Z", - "updated_at": "2015-02-02T19:49:26.013Z", - "due_date": "2018-09-22", - "start_date": "2018-08-08", - "web_url": "https://gitlab.example.com/my-group/my-project/milestones/1", - }, - "merge_when_pipeline_succeeds": None, - "merge_status": "can_be_merged", - "sha": "8888888888888888888888888888888888888888", - "merge_commit_sha": None, - "squash_commit_sha": None, - "user_notes_count": 1, - "discussion_locked": None, - "should_remove_source_branch": True, - "force_remove_source_branch": False, - "allow_collaboration": False, - "allow_maintainer_to_push": False, - "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", - "references": { - "short": "!1", - "relative": "my-group/my-project!1", - "full": "my-group/my-project!1", - }, - "time_stats": { - "time_estimate": 0, - "total_time_spent": 0, - "human_time_estimate": None, - "human_total_time_spent": None, - }, - "squash": False, - "task_completion_status": {"count": 0, "completed_count": 0}, - } - ] +def resp_mr_approval_rules(): mr_ars_content = [ { "id": approval_rule_id, @@ -188,20 +93,6 @@ def resp_snippet(): } with responses.RequestsMock(assert_all_requests_are_fired=False) as rsps: - rsps.add( - method=responses.GET, - url="http://localhost/api/v4/projects/1/merge_requests", - json=merge_request_content, - content_type="application/json", - status=200, - ) - rsps.add( - method=responses.GET, - url="http://localhost/api/v4/projects/1/merge_requests/1", - json=merge_request_content[0], - content_type="application/json", - status=200, - ) rsps.add( method=responses.GET, url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules", @@ -248,7 +139,20 @@ def resp_snippet(): yield rsps -def test_project_approval_manager_update_uses_post(project, resp_snippet): +@pytest.fixture +def resp_delete_mr_approval_rule(no_content): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/merge_requests/1/approval_rules/1", + json=no_content, + content_type="application/json", + status=204, + ) + yield rsps + + +def test_project_approval_manager_update_uses_post(project): """Ensure the gitlab.v4.objects.merge_request_approvals.ProjectApprovalManager object has _update_uses_post set to True""" @@ -259,15 +163,20 @@ def test_project_approval_manager_update_uses_post(project, resp_snippet): assert approvals._update_uses_post is True -def test_list_merge_request_approval_rules(project, resp_snippet): - approval_rules = project.mergerequests.get(1).approval_rules.list() +def test_list_merge_request_approval_rules(project, resp_mr_approval_rules): + approval_rules = project.mergerequests.get(1, lazy=True).approval_rules.list() assert len(approval_rules) == 1 assert approval_rules[0].name == approval_rule_name assert approval_rules[0].id == approval_rule_id -def test_update_merge_request_approvals_set_approvers(project, resp_snippet): - approvals = project.mergerequests.get(1).approvals +def test_delete_merge_request_approval_rule(project, resp_delete_mr_approval_rule): + merge_request = project.mergerequests.get(1, lazy=True) + merge_request.approval_rules.delete(approval_rule_id) + + +def test_update_merge_request_approvals_set_approvers(project, resp_mr_approval_rules): + approvals = project.mergerequests.get(1, lazy=True).approvals assert isinstance( approvals, gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalManager, @@ -286,8 +195,8 @@ def test_update_merge_request_approvals_set_approvers(project, resp_snippet): assert response.name == approval_rule_name -def test_create_merge_request_approvals_set_approvers(project, resp_snippet): - approvals = project.mergerequests.get(1).approvals +def test_create_merge_request_approvals_set_approvers(project, resp_mr_approval_rules): + approvals = project.mergerequests.get(1, lazy=True).approvals assert isinstance( approvals, gitlab.v4.objects.merge_request_approvals.ProjectMergeRequestApprovalManager, @@ -305,8 +214,8 @@ def test_create_merge_request_approvals_set_approvers(project, resp_snippet): assert response.name == new_approval_rule_name -def test_create_merge_request_approval_rule(project, resp_snippet): - approval_rules = project.mergerequests.get(1).approval_rules +def test_create_merge_request_approval_rule(project, resp_mr_approval_rules): + approval_rules = project.mergerequests.get(1, lazy=True).approval_rules data = { "name": new_approval_rule_name, "approvals_required": new_approval_rule_approvals_required, @@ -321,8 +230,8 @@ def test_create_merge_request_approval_rule(project, resp_snippet): assert response.name == new_approval_rule_name -def test_update_merge_request_approval_rule(project, resp_snippet): - approval_rules = project.mergerequests.get(1).approval_rules +def test_update_merge_request_approval_rule(project, resp_mr_approval_rules): + approval_rules = project.mergerequests.get(1, lazy=True).approval_rules ar_1 = approval_rules.list()[0] ar_1.user_ids = updated_approval_rule_user_ids ar_1.approvals_required = updated_approval_rule_approvals_required @@ -333,8 +242,8 @@ def test_update_merge_request_approval_rule(project, resp_snippet): assert ar_1.eligible_approvers[0]["id"] == updated_approval_rule_user_ids[0] -def test_get_merge_request_approval_state(project, resp_snippet): - merge_request = project.mergerequests.get(1) +def test_get_merge_request_approval_state(project, resp_mr_approval_rules): + merge_request = project.mergerequests.get(1, lazy=True) approval_state = merge_request.approval_state.get() assert isinstance( approval_state, diff --git a/tests/unit/objects/test_runners.py b/tests/unit/objects/test_runners.py index 1f3dc481f..3d5cdd1ee 100644 --- a/tests/unit/objects/test_runners.py +++ b/tests/unit/objects/test_runners.py @@ -173,6 +173,18 @@ def resp_runner_delete(): yield rsps +@pytest.fixture +def resp_runner_delete_by_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/runners", + status=204, + match=[responses.matchers.query_param_matcher({"token": "auth-token"})], + ) + yield rsps + + @pytest.fixture def resp_runner_disable(): with responses.RequestsMock() as rsps: @@ -242,12 +254,16 @@ def test_get_update_runner(gl: gitlab.Gitlab, resp_runner_detail): runner.save() -def test_remove_runner(gl: gitlab.Gitlab, resp_runner_delete): +def test_delete_runner_by_id(gl: gitlab.Gitlab, resp_runner_delete): runner = gl.runners.get(6) runner.delete() gl.runners.delete(6) +def test_delete_runner_by_token(gl: gitlab.Gitlab, resp_runner_delete_by_token): + gl.runners.delete(token="auth-token") + + def test_disable_project_runner(gl: gitlab.Gitlab, resp_runner_disable): gl.projects.get(1, lazy=True).runners.delete(6) diff --git a/tests/unit/objects/test_topics.py b/tests/unit/objects/test_topics.py index c0654acf6..14b2cfddf 100644 --- a/tests/unit/objects/test_topics.py +++ b/tests/unit/objects/test_topics.py @@ -75,6 +75,19 @@ def resp_update_topic(): yield rsps +@pytest.fixture +def resp_delete_topic(no_content): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url=topic_url, + json=no_content, + content_type="application/json", + status=204, + ) + yield rsps + + def test_list_topics(gl, resp_list_topics): topics = gl.topics.list() assert isinstance(topics, list) @@ -99,3 +112,8 @@ def test_update_topic(gl, resp_update_topic): topic.name = new_name topic.save() assert topic.name == new_name + + +def test_delete_topic(gl, resp_delete_topic): + topic = gl.topics.get(1, lazy=True) + topic.delete() diff --git a/tests/unit/test_gitlab_http_methods.py b/tests/unit/test_gitlab_http_methods.py index a65b53e61..0f0d5d3f9 100644 --- a/tests/unit/test_gitlab_http_methods.py +++ b/tests/unit/test_gitlab_http_methods.py @@ -1,8 +1,12 @@ +import copy +import warnings + import pytest import requests import responses from gitlab import GitlabHttpError, GitlabList, GitlabParsingError, RedirectError +from gitlab.client import RETRYABLE_TRANSIENT_ERROR_CODES from tests.unit import helpers MATCH_EMPTY_QUERY_PARAMS = [responses.matchers.query_param_matcher({})] @@ -51,7 +55,7 @@ def test_http_request_404(gl): @responses.activate -@pytest.mark.parametrize("status_code", [500, 502, 503, 504]) +@pytest.mark.parametrize("status_code", RETRYABLE_TRANSIENT_ERROR_CODES) def test_http_request_with_only_failures(gl, status_code): url = "http://localhost/api/v4/projects" responses.add( @@ -97,6 +101,46 @@ def request_callback(request): assert len(responses.calls) == calls_before_success +@responses.activate +@pytest.mark.parametrize( + "exception", + [ + requests.ConnectionError("Connection aborted."), + requests.exceptions.ChunkedEncodingError("Connection broken."), + ], +) +def test_http_request_with_retry_on_method_for_transient_network_failures( + gl, exception +): + call_count = 0 + calls_before_success = 3 + + url = "http://localhost/api/v4/projects" + + def request_callback(request): + nonlocal call_count + call_count += 1 + status_code = 200 + headers = {} + body = "[]" + + if call_count >= calls_before_success: + return (status_code, headers, body) + raise exception + + responses.add_callback( + method=responses.GET, + url=url, + callback=request_callback, + content_type="application/json", + ) + + http_r = gl.http_request("get", "/projects", retry_transient_errors=True) + + assert http_r.status_code == 200 + assert len(responses.calls) == calls_before_success + + @responses.activate def test_http_request_with_retry_on_class_for_transient_failures(gl_retry): call_count = 0 @@ -126,6 +170,37 @@ def request_callback(request: requests.models.PreparedRequest): assert len(responses.calls) == calls_before_success +@responses.activate +def test_http_request_with_retry_on_class_for_transient_network_failures(gl_retry): + call_count = 0 + calls_before_success = 3 + + url = "http://localhost/api/v4/projects" + + def request_callback(request: requests.models.PreparedRequest): + nonlocal call_count + call_count += 1 + status_code = 200 + headers = {} + body = "[]" + + if call_count >= calls_before_success: + return (status_code, headers, body) + raise requests.ConnectionError("Connection aborted.") + + responses.add_callback( + method=responses.GET, + url=url, + callback=request_callback, + content_type="application/json", + ) + + http_r = gl_retry.http_request("get", "/projects", retry_transient_errors=True) + + assert http_r.status_code == 200 + assert len(responses.calls) == calls_before_success + + @responses.activate def test_http_request_with_retry_on_class_and_method_for_transient_failures(gl_retry): call_count = 0 @@ -155,6 +230,39 @@ def request_callback(request): assert len(responses.calls) == 1 +@responses.activate +def test_http_request_with_retry_on_class_and_method_for_transient_network_failures( + gl_retry, +): + call_count = 0 + calls_before_success = 3 + + url = "http://localhost/api/v4/projects" + + def request_callback(request): + nonlocal call_count + call_count += 1 + status_code = 200 + headers = {} + body = "[]" + + if call_count >= calls_before_success: + return (status_code, headers, body) + raise requests.ConnectionError("Connection aborted.") + + responses.add_callback( + method=responses.GET, + url=url, + callback=request_callback, + content_type="application/json", + ) + + with pytest.raises(requests.ConnectionError): + gl_retry.http_request("get", "/projects", retry_transient_errors=False) + + assert len(responses.calls) == 1 + + def create_redirect_response( *, response: requests.models.Response, http_method: str, api_path: str ) -> requests.models.Response: @@ -329,13 +437,15 @@ def test_list_request(gl): match=MATCH_EMPTY_QUERY_PARAMS, ) - result = gl.http_list("/projects", as_list=True) + with warnings.catch_warnings(record=True) as caught_warnings: + result = gl.http_list("/projects", as_list=True) + assert len(caught_warnings) == 0 assert isinstance(result, list) assert len(result) == 1 result = gl.http_list("/projects", as_list=False) assert isinstance(result, GitlabList) - assert len(result) == 1 + assert len(list(result)) == 1 result = gl.http_list("/projects", all=True) assert isinstance(result, list) @@ -343,6 +453,99 @@ def test_list_request(gl): assert responses.assert_call_count(url, 3) is True +large_list_response = { + "method": responses.GET, + "url": "http://localhost/api/v4/projects", + "json": [ + {"name": "project01"}, + {"name": "project02"}, + {"name": "project03"}, + {"name": "project04"}, + {"name": "project05"}, + {"name": "project06"}, + {"name": "project07"}, + {"name": "project08"}, + {"name": "project09"}, + {"name": "project10"}, + {"name": "project11"}, + {"name": "project12"}, + {"name": "project13"}, + {"name": "project14"}, + {"name": "project15"}, + {"name": "project16"}, + {"name": "project17"}, + {"name": "project18"}, + {"name": "project19"}, + {"name": "project20"}, + ], + "headers": {"X-Total": "30", "x-per-page": "20"}, + "status": 200, + "match": MATCH_EMPTY_QUERY_PARAMS, +} + + +@responses.activate +def test_list_request_pagination_warning(gl): + responses.add(**large_list_response) + + with warnings.catch_warnings(record=True) as caught_warnings: + result = gl.http_list("/projects", as_list=True) + assert len(caught_warnings) == 1 + warning = caught_warnings[0] + assert isinstance(warning.message, UserWarning) + message = str(warning.message) + assert "Calling a `list()` method" in message + assert "python-gitlab.readthedocs.io" in message + assert __file__ == warning.filename + assert isinstance(result, list) + assert len(result) == 20 + assert len(responses.calls) == 1 + + +@responses.activate +def test_list_request_as_list_false_nowarning(gl): + responses.add(**large_list_response) + with warnings.catch_warnings(record=True) as caught_warnings: + result = gl.http_list("/projects", as_list=False) + assert len(caught_warnings) == 0 + assert isinstance(result, GitlabList) + assert len(list(result)) == 20 + assert len(responses.calls) == 1 + + +@responses.activate +def test_list_request_all_true_nowarning(gl): + responses.add(**large_list_response) + with warnings.catch_warnings(record=True) as caught_warnings: + result = gl.http_list("/projects", all=True) + assert len(caught_warnings) == 0 + assert isinstance(result, list) + assert len(result) == 20 + assert len(responses.calls) == 1 + + +@responses.activate +def test_list_request_all_false_nowarning(gl): + responses.add(**large_list_response) + with warnings.catch_warnings(record=True) as caught_warnings: + result = gl.http_list("/projects", all=False) + assert len(caught_warnings) == 0 + assert isinstance(result, list) + assert len(result) == 20 + assert len(responses.calls) == 1 + + +@responses.activate +def test_list_request_page_nowarning(gl): + response_dict = copy.deepcopy(large_list_response) + response_dict["match"] = [responses.matchers.query_param_matcher({"page": "1"})] + responses.add(**response_dict) + with warnings.catch_warnings(record=True) as caught_warnings: + gl.http_list("/projects", page=1) + assert len(caught_warnings) == 0 + assert len(responses.calls) == 1 + + @responses.activate def test_list_request_404(gl): url = "http://localhost/api/v4/not_there" diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index b3249d1b0..ae192b4cb 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -30,8 +30,8 @@ def test_gitlab_attribute_get(): assert o._value is None -def test_csv_list_attribute_input(): - o = types.CommaSeparatedListAttribute() +def test_array_attribute_input(): + o = types.ArrayAttribute() o.set_from_cli("foo,bar,baz") assert o.get() == ["foo", "bar", "baz"] @@ -39,8 +39,8 @@ def test_csv_list_attribute_input(): assert o.get() == ["foo"] -def test_csv_list_attribute_empty_input(): - o = types.CommaSeparatedListAttribute() +def test_array_attribute_empty_input(): + o = types.ArrayAttribute() o.set_from_cli("") assert o.get() == [] @@ -48,27 +48,45 @@ def test_csv_list_attribute_empty_input(): assert o.get() == [] -def test_csv_list_attribute_get_for_api_from_cli(): - o = types.CommaSeparatedListAttribute() +def test_array_attribute_get_for_api_from_cli(): + o = types.ArrayAttribute() o.set_from_cli("foo,bar,baz") assert o.get_for_api() == "foo,bar,baz" -def test_csv_list_attribute_get_for_api_from_list(): - o = types.CommaSeparatedListAttribute(["foo", "bar", "baz"]) +def test_array_attribute_get_for_api_from_list(): + o = types.ArrayAttribute(["foo", "bar", "baz"]) assert o.get_for_api() == "foo,bar,baz" -def test_csv_list_attribute_get_for_api_from_int_list(): - o = types.CommaSeparatedListAttribute([1, 9, 7]) +def test_array_attribute_get_for_api_from_int_list(): + o = types.ArrayAttribute([1, 9, 7]) assert o.get_for_api() == "1,9,7" -def test_csv_list_attribute_does_not_split_string(): - o = types.CommaSeparatedListAttribute("foo") +def test_array_attribute_does_not_split_string(): + o = types.ArrayAttribute("foo") assert o.get_for_api() == "foo" +# CommaSeparatedListAttribute tests +def test_csv_string_attribute_get_for_api_from_cli(): + o = types.CommaSeparatedListAttribute() + o.set_from_cli("foo,bar,baz") + assert o.get_for_api() == "foo,bar,baz" + + +def test_csv_string_attribute_get_for_api_from_list(): + o = types.CommaSeparatedListAttribute(["foo", "bar", "baz"]) + assert o.get_for_api() == "foo,bar,baz" + + +def test_csv_string_attribute_get_for_api_from_int_list(): + o = types.CommaSeparatedListAttribute([1, 9, 7]) + assert o.get_for_api() == "1,9,7" + + +# LowercaseStringAttribute tests def test_lowercase_string_attribute_get_for_api(): o = types.LowercaseStringAttribute("FOO") assert o.get_for_api() == "foo" diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 9f909830d..7641c6979 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import json +import warnings from gitlab import utils @@ -76,3 +77,21 @@ def test_json_serializable(self): obj = utils.EncodedId("we got/a/path") assert '"we%20got%2Fa%2Fpath"' == json.dumps(obj) + + +class TestWarningsWrapper: + def test_warn(self): + warn_message = "short and stout" + warn_source = "teapot" + + with warnings.catch_warnings(record=True) as caught_warnings: + utils.warn(message=warn_message, category=UserWarning, source=warn_source) + assert len(caught_warnings) == 1 + warning = caught_warnings[0] + # File name is this file as it is the first file outside of the `gitlab/` path. + assert __file__ == warning.filename + assert warning.category == UserWarning + assert isinstance(warning.message, UserWarning) + assert warn_message in str(warning.message) + assert __file__ in str(warning.message) + assert warn_source == warning.source diff --git a/tox.ini b/tox.ini index 4d502be8e..4c197abaf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,8 @@ [tox] minversion = 1.6 skipsdist = True -envlist = py310,py39,py38,py37,pep8,black,twine-check,mypy,isort +skip_missing_interpreters = True +envlist = py310,py39,py38,py37,pep8,black,twine-check,mypy,isort,cz [testenv] passenv = GITLAB_IMAGE GITLAB_TAG PY_COLORS NO_COLOR FORCE_COLOR @@ -51,6 +52,13 @@ deps = -r{toxinidir}/requirements-lint.txt commands = pylint {posargs} gitlab/ +[testenv:cz] +basepython = python3 +envdir={toxworkdir}/lint +deps = -r{toxinidir}/requirements-lint.txt +commands = + cz check --rev-range 65ecadc..HEAD # cz is fast, check from first valid commit + [testenv:twine-check] basepython = python3 deps = -r{toxinidir}/requirements.txt