Skip to content

Commit 80a1a84

Browse files
claudecs-rajreeshika-haniket-shikhare-cstk
committed
feat(branches): add optional environments list to branch create
Branch create accepts an optional branch.publish_config.environments list of environment names so a caller can clone only the environments it needs. An empty list clones none. Omitting publish_config sends the same body as before and clones all environments. The SDK passes the list through unvalidated and adds no length limit; an unknown name or a mix returns the API's 422 response unchanged and no branch is created. Documents the field and its three behaviors in the create docstring with an example. Adds mocked unit tests for the posted body (two names, [], duplicates, a 150-name list, mixed case, omitted, publish_config {}) and for a 422 returned unchanged, plus sanity integration tests for create with two names, [], an unknown name and a mix (not run here). Bumps the minor version to 1.12.0 and adds a changelog entry. Refs: DX-31563 Co-authored-by: Raj Pandey <raj.pandey@contentstack.com> Co-authored-by: Reeshika Hosmani <reeshika.hosmani@contentstack.com> Co-authored-by: Aniket Shikhare <aniket.shikhare@contentstack.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f81b09 commit 80a1a84

5 files changed

Lines changed: 184 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# CHANGELOG
22

33
## Content Management SDK For Python
4+
---
5+
## v1.12.0
6+
7+
#### Date: 28 October 2026
8+
9+
- Branch create: optional `branch.publish_config.environments` (environment names, not UIDs) clones only the listed environments. `[]` clones none; omitting `publish_config` clones all environments, as before. The list is sent unchanged; an unknown name returns the API's 422 response with its body unchanged and no branch is created.
10+
- Tests: unit tests for the branch create body with and without the environment list and for a mocked 422. Sanity integration tests for create with two names, `[]`, an unknown name and a mix (not run here).
11+
412
---
513
## v1.11.2
614

‎contentstack_management/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ def get_contentstack_endpoint(region='us', service='', omit_https=False):
102102
__author__ = 'dev-ex'
103103
__status__ = 'debug'
104104
__region__ = 'na'
105-
__version__ = '1.11.2'
105+
__version__ = '1.12.0'
106106
__host__ = 'api.contentstack.io'
107107
__protocol__ = 'https://'
108108
__api_version__ = 'v3'

‎contentstack_management/branches/branches.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ def create(self, data):
6464
r"""
6565
The Create a branch request creates a new branch in a particular stack of your organization.
6666
67+
Optionally set ``branch.publish_config.environments`` to a list of environment names
68+
(not UIDs) to choose which environments the branch clones:
69+
70+
- a list of names clones only those environments;
71+
- an empty list ``[]`` clones none;
72+
- omitting ``publish_config`` clones all environments, as before.
73+
74+
The list is sent unchanged. The SDK does not validate the names or limit the list length. An
75+
unknown name, or any other validation failure, returns the API's 422 response with its body
76+
unchanged, and no branch is created.
77+
6778
:param data: {json} -- Data required in json format to create a branch
6879
:return: Returns a branch with the given data
6980
@@ -80,6 +91,19 @@ def create(self, data):
8091
}
8192
>>> branch = contentstack_management.Client(authtoken='your_authtoken').stack(api_key='api_key').branch()
8293
>>> response = branch.create(data)
94+
95+
Clone only two environments (names, not UIDs):
96+
97+
>>> data = {
98+
"branch": {
99+
"uid": "release",
100+
"source": "main",
101+
"publish_config": {
102+
"environments": ["development", "staging"]
103+
}
104+
}
105+
}
106+
>>> response = branch.create(data)
83107
--------------------------------
84108
"""
85109
data = json.dumps(data)

‎tests/integration/api/test_18_branch.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,83 @@ def test_delete(self, stack, store):
5757
h.wait(h.LONG_DELAY)
5858
h.assert_status(resp, 200, 204)
5959
h.wait(h.SHORT_DELAY)
60+
61+
62+
def _environment_names(stack):
63+
resp = stack.environments().find()
64+
h.assert_status(resp, 200)
65+
return [env["name"] for env in h.body(resp).get("environments", [])]
66+
67+
68+
def _create_with_environments(stack, uid, environments):
69+
data = {"branch": {"uid": uid, "source": "main", "publish_config": {"environments": environments}}}
70+
return stack.branch().create(data)
71+
72+
73+
def _branch_environments(response):
74+
return h.body(response).get("branch", {}).get("publish_config", {}).get("environments")
75+
76+
77+
def _delete_created_branch(stack, uid):
78+
# Same retry as TestBranchDelete: a freshly created branch can briefly be "not valid" (422, code 905).
79+
for _ in range(4):
80+
branch = stack.branch(uid)
81+
branch.add_param("force", "true")
82+
resp = branch.delete()
83+
if resp.status_code in (200, 204):
84+
return
85+
h.wait(h.LONG_DELAY)
86+
raise AssertionError(f"branch {uid} was not deleted after retries; status {resp.status_code}")
87+
88+
89+
class TestBranchEnvironmentList:
90+
"""Branch create with publish_config.environments. Each test deletes its branch (stack branch cap)."""
91+
92+
def test_create_two_names_clones_only_those(self, stack):
93+
names = _environment_names(stack)
94+
if len(names) < 2:
95+
pytest.skip("stack has fewer than two environments")
96+
wanted = names[:2]
97+
uid = h.generate_valid_uid("branch_env")
98+
resp = _create_with_environments(stack, uid, wanted)
99+
try:
100+
h.assert_status(resp, 201)
101+
assert _branch_environments(resp) == wanted
102+
h.wait(h.LONG_DELAY)
103+
fetched = stack.branch(uid).fetch()
104+
h.assert_status(fetched, 200)
105+
assert _branch_environments(fetched) == wanted
106+
finally:
107+
if resp.status_code == 201:
108+
_delete_created_branch(stack, uid)
109+
110+
def test_create_empty_list_clones_none(self, stack):
111+
uid = h.generate_valid_uid("branch_env")
112+
resp = _create_with_environments(stack, uid, [])
113+
try:
114+
h.assert_status(resp, 201)
115+
assert _branch_environments(resp) == []
116+
h.wait(h.LONG_DELAY)
117+
fetched = stack.branch(uid).fetch()
118+
h.assert_status(fetched, 200)
119+
assert _branch_environments(fetched) == []
120+
finally:
121+
if resp.status_code == 201:
122+
_delete_created_branch(stack, uid)
123+
124+
def test_unknown_name_returns_422_and_creates_no_branch(self, stack):
125+
uid = h.generate_valid_uid("branch_env")
126+
resp = _create_with_environments(stack, uid, ["no_such_env_for_integration"])
127+
h.assert_status(resp, 422)
128+
assert "publish_config.environments" in h.body(resp).get("errors", {})
129+
h.assert_status(stack.branch(uid).fetch(), 404)
130+
131+
def test_mix_of_valid_and_unknown_names_returns_422_and_creates_no_branch(self, stack):
132+
names = _environment_names(stack)
133+
if not names:
134+
pytest.skip("stack has no environments")
135+
uid = h.generate_valid_uid("branch_env")
136+
resp = _create_with_environments(stack, uid, [names[0], "no_such_env_for_integration"])
137+
h.assert_status(resp, 422)
138+
assert "publish_config.environments" in h.body(resp).get("errors", {})
139+
h.assert_status(stack.branch(uid).fetch(), 404)

