tablemaster 2.1.4__tar.gz → 2.1.6__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.
Files changed (37) hide show
  1. {tablemaster-2.1.4 → tablemaster-2.1.6}/PKG-INFO +1 -1
  2. {tablemaster-2.1.4 → tablemaster-2.1.6}/pyproject.toml +1 -1
  3. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/feishu.py +65 -12
  4. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/gspread.py +85 -4
  5. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster.egg-info/PKG-INFO +1 -1
  6. {tablemaster-2.1.4 → tablemaster-2.1.6}/LICENSE +0 -0
  7. {tablemaster-2.1.4 → tablemaster-2.1.6}/README.md +0 -0
  8. {tablemaster-2.1.4 → tablemaster-2.1.6}/setup.cfg +0 -0
  9. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/__init__.py +0 -0
  10. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/__main__.py +0 -0
  11. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/cli.py +0 -0
  12. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/config.py +0 -0
  13. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/database.py +0 -0
  14. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/local.py +0 -0
  15. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/__init__.py +0 -0
  16. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/apply.py +0 -0
  17. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/dialects/__init__.py +0 -0
  18. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/dialects/base.py +0 -0
  19. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/dialects/mysql.py +0 -0
  20. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/dialects/postgresql.py +0 -0
  21. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/dialects/tidb.py +0 -0
  22. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/diff.py +0 -0
  23. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/init.py +0 -0
  24. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/introspect.py +0 -0
  25. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/loader.py +0 -0
  26. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/models.py +0 -0
  27. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/plan.py +0 -0
  28. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/schema/pull.py +0 -0
  29. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/sync.py +0 -0
  30. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster/utils.py +0 -0
  31. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster.egg-info/SOURCES.txt +0 -0
  32. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster.egg-info/dependency_links.txt +0 -0
  33. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster.egg-info/entry_points.txt +0 -0
  34. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster.egg-info/requires.txt +0 -0
  35. {tablemaster-2.1.4 → tablemaster-2.1.6}/tablemaster.egg-info/top_level.txt +0 -0
  36. {tablemaster-2.1.4 → tablemaster-2.1.6}/tests/test_error_visibility.py +0 -0
  37. {tablemaster-2.1.4 → tablemaster-2.1.6}/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.4
3
+ Version: 2.1.6
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.4"
7
+ version = "2.1.6"
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"
@@ -10,12 +10,53 @@ logger = logging.getLogger(__name__)
10
10
  _TOKEN_CACHE = {}
11
11
 
12
12
 
13
+ def _normalize_url(url):
14
+ normalized = url.strip()
15
+ if '`' in normalized:
16
+ logger.warning('url contains backticks, auto sanitize')
17
+ normalized = normalized.replace('`', '').strip()
18
+ return normalized
19
+
20
+
21
+ def _parse_json_response(response, context):
22
+ if response.status_code >= 400:
23
+ body_preview = response.text[:500].replace('\n', ' ')
24
+ raise requests.HTTPError(
25
+ f'{context} failed with status={response.status_code}, body={body_preview}',
26
+ response=response
27
+ )
28
+
29
+ content_type = response.headers.get('Content-Type', '')
30
+ if 'application/json' not in content_type.lower():
31
+ body_preview = response.text[:500].replace('\n', ' ')
32
+ raise ValueError(
33
+ f'{context} expected JSON but got Content-Type={content_type}, body={body_preview}'
34
+ )
35
+
36
+ try:
37
+ return response.json()
38
+ except ValueError as exc:
39
+ body_preview = response.text[:500].replace('\n', ' ')
40
+ raise ValueError(f'{context} invalid JSON response, body={body_preview}') from exc
41
+
42
+
43
+ def _ensure_feishu_success(body, context):
44
+ if not isinstance(body, dict):
45
+ raise ValueError(f'{context} expected dict JSON body')
46
+ code = body.get('code')
47
+ if code != 0:
48
+ msg = body.get('msg', 'Unknown error')
49
+ raise RuntimeError(f'{context} failed with code={code}, msg={msg}')
50
+ return body
51
+
52
+
13
53
  def _request_with_retry(method, url, headers=None, params=None, json_data=None, data=None, timeout=30):
14
54
  max_retries = 3
