xlwings-utils 25.0.9__tar.gz → 25.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of xlwings-utils might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xlwings_utils
3
- Version: 25.0.9
3
+ Version: 25.1.0
4
4
  Summary: xlwings_utils
5
5
  Author-email: Ruud van der Ham <rt.van.der.ham@gmail.com>
6
6
  Project-URL: Homepage, https://github.com/salabim/xlwings_utils
@@ -9,7 +9,6 @@ Classifier: Development Status :: 5 - Production/Stable
9
9
  Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
- Requires-Dist: dropbox
13
12
 
14
13
  <img src="https://www.salabim.org/xlwings_utils_logo2.png">
15
14
 
@@ -19,7 +18,7 @@ This module provides some useful functions to be used in xlwings (lite).
19
18
 
20
19
  ## Installation
21
20
 
22
- Just add `xlwings-utils` and `ssl` (even if `dropbox` is not used) to the *requirements.txt* tab.
21
+ Just add `xlwings-utils` to the *requirements.txt* tab.
23
22
 
24
23
  In the script, add
25
24
 
@@ -66,10 +65,10 @@ The file `dropbox setup.py` can also be found in the repo of xlwings_lite .
66
65
  Then, it is possible to list all files in a specified folder using the list_dropbox function.
67
66
  It is also possible to get at all folders and to access all underlying folders.
68
67
 
69
- The `read_dropbox` function can be used to read the contents (bytes) of a Dropbox file. As reading from Dropbox under pyodide is rather unreliable, xlwings_utils automatically retries several times (by default 100 times). The actual number of retries can be found with `read_dropbox.retries`.
70
-
71
68
  The function `write_dropbox` can be used to write contents (bytes) to a Dropbox file.
72
69
 
70
+ The function `delete_from_dropbox` can be used to delete a Dropbox file.
71
+
73
72
  The functions `list_local`, `read_local` and `write_local` offer similar functionality for the local file system (on pyodide).
74
73
 
75
74
  So, a way to access a file on the system's drive (mapped to Dropbox) as a local file is:
@@ -6,7 +6,7 @@ This module provides some useful functions to be used in xlwings (lite).
6
6
 
7
7
  ## Installation
8
8
 
9
- Just add `xlwings-utils` and `ssl` (even if `dropbox` is not used) to the *requirements.txt* tab.
9
+ Just add `xlwings-utils` to the *requirements.txt* tab.
10
10
 
11
11
  In the script, add
12
12
 
@@ -53,10 +53,10 @@ The file `dropbox setup.py` can also be found in the repo of xlwings_lite .
53
53
  Then, it is possible to list all files in a specified folder using the list_dropbox function.
54
54
  It is also possible to get at all folders and to access all underlying folders.
55
55
 
56
- The `read_dropbox` function can be used to read the contents (bytes) of a Dropbox file. As reading from Dropbox under pyodide is rather unreliable, xlwings_utils automatically retries several times (by default 100 times). The actual number of retries can be found with `read_dropbox.retries`.
57
-
58
56
  The function `write_dropbox` can be used to write contents (bytes) to a Dropbox file.
59
57
 
58
+ The function `delete_from_dropbox` can be used to delete a Dropbox file.
59
+
60
60
  The functions `list_local`, `read_local` and `write_local` offer similar functionality for the local file system (on pyodide).
61
61
 
62
62
  So, a way to access a file on the system's drive (mapped to Dropbox) as a local file is:
@@ -10,12 +10,10 @@ authors = [
10
10
  { name = "Ruud van der Ham", email = "rt.van.der.ham@gmail.com" },
11
11
  ]
12
12
  description = "xlwings_utils"
