From 57d47a2bc57216ec9a45cbd2d2b2a13879a879c5 Mon Sep 17 00:00:00 2001 From: ryzdo Date: Mon, 21 Aug 2023 09:48:13 +0300 Subject: [PATCH 1/6] Update ExportType according to Google documentation --- pygsheets/custom_types.py | 10 +++++++--- tests/online_test.py | 22 +++++++++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/pygsheets/custom_types.py b/pygsheets/custom_types.py index 36b1e7a..275d358 100644 --- a/pygsheets/custom_types.py +++ b/pygsheets/custom_types.py @@ -74,9 +74,13 @@ class FormatType(Enum): class ExportType(Enum): - """Enum for possible export types""" - XLS = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:.xls" - ODT = "application/x-vnd.oasis.opendocument.spreadsheet:.odt" + """Enum for possible export types + + `Export MIME types doc `_ + + """ + XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:.xlsx" + ODS = "application/x-vnd.oasis.opendocument.spreadsheet:.ods" PDF = "application/pdf:.pdf" CSV = "text/csv:.csv" TSV = 'text/tab-separated-values:.tsv' diff --git a/tests/online_test.py b/tests/online_test.py index 35bae51..379d92f 100644 --- a/tests/online_test.py +++ b/tests/online_test.py @@ -226,14 +226,14 @@ def test_export(self): self.spreadsheet.export(filename='test', path=self.output_path) - self.spreadsheet.export(file_format=ExportType.XLS, filename='test', path=self.output_path) + self.spreadsheet.export(file_format=ExportType.XLSX, filename='test', path=self.output_path) self.spreadsheet.export(file_format=ExportType.HTML, filename='test', path=self.output_path) - self.spreadsheet.export(file_format=ExportType.ODT, filename='test', path=self.output_path) + self.spreadsheet.export(file_format=ExportType.ODS, filename='test', path=self.output_path) self.spreadsheet.export(file_format=ExportType.PDF, filename='test', path=self.output_path) assert os.path.exists('{}/test.pdf'.format(self.output_path)) - assert os.path.exists('{}/test.xls'.format(self.output_path)) - assert os.path.exists('{}/test.odt'.format(self.output_path)) + assert os.path.exists('{}/test.xlsx'.format(self.output_path)) + assert os.path.exists('{}/test.ods'.format(self.output_path)) assert os.path.exists('{}/test.zip'.format(self.output_path)) self.spreadsheet.export(filename='spreadsheet', path=self.output_path) @@ -389,7 +389,7 @@ def test_append_table(self): with pytest.raises(KeyError): temp = ret['tableRange'] # tableRange should not be included assert self.worksheet.rows == rows + 2 - + # Also test appending values to an existing range rows = self.worksheet.rows ret = self.worksheet.append_table(['A', 'B', 'C', 'D', 'tea']) @@ -398,7 +398,7 @@ def test_append_table(self): assert isinstance(ret['updates']['updatedRange'], pygsheets.GridRange) assert ret['updates']['updatedRange'].start == 'A3' assert self.worksheet.rows == rows + 1 - + # Test overwrite and columns options rows = self.worksheet.rows ret = self.worksheet.append_table(['bom', 'bom', 'bom'], dimension='COLUMNS', overwrite=True) @@ -668,15 +668,15 @@ def test_export(self): self.worksheet.update_row(1, ['test', 'test', 'test']) self.worksheet.export(filename='test', path=self.output_path) self.worksheet.export(file_format=ExportType.PDF, filename='test', path=self.output_path) - self.worksheet.export(file_format=ExportType.XLS, filename='test', path=self.output_path) - self.worksheet.export(file_format=ExportType.ODT, filename='test', path=self.output_path) + self.worksheet.export(file_format=ExportType.XLSX, filename='test', path=self.output_path) + self.worksheet.export(file_format=ExportType.ODS, filename='test', path=self.output_path) self.worksheet.export(file_format=ExportType.HTML, filename='test', path=self.output_path) self.worksheet.export(file_format=ExportType.TSV, filename='test', path=self.output_path) assert os.path.exists(self.output_path + '/test.csv') assert os.path.exists(self.output_path + '/test.tsv') - assert os.path.exists(self.output_path + '/test.xls') - assert os.path.exists(self.output_path + '/test.odt') + assert os.path.exists(self.output_path + '/test.xlsx') + assert os.path.exists(self.output_path + '/test.ods') assert os.path.exists(self.output_path + '/test.zip') self.spreadsheet.add_worksheet('test2') @@ -1174,7 +1174,7 @@ def test_wrap_strategy(self): cell.wrap_strategy = "WRAP" cell = self.worksheet.get_values('A1', 'A1', returnas="range")[0][0] assert cell.wrap_strategy == "WRAP" - + cell.wrap_strategy = None From 0b1d65c729e1ec4a39dae9a984e63365d03ae535 Mon Sep 17 00:00:00 2001 From: ryfi <6692977+ryfi@users.noreply.github.com> Date: Sun, 24 Sep 2023 18:34:03 -0700 Subject: [PATCH 2/6] Added ability to update hyperlink value at the cell level. Proptected value _hyperlink added on cell object, along with setter and getter properties. Within update() method on cell added conditional check for value and adding url value to appropriate section of ret_json. --- pygsheets/cell.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pygsheets/cell.py b/pygsheets/cell.py index c3777e0..268b8c9 100755 --- a/pygsheets/cell.py +++ b/pygsheets/cell.py @@ -35,6 +35,7 @@ def __init__(self, pos, val='', worksheet=None, cell_data=None): self._value = val # formatted value self._unformated_value = val # un-formatted value self._formula = '' + self._hyperlink = '' self._note = None if self._worksheet is None: self._linked = False @@ -141,6 +142,16 @@ def formula(self, formula): self.parse_value = tmp self.fetch() + @property + def hyperlink(self): + """Get this cell's hyperlink if any.""" + return self._hyperlink + + @hyperlink.setter + def hyperlink(self, hyperlink): + self._hyperlink = hyperlink + self.update() + @property def horizontal_alignment(self): """Horizontal alignment of the value in this cell. @@ -500,6 +511,9 @@ def get_json(self): fg = ret_json["userEnteredFormat"]["textFormat"].get('foregroundColor', None) ret_json["userEnteredFormat"]["textFormat"]['foregroundColor'] = format_color(fg, to='dict') + if self._hyperlink != '': + ret_json["userEnteredFormat"]["textFormat"]['link'] = {'uri': self._hyperlink} + if self.borders is not None: ret_json["userEnteredFormat"]["borders"] = self.borders if self._horizontal_alignment is not None: @@ -553,7 +567,7 @@ def set_json(self, cell_data): self._vertical_alignment = \ VerticalAlignment[nvertical_alignment] if nvertical_alignment is not None else None - self.hyperlink = cell_data.get('hyperlink', '') + self._hyperlink = cell_data.get('hyperlink', '') def __setattr__(self, key, value): if key not in ['_linked', '_worksheet']: From ed3b6d6d7c9b00777c87f3e4cb0787baeae2e30d Mon Sep 17 00:00:00 2001 From: ryfi <6692977+ryfi@users.noreply.github.com> Date: Sun, 24 Sep 2023 20:56:17 -0700 Subject: [PATCH 3/6] Added call to fetch to ensure linking update and avoid losing formatting if called from worksheet level. --- pygsheets/cell.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pygsheets/cell.py b/pygsheets/cell.py index 268b8c9..026b3e0 100755 --- a/pygsheets/cell.py +++ b/pygsheets/cell.py @@ -149,6 +149,8 @@ def hyperlink(self): @hyperlink.setter def hyperlink(self, hyperlink): + if self._simplecell: + self.fetch() self._hyperlink = hyperlink self.update() From 90de95f7b2c28a9cf814c4873ff39740c32355bd Mon Sep 17 00:00:00 2001 From: John Barton Date: Sun, 7 Jan 2024 18:49:35 -0500 Subject: [PATCH 4/6] Add initial Pie Chart implementation --- pygsheets/custom_types.py | 1 + pygsheets/pie_chart.py | 111 ++++++++++++++++++++++++++++++++++++++ pygsheets/worksheet.py | 18 ++++++- tests/online_test.py | 63 ++++++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 pygsheets/pie_chart.py diff --git a/pygsheets/custom_types.py b/pygsheets/custom_types.py index 275d358..a60bc5e 100644 --- a/pygsheets/custom_types.py +++ b/pygsheets/custom_types.py @@ -123,3 +123,4 @@ class ChartType(Enum): SCATTER = "SCATTER" COMBO = "COMBO" STEPPED_AREA = "STEPPED_AREA" + PIE = "PIE" diff --git a/pygsheets/pie_chart.py b/pygsheets/pie_chart.py new file mode 100644 index 0000000..2685779 --- /dev/null +++ b/pygsheets/pie_chart.py @@ -0,0 +1,111 @@ +from pygsheets.chart import Chart + + +class PieChart(Chart): + """ + Represents a Pie Chart in a worksheet. + + :param worksheet: Worksheet object in which the chart resides + :param domain: Cell range of the desired chart domain in the form of tuple of tuples + :param chart_range: Cell ranges of the desired (singular) range in the form of a tuple of tuples + :param title: Title of the chart + :param anchor_cell: Position of the left corner of the chart in the form of cell address or cell object + :param three_dimensional True if the pie is three dimensional + :param pie_hole (float) The size of the hole in the pie chart (defaults to 0). Must be between 0 and 1. + :param json_obj: Represents a json structure of the chart as given in `api `__. + """ + def __init__(self, worksheet, domain=None, chart_range=None, title='', anchor_cell=None, three_dimensional=False, + pie_hole=0, json_obj=None): + self._three_dimensional = three_dimensional + self._pie_hole = pie_hole + + if self._pie_hole < 0 or self._pie_hole > 1: + raise ValueError("Pie Chart's pie_hole must be between 0 and 1.") + + super().__init__(worksheet, domain, ranges=[chart_range], chart_type=None, title=title, + anchor_cell=anchor_cell, json_obj=json_obj) + + def get_json(self): + """Returns the pie chart as a dictionary structured like the Google Sheets API v4.""" + + domains = [{'domain': {'sourceRange': {'sources': [ + self._worksheet.get_gridrange(self._domain[0], self._domain[1])]}}}] + ranges = self._get_ranges_request() + spec = dict() + spec['title'] = self._title + spec['pieChart'] = dict() + spec['pieChart']['legendPosition'] = self._legend_position + spec['fontName'] = self._font_name + spec['pieChart']['domain'] = domains[0]["domain"] + spec['pieChart']['series'] = ranges[0]["series"] + spec['pieChart']['threeDimensional'] = self._three_dimensional + spec['pieChart']['pieHole'] = self._pie_hole + return spec + + def _create_chart(self): + domains = [] + if self._domain: + domains.append({ + "domain": { + "sourceRange": { + "sources": [self._worksheet.get_gridrange(self._domain[0], self._domain[1])] + } + } + }) + + request = { + "addChart": { + "chart": { + "spec": { + "title": self._title, + "pieChart": { + "domain": domains[0]["domain"] if domains else None, + "series": self._get_ranges_request()[0]["series"], + "threeDimensional": self._three_dimensional, + "pieHole": self._pie_hole + }, + }, + "position": { + "overlayPosition": { + "anchorCell": self._get_anchor_cell() + } + } + } + } + } + response = self._worksheet.client.sheet.batch_update(self._worksheet.spreadsheet.id, request) + chart_data_list = response.get('replies') + chart_json = chart_data_list[0].get('addChart',{}).get('chart') + self.set_json(chart_json) + + def set_json(self, chart_data): + """ + Reads a json-dictionary returned by the Google Sheets API v4 and initialize all the properties from it. + + :param chart_data: The chart data as json specified in sheets api. + """ + anchor_cell_data = chart_data.get('position',{}).get('overlayPosition',{}).get('anchorCell') + self._anchor_cell = (anchor_cell_data.get('rowIndex',0)+1, anchor_cell_data.get('columnIndex',0)+1) + self._title = chart_data.get('spec',{}).get('title',None) + self._chart_id = chart_data.get('chartId',None) + self._title_font_family = chart_data.get('spec',{}).get('titleTextFormat',{}).get('fontFamily',None) + self._font_name = chart_data.get('spec',{}).get('titleTextFormat',{}).get('fontFamily',None) + pie_chart = chart_data.get('spec',{}).get('pieChart', None) + self._legend_position = pie_chart.get('legendPosition', None) + domain = pie_chart.get('domain', {}) + source_list = domain.get('sourceRange', {}).get('sources', None) + for source in source_list: + start_row = source.get('startRowIndex',0) + end_row = source.get('endRowIndex',0) + start_column = source.get('startColumnIndex',0) + end_column = source.get('endColumnIndex',0) + self._domain = [(start_row+1, start_column+1),(end_row, end_column)] + range = pie_chart.get('series', {}) + self._ranges = [] + source_list = range.get('sourceRange',{}).get('sources',None) + for source in source_list: + start_row = source.get('startRowIndex',0) + end_row = source.get('endRowIndex',0) + start_column = source.get('startColumnIndex',0) + end_column = source.get('endColumnIndex',0) + self._ranges.append([(start_row+1, start_column+1), (end_row, end_column)]) diff --git a/pygsheets/worksheet.py b/pygsheets/worksheet.py index 3e312b9..9a24cea 100755 --- a/pygsheets/worksheet.py +++ b/pygsheets/worksheet.py @@ -20,6 +20,7 @@ from pygsheets.utils import numericise_all, format_addr, fullmatch, batchable, allow_gridrange, get_color_style, get_boolean_condition from pygsheets.custom_types import * from pygsheets.chart import Chart +from pygsheets.pie_chart import PieChart from pygsheets.developer_metadata import DeveloperMetadataLookupDataFilter, DeveloperMetadata try: import pandas as pd @@ -1657,9 +1658,9 @@ def add_chart(self, domain, ranges, title=None, chart_type=ChartType.COLUMN, anc You can just add the rainfall data as a range. - :param domain: Cell range of the desired chart domain (x-axis) in the form of tuple of adresses + :param domain: Cell range of the desired chart domain (x-axis) in the form of tuple of addresses (start_address, end_address) - :param ranges: Cell ranges of the desired ranges (y-axis) in the form of list of tuples of adresses + :param ranges: Cell ranges of the desired ranges (y-axis) in the form of list of tuples of addresses :param title: Title of the chart :param chart_type: Basic chart type (default: COLUMN) :param anchor_cell: position of the left corner of the chart in the form of cell address or cell object @@ -1676,6 +1677,19 @@ def add_chart(self, domain, ranges, title=None, chart_type=ChartType.COLUMN, anc """ return Chart(self, domain, ranges, chart_type, title, anchor_cell) + def add_pie_chart(self, domain, chart_range, title=None, anchor_cell=None, three_dimensional=False, pie_hole=0): + """ + Similar to `add_cart`, but created a Pie Chart instead of a Basic Chart. + :param domain: Cell range of the desired chart domain (x-axis) in the form of tuple of addresses (start_address, end_address) + :param chart_range: Cell ranges of the desired (singular) range (y-axis) in the form of tuples of addresses + :param title: Title of the chart + :param anchor_cell: position of the left corner of the chart in the form of cell address or cell object + :param three_dimensional: True if the pie is three dimensional + :param pie_hole: (float) The size of the hole in the pie chart (defaults to 0). Must be between 0 and 1. + :return: :class:`PieChart` + """ + return PieChart(self, domain, chart_range, title, anchor_cell, three_dimensional, pie_hole) + def get_charts(self, title=None): """Returns a list of chart objects, can be filtered by title. diff --git a/tests/online_test.py b/tests/online_test.py index 379d92f..8e5a117 100644 --- a/tests/online_test.py +++ b/tests/online_test.py @@ -731,6 +731,64 @@ def test_add_chart(self): obj.delete() self.worksheet.clear() + def test_add_pie_chart(self): + self.worksheet.resize(50,50) + self.worksheet.update_values('A10:C13', [['x', 'y', 'z'], [1, 5, 9]]) + dmn = [(10, 1), (13, 1)] + rng = [(10, 2), (13, 2)] + obj = self.worksheet.add_pie_chart(dmn, rng, "Test Pie Chart", "A16") + assert obj.title == "Test Pie Chart" + assert obj.domain == dmn + assert obj.ranges[0] == rng + assert obj._three_dimensional is False + assert obj._pie_hole == 0 + assert obj.font_name == "Roboto" + assert obj.title_font_family == "Roboto" + obj.delete() + self.worksheet.clear() + + def test_add_pie_chart_three_dimensional(self): + self.worksheet.resize(50,50) + self.worksheet.update_values('A10:C13', [['x', 'y', 'z'], [1, 5, 9]]) + dmn = [(10, 1), (13, 1)] + rng = [(10, 2), (13, 2)] + obj = self.worksheet.add_pie_chart(dmn, rng, "Test Pie Chart", "A16", three_dimensional=True) + assert obj.title == "Test Pie Chart" + assert obj.domain == dmn + assert obj.ranges[0] == rng + assert obj._three_dimensional is True + assert obj._pie_hole == 0 + assert obj.font_name == "Roboto" + assert obj.title_font_family == "Roboto" + obj.delete() + self.worksheet.clear() + + def test_add_pie_chart_pie_hole(self): + self.worksheet.resize(50,50) + self.worksheet.update_values('A10:C13', [['x', 'y', 'z'], [1, 5, 9]]) + dmn = [(10, 1), (13, 1)] + rng = [(10, 2), (13, 2)] + obj = self.worksheet.add_pie_chart(dmn, rng, "Test Pie Chart", "A16", pie_hole=0.5) + assert obj.title == "Test Pie Chart" + assert obj.domain == dmn + assert obj.ranges[0] == rng + assert obj._three_dimensional is False + assert obj._pie_hole == 0.5 + assert obj.font_name == "Roboto" + assert obj.title_font_family == "Roboto" + obj.delete() + self.worksheet.clear() + + def test_add_pie_chart_invalid_pie_hole(self): + self.worksheet.resize(50,50) + self.worksheet.update_values('A10:C13', [['x', 'y', 'z'], [1, 5, 9]]) + dmn = [(10, 1), (13, 1)] + rng = [(10, 2), (13, 2)] + with pytest.raises(ValueError): + obj = self.worksheet.add_pie_chart(dmn, rng, "Test Pie Chart", "A16", pie_hole=2) + + self.worksheet.clear() + def test_get_charts(self): self.worksheet.resize(50,50) self.worksheet.update_values('A30:C33',[['x','y','z'],[1,5,9],[2,4,8],[3,6,10]]) @@ -1234,6 +1292,11 @@ def test_start_ub_end(self): assert self.grange.label == "'" + self.worksheet.title + "'" + '!' + '1' + ':' + '4' +class TestPieChart(object): + pass + + + class TestUtils(object): def test_is_number(self): From 1af3fa6aa3373296c11d44cf7956e8194b480086 Mon Sep 17 00:00:00 2001 From: John Barton Date: Sun, 14 Jan 2024 18:00:49 -0500 Subject: [PATCH 5/6] Fix typo in docstring --- pygsheets/worksheet.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygsheets/worksheet.py b/pygsheets/worksheet.py index 9a24cea..1fd4bbe 100755 --- a/pygsheets/worksheet.py +++ b/pygsheets/worksheet.py @@ -1679,7 +1679,7 @@ def add_chart(self, domain, ranges, title=None, chart_type=ChartType.COLUMN, anc def add_pie_chart(self, domain, chart_range, title=None, anchor_cell=None, three_dimensional=False, pie_hole=0): """ - Similar to `add_cart`, but created a Pie Chart instead of a Basic Chart. + Similar to `add_chart`, but created a Pie Chart instead of a Basic Chart. :param domain: Cell range of the desired chart domain (x-axis) in the form of tuple of addresses (start_address, end_address) :param chart_range: Cell ranges of the desired (singular) range (y-axis) in the form of tuples of addresses :param title: Title of the chart From a62ae3bf654c517b14282e778702dded1cf4398c Mon Sep 17 00:00:00 2001 From: John Barton Date: Sun, 14 Jan 2024 18:01:28 -0500 Subject: [PATCH 6/6] Remove unused TestPieChart class --- tests/online_test.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/online_test.py b/tests/online_test.py index 8e5a117..7542c48 100644 --- a/tests/online_test.py +++ b/tests/online_test.py @@ -1292,11 +1292,6 @@ def test_start_ub_end(self): assert self.grange.label == "'" + self.worksheet.title + "'" + '!' + '1' + ':' + '4' -class TestPieChart(object): - pass - - - class TestUtils(object): def test_is_number(self):