55
+ normalized_url = _normalize_url(url)
15
56
  for attempt in range(max_retries):
16
57
  response = requests.request(
17
58
  method=method,
18
- url=url,
59
+ url=normalized_url,
19
60
  headers=headers,
20
61
  params=params,
21
62
  json=json_data,
@@ -45,8 +86,11 @@ def _get_tenant_access_token(feishu_cfg):
45
86
  "app_secret": feishu_cfg.feishu_app_secret
46
87
  }
47
88
  r = _request_with_retry("post", feishu_url, json_data=post_data)
48
- body = r.json()
49
- token = body["tenant_access_token"]
89
+ body = _parse_json_response(r, 'get tenant access token')
90
+ _ensure_feishu_success(body, 'get tenant access token')
91
+ token = body.get("tenant_access_token")
92
+ if not token:
93
+ raise KeyError('tenant_access_token is missing in auth response')
50
94
  expire_seconds = int(body.get('expire', 7200))
51
95
  _TOKEN_CACHE[cache_key] = {
52
96
  'token': token,
@@ -91,7 +135,17 @@ def fs_read_df(sheet_address, feishu_cfg):
91
135
  + "?valueRenderOption=ToString&dateTimeRenderOption=FormattedString"
92
136
  )
93
137
  r = _request_with_retry("get", url, headers=header)
94
- pull_data = r.json()['data']['valueRange']['values']
138
+ body = _parse_json_response(r, 'read sheets values')
139
+ _ensure_feishu_success(body, 'read sheets values')
140
+
141
+ pull_data = body.get('data', {}).get('valueRange', {}).get('values')
142
+ if not pull_data:
143
+ logger.info('sheet is empty: %s', sheet_address)
144
+ return pd.DataFrame()
145
+ if not isinstance(pull_data, list):
146
+ raise ValueError('unexpected sheets values structure: values is not a list')
147
+ if not pull_data[0]:
148
+ return pd.DataFrame(pull_data[1:] if len(pull_data) > 1 else [])
95
149
  return pd.DataFrame(pull_data[1:], columns=pull_data[0])
96
150
 
97
151
 
@@ -127,7 +181,9 @@ def fs_read_base(sheet_address, feishu_cfg):
127
181
  query_params += f"&page_token={page_token}"
128
182
 
129
183
  r = _request_with_retry("get", base_url + query_params, headers=header)
130
- data = r.json().get('data', {})
184
+ body = _parse_json_response(r, 'read bitable records')
185
+ _ensure_feishu_success(body, 'read bitable records')
186
+ data = body.get('data', {})
131
187
  pull_data.extend(data.get('items', []))
132
188
  has_more = data.get('has_more', False)
133
189
  page_token = data.get('page_token')
@@ -263,13 +319,10 @@ def _get_bitable_fields(app_token, table_id, header):
263
319
  """
264
320
  url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/fields"
265
321
  r = _request_with_retry("get", url, headers=header)
266
-
267
- if r.status_code == 200 and r.json().get('code') == 0:
268
- items = r.json().get('data', {}).get('items', [])
269
- return {item['field_name'] for item in items}
270
- else:
271
- logger.warning("failed to get fields: %s", r.json().get('msg', 'Unknown error'))
272
- return set()
322
+ body = _parse_json_response(r, 'get bitable fields')
323
+ _ensure_feishu_success(body, 'get bitable fields')
324
+ items = body.get('data', {}).get('items', [])
325
+ return {item['field_name'] for item in items}
273
326
 
274
327
  def fs_write_base(sheet_address, df, feishu_cfg, clear_table=False):
275
328
  """
@@ -1,8 +1,12 @@
1
1
  import gspread
2
+ import json
3
+ import math
2
4
  import pandas as pd
5
+ import numpy as np
3
6
  import re
4
7
  import warnings
5
8
  import logging
9
+ from datetime import date, datetime, time
6
10
  from functools import lru_cache
7
11
 
8
12
  logger = logging.getLogger(__name__)
@@ -19,6 +23,85 @@ def _warn_deprecated(message):
19
23
  warnings.warn(f'{message} This usage will be removed in a future release.', FutureWarning, stacklevel=3)
20
24
 
21
25
 
