Skip to content

Commit de83fe4

Browse files
committed
feat(api): add support for project feature flags
Add support for the Project Feature Flags API. - Add `ProjectFeatureFlag` and `ProjectFeatureFlagManager`. - Add `project.feature_flags` manager. - Add functional tests for API and CLI. - Handle JSON parsing for `strategies` attribute in CLI commands by overriding create/update methods in the manager.
1 parent 276b84f commit de83fe4

File tree

5 files changed

+284
-0
lines changed

5 files changed

+284
-0
lines changed

gitlab/v4/objects/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from .epics import *
2525
from .events import *
2626
from .export_import import *
27+
from .feature_flags import *
2728
from .features import *
2829
from .files import *
2930
from .geo_nodes import *

gitlab/v4/objects/feature_flags.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
GitLab API:
3+
https://docs.gitlab.com/ee/api/feature_flags.html
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
from typing import Any
10+
11+
from gitlab.base import RESTObject
12+
from gitlab.mixins import CRUDMixin, ObjectDeleteMixin, SaveMixin
13+
from gitlab.types import RequiredOptional
14+
15+
__all__ = ["ProjectFeatureFlag", "ProjectFeatureFlagManager"]
16+
17+
18+
class ProjectFeatureFlag(SaveMixin, ObjectDeleteMixin, RESTObject):
19+
_id_attr = "name"
20+
21+
22+
class ProjectFeatureFlagManager(CRUDMixin[ProjectFeatureFlag]):
23+
_path = "/projects/{project_id}/feature_flags"
24+
_obj_cls = ProjectFeatureFlag
25+
_from_parent_attrs = {"project_id": "id"}
26+
_create_attrs = RequiredOptional(
27+
required=("name",), optional=("version", "description", "active", "strategies")
28+
)
29+
_update_attrs = RequiredOptional(optional=("description", "active", "strategies"))
30+
_list_filters = ("scope",)
31+
32+
def create(
33+
self, data: dict[str, Any] | None = None, **kwargs: Any
34+
) -> ProjectFeatureFlag:
35+
"""Create a new object.
36+
37+
Args:
38+
data: Parameters to send to the server to create the
39+
resource
40+
**kwargs: Extra options to send to the server (e.g. sudo)
41+
42+
Returns:
43+
A new instance of the managed object class build with
44+
the data sent by the server
45+
"""
46+
# Handle strategies being passed as a JSON string (e.g. from CLI)
47+
if "strategies" in kwargs and isinstance(kwargs["strategies"], str):
48+
kwargs["strategies"] = json.loads(kwargs["strategies"])
49+
if data and "strategies" in data and isinstance(data["strategies"], str):
50+
data["strategies"] = json.loads(data["strategies"])
51+
52+
return super().create(data, **kwargs)
53+
54+
def update(
55+
self,
56+
id: str | int | None = None,
57+
new_data: dict[str, Any] | None = None,
58+
**kwargs: Any,
59+
) -> dict[str, Any]:
60+
"""Update an object on the server.
61+
62+
Args:
63+
id: ID of the object to update (can be None if not required)
64+
new_data: the update data for the object
65+
**kwargs: Extra options to send to the server (e.g. sudo)
66+
67+
Returns:
68+
The new object data (*not* a RESTObject)
69+
"""
70+
# Handle strategies being passed as a JSON string (e.g. from CLI)
71+
if "strategies" in kwargs and isinstance(kwargs["strategies"], str):
72+
kwargs["strategies"] = json.loads(kwargs["strategies"])
73+
if (
74+
new_data
75+
and "strategies" in new_data
76+
and isinstance(new_data["strategies"], str)
77+
):
78+
new_data["strategies"] = json.loads(new_data["strategies"])
79+
80+
return super().update(id, new_data, **kwargs)

