From 80331ed5e88b905e3ab11b62e7b606c5bd0b4bf6 Mon Sep 17 00:00:00 2001 From: Ahmed Ahmed Date: Mon, 18 Aug 2025 11:46:50 +0300 Subject: [PATCH 1/3] Added support for Smartsheet-Integration-Source --- CHANGELOG.md | 6 ++++++ smartsheet/smartsheet.py | 26 ++++++++++++++++++++++++ tests/mock_api/test_mock_change_agent.py | 1 + 3 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8438d43..116bac6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [3.0.6] - 2025-08-18 + +### Added + +- Support for the `Smartsheet-Integration-Source` header. This can be configured using the `smartsheet.py`. + ## [3.0.5] - 2025-04-07 ### Changed diff --git a/smartsheet/smartsheet.py b/smartsheet/smartsheet.py index 78790ac3..f87811f3 100644 --- a/smartsheet/smartsheet.py +++ b/smartsheet/smartsheet.py @@ -186,6 +186,7 @@ def __init__( self._assume_user = None self._test_scenario_name = None self._change_agent = None + self._smartsheet_integration_source = None def assume_user(self, email=None): """Assume identity of specified user. @@ -236,6 +237,21 @@ def with_change_agent(self, change_agent): """ self._change_agent = change_agent + def with_smartsheet_integration_source(self, smartsheet_integration_source): + """ + Request headers will contain the 'Smartsheet-Integration-Source' header value + + Agrs: + smartsheet_integration_source: (str) the name of this integration source + + Format: $TYPE,$ORG_NAME,$INTEGRATOR_NAME + (NB: Comma is used as a delimiter and is required if value is missing) + $INTEGRATION-TYPE - Required, the type of the integrator (e.g. AI, SCRIPT, APPLICATION) + $SMAR-ORGANIZATION-NAME - Optional (but COMMA is required), organization name (e.g. Microsoft, Google, OpenAI, etc.) + $INTEGRATOR-NAME - Required, the name of the integrator (e.g. Claude, Copilot, ChatGPT, DeepSeek, etc.) + """ + self._smartsheet_integration_source = smartsheet_integration_source + def request(self, prepped_request, expected, operation): """ Make a request from the Smartsheet API. @@ -447,6 +463,16 @@ def prepare_request(self, _op): del prepped_request.headers["Smartsheet-Change-Agent"] except KeyError: pass + + if self._smartsheet_integration_source is not None: + prepped_request.headers.update( + {"Smartsheet-Integration-Source": self._smartsheet_integration_source} + ) + else: + try: + del prepped_request.headers["Smartsheet-Integration-Source"] + except KeyError: + pass return prepped_request diff --git a/tests/mock_api/test_mock_change_agent.py b/tests/mock_api/test_mock_change_agent.py index b0efdce9..55c8cba7 100644 --- a/tests/mock_api/test_mock_change_agent.py +++ b/tests/mock_api/test_mock_change_agent.py @@ -9,6 +9,7 @@ class TestMockChangeAgent(MockApiTestHelper): def test_create_sheet(self): self.client.as_test_scenario('Change Agent Header - Can Be Passed') self.client.with_change_agent('MyChangeAgent') + self.client.with_smartsheet_integration_source('AI,MyCompany,MyGPT') new_sheet = Sheet({ "name": "My new sheet", From 0344ff2b537ff563396ee3ee9f6fd86fb7ade1e2 Mon Sep 17 00:00:00 2001 From: Ahmed Ahmed Date: Mon, 1 Sep 2025 09:17:24 +0300 Subject: [PATCH 2/3] Make smartsheet-integration-source header mandatory --- .../smartsheet_integration_source_type.py | 23 +++++++++ smartsheet/smartsheet.py | 14 +++++- ...smartsheet_integration_source_validator.py | 50 +++++++++++++++++++ tests/integration/conftest.py | 5 +- tests/integration/test_sheets.py | 4 +- tests/mock_api/mock_api_test_helper.py | 6 ++- ...smartsheet_integration_source_validator.py | 42 ++++++++++++++++ 7 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 smartsheet/models/enums/smartsheet_integration_source_type.py create mode 100644 smartsheet/smartsheet_integration_source_validator.py create mode 100644 tests/mock_api/test_smartsheet_integration_source_validator.py diff --git a/smartsheet/models/enums/smartsheet_integration_source_type.py b/smartsheet/models/enums/smartsheet_integration_source_type.py new file mode 100644 index 00000000..16414347 --- /dev/null +++ b/smartsheet/models/enums/smartsheet_integration_source_type.py @@ -0,0 +1,23 @@ +# pylint: disable=C0111,R0902,R0904,R0912,R0913,R0915,E1101 +# Smartsheet Python SDK. +# +# Copyright 2018 Smartsheet.com, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"): you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +from enum import Enum + + +class SmartsheetIntegrationSourceType(Enum): + AI = 1 + SCRIPT = 2 + APPLICATION = 3 \ No newline at end of file diff --git a/smartsheet/smartsheet.py b/smartsheet/smartsheet.py index f87811f3..c28db77a 100644 --- a/smartsheet/smartsheet.py +++ b/smartsheet/smartsheet.py @@ -36,6 +36,7 @@ from .models import Error, ErrorResult from .session import pinned_session from .util import is_multipart, serialize +from .smartsheet_integration_source_validator import is_valid_format __all__ = ("Smartsheet", "fresh_operation", "AbstractUserCalcBackoff") @@ -121,6 +122,7 @@ class Smartsheet: def __init__( self, access_token=None, + smartsheet_integration_source=None, max_connections=8, user_agent=None, max_retry_time=30, @@ -134,6 +136,9 @@ def __init__( access_token (str): Access Token for making client requests. May also be set as an env variable in SMARTSHEET_ACCESS_TOKEN. (required) + smartsheet_integration_source (str): Integration source identifier. + Format: $TYPE,$ORG_NAME,$INTEGRATOR_NAME + Required. Must be provided to identify the integration source. max_connections (int): Maximum connection pool size. max_retry_time (int or AbstractUserCalcBackoff): user provided maximum elapsed time or AbstractUserCalcBackoff class for user back off calculation on retry. @@ -159,6 +164,10 @@ def __init__( "as a parameter." ) + # Validate Smartsheet integration source format + is_valid_format(smartsheet_integration_source) + self._smartsheet_integration_source = smartsheet_integration_source + if isinstance(max_retry_time, AbstractUserCalcBackoff): self._user_calc_backoff = max_retry_time else: @@ -186,7 +195,6 @@ def __init__( self._assume_user = None self._test_scenario_name = None self._change_agent = None - self._smartsheet_integration_source = None def assume_user(self, email=None): """Assume identity of specified user. @@ -241,7 +249,7 @@ def with_smartsheet_integration_source(self, smartsheet_integration_source): """ Request headers will contain the 'Smartsheet-Integration-Source' header value - Agrs: + Args: smartsheet_integration_source: (str) the name of this integration source Format: $TYPE,$ORG_NAME,$INTEGRATOR_NAME @@ -250,6 +258,8 @@ def with_smartsheet_integration_source(self, smartsheet_integration_source): $SMAR-ORGANIZATION-NAME - Optional (but COMMA is required), organization name (e.g. Microsoft, Google, OpenAI, etc.) $INTEGRATOR-NAME - Required, the name of the integrator (e.g. Claude, Copilot, ChatGPT, DeepSeek, etc.) """ + # Validate before setting + is_valid_format(smartsheet_integration_source) self._smartsheet_integration_source = smartsheet_integration_source def request(self, prepped_request, expected, operation): diff --git a/smartsheet/smartsheet_integration_source_validator.py b/smartsheet/smartsheet_integration_source_validator.py new file mode 100644 index 00000000..8ff0a79d --- /dev/null +++ b/smartsheet/smartsheet_integration_source_validator.py @@ -0,0 +1,50 @@ +# pylint: disable=C0111 +from __future__ import annotations + +from enum import Enum + +from .exceptions import SmartsheetException +from .models.enums.smartsheet_integration_source_type import SmartsheetIntegrationSourceType + +def is_valid_format(input_value: str) -> bool: + """ + Validates a smartsheet integration source string in the format: + type, organisation name, integrator name + + - type: must be one of the enum values + - organisation name: optional (can be empty) + - integrator name: non-empty + """ + if input_value is None: + raise SmartsheetException("Smartsheet integration source cannot be null") + + parts = input_value.split(",", -1) # -1 keeps empty slots + if len(parts) != 3: + raise SmartsheetException("Invalid smartsheet integration source format") + + integration_type = parts[0] + integrator_name = parts[2] + + if not _is_valid_type(integration_type): + allowed = ", ".join([t.name for t in SmartsheetIntegrationSourceType]) + raise SmartsheetException( + "Invalid smartsheet integration source format. " + "The integration type has to be one of the following: " + allowed + ) + + if integrator_name == "": + raise SmartsheetException( + "Invalid smartsheet integration source format. " + "The integrator name cannot be empty." + ) + + return True + + +def _is_valid_type(integration_type_value: str | None) -> bool: + if integration_type_value is None or integration_type_value == "": + return False + for source_type in SmartsheetIntegrationSourceType: + if source_type.name.lower() == integration_type_value.lower(): + return True + return False diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 8f0d8917..b4ef426a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -10,7 +10,10 @@ @pytest.fixture(scope="module") def smart_setup(request): # set up a test session folder with basic starting points - smart = smartsheet.Smartsheet(max_retry_time=60) + smart = smartsheet.Smartsheet( + smartsheet_integration_source='AI,MyOrg,MyGPT', + max_retry_time=60 + ) now = datetime.now(tzlocal()).strftime("%Y-%m-%d %H:%M:%S") users = os.environ.get('SMARTSHEET_FIXTURE_USERS', None) diff --git a/tests/integration/test_sheets.py b/tests/integration/test_sheets.py index d2e5a9a2..d5b62077 100644 --- a/tests/integration/test_sheets.py +++ b/tests/integration/test_sheets.py @@ -113,7 +113,7 @@ def test_get_share(self, smart_setup): # on behalf of Moe, I try to make this change def test_update_sheet(self, smart_setup): - smart = smartsheet.Smartsheet() + smart = smartsheet.Smartsheet(smartsheet_integration_source='AI,MyOrg,MyGPT') smart.assume_user(smart_setup['users']['moe'].email) action = smart.Sheets.update_sheet( smart_setup['sheet'].id, @@ -131,7 +131,7 @@ def test_delete_share(self, smart_setup): assert action.message == 'SUCCESS' def test_new_and_shared_sheet(self, smart_setup): - smart = smartsheet.Smartsheet() + smart = smartsheet.Smartsheet(smartsheet_integration_source='AI,MyOrg,MyGPT') newsheet = smart.models.Sheet({ 'name': 'pytest_social_sheet', 'columns': [{ diff --git a/tests/mock_api/mock_api_test_helper.py b/tests/mock_api/mock_api_test_helper.py index 021adda8..d9435d41 100644 --- a/tests/mock_api/mock_api_test_helper.py +++ b/tests/mock_api/mock_api_test_helper.py @@ -15,7 +15,11 @@ def wrapper(*args, **kwargs): class MockApiTestHelper(object): def setup_method(self, method): - self.client = smartsheet.Smartsheet(access_token='abc123', api_base='http://localhost:8082') + self.client = smartsheet.Smartsheet( + access_token='abc123', + smartsheet_integration_source='AI,MyOrg,MyGPT', + api_base='http://localhost:8082' + ) self.client.errors_as_exceptions() def check_error_code(self, exception_info, expected_error_code): diff --git a/tests/mock_api/test_smartsheet_integration_source_validator.py b/tests/mock_api/test_smartsheet_integration_source_validator.py new file mode 100644 index 00000000..964349ff --- /dev/null +++ b/tests/mock_api/test_smartsheet_integration_source_validator.py @@ -0,0 +1,42 @@ +# pylint: disable=missing-function-docstring +import pytest + +from smartsheet.smartsheet_integration_source_validator import ( + is_valid_format, + SmartsheetIntegrationSourceType, +) +from smartsheet.exceptions import SmartsheetException + + +def test_valid_formats_minimum_and_with_org(): + assert is_valid_format("AI,,Integrator") is True + assert is_valid_format("APPLICATION,Org Name,Integrator Name") is True + assert is_valid_format("SCRIPT,Acme Inc,MyBot") is True + + +def test_invalid_when_null(): + with pytest.raises(SmartsheetException) as exc: + is_valid_format(None) # type: ignore[arg-type] + assert "cannot be null" in str(exc.value) + + +def test_invalid_when_wrong_number_of_parts(): + for bad in ["AI,OnlyTwo", "AI,Org,Name,Extra", "AI"]: + with pytest.raises(SmartsheetException) as exc: + is_valid_format(bad) + assert "Invalid smartsheet integration source format" in str(exc.value) + + +def test_invalid_when_type_not_allowed(): + with pytest.raises(SmartsheetException) as exc: + is_valid_format("NOT_A_TYPE,Org,Integrator") + assert "The integration type has to be one of the following" in str(exc.value) + # ensure enum contains expected members + names = {t.name for t in SmartsheetIntegrationSourceType} + assert {"AI", "SCRIPT", "APPLICATION"}.issubset(names) + + +def test_invalid_when_integrator_missing(): + with pytest.raises(SmartsheetException) as exc: + is_valid_format("AI,Org,") + assert "integrator name cannot be empty" in str(exc.value) From df8044a4d0ad6db497ee0c54ec9cbc0bb70d1906 Mon Sep 17 00:00:00 2001 From: Ahmed Ahmed Date: Wed, 3 Sep 2025 15:14:35 +0300 Subject: [PATCH 3/3] Update exceptions for smartsheet integration source header --- .../enums/smartsheet_integration_source_type.py | 5 +++-- .../smartsheet_integration_source_validator.py | 13 ++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/smartsheet/models/enums/smartsheet_integration_source_type.py b/smartsheet/models/enums/smartsheet_integration_source_type.py index 16414347..ea2e8707 100644 --- a/smartsheet/models/enums/smartsheet_integration_source_type.py +++ b/smartsheet/models/enums/smartsheet_integration_source_type.py @@ -10,7 +10,7 @@ # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# distributed under an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. @@ -20,4 +20,5 @@ class SmartsheetIntegrationSourceType(Enum): AI = 1 SCRIPT = 2 - APPLICATION = 3 \ No newline at end of file + APPLICATION = 3 + PERSONAL_ACCOUNT = 4 \ No newline at end of file diff --git a/smartsheet/smartsheet_integration_source_validator.py b/smartsheet/smartsheet_integration_source_validator.py index 8ff0a79d..cd524818 100644 --- a/smartsheet/smartsheet_integration_source_validator.py +++ b/smartsheet/smartsheet_integration_source_validator.py @@ -6,6 +6,9 @@ from .exceptions import SmartsheetException from .models.enums.smartsheet_integration_source_type import SmartsheetIntegrationSourceType +# Documentation link for error messages +DOCUMENTATION_LINK = "https://developers.smartsheet.com/api/smartsheet/guides/basics/http-and-rest#http-headers" + def is_valid_format(input_value: str) -> bool: """ Validates a smartsheet integration source string in the format: @@ -20,16 +23,20 @@ def is_valid_format(input_value: str) -> bool: parts = input_value.split(",", -1) # -1 keeps empty slots if len(parts) != 3: - raise SmartsheetException("Invalid smartsheet integration source format") + raise SmartsheetException( + "Invalid smartsheet integration source format. " + f"Expected format: 'TYPE,ORGANIZATION,INTEGRATOR. {DOCUMENTATION_LINK}" + ) integration_type = parts[0] integrator_name = parts[2] if not _is_valid_type(integration_type): - allowed = ", ".join([t.name for t in SmartsheetIntegrationSourceType]) + allowed = [t.name for t in SmartsheetIntegrationSourceType] raise SmartsheetException( "Invalid smartsheet integration source format. " - "The integration type has to be one of the following: " + allowed + f"The integration type has to be one of the following: {allowed}. " + f"Invalid integration type: {integration_type} {DOCUMENTATION_LINK}" ) if integrator_name == "":