26
+ def _json_safe(value):
27
+ if value is None or value is pd.NA or value is pd.NaT:
28
+ return None
29
+
30
+ if isinstance(value, pd.Timestamp):
31
+ return None if pd.isna(value) else value.isoformat()
32
+ if isinstance(value, pd.Timedelta):
33
+ return None if pd.isna(value) else str(value)
34
+ if isinstance(value, np.datetime64):
35
+ return None if np.isnat(value) else pd.Timestamp(value).isoformat()
36
+ if isinstance(value, np.timedelta64):
37
+ try:
38
+ if np.isnat(value):
39
+ return None
40
+ except TypeError:
41
+ pass
42
+ return str(pd.Timedelta(value))
43
+ if isinstance(value, (datetime, date, time)):
44
+ return value.isoformat()
45
+
46
+ if isinstance(value, (float, np.floating)):
47
+ if np.isnan(value) or np.isinf(value):
48
+ return None
49
+ return float(value)
50
+ if isinstance(value, np.integer):
51
+ return int(value)
52
+ if isinstance(value, np.bool_):
53
+ return bool(value)
54
+
55
+ if isinstance(value, dict):
56
+ return {k: _json_safe(v) for k, v in value.items()}
57
+ if isinstance(value, (list, tuple, set)):
58
+ return [_json_safe(v) for v in value]
59
+ if isinstance(value, np.ndarray):
60
+ return [_json_safe(v) for v in value.tolist()]
61
+
62
+ try:
63
+ if math.isnan(value) or math.isinf(value):
64
+ return None
65
+ except Exception:
66
+ pass
67
+
68
+ return value
69
+
70
+
71
+ def _build_values(df):
72
+ safe_cols = [_json_safe(col) for col in df.columns.tolist()]
73
+ safe_cols = [("" if col is None else col) for col in safe_cols]
74
+ for col_index, col_value in enumerate(safe_cols, start=1):
75
+ try:
76
+ json.dumps(col_value, allow_nan=False)
77
+ except (TypeError, ValueError) as exc:
78
+ raise ValueError(f'column header at col {col_index} is not JSON serializable: {exc}') from exc
79
+
80
+ rows = df.values.tolist()
81
+ safe_rows = []
82
+ for row_index, row in enumerate(rows, start=2):
83
+ safe_row = []
84
+ for col_index, cell in enumerate(row, start=1):
85
+ safe_cell = _json_safe(cell)
86
+ safe_cell = '' if safe_cell is None else safe_cell
87
+ try:
88
+ json.dumps(safe_cell, allow_nan=False)
89
+ except (TypeError, ValueError) as exc:
90
+ column_name = str(df.columns[col_index - 1])
91
+ raise ValueError(
92
+ f'cell at row {row_index}, col {col_index} ({column_name}) is not JSON serializable: {exc}'
93
+ ) from exc
94
+ safe_row.append(safe_cell)
95
+ safe_rows.append(safe_row)
96
+
97
+ values = [safe_cols] + safe_rows
98
+ try:
99
+ json.dumps({'values': values}, allow_nan=False)
100
+ except (TypeError, ValueError) as exc:
101
+ raise ValueError(f'worksheet payload contains non-JSON-safe data: {exc}') from exc
102
+ return values
103
+
104
+
22
105
  def _resolve_service_account_path(cfg, service_account_path):
23
106
  if service_account_path:
24
107
  _warn_deprecated('service_account_path argument is deprecated; pass a cfg object instead.')
@@ -123,10 +206,8 @@ def gs_write_df(address, df, cfg=None, loc='A1', service_account_path=None):
123
206
  try:
124
207
  wks.clear()
125
208
  df_copy = df.copy()
126
- non_float_int_columns = df_copy.select_dtypes(exclude=['float64', 'int64']).columns
127
- for col in non_float_int_columns:
128
- df_copy[col] = df_copy[col].astype(str)
129
- wks.update(loc, ([df_copy.columns.values.tolist()] + df_copy.values.tolist()))
209
+ values = _build_values(df_copy)
210
+ wks.update(loc, values)
130
211
 
131
212
  logger.info('data is written')
132
213
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tablemaster
3
- Version: 2.1.4
3
+ Version: 2.1.6
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
File without changes
File without changes
File without changes