gitlab/v4/objects/projects.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
)
5050
from .events import ProjectEventManager # noqa: F401
5151
from .export_import import ProjectExportManager, ProjectImportManager # noqa: F401
52+
from .feature_flags import ProjectFeatureFlagManager # noqa: F401
5253
from .files import ProjectFileManager # noqa: F401
5354
from .hooks import ProjectHookManager # noqa: F401
5455
from .integrations import ProjectIntegrationManager, ProjectServiceManager # noqa: F401
@@ -201,6 +202,7 @@ class Project(
201202
environments: ProjectEnvironmentManager
202203
events: ProjectEventManager
203204
exports: ProjectExportManager
205+
feature_flags: ProjectFeatureFlagManager
204206
files: ProjectFileManager
205207
forks: ProjectForkManager
206208
generic_packages: GenericPackageManager
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import pytest
2+
3+
from gitlab import exceptions
4+
5+
6+
@pytest.fixture
7+
def feature_flag(project):
8+
flag_name = "test_flag_fixture"
9+
flag = project.feature_flags.create(
10+
{"name": flag_name, "version": "new_version_flag"}
11+
)
12+
yield flag
13+
try:
14+
flag.delete()
15+
except exceptions.GitlabDeleteError:
16+
pass
17+
18+
19+
def test_create_feature_flag(project):
20+
flag_name = "test_flag_create"
21+
flag = project.feature_flags.create(
22+
{"name": flag_name, "version": "new_version_flag"}
23+
)
24+
assert flag.name == flag_name
25+
assert flag.active is True
26+
flag.delete()
27+
28+
29+
def test_create_feature_flag_with_strategies(project):
30+
flag_name = "test_flag_strategies"
31+
strategies = [{"name": "userWithId", "parameters": {"userIds": "user1"}}]
32+
flag = project.feature_flags.create(
33+
{"name": flag_name, "version": "new_version_flag", "strategies": strategies}
34+
)
35+
assert len(flag.strategies) == 1
36+
assert flag.strategies[0]["name"] == "userWithId"
37+
assert flag.strategies[0]["parameters"]["userIds"] == "user1"
38+
flag.delete()
39+
40+
41+
def test_list_feature_flags(project, feature_flag):
42+
flags = project.feature_flags.list()
43+
assert len(flags) >= 1
44+
assert feature_flag.name in [f.name for f in flags]
45+
46+
47+
def test_update_feature_flag(project, feature_flag):
48+
feature_flag.active = False
49+
feature_flag.save()
50+
51+
updated_flag = project.feature_flags.get(feature_flag.name)
52+
assert updated_flag.active is False
53+
54+
55+
def test_delete_feature_flag(project, feature_flag):
56+
feature_flag.delete()
57+
with pytest.raises(exceptions.GitlabGetError):
58+
project.feature_flags.get(feature_flag.name)
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import json
2+
3+
import pytest
4+
5+
6+
@pytest.fixture
7+
def feature_flag_cli(gitlab_cli, project):
8+
flag_name = "test_flag_cli_fixture"
9+
cmd = [
10+
"project-feature-flag",
11+
"create",
12+
"--project-id",
13+
str(project.id),
14+
"--name",
15+
flag_name,
16+
]
17+
gitlab_cli(cmd)
18+
yield flag_name
19+
try:
20+
cmd = [
21+
"project-feature-flag",
22+
"delete",
23+
"--project-id",
24+
str(project.id),
25+
"--name",
26+
flag_name,
27+
]
28+
gitlab_cli(cmd)
29+
except Exception:
30+
pass
31+
32+
33+
def test_project_feature_flag_cli_create_delete(gitlab_cli, project):
34+
flag_name = "test_flag_cli_create"
35+
cmd = [
36+
"project-feature-flag",
37+
"create",
38+
"--project-id",
39+
str(project.id),
40+
"--name",
41+
flag_name,
42+
]
43+
ret = gitlab_cli(cmd)
44+
assert ret.success
45+
assert flag_name in ret.stdout
46+
47+
cmd = [
48+
"project-feature-flag",
49+
"delete",
50+
"--project-id",
51+
str(project.id),
52+
"--name",
53+
flag_name,
54+
]
55+
ret = gitlab_cli(cmd)
56+
assert ret.success
57+
58+
59+
def test_project_feature_flag_cli_create_with_strategies(gitlab_cli, project):
60+
flag_name = "test_flag_cli_strategies"
61+
strategies_json = (
62+
'[{"name": "userWithId", "parameters": {"userIds": "user1,user2"}}]'
63+
)
64+
65+
cmd = [
66+
"project-feature-flag",
67+
"create",
68+
"--project-id",
69+
str(project.id),
70+
"--name",
71+
flag_name,
72+
"--strategies",
73+
strategies_json,
74+
]
75+
ret = gitlab_cli(cmd)
76+
assert ret.success
77+
78+
cmd = [
79+
"-o",
80+
"json",
81+
"project-feature-flag",
82+
"get",
83+
"--project-id",
84+
str(project.id),
85+
"--name",
86+
flag_name,
87+
]
88+
ret = gitlab_cli(cmd)
89+
assert ret.success
90+
data = json.loads(ret.stdout)
91+
assert len(data["strategies"]) == 1
92+
assert data["strategies"][0]["name"] == "userWithId"
93+
94+
95+
def test_project_feature_flag_cli_list(gitlab_cli, project, feature_flag_cli):
96+
cmd = ["project-feature-flag", "list", "--project-id", str(project.id)]
97+
ret = gitlab_cli(cmd)
98+
assert ret.success
99+
assert feature_flag_cli in ret.stdout
100+
101+
102+
def test_project_feature_flag_cli_get(gitlab_cli, project, feature_flag_cli):
103+
cmd = [
104+
"project-feature-flag",
105+
"get",
106+
"--project-id",
107+
str(project.id),
108+
"--name",
109+
feature_flag_cli,
110+
]
111+
ret = gitlab_cli(cmd)
112+
assert ret.success
113+
assert feature_flag_cli in ret.stdout
114+
115+
116+
def test_project_feature_flag_cli_update(gitlab_cli, project, feature_flag_cli):
117+
cmd = [
118+
"project-feature-flag",
119+
"update",
120+
"--project-id",
121+
str(project.id),
122+
"--name",
123+
feature_flag_cli,
124+
"--active",
125+
"false",
126+
]
127+
ret = gitlab_cli(cmd)
128+
assert ret.success
129+
130+
cmd = [
131+
"-o",
132+
"json",
133+
"project-feature-flag",
134+
"get",
135+
"--project-id",
136+
str(project.id),
137+
"--name",
138+
feature_flag_cli,
139+
]
140+
ret = gitlab_cli(cmd)
141+
assert ret.success
142+
data = json.loads(ret.stdout)
143+
assert data["active"] is False

0 commit comments

Comments
 (0)