Skip to content

Commit 12f76cf

Browse files
authored
Merge pull request #194 from contentstack/claude/feat/DX-31563-py-branch-create-environments
feat(branches): add optional environments list to branch create (DX-31563)
2 parents 7f81b09 + 80a1a84 commit 12f76cf

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)