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
10 changes: 10 additions & 0 deletions gspread/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,16 @@ class TableDirection(StrEnum):
right = "RIGHT"


class DelimiterType(StrEnum):
unspecified = "DELIMITER_TYPE_UNSPECIFIED"
comma = "COMMA"
semicolon = "SEMICOLON"
period = "PERIOD"
space = "SPACE"
custom = "CUSTOM"
autodetect = "AUTODETECT"


def convert_credentials(credentials: Credentials) -> Credentials:
module = credentials.__module__
cls = credentials.__class__.__name__
Expand Down
83 changes: 83 additions & 0 deletions gspread/worksheet.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .urls import WORKSHEET_DRIVE_URL
from .utils import (
DateTimeOption,
DelimiterType,
Dimension,
GridRangeType,
InsertDataOption,
Expand Down Expand Up @@ -3451,3 +3452,85 @@ def expand(

values = self.get(pad_values=True)
return find_table(values, top_left_range_name, direction)

def _text_to_column(
self,
src_range: str,
delimiter_type: DelimiterType = DelimiterType.comma,
custom_delimiter: Optional[str] = None,
) -> JSONResponse:
grid_range = a1_range_to_grid_range(src_range, self.id)

start = grid_range.get("startColumnIndex")
end = grid_range.get("endColumnIndex")

if start is not None and end is not None:
if end - start != 1:
raise ValueError(
f"Source range must span exactly one column. "
f"Got range spanning {end - start} columns."
)

if delimiter_type == DelimiterType.custom and custom_delimiter is None:
raise ValueError(
"custom_delimiter parameter is required when delimiter_type is DelimiterType.custom"
)

text_to_columns_request: Dict[str, Any] = {
"source": grid_range,
"delimiterType": delimiter_type,
}

if delimiter_type == DelimiterType.custom and custom_delimiter is not None:
text_to_columns_request["delimiter"] = custom_delimiter

body = {"requests": [{"textToColumns": text_to_columns_request}]}

return self.client.batch_update(self.spreadsheet_id, body)

@cast_to_a1_notation
def text_to_column(
self,
source_range: str,
delimiter_type: DelimiterType = DelimiterType.comma,
custom_delimiter: Optional[str] = None,
) -> JSONResponse:
"""
Split text from a single column into multiple columns based on a delimiter.

:param str source_range: A string with range value in A1 notation (e.g., 'A:A', 'B2:B10').
The range must span exactly one column.

:param delimiter_type: The type of delimiter to use for splitting.
Possible values are:

``DelimiterType.comma`` - Split on commas ","
``DelimiterType.semicolon`` - Split on semicolons ";"
``DelimiterType.period`` - Split on periods "."
``DelimiterType.space`` - Split on spaces " "
``DelimiterType.custom`` - Split on a custom delimiter (requires `custom_delimiter`)
``DelimiterType.autodetect`` - Automatically detect the delimiter

:type delimiter_type: :class:`~gspread.utils.DelimiterType`

:param str custom_delimiter: (optional) The custom delimiter to use.
Required when `delimiter_type` is `DelimiterType.custom`.

:returns: The response body from the request
:rtype: JSONResponse

:raises ValueError: If the source range spans more than one column, or if
`delimiter_type` is CUSTOM but no `custom_delimiter` is provided.

Example::

# Split column A on commas
worksheet.text_to_column('A:A', DelimiterType.comma)

# Split range B2:B10 using a custom delimiter
worksheet.text_to_column('B2:B10', DelimiterType.custom, custom_delimiter='|')

# Auto-detect delimiter for column C
worksheet.text_to_column('C:C', DelimiterType.autodetect)
"""
return self._text_to_column(source_range, delimiter_type, custom_delimiter)

Large diffs are not rendered by default.

594 changes: 594 additions & 0 deletions tests/cassettes/WorksheetTest.test_text_to_column_comma_delimiter.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

594 changes: 594 additions & 0 deletions tests/cassettes/WorksheetTest.test_text_to_column_empty_cells.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

594 changes: 594 additions & 0 deletions tests/cassettes/WorksheetTest.test_text_to_column_space_delimiter.json

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

185 changes: 185 additions & 0 deletions tests/worksheet_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2007,3 +2007,188 @@ def test_add_validation(self):
# Further ensure we are able to access the exception's properties after pickling
reloaded_exception = pickle.loads(pickle.dumps(ex.exception)) # nosec
self.assertEqual(reloaded_exception.args[0]["status"], "INVALID_ARGUMENT")

@pytest.mark.vcr()
def test_text_to_column_comma_delimiter(self):
test_data = [
["apple,banana,cherry"],
["red,blue,green"],
["one,two,three,four"],
]

self.sheet.update(test_data, "A1:A3")

response = self.sheet.text_to_column("A1:A3", utils.DelimiterType.comma)

self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)
self.assertEqual(response["spreadsheetId"], self.spreadsheet.id)

result_data = self.sheet.get("A1:D3")

expected_data = [
["apple", "banana", "cherry"],
["red", "blue", "green"],
["one", "two", "three", "four"],
]

self.assertEqual(result_data, expected_data)

@pytest.mark.vcr()
def test_text_to_column_custom_delimiter_pipe(self):
test_data = [
["apple|banana|cherry"],
["red|blue|green"],
["one|two|three|four"],
]

self.sheet.update(test_data, "A1:A3")

response = self.sheet.text_to_column(
"A1:A3", utils.DelimiterType.custom, custom_delimiter="|"
)

self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)
self.assertEqual(response["spreadsheetId"], self.spreadsheet.id)

