tablemaster 2.1.1__tar.gz → 2.1.2__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.
- {tablemaster-2.1.1 → tablemaster-2.1.2}/PKG-INFO +1 -1
- {tablemaster-2.1.1 → tablemaster-2.1.2}/pyproject.toml +1 -1
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/database.py +13 -8
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/feishu.py +14 -9
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/gspread.py +11 -7
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster.egg-info/PKG-INFO +1 -1
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster.egg-info/SOURCES.txt +1 -0
- tablemaster-2.1.2/tests/test_error_visibility.py +54 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/LICENSE +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/README.md +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/setup.cfg +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/__init__.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/__main__.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/cli.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/config.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/local.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/__init__.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/apply.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/dialects/__init__.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/dialects/base.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/dialects/mysql.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/dialects/postgresql.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/dialects/tidb.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/diff.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/init.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/introspect.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/loader.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/models.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/plan.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/schema/pull.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/sync.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster/utils.py +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster.egg-info/dependency_links.txt +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster.egg-info/entry_points.txt +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster.egg-info/requires.txt +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tablemaster.egg-info/top_level.txt +0 -0
- {tablemaster-2.1.1 → tablemaster-2.1.2}/tests/test_schema_core.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tablemaster
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: tablemaster is a Python toolkit for moving and managing tabular data across databases, Feishu/Lark, Google Sheets, and local files with one consistent API.
|
|
5
5
|
Author-email: Livid <livid.su@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/ilivid/tablemaster
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "tablemaster"
|
|
7
|
-
version = "2.1.
|
|
7
|
+
version = "2.1.2"
|
|
8
8
|
description = "tablemaster is a Python toolkit for moving and managing tabular data across databases, Feishu/Lark, Google Sheets, and local files with one consistent API."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -5,7 +5,7 @@ import warnings
|
|
|
5
5
|
from typing import Union, List, Tuple, Dict, Any, Optional
|
|
6
6
|
from functools import lru_cache
|
|
7
7
|
|
|
8
|
-
from sqlalchemy import create_engine, pool, text
|
|
8
|
+
from sqlalchemy import create_engine, inspect, pool, text
|
|
9
9
|
from sqlalchemy.engine import Engine
|
|
10
10
|
import pandas as pd
|
|
11
11
|
from datetime import datetime
|
|
@@ -230,12 +230,13 @@ class ManageTable:
|
|
|
230
230
|
bool: True if table exists, False otherwise.
|
|
231
231
|
"""
|
|
232
232
|
safe_table: str = _safe_identifier(self.table)
|
|
233
|
-
check_sql = text(f'SELECT 1 FROM `{safe_table}` LIMIT 1')
|
|
234
233
|
try:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
engine: Engine = _resolve_engine(self.configs if hasattr(self, 'configs') else self, autocommit=False)
|
|
235
|
+
inspector = inspect(engine)
|
|
236
|
+
return inspector.has_table(safe_table)
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.exception('failed to check if table exists: %s', e)
|
|
239
|
+
raise
|
|
239
240
|
|
|
240
241
|
def delete_table(self) -> None:
|
|
241
242
|
"""
|
|
@@ -245,8 +246,9 @@ class ManageTable:
|
|
|
245
246
|
try:
|
|
246
247
|
opt(text(f'DROP TABLE `{safe_table}`'), self)
|
|
247
248
|
logger.info('%s deleted', self.table)
|
|
248
|
-
except Exception:
|
|
249
|
-
logger.exception('table was not deleted')
|
|
249
|
+
except Exception as e:
|
|
250
|
+
logger.exception('table was not deleted: %s', e)
|
|
251
|
+
raise
|
|
250
252
|
|
|
251
253
|
def par_del(self, clause: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
252
254
|
"""
|
|
@@ -305,6 +307,7 @@ class ManageTable:
|
|
|
305
307
|
pbar.update(1)
|
|
306
308
|
except Exception as e:
|
|
307
309
|
logger.exception('an error occurred during upload: %s', e)
|
|
310
|
+
raise
|
|
308
311
|
|
|
309
312
|
def upsert_data(self, df: pd.DataFrame, chunk_size: int = 10000, add_date: bool = False, ignore: bool = False, key: Union[str, List[str], Tuple[str, ...], None] = None) -> None:
|
|
310
313
|
"""
|
|
@@ -411,6 +414,7 @@ class ManageTable:
|
|
|
411
414
|
pbar.update(1)
|
|
412
415
|
except Exception as e:
|
|
413
416
|
logger.exception('an error occurred during upsert: %s', e)
|
|
417
|
+
raise
|
|
414
418
|
|
|
415
419
|
class Manage_table(ManageTable):
|
|
416
420
|
"""
|
|
@@ -466,3 +470,4 @@ class Manage_table(ManageTable):
|
|
|
466
470
|
pbar.update(1)
|
|
467
471
|
except Exception as e:
|
|
468
472
|
logger.exception('an error occurred during upload: %s', e)
|
|
473
|
+
raise
|
|
@@ -178,9 +178,10 @@ def fs_write_df(sheet_address, df, feishu_cfg, loc='A1', clear_sheet=True):
|
|
|
178
178
|
if clear_resp.json().get('code') == 0:
|
|
179
179
|
logger.info('sheet cleared')
|
|
180
180
|
else:
|
|
181
|
-
|
|
181
|
+
raise RuntimeError(f"failed to clear sheet: {clear_resp.json().get('msg')}")
|
|
182
182
|
except Exception as e:
|
|
183
|
-
logger.
|
|
183
|
+
logger.exception('failed to clear sheet: %s', e)
|
|
184
|
+
raise
|
|
184
185
|
|
|
185
186
|
# 处理 DataFrame 数据类型
|
|
186
187
|
df_copy = df.copy()
|
|
@@ -305,8 +306,7 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
305
306
|
existing_fields = _get_bitable_fields(app_token, table_id, header)
|
|
306
307
|
|
|
307
308
|
if not existing_fields:
|
|
308
|
-
|
|
309
|
-
return None
|
|
309
|
+
raise ValueError('could not fetch table fields or table has no fields')
|
|
310
310
|
|
|
311
311
|
logger.info('table has %s fields', len(existing_fields))
|
|
312
312
|
|
|
@@ -323,8 +323,7 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
323
323
|
logger.warning('skip column: %s', field)
|
|
324
324
|
|
|
325
325
|
if not valid_fields:
|
|
326
|
-
|
|
327
|
-
return None
|
|
326
|
+
raise ValueError('no valid fields to write, all dataframe columns are missing in bitable')
|
|
328
327
|
|
|
329
328
|
logger.info('will write %s valid fields', len(valid_fields))
|
|
330
329
|
|
|
@@ -360,8 +359,9 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
360
359
|
_request_with_retry("post", delete_url, headers=header, json_data=delete_data)
|
|
361
360
|
logger.info('deleted %s records', len(record_ids))
|
|
362
361
|
|
|
363
|
-
except Exception as e:
|
|
364
|
-
logger.
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.exception('failed to clear table: %s', e)
|
|
364
|
+
raise
|
|
365
365
|
|
|
366
366
|
# 处理 DataFrame - 只保留有效字段
|
|
367
367
|
df_copy = df[list(valid_fields)].copy()
|
|
@@ -444,7 +444,7 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
444
444
|
str_val = str(value)
|
|
445
445
|
if str_val and str_val != 'None' and str_val != 'nan':
|
|
446
446
|
fields[col] = str_val
|
|
447
|
-
except:
|
|
447
|
+
except Exception:
|
|
448
448
|
if col not in skipped_cols:
|
|
449
449
|
skipped_cols.add(col)
|
|
450
450
|
continue
|
|
@@ -457,6 +457,7 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
457
457
|
# 批量写入(每次最多500条)
|
|
458
458
|
batch_size = 500
|
|
459
459
|
all_responses = []
|
|
460
|
+
failed_batches = []
|
|
460
461
|
|
|
461
462
|
for i in range(0, len(records), batch_size):
|
|
462
463
|
batch = records[i:i + batch_size]
|
|
@@ -473,9 +474,11 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
473
474
|
logger.info('batch %s wrote %s records', i // batch_size + 1, len(batch))
|
|
474
475
|
else:
|
|
475
476
|
logger.error('failed to write batch: %s', response.get('msg', 'Unknown error'))
|
|
477
|
+
failed_batches.append((i // batch_size + 1, response.get('msg', 'Unknown error')))
|
|
476
478
|
|
|
477
479
|
except Exception as e:
|
|
478
480
|
logger.exception('failed to write batch: %s', e)
|
|
481
|
+
failed_batches.append((i // batch_size + 1, str(e)))
|
|
479
482
|
|
|
480
483
|
logger.info('write summary total records: %s', len(records))
|
|
481
484
|
logger.info('write summary fields written: %s', len(valid_fields))
|
|
@@ -483,6 +486,8 @@ def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
|
|
|
483
486
|
logger.info('write summary fields skipped: %s', len(missing_fields))
|
|
484
487
|
for field in sorted(missing_fields):
|
|
485
488
|
logger.info('skip field: %s', field)
|
|
489
|
+
if failed_batches:
|
|
490
|
+
raise RuntimeError(f'bitable write failed for {len(failed_batches)} batch(es): {failed_batches}')
|
|
486
491
|
logger.info('data is written')
|
|
487
492
|
|
|
488
493
|
return all_responses
|
|
@@ -68,14 +68,16 @@ def gs_read_df(address, cfg=None, service_account_path=None):
|
|
|
68
68
|
return df
|
|
69
69
|
|
|
70
70
|
except gspread.exceptions.SpreadsheetNotFound:
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
message = f"spreadsheet '{spreadsheet_identifier}' not found"
|
|
72
|
+
logger.error(message)
|
|
73
|
+
raise ValueError(message)
|
|
73
74
|
except gspread.exceptions.WorksheetNotFound:
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
message = f"worksheet '{worksheet_name}' not found in spreadsheet"
|
|
76
|
+
logger.error(message)
|
|
77
|
+
raise ValueError(message)
|
|
76
78
|
except Exception as e:
|
|
77
79
|
logger.exception('an unexpected error occurred: %s', e)
|
|
78
|
-
|
|
80
|
+
raise
|
|
79
81
|
|
|
80
82
|
|
|
81
83
|
def gs_write_df(address, df, cfg=None, loc='A1', service_account_path=None):
|
|
@@ -105,8 +107,9 @@ def gs_write_df(address, df, cfg=None, loc='A1', service_account_path=None):
|
|
|
105
107
|
|
|
106
108
|
except gspread.exceptions.SpreadsheetNotFound:
|
|
107
109
|
if is_id:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
message = f"spreadsheet ID '{spreadsheet_identifier}' not found, cannot create with specific ID"
|
|
111
|
+
logger.error(message)
|
|
112
|
+
raise ValueError(message)
|
|
110
113
|
else:
|
|
111
114
|
logger.info("spreadsheet '%s' not found, creating one", spreadsheet_identifier)
|
|
112
115
|
sh = gc.create(spreadsheet_identifier)
|
|
@@ -128,3 +131,4 @@ def gs_write_df(address, df, cfg=None, loc='A1', service_account_path=None):
|
|
|
128
131
|
logger.info('data is written')
|
|
129
132
|
except Exception as e:
|
|
130
133
|
logger.exception('failed to update worksheet: %s', e)
|
|
134
|
+
raise
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tablemaster
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.2
|
|
4
4
|
Summary: tablemaster is a Python toolkit for moving and managing tabular data across databases, Feishu/Lark, Google Sheets, and local files with one consistent API.
|
|
5
5
|
Author-email: Livid <livid.su@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/ilivid/tablemaster
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from types import SimpleNamespace
|
|
2
|
+
from unittest import TestCase
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from tablemaster.database import ManageTable
|
|
8
|
+
from tablemaster.feishu import fs_write_base
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _DummyResponse:
|
|
12
|
+
def __init__(self, body, status_code=200):
|
|
13
|
+
self._body = body
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
def json(self):
|
|
17
|
+
return self._body
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ErrorVisibilityTests(TestCase):
|
|
21
|
+
def setUp(self):
|
|
22
|
+
self.db_cfg = SimpleNamespace(
|
|
23
|
+
name='test_db',
|
|
24
|
+
user='u',
|
|
25
|
+
password='p',
|
|
26
|
+
host='127.0.0.1',
|
|
27
|
+
database='d',
|
|
28
|
+
db_type='mysql',
|
|
29
|
+
)
|
|
30
|
+
self.feishu_cfg = SimpleNamespace(feishu_app_id='id', feishu_app_secret='secret')
|
|
31
|
+
|
|
32
|
+
def test_manage_table_exists_propagates_errors(self):
|
|
33
|
+
table = ManageTable('orders', self.db_cfg)
|
|
34
|
+
with patch('tablemaster.database._resolve_engine', side_effect=RuntimeError('db unavailable')):
|
|
35
|
+
with self.assertRaises(RuntimeError):
|
|
36
|
+
table.exists()
|
|
37
|
+
|
|
38
|
+
def test_delete_table_propagates_errors(self):
|
|
39
|
+
table = ManageTable('orders', self.db_cfg)
|
|
40
|
+
with patch('tablemaster.database.opt', side_effect=RuntimeError('drop failed')):
|
|
41
|
+
with self.assertRaises(RuntimeError):
|
|
42
|
+
table.delete_table()
|
|
43
|
+
|
|
44
|
+
def test_fs_write_base_raises_when_batch_write_failed(self):
|
|
45
|
+
df = pd.DataFrame({'a': [1]})
|
|
46
|
+
|
|
47
|
+
with patch('tablemaster.feishu._get_tenant_access_token', return_value='token'):
|
|
48
|
+
with patch('tablemaster.feishu._get_bitable_fields', return_value={'a'}):
|
|
49
|
+
with patch(
|
|
50
|
+
'tablemaster.feishu._request_with_retry',
|
|
51
|
+
return_value=_DummyResponse({'code': 1001, 'msg': 'bad request'}),
|
|
52
|
+
):
|
|
53
|
+
with self.assertRaises(RuntimeError):
|
|
54
|
+
fs_write_base(['app_token', 'table_id'], df, self.feishu_cfg)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|