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"])