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..fa6aac8 100644 --- a/tests/integration/api/test_18_branch.py +++ b/tests/integration/api/test_18_branch.py @@ -57,3 +57,140 @@ def test_delete(self, stack, store): h.wait(h.LONG_DELAY) h.assert_status(resp, 200, 204) h.wait(h.SHORT_DELAY) + + +def _create_branch(stack, uid, environments=None): + branch = {"uid": uid, "source": "main"} + if environments is not None: + branch["publish_config"] = {"environments": environments} + return stack.branch().create({"branch": branch}) + + +def _branch(response): + return h.body(response).get("branch", {}) + + +def _branch_environments(response): + return (_branch(response).get("publish_config") or {}).get("environments") + + +def _fetch_when_ready(stack, uid): + # Branch provisioning is async: poll until the fetch succeeds + resp = None + for _ in range(10): + resp = stack.branch(uid).fetch() + if resp.status_code == 200: + return resp + h.wait(h.SHORT_DELAY) + return resp + + +def _delete_created_branch(stack, uid): + # Same retry as TestBranchDelete: a freshly created branch can briefly be "not valid" (422, code 905). + resp = None + 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}") + + +def _not_found(response): + return response.status_code, h.body(response).get("error_code") + + +@pytest.fixture(scope="class") +def env_names(stack): + """Three environments of this class, so a list of two is a strict subset; deleted after.""" + names = [h.generate_valid_uid("bev") for _ in range(3)] + for name in names: + data = {"environment": {"name": name, "urls": [{"url": "https://example.com", "locale": "en-us"}]}} + h.assert_status(stack.environments().create(data), 201) + yield names + for name in names: + stack.environments(name).delete() + + +@pytest.fixture(scope="class") +def nonexistent(stack): + """The response for a branch that does not exist (dev11: 422, error_code 905).""" + resp = stack.branch(h.generate_valid_uid("nob")).fetch() + assert resp.status_code != 200 + return _not_found(resp) + + +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, env_names): + wanted = env_names[:2] + uid = h.generate_valid_uid("bev") + resp = _create_branch(stack, uid, wanted) + try: + h.assert_status(resp, 201) + assert sorted(_branch_environments(resp)) == sorted(wanted) + fetched = _fetch_when_ready(stack, uid) + h.assert_status(fetched, 200) + assert sorted(_branch_environments(fetched)) == sorted(wanted) + assert env_names[2] not in _branch_environments(fetched) + finally: + if resp.status_code == 201: + _delete_created_branch(stack, uid) + + def test_create_one_name_reads_back_that_one(self, stack, env_names): + uid = h.generate_valid_uid("bev") + resp = _create_branch(stack, uid, env_names[:1]) + try: + h.assert_status(resp, 201) + fetched = _fetch_when_ready(stack, uid) + h.assert_status(fetched, 200) + assert _branch_environments(fetched) == env_names[:1] + finally: + if resp.status_code == 201: + _delete_created_branch(stack, uid) + + def test_create_empty_list_clones_none(self, stack, env_names): + uid = h.generate_valid_uid("bev") + resp = _create_branch(stack, uid, []) + try: + h.assert_status(resp, 201) + assert _branch_environments(resp) == [] + fetched = _fetch_when_ready(stack, uid) + h.assert_status(fetched, 200) + assert "publish_config" in _branch(fetched) + assert _branch_environments(fetched) == [] + finally: + if resp.status_code == 201: + _delete_created_branch(stack, uid) + + def test_create_without_list_reads_back_no_publish_config(self, stack, env_names): + uid = h.generate_valid_uid("bev") + resp = _create_branch(stack, uid) + try: + h.assert_status(resp, 201) + fetched = _fetch_when_ready(stack, uid) + h.assert_status(fetched, 200) + assert "publish_config" not in _branch(fetched) + finally: + if resp.status_code == 201: + _delete_created_branch(stack, uid) + + @pytest.mark.parametrize("case", ["unknown", "other_case", "mix"]) + def test_rejected_names_return_422_and_create_no_branch(self, stack, env_names, nonexistent, case): + environments = { + "unknown": [h.generate_valid_uid("nos")], + "other_case": [env_names[0].upper()], + "mix": [env_names[0], h.generate_valid_uid("nos")], + }[case] + uid = h.generate_valid_uid("bev") + resp = _create_branch(stack, uid, environments) + fetched = stack.branch(uid).fetch() + if fetched.status_code == 200: + _delete_created_branch(stack, uid) + h.assert_status(resp, 422) + assert h.body(resp).get("error_message") + assert "publish_config.environments" in h.body(resp).get("errors", {}) + assert _not_found(fetched) == nonexistent, "a rejected create must leave no branch behind" 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"])