From 80a1a84c47db3faa799e6eb91374756414fdcff2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:59:52 +0000 Subject: [PATCH] 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 Co-authored-by: Reeshika Hosmani Co-authored-by: Aniket Shikhare Co-authored-by: Claude --- CHANGELOG.md | 8 ++ contentstack_management/__init__.py | 2 +- contentstack_management/branches/branches.py | 24 ++++++ tests/integration/api/test_18_branch.py | 80 ++++++++++++++++++++ tests/unit/branches/test_branch.py | 71 +++++++++++++++++ 5 files changed, 184 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9539610..aa95f85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/contentstack_management/__init__.py b/contentstack_management/__init__.py index e038906..98222aa 100644 --- a/contentstack_management/__init__.py +++ b/contentstack_management/__init__.py @@ -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' diff --git a/contentstack_management/branches/branches.py b/contentstack_management/branches/branches.py index 8da5d3c..cc7594d 100644 --- a/contentstack_management/branches/branches.py +++ b/contentstack_management/branches/branches.py @@ -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 @@ -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) diff --git a/tests/integration/api/test_18_branch.py b/tests/integration/api/test_18_branch.py index a00bcf5..9b0cfa0 100644 --- a/tests/integration/api/test_18_branch.py +++ b/tests/integration/api/test_18_branch.py @@ -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) diff --git a/tests/unit/branches/test_branch.py b/tests/unit/branches/test_branch.py index b37cfd7..2f73584 100644 --- a/tests/unit/branches/test_branch.py +++ b/tests/unit/branches/test_branch.py @@ -1,4 +1,6 @@ +import json import unittest +from unittest import mock import contentstack_management @@ -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"])