diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c974f3a45..899f8fabf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,11 +22,11 @@ jobs: sphinx: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: pip install tox - name: Build docs @@ -37,11 +37,11 @@ jobs: twine-check: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: pip install tox twine wheel - name: Check twine readme rendering diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d16f7fe09..7a0dd38dc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,12 +22,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@v5.6.0 + - uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - run: pip install --upgrade tox - name: Run commitizen (https://commitizen-tools.github.io/commitizen/) run: tox -e cz diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 05e21065c..f6f1c6229 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -15,6 +15,6 @@ jobs: action: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5.0.1 + - uses: dessant/lock-threads@v6.0.0 with: process-only: 'issues' diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 9fadeca81..993606750 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -29,10 +29,10 @@ jobs: pre_commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 - - uses: actions/setup-python@v5.6.0 + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - name: install tox run: pip install tox==3.26.0 - name: pre-commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 576c1befb..01829b244 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,14 +14,14 @@ jobs: id-token: write environment: pypi.org steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 with: fetch-depth: 0 token: ${{ secrets.RELEASE_GITHUB_TOKEN }} - name: Python Semantic Release id: release - uses: python-semantic-release/python-semantic-release@v10.2.0 + uses: python-semantic-release/python-semantic-release@v10.5.3 with: github_token: ${{ secrets.RELEASE_GITHUB_TOKEN }} @@ -32,7 +32,7 @@ jobs: if: steps.release.outputs.released == 'true' - name: Publish package distributions to GitHub Releases - uses: python-semantic-release/publish-action@v10.2.0 + uses: python-semantic-release/publish-action@v10.5.3 if: steps.release.outputs.released == 'true' with: - github_token: ${{ secrets.GITHUB_TOKEN }} + github_token: ${{ secrets.RELEASE_GITHUB_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 0b6cbe5db..167deac9b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -3,10 +3,12 @@ name: 'Close stale issues and PRs' on: schedule: - cron: '30 1 * * *' + workflow_dispatch: # For manual runs permissions: issues: write pull-requests: write + actions: write concurrency: group: lock @@ -15,7 +17,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v9.1.0 + - uses: actions/stale@v10.2.0 with: operations-per-run: 500 stale-issue-label: "stale" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 17d514b11..83867b546 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,6 @@ jobs: matrix: os: [ubuntu-latest] python: - - version: "3.9" - toxenv: py39,smoke - version: "3.10" toxenv: py310,smoke - version: "3.11" @@ -36,21 +34,21 @@ jobs: toxenv: py312,smoke - version: "3.13" toxenv: py313,smoke - - version: "3.14.0-alpha - 3.14" # SemVer's version range syntax + - version: "3.14" toxenv: py314,smoke include: - os: macos-latest python: - version: "3.13" - toxenv: py313,smoke + version: "3.14" + toxenv: py314,smoke - os: windows-latest python: - version: "3.13" - toxenv: py313,smoke + version: "3.14" + toxenv: py314,smoke steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.2.0 with: python-version: ${{ matrix.python.version }} - name: Install dependencies @@ -67,11 +65,11 @@ jobs: matrix: toxenv: [api_func_v4, cli_func_v4] steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: pip install tox - name: Run tests @@ -79,7 +77,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox -- --override-ini='log_cli=True' - name: Upload codecov coverage - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v6.0.0 with: files: ./coverage.xml flags: ${{ matrix.toxenv }} @@ -89,11 +87,11 @@ jobs: coverage: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: pip install tox - name: Run tests @@ -102,7 +100,7 @@ jobs: TOXENV: cover run: tox - name: Upload codecov coverage - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v6.0.0 with: files: ./coverage.xml flags: unit @@ -113,16 +111,16 @@ jobs: runs-on: ubuntu-latest name: Python wheel steps: - - uses: actions/checkout@v4.2.2 - - uses: actions/setup-python@v5.6.0 + - uses: actions/checkout@v6.0.2 + - uses: actions/setup-python@v6.2.0 with: - python-version: "3.13" + python-version: "3.14" - name: Install dependencies run: | pip install -r requirements-test.txt - name: Build package run: python -m build -o dist/ - - uses: actions/upload-artifact@v4.6.2 + - uses: actions/upload-artifact@v7.0.0 with: name: dist path: dist @@ -131,12 +129,12 @@ jobs: runs-on: ubuntu-latest needs: [dist] steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.2 - name: Set up Python - uses: actions/setup-python@v5.6.0 + uses: actions/setup-python@v6.2.0 with: - python-version: '3.13' - - uses: actions/download-artifact@v4.3.0 + python-version: '3.14' + - uses: actions/download-artifact@v8.0.1 with: name: dist path: dist diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b1094aa9a..8a396443b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: python:3.13 +image: python:3.14 stages: - build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f18249f20..9c2e37aeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,11 +3,11 @@ default_language_version: repos: - repo: https://github.com/psf/black - rev: 25.1.0 + rev: 26.3.1 hooks: - id: black - repo: https://github.com/commitizen-tools/commitizen - rev: v4.8.3 + rev: v4.13.9 hooks: - id: commitizen stages: [commit-msg] @@ -16,28 +16,28 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 6.0.1 + rev: 8.0.1 hooks: - id: isort - repo: https://github.com/pycqa/pylint - rev: v3.3.7 + rev: v4.0.5 hooks: - id: pylint additional_dependencies: - argcomplete==2.0.0 - - gql==3.5.0 + - gql==4.0.0 - httpx==0.27.2 - pytest==7.4.2 - requests==2.28.1 - requests-toolbelt==1.0.0 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.17.0 + rev: v1.20.0 hooks: - id: mypy args: [] additional_dependencies: - - gql==3.5.0 + - gql==4.0.0 - httpx==0.27.2 - jinja2==3.1.2 - pytest==7.4.2 @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 41.42.1 + rev: 43.104.4 hooks: - id: renovate-config-validator diff --git a/.readthedocs.yml b/.readthedocs.yml index 2d561b88b..facdbd3f9 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,7 +3,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.11" + python: "3.13" sphinx: configuration: docs/conf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cf99cd4..7692c338c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,288 @@ # CHANGELOG +All versions below are listed in reverse chronological order. + +## v8.2.0 (2026-03-28) + +### Documentation + +- **testing**: Document passing pytest options during local development + ([`e6669f9`](https://github.com/python-gitlab/python-gitlab/commit/e6669f96d662d310109afa4a61fe8dabbd780a4e)) + +### Features + +- **api**: Add support for project feature flags and feature flag user lists + ([`be68285`](https://github.com/python-gitlab/python-gitlab/commit/be68285793f35afc10a72b59da3fb24429631f54)) + +- **projects**: Add optional parameter to set approval rule on all protected branches. + ([`8d76028`](https://github.com/python-gitlab/python-gitlab/commit/8d76028a1ae3554527291dc98e6be041ff089ec5)) + + +## v8.1.0 (2026-02-28) + +### Bug Fixes + +- **types**: Add explicit submodule import for pyright compatibility + ([`84ad3fd`](https://github.com/python-gitlab/python-gitlab/commit/84ad3fd19cfbc236e962ac77c910571b0888c000)) + +### Features + +- **api**: Add support for order_by filter in runner jobs + ([`d5dc73d`](https://github.com/python-gitlab/python-gitlab/commit/d5dc73d0f82a15d96a956c1d868c253c9586e1bf)) + +- **api**: Add support for sort filter in runner jobs + ([`b117ee3`](https://github.com/python-gitlab/python-gitlab/commit/b117ee3d139f422e463ebeb5007517a2052af8a4)) + + +## v8.0.0 (2026-01-28) + +### Bug Fixes + +- Actually define project repr_attr + ([`4187a69`](https://github.com/python-gitlab/python-gitlab/commit/4187a69420dd7b2e60c2d833ab246aec745d35cb)) + +- File save start_branch as a body attribute + ([`1001d93`](https://github.com/python-gitlab/python-gitlab/commit/1001d934e8c36cc3b14408b46b41030bf705a294)) + +### Chores + +- **black**: Run black v26 on code + ([`4a8d82b`](https://github.com/python-gitlab/python-gitlab/commit/4a8d82bec8f09fa142e8134589a0f40ef4f9c3be)) + +- **precommit**: Update dependency black to v26 + ([`ad43b76`](https://github.com/python-gitlab/python-gitlab/commit/ad43b763acdcd8d7db832972921fb071ea0a826f)) + +### Features + +- **graphql**: Update to gql 4.0.0 + ([`6f0da67`](https://github.com/python-gitlab/python-gitlab/commit/6f0da671b4586b23232ae89d57622254fa8a7945)) + +### Breaking Changes + +- **graphql**: GraphQL.execute() no longer accepts graphql.Source + + +## v7.1.0 (2025-12-28) + +### Bug Fixes + +- **utils**: Prevent negative sleep time in rate limit retry + ([`4221195`](https://github.com/python-gitlab/python-gitlab/commit/422119576287de30e1b70411c7ab0bbe39231af7)) + +### Continuous Integration + +- **release**: Use the correct token for publish to GitHub + ([`614a74c`](https://github.com/python-gitlab/python-gitlab/commit/614a74c00f027f70b8e48a6b2a2ddcd3f823bffa)) + +### Features + +- **registry-protection**: Add support for registry protection rule deletion + ([`9dd62c3`](https://github.com/python-gitlab/python-gitlab/commit/9dd62c3f5bcf3e082c2733bd4edc068f993c22ec)) + +### Testing + +- **functional**: Update to GitLab 18.6 and resolve issues found + ([`c7c139b`](https://github.com/python-gitlab/python-gitlab/commit/c7c139b9e7823ec1800a819233aee469355ee8d1)) + + +## v7.0.0 (2025-10-29) + +### Features + +- Drop Python 3.9 support and add Python 3.14 + ([`22941ac`](https://github.com/python-gitlab/python-gitlab/commit/22941acc3f331d5b683599c014ec962ece5d4b76)) + +### Breaking Changes + +- As of python-gitlab 7.0.0, Python 3.9 is no longer supported. Python 3.10 or higher is required. + + +## v6.5.0 (2025-10-17) + +### Bug Fixes + +- **semantic-release**: Enable CHANGELOG.md generation + ([`fb9693b`](https://github.com/python-gitlab/python-gitlab/commit/fb9693bf1e6798149196e57fed87bf2588ad3b47)) + +### Continuous Integration + +- **stale**: Fix permission for stale action and allow manual run + ([`9357a37`](https://github.com/python-gitlab/python-gitlab/commit/9357a374702dcc8049a6d8af636f48c736d3f160)) + +### Documentation + +- **pull_mirror**: Fix incorrect start() method usage example + ([`2acac19`](https://github.com/python-gitlab/python-gitlab/commit/2acac19356c8624def90c7e54237b256bceece18)) + +### Features + +- **api**: Add content_ref and dry_run_ref parameters to ProjectCiLintManager + ([`e8d2538`](https://github.com/python-gitlab/python-gitlab/commit/e8d2538cdf85a7c57babbc00074efbdab97548cd)) + +- **users**: Implement 'skip_confirmation' in users 'emails' creation + ([`2981730`](https://github.com/python-gitlab/python-gitlab/commit/298173017be387f26aa0828cae1e9a48e3cce328)) + + +## v6.4.0 (2025-09-28) + +### Features + +- **users**: Implement missing arguments in users 'list' + ([`99923d4`](https://github.com/python-gitlab/python-gitlab/commit/99923d40dcb4f32f02bcbc5e8ef5ec4b77e3cb02)) + +- **users**: Sort 'user list' arguments against documentation + ([`99923d4`](https://github.com/python-gitlab/python-gitlab/commit/99923d40dcb4f32f02bcbc5e8ef5ec4b77e3cb02)) + + +## v6.3.0 (2025-08-28) + +### Features + +- Add sync method to force remote mirror updates + ([`f3c6678`](https://github.com/python-gitlab/python-gitlab/commit/f3c6678482b7ca35b1dd1e3bc49fc0c56cd03639)) + +- **api**: Add missing ProjectJob list filters + ([`5fe0e71`](https://github.com/python-gitlab/python-gitlab/commit/5fe0e715448b00a666f76cdce6db321686f6a271)) + +- **api**: Add missing ProjectPackageManager list filters + ([`b1696be`](https://github.com/python-gitlab/python-gitlab/commit/b1696be5fb223028755e303069e23e42a11cab42)) + +- **users**: Implement support for 'admins' in administrators 'list' + ([`aaed51c`](https://github.com/python-gitlab/python-gitlab/commit/aaed51cdec65c8acabe8b9a39fd18c7e1e48519c)) + + +## v6.2.0 (2025-07-28) + +### Build System + +- **release**: Use correct python-semantic-release/publish-action + ([`2f20634`](https://github.com/python-gitlab/python-gitlab/commit/2f20634b9700bc802177278ffdd7bdf83ef1605a)) + +### Continuous Integration + +- **stale**: Improve formatting of stale message + ([`0ef20d1`](https://github.com/python-gitlab/python-gitlab/commit/0ef20d1b0ee6cd82c4e34003aca4c0c72935f4d9)) + +- **stale**: Increase `operations-per-run` to 500 + ([`326e1a4`](https://github.com/python-gitlab/python-gitlab/commit/326e1a46881467f41dc3de5f060ac654924fbe40)) + +### Features + +- **api**: Add ListMixin to ProjectIssueDiscussionNoteManager + ([`f908f0e`](https://github.com/python-gitlab/python-gitlab/commit/f908f0e82840a5df374e8fbfb1298608d23f02bd)) + +- **api**: Add ListMixin to ProjectMergeRequestDiscussionNoteManager + ([`865339a`](https://github.com/python-gitlab/python-gitlab/commit/865339ac037fb125280180b05a2c4e44067dc5e9)) + + +## v6.1.0 (2025-06-28) + +### Chores + +- Update to mypy 1.16.0 and resolve issues found + ([`f734c58`](https://github.com/python-gitlab/python-gitlab/commit/f734c586e3fe5a0e866bcf60030107ca142fa763)) + +### Documentation + +- Update CONTRIBUTING.rst with policy on issue management + ([`45dda50`](https://github.com/python-gitlab/python-gitlab/commit/45dda50ff4c0e01307480befa86498600563f818)) + +### Features + +- **api**: Add listing user contributed projects + ([`98c1307`](https://github.com/python-gitlab/python-gitlab/commit/98c13074127ae46d85545498746d55c8b75aef48)) + +- **api**: Add support for project tag list filters + ([`378a836`](https://github.com/python-gitlab/python-gitlab/commit/378a836bf5744ca6c9409dd60899e5d2f90b55be)) + +- **api**: Pipeline inputs support + ([#3194](https://github.com/python-gitlab/python-gitlab/pull/3194), + [`306c4b1`](https://github.com/python-gitlab/python-gitlab/commit/306c4b1931e2b03d7cbcef5773668e876d5644b1)) + +- **const**: Add PLANNER_ACCESS constant + ([`ba6f174`](https://github.com/python-gitlab/python-gitlab/commit/ba6f174896f908ba711e1e3e8ebf4692c86bd3d4)) + +- **groups**: Add protectedbranches to group class + ([#3164](https://github.com/python-gitlab/python-gitlab/pull/3164), + [`bfd31a8`](https://github.com/python-gitlab/python-gitlab/commit/bfd31a867547dffb2c2d54127e184fefa058cb30)) + + +## v6.0.0 (2025-06-04) + +### Chores + +- Add reformat code commit to .git-blame-ignore-revs + ([`a6ac939`](https://github.com/python-gitlab/python-gitlab/commit/a6ac9392b0e543df32adf9058f9808d199149982)) + +- Reformat code with skip_magic_trailing_comma = true + ([`2100aa4`](https://github.com/python-gitlab/python-gitlab/commit/2100aa458ba4f1c084bc97b00f7ba1693541d68a)) + +- Remove trivial get methods in preparation for generic Get mixin + ([`edd01a5`](https://github.com/python-gitlab/python-gitlab/commit/edd01a57aa8c45e6514e618263003beaa0fb68e8)) + +- Upgrade to sphinx 8.2.1 and resolve issues + ([`d0b5ae3`](https://github.com/python-gitlab/python-gitlab/commit/d0b5ae36bd0bc06f1f338adbd93d76a83a0fa459)) + +- **ci**: Replace docs artifact with readthedocs previews + ([`193c5de`](https://github.com/python-gitlab/python-gitlab/commit/193c5de9b443193da3f87d664a777f056d920146)) + +### Documentation + +- Use get_all keyword arg instead of all in docstrings + ([`f62dda7`](https://github.com/python-gitlab/python-gitlab/commit/f62dda7fa44e3bc46f03bd6402eba3f641f365eb)) + +- Use list(get_all=True) in documentation examples + ([`f36614f`](https://github.com/python-gitlab/python-gitlab/commit/f36614f1ce5a873ed1bbb8618ced39fa80f13ee6)) + +- **api-usage**: Fix GitLab API links to the publicly accessible URLs + ([`f55fa15`](https://github.com/python-gitlab/python-gitlab/commit/f55fa152cdccc0dd4815f17df9ff80628115667d)) + +- **api-usage-graphql**: Fix the example graphql query string + ([`8dbdd7e`](https://github.com/python-gitlab/python-gitlab/commit/8dbdd7e75447d01a457ac55f18066ebd355e4dbf)) + +- **job_token_scope**: Fix typo/inconsistency + ([`203bd92`](https://github.com/python-gitlab/python-gitlab/commit/203bd92e524845a3e1287439d78c167133347a69)) + +### Features + +- Adds member role methods + ([`055557e`](https://github.com/python-gitlab/python-gitlab/commit/055557efe9de9d4ab7b8237f7de7e033a6b02cd9)) + +- **api**: Add iteration_id as boards create attribute + ([#3191](https://github.com/python-gitlab/python-gitlab/pull/3191), + [`938b0d9`](https://github.com/python-gitlab/python-gitlab/commit/938b0d9c188bcffc6759184325bf292131307556)) + +- **api**: Add support for adding instance deploy keys + ([`22be96c`](https://github.com/python-gitlab/python-gitlab/commit/22be96cbe698f5d8b18be388edf9b01d6008d1dd)) + +- **api**: Add support for avatar removal + ([`5edd2e6`](https://github.com/python-gitlab/python-gitlab/commit/5edd2e66cd5d4cd48fcf5f996d4ad4c3d495b3fa)) + +- **api**: Add support for token self-rotation + ([`da40e09`](https://github.com/python-gitlab/python-gitlab/commit/da40e09498277467878b810aa44f86b48813d832)) + +- **api**: ListMixin.list typing overload + ([`6eee494`](https://github.com/python-gitlab/python-gitlab/commit/6eee494749ccc5387a0d3af7ce331cfb1e95308b)) + +- **api**: Make RESTManager generic on RESTObject class + ([`91c4f18`](https://github.com/python-gitlab/python-gitlab/commit/91c4f18dc49a7eed101ce5f004f396436c6ef7eb)) + +- **api**: Make RESTObjectList typing generic + ([`befba35`](https://github.com/python-gitlab/python-gitlab/commit/befba35a16c5543c5f270996a9bf7a4277482915)) + +- **settings**: Implement support for 'silent_mode_enabled' + ([`a9163a9`](https://github.com/python-gitlab/python-gitlab/commit/a9163a9775b3f9a7b729048fab83bb0bca7228b5)) + +### Refactoring + +- Use more python3.9 syntax + ([`4e90c11`](https://github.com/python-gitlab/python-gitlab/commit/4e90c113f1af768b8b049f4a64c5978a1bfbf323)) + +### Testing + +- **functional**: Switch to new runner registration API + ([`cbc613d`](https://github.com/python-gitlab/python-gitlab/commit/cbc613d0f2ccd8ec021bf789b337104489a3e5f1)) + ## v5.6.0 (2025-01-28) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 9b07ada11..4da710499 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -194,6 +194,32 @@ To cleanup the environment delete the container: docker rm -f gitlab-test docker rm -f gitlab-runner-test +Pass options to ``pytest`` +-------------------------- + +Options to ``pytest`` can be passed by adding them after ``--`` when running ``tox``: + +.. code-block:: bash + + tox -e api_func_v4 -- . + +For example, you can use this to run a specific test. Running all tests can be time-consuming, +so this allows you to focus on just the tests relevant to your changes. You can do this by passing +the ``-k`` flag to ``pytest`` and setting a relevant expression to select the tests to run. For example: + +.. code-block:: bash + + # Run all API functional tests from the ``test_projects.py`` file: + tox -e api_func_v4 -- --keep-containers -k test_projects.py + + # Run only the ``test_get_project`` test method from the ``test_projects.py`` file: + tox -e api_func_v4 -- --keep-containers -k "test_projects.py and test_create_project" + + # The above will select all test methods start with ``test_create_project`` from the ``test_projects.py`` file. + # To select only the ``test_create_project`` method, you can exclude other methods by using the ``not`` operator: + tox -e api_func_v4 -- --keep-containers -k "test_projects.py and test_create_project and not test_create_project_" + + Rerunning failed CI workflows ----------------------------- diff --git a/README.rst b/README.rst index 101add1eb..1b7b7ce48 100644 --- a/README.rst +++ b/README.rst @@ -53,7 +53,7 @@ Features Installation ------------ -As of 5.0.0, ``python-gitlab`` is compatible with Python 3.9+. +As of 7.0.0, ``python-gitlab`` is compatible with Python 3.10+. Use ``pip`` to install the latest stable version of ``python-gitlab``: diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 7218518b1..7107107c2 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -24,7 +24,7 @@ API examples gl_objects/environments gl_objects/events gl_objects/epics - gl_objects/features + gl_objects/gitlab_features gl_objects/geo_nodes gl_objects/groups gl_objects/group_access_tokens @@ -49,6 +49,8 @@ API examples gl_objects/pipelines_and_jobs gl_objects/projects gl_objects/project_access_tokens + gl_objects/project_feature_flags + gl_objects/project_feature_flag_user_lists gl_objects/protected_branches gl_objects/protected_container_repositories gl_objects/protected_environments diff --git a/docs/gl_objects/ci_lint.rst b/docs/gl_objects/ci_lint.rst index b44b09486..69a403eac 100644 --- a/docs/gl_objects/ci_lint.rst +++ b/docs/gl_objects/ci_lint.rst @@ -46,6 +46,18 @@ Lint a project's CI configuration:: assert lint_result.valid is True # Test that the .gitlab-ci.yml is valid print(lint_result.merged_yaml) # Print the merged YAML file +Lint a project's CI configuration from a specific branch or tag:: + + lint_result = project.ci_lint.get(content_ref="main") + assert lint_result.valid is True # Test that the .gitlab-ci.yml is valid + print(lint_result.merged_yaml) # Print the merged YAML file + +Lint a project's CI configuration with dry run simulation:: + + lint_result = project.ci_lint.get(dry_run=True, dry_run_ref="develop") + assert lint_result.valid is True # Test that the .gitlab-ci.yml is valid + print(lint_result.merged_yaml) # Print the merged YAML file + Lint a CI YAML configuration with a namespace:: lint_result = project.ci_lint.create({"content": gitlab_ci_yml}) diff --git a/docs/gl_objects/features.rst b/docs/gl_objects/gitlab_features.rst similarity index 64% rename from docs/gl_objects/features.rst rename to docs/gl_objects/gitlab_features.rst index d7552041d..7df506b09 100644 --- a/docs/gl_objects/features.rst +++ b/docs/gl_objects/gitlab_features.rst @@ -1,6 +1,11 @@ -############## -Features flags -############## +################################ +GitLab Development Feature Flags +################################ + +.. note:: + + This API is for managing GitLab's internal development feature flags and requires administrator access. + For project-level feature flags, see :doc:`project_feature_flags`. Reference --------- @@ -29,4 +34,4 @@ Create or set a feature:: Delete a feature:: - feature.delete() + feature.delete() \ No newline at end of file diff --git a/docs/gl_objects/project_feature_flag_user_lists.rst b/docs/gl_objects/project_feature_flag_user_lists.rst new file mode 100644 index 000000000..9e25ceb44 --- /dev/null +++ b/docs/gl_objects/project_feature_flag_user_lists.rst @@ -0,0 +1,51 @@ +############################### +Project Feature Flag User Lists +############################### + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserList` + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserListManager` + + :attr:`gitlab.v4.objects.Project.feature_flags_user_lists` + +* GitLab API: https://docs.gitlab.com/api/feature_flag_user_lists + +Examples +-------- + +List user lists:: + + user_lists = project.feature_flags_user_lists.list() + +Get a user list:: + + user_list = project.feature_flags_user_lists.get(list_iid) + +Create a user list:: + + user_list = project.feature_flags_user_lists.create({ + 'name': 'my_user_list', + 'user_xids': 'user1,user2,user3' + }) + +Update a user list:: + + user_list.name = 'updated_list_name' + user_list.user_xids = 'user1,user2' + user_list.save() + +Delete a user list:: + + user_list.delete() + +Search for a user list:: + + user_lists = project.feature_flags_user_lists.list(search='my_list') + +See also +-------- + +* :doc:`project_feature_flags` diff --git a/docs/gl_objects/project_feature_flags.rst b/docs/gl_objects/project_feature_flags.rst new file mode 100644 index 000000000..c9630f3e6 --- /dev/null +++ b/docs/gl_objects/project_feature_flags.rst @@ -0,0 +1,63 @@ +##################### +Project Feature Flags +##################### + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlag` + + :class:`gitlab.v4.objects.ProjectFeatureFlagManager` + + :attr:`gitlab.v4.objects.Project.feature_flags` + +* GitLab API: https://docs.gitlab.com/api/feature_flags + +Examples +-------- + +List feature flags:: + + flags = project.feature_flags.list() + +Get a feature flag:: + + flag = project.feature_flags.get('my_feature_flag') + +Create a feature flag:: + + flag = project.feature_flags.create({'name': 'my_feature_flag', 'version': 'new_version_flag'}) + +Create a feature flag with strategies:: + + flag = project.feature_flags.create({ + 'name': 'my_complex_flag', + 'version': 'new_version_flag', + 'strategies': [{ + 'name': 'userWithId', + 'parameters': {'userIds': 'user1,user2'} + }] + }) + +Update a feature flag:: + + flag.description = 'Updated description' + flag.save() + +Rename a feature flag:: + + # You can rename a flag by changing its name attribute and calling save() + flag.name = 'new_flag_name' + flag.save() + + # Alternatively, you can use the manager's update method + project.feature_flags.update('old_flag_name', {'name': 'new_flag_name'}) + +Delete a feature flag:: + + flag.delete() + +See also +-------- + +* :doc:`project_feature_flag_user_lists` diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst index 8305a6b0b..824914cef 100644 --- a/docs/gl_objects/projects.rst +++ b/docs/gl_objects/projects.rst @@ -409,6 +409,44 @@ Search projects by custom attribute:: project.customattributes.set('type', 'internal') gl.projects.list(custom_attributes={'type': 'internal'}, get_all=True) +Project feature flags +===================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlag` + + :class:`gitlab.v4.objects.ProjectFeatureFlagManager` + + :attr:`gitlab.v4.objects.Project.feature_flags` + +* GitLab API: https://docs.gitlab.com/api/feature_flags + +Examples +-------- + +See :doc:`project_feature_flags`. + +Project feature flag user lists +=============================== + +Reference +--------- + +* v4 API: + + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserList` + + :class:`gitlab.v4.objects.ProjectFeatureFlagUserListManager` + + :attr:`gitlab.v4.objects.Project.feature_flags_user_lists` + +* GitLab API: https://docs.gitlab.com/api/feature_flag_user_lists + +Examples +-------- + +See :doc:`project_feature_flag_user_lists`. + Project files ============= diff --git a/docs/gl_objects/pull_mirror.rst b/docs/gl_objects/pull_mirror.rst index bc83ba36d..19e8a1946 100644 --- a/docs/gl_objects/pull_mirror.rst +++ b/docs/gl_objects/pull_mirror.rst @@ -33,6 +33,6 @@ Update an existing remote mirror's attributes:: mirror.only_protected_branches = True mirror.save() -Start an sync of the pull mirror:: +Start a sync of the pull mirror:: - mirror.start() + project.pull_mirror.start() diff --git a/docs/gl_objects/remote_mirrors.rst b/docs/gl_objects/remote_mirrors.rst index b4610117d..c672ca5bb 100644 --- a/docs/gl_objects/remote_mirrors.rst +++ b/docs/gl_objects/remote_mirrors.rst @@ -36,3 +36,7 @@ Update an existing remote mirror's attributes:: Delete an existing remote mirror:: mirror.delete() + +Force push mirror update:: + + mirror.sync() diff --git a/gitlab/_version.py b/gitlab/_version.py index 2f7a85281..82b2161e7 100644 --- a/gitlab/_version.py +++ b/gitlab/_version.py @@ -3,4 +3,4 @@ __email__ = "gauvainpocentek@gmail.com" __license__ = "LGPL3" __title__ = "python-gitlab" -__version__ = "6.2.0" +__version__ = "8.2.0" diff --git a/gitlab/cli.py b/gitlab/cli.py index ca4734190..c804911a1 100644 --- a/gitlab/cli.py +++ b/gitlab/cli.py @@ -334,7 +334,7 @@ def main() -> None: # This first parsing step is used to find the gitlab config to use, and # load the propermodule (v3 or v4) accordingly. At that point we don't have # any subparser setup - (options, _) = parser.parse_known_args(sys.argv) + options, _ = parser.parse_known_args(sys.argv) try: config = gitlab.config.GitlabConfigParser(options.gitlab, options.config_file) except gitlab.config.ConfigError as e: diff --git a/gitlab/client.py b/gitlab/client.py index 37dd4c2e6..a3cf1f31a 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -18,7 +18,6 @@ try: import gql import gql.transport.exceptions - import graphql import httpx from ._backends.graphql import GitlabAsyncTransport, GitlabTransport @@ -1350,7 +1349,7 @@ def __enter__(self) -> GraphQL: def __exit__(self, *args: Any) -> None: self._http_client.close() - def execute(self, request: str | graphql.Source, *args: Any, **kwargs: Any) -> Any: + def execute(self, request: str, *args: Any, **kwargs: Any) -> Any: parsed_document = self._gql(request) retry = utils.Retry( max_retries=self._max_retries, @@ -1420,9 +1419,7 @@ async def __aenter__(self) -> AsyncGraphQL: async def __aexit__(self, *args: Any) -> None: await self._http_client.aclose() - async def execute( - self, request: str | graphql.Source, *args: Any, **kwargs: Any - ) -> Any: + async def execute(self, request: str, *args: Any, **kwargs: Any) -> Any: parsed_document = self._gql(request) retry = utils.Retry( max_retries=self._max_retries, diff --git a/gitlab/types.py b/gitlab/types.py index d0e8d3952..ae0aba707 100644 --- a/gitlab/types.py +++ b/gitlab/types.py @@ -1,8 +1,11 @@ from __future__ import annotations import dataclasses +import json from typing import Any, TYPE_CHECKING +from gitlab import exceptions + @dataclasses.dataclass(frozen=True) class RequiredOptional: @@ -36,6 +39,13 @@ def validate_attrs( class GitlabAttribute: + # Used in utils._transform_types() to decide if we should call get_for_api() + # on the attribute when transform_data is False (e.g. for POST/PUT/PATCH). + # + # This allows us to force transformation of data even when sending JSON bodies, + # which is useful for types like CommaSeparatedStringAttribute. + transform_in_body = False + def __init__(self, value: Any = None) -> None: self._value = value @@ -49,6 +59,16 @@ def get_for_api(self, *, key: str) -> tuple[str, Any]: return (key, self._value) +class JsonAttribute(GitlabAttribute): + def set_from_cli(self, cli_value: str) -> None: + try: + self._value = json.loads(cli_value) + except (ValueError, TypeError) as e: + raise exceptions.GitlabParsingError( + f"Could not parse JSON data: {e}" + ) from e + + class _ListArrayAttribute(GitlabAttribute): """Helper class to support `list` / `array` types.""" @@ -82,9 +102,23 @@ def get_for_api(self, *, key: str) -> tuple[str, Any]: 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""" + """ + For values which are sent to the server as a Comma Separated Values (CSV) string + in query parameters (GET), but as a list/array in JSON bodies (POST/PUT). + """ + + +class CommaSeparatedStringAttribute(_ListArrayAttribute): + """ + For values which are sent to the server as a Comma Separated Values (CSV) string. + Unlike CommaSeparatedListAttribute, this type ensures the value is converted + to a string even in JSON bodies (POST/PUT requests). + """ + + # Used in utils._transform_types() to ensure the value is converted to a string + # via get_for_api() even when transform_data is False (e.g. for POST/PUT/PATCH). + # This is needed because some APIs require a CSV string instead of a JSON array. + transform_in_body = True class LowercaseStringAttribute(GitlabAttribute): diff --git a/gitlab/utils.py b/gitlab/utils.py index bf37e09a5..49a280278 100644 --- a/gitlab/utils.py +++ b/gitlab/utils.py @@ -133,7 +133,7 @@ def handle_retry_on_status( if "Retry-After" in headers: wait_time = int(headers["Retry-After"]) elif "RateLimit-Reset" in headers: - wait_time = int(headers["RateLimit-Reset"]) - time.time() + wait_time = max(0, int(headers["RateLimit-Reset"]) - time.time()) self.cur_retries += 1 time.sleep(wait_time) return True @@ -198,7 +198,15 @@ def _transform_types( files[attr_name] = (key, data.pop(attr_name)) continue - if not transform_data: + # If transform_data is False, it means we are preparing data for a JSON body + # (POST/PUT/PATCH). In this case, we normally skip transformation because + # most types (like ArrayAttribute) only need transformation for query + # parameters (GET). + # + # However, some types (like CommaSeparatedStringAttribute) need to be + # transformed even in JSON bodies (e.g. converting a list to a CSV string). + # The 'transform_in_body' flag on the attribute class controls this behavior. + if not transform_data and not gitlab_attribute.transform_in_body: continue if isinstance(gitlab_attribute, types.GitlabAttribute): diff --git a/gitlab/v4/objects/__init__.py b/gitlab/v4/objects/__init__.py index cc2ffeb52..460297df7 100644 --- a/gitlab/v4/objects/__init__.py +++ b/gitlab/v4/objects/__init__.py @@ -24,6 +24,8 @@ from .epics import * from .events import * from .export_import import * +from .feature_flag_user_lists import * +from .feature_flags import * from .features import * from .files import * from .geo_nodes import * diff --git a/gitlab/v4/objects/ci_lint.py b/gitlab/v4/objects/ci_lint.py index 01d38373d..9bbe9f7e4 100644 --- a/gitlab/v4/objects/ci_lint.py +++ b/gitlab/v4/objects/ci_lint.py @@ -51,7 +51,14 @@ class ProjectCiLintManager( _path = "/projects/{project_id}/ci/lint" _obj_cls = ProjectCiLint _from_parent_attrs = {"project_id": "id"} - _optional_get_attrs = ("dry_run", "include_jobs", "ref") + _optional_get_attrs = ( + "content_ref", + "dry_run", + "dry_run_ref", + "include_jobs", + "ref", + ) + _create_attrs = RequiredOptional( required=("content",), optional=("dry_run", "include_jobs", "ref") ) diff --git a/gitlab/v4/objects/feature_flag_user_lists.py b/gitlab/v4/objects/feature_flag_user_lists.py new file mode 100644 index 000000000..50861715a --- /dev/null +++ b/gitlab/v4/objects/feature_flag_user_lists.py @@ -0,0 +1,27 @@ +""" +GitLab API: +https://docs.gitlab.com/api/feature_flag_user_lists +""" + +from __future__ import annotations + +from gitlab import types +from gitlab.base import RESTObject +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin +from gitlab.types import RequiredOptional + +__all__ = ["ProjectFeatureFlagUserList", "ProjectFeatureFlagUserListManager"] + + +class ProjectFeatureFlagUserList(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = "iid" + + +class ProjectFeatureFlagUserListManager(CRUDMixin[ProjectFeatureFlagUserList]): + _path = "/projects/{project_id}/feature_flags_user_lists" + _obj_cls = ProjectFeatureFlagUserList + _from_parent_attrs = {"project_id": "id"} + _create_attrs = RequiredOptional(required=("name", "user_xids")) + _update_attrs = RequiredOptional(optional=("name", "user_xids")) + _list_filters = ("search",) + _types = {"user_xids": types.CommaSeparatedStringAttribute} diff --git a/gitlab/v4/objects/feature_flags.py b/gitlab/v4/objects/feature_flags.py new file mode 100644 index 000000000..b34283b6c --- /dev/null +++ b/gitlab/v4/objects/feature_flags.py @@ -0,0 +1,106 @@ +""" +GitLab API: +https://docs.gitlab.com/api/feature_flags +""" + +from __future__ import annotations + +from typing import Any + +from gitlab import types, utils +from gitlab.base import RESTObject +from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin +from gitlab.types import RequiredOptional + +__all__ = ["ProjectFeatureFlag", "ProjectFeatureFlagManager"] + + +class ProjectFeatureFlag(SaveMixin, ObjectDeleteMixin, RESTObject): + _id_attr = "name" + manager: ProjectFeatureFlagManager + + def _get_save_url_id(self) -> str | int | None: + """Get the ID used to construct the API URL for the save operation. + + For renames, this must be the *original* name of the flag. For other + updates, it is the current name. + """ + if self._id_attr in self._updated_attrs: + # If the name is being changed, use the original name for the URL. + obj_id = self._attrs.get(self._id_attr) + if isinstance(obj_id, str): + return utils.EncodedId(obj_id) + return obj_id + return self.encoded_id + + def save(self, **kwargs: Any) -> dict[str, Any] | None: + """Save the changes made to the object to the server. + + This is the standard method to use when updating a feature flag object + that you have already retrieved. + + It is overridden here to correctly handle renaming. When `name` is + changed, the API requires the *original* name in the URL, and this + method provides it. + + Args: + **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 + """ + updated_data = self._get_updated_data() + if not updated_data: + return None + + obj_id = self._get_save_url_id() + server_data = self.manager.update(obj_id, updated_data, **kwargs) + self._update_attrs(server_data) + return server_data + + +class ProjectFeatureFlagManager(CRUDMixin[ProjectFeatureFlag]): + _path = "/projects/{project_id}/feature_flags" + _obj_cls = ProjectFeatureFlag + _from_parent_attrs = {"project_id": "id"} + _create_attrs = RequiredOptional( + required=("name",), optional=("version", "description", "active", "strategies") + ) + _update_attrs = RequiredOptional( + # new_name is used for renaming via CLI and mapped to 'name' in update() + optional=("name", "new_name", "description", "active", "strategies") + ) + _list_filters = ("scope",) + _types = {"strategies": types.JsonAttribute} + + def update( + self, + id: str | int | None = None, + new_data: dict[str, Any] | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + """Update a Project Feature Flag. + + This is a lower-level method called by `ProjectFeatureFlag.save()` and + is also used directly by the CLI. + + The `new_name` parameter is a special case to support renaming via the + CLI (`--new-name`). It is converted to the `name` parameter that the + GitLab API expects in the request body. + + Args: + id: The current name of the feature flag. + new_data: The dictionary of attributes to update. + **kwargs: Extra options to send to the server (e.g. sudo) + """ + # Avoid mutating the caller-provided new_data dict by working on a copy. + data = dict(new_data or {}) + # When used via CLI, we have 'new_name' to distinguish from the ID 'name'. + # When used via .save(), the object passes 'name' directly in new_data. + if "new_name" in data: + data["name"] = data.pop("new_name") + return super().update(id, data, **kwargs) diff --git a/gitlab/v4/objects/files.py b/gitlab/v4/objects/files.py index 757d16eeb..3bcf931a2 100644 --- a/gitlab/v4/objects/files.py +++ b/gitlab/v4/objects/files.py @@ -29,6 +29,7 @@ class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject): file_path: str manager: ProjectFileManager content: str # since the `decode()` method uses `self.content` + start_branch: str | None = None def decode(self) -> bytes: """Returns the decoded content of the file. @@ -41,7 +42,11 @@ def decode(self) -> bytes: # NOTE(jlvillal): Signature doesn't match SaveMixin.save() so ignore # type error def save( # type: ignore[override] - self, branch: str, commit_message: str, **kwargs: Any + self, + branch: str, + commit_message: str, + start_branch: str | None = None, + **kwargs: Any, ) -> None: """Save the changes made to the file to the server. @@ -50,6 +55,7 @@ def save( # type: ignore[override] Args: branch: Branch in which the file will be updated commit_message: Message to send with the commit + start_branch: Name of the branch to start the new branch from **kwargs: Extra options to send to the server (e.g. sudo) Raises: @@ -58,6 +64,7 @@ def save( # type: ignore[override] """ self.branch = branch self.commit_message = commit_message + self.start_branch = start_branch self.file_path = utils.EncodedId(self.file_path) super().save(**kwargs) diff --git a/gitlab/v4/objects/jobs.py b/gitlab/v4/objects/jobs.py index 6aa6fc460..f0062c989 100644 --- a/gitlab/v4/objects/jobs.py +++ b/gitlab/v4/objects/jobs.py @@ -346,5 +346,5 @@ class ProjectJobManager(RetrieveMixin[ProjectJob]): _path = "/projects/{project_id}/jobs" _obj_cls = ProjectJob _from_parent_attrs = {"project_id": "id"} - _list_filters = ("scope",) + _list_filters = ("scope", "order_by", "sort") _types = {"scope": ArrayAttribute} diff --git a/gitlab/v4/objects/merge_request_approvals.py b/gitlab/v4/objects/merge_request_approvals.py index 6ca324ecf..3d2082b91 100644 --- a/gitlab/v4/objects/merge_request_approvals.py +++ b/gitlab/v4/objects/merge_request_approvals.py @@ -90,7 +90,13 @@ class ProjectApprovalRuleManager( _from_parent_attrs = {"project_id": "id"} _create_attrs = RequiredOptional( required=("name", "approvals_required"), - optional=("user_ids", "group_ids", "protected_branch_ids", "usernames"), + optional=( + "user_ids", + "group_ids", + "protected_branch_ids", + "usernames", + "applies_to_all_protected_branches", + ), ) diff --git a/gitlab/v4/objects/packages.py b/gitlab/v4/objects/packages.py index 1a59c7ec7..99edd2f83 100644 --- a/gitlab/v4/objects/packages.py +++ b/gitlab/v4/objects/packages.py @@ -220,6 +220,9 @@ class GroupPackageManager(ListMixin[GroupPackage]): "sort", "package_type", "package_name", + "package_version", + "include_versionless", + "status", ) @@ -234,7 +237,15 @@ class ProjectPackageManager( _path = "/projects/{project_id}/packages" _obj_cls = ProjectPackage _from_parent_attrs = {"project_id": "id"} - _list_filters = ("order_by", "sort", "package_type", "package_name") + _list_filters = ( + "order_by", + "sort", + "package_type", + "package_name", + "package_version", + "include_versionless", + "status", + ) class ProjectPackageFile(ObjectDeleteMixin, RESTObject): diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index b415a8b98..22975ff9f 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -49,6 +49,8 @@ ) from .events import ProjectEventManager # noqa: F401 from .export_import import ProjectExportManager, ProjectImportManager # noqa: F401 +from .feature_flag_user_lists import ProjectFeatureFlagUserListManager # noqa: F401 +from .feature_flags import ProjectFeatureFlagManager # noqa: F401 from .files import ProjectFileManager # noqa: F401 from .hooks import ProjectHookManager # noqa: F401 from .integrations import ProjectIntegrationManager, ProjectServiceManager # noqa: F401 @@ -178,6 +180,8 @@ class Project( _repr_attr = "path_with_namespace" _upload_path = "/projects/{id}/uploads" + path_with_namespace: str + access_tokens: ProjectAccessTokenManager accessrequests: ProjectAccessRequestManager additionalstatistics: ProjectAdditionalStatisticsManager @@ -199,6 +203,8 @@ class Project( environments: ProjectEnvironmentManager events: ProjectEventManager exports: ProjectExportManager + feature_flags: ProjectFeatureFlagManager + feature_flags_user_lists: ProjectFeatureFlagUserListManager files: ProjectFileManager forks: ProjectForkManager generic_packages: GenericPackageManager @@ -1233,7 +1239,20 @@ def create(self, data: dict[str, Any] | None = None, **kwargs: Any) -> ProjectFo class ProjectRemoteMirror(ObjectDeleteMixin, SaveMixin, RESTObject): - pass + @cli.register_custom_action(cls_names="ProjectRemoteMirror") + @exc.on_http_error(exc.GitlabCreateError) + def sync(self, **kwargs: Any) -> dict[str, Any] | requests.Response: + """Force push mirror update. + + Args: + **kwargs: Extra options to send to the server (e.g. sudo) + + Raises: + GitlabAuthenticationError: If authentication is not correct + GitlabCreateError: If the server cannot perform the request + """ + path = f"{self.manager.path}/{self.encoded_id}/sync" + return self.manager.gitlab.http_post(path, **kwargs) class ProjectRemoteMirrorManager( diff --git a/gitlab/v4/objects/registry_protection_repository_rules.py b/gitlab/v4/objects/registry_protection_repository_rules.py index 19d4bdf59..2a457a024 100644 --- a/gitlab/v4/objects/registry_protection_repository_rules.py +++ b/gitlab/v4/objects/registry_protection_repository_rules.py @@ -1,5 +1,12 @@ from gitlab.base import RESTObject -from gitlab.mixins import CreateMixin, ListMixin, SaveMixin, UpdateMethod, UpdateMixin +from gitlab.mixins import ( + CreateMixin, + DeleteMixin, + ListMixin, + SaveMixin, + UpdateMethod, + UpdateMixin, +) from gitlab.types import RequiredOptional __all__ = [ @@ -16,6 +23,7 @@ class ProjectRegistryRepositoryProtectionRuleManager( ListMixin[ProjectRegistryRepositoryProtectionRule], CreateMixin[ProjectRegistryRepositoryProtectionRule], UpdateMixin[ProjectRegistryRepositoryProtectionRule], + DeleteMixin[ProjectRegistryRepositoryProtectionRule], ): _path = "/projects/{project_id}/registry/protection/repository/rules" _obj_cls = ProjectRegistryRepositoryProtectionRule diff --git a/gitlab/v4/objects/repositories.py b/gitlab/v4/objects/repositories.py index 71935caaa..a621cda43 100644 --- a/gitlab/v4/objects/repositories.py +++ b/gitlab/v4/objects/repositories.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: # When running mypy we use these as the base classes + import gitlab.base + _RestObjectBase = gitlab.base.RESTObject else: _RestObjectBase = object diff --git a/gitlab/v4/objects/runners.py b/gitlab/v4/objects/runners.py index e4a37e8e3..ba7256cf5 100644 --- a/gitlab/v4/objects/runners.py +++ b/gitlab/v4/objects/runners.py @@ -38,7 +38,7 @@ class RunnerJobManager(ListMixin[RunnerJob]): _path = "/runners/{runner_id}/jobs" _obj_cls = RunnerJob _from_parent_attrs = {"runner_id": "id"} - _list_filters = ("status",) + _list_filters = ("status", "order_by", "sort") class Runner(SaveMixin, ObjectDeleteMixin, RESTObject): diff --git a/gitlab/v4/objects/users.py b/gitlab/v4/objects/users.py index dec0b375d..be0e36529 100644 --- a/gitlab/v4/objects/users.py +++ b/gitlab/v4/objects/users.py @@ -400,16 +400,29 @@ class UserManager(CRUDMixin[User]): _obj_cls = User _list_filters = ( + "username", + "public_email", + "search", "active", + "external", "blocked", - "username", + "humans", + "created_after", + "created_before", + "exclude_active", + "exclude_external", + "exclude_humans", + "exclude_internal", + "without_project_bots", "extern_uid", "provider", - "external", - "search", + "two_factor", + "without_projects", + "admins", + "auditors", + "skip_ldap", "custom_attributes", "status", - "two_factor", ) _create_attrs = RequiredOptional( optional=( @@ -489,7 +502,9 @@ class UserEmailManager( _path = "/users/{user_id}/emails" _obj_cls = UserEmail _from_parent_attrs = {"user_id": "id"} - _create_attrs = RequiredOptional(required=("email",)) + _create_attrs = RequiredOptional( + required=("email",), optional=("skip_confirmation",) + ) class UserActivities(RESTObject): diff --git a/pyproject.toml b/pyproject.toml index 5104c2b16..45e8c36f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ maintainers = [ {name = "Nejc Habjan", email="nejc.habjan@siemens.com"}, {name = "Roger Meier", email="r.meier@siemens.com"} ] -requires-python = ">=3.9.0" +requires-python = ">=3.10.0" dependencies = [ "requests>=2.32.0", "requests-toolbelt>=1.0.0", @@ -30,11 +30,11 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] keywords = ["api", "client", "gitlab", "python", "python-gitlab", "wrapper"] license = {text = "LGPL-3.0-or-later"} @@ -43,7 +43,7 @@ dynamic = ["version"] [project.optional-dependencies] autocompletion = ["argcomplete>=1.10.0,<3"] yaml = ["PyYaml>=6.0.1"] -graphql = ["gql[httpx]>=3.5.0,<4"] +graphql = ["gql[httpx]>=3.5.0,<5"] [project.scripts] gitlab = "gitlab.cli:main" @@ -101,6 +101,13 @@ version_variables = [ ] commit_message = "chore: release v{version}" +[tool.semantic_release.changelog] +exclude_commit_patterns = [ + '''chore\(deps\): .+''', # Exclude dependency updates from the changelog +] +mode = "update" +insertion_flag = "All versions below are listed in reverse chronological order." + [tool.pylint.messages_control] max-line-length = 88 jobs = 0 # Use auto-detected number of multiple processes to speed up Pylint. diff --git a/requirements-docker.txt b/requirements-docker.txt index 532609b3f..123a4438a 100644 --- a/requirements-docker.txt +++ b/requirements-docker.txt @@ -1,3 +1,3 @@ -r requirements.txt -r requirements-test.txt -pytest-docker==3.2.3 +pytest-docker==3.2.5 diff --git a/requirements-docs.txt b/requirements-docs.txt index 39f5f61e2..1c445092a 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,7 +1,7 @@ -r requirements.txt -furo==2025.7.19 +furo==2025.12.19 jinja2==3.1.6 -myst-parser==4.0.1 -sphinx==8.2.3 +myst-parser==5.0.0 +sphinx==9.1.0 sphinxcontrib-autoprogram==0.1.9 -sphinx-autobuild==2024.10.3 +sphinx-autobuild==2025.8.25 diff --git a/requirements-lint.txt b/requirements-lint.txt index 73eb2fda0..504a381b7 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,14 +1,14 @@ -r requirements.txt argcomplete==2.0.0 -black==25.1.0 -commitizen==4.8.3 +black==26.3.1 +commitizen==4.13.9 flake8==7.3.0 -isort==6.0.1 -mypy==1.17.0 -pylint==3.3.7 -pytest==8.4.1 -responses==0.25.7 +isort==8.0.1 +mypy==1.20.0 +pylint==4.0.5 +pytest==9.0.2 +responses==0.26.0 respx==0.22.0 -types-PyYAML==6.0.12.20250516 -types-requests==2.32.4.20250611 -types-setuptools==80.9.0.20250529 +types-PyYAML==6.0.12.20250915 +types-requests==2.33.0.20260402 +types-setuptools==82.0.0.20260402 diff --git a/requirements-precommit.txt b/requirements-precommit.txt index d5c247795..fc2379223 100644 --- a/requirements-precommit.txt +++ b/requirements-precommit.txt @@ -1 +1 @@ -pre-commit==4.2.0 +pre-commit==4.5.1 diff --git a/requirements-test.txt b/requirements-test.txt index 26d3b35af..5eb57dfb8 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,13 +1,13 @@ -r requirements.txt -anyio==4.9.0 -build==1.2.2.post1 -coverage==7.9.2 +anyio==4.13.0 +build==1.4.2 +coverage==7.13.5 pytest-console-scripts==1.4.1 -pytest-cov==6.2.1 -pytest-github-actions-annotate-failures==0.3.0 -pytest==8.4.1 -PyYaml==6.0.2 -responses==0.25.7 +pytest-cov==7.1.0 +pytest-github-actions-annotate-failures==0.4.0 +pytest==9.0.2 +PyYaml==6.0.3 +responses==0.26.0 respx==0.22.0 -trio==0.30.0 -wheel==0.45.1 +trio==0.33.0 +wheel==0.46.3 diff --git a/requirements.txt b/requirements.txt index 7941900de..31ae12e35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -gql==3.5.3 +gql==4.0.0 httpx==0.28.1 -requests==2.32.4 +requests==2.33.1 requests-toolbelt==1.0.0 diff --git a/tests/functional/api/test_epics.py b/tests/functional/api/test_epics.py index a4f6765da..b61e23776 100644 --- a/tests/functional/api/test_epics.py +++ b/tests/functional/api/test_epics.py @@ -15,18 +15,20 @@ def test_epics(group): assert group.epics.list() -@pytest.mark.xfail(reason="404 on issue.id") def test_epic_issues(epic, issue): assert not epic.issues.list() + # FYI: Creating an issue causes a note to be created epic_issue = epic.issues.create({"issue_id": issue.id}) assert epic.issues.list() + # FYI: Deleting an issue causes a note to be created epic_issue.delete() def test_epic_notes(epic): - assert not epic.notes.list() + notes = epic.notes.list(get_all=True) epic.notes.create({"body": "Test note"}) - assert epic.notes.list() + new_notes = epic.notes.list(get_all=True) + assert len(new_notes) == (len(notes) + 1), f"{new_notes} {notes}" diff --git a/tests/functional/api/test_keys.py b/tests/functional/api/test_keys.py index 359649bef..6a2d660ed 100644 --- a/tests/functional/api/test_keys.py +++ b/tests/functional/api/test_keys.py @@ -38,6 +38,14 @@ def test_keys_deploy(gl, project, DEPLOY_KEY): key_by_fingerprint = gl.keys.get(fingerprint=fingerprint) assert key_by_fingerprint.title == key.title assert key_by_fingerprint.key == key.key - assert len(key_by_fingerprint.deploy_keys_projects) == 1 + + if not any( + key_project.get("project_id") == project.id + for key_project in key_by_fingerprint.deploy_keys_projects + ): + raise AssertionError( + f"Project {project} not found in 'deploy_keys_projects' " + f"{key_by_fingerprint.pformat()}" + ) key.delete() diff --git a/tests/functional/api/test_project_feature_flag_user_lists.py b/tests/functional/api/test_project_feature_flag_user_lists.py new file mode 100644 index 000000000..ecf7972f9 --- /dev/null +++ b/tests/functional/api/test_project_feature_flag_user_lists.py @@ -0,0 +1,56 @@ +import pytest + +from gitlab import exceptions + + +@pytest.fixture +def user_list(project, user): + user_list = project.feature_flags_user_lists.create( + {"name": "test_user_list", "user_xids": str(user.id)} + ) + yield user_list + try: + user_list.delete() + except exceptions.GitlabDeleteError: + pass + + +def test_create_user_list(project, user): + user_list = project.feature_flags_user_lists.create( + {"name": "created_user_list", "user_xids": str(user.id)} + ) + assert user_list.name == "created_user_list" + assert str(user.id) in user_list.user_xids + user_list.delete() + + +def test_list_user_lists(project, user_list): + ff_user_lists = project.feature_flags_user_lists.list() + assert len(ff_user_lists) >= 1 + assert user_list.iid in [ff_user.iid for ff_user in ff_user_lists] + + +def test_get_user_list(project, user_list, user): + retrieved_list = project.feature_flags_user_lists.get(user_list.iid) + assert retrieved_list.name == user_list.name + assert str(user.id) in retrieved_list.user_xids + + +def test_update_user_list(project, user_list): + user_list.name = "updated_user_list" + user_list.save() + + updated_list = project.feature_flags_user_lists.get(user_list.iid) + assert updated_list.name == "updated_user_list" + + +def test_delete_user_list(project, user_list): + user_list.delete() + with pytest.raises(exceptions.GitlabGetError): + project.feature_flags_user_lists.get(user_list.iid) + + +def test_search_user_list(project, user_list): + ff_user_lists = project.feature_flags_user_lists.list(search=user_list.name) + assert len(ff_user_lists) >= 1 + assert user_list.iid in [ff_user.iid for ff_user in ff_user_lists] diff --git a/tests/functional/api/test_project_feature_flags.py b/tests/functional/api/test_project_feature_flags.py new file mode 100644 index 000000000..f555dc3d4 --- /dev/null +++ b/tests/functional/api/test_project_feature_flags.py @@ -0,0 +1,127 @@ +import pytest + +from gitlab import exceptions + + +@pytest.fixture +def feature_flag(project): + flag_name = "test_flag_fixture" + flag = project.feature_flags.create( + {"name": flag_name, "version": "new_version_flag"} + ) + yield flag + try: + flag.delete() + except exceptions.GitlabDeleteError: + pass + + +def test_create_feature_flag(project): + flag_name = "test_flag_create" + flag = project.feature_flags.create( + {"name": flag_name, "version": "new_version_flag"} + ) + assert flag.name == flag_name + assert flag.active is True + flag.delete() + + +def test_create_feature_flag_with_strategies(project): + flag_name = "test_flag_strategies" + strategies = [{"name": "userWithId", "parameters": {"userIds": "user1"}}] + flag = project.feature_flags.create( + {"name": flag_name, "version": "new_version_flag", "strategies": strategies} + ) + assert len(flag.strategies) == 1 + assert flag.strategies[0]["name"] == "userWithId" + assert flag.strategies[0]["parameters"]["userIds"] == "user1" + flag.delete() + + +def test_list_feature_flags(project, feature_flag): + flags = project.feature_flags.list() + assert len(flags) >= 1 + assert feature_flag.name in [f.name for f in flags] + + +def test_update_feature_flag(project, feature_flag): + feature_flag.active = False + feature_flag.save() + + updated_flag = project.feature_flags.get(feature_flag.name) + assert updated_flag.active is False + + +def test_rename_feature_flag(project, feature_flag): + # Rename via save() + new_name = "renamed_flag" + feature_flag.name = new_name + feature_flag.save() + + updated_flag = project.feature_flags.get(new_name) + assert updated_flag.name == new_name + + # Rename via update() + newer_name = "renamed_flag_2" + project.feature_flags.update(new_name, {"name": newer_name}) + + updated_flag_2 = project.feature_flags.get(newer_name) + assert updated_flag_2.name == newer_name + + # Update the fixture object so teardown can delete the correct flag + feature_flag.name = newer_name + + +def test_delete_feature_flag(project, feature_flag): + feature_flag.delete() + with pytest.raises(exceptions.GitlabGetError): + project.feature_flags.get(feature_flag.name) + + +def test_delete_feature_flag_strategy(project, feature_flag): + strategies = [ + {"name": "default", "parameters": {}}, + {"name": "userWithId", "parameters": {"userIds": "user1"}}, + ] + feature_flag.strategies = strategies + feature_flag.save() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies) == 2 + + # Remove strategy using _destroy + updated_strategies = updated_feature_flag.strategies + for strategy in updated_strategies: + if strategy["name"] == "userWithId": + strategy["_destroy"] = True + updated_feature_flag.save() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies) == 1 + assert updated_feature_flag.strategies[0]["name"] == "default" + + +def test_delete_feature_flag_scope(project, feature_flag): + strategies = [ + { + "name": "default", + "parameters": {}, + "scopes": [{"environment_scope": "*"}, {"environment_scope": "production"}], + } + ] + feature_flag.strategies = strategies + feature_flag.save() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies[0]["scopes"]) == 2 + + # Remove scope using _destroy + updated_strategies = updated_feature_flag.strategies + for scope in updated_strategies[0]["scopes"]: + if scope["environment_scope"] == "production": + scope["_destroy"] = True + updated_feature_flag.save() + + updated_feature_flag = project.feature_flags.get(feature_flag.name) + assert len(updated_feature_flag.strategies[0]["scopes"]) == 1 + assert updated_feature_flag.strategies[0]["scopes"][0]["environment_scope"] == "*" diff --git a/tests/functional/api/test_project_job_token_scope.py b/tests/functional/api/test_project_job_token_scope.py index 0d0466182..b1de0a7b2 100644 --- a/tests/functional/api/test_project_job_token_scope.py +++ b/tests/functional/api/test_project_job_token_scope.py @@ -1,3 +1,6 @@ +import pytest + + # https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html#allow-any-project-to-access-your-project def test_enable_limit_access_to_this_project(gl, project): scope = project.job_token_scope.get() @@ -10,6 +13,7 @@ def test_enable_limit_access_to_this_project(gl, project): assert scope.inbound_enabled +@pytest.mark.xfail(reason="https://gitlab.com/gitlab-org/gitlab/-/issues/582271") def test_disable_limit_access_to_this_project(gl, project): scope = project.job_token_scope.get() diff --git a/tests/functional/api/test_projects.py b/tests/functional/api/test_projects.py index 760f95336..c56b23ec7 100644 --- a/tests/functional/api/test_projects.py +++ b/tests/functional/api/test_projects.py @@ -26,9 +26,9 @@ def test_create_project(gl, user): sudo_project = gl.projects.create({"name": "sudo_project"}, sudo=user.id) - created = gl.projects.list() + created = gl.projects.list(get_all=True) created_gen = gl.projects.list(iterator=True) - owned = gl.projects.list(owned=True) + owned = gl.projects.list(owned=True, get_all=True) assert admin_project in created and sudo_project in created assert admin_project in owned and sudo_project not in owned diff --git a/tests/functional/api/test_registry.py b/tests/functional/api/test_registry.py index 91fdceacc..d234128ca 100644 --- a/tests/functional/api/test_registry.py +++ b/tests/functional/api/test_registry.py @@ -26,3 +26,8 @@ def test_project_protected_registry(project: Project): protected_registry.minimum_access_level_for_push = "owner" protected_registry.save() assert protected_registry.minimum_access_level_for_push == "owner" + + protected_registry.delete() + + rules = project.registry_protection_repository_rules.list() + assert rules == [] diff --git a/tests/functional/cli/test_cli_artifacts.py b/tests/functional/cli/test_cli_artifacts.py index 589486844..9fe4d01ef 100644 --- a/tests/functional/cli/test_cli_artifacts.py +++ b/tests/functional/cli/test_cli_artifacts.py @@ -7,14 +7,12 @@ import pytest -content = textwrap.dedent( - """\ +content = textwrap.dedent("""\ test-artifact: script: echo "test" > artifact.txt artifacts: untracked: true - """ -) + """) data = { "file_path": ".gitlab-ci.yml", "branch": "main", diff --git a/tests/functional/cli/test_cli_project_feature_flag_user_lists.py b/tests/functional/cli/test_cli_project_feature_flag_user_lists.py new file mode 100644 index 000000000..96e48379e --- /dev/null +++ b/tests/functional/cli/test_cli_project_feature_flag_user_lists.py @@ -0,0 +1,120 @@ +import json + +import pytest + + +@pytest.fixture +def user_list_cli(gitlab_cli, project, user): + list_name = "cli_test_list_fixture" + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "create", + "--project-id", + str(project.id), + "--name", + list_name, + "--user-xids", + str(user.id), + ] + ret = gitlab_cli(cmd) + data = json.loads(ret.stdout) + iid = str(data["iid"]) + + yield iid + + try: + cmd = [ + "project-feature-flag-user-list", + "delete", + "--project-id", + str(project.id), + "--iid", + iid, + ] + gitlab_cli(cmd) + except Exception: + pass + + +def test_project_feature_flag_user_list_cli_create_delete(gitlab_cli, project, user): + list_name = "cli_test_list_create" + + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "create", + "--project-id", + str(project.id), + "--name", + list_name, + "--user-xids", + str(user.id), + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["name"] == list_name + assert str(user.id) in data["user_xids"] + iid = str(data["iid"]) + + cmd = [ + "project-feature-flag-user-list", + "delete", + "--project-id", + str(project.id), + "--iid", + iid, + ] + ret = gitlab_cli(cmd) + assert ret.success + + +def test_project_feature_flag_user_list_cli_list(gitlab_cli, project, user_list_cli): + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "list", + "--project-id", + str(project.id), + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert any(item["name"] == "cli_test_list_fixture" for item in data) + + +def test_project_feature_flag_user_list_cli_get(gitlab_cli, project, user_list_cli): + cmd = [ + "-o", + "json", + "project-feature-flag-user-list", + "get", + "--project-id", + str(project.id), + "--iid", + user_list_cli, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["name"] == "cli_test_list_fixture" + + +def test_project_feature_flag_user_list_cli_update(gitlab_cli, project, user_list_cli): + new_name = "cli_updated_list" + cmd = [ + "project-feature-flag-user-list", + "update", + "--project-id", + str(project.id), + "--iid", + user_list_cli, + "--name", + new_name, + ] + ret = gitlab_cli(cmd) + assert ret.success diff --git a/tests/functional/cli/test_cli_project_feature_flags.py b/tests/functional/cli/test_cli_project_feature_flags.py new file mode 100644 index 000000000..559970f26 --- /dev/null +++ b/tests/functional/cli/test_cli_project_feature_flags.py @@ -0,0 +1,203 @@ +import json + +import pytest + + +@pytest.fixture +def feature_flag_cli(gitlab_cli, project): + flag_name = "test_flag_cli_fixture" + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + gitlab_cli(cmd) + yield flag_name + try: + cmd = [ + "project-feature-flag", + "delete", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + gitlab_cli(cmd) + except Exception: + pass + + +def test_project_feature_flag_cli_create_delete(gitlab_cli, project): + flag_name = "test_flag_cli_create" + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + assert flag_name in ret.stdout + + cmd = [ + "project-feature-flag", + "delete", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + + +def test_project_feature_flag_cli_create_with_strategies(gitlab_cli, project): + flag_name = "test_flag_cli_strategies" + strategies_json = ( + '[{"name": "userWithId", "parameters": {"userIds": "user1,user2"}}]' + ) + + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + "--strategies", + strategies_json, + ] + ret = gitlab_cli(cmd) + assert ret.success + + cmd = [ + "-o", + "json", + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + flag_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert len(data["strategies"]) == 1 + assert data["strategies"][0]["name"] == "userWithId" + + +def test_project_feature_flag_cli_list(gitlab_cli, project, feature_flag_cli): + cmd = ["project-feature-flag", "list", "--project-id", str(project.id)] + ret = gitlab_cli(cmd) + assert ret.success + assert feature_flag_cli in ret.stdout + + +def test_project_feature_flag_cli_get(gitlab_cli, project, feature_flag_cli): + cmd = [ + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + ] + ret = gitlab_cli(cmd) + assert ret.success + assert feature_flag_cli in ret.stdout + + +def test_project_feature_flag_cli_update(gitlab_cli, project, feature_flag_cli): + cmd = [ + "project-feature-flag", + "update", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + "--active", + "false", + ] + ret = gitlab_cli(cmd) + assert ret.success + + cmd = [ + "-o", + "json", + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["active"] is False + + +def test_project_feature_flag_cli_create_with_malformed_strategies(gitlab_cli, project): + flag_name = "test_flag_cli_malformed_strategies" + strategies_json = '[{"name": "userWithId"' # Malformed JSON + + cmd = [ + "project-feature-flag", + "create", + "--project-id", + str(project.id), + "--name", + flag_name, + "--strategies", + strategies_json, + ] + ret = gitlab_cli(cmd) + assert not ret.success + assert "Could not parse JSON data" in ret.stderr + + +def test_project_feature_flag_cli_rename(gitlab_cli, project, feature_flag_cli): + new_name = "cli_renamed_flag" + cmd = [ + "project-feature-flag", + "update", + "--project-id", + str(project.id), + "--name", + feature_flag_cli, + "--new-name", + new_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + + cmd = [ + "-o", + "json", + "project-feature-flag", + "get", + "--project-id", + str(project.id), + "--name", + new_name, + ] + ret = gitlab_cli(cmd) + assert ret.success + data = json.loads(ret.stdout) + assert data["name"] == new_name + # Cleanup renamed flag + cmd = [ + "project-feature-flag", + "delete", + "--project-id", + str(project.id), + "--name", + new_name, + ] + gitlab_cli(cmd) diff --git a/tests/functional/fixtures/.env b/tests/functional/fixtures/.env index e85f85e6f..60eda1be2 100644 --- a/tests/functional/fixtures/.env +++ b/tests/functional/fixtures/.env @@ -1,4 +1,4 @@ GITLAB_IMAGE=gitlab/gitlab-ee -GITLAB_TAG=17.8.2-ee.0 +GITLAB_TAG=18.9.2-ee.0 GITLAB_RUNNER_IMAGE=gitlab/gitlab-runner GITLAB_RUNNER_TAG=96856197 diff --git a/tests/functional/fixtures/docker-compose.yml b/tests/functional/fixtures/docker-compose.yml index f36f3d2fd..17562d5be 100644 --- a/tests/functional/fixtures/docker-compose.yml +++ b/tests/functional/fixtures/docker-compose.yml @@ -34,7 +34,7 @@ services: entrypoint: - /bin/sh - -c - - ruby /create_license.rb && /assets/wrapper + - ruby /create_license.rb && /assets/init-container volumes: - ${PWD}/tests/functional/fixtures/create_license.rb:/create_license.rb ports: diff --git a/tests/functional/helpers.py b/tests/functional/helpers.py index 090673bf7..9d313e540 100644 --- a/tests/functional/helpers.py +++ b/tests/functional/helpers.py @@ -9,6 +9,7 @@ import gitlab import gitlab.base import gitlab.exceptions +import gitlab.v4.objects SLEEP_INTERVAL = 0.5 TIMEOUT = 60 # seconds before timeout will occur @@ -37,6 +38,11 @@ def safe_delete(object: gitlab.base.RESTObject) -> None: object = manager.get(object.get_id()) # type: ignore[attr-defined] except gitlab.exceptions.GitlabGetError: return + # If object is already marked for deletion we have succeeded + if getattr(object, "marked_for_deletion_on", None) is not None: + # 'Group' and 'Project' objects have a 'marked_for_deletion_on' attribute + logging.info(f"{object!r} is marked for deletion.") + return if index: logging.info(f"Attempt {index + 1} to delete {object!r}.") @@ -52,22 +58,16 @@ def safe_delete(object: gitlab.base.RESTObject) -> None: # we shouldn't cause test to fail if it still exists return elif isinstance(object, gitlab.v4.objects.Project): - # Immediately delete rather than waiting for at least 1day - # https://docs.gitlab.com/ee/api/projects.html#delete-project - object.delete(permanently_remove=True) - pass + # Starting in GitLab 18, projects can't be immediately deleted. + # So this will mark it for deletion. + object.delete() else: # We only attempt to delete parent groups to prevent dangling sub-groups - # However parent groups can only be deleted on a delay in Gl 16 + # However parent groups can only be deleted on a delay in GitLab 16 # https://docs.gitlab.com/ee/api/groups.html#remove-group object.delete() except gitlab.exceptions.GitlabDeleteError: - logging.info(f"{object!r} already deleted or scheduled for deletion.") - if isinstance(object, gitlab.v4.objects.Group): - # Parent groups can never be immediately deleted in GL 16, - # so don't cause test to fail if it still exists - return - pass + logging.exception(f"Error attempting to delete: {object.pformat()}") time.sleep(SLEEP_INTERVAL) pytest.fail(f"{object!r} was not deleted") diff --git a/tests/unit/objects/test_project_feature_flag_user_lists.py b/tests/unit/objects/test_project_feature_flag_user_lists.py new file mode 100644 index 000000000..92a9fd13e --- /dev/null +++ b/tests/unit/objects/test_project_feature_flag_user_lists.py @@ -0,0 +1,30 @@ +""" +Unit tests for Project Feature Flag User Lists. +""" + +import responses + + +def test_create_user_list_with_list_conversion(project): + """ + Verify that passing a list of integers for user_xids is converted + to a comma-separated string in the API payload. + """ + with responses.RequestsMock() as rs: + rs.add( + responses.POST, + "http://localhost/api/v4/projects/1/feature_flags_user_lists", + json={"iid": 1, "name": "list", "user_xids": "1,2,3"}, + status=201, + match=[ + responses.matchers.json_params_matcher( + {"name": "list", "user_xids": "1,2,3"} + ) + ], + ) + + project.feature_flags_user_lists.create( + {"name": "list", "user_xids": [1, 2, 3]} + ) + + assert len(rs.calls) == 1 diff --git a/tests/unit/objects/test_project_feature_flags.py b/tests/unit/objects/test_project_feature_flags.py new file mode 100644 index 000000000..a74fb7ee6 --- /dev/null +++ b/tests/unit/objects/test_project_feature_flags.py @@ -0,0 +1,35 @@ +""" +Unit tests for Project Feature Flags. +""" + +import responses + +from gitlab.v4.objects import ProjectFeatureFlag + + +def test_feature_flag_rename(project): + """ + Verify that renaming a feature flag uses the old name in the URL + and the new name in the payload. + """ + flag_content = {"name": "old_name", "version": "new_version_flag", "active": True} + flag = ProjectFeatureFlag(project.feature_flags, flag_content) + + # Rename locally + flag.name = "new_name" + + with responses.RequestsMock() as rs: + rs.add( + responses.PUT, + "http://localhost/api/v4/projects/1/feature_flags/old_name", + json={"name": "new_name", "version": "new_version_flag", "active": True}, + status=200, + match=[responses.matchers.json_params_matcher({"name": "new_name"})], + ) + + flag.save() + + assert len(rs.calls) == 1 + # URL should use the old name (ID) + assert rs.calls[0].request.url.endswith("/feature_flags/old_name") + assert flag.name == "new_name" diff --git a/tests/unit/objects/test_registry_protection_rules.py b/tests/unit/objects/test_registry_protection_rules.py index 3078278f5..3e9db414a 100644 --- a/tests/unit/objects/test_registry_protection_rules.py +++ b/tests/unit/objects/test_registry_protection_rules.py @@ -58,6 +58,17 @@ def resp_update_protected_registry(): yield rsps +@pytest.fixture +def resp_delete_protected_registry(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/registry/protection/repository/rules/1", + status=204, + ) + yield rsps + + def test_list_project_protected_registries(project, resp_list_protected_registries): protected_registry = project.registry_protection_repository_rules.list()[0] assert isinstance(protected_registry, ProjectRegistryRepositoryProtectionRule) @@ -80,3 +91,7 @@ def test_update_project_protected_registry(project, resp_update_protected_regist 1, {"repository_path_pattern": "abc*"} ) assert updated["repository_path_pattern"] == "abc*" + + +def test_delete_project_protected_registry(project, resp_delete_protected_registry): + project.registry_protection_repository_rules.delete(1) diff --git a/tests/unit/objects/test_remote_mirrors.py b/tests/unit/objects/test_remote_mirrors.py index f493032e8..be2aaaaba 100644 --- a/tests/unit/objects/test_remote_mirrors.py +++ b/tests/unit/objects/test_remote_mirrors.py @@ -54,6 +54,12 @@ def resp_remote_mirrors(): url="http://localhost/api/v4/projects/1/remote_mirrors/1", status=204, ) + + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/remote_mirrors/1/sync", + status=204, + ) yield rsps @@ -81,3 +87,9 @@ def test_update_project_remote_mirror(project, resp_remote_mirrors): def test_delete_project_remote_mirror(project, resp_remote_mirrors): mirror = project.remote_mirrors.create({"url": "https://example.com"}) mirror.delete() + + +def test_sync_project_remote_mirror(project, resp_remote_mirrors): + mirror = project.remote_mirrors.create({"url": "https://example.com"}) + response = mirror.sync() + assert response.status_code == 204 diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 32b9c9ef9..b8470f22b 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -268,28 +268,22 @@ def test_ssl_verify_as_str(m_open, monkeypatch): def test_data_from_helper(m_open, monkeypatch, tmp_path): helper = tmp_path / "helper.sh" helper.write_text( - dedent( - """\ + dedent("""\ #!/bin/sh echo "secret" - """ - ), + """), encoding="utf-8", ) helper.chmod(0o755) - fd = io.StringIO( - dedent( - f"""\ + fd = io.StringIO(dedent(f"""\ [global] default = helper [helper] url = https://helper.url oauth_token = helper: {helper} - """ - ) - ) + """)) fd.close = mock.Mock(return_value=None) m_open.return_value = fd @@ -306,18 +300,14 @@ def test_data_from_helper(m_open, monkeypatch, tmp_path): @pytest.mark.skipif(sys.platform.startswith("win"), reason="Not supported on Windows") def test_from_helper_subprocess_error_raises_error(m_open, monkeypatch): # using false here to force a non-zero return code - fd = io.StringIO( - dedent( - """\ + fd = io.StringIO(dedent("""\ [global] default = helper [helper] url = https://helper.url oauth_token = helper: false - """ - ) - ) + """)) fd.close = mock.Mock(return_value=None) m_open.return_value = fd diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py index 351f6ca34..0c1b6b8ed 100644 --- a/tests/unit/test_types.py +++ b/tests/unit/test_types.py @@ -1,6 +1,6 @@ import pytest -from gitlab import types +from gitlab import exceptions, types class TestRequiredOptional: @@ -122,3 +122,40 @@ def test_csv_string_attribute_get_for_api_from_int_list(): def test_lowercase_string_attribute_get_for_api(): o = types.LowercaseStringAttribute("FOO") assert o.get_for_api(key="spam") == ("spam", "foo") + + +# JsonAttribute tests +def test_json_attribute() -> None: + attr = types.JsonAttribute() + + attr.set_from_cli('{"key": "value"}') + assert attr.get() == {"key": "value"} + + with pytest.raises(exceptions.GitlabParsingError): + attr.set_from_cli(" ") + + +# CommaSeparatedStringAttribute tests +def test_comma_separated_string_attribute() -> None: + # Test with list of integers + attr = types.CommaSeparatedStringAttribute([1, 2, 3]) + assert attr.get_for_api(key="ids") == ("ids", "1,2,3") + + # Test with list of strings + attr = types.CommaSeparatedStringAttribute(["a", "b"]) + assert attr.get_for_api(key="names") == ("names", "a,b") + + # Test with string value (should be preserved) + attr = types.CommaSeparatedStringAttribute("1,2,3") + assert attr.get_for_api(key="ids") == ("ids", "1,2,3") + + # Test CLI setting + attr = types.CommaSeparatedStringAttribute() + attr.set_from_cli("1, 2, 3") + assert attr.get() == ["1", "2", "3"] + + attr.set_from_cli("") + assert attr.get() == [] + + # Verify transform_in_body is True + assert types.CommaSeparatedStringAttribute.transform_in_body is True diff --git a/tox.ini b/tox.ini index 05a15c6c4..0ba295692 100644 --- a/tox.ini +++ b/tox.ini @@ -2,14 +2,14 @@ minversion = 4.0 skipsdist = True skip_missing_interpreters = True -envlist = py313,py312,py311,py310,py39,black,isort,flake8,mypy,twine-check,cz,pylint +envlist = py314,py313,py312,py311,py310,black,isort,flake8,mypy,twine-check,cz,pylint # NOTE(jlvillal): To use a label use the `-m` flag. # For example to run the `func` label group of environments do: # tox -m func labels = lint = black,isort,flake8,mypy,pylint,cz - unit = py313,py312,py311,py310,py39,py38 + unit = py314,py313,py312,py311,py310 # func is the functional tests. This is very time consuming. func = cli_func_v4,api_func_v4