result_data = self.sheet.get("A1:D3", pad_values=True)

expected_data = [
["apple", "banana", "cherry", ""],
["red", "blue", "green", ""],
["one", "two", "three", "four"],
]

self.assertEqual(result_data, expected_data)

@pytest.mark.vcr()
def test_text_to_column_autodetect_delimiter(self):
# Test autodetect with consistent comma delimiters
test_data = [["apple,banana,cherry"], ["red,blue,green"], ["one,two,three"]]

self.sheet.update(test_data, "A1:A3")

response = self.sheet.text_to_column("A1:A3", utils.DelimiterType.autodetect)

# Check response structure
self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)
self.assertEqual(response["spreadsheetId"], self.spreadsheet.id)

# The autodetect should detect comma as the delimiter and split all rows
result_data = self.sheet.get("A1:C3", pad_values=True)

expected_data = [
["apple", "banana", "cherry"],
["red", "blue", "green"],
["one", "two", "three"],
]

self.assertEqual(result_data, expected_data)

@pytest.mark.vcr()
def test_text_to_column_with_cast_to_a1_notation(self):
test_data = [["apple,banana"], ["red,blue"]]

self.sheet.update(test_data, "A1:A2")

# Test using numeric coordinates (should be converted to A1 notation)
response = self.sheet.text_to_column(1, 1, 2, 1, utils.DelimiterType.comma)

# Check response structure
self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)
self.assertEqual(response["spreadsheetId"], self.spreadsheet.id)

# Verify the split worked
result_data = self.sheet.get("A1:B2", pad_values=True)
expected_data = [["apple", "banana"], ["red", "blue"]]

self.assertEqual(result_data, expected_data)

def test_text_to_column_invalid_range_multiple_columns(self):
# Test without @pytest.mark.vcr() since this should raise an error before API call
with self.assertRaises(ValueError) as context:
self.sheet.text_to_column("A1:B1", utils.DelimiterType.comma)

self.assertIn(
"Source range must span exactly one column", str(context.exception)
)
self.assertIn("Got range spanning 2 columns", str(context.exception))

def test_text_to_column_custom_delimiter_missing(self):
# Test without @pytest.mark.vcr() since this should raise an error before API call
with self.assertRaises(ValueError) as context:
self.sheet.text_to_column("A1:A1", utils.DelimiterType.custom)

self.assertIn("custom_delimiter parameter is required", str(context.exception))

@pytest.mark.vcr()
def test_text_to_column_empty_cells(self):
test_data = [["apple,banana"], [""], ["red,blue"]] # Empty cell

self.sheet.update(test_data, "A1:A3")

response = self.sheet.text_to_column("A1:A3", utils.DelimiterType.comma)

# Check response structure
self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)

# Get result data
result_data = self.sheet.get("A1:B3", pad_values=True)

expected_data = [
["apple", "banana"],
["", ""], # Empty cell should remain empty
["red", "blue"],
]

self.assertEqual(result_data, expected_data)

@pytest.mark.vcr()
def test_text_to_column_no_delimiter_found(self):
test_data = [
["apple"], # No delimiter
["banana"], # No delimiter
["cherry"], # No delimiter
]

self.sheet.update(test_data, "A1:A3")

response = self.sheet.text_to_column("A1:A3", utils.DelimiterType.comma)

# Check response structure
self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)

# When no delimiter is found, text should remain in original column
result_data = self.sheet.get("A1:A3")

expected_data = [["apple"], ["banana"], ["cherry"]]

self.assertEqual(result_data, expected_data)

@pytest.mark.vcr()
def test_text_to_column_with_unicode_text(self):
test_data = [["café,naïve,résumé"], ["北京,東京,서울"], ["🍎,🍌,🍒"]]

self.sheet.update(test_data, "A1:A3")

response = self.sheet.text_to_column("A1:A3", utils.DelimiterType.comma)

# Check response structure
self.assertIn("spreadsheetId", response)
self.assertIn("replies", response)

# Get result data
result_data = self.sheet.get("A1:C3", pad_values=True)

expected_data = [
["café", "naïve", "résumé"],
["北京", "東京", "서울"],
["🍎", "🍌", "🍒"],
]

self.assertEqual(result_data, expected_data)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ profile=black
description = Run code linters
deps = -r lint-requirements.txt
commands = black --check --diff --extend-exclude="./env|gspread/__init__.py" .
codespell --skip=".tox,.git,./docs/build,.mypy_cache,./env" .
codespell --skip=".tox,.git,./docs/build,.mypy_cache,./env,./tests/cassettes" .
flake8 .
isort --check-only .
mypy --install-types --non-interactive --ignore-missing-imports ./gspread ./tests
Expand Down
Loading