Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
# CHANGELOG

## Content Management SDK For Python
---
## v1.12.0

#### Date: 28 October 2026

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

---
## v1.11.2

Expand Down
2 changes: 1 addition & 1 deletion contentstack_management/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def get_contentstack_endpoint(region='us', service='', omit_https=False):
__author__ = 'dev-ex'
__status__ = 'debug'
__region__ = 'na'
__version__ = '1.11.2'
__version__ = '1.12.0'
__host__ = 'api.contentstack.io'
__protocol__ = 'https://'
__api_version__ = 'v3'
Expand Down
24 changes: 24 additions & 0 deletions contentstack_management/branches/branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,17 @@ def create(self, data):
r"""
The Create a branch request creates a new branch in a particular stack of your organization.

Optionally set ``branch.publish_config.environments`` to a list of environment names
(not UIDs) to choose which environments the branch clones:

- a list of names clones only those environments;
- an empty list ``[]`` clones none;
- omitting ``publish_config`` clones all environments, as before.

The list is sent unchanged. The SDK does not validate the names or limit the list length. An
unknown name, or any other validation failure, returns the API's 422 response with its body
unchanged, and no branch is created.

:param data: {json} -- Data required in json format to create a branch
:return: Returns a branch with the given data

Expand All @@ -80,6 +91,19 @@ def create(self, data):
}
>>> branch = contentstack_management.Client(authtoken='your_authtoken').stack(api_key='api_key').branch()
>>> response = branch.create(data)

Clone only two environments (names, not UIDs):

>>> data = {
"branch": {
"uid": "release",
"source": "main",
"publish_config": {
"environments": ["development", "staging"]
}
}
}
>>> response = branch.create(data)
--------------------------------
"""
data = json.dumps(data)
Expand Down
80 changes: 80 additions & 0 deletions tests/integration/api/test_18_branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,83 @@ def test_delete(self, stack, store):
h.wait(h.LONG_DELAY)
h.assert_status(resp, 200, 204)
h.wait(h.SHORT_DELAY)


def _environment_names(stack):
resp = stack.environments().find()
h.assert_status(resp, 200)
return [env["name"] for env in h.body(resp).get("environments", [])]


def _create_with_environments(stack, uid, environments):
data = {"branch": {"uid": uid, "source": "main", "publish_config": {"environments": environments}}}
return stack.branch().create(data)


def _branch_environments(response):
return h.body(response).get("branch", {}).get("publish_config", {}).get("environments")


def _delete_created_branch(stack, uid):
# Same retry as TestBranchDelete: a freshly created branch can briefly be "not valid" (422, code 905).
for _ in range(4):
branch = stack.branch(uid)
branch.add_param("force", "true")
resp = branch.delete()
if resp.status_code in (200, 204):
return
h.wait(h.LONG_DELAY)
raise AssertionError(f"branch {uid} was not deleted after retries; status {resp.status_code}")


class TestBranchEnvironmentList:
"""Branch create with publish_config.environments. Each test deletes its branch (stack branch cap)."""

def test_create_two_names_clones_only_those(self, stack):
names = _environment_names(stack)
if len(names) < 2:
pytest.skip("stack has fewer than two environments")
wanted = names[:2]
uid = h.generate_valid_uid("branch_env")
resp = _create_with_environments(stack, uid, wanted)
try:
h.assert_status(resp, 201)
assert _branch_environments(resp) == wanted
h.wait(h.LONG_DELAY)
fetched = stack.branch(uid).fetch()
h.assert_status(fetched, 200)
assert _branch_environments(fetched) == wanted
finally:
if resp.status_code == 201:
_delete_created_branch(stack, uid)

def test_create_empty_list_clones_none(self, stack):
uid = h.generate_valid_uid("branch_env")
resp = _create_with_environments(stack, uid, [])
try:
h.assert_status(resp, 201)
assert _branch_environments(resp) == []
h.wait(h.LONG_DELAY)
fetched = stack.branch(uid).fetch()
h.assert_status(fetched, 200)
assert _branch_environments(fetched) == []
finally:
if resp.status_code == 201:
_delete_created_branch(stack, uid)

