Skip to content

Commit 2506a62

Browse files
authored
Merge pull request #195 from contentstack/epic/DX-31560-branch-create-environments
feat(branches): optional environment list on branch create (DX-31560)
2 parents 7f81b09 + 68736ad commit 2506a62

5 files changed

Lines changed: 241 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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,140 @@ 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 _create_branch(stack, uid, environments=None):
63+
branch = {"uid": uid, "source": "main"}
64+
if environments is not None:
65+
branch["publish_config"] = {"environments": environments}
66+
return stack.branch().create({"branch": branch})
67+
68+
69+
def _branch(response):
70+
return h.body(response).get("branch", {})
71+
72+
73+
def _branch_environments(response):
74+
return (_branch(response).get("publish_config") or {}).get("environments")
75+
76+
77+
def _fetch_when_ready(stack, uid):
78+
# Branch provisioning is async: poll until the fetch succeeds
79+
resp = None
80+
for _ in range(10):
81+
resp = stack.branch(uid).fetch()
82+
if resp.status_code == 200:
83+
return resp
84+
h.wait(h.SHORT_DELAY)
85+
return resp
86+
87+
88+
def _delete_created_branch(stack, uid):
89+
# Same retry as TestBranchDelete: a freshly created branch can briefly be "not valid" (422, code 905).
90+
resp = None
91+
for _ in range(4):
92+
branch = stack.branch(uid)
93+
branch.add_param("force", "true")
94+
resp = branch.delete()
95+
if resp.status_code in (200, 204):
96+
return
97+
h.wait(h.LONG_DELAY)
98+
raise AssertionError(f"branch {uid} was not deleted after retries; status {resp.status_code}")
99+
100+
101+
def _not_found(response):
102+
return response.status_code, h.body(response).get("error_code")
103+
104+
105+
@pytest.fixture(scope="class")
106+
def env_names(stack):
107+
"""Three environments of this class, so a list of two is a strict subset; deleted after."""
108+
names = [h.generate_valid_uid("bev") for _ in range(3)]
109+
for name in names:
110+
data = {"environment": {"name": name, "urls": [{"url": "https://example.com", "locale": "en-us"}]}}
111+
h.assert_status(stack.environments().create(data), 201)
112+
yield names
113+
for name in names:
114+
stack.environments(name).delete()
115+
116+
117+
@pytest.fixture(scope="class")
118+
def nonexistent(stack):
119+
"""The response for a branch that does not exist (dev11: 422, error_code 905)."""
120+
resp = stack.branch(h.generate_valid_uid("nob")).fetch()
121+
assert resp.status_code != 200
122+
return _not_found(resp)
123+
124+
125+
class TestBranchEnvironmentList:
126+
"""Branch create with publish_config.environments. Each test deletes its branch (stack branch cap)."""
127+
128+
def test_create_two_names_clones_only_those(self, stack, env_names):
129+
wanted = env_names[:2]
130+
uid = h.generate_valid_uid("bev")
131+
resp = _create_branch(stack, uid, wanted)
132+
try:
133+
h.assert_status(resp, 201)
134+
assert sorted(_branch_environments(resp)) == sorted(wanted)
135+
fetched = _fetch_when_ready(stack, uid)
136+
h.assert_status(fetched, 200)
137+
assert sorted(_branch_environments(fetched)) == sorted(wanted)
138+
assert env_names[2] not in _branch_environments(fetched)
139+
finally:
140+
if resp.status_code == 201:
141+
_delete_created_branch(stack, uid)
142+
143+
def test_create_one_name_reads_back_that_one(self, stack, env_names):
144+
uid = h.generate_valid_uid("bev")
145+
resp = _create_branch(stack, uid, env_names[:1])
146+
try:
147+
h.assert_status(resp, 201)
148+
fetched = _fetch_when_ready(stack, uid)
149+
h.assert_status(fetched, 200)
150+
assert _branch_environments(fetched) == env_names[:1]
151+
finally:
152+
if resp.status_code == 201:
153+
_delete_created_branch(stack, uid)
154+
155+
def test_create_empty_list_clones_none(self, stack, env_names):
156+
uid = h.generate_valid_uid("bev")
157+
resp = _create_branch(stack, uid, [])
158+
try:
159+
h.assert_status(resp, 201)
160+
assert _branch_environments(resp) == []
161+
fetched = _fetch_when_ready(stack, uid)
162+
h.assert_status(fetched, 200)
163+
assert "publish_config" in _branch(fetched)
164+
assert _branch_environments(fetched) == []
165+
finally:
166+
if resp.status_code == 201:
167+
_delete_created_branch(stack, uid)
168+
169+
def test_create_without_list_reads_back_no_publish_config(self, stack, env_names):
170+
uid = h.generate_valid_uid("bev")
171+
resp = _create_branch(stack, uid)
172+
try:
173+
h.assert_status(resp, 201)
174+
fetched = _fetch_when_ready(stack, uid)
175+
h.assert_status(fetched, 200)
176+
assert "publish_config" not in _branch(fetched)
177+
finally:
178+
if resp.status_code == 201:
179+
_delete_created_branch(stack, uid)
180+
181+
@pytest.mark.parametrize("case", ["unknown", "other_case", "mix"])
182+
def test_rejected_names_return_422_and_create_no_branch(self, stack, env_names, nonexistent, case):
183+
environments = {
184+
"unknown": [h.generate_valid_uid("nos")],
185+
"other_case": [env_names[0].upper()],
186+
"mix": [env_names[0], h.generate_valid_uid("nos")],
187+
}[case]
188+
uid = h.generate_valid_uid("bev")
189+
resp = _create_branch(stack, uid, environments)
190+
fetched = stack.branch(uid).fetch()
191+
if fetched.status_code == 200:
192+
_delete_created_branch(stack, uid)
193+
h.assert_status(resp, 422)
194+
assert h.body(resp).get("error_message")
195+
assert "publish_config.environments" in h.body(resp).get("errors", {})
196+
assert _not_found(fetched) == nonexistent, "a rejected create must leave no branch behind"

‎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)