Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.1.1] - 2025-09-01

### Added

- Support for the `Smartsheet-Integration-Source` header. This can be configured using the `smartsheet.py`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be configured using the Smartsheet class.


## [3.1.0] - 2025-08-28

### Added
Expand Down
24 changes: 24 additions & 0 deletions smartsheet/models/enums/smartsheet_integration_source_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 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 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
PERSONAL_ACCOUNT = 4
36 changes: 36 additions & 0 deletions smartsheet/smartsheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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,
Expand All @@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't it make sense to make this a class with type, org_name and integrator_name instead of string that the client needs to fiddle with?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could just be named integration_source. The smartsheet prefix is not necessary having in mind that the class name is Smartsheet.

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.
Expand All @@ -159,6 +164,10 @@ def __init__(
"as a parameter."
)

# Validate Smartsheet integration source format
is_valid_format(smartsheet_integration_source)
Copy link
Contributor

@ggoranov-smar ggoranov-smar Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you can use with_smartsheet_integration_source to avoid duplication.

self._smartsheet_integration_source = smartsheet_integration_source

if isinstance(max_retry_time, AbstractUserCalcBackoff):
self._user_calc_backoff = max_retry_time
else:
Expand Down Expand Up @@ -236,6 +245,23 @@ 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

Args:
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.)
"""
# Validate before setting
is_valid_format(smartsheet_integration_source)
self._smartsheet_integration_source = smartsheet_integration_source

def request(self, prepped_request, expected, operation):
"""
Make a request from the Smartsheet API.
Expand Down Expand Up @@ -447,6 +473,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(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dict.update() method is overkill here to set a single key. You should just use prepped_request.headers["Smartsheet-Integration-Source"] = self._smartsheet_integration_source

{"Smartsheet-Integration-Source": self._smartsheet_integration_source}
)
else:
try:
del prepped_request.headers["Smartsheet-Integration-Source"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the deletion in the else case?
Also it's better to use prepped_request.headers.pop("Smartsheet-Integration-Source") without try/catch instead of del.

except KeyError:
pass

return prepped_request

Expand Down
57 changes: 57 additions & 0 deletions smartsheet/smartsheet_integration_source_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# pylint: disable=C0111
from __future__ import annotations

from enum import Enum

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:
type, organisation name, integrator name

- type: must be one of the enum values
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must be one of the SmartsheetIntegrationSourceType 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")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message should use None instead of null: Smartsheet integration source cannot be None


parts = input_value.split(",", -1) # -1 keeps empty slots
if len(parts) != 3:
raise SmartsheetException(
"Invalid smartsheet integration source format. "
f"Expected format: 'TYPE,ORGANIZATION,INTEGRATOR. {DOCUMENTATION_LINK}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message here has an opening single quote but the closing one is missing.

)

integration_type = parts[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are trimmed in the C# SDK. Maybe its worth doing it here as well for consistency.

integrator_name = parts[2]

if not _is_valid_type(integration_type):
allowed = [t.name for t in SmartsheetIntegrationSourceType]
raise SmartsheetException(
"Invalid smartsheet integration source format. "
f"The integration type has to be one of the following: {allowed}. "
f"Invalid integration type: {integration_type} {DOCUMENTATION_LINK}"
)

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
5 changes: 4 additions & 1 deletion tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_sheets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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': [{
Expand Down
6 changes: 5 additions & 1 deletion tests/mock_api/mock_api_test_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/mock_api/test_mock_change_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions tests/mock_api/test_smartsheet_integration_source_validator.py
Original file line number Diff line number Diff line change
@@ -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)