tablemaster 2.0.0__py3-none-any.whl

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.
tablemaster/feishu.py ADDED
@@ -0,0 +1,502 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from datetime import datetime, timedelta
5
+
6
+ import requests
7
+ import pandas as pd
8
+
9
+ logger = logging.getLogger(__name__)
10
+ _TOKEN_CACHE = {}
11
+
12
+
13
+ def _request_with_retry(method, url, headers=None, params=None, json_data=None, data=None, timeout=30):
14
+ max_retries = 3
15
+ for attempt in range(max_retries):
16
+ response = requests.request(
17
+ method=method,
18
+ url=url,
19
+ headers=headers,
20
+ params=params,
21
+ json=json_data,
22
+ data=data,
23
+ timeout=timeout,
24
+ )
25
+ if response.status_code != 429:
26
+ return response
27
+ if attempt == max_retries - 1:
28
+ break
29
+ sleep_seconds = 2 ** attempt
30
+ logger.warning('feishu api rate limited (429), retry in %ss', sleep_seconds)
31
+ time.sleep(sleep_seconds)
32
+ raise requests.HTTPError(f'Feishu API rate limited after {max_retries} retries', response=response)
33
+
34
+
35
+ def _get_tenant_access_token(feishu_cfg):
36
+ cache_key = (feishu_cfg.feishu_app_id, feishu_cfg.feishu_app_secret)
37
+ cached = _TOKEN_CACHE.get(cache_key)
38
+ now = datetime.utcnow()
39
+ if cached and now < cached['expire_at'] - timedelta(minutes=5):
40
+ return cached['token']
41
+
42
+ feishu_url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal/"
43
+ post_data = {
44
+ "app_id": feishu_cfg.feishu_app_id,
45
+ "app_secret": feishu_cfg.feishu_app_secret
46
+ }
47
+ r = _request_with_retry("post", feishu_url, json_data=post_data)
48
+ body = r.json()
49
+ token = body["tenant_access_token"]
50
+ expire_seconds = int(body.get('expire', 7200))
51
+ _TOKEN_CACHE[cache_key] = {
52
+ 'token': token,
53
+ 'expire_at': now + timedelta(seconds=expire_seconds),
54
+ }
55
+ return token
56
+
57
+
58
+ def _col_num_to_letter(n):
59
+ """
60
+ 将列号(从1开始)转换为Excel风格的列字母
61
+ 例如: 1 -> A, 26 -> Z, 27 -> AA, 28 -> AB
62
+ """
63
+ result = ""
64
+ while n > 0:
65
+ n, remainder = divmod(n - 1, 26)
66
+ result = chr(65 + remainder) + result
67
+ return result
68
+
69
+
70
+ def fs_read_df(sheet_address, feishu_cfg):
71
+ """
72
+ 从飞书电子表格读取数据并返回 DataFrame
73
+
74
+ Args:
75
+ sheet_address: [spreadsheet_token, sheet_id]
76
+ - spreadsheet_token: 表格的唯一标识(URL中sh开头的部分)
77
+ - sheet_id: 工作表的唯一标识(URL中sheet=后的部分)
78
+ feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
79
+
80
+ Returns:
81
+ pd.DataFrame: 读取的数据
82
+ """
83
+ tat = _get_tenant_access_token(feishu_cfg)
84
+ header = {
85
+ "content-type": "application/json",
86
+ "Authorization": "Bearer " + str(tat)
87
+ }
88
+ url = (
89
+ "https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/"
90
+ + sheet_address[0] + "/values/" + sheet_address[1]
91
+ + "?valueRenderOption=ToString&dateTimeRenderOption=FormattedString"
92
+ )
93
+ r = _request_with_retry("get", url, headers=header)
94
+ pull_data = r.json()['data']['valueRange']['values']
95
+ return pd.DataFrame(pull_data[1:], columns=pull_data[0])
96
+
97
+
98
+ def fs_read_base(sheet_address, feishu_cfg):
99
+ """
100
+ 从飞书多维表格(Bitable)读取数据并返回 DataFrame
101
+
102
+ Args:
103
+ sheet_address: [app_token, table_id]
104
+ - app_token: 多维表格的唯一标识
105
+ - table_id: 数据表的唯一标识
106
+ feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
107
+
108
+ Returns:
109
+ pd.DataFrame: 读取的数据
110
+ """
111
+ tat = _get_tenant_access_token(feishu_cfg)
112
+ header = {
113
+ "content-type": "application/json",
114
+ "Authorization": "Bearer " + str(tat)
115
+ }
116
+ base_url = (
117
+ "https://open.feishu.cn/open-apis/bitable/v1/apps/"
118
+ + sheet_address[0] + "/tables/" + sheet_address[1] + '/records'
119
+ )
120
+ pull_data = []
121
+ page_token = None
122
+ has_more = True
123
+
124
+ while has_more:
125
+ query_params = "?valueRenderOption=ToString&dateTimeRenderOption=FormattedString&page_size=500"
126
+ if page_token:
127
+ query_params += f"&page_token={page_token}"
128
+
129
+ r = _request_with_retry("get", base_url + query_params, headers=header)
130
+ data = r.json().get('data', {})
131
+ pull_data.extend(data.get('items', []))
132
+ has_more = data.get('has_more', False)
133
+ page_token = data.get('page_token')
134
+
135
+ pull_data_parse = [x['fields'] for x in pull_data]
136
+ return pd.DataFrame(pull_data_parse)
137
+
138
+
139
+ def fs_write_df(sheet_address, df, feishu_cfg, loc='A1', clear_sheet=True):
140
+ """
141
+ 将 DataFrame 写入飞书电子表格
142
+
143
+ Args:
144
+ sheet_address: [spreadsheet_token, sheet_id]
145
+ - spreadsheet_token: 表格的唯一标识(URL中sh开头的部分)
146
+ - sheet_id: 工作表的唯一标识(URL中sheet=后的部分)
147
+ df: 要写入的 pandas DataFrame
148
+ feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
149
+ loc: 写入起始位置,默认 'A1'
150
+ clear_sheet: 是否在写入前清空工作表,默认 True
151
+
152
+ Returns:
153
+ dict: API 响应结果
154
+
155
+ Example:
156
+ >>> sheet_address = ['shtcnxxxxxx', 'sheet_id_xxx']
157
+ >>> fs_write_df(sheet_address, df, feishu_cfg)
158
+ """
159
+ logger.info('writing feishu sheets')
160
+
161
+ # 获取 access token
162
+ tat = _get_tenant_access_token(feishu_cfg)
163
+ header = {
164
+ "Content-Type": "application/json",
165
+ "Authorization": "Bearer " + str(tat)
166
+ }
167
+
168
+ spreadsheet_token = sheet_address[0]
169
+ sheet_id = sheet_address[1]
170
+
171
+ # 清空工作表(如果需要)
172
+ if clear_sheet:
173
+ logger.info('clearing sheet by values_batch_clear api')
174
+ try:
175
+ clear_url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values_batch_clear"
176
+ clear_data = {"ranges": [f"{sheet_id}!A1:XFD1048576"]}
177
+ clear_resp = _request_with_retry("post", clear_url, headers=header, json_data=clear_data)
178
+ if clear_resp.json().get('code') == 0:
179
+ logger.info('sheet cleared')
180
+ else:
181
+ logger.warning("failed to clear sheet: %s", clear_resp.json().get('msg'))
182
+ except Exception as e:
183
+ logger.warning('failed to clear sheet: %s', e)
184
+
185
+ # 处理 DataFrame 数据类型
186
+ df_copy = df.copy()
187
+
188
+ # 将非数值类型转换为字符串
189
+ non_float_int_columns = df_copy.select_dtypes(exclude=['float64', 'int64', 'float32', 'int32']).columns
190
+ for col in non_float_int_columns:
191
+ df_copy[col] = df_copy[col].astype(str)
192
+
193
+ # 处理 NaN 值,转换为空字符串
194
+ df_copy = df_copy.fillna('')
195
+
196
+ # 替换 'nan' 字符串为空字符串
197
+ df_copy = df_copy.replace('nan', '')
198
+ df_copy = df_copy.replace('NaT', '')
199
+
200
+ # 准备写入数据:表头 + 数据
201
+ values = [df_copy.columns.values.tolist()] + df_copy.values.tolist()
202
+
203
+ # 计算写入范围
204
+ num_rows = len(values)
205
+ num_cols = len(values[0]) if values else 0
206
+
207
+ # 解析起始位置
208
+ import re
209
+ loc_match = re.match(r'([A-Z]+)(\d+)', loc.upper())
210
+ if loc_match:
211
+ start_col = loc_match.group(1)
212
+ start_row = int(loc_match.group(2))
213
+ else:
214
+ start_col = 'A'
215
+ start_row = 1
216
+
217
+ # 计算结束列
218
+ start_col_num = sum((ord(c) - ord('A') + 1) * (26 ** i)
219
+ for i, c in enumerate(reversed(start_col)))
220
+ end_col_num = start_col_num + num_cols - 1
221
+ end_col = _col_num_to_letter(end_col_num)
222
+ end_row = start_row + num_rows - 1
223
+
224
+ # 构建写入范围
225
+ write_range = f"{sheet_id}!{start_col}{start_row}:{end_col}{end_row}"
226
+
227
+ logger.info('writing to range: %s', write_range)
228
+
229
+ # 构建请求数据
230
+ post_data = {
231
+ "valueRange": {
232
+ "range": write_range,
233
+ "values": values
234
+ }
235
+ }
236
+
237
+ # 发送 PUT 请求写入数据
238
+ url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values"
239
+
240
+ try:
241
+ r = _request_with_retry("put", url, headers=header, json_data=post_data)
242
+ response = r.json()
243
+
244
+ if response.get('code') == 0:
245
+ logger.info('data is written')
246
+ else:
247
+ logger.error('failed to write data: %s', response.get('msg', 'Unknown error'))
248
+ logger.error('error code: %s', response.get('code'))
249
+
250
+ return response
251
+
252
+ except Exception as e:
253
+ logger.exception('failed to write data: %s', e)
254
+ raise
255
+
256
+ def _get_bitable_fields(app_token, table_id, header):
257
+ """
258
+ 获取多维表格的所有字段名
259
+
260
+ Returns:
261
+ set: 字段名集合
262
+ """
263
+ url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
264
+ r = _request_with_retry("get", url, headers=header)
265
+
266
+ if r.status_code == 200 and r.json().get('code') == 0:
267
+ items = r.json().get('data', {}).get('items', [])
268
+ return {item['field_name'] for item in items}
269
+ else:
270
+ logger.warning("failed to get fields: %s", r.json().get('msg', 'Unknown error'))
271
+ return set()
272
+
273
+ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
274
+ """
275
+ 将 DataFrame 写入飞书多维表格(Bitable)
276
+
277
+ Args:
278
+ sheet_address: [app_token, table_id]
279
+ - app_token: 多维表格的唯一标识
280
+ - table_id: 数据表的唯一标识
281
+ df: 要写入的 pandas DataFrame (列名需与多维表格字段名匹配)
282
+ feishu_cfg: 配置对象,包含 feishu_app_id 和 feishu_app_secret
283
+ clear_table: 是否在写入前清空数据表,默认 False
284
+
285
+ Returns:
286
+ dict: API 响应结果
287
+
288
+ Note:
289
+ - 多维表格的写入是基于字段名称的,DataFrame的列名需要与表格中的字段名完全匹配
290
+ - 不存在的字段会被自动跳过并打印警告信息
291
+ """
292
+ logger.info('writing feishu bitable')
293
+
294
+ tat = _get_tenant_access_token(feishu_cfg)
295
+ header = {
296
+ "Content-Type": "application/json",
297
+ "Authorization": "Bearer " + str(tat)
298
+ }
299
+
300
+ app_token = sheet_address[0]
301
+ table_id = sheet_address[1]
302
+
303
+ # 获取多维表格中的字段名
304
+ logger.info('fetching bitable fields')
305
+ existing_fields = _get_bitable_fields(app_token, table_id, header)
306
+
307
+ if not existing_fields:
308
+ logger.error('could not fetch table fields or table has no fields')
309
+ return None
310
+
311
+ logger.info('table has %s fields', len(existing_fields))
312
+
313
+ # 检查 DataFrame 列名与表格字段的匹配情况
314
+ df_columns = set(df.columns.tolist())
315
+
316
+ # 找出不存在的字段
317
+ missing_fields = df_columns - existing_fields
318
+ valid_fields = df_columns & existing_fields
319
+
320
+ if missing_fields:
321
+ logger.warning('the following columns do not exist in bitable and will be skipped')
322
+ for field in sorted(missing_fields):
323
+ logger.warning('skip column: %s', field)
324
+
325
+ if not valid_fields:
326
+ logger.error('no valid fields to write, all dataframe columns are missing in bitable')
327
+ return None
328
+
329
+ logger.info('will write %s valid fields', len(valid_fields))
330
+
331
+ # 清空数据表(如果需要)
332
+ if clear_table:
333
+ logger.info('clearing bitable records')
334
+ try:
335
+ list_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records"
336
+ record_ids = []
337
+ page_token = None
338
+ has_more = True
339
+
340
+ while has_more:
341
+ query_params = "?page_size=500"
342
+ if page_token:
343
+ query_params += f"&page_token={page_token}"
344
+ list_resp = _request_with_retry("get", list_url + query_params, headers=header)
345
+
346
+ if list_resp.status_code != 200:
347
+ break
348
+
349
+ data = list_resp.json().get('data', {})
350
+ items = data.get('items', [])
351
+ record_ids.extend([item['record_id'] for item in items])
352
+ has_more = data.get('has_more', False)
353
+ page_token = data.get('page_token')
354
+
355
+ if record_ids:
356
+ delete_url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete"
357
+ for i in range(0, len(record_ids), 500):
358
+ batch_ids = record_ids[i:i + 500]
359
+ delete_data = {"records": batch_ids}
360
+ _request_with_retry("post", delete_url, headers=header, json_data=delete_data)
361
+ logger.info('deleted %s records', len(record_ids))
362
+
363
+ except Exception as e:
364
+ logger.warning('failed to clear table: %s', e)
365
+
366
+ # 处理 DataFrame - 只保留有效字段
367
+ df_copy = df[list(valid_fields)].copy()
368
+ df_copy = df_copy.fillna('')
369
+ df_copy = df_copy.replace('nan', '')
370
+
371
+ # 将 DataFrame 转换为 records 格式
372
+ records = []
373
+ skipped_cols = set() # 记录因数据类型问题跳过的列
374
+
375
+ for idx, row in df_copy.iterrows():
376
+ fields = {}
377
+ for col in df_copy.columns:
378
+ value = row[col]
379
+
380
+ try:
381
+ # 检查是否为空值
382
+ is_na = False
383
+ try:
384
+ is_na = pd.isna(value)
385
+ # 如果是数组类型,pd.isna 返回数组,需要用 all() 判断
386
+ if hasattr(is_na, '__iter__') and not isinstance(is_na, str):
387
+ is_na = all(is_na) if len(is_na) > 0 else True
388
+ except (ValueError, TypeError):
389
+ is_na = value is None
390
+
391
+ if is_na:
392
+ continue # 跳过空值
393
+ elif value == '' or value == 'nan' or value == 'None':
394
+ continue # 跳过空字符串
395
+ elif isinstance(value, bool):
396
+ # 布尔值直接写入
397
+ fields[col] = value
398
+ elif isinstance(value, (int, float)):
399
+ # 数字直接写入
400
+ fields[col] = value
401
+ elif isinstance(value, str):
402
+ # 字符串直接写入
403
+ fields[col] = value
404
+ elif isinstance(value, (list, tuple)):
405
+ # 检查列表内容
406
+ if len(value) == 0:
407
+ continue
408
+ first_item = value[0]
409
+ # 如果是字典列表(如附件、人员字段),提取文本或跳过
410
+ if isinstance(first_item, dict):
411
+ # 尝试提取文本内容
412
+ texts = []
413
+ for item in value:
414
+ if isinstance(item, dict):
415
+ # 尝试获取 text、name、title 等常见文本字段
416
+ text = item.get('text') or item.get('name') or item.get('title') or item.get('value')
417
+ if text:
418
+ texts.append(str(text))
419
+ if texts:
420
+ fields[col] = ', '.join(texts)
421
+ else:
422
+ # 无法提取有效文本,跳过该字段
423
+ if col not in skipped_cols:
424
+ skipped_cols.add(col)
425
+ continue
426
+ else:
427
+ # 简单列表(如多选字段的字符串列表)
428
+ fields[col] = [str(item) for item in value]
429
+ elif isinstance(value, dict):
430
+ # 字典类型,尝试提取文本或转为字符串
431
+ text = value.get('text') or value.get('name') or value.get('title') or value.get('value')
432
+ if text:
433
+ fields[col] = str(text)
434
+ else:
435
+ # 转为 JSON 字符串
436
+ fields[col] = json.dumps(value, ensure_ascii=False)
437
+ else:
438
+ # 其他类型转为字符串
439
+ fields[col] = str(value)
440
+
441
+ except Exception as e:
442
+ # 如果处理出错,尝试转为字符串
443
+ try:
444
+ str_val = str(value)
445
+ if str_val and str_val != 'None' and str_val != 'nan':
446
+ fields[col] = str_val
447
+ except:
448
+ if col not in skipped_cols:
449
+ skipped_cols.add(col)
450
+ continue
451
+
452
+ records.append({"fields": fields})
453
+
454
+ if skipped_cols:
455
+ logger.warning('skipped columns due to unsupported data types: %s', skipped_cols)
456
+
457
+ # 批量写入(每次最多500条)
458
+ batch_size = 500
459
+ all_responses = []
460
+
461
+ for i in range(0, len(records), batch_size):
462
+ batch = records[i:i + batch_size]
463
+
464
+ url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
465
+ post_data = {"records": batch}
466
+
467
+ try:
468
+ r = _request_with_retry("post", url, headers=header, json_data=post_data)
469
+ response = r.json()
470
+ all_responses.append(response)
471
+
472
+ if response.get('code') == 0:
473
+ logger.info('batch %s wrote %s records', i // batch_size + 1, len(batch))
474
+ else:
475
+ logger.error('failed to write batch: %s', response.get('msg', 'Unknown error'))
476
+
477
+ except Exception as e:
478
+ logger.exception('failed to write batch: %s', e)
479
+
480
+ logger.info('write summary total records: %s', len(records))
481
+ logger.info('write summary fields written: %s', len(valid_fields))
482
+ if missing_fields:
483
+ logger.info('write summary fields skipped: %s', len(missing_fields))
484
+ for field in sorted(missing_fields):
485
+ logger.info('skip field: %s', field)
486
+ logger.info('data is written')
487
+
488
+ return all_responses
489
+
490
+
491
+ # 为了向后兼容,保留原有函数签名的包装
492
+ def fs_write_df_simple(map, df, feishu_cfg, loc='A1'):
493
+ """
494
+ 简化版写入函数,参数顺序与 gs_write_df 保持一致
495
+
496
+ Args:
497
+ map: [spreadsheet_token, sheet_id]
498
+ df: pandas DataFrame
499
+ feishu_cfg: 配置对象
500
+ loc: 起始位置,默认 'A1'
501
+ """
502
+ return fs_write_df(map, df, feishu_cfg, loc=loc)
tablemaster/gspread.py ADDED
@@ -0,0 +1,130 @@
1
+ import gspread
2
+ import pandas as pd
3
+ import re
4
+ import warnings
5
+ import logging
6
+ from functools import lru_cache
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ def _is_google_sheet_id(s):
11
+ return len(s) > 40 and ' ' not in s
12
+
13
+
14
+ def _is_cell_loc(value):
15
+ return isinstance(value, str) and re.match(r'^[A-Za-z]+[1-9]\d*$', value.strip()) is not None
16
+
17
+
18
+ def _warn_deprecated(message):
19
+ warnings.warn(f'{message} This usage will be removed in a future release.', FutureWarning, stacklevel=3)
20
+
21
+
22
+ def _resolve_service_account_path(cfg, service_account_path):
23
+ if service_account_path:
24
+ _warn_deprecated('service_account_path argument is deprecated; pass a cfg object instead.')
25
+ return service_account_path
26
+
27
+ if cfg is None:
28
+ _warn_deprecated('No cfg argument provided; use gs_read_df(address, cfg).')
29
+ return None
30
+
31
+ if isinstance(cfg, str):
32
+ _warn_deprecated('Passing a string path as the second argument is deprecated; pass a cfg object instead.')
33
+ return cfg
34
+
35
+ path = getattr(cfg, 'service_account_path', None)
36
+ if not path:
37
+ raise ValueError('Google config is missing service_account_path; please check cfg.')
38
+ return path
39
+
40
+
41
+ @lru_cache(maxsize=8)
42
+ def _get_gspread_client(service_account_path=None):
43
+ if service_account_path:
44
+ return gspread.service_account(service_account_path)
45
+ return gspread.service_account()
46
+
47
+
48
+ def gs_read_df(address, cfg=None, service_account_path=None):
49
+ logger.info('reading google sheets')
50
+ sa_path = _resolve_service_account_path(cfg, service_account_path)
51
+ gc = _get_gspread_client(sa_path)
52
+
53
+ spreadsheet_identifier = address[0]
54
+ worksheet_name = address[1]
55
+
56
+ try:
57
+ if _is_google_sheet_id(spreadsheet_identifier):
58
+ logger.info('opening sheet by ID: %s', spreadsheet_identifier)
59
+ sh = gc.open_by_key(spreadsheet_identifier)
60
+ else:
61
+ logger.info('opening sheet by name: %s', spreadsheet_identifier)
62
+ sh = gc.open(spreadsheet_identifier)
63
+
64
+ wks = sh.worksheet(worksheet_name)
65
+ df = pd.DataFrame(wks.get_all_records())
66
+ logger.info('google sheets read success')
67
+ logger.debug('google sheets preview: %s', df.head())
68
+ return df
69
+
70
+ except gspread.exceptions.SpreadsheetNotFound:
71
+ logger.error("spreadsheet '%s' not found", spreadsheet_identifier)
72
+ return None
73
+ except gspread.exceptions.WorksheetNotFound:
74
+ logger.error("worksheet '%s' not found in spreadsheet", worksheet_name)
75
+ return None
76
+ except Exception as e:
77
+ logger.exception('an unexpected error occurred: %s', e)
78
+ return None
79
+
80
+
81
+ def gs_write_df(address, df, cfg=None, loc='A1', service_account_path=None):
82
+ if isinstance(cfg, str) and _is_cell_loc(cfg):
83
+ _warn_deprecated('Passing loc as the third positional argument is deprecated; use keyword loc=...')
84
+ if isinstance(loc, str) and not _is_cell_loc(loc) and service_account_path is None:
85
+ service_account_path = loc
86
+ loc = cfg
87
+ cfg = None
88
+
89
+ logger.info('writing google sheets')
90
+ sa_path = _resolve_service_account_path(cfg, service_account_path)
91
+ gc = _get_gspread_client(sa_path)
92
+
93
+ spreadsheet_identifier = address[0]
94
+ worksheet_name = address[1]
95
+
96
+ is_id = _is_google_sheet_id(spreadsheet_identifier)
97
+
98
+ try:
99
+ if is_id:
100
+ logger.info('opening sheet by ID: %s', spreadsheet_identifier)
101
+ sh = gc.open_by_key(spreadsheet_identifier)
102
+ else:
103
+ logger.info('opening sheet by name: %s', spreadsheet_identifier)
104
+ sh = gc.open(spreadsheet_identifier)
105
+
106
+ except gspread.exceptions.SpreadsheetNotFound:
107
+ if is_id:
108
+ logger.error("spreadsheet ID '%s' not found, cannot create with specific ID", spreadsheet_identifier)
109
+ return
110
+ else:
111
+ logger.info("spreadsheet '%s' not found, creating one", spreadsheet_identifier)
112
+ sh = gc.create(spreadsheet_identifier)
113
+
114
+ try:
115
+ wks = sh.worksheet(worksheet_name)
116
+ except gspread.exceptions.WorksheetNotFound:
117
+ logger.info('worksheet "%s" not found, creating one', worksheet_name)
118
+ wks = sh.add_worksheet(title=worksheet_name, rows="100", cols="20")
119
+
120
+ try:
121
+ wks.clear()
122
+ df_copy = df.copy()
123
+ non_float_int_columns = df_copy.select_dtypes(exclude=['float64', 'int64']).columns
124
+ for col in non_float_int_columns:
125
+ df_copy[col] = df_copy[col].astype(str)
126
+ wks.update(loc, ([df_copy.columns.values.tolist()] + df_copy.values.tolist()))
127
+
128
+ logger.info('data is written')
129
+ except Exception as e:
130
+ logger.exception('failed to update worksheet: %s', e)