From abdaa68f892bc52a2a1066adadaf08dd099f94e6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:35:16 +0000 Subject: [PATCH 01/10] chore(deps): update all non-major dependencies --- .github/workflows/test.yml | 4 ++-- .pre-commit-config.yaml | 2 +- requirements-lint.txt | 2 +- requirements-test.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 388f66bbe..c8a1c66be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox -- --override-ini='log_cli=True' - name: Upload codecov coverage - uses: codecov/codecov-action@v5.5.3 + uses: codecov/codecov-action@v5.5.4 with: files: ./coverage.xml flags: ${{ matrix.toxenv }} @@ -100,7 +100,7 @@ jobs: TOXENV: cover run: tox - name: Upload codecov coverage - uses: codecov/codecov-action@v5.5.3 + uses: codecov/codecov-action@v5.5.4 with: files: ./coverage.xml flags: unit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73c726956..da9f5d238 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 43.86.0 + rev: 43.100.0 hooks: - id: renovate-config-validator diff --git a/requirements-lint.txt b/requirements-lint.txt index 220f0c407..d852812dd 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -10,5 +10,5 @@ pytest==9.0.2 responses==0.26.0 respx==0.22.0 types-PyYAML==6.0.12.20250915 -types-requests==2.32.4.20260107 +types-requests==2.33.0.20260327 types-setuptools==82.0.0.20260210 diff --git a/requirements-test.txt b/requirements-test.txt index 2a91f37db..5eb57dfb8 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -r requirements.txt -anyio==4.12.1 -build==1.4.0 +anyio==4.13.0 +build==1.4.2 coverage==7.13.5 pytest-console-scripts==1.4.1 pytest-cov==7.1.0 From bfadc28aaaa43b137215f9e5061cea9df57a306e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 01:35:55 +0000 Subject: [PATCH 02/10] chore(deps): update all non-major dependencies --- .pre-commit-config.yaml | 4 ++-- requirements-lint.txt | 6 +++--- requirements.txt | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da9f5d238..9c2e37aeb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - requests-toolbelt==1.0.0 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.19.1 + rev: v1.20.0 hooks: - id: mypy args: [] @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 43.100.0 + rev: 43.104.4 hooks: - id: renovate-config-validator diff --git a/requirements-lint.txt b/requirements-lint.txt index d852812dd..504a381b7 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -4,11 +4,11 @@ black==26.3.1 commitizen==4.13.9 flake8==7.3.0 isort==8.0.1 -mypy==1.19.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.20250915 -types-requests==2.33.0.20260327 -types-setuptools==82.0.0.20260210 +types-requests==2.33.0.20260402 +types-setuptools==82.0.0.20260402 diff --git a/requirements.txt b/requirements.txt index ef502e9d6..31ae12e35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ gql==4.0.0 httpx==0.28.1 -requests==2.33.0 +requests==2.33.1 requests-toolbelt==1.0.0 From 0e8ae57600467d030fd2e0c5ccb40712bb89bad8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Apr 2026 04:56:32 +0000 Subject: [PATCH 03/10] chore(deps): update codecov/codecov-action action to v6 --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8a1c66be..83867b546 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox -- --override-ini='log_cli=True' - name: Upload codecov coverage - uses: codecov/codecov-action@v5.5.4 + uses: codecov/codecov-action@v6.0.0 with: files: ./coverage.xml flags: ${{ matrix.toxenv }} @@ -100,7 +100,7 @@ jobs: TOXENV: cover run: tox - name: Upload codecov coverage - uses: codecov/codecov-action@v5.5.4 + uses: codecov/codecov-action@v6.0.0 with: files: ./coverage.xml flags: unit From 659c64899f8679dbeeb8edac1fbcae4a91292f08 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 05:41:47 +0000 Subject: [PATCH 04/10] chore(deps): update all non-major dependencies --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 4 ++-- requirements-lint.txt | 14 +++++++------- requirements-test.txt | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83867b546..125555594 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,7 +120,7 @@ jobs: pip install -r requirements-test.txt - name: Build package run: python -m build -o dist/ - - uses: actions/upload-artifact@v7.0.0 + - uses: actions/upload-artifact@v7.0.1 with: name: dist path: dist diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9c2e37aeb..3e428a173 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: hooks: - id: black - repo: https://github.com/commitizen-tools/commitizen - rev: v4.13.9 + rev: v4.13.10 hooks: - id: commitizen stages: [commit-msg] @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 43.104.4 + rev: 43.111.0 hooks: - id: renovate-config-validator diff --git a/requirements-lint.txt b/requirements-lint.txt index 504a381b7..05b5ca496 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,14 +1,14 @@ -r requirements.txt argcomplete==2.0.0 black==26.3.1 -commitizen==4.13.9 +commitizen==4.13.10 flake8==7.3.0 isort==8.0.1 -mypy==1.20.0 +mypy==1.20.1 pylint==4.0.5 -pytest==9.0.2 +pytest==9.0.3 responses==0.26.0 -respx==0.22.0 -types-PyYAML==6.0.12.20250915 -types-requests==2.33.0.20260402 -types-setuptools==82.0.0.20260402 +respx==0.23.1 +types-PyYAML==6.0.12.20260408 +types-requests==2.33.0.20260408 +types-setuptools==82.0.0.20260408 diff --git a/requirements-test.txt b/requirements-test.txt index 5eb57dfb8..55ccf1a19 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,13 +1,13 @@ -r requirements.txt anyio==4.13.0 -build==1.4.2 +build==1.4.3 coverage==7.13.5 pytest-console-scripts==1.4.1 pytest-cov==7.1.0 pytest-github-actions-annotate-failures==0.4.0 -pytest==9.0.2 +pytest==9.0.3 PyYaml==6.0.3 responses==0.26.0 -respx==0.22.0 +respx==0.23.1 trio==0.33.0 wheel==0.46.3 From d72b3786ff6d2bebd5ae3ed092bf3960845ed8a4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 01:32:12 +0000 Subject: [PATCH 05/10] chore(deps): update all non-major dependencies --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3e428a173..cfc74fe6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - requests-toolbelt==1.0.0 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.0 + rev: v1.20.1 hooks: - id: mypy args: [] @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 43.111.0 + rev: 43.132.1 hooks: - id: renovate-config-validator From 849301b830b0d4f76ab85ffa3f39b22a6e628df9 Mon Sep 17 00:00:00 2001 From: Philip Nelson Date: Mon, 20 Apr 2026 16:54:42 -0600 Subject: [PATCH 06/10] fix(api): update CI/CD variable create and update attributes Make `value` optional when updating CI/CD variables. Variables marked as `masked_and_hidden` have their value hidden by GitLab and cannot be retrieved, so `value` must not be required for updates. Add `masked_and_hidden` to optional create attributes for group and project variables (admin variables do not support this attribute). Also expand the optional attributes for all three variable managers (admin, group, project) to include `description`, `raw`, and `environment_scope` where applicable, aligning with the GitLab API. Add functional tests covering create, get, update, and delete of masked-and-hidden variables for both group and project scopes. --- .gitignore | 1 + gitlab/v4/objects/variables.py | 57 ++++++++++++++++++++++---- tests/functional/api/test_variables.py | 36 ++++++++++++++++ 3 files changed, 87 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 849ca6e85..3a1338bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ docs/_build .tox .venv/ venv/ +.mypy_cache/ # Include tracked hidden files and directories in search and diff tools !.dockerignore diff --git a/gitlab/v4/objects/variables.py b/gitlab/v4/objects/variables.py index bae2be22b..afb42bda3 100644 --- a/gitlab/v4/objects/variables.py +++ b/gitlab/v4/objects/variables.py @@ -27,10 +27,19 @@ class VariableManager(CRUDMixin[Variable]): _path = "/admin/ci/variables" _obj_cls = Variable _create_attrs = RequiredOptional( - required=("key", "value"), optional=("protected", "variable_type", "masked") + required=("key", "value"), + optional=("description", "masked", "protected", "raw", "variable_type"), ) _update_attrs = RequiredOptional( - required=("key", "value"), optional=("protected", "variable_type", "masked") + required=("key",), + optional=( + "description", + "masked", + "protected", + "raw", + "value", + "variable_type", + ), ) @@ -43,10 +52,28 @@ class GroupVariableManager(CRUDMixin[GroupVariable]): _obj_cls = GroupVariable _from_parent_attrs = {"group_id": "id"} _create_attrs = RequiredOptional( - required=("key", "value"), optional=("protected", "variable_type", "masked") + required=("key", "value"), + optional=( + "description", + "environment_scope", + "masked", + "masked_and_hidden", + "protected", + "raw", + "variable_type", + ), ) _update_attrs = RequiredOptional( - required=("key", "value"), optional=("protected", "variable_type", "masked") + required=("key",), + optional=( + "description", + "environment_scope", + "masked", + "protected", + "raw", + "value", + "variable_type", + ), ) @@ -60,9 +87,25 @@ class ProjectVariableManager(CRUDMixin[ProjectVariable]): _from_parent_attrs = {"project_id": "id"} _create_attrs = RequiredOptional( required=("key", "value"), - optional=("protected", "variable_type", "masked", "environment_scope"), + optional=( + "description", + "environment_scope", + "masked", + "masked_and_hidden", + "protected", + "raw", + "variable_type", + ), ) _update_attrs = RequiredOptional( - required=("key", "value"), - optional=("protected", "variable_type", "masked", "environment_scope"), + required=("key",), + optional=( + "description", + "environment_scope", + "masked", + "protected", + "raw", + "value", + "variable_type", + ), ) diff --git a/tests/functional/api/test_variables.py b/tests/functional/api/test_variables.py index eeed51da7..bed26c1d0 100644 --- a/tests/functional/api/test_variables.py +++ b/tests/functional/api/test_variables.py @@ -43,3 +43,39 @@ def test_project_variables(project): assert variable.value == "new_value1" variable.delete() + + +def test_hidden_group_variables(group): + variable = group.variables.create( + {"key": "key1", "value": "secret_value", "masked_and_hidden": True} + ) + + variable = group.variables.get(variable.key) + assert variable.value is None + assert variable.description is None + assert variable in group.variables.list() + + variable.description = "new_description" + variable.save() + variable = group.variables.get(variable.key) + assert variable.description == "new_description" + + variable.delete() + + +def test_hidden_project_variables(project): + variable = project.variables.create( + {"key": "key1", "value": "secret_value", "masked_and_hidden": True} + ) + + variable = project.variables.get(variable.key) + assert variable.value is None + assert variable.description is None + assert variable in project.variables.list() + + variable.description = "new_description" + variable.save() + variable = project.variables.get(variable.key) + assert variable.description == "new_description" + + variable.delete() From feb6cedeffa75ef386ef13a3226bc07bcb396f61 Mon Sep 17 00:00:00 2001 From: Frank Klaassen <639906+syphernl@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:04:06 +0100 Subject: [PATCH 07/10] feat(api): add full service account support at instance, group, and project level --- docs/api-objects.rst | 1 + docs/gl_objects/service_accounts.rst | 153 +++++ gitlab/client.py | 2 + gitlab/v4/objects/projects.py | 2 + gitlab/v4/objects/service_accounts.py | 145 ++++- tests/unit/objects/test_service_accounts.py | 592 ++++++++++++++++++++ 6 files changed, 890 insertions(+), 5 deletions(-) create mode 100644 docs/gl_objects/service_accounts.rst create mode 100644 tests/unit/objects/test_service_accounts.py diff --git a/docs/api-objects.rst b/docs/api-objects.rst index 7107107c2..7d1370fd3 100644 --- a/docs/api-objects.rst +++ b/docs/api-objects.rst @@ -64,6 +64,7 @@ API examples gl_objects/resource_groups gl_objects/search gl_objects/secure_files + gl_objects/service_accounts gl_objects/settings gl_objects/snippets gl_objects/statistics diff --git a/docs/gl_objects/service_accounts.rst b/docs/gl_objects/service_accounts.rst new file mode 100644 index 000000000..db493ad55 --- /dev/null +++ b/docs/gl_objects/service_accounts.rst @@ -0,0 +1,153 @@ +################ +Service Accounts +################ + +References +---------- + +* v4 API: + + + :class:`gitlab.v4.objects.ServiceAccount` + + :class:`gitlab.v4.objects.ServiceAccountManager` + + :class:`gitlab.v4.objects.GroupServiceAccount` + + :class:`gitlab.v4.objects.GroupServiceAccountManager` + + :class:`gitlab.v4.objects.GroupServiceAccountAccessToken` + + :class:`gitlab.v4.objects.GroupServiceAccountAccessTokenManager` + + :class:`gitlab.v4.objects.ProjectServiceAccount` + + :class:`gitlab.v4.objects.ProjectServiceAccountManager` + + :class:`gitlab.v4.objects.ProjectServiceAccountAccessToken` + + :class:`gitlab.v4.objects.ProjectServiceAccountAccessTokenManager` + +* GitLab API: https://docs.gitlab.com/api/service_accounts/ + +Instance service accounts +------------------------- + +List instance service accounts:: + + accounts = gl.service_accounts.list() + +Create an instance service account:: + + sa = gl.service_accounts.create({}) + # with optional attributes + sa = gl.service_accounts.create({"name": "my-bot", "username": "my-bot", "email": "my-bot@example.com"}) + +Update an instance service account:: + + gl.service_accounts.update(sa.id, {"name": "renamed-bot"}) + # or via the object + sa.name = "renamed-bot" + sa.save() + +Group service accounts +---------------------- + +List group service accounts:: + + accounts = group.service_accounts.list() + +Create a group service account:: + + sa = group.service_accounts.create({}) + # with optional attributes + sa = group.service_accounts.create({"name": "ci-bot", "username": "ci-bot"}) + +Update a group service account:: + + group.service_accounts.update(sa.id, {"name": "renamed-bot"}) + # or via the object + sa.name = "renamed-bot" + sa.save() + +Delete a group service account:: + + group.service_accounts.delete(sa.id) + # or via the object + sa.delete() + +Group service account personal access tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +List tokens for a group service account:: + + tokens = sa.access_tokens.list() + +Create a token for a group service account:: + + token = sa.access_tokens.create({ + "name": "ci-token", + "scopes": ["api"], + "expires_at": "2026-01-01", + }) + print(token.token) + +Rotate a token:: + + token.rotate() + print(token.token) + # or directly using a token ID + new_token = sa.access_tokens.rotate(token.id) + print(new_token["token"]) + +Revoke a token:: + + sa.access_tokens.delete(token.id) + # or via the object + token.delete() + +Project service accounts +------------------------ + +List project service accounts:: + + accounts = project.service_accounts.list() + +Create a project service account:: + + sa = project.service_accounts.create({}) + # with optional attributes + sa = project.service_accounts.create({"name": "ci-bot", "username": "ci-bot"}) + +Update a project service account:: + + project.service_accounts.update(sa.id, {"name": "renamed-bot"}) + # or via the object + sa.name = "renamed-bot" + sa.save() + +Delete a project service account:: + + project.service_accounts.delete(sa.id) + # or via the object + sa.delete() + +Project service account personal access tokens +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +List tokens for a project service account:: + + tokens = sa.access_tokens.list() + +Create a token for a project service account:: + + token = sa.access_tokens.create({ + "name": "ci-token", + "scopes": ["read_repository"], + "expires_at": "2026-01-01", + }) + print(token.token) + +Rotate a token:: + + token.rotate() + print(token.token) + # or directly using a token ID + new_token = sa.access_tokens.rotate(token.id) + print(new_token["token"]) + +Revoke a token:: + + sa.access_tokens.delete(token.id) + # or via the object + token.delete() diff --git a/gitlab/client.py b/gitlab/client.py index a3cf1f31a..ea3a0c209 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -211,6 +211,8 @@ def __init__( """See :class:`~gitlab.v4.objects.PersonalAccessTokenManager`""" self.topics = objects.TopicManager(self) """See :class:`~gitlab.v4.objects.TopicManager`""" + self.service_accounts = objects.ServiceAccountManager(self) + """See :class:`~gitlab.v4.objects.ServiceAccountManager`""" self.statistics = objects.ApplicationStatisticsManager(self) """See :class:`~gitlab.v4.objects.ApplicationStatisticsManager`""" diff --git a/gitlab/v4/objects/projects.py b/gitlab/v4/objects/projects.py index 22975ff9f..01da8593e 100644 --- a/gitlab/v4/objects/projects.py +++ b/gitlab/v4/objects/projects.py @@ -91,6 +91,7 @@ from .resource_groups import ProjectResourceGroupManager from .runners import ProjectRunnerManager # noqa: F401 from .secure_files import ProjectSecureFileManager # noqa: F401 +from .service_accounts import ProjectServiceAccountManager # noqa: F401 from .snippets import ProjectSnippetManager # noqa: F401 from .statistics import ( # noqa: F401 ProjectAdditionalStatisticsManager, @@ -251,6 +252,7 @@ class Project( repositories: ProjectRegistryRepositoryManager runners: ProjectRunnerManager secure_files: ProjectSecureFileManager + service_accounts: ProjectServiceAccountManager services: ProjectServiceManager snippets: ProjectSnippetManager external_status_checks: ProjectExternalStatusCheckManager diff --git a/gitlab/v4/objects/service_accounts.py b/gitlab/v4/objects/service_accounts.py index bf6f53d4f..32056e3bd 100644 --- a/gitlab/v4/objects/service_accounts.py +++ b/gitlab/v4/objects/service_accounts.py @@ -1,20 +1,155 @@ +""" +GitLab API: https://docs.gitlab.com/api/service_accounts/ +""" + from gitlab.base import RESTObject -from gitlab.mixins import CreateMixin, DeleteMixin, ListMixin, ObjectDeleteMixin -from gitlab.types import RequiredOptional +from gitlab.mixins import ( + CreateMixin, + DeleteMixin, + ListMixin, + ObjectDeleteMixin, + ObjectRotateMixin, + RotateMixin, + SaveMixin, + UpdateMethod, + UpdateMixin, +) +from gitlab.types import ArrayAttribute, RequiredOptional + +__all__ = [ + "ServiceAccount", + "ServiceAccountManager", + "GroupServiceAccount", + "GroupServiceAccountManager", + "GroupServiceAccountAccessToken", + "GroupServiceAccountAccessTokenManager", + "ProjectServiceAccount", + "ProjectServiceAccountManager", + "ProjectServiceAccountAccessToken", + "ProjectServiceAccountAccessTokenManager", +] + +_SA_ACCOUNT_ATTRS = RequiredOptional(optional=("name", "username", "email")) + +_SA_TOKEN_CREATE_ATTRS = RequiredOptional( + required=("name", "scopes"), optional=("description", "expires_at") +) + +_SA_TOKEN_LIST_FILTERS = ( + "created_after", + "created_before", + "expires_after", + "expires_before", + "last_used_after", + "last_used_before", + "revoked", + "search", + "sort", + "state", +) -__all__ = ["GroupServiceAccount", "GroupServiceAccountManager"] +# --------------------------------------------------------------------------- +# Instance-level service accounts +# --------------------------------------------------------------------------- -class GroupServiceAccount(ObjectDeleteMixin, RESTObject): + +class ServiceAccount(SaveMixin, RESTObject): pass +class ServiceAccountManager( + CreateMixin[ServiceAccount], ListMixin[ServiceAccount], UpdateMixin[ServiceAccount] +): + _path = "/service_accounts" + _obj_cls = ServiceAccount + _create_attrs = _SA_ACCOUNT_ATTRS + _update_attrs = _SA_ACCOUNT_ATTRS + _update_method = UpdateMethod.PATCH + _list_filters = ("order_by", "sort") + + +# --------------------------------------------------------------------------- +# Group-level service accounts +# --------------------------------------------------------------------------- + + +class GroupServiceAccountAccessToken(ObjectDeleteMixin, ObjectRotateMixin, RESTObject): + pass + + +class GroupServiceAccountAccessTokenManager( + CreateMixin[GroupServiceAccountAccessToken], + DeleteMixin[GroupServiceAccountAccessToken], + ListMixin[GroupServiceAccountAccessToken], + RotateMixin[GroupServiceAccountAccessToken], +): + _path = "/groups/{group_id}/service_accounts/{user_id}/personal_access_tokens" + _obj_cls = GroupServiceAccountAccessToken + _from_parent_attrs = {"group_id": "group_id", "user_id": "id"} + _create_attrs = _SA_TOKEN_CREATE_ATTRS + _types = {"scopes": ArrayAttribute} + _list_filters = _SA_TOKEN_LIST_FILTERS + + +class GroupServiceAccount(SaveMixin, ObjectDeleteMixin, RESTObject): + access_tokens: GroupServiceAccountAccessTokenManager + + class GroupServiceAccountManager( CreateMixin[GroupServiceAccount], DeleteMixin[GroupServiceAccount], ListMixin[GroupServiceAccount], + UpdateMixin[GroupServiceAccount], ): _path = "/groups/{group_id}/service_accounts" _obj_cls = GroupServiceAccount _from_parent_attrs = {"group_id": "id"} - _create_attrs = RequiredOptional(optional=("name", "username")) + _create_attrs = _SA_ACCOUNT_ATTRS + _update_attrs = _SA_ACCOUNT_ATTRS + _update_method = UpdateMethod.PATCH + _list_filters = ("order_by", "sort") + + +# --------------------------------------------------------------------------- +# Project-level service accounts +# --------------------------------------------------------------------------- + + +class ProjectServiceAccountAccessToken( + ObjectDeleteMixin, ObjectRotateMixin, RESTObject +): + pass + + +class ProjectServiceAccountAccessTokenManager( + CreateMixin[ProjectServiceAccountAccessToken], + DeleteMixin[ProjectServiceAccountAccessToken], + ListMixin[ProjectServiceAccountAccessToken], + RotateMixin[ProjectServiceAccountAccessToken], +): + _path = "/projects/{project_id}/service_accounts/{user_id}/personal_access_tokens" + _obj_cls = ProjectServiceAccountAccessToken + _from_parent_attrs = {"project_id": "project_id", "user_id": "id"} + _create_attrs = _SA_TOKEN_CREATE_ATTRS + _types = {"scopes": ArrayAttribute} + _list_filters = _SA_TOKEN_LIST_FILTERS + + +class ProjectServiceAccount(SaveMixin, ObjectDeleteMixin, RESTObject): + access_tokens: ProjectServiceAccountAccessTokenManager + + +class ProjectServiceAccountManager( + CreateMixin[ProjectServiceAccount], + DeleteMixin[ProjectServiceAccount], + ListMixin[ProjectServiceAccount], + UpdateMixin[ProjectServiceAccount], +): + _path = "/projects/{project_id}/service_accounts" + _obj_cls = ProjectServiceAccount + _from_parent_attrs = {"project_id": "id"} + _create_attrs = _SA_ACCOUNT_ATTRS + _update_attrs = _SA_ACCOUNT_ATTRS + _update_method = UpdateMethod.PATCH + _list_filters = ("order_by", "sort") diff --git a/tests/unit/objects/test_service_accounts.py b/tests/unit/objects/test_service_accounts.py new file mode 100644 index 000000000..1658488ef --- /dev/null +++ b/tests/unit/objects/test_service_accounts.py @@ -0,0 +1,592 @@ +""" +GitLab API: https://docs.gitlab.com/api/service_accounts/ +""" + +import pytest +import responses + +from gitlab.v4.objects import ( + GroupServiceAccount, + GroupServiceAccountAccessToken, + ProjectServiceAccount, + ProjectServiceAccountAccessToken, + ServiceAccount, +) + +# --------------------------------------------------------------------------- +# Fixtures – instance-level service accounts +# --------------------------------------------------------------------------- + +instance_sa_content = { + "id": 57, + "username": "service_account_abc123", + "name": "Service account user", + "email": "service_account_abc123@noreply.example.com", +} + + +@pytest.fixture +def resp_list_service_accounts(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/service_accounts", + json=[instance_sa_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/service_accounts", + json=instance_sa_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture +def resp_update_service_account(): + updated = {**instance_sa_content, "name": "Renamed account"} + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.PATCH, + url=f"http://localhost/api/v4/service_accounts/{instance_sa_content['id']}", + json=updated, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_and_save_service_account(): + updated = {**instance_sa_content, "name": "Renamed account"} + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/service_accounts", + json=instance_sa_content, + content_type="application/json", + status=201, + ) + rsps.add( + method=responses.PATCH, + url=f"http://localhost/api/v4/service_accounts/{instance_sa_content['id']}", + json=updated, + content_type="application/json", + status=200, + ) + yield rsps + + +# --------------------------------------------------------------------------- +# Fixtures – group service accounts +# --------------------------------------------------------------------------- + +group_sa_content = { + "id": 42, + "username": "group-service-account", + "name": "Group Service Account", + "email": "group-sa@example.com", +} + +group_sa_updated = {**group_sa_content, "name": "Renamed Group SA"} + +sa_token_content = { + "id": 1, + "name": "my-token", + "scopes": ["api", "read_api"], + "user_id": 42, + "revoked": False, + "active": True, + "expires_at": "2025-12-31", + "token": "glpat-secret", +} + +sa_token_rotated = {**sa_token_content, "token": "glpat-rotated"} + + +@pytest.fixture +def resp_list_group_service_accounts(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/groups/1/service_accounts", + json=[group_sa_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_group_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/groups/1/service_accounts", + json=group_sa_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture +def resp_update_group_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.PATCH, + url="http://localhost/api/v4/groups/1/service_accounts/42", + json=group_sa_updated, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_delete_group_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/groups/1/service_accounts/42", + status=204, + ) + yield rsps + + +@pytest.fixture +def resp_list_group_sa_tokens(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/groups/1/service_accounts/42/personal_access_tokens", + json=[sa_token_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_group_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/groups/1/service_accounts/42/personal_access_tokens", + json=sa_token_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture +def resp_delete_group_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/groups/1/service_accounts/42/personal_access_tokens/1", + status=204, + ) + yield rsps + + +@pytest.fixture +def resp_list_and_delete_group_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/groups/1/service_accounts/42/personal_access_tokens", + json=[sa_token_content], + content_type="application/json", + status=200, + ) + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/groups/1/service_accounts/42/personal_access_tokens/1", + status=204, + ) + yield rsps + + +@pytest.fixture +def resp_rotate_group_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/groups/1/service_accounts/42/personal_access_tokens/1/rotate", + json=sa_token_rotated, + content_type="application/json", + status=200, + ) + yield rsps + + +# --------------------------------------------------------------------------- +# Helper – lazy service account under group 1 with id 42 +# --------------------------------------------------------------------------- + + +@pytest.fixture +def group_service_account(gl): + manager = gl.groups.get(1, lazy=True).service_accounts + return GroupServiceAccount(manager, group_sa_content) + + +# --------------------------------------------------------------------------- +# Tests – instance-level service accounts +# --------------------------------------------------------------------------- + + +def test_list_service_accounts(gl, resp_list_service_accounts): + accounts = gl.service_accounts.list() + assert len(accounts) == 1 + assert isinstance(accounts[0], ServiceAccount) + assert accounts[0].id == 57 + assert accounts[0].username == "service_account_abc123" + + +def test_create_service_account_with_defaults(gl, resp_create_service_account): + sa = gl.service_accounts.create({}) + assert isinstance(sa, ServiceAccount) + assert sa.id == 57 + assert sa.name == "Service account user" + + +def test_create_service_account_with_attrs(gl, resp_create_service_account): + sa = gl.service_accounts.create( + {"name": "Service account user", "username": "service_account_abc123"} + ) + assert isinstance(sa, ServiceAccount) + assert sa.username == "service_account_abc123" + + +def test_update_service_account(gl, resp_update_service_account): + updated = gl.service_accounts.update(57, {"name": "Renamed account"}) + assert updated["name"] == "Renamed account" + + +def test_save_service_account(gl, resp_create_and_save_service_account): + sa = gl.service_accounts.create({}) + sa.name = "Renamed account" + sa.save() + + +# --------------------------------------------------------------------------- +# Tests – group service accounts +# --------------------------------------------------------------------------- + + +def test_list_group_service_accounts(gl, resp_list_group_service_accounts): + accounts = gl.groups.get(1, lazy=True).service_accounts.list() + assert len(accounts) == 1 + assert isinstance(accounts[0], GroupServiceAccount) + assert accounts[0].id == 42 + + +def test_create_group_service_account(gl, resp_create_group_service_account): + sa = gl.groups.get(1, lazy=True).service_accounts.create( + {"name": "Group Service Account", "username": "group-service-account"} + ) + assert isinstance(sa, GroupServiceAccount) + assert sa.id == 42 + assert sa.username == "group-service-account" + + +def test_update_group_service_account(gl, resp_update_group_service_account): + updated = gl.groups.get(1, lazy=True).service_accounts.update( + 42, {"name": "Renamed Group SA"} + ) + assert updated["name"] == "Renamed Group SA" + + +def test_save_group_service_account( + group_service_account, resp_update_group_service_account +): + group_service_account.name = "Renamed Group SA" + group_service_account.save() + + +def test_delete_group_service_account(gl, resp_delete_group_service_account): + gl.groups.get(1, lazy=True).service_accounts.delete(42) + + +def test_delete_group_service_account_via_object( + group_service_account, resp_delete_group_service_account +): + group_service_account.delete() + + +# --------------------------------------------------------------------------- +# Tests – group service account personal access tokens +# --------------------------------------------------------------------------- + + +def test_list_group_sa_tokens(group_service_account, resp_list_group_sa_tokens): + tokens = group_service_account.access_tokens.list() + assert len(tokens) == 1 + assert isinstance(tokens[0], GroupServiceAccountAccessToken) + assert tokens[0].name == "my-token" + assert tokens[0].scopes == ["api", "read_api"] + + +def test_create_group_sa_token(group_service_account, resp_create_group_sa_token): + token = group_service_account.access_tokens.create( + {"name": "my-token", "scopes": ["api", "read_api"]} + ) + assert isinstance(token, GroupServiceAccountAccessToken) + assert token.id == 1 + assert token.token == "glpat-secret" + + +def test_delete_group_sa_token(group_service_account, resp_delete_group_sa_token): + group_service_account.access_tokens.delete(1) + + +def test_delete_group_sa_token_via_object( + group_service_account, resp_list_and_delete_group_sa_token +): + token = group_service_account.access_tokens.list()[0] + token.delete() + + +def test_rotate_group_sa_token(group_service_account, resp_rotate_group_sa_token): + token = GroupServiceAccountAccessToken( + group_service_account.access_tokens, sa_token_content + ) + token.rotate() + assert token.token == "glpat-rotated" + + +def test_rotate_group_sa_token_via_manager( + group_service_account, resp_rotate_group_sa_token +): + result = group_service_account.access_tokens.rotate(1) + assert result["token"] == "glpat-rotated" + + +# --------------------------------------------------------------------------- +# Fixtures – project service accounts +# --------------------------------------------------------------------------- + +proj_sa_content = { + "id": 99, + "username": "project-service-account", + "name": "Project Service Account", + "email": "proj-sa@example.com", +} + +proj_sa_updated = {**proj_sa_content, "name": "Renamed Project SA"} + +proj_sa_token_content = { + "id": 2, + "name": "proj-token", + "scopes": ["read_api"], + "user_id": 99, + "revoked": False, + "active": True, + "expires_at": "2025-12-31", + "token": "glpat-proj-secret", +} + +proj_sa_token_rotated = {**proj_sa_token_content, "token": "glpat-proj-rotated"} + + +@pytest.fixture +def resp_list_project_service_accounts(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/service_accounts", + json=[proj_sa_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_project_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/service_accounts", + json=proj_sa_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture +def resp_update_project_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.PATCH, + url="http://localhost/api/v4/projects/1/service_accounts/99", + json=proj_sa_updated, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_delete_project_service_account(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/service_accounts/99", + status=204, + ) + yield rsps + + +@pytest.fixture +def resp_list_project_sa_tokens(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.GET, + url="http://localhost/api/v4/projects/1/service_accounts/99/personal_access_tokens", + json=[proj_sa_token_content], + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def resp_create_project_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/service_accounts/99/personal_access_tokens", + json=proj_sa_token_content, + content_type="application/json", + status=201, + ) + yield rsps + + +@pytest.fixture +def resp_delete_project_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.DELETE, + url="http://localhost/api/v4/projects/1/service_accounts/99/personal_access_tokens/2", + status=204, + ) + yield rsps + + +@pytest.fixture +def resp_rotate_project_sa_token(): + with responses.RequestsMock() as rsps: + rsps.add( + method=responses.POST, + url="http://localhost/api/v4/projects/1/service_accounts/99/personal_access_tokens/2/rotate", + json=proj_sa_token_rotated, + content_type="application/json", + status=200, + ) + yield rsps + + +@pytest.fixture +def project_service_account(gl): + manager = gl.projects.get(1, lazy=True).service_accounts + return ProjectServiceAccount(manager, proj_sa_content) + + +# --------------------------------------------------------------------------- +# Tests – project service accounts +# --------------------------------------------------------------------------- + + +def test_list_project_service_accounts(gl, resp_list_project_service_accounts): + accounts = gl.projects.get(1, lazy=True).service_accounts.list() + assert len(accounts) == 1 + assert isinstance(accounts[0], ProjectServiceAccount) + assert accounts[0].id == 99 + + +def test_create_project_service_account(gl, resp_create_project_service_account): + sa = gl.projects.get(1, lazy=True).service_accounts.create( + {"name": "Project Service Account"} + ) + assert isinstance(sa, ProjectServiceAccount) + assert sa.id == 99 + assert sa.username == "project-service-account" + + +def test_update_project_service_account(gl, resp_update_project_service_account): + updated = gl.projects.get(1, lazy=True).service_accounts.update( + 99, {"name": "Renamed Project SA"} + ) + assert updated["name"] == "Renamed Project SA" + + +def test_save_project_service_account( + project_service_account, resp_update_project_service_account +): + project_service_account.name = "Renamed Project SA" + project_service_account.save() + + +def test_delete_project_service_account(gl, resp_delete_project_service_account): + gl.projects.get(1, lazy=True).service_accounts.delete(99) + + +def test_delete_project_service_account_via_object( + project_service_account, resp_delete_project_service_account +): + project_service_account.delete() + + +# --------------------------------------------------------------------------- +# Tests – project service account personal access tokens +# --------------------------------------------------------------------------- + + +def test_list_project_sa_tokens(project_service_account, resp_list_project_sa_tokens): + tokens = project_service_account.access_tokens.list() + assert len(tokens) == 1 + assert isinstance(tokens[0], ProjectServiceAccountAccessToken) + assert tokens[0].name == "proj-token" + + +def test_create_project_sa_token(project_service_account, resp_create_project_sa_token): + token = project_service_account.access_tokens.create( + {"name": "proj-token", "scopes": ["read_api"]} + ) + assert isinstance(token, ProjectServiceAccountAccessToken) + assert token.id == 2 + assert token.token == "glpat-proj-secret" + + +def test_delete_project_sa_token(project_service_account, resp_delete_project_sa_token): + project_service_account.access_tokens.delete(2) + + +def test_rotate_project_sa_token(project_service_account, resp_rotate_project_sa_token): + token = ProjectServiceAccountAccessToken( + project_service_account.access_tokens, proj_sa_token_content + ) + token.rotate() + assert token.token == "glpat-proj-rotated" + + +def test_rotate_project_sa_token_via_manager( + project_service_account, resp_rotate_project_sa_token +): + result = project_service_account.access_tokens.rotate(2) + assert result["token"] == "glpat-proj-rotated" From 2cf430b9c55c88f1d43fd31122a022dda0a88700 Mon Sep 17 00:00:00 2001 From: Frank Klaassen Date: Sun, 19 Apr 2026 16:35:54 +0200 Subject: [PATCH 08/10] fix(mixins): register service account token classes in RotateMixin CLI actions Add GroupServiceAccountAccessTokenManager, ProjectServiceAccountAccessTokenManager, GroupServiceAccountAccessToken, and ProjectServiceAccountAccessToken to the cli.register_custom_action cls_names in RotateMixin and ObjectRotateMixin. --- gitlab/mixins.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/gitlab/mixins.py b/gitlab/mixins.py index 51de97876..4e9dc39c5 100644 --- a/gitlab/mixins.py +++ b/gitlab/mixins.py @@ -619,6 +619,8 @@ class RotateMixin(base.RESTManager[base.TObjCls]): "PersonalAccessTokenManager", "GroupAccessTokenManager", "ProjectAccessTokenManager", + "GroupServiceAccountAccessTokenManager", + "ProjectServiceAccountAccessTokenManager", ), optional=("expires_at",), ) @@ -656,7 +658,13 @@ class ObjectRotateMixin(_RestObjectBase): manager: base.RESTManager[Any] @cli.register_custom_action( - cls_names=("PersonalAccessToken", "GroupAccessToken", "ProjectAccessToken"), + cls_names=( + "PersonalAccessToken", + "GroupAccessToken", + "ProjectAccessToken", + "GroupServiceAccountAccessToken", + "ProjectServiceAccountAccessToken", + ), optional=("expires_at",), ) @exc.on_http_error(exc.GitlabRotateError) From 9c277619973578d8165282f734b583932ae3cec2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:16:25 +0000 Subject: [PATCH 09/10] chore(deps): update all non-major dependencies --- .pre-commit-config.yaml | 4 ++-- requirements-lint.txt | 2 +- requirements-precommit.txt | 2 +- requirements-test.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cfc74fe6e..94e3f782f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: - requests-toolbelt==1.0.0 files: 'gitlab/' - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.1 + rev: v1.20.2 hooks: - id: mypy args: [] @@ -51,6 +51,6 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/maxbrunet/pre-commit-renovate - rev: 43.132.1 + rev: 43.141.2 hooks: - id: renovate-config-validator diff --git a/requirements-lint.txt b/requirements-lint.txt index 05b5ca496..cfc22accc 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -4,7 +4,7 @@ black==26.3.1 commitizen==4.13.10 flake8==7.3.0 isort==8.0.1 -mypy==1.20.1 +mypy==1.20.2 pylint==4.0.5 pytest==9.0.3 responses==0.26.0 diff --git a/requirements-precommit.txt b/requirements-precommit.txt index fc2379223..39d99cc57 100644 --- a/requirements-precommit.txt +++ b/requirements-precommit.txt @@ -1 +1 @@ -pre-commit==4.5.1 +pre-commit==4.6.0 diff --git a/requirements-test.txt b/requirements-test.txt index 55ccf1a19..69399ce41 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ -r requirements.txt anyio==4.13.0 -build==1.4.3 +build==1.4.4 coverage==7.13.5 pytest-console-scripts==1.4.1 pytest-cov==7.1.0 @@ -10,4 +10,4 @@ PyYaml==6.0.3 responses==0.26.0 respx==0.23.1 trio==0.33.0 -wheel==0.46.3 +wheel==0.47.0 From 5e0ee333db2f05349c54429ac11cdcd808acebc0 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 28 Apr 2026 02:32:01 +0000 Subject: [PATCH 10/10] chore: release v8.3.0 --- CHANGELOG.md | 16 ++++++++++++++++ gitlab/_version.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7692c338c..374528f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All versions below are listed in reverse chronological order. +## v8.3.0 (2026-04-28) + +### Bug Fixes + +- **api**: Update CI/CD variable create and update attributes + ([`849301b`](https://github.com/python-gitlab/python-gitlab/commit/849301b830b0d4f76ab85ffa3f39b22a6e628df9)) + +- **mixins**: Register service account token classes in RotateMixin CLI actions + ([`2cf430b`](https://github.com/python-gitlab/python-gitlab/commit/2cf430b9c55c88f1d43fd31122a022dda0a88700)) + +### Features + +- **api**: Add full service account support at instance, group, and project level + ([`feb6ced`](https://github.com/python-gitlab/python-gitlab/commit/feb6cedeffa75ef386ef13a3226bc07bcb396f61)) + + ## v8.2.0 (2026-03-28) ### Documentation diff --git a/gitlab/_version.py b/gitlab/_version.py index 82b2161e7..b1e5f614c 100644 --- a/gitlab/_version.py +++ b/gitlab/_version.py @@ -3,4 +3,4 @@ __email__ = "gauvainpocentek@gmail.com" __license__ = "LGPL3" __title__ = "python-gitlab" -__version__ = "8.2.0" +__version__ = "8.3.0"