‎tests/unit/branches/test_branch.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import json
12
import unittest
3+
from unittest import mock
24

35

46
import contentstack_management
@@ -49,3 +51,72 @@ def test_delete_branch(self):
4951
self.assertEqual(response.request.url, f"{self.client.endpoint}stacks/branches/{branch_uid}")
5052
self.assertEqual(response.request.method, "DELETE")
5153
self.assertEqual(response.request.headers["Content-Type"], "application/json")
54+
55+
56+
class BranchCreateEnvironmentsUnitTests(unittest.TestCase):
57+
"""Branch create with publish_config.environments, mocked at the HTTP layer (no credentials)."""
58+
59+
def setUp(self):
60+
self.client = contentstack_management.Client(host=host)
61+
self.requests_patch = mock.patch("contentstack_management._api_client.requests.request")
62+
self.request = self.requests_patch.start()
63+
self.request.return_value = mock.Mock(status_code=200, json=lambda: {"branch": {"uid": "release"}})
64+
self.addCleanup(self.requests_patch.stop)
65+
66+
def _create(self, data):
67+
return self.client.stack(api_key).branch().create(data)
68+
69+
def _posted_body(self):
70+
self.assertEqual(self.request.call_count, 1)
71+
kwargs = self.request.call_args.kwargs
72+
self.assertEqual(self.request.call_args.args[0], "POST")
73+
self.assertEqual(self.request.call_args.args[1], f"{self.client.endpoint}stacks/branches")
74+
return json.loads(kwargs["data"])
75+
76+
def test_two_environment_names_are_posted_as_given(self):
77+
data = {"branch": {"uid": "release", "source": "main",
78+
"publish_config": {"environments": ["development", "staging"]}}}
79+
self._create(data)
80+
self.assertEqual(self._posted_body(), data)
81+
82+
def test_empty_environment_list_is_posted_as_empty_list(self):
83+
data = {"branch": {"uid": "release", "source": "main", "publish_config": {"environments": []}}}
84+
self._create(data)
85+
self.assertEqual(self._posted_body()["branch"]["publish_config"], {"environments": []})
86+
87+
def test_duplicates_long_list_and_mixed_case_are_posted_unchanged(self):
88+
cases = [
89+
["development", "development"],
90+
[f"env_{i}" for i in range(150)],
91+
["Development", " staging ", "PRODUCTION"],
92+
]
93+
for environments in cases:
94+
with self.subTest(count=len(environments)):
95+
self.request.reset_mock()
96+
self._create({"branch": {"uid": "release", "source": "main",
97+
"publish_config": {"environments": environments}}})
98+
self.assertEqual(self._posted_body()["branch"]["publish_config"]["environments"], environments)
99+
100+
def test_omitted_environment_list_posts_todays_body(self):
101+
data = {"branch": {"uid": "release", "source": "main"}}
102+
self._create(data)
103+
self.assertEqual(self._posted_body(), data)
104+
self.assertNotIn("publish_config", self._posted_body()["branch"])
105+
106+
def test_empty_publish_config_is_posted_as_empty_object(self):
107+
self._create({"branch": {"uid": "release", "source": "main", "publish_config": {}}})
108+
self.assertEqual(self._posted_body()["branch"]["publish_config"], {})
109+
110+
def test_api_422_for_environment_list_is_returned_unchanged(self):
111+
api_body = {
112+
"error_message": "Environment not found.",
113+
"error_code": 422,
114+
"errors": {"publish_config.environments": ["no_such_env"]},
115+
}
116+
self.request.return_value = mock.Mock(status_code=422, json=lambda: api_body)
117+
response = self._create({"branch": {"uid": "release", "source": "main",
118+
"publish_config": {"environments": ["development", "no_such_env"]}}})
119+
self.assertEqual(response.status_code, 422)
120+
self.assertEqual(response.json(), api_body)
121+
self.assertEqual(self._posted_body()["branch"]["publish_config"]["environments"],
122+
["development", "no_such_env"])

0 commit comments

Comments
 (0)