def test_unknown_name_returns_422_and_creates_no_branch(self, stack):
uid = h.generate_valid_uid("branch_env")
resp = _create_with_environments(stack, uid, ["no_such_env_for_integration"])
h.assert_status(resp, 422)
assert "publish_config.environments" in h.body(resp).get("errors", {})
h.assert_status(stack.branch(uid).fetch(), 404)

def test_mix_of_valid_and_unknown_names_returns_422_and_creates_no_branch(self, stack):
names = _environment_names(stack)
if not names:
pytest.skip("stack has no environments")
uid = h.generate_valid_uid("branch_env")
resp = _create_with_environments(stack, uid, [names[0], "no_such_env_for_integration"])
h.assert_status(resp, 422)
assert "publish_config.environments" in h.body(resp).get("errors", {})
h.assert_status(stack.branch(uid).fetch(), 404)
71 changes: 71 additions & 0 deletions tests/unit/branches/test_branch.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import json
import unittest
from unittest import mock


import contentstack_management
Expand Down Expand Up @@ -49,3 +51,72 @@ def test_delete_branch(self):
self.assertEqual(response.request.url, f"{self.client.endpoint}stacks/branches/{branch_uid}")
self.assertEqual(response.request.method, "DELETE")
self.assertEqual(response.request.headers["Content-Type"], "application/json")


class BranchCreateEnvironmentsUnitTests(unittest.TestCase):
"""Branch create with publish_config.environments, mocked at the HTTP layer (no credentials)."""

def setUp(self):
self.client = contentstack_management.Client(host=host)
self.requests_patch = mock.patch("contentstack_management._api_client.requests.request")
self.request = self.requests_patch.start()
self.request.return_value = mock.Mock(status_code=200, json=lambda: {"branch": {"uid": "release"}})
self.addCleanup(self.requests_patch.stop)

def _create(self, data):
return self.client.stack(api_key).branch().create(data)

def _posted_body(self):
self.assertEqual(self.request.call_count, 1)
kwargs = self.request.call_args.kwargs
self.assertEqual(self.request.call_args.args[0], "POST")
self.assertEqual(self.request.call_args.args[1], f"{self.client.endpoint}stacks/branches")
return json.loads(kwargs["data"])

def test_two_environment_names_are_posted_as_given(self):
data = {"branch": {"uid": "release", "source": "main",
"publish_config": {"environments": ["development", "staging"]}}}
self._create(data)
self.assertEqual(self._posted_body(), data)

def test_empty_environment_list_is_posted_as_empty_list(self):
data = {"branch": {"uid": "release", "source": "main", "publish_config": {"environments": []}}}
self._create(data)
self.assertEqual(self._posted_body()["branch"]["publish_config"], {"environments": []})

def test_duplicates_long_list_and_mixed_case_are_posted_unchanged(self):
cases = [
["development", "development"],
[f"env_{i}" for i in range(150)],
["Development", " staging ", "PRODUCTION"],
]
for environments in cases:
with self.subTest(count=len(environments)):
self.request.reset_mock()
self._create({"branch": {"uid": "release", "source": "main",
"publish_config": {"environments": environments}}})
self.assertEqual(self._posted_body()["branch"]["publish_config"]["environments"], environments)

def test_omitted_environment_list_posts_todays_body(self):
data = {"branch": {"uid": "release", "source": "main"}}
self._create(data)
self.assertEqual(self._posted_body(), data)
self.assertNotIn("publish_config", self._posted_body()["branch"])

def test_empty_publish_config_is_posted_as_empty_object(self):
self._create({"branch": {"uid": "release", "source": "main", "publish_config": {}}})
self.assertEqual(self._posted_body()["branch"]["publish_config"], {})

def test_api_422_for_environment_list_is_returned_unchanged(self):
api_body = {
"error_message": "Environment not found.",
"error_code": 422,
"errors": {"publish_config.environments": ["no_such_env"]},
}
self.request.return_value = mock.Mock(status_code=422, json=lambda: api_body)
response = self._create({"branch": {"uid": "release", "source": "main",
"publish_config": {"environments": ["development", "no_such_env"]}}})
self.assertEqual(response.status_code, 422)
self.assertEqual(response.json(), api_body)
self.assertEqual(self._posted_body()["branch"]["publish_config"]["environments"],
["development", "no_such_env"])
Loading