13
- version = "25.0.9"
13
+ version = "25.1.0"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.9"
16
- dependencies = [
17
- "dropbox",
18
- ]
16
+ dependencies = []
19
17
  classifiers = [
20
18
  "Development Status :: 5 - Production/Stable",
21
19
  "Programming Language :: Python :: 3 :: Only",
@@ -67,6 +67,39 @@ def test_block2():
67
67
  assert this_block.value == [[1, 2, 3, None], [4, 5, 6, None], [None, None, None, None]]
68
68
 
69
69
 
70
+ def test_highest_used():
71
+ this_block = xwu.block(number_of_rows=4, number_of_columns=4)
72
+ assert this_block._highest_used_row_number is None
73
+ assert this_block.highest_used_row_number == 1
74
+ assert this_block.highest_used_column_number == 1
75
+ this_block[2, 3] = 1
76
+ assert this_block._highest_used_row_number is not None
77
+ assert this_block.highest_used_row_number == 2
78
+ assert this_block.highest_used_column_number == 3
79
+ this_block[1, 2] = 1
80
+ assert this_block._highest_used_row_number is not None
81
+ assert this_block.highest_used_row_number == 2
82
+ assert this_block.highest_used_column_number == 3
83
+ this_block[3, 4] = 1
84
+ assert this_block._highest_used_row_number is not None
85
+ assert this_block.highest_used_row_number == 3
86
+ assert this_block.highest_used_column_number == 4
87
+ this_block[3, 4] = None
88
+ assert this_block._highest_used_row_number is None
89
+ assert this_block.highest_used_row_number == 2
90
+ assert this_block.highest_used_column_number == 3
91
+ this_block[2, 3] = None
92
+ assert this_block._highest_used_row_number is None
93
+ this_block[2, 3] = 1
94
+ assert this_block._highest_used_row_number is None
95
+ this_block[2, 3] = None
96
+ this_block[1, 2] = None
97
+ assert this_block._highest_used_row_number is None
98
+ assert this_block.highest_used_row_number == 1
99
+ assert this_block.highest_used_column_number == 1
100
+ assert this_block._highest_used_row_number is not None
101
+
102
+
70
103
  def test_block_one_dimension():
71
104
  this_block = xwu.block.from_value([1, 2, 3])
72
105
  assert this_block.value == [[1, 2, 3]]
@@ -79,20 +112,23 @@ def test_block_scalar():
79
112
  this_block = xwu.block.from_value(1)
80
113
  assert this_block.value == [[1]]
81
114
 
115
+
82
116
  def test_transpose():
83
117
  this_block = xwu.block.from_value([[1, 2, 3], [4, 5, 6]])
84
- transposed_block=this_block.transposed()
85
- assert transposed_block.value==[[1,4],[2,5],[3,6]]
118
+ transposed_block = this_block.transposed()
119
+ assert transposed_block.value == [[1, 4], [2, 5], [3, 6]]
120
+
86
121
 
87
122
  def test_delete_none():
88
123
  this_block = xwu.block.from_value([[1, 2, None], [4, 5, None]])
89
- assert len(this_block.dict)==4
90
- this_block[1,1]=None
91
- assert len(this_block.dict)==3
92
- this_block[1,1]=None
93
- assert len(this_block.dict)==3
94
- assert this_block.value==[[None, 2, None], [4, 5, None]]
95
-
124
+ assert len(this_block.dict) == 4
125
+ this_block[1, 1] = None
126
+ assert len(this_block.dict) == 3
127
+ this_block[1, 1] = None
128
+ assert len(this_block.dict) == 3
129
+ assert this_block.value == [[None, 2, None], [4, 5, None]]
130
+
131
+
96
132
  def test_raise():
97
133
  this_block = xwu.block(number_of_rows=4, number_of_columns=6)
98
134
  with pytest.raises(IndexError):
@@ -115,52 +151,55 @@ def test_raise():
115
151
  with pytest.raises(IndexError):
116
152
  this_block[1, 7] = 1
117
153
 
154
+
118
155
  def test_lookup():
119
- bl=xwu.block.from_value([[1,"One", "Un"],[2, "Two", "Deux"],[3,"Three","Trois"]])
120
- assert bl.lookup(1)=="One"
121
- assert bl.lookup(3, column2=3)=="Trois"
156
+ bl = xwu.block.from_value([[1, "One", "Un"], [2, "Two", "Deux"], [3, "Three", "Trois"]])
157
+ assert bl.lookup(1) == "One"
158
+ assert bl.lookup(3, column2=3) == "Trois"
122
159
  with pytest.raises(ValueError):
123
160
  bl.lookup(4)
124
- assert bl.lookup(4,default='x')=='x'
161
+ assert bl.lookup(4, default="x") == "x"
125
162
  with pytest.raises(ValueError):
126
163
  bl.lookup(1, column1=4)
127
164
  with pytest.raises(ValueError):
128
165
  bl.lookup(1, column1=3)
129
- assert bl.lookup_row(1)==1
130
- assert bl.lookup_row(3)==3
166
+ assert bl.lookup_row(1) == 1
167
+ assert bl.lookup_row(3) == 3
168
+
131
169
 
132
170
  def test_vookup():
133
- bl=xwu.block.from_value([[1,"One", "Un"],[2, "Two", "Deux"],[3,"Three","Trois"]])
134
- assert bl.vlookup(1)=="One"
135
- assert bl.vlookup(3, column2=3)=="Trois"
171
+ bl = xwu.block.from_value([[1, "One", "Un"], [2, "Two", "Deux"], [3, "Three", "Trois"]])
172
+ assert bl.vlookup(1) == "One"
173
+ assert bl.vlookup(3, column2=3) == "Trois"
136
174
  with pytest.raises(ValueError):
137
175
  bl.vlookup(4)
138
- assert bl.vlookup(4, default='x')=='x'
176
+ assert bl.vlookup(4, default="x") == "x"
139
177
  with pytest.raises(ValueError):
140
178
  bl.vlookup(1, column1=4)
141
179
  with pytest.raises(ValueError):
142
180
  bl.vlookup(1, column1=3)
143
181
 
182
+
144
183
  def test_hlookup():
145
- bl=xwu.block.from_value([[1,2,3],"One Two Three".split(), "Un Deux Trois".split()])
146
-
147
- assert bl.hlookup(1)=="One"
148
- assert bl.hlookup(3, row2=3)=="Trois"
184
+ bl = xwu.block.from_value([[1, 2, 3], "One Two Three".split(), "Un Deux Trois".split()])
185
+
186
+ assert bl.hlookup(1) == "One"
187
+ assert bl.hlookup(3, row2=3) == "Trois"
149
188
  with pytest.raises(ValueError):
150
189
  bl.hlookup(4)
151
- assert bl.hlookup(4,default='x')=='x'
190
+ assert bl.hlookup(4, default="x") == "x"
152
191
  with pytest.raises(ValueError):
153
192
  bl.hlookup(1, row1=4)
154
193
  with pytest.raises(ValueError):
155
194
  bl.hlookup(1, row1=3)
156
- assert bl.lookup_column(1)==1
157
- assert bl.lookup_column(3)==3
195
+ assert bl.lookup_column(1) == 1
196
+ assert bl.lookup_column(3) == 3
158
197
 
159
198
 
160
199
  def test_capture(capsys):
161
200
  print("abc")
162
201
  print("def")
163
- capture=xwu.Capture()
202
+ capture = xwu.Capture()
164
203
  out, err = capsys.readouterr()
165
204
  assert out == "abc\ndef\n"
166
205
  assert capture.str_keep == ""
@@ -172,10 +211,10 @@ def test_capture(capsys):
172
211
  out, err = capsys.readouterr()
173
212
  assert out == ""
174
213
  assert capture.str_keep == "abc\ndef\n"
175
- assert capture.value_keep == [['abc'], ['def']]
214
+ assert capture.value_keep == [["abc"], ["def"]]
176
215
  assert capture.str == "abc\ndef\n"
177
216
  assert capture.value == []
178
-
217
+
179
218
  with capture:
180
219
  print("abc")
181
220
  print("def")
@@ -188,28 +227,27 @@ def test_capture(capsys):
188
227
  print("def")
189
228
  with capture:
190
229
  print("ghi")
191
- print("jkl")
230
+ print("jkl")
192
231
  out, err = capsys.readouterr()
193
232
  assert out == ""
194
- assert capture.str_keep == "abc\ndef\nghi\njkl\n"
195
- assert capture.value_keep == [['abc'], ['def'], ['ghi'], ['jkl']]
196
- assert capture.value == [['abc'], ['def'], ['ghi'], ['jkl']]
233
+ assert capture.str_keep == "abc\ndef\nghi\njkl\n"
234
+ assert capture.value_keep == [["abc"], ["def"], ["ghi"], ["jkl"]]
235
+ assert capture.value == [["abc"], ["def"], ["ghi"], ["jkl"]]
197
236
  assert capture.value == []
198
-
199
- capture.enabled=True
237
+
238
+ capture.enabled = True
200
239
  print("abc")
201
- print("def")
202
- capture.enabled=False
240
+ print("def")
241
+ capture.enabled = False
203
242
  print("xxx")
204
- print("yyy")
205
- capture.enabled=True
243
+ print("yyy")
244
+ capture.enabled = True
206
245
  print("ghi")
207
- print("jkl")
208
- assert capture.str_keep == "abc\ndef\nghi\njkl\n"
246
+ print("jkl")
247
+ assert capture.str_keep == "abc\ndef\nghi\njkl\n"
248
+
249
+ # include_print is not testable with pytest
250
+
209
251
 
210
-
211
- # include_print is not testable with pytest
212
-
213
252
  if __name__ == "__main__":
214
253
  pytest.main(["-vv", "-s", "-x", __file__])
215
-
@@ -5,28 +5,33 @@
5
5
  # /_/\_\|_| \_/\_/ |_||_| |_| \__, ||___/ _____ \__,_| \__||_||_||___/
6
6
  # |___/ |_____|
7
7
 
8
- __version__ = "25.0.9"
8
+ __version__ = "25.1.0"
9
9
 
10
10
 
11
- import dropbox
12
11
  from pathlib import Path
13
12
  import os
14
13
  import sys
15
14
  import math
16
15
  import base64
16
+ import requests
17
+ import json
17
18
 
18
- dbx = None
19
19
  Pythonista = sys.platform == "ios"
20
+
20
21
  try:
21
22
  import xlwings
22
23
 
23
24
  xlwings = True
25
+ import pyodide_http
24
26
 
25
27
  except ImportError:
26
28
  xlwings = False
27
29
 
28
30
  missing = object()
29
31
 
32
+ _token = None
33
+ missing = object()
34
+
30
35
 
31
36
  def dropbox_init(refresh_token=missing, app_key=missing, app_secret=missing, **kwargs):
32
37
  """
@@ -59,7 +64,10 @@ def dropbox_init(refresh_token=missing, app_key=missing, app_secret=missing, **k
59
64
  -------
60
65
  dropbox object
61
66
  """
62
- global dbx
67
+
68
+ global _token
69
+ if xlwings:
70
+ pyodide_http.patch_all() # to enable chunked mode
63
71
 
64
72
  if refresh_token is missing:
65
73
  if "DROPBOX.REFRESH_TOKEN" in os.environ:
@@ -77,18 +85,22 @@ def dropbox_init(refresh_token=missing, app_key=missing, app_secret=missing, **k
77
85
  else:
78
86
  raise ValueError("no DROPBOX.APP_SECRET found in environment.")
79
87
 
80
- _dbx = dropbox.Dropbox(oauth2_refresh_token=refresh_token, app_key=app_key, app_secret=app_secret, **kwargs)
88
+ resp = requests.post(
89
+ "https://api.dropbox.com/oauth2/token",
90
+ data={"grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": app_key, "client_secret": app_secret},
91
+ timeout=30,
92
+ )
81
93
  try:
82
- _dbx.files_list_folder(path="") # just to test proper credentials
83
- except dropbox.exceptions.AuthError:
94
+ resp.raise_for_status()
95
+ except requests.exceptions.HTTPError:
84
96
  raise ValueError("invalid dropbox credentials")
85
- return _dbx
97
+ _token = resp.json()["access_token"]
86
98
 
87
99
 
88
- def _login_dbx():
89
- global dbx
90
- if dbx is None:
91
- dbx = dropbox_init() # use environment
100
+ def _login_dropbox():
101
+ global _token
102
+ if _token is None:
103
+ dropbox_init() # use environment
92
104
 
93
105
 
94
106
  def list_dropbox(path="", recursive=False, show_files=True, show_folders=False):
@@ -122,28 +134,31 @@ def list_dropbox(path="", recursive=False, show_files=True, show_folders=False):
122
134
  If REFRESH_TOKEN, APP_KEY and APP_SECRET environment variables are specified,
123
135
  it is not necessary to call dropbox_init() prior to any dropbox function.
124
136
  """
125
- _login_dbx()
126
- out = []
127
- result = dbx.files_list_folder(path, recursive=recursive)
137
+ _login_dropbox()
138
+
139
+ API_RPC = "https://api.dropboxapi.com/2"
140
+ headers = {"Authorization": f"Bearer {_token}", "Content-Type": "application/json"}
141
+ payload = {"path": path, "recursive": recursive, "include_deleted": False}
142
+ r = requests.post("https://api.dropboxapi.com/2/files/list_folder", headers=headers, json=payload, timeout=30)
143
+ r.raise_for_status()
144
+ data = r.json()
145
+ entries = data["entries"]
146
+ while data.get("has_more"):
147
+ r = requests.post(f"{API_RPC}/files/list_folder/continue", headers=headers, json={"cursor": data["cursor"]}, timeout=30)
148
+ r.raise_for_status()
149
+ data = r.json()
150
+ entries.extend(data["entries"])
128
151
 
129
- for entry in result.entries:
130
- if show_files and isinstance(entry, dropbox.files.FileMetadata):
131
- out.append(entry.path_display)
132
- if show_folders and isinstance(entry, dropbox.files.FolderMetadata):
133
- out.append(entry.path_display + "/")
134
-
135
- while result.has_more:
136
- result = dbx.files_list_folder_continue(result.cursor)
137
- for entry in result.entries:
138
- if show_files and isinstance(entry, dropbox.files.FileMetadata):
139
- out.append(entry.path_display)
140
- if show_folders and isinstance(entry, dropbox.files.FolderMetadata):
141
- out.append(entry.path_display + "/")
142
-
143
- return out
152
+ result = []
153
+ for entry in entries:
154
+ if show_files and entry[".tag"] == "file":
155
+ result.append(entry["path_display"])
156
+ if show_folders and entry[".tag"] == "folder":
157
+ result.append(entry["path_display"] + "/")
158
+ return result
144
159
 
145
160
 
146
- def read_dropbox(dropbox_path, max_retries=100):
161
+ def read_dropbox(dropbox_path):
147
162
  """
148
163
  read_dropbox
149
164
 
@@ -154,9 +169,6 @@ def read_dropbox(dropbox_path, max_retries=100):
154
169
  dropbox_path : str or Pathlib.Path
155
170
  path to read from
156
171
 
157
- max_retries : int
158
- number of retries (default: 100)
159
-
160
172
  Returns
161
173
  -------
162
174
  contents of the dropbox file : bytes
@@ -165,18 +177,20 @@ def read_dropbox(dropbox_path, max_retries=100):
165
177
  ----
166
178
  If REFRESH_TOKEN, APP_KEY and APP_SECRET environment variables are specified,
167
179
  it is not necessary to call dropbox_init() prior to any dropbox function.
168
-
169
- As reading from dropbox is very unreliable under pyodide, reading will have to be retried (by default maximum 100 times).
170
- The number of retries can be found with read_dropbox.retries.
171
180
  """
172
181
 
173
- _login_dbx()
174
- for read_dropbox.retries in range(max_retries + 1):
175
- metadata, response = dbx.files_download(dropbox_path)
176
- file_content = response.content
177
- if len(file_content) == metadata.size:
178
- return file_content
179
- raise OSError(f"after {max_retries} still no valid response")
182
+ _login_dropbox()
183
+ headers = {"Authorization": f"Bearer {_token}", "Dropbox-API-Arg": json.dumps({"path": dropbox_path})}
184
+ with requests.post("https://content.dropboxapi.com/2/files/download", headers=headers, stream=True, timeout=60) as r:
185
+ try:
186
+ r.raise_for_status()
187
+ except requests.exceptions.HTTPError as e:
188
+ raise FileNotFoundError(f"file {dropbox_path} not found. Original message is {e}") from None
189
+ chunks = []
190
+ for chunk in r.iter_content(chunk_size=1024):
191
+ if chunk:
192
+ chunks.append(chunk)
193
+ return b"".join(chunks)
180
194
 
181
195
 
182
196
  def write_dropbox(dropbox_path, contents):
@@ -198,8 +212,36 @@ def write_dropbox(dropbox_path, contents):
198
212
  If REFRESH_TOKEN, APP_KEY and APP_SECRET environment variables are specified,
199
213
  it is not necessary to call dropbox_init() prior to any dropbox function.
200
214
  """
201
- _login_dbx()
202
- dbx.files_upload(contents, dropbox_path, mode=dropbox.files.WriteMode.overwrite)
215
+ _login_dropbox()
216
+ headers = {
217
+ "Authorization": f"Bearer {_token}",
218
+ "Dropbox-API-Arg": json.dumps(
219
+ {
220
+ "path": dropbox_path, # Where it will be saved in Dropbox
221
+ "mode": "overwrite", # "add" or "overwrite"
222
+ "autorename": False,
223
+ "mute": False,
224
+ }
225
+ ),
226
+ "Content-Type": "application/octet-stream",
227
+ }
228
+ response = requests.post("https://content.dropboxapi.com/2/files/upload", headers=headers, data=contents)
229
+ return response
230
+
231
+
232
+ def delete_from_dropbox(dropbox_path):
233
+ _login_dropbox()
234
+
235
+ headers = {"Authorization": f"Bearer {_token}", "Content-Type": "application/json"}
236
+
237
+ data = {
238
+ "path": dropbox_path # Path in Dropbox, starting with /
239
+ }
240
+
241
+ response = requests.post("https://api.dropboxapi.com/2/files/delete_v2", headers=headers, data=json.dumps(data))
242
+ if response.status_code == 200:
243
+ return
244
+ raise FileNotFoundError(f"dropbox file {dropbox_path} not found")
203
245
 
204
246
 
205
247
  def list_local(path, recursive=False, show_files=True, show_folders=False):
@@ -278,6 +320,8 @@ class block:
278
320
  self.dict = {}
279
321
  self.number_of_rows = number_of_rows
280
322
  self.number_of_columns = number_of_columns
323
+ self._highest_used_row_number = None
324
+ self._highest_used_column_number = None
281
325
 
282
326
  def __eq__(self, other):
283
327
  if isinstance(other, block):
@@ -460,8 +504,14 @@ class block:
460
504
  if value is None:
461
505
  if (row, column) in self.dict:
462
506
  del self.dict[row, column]
507
+ self._highest_used_row_number = None # invalidate cached value
508
+ self._highest_used_column_number = None # invalidate cached value
463
509
  else:
464
510
  self.dict[row, column] = value
511
+ if self._highest_used_row_number:
512
+ self._highest_used_row_number = max(self._highest_used_row_number, row)
513
+ if self._highest_used_column_number:
514
+ self._highest_used_column_number = max(self._highest_used_column_number, column)
465
515
 
466
516
  def __getitem__(self, row_column):
467
517
  row, column = row_column
@@ -488,6 +538,7 @@ class block:
488
538
  def number_of_rows(self, value):
489
539
  if value < 1:
490
540
  raise ValueError(f"number_of_rows should be >=1, not {value}")
541
+ self._highest_used_row_number = None
491
542
  self._number_of_rows = value
492
543
  for row, column in list(self.dict):
493
544
  if row > self._number_of_rows:
@@ -501,6 +552,7 @@ class block:
501
552
  def number_of_columns(self, value):
502
553
  if value < 1:
503
554
  raise ValueError(f"number_of_columns should be >=1, not {value}")
555
+ self._highest_used_column_number = None
504
556
  self._number_of_columns = value
505
557
  for row, column in list(self.dict):
506
558
  if column > self._number_of_columns:
@@ -508,17 +560,22 @@ class block:
508
560
 
509
561
  @property
510
562
  def highest_used_row_number(self):
511
- if self.dict:
512
- return max(row for (row, column) in self.dict)
513
- else:
514
- return 1
563
+ if not self._highest_used_row_number:
564
+ if self.dict:
565
+ self._highest_used_row_number = max(row for (row, column) in self.dict)
566
+ else:
567
+ self._highest_used_row_number = 1
568
+ return self._highest_used_row_number
515
569
 
516
570
  @property
517
571
  def highest_used_column_number(self):
518
- if self.dict:
519
- return max(column for (row, column) in self.dict)
520
- else:
521
- return 1
572
+ if not self._highest_used_column_number:
573
+ if self.dict:
574
+ self._highest_used_column_number = max(column for (row, column) in self.dict)
575
+ else:
576
+ self._highest_used_column_number = 1
577
+
578
+ return self._highest_used_column_number
522
579
 
523
580
  def __repr__(self):
524
581
  return f"block({self.value})"