larksync 0.1.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.
- larksync/__init__.py +6 -0
- larksync/auth.py +179 -0
- larksync/bitable.py +186 -0
- larksync/diff.py +154 -0
- larksync/history.py +41 -0
- larksync/http.py +55 -0
- larksync/sheets.py +230 -0
- larksync/table.py +223 -0
- larksync-0.1.0.dist-info/METADATA +112 -0
- larksync-0.1.0.dist-info/RECORD +12 -0
- larksync-0.1.0.dist-info/WHEEL +4 -0
- larksync-0.1.0.dist-info/licenses/LICENSE +21 -0
larksync/__init__.py
ADDED
larksync/auth.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""本机飞书应用配置与登录。
|
|
2
|
+
|
|
3
|
+
每人用自己的飞书应用(表格权限因人而异),configure() 一次即可。
|
|
4
|
+
user 身份(默认)首次登录打开浏览器,之后缓存 token、自动续期;tenant 身份无需登录。
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
15
|
+
|
|
16
|
+
import platformdirs
|
|
17
|
+
|
|
18
|
+
from .http import BASE, LarkError, call
|
|
19
|
+
|
|
20
|
+
CONFIG_DIR = Path(platformdirs.user_config_dir("larksync", appauthor=False))
|
|
21
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
22
|
+
TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
23
|
+
|
|
24
|
+
SCOPES = "wiki:wiki:readonly sheets:spreadsheet bitable:app"
|
|
25
|
+
_EXPIRY_MARGIN_S = 300 # 提前 5 分钟判过期,免得请求发出的瞬间失效
|
|
26
|
+
_LOGIN_TIMEOUT_S = 180
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def configure(app_id, app_secret, port=8888):
|
|
30
|
+
"""保存本机的飞书应用。port 要和应用后台配置的重定向地址 http://localhost:<port>/callback 一致。"""
|
|
31
|
+
write_json(CONFIG_FILE, {"app_id": app_id.strip(), "app_secret": app_secret.strip(),
|
|
32
|
+
"port": int(port)})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config():
|
|
36
|
+
cfg = read_json(CONFIG_FILE)
|
|
37
|
+
app_id = os.environ.get("LARKSYNC_APP_ID") or cfg.get("app_id")
|
|
38
|
+
app_secret = os.environ.get("LARKSYNC_APP_SECRET") or cfg.get("app_secret")
|
|
39
|
+
if not app_id or not app_secret:
|
|
40
|
+
raise LarkError("还没配置飞书应用:先运行 larksync.configure(app_id, app_secret),"
|
|
41
|
+
"或设置环境变量 LARKSYNC_APP_ID / LARKSYNC_APP_SECRET")
|
|
42
|
+
return app_id, app_secret, int(cfg.get("port") or 8888)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Auth:
|
|
46
|
+
def __init__(self, method="user"):
|
|
47
|
+
if method not in ("user", "tenant"):
|
|
48
|
+
raise LarkError(f'auth 只能是 "user" 或 "tenant",收到 {method!r}')
|
|
49
|
+
self.method = method
|
|
50
|
+
self.app_id, self.app_secret, self.port = load_config()
|
|
51
|
+
|
|
52
|
+
def token(self):
|
|
53
|
+
c = self._cached()
|
|
54
|
+
if c and c["expires_at"] > time.time():
|
|
55
|
+
return c["access_token"]
|
|
56
|
+
if self.method == "tenant":
|
|
57
|
+
body = call("POST", "/auth/v3/tenant_access_token/internal",
|
|
58
|
+
json={"app_id": self.app_id, "app_secret": self.app_secret})
|
|
59
|
+
return self._save({"access_token": body["tenant_access_token"],
|
|
60
|
+
"expires_in": body.get("expire")})
|
|
61
|
+
if c and (t := self._refresh(c.get("refresh_token"))):
|
|
62
|
+
return t
|
|
63
|
+
return self._login()
|
|
64
|
+
|
|
65
|
+
def account(self):
|
|
66
|
+
"""已登录的用户名(只看本地缓存,不联网);没登录返回 None。"""
|
|
67
|
+
c = self._cached()
|
|
68
|
+
if not c or (c["expires_at"] <= time.time() and not c.get("refresh_token")):
|
|
69
|
+
return None
|
|
70
|
+
return c.get("name") or "飞书用户"
|
|
71
|
+
|
|
72
|
+
def logout(self):
|
|
73
|
+
d = read_json(TOKEN_FILE)
|
|
74
|
+
if d.pop(self.method, None) is not None:
|
|
75
|
+
write_json(TOKEN_FILE, d)
|
|
76
|
+
|
|
77
|
+
# -- 缓存:按 app_id 隔离,换应用自动作废 --
|
|
78
|
+
|
|
79
|
+
def _cached(self):
|
|
80
|
+
d = read_json(TOKEN_FILE)
|
|
81
|
+
block = d.get(self.method) if d.get("app_id") == self.app_id else None
|
|
82
|
+
if isinstance(block, dict) and block.get("access_token"):
|
|
83
|
+
return block
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def _save(self, data):
|
|
87
|
+
token = data.get("access_token")
|
|
88
|
+
if not token:
|
|
89
|
+
raise LarkError(f"飞书没有返回 access_token:{data}")
|
|
90
|
+
d = read_json(TOKEN_FILE)
|
|
91
|
+
if d.get("app_id") != self.app_id:
|
|
92
|
+
d = {"app_id": self.app_id}
|
|
93
|
+
d[self.method] = {
|
|
94
|
+
"access_token": token,
|
|
95
|
+
"refresh_token": data.get("refresh_token", ""),
|
|
96
|
+
"expires_at": time.time() + float(data.get("expires_in") or 0) - _EXPIRY_MARGIN_S,
|
|
97
|
+
"name": data.get("name") or (d.get(self.method) or {}).get("name", ""),
|
|
98
|
+
}
|
|
99
|
+
try:
|
|
100
|
+
write_json(TOKEN_FILE, d)
|
|
101
|
+
except OSError:
|
|
102
|
+
pass # 缓存写不下去不该让整个流程失败
|
|
103
|
+
return token
|
|
104
|
+
|
|
105
|
+
# -- user 身份:续期 -> 浏览器登录 --
|
|
106
|
+
|
|
107
|
+
def _app_token(self):
|
|
108
|
+
body = call("POST", "/auth/v3/app_access_token/internal",
|
|
109
|
+
json={"app_id": self.app_id, "app_secret": self.app_secret})
|
|
110
|
+
return body["app_access_token"]
|
|
111
|
+
|
|
112
|
+
def _refresh(self, refresh_token):
|
|
113
|
+
if not refresh_token:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
body = call("POST", "/authen/v1/refresh_access_token", token=self._app_token(),
|
|
117
|
+
json={"grant_type": "refresh_token", "refresh_token": refresh_token})
|
|
118
|
+
except LarkError:
|
|
119
|
+
# refresh_token 每次续期都会轮换:可能是别的进程刚续过,再看一眼缓存
|
|
120
|
+
c = self._cached()
|
|
121
|
+
if c and c["expires_at"] > time.time():
|
|
122
|
+
return c["access_token"]
|
|
123
|
+
return None
|
|
124
|
+
return self._save(body.get("data") or {})
|
|
125
|
+
|
|
126
|
+
def _login(self):
|
|
127
|
+
got = {}
|
|
128
|
+
done = threading.Event()
|
|
129
|
+
|
|
130
|
+
class Callback(BaseHTTPRequestHandler):
|
|
131
|
+
def do_GET(self):
|
|
132
|
+
got["code"] = (parse_qs(urlparse(self.path).query).get("code") or [None])[0]
|
|
133
|
+
self.send_response(200)
|
|
134
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
135
|
+
self.end_headers()
|
|
136
|
+
self.wfile.write("<h3>飞书授权完成,可以关闭这个页面了。</h3>".encode())
|
|
137
|
+
done.set()
|
|
138
|
+
|
|
139
|
+
def log_message(self, *a):
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
server = HTTPServer(("localhost", self.port), Callback)
|
|
144
|
+
except OSError as e:
|
|
145
|
+
raise LarkError(f"本机端口 {self.port} 被占用:换个端口重新 configure,"
|
|
146
|
+
f"并同步修改应用后台的重定向地址") from e
|
|
147
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
148
|
+
url = f"{BASE}/authen/v1/index?" + urlencode({
|
|
149
|
+
"app_id": self.app_id, "scope": SCOPES,
|
|
150
|
+
"redirect_uri": f"http://localhost:{self.port}/callback"})
|
|
151
|
+
print(f"请在浏览器中登录飞书并授权(没有自动打开就手动访问):\n {url}",
|
|
152
|
+
file=sys.stderr, flush=True)
|
|
153
|
+
webbrowser.open(url)
|
|
154
|
+
try:
|
|
155
|
+
if not done.wait(_LOGIN_TIMEOUT_S) or not got.get("code"):
|
|
156
|
+
raise LarkError(f"{_LOGIN_TIMEOUT_S} 秒内没有完成飞书授权。服务器上请改用 "
|
|
157
|
+
f'auth="tenant",或拷贝本机的 {TOKEN_FILE.name}')
|
|
158
|
+
finally:
|
|
159
|
+
server.shutdown()
|
|
160
|
+
server.server_close()
|
|
161
|
+
body = call("POST", "/authen/v1/access_token", token=self._app_token(),
|
|
162
|
+
json={"grant_type": "authorization_code", "code": got["code"]})
|
|
163
|
+
return self._save(body.get("data") or {})
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def read_json(path):
|
|
167
|
+
try:
|
|
168
|
+
d = json.loads(path.read_text(encoding="utf-8"))
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
return {}
|
|
171
|
+
return d if isinstance(d, dict) else {}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_json(path, data):
|
|
175
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
177
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
178
|
+
os.chmod(tmp, 0o600)
|
|
179
|
+
os.replace(tmp, path) # 原子替换,并发写入不会留下半个文件
|
larksync/bitable.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""多维表格后端:字段即列,record_id 即行。"""
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from .http import LarkError, call
|
|
6
|
+
from .sheets import cell_str
|
|
7
|
+
|
|
8
|
+
_BATCH = 500
|
|
9
|
+
_OR_MAX = 50 # 一次查询里 OR 的条件数
|
|
10
|
+
_LOOKUP_MAX = 2000 # 主键值再多就不如直接读全表
|
|
11
|
+
_WORKERS = 5
|
|
12
|
+
_FILTERABLE = {1, 13} # 能按「等于」精确查询的主键类型:文本、电话
|
|
13
|
+
_WRITABLE = {1, 2, 3, 4, 5, 7, 13} # 文本 数字 单选 多选 日期 复选框 电话
|
|
14
|
+
_DATES = {5, 1001, 1002} # 日期 创建时间 修改时间
|
|
15
|
+
_DATE_FORMATS = ("%Y-%m-%d", "%Y/%m/%d", "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M",
|
|
16
|
+
"%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S")
|
|
17
|
+
_TRUE = {"true", "1", "是", "yes", "y", "✓"}
|
|
18
|
+
_FALSE = {"false", "0", "否", "no", "n"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Bitable:
|
|
22
|
+
def __init__(self, auth, app_token, table_id=None):
|
|
23
|
+
self.auth = auth
|
|
24
|
+
tables = call("GET", f"/bitable/v1/apps/{app_token}/tables", token=auth.token(),
|
|
25
|
+
params={"page_size": 100})["data"].get("items") or []
|
|
26
|
+
self._tabs = [(t["table_id"], t.get("name", "")) for t in tables]
|
|
27
|
+
if not self._tabs:
|
|
28
|
+
raise LarkError("这个多维表格里没有数据表")
|
|
29
|
+
table_id = table_id or self._tabs[0][0]
|
|
30
|
+
if table_id not in dict(self._tabs):
|
|
31
|
+
ids = [f"{i}({n})" for i, n in self._tabs]
|
|
32
|
+
raise LarkError(f"没有 table={table_id} 的数据表,现有:{', '.join(ids)}")
|
|
33
|
+
self.target = f"bitable:{app_token}:{table_id}"
|
|
34
|
+
app = call("GET", f"/bitable/v1/apps/{app_token}", token=auth.token())["data"].get("app") or {}
|
|
35
|
+
self.doc_title = app.get("name", "")
|
|
36
|
+
self._base = f"/bitable/v1/apps/{app_token}/tables/{table_id}"
|
|
37
|
+
self._fields = {} # 字段名 → 类型,每次读取都刷新
|
|
38
|
+
|
|
39
|
+
def tabs(self):
|
|
40
|
+
return list(self._tabs)
|
|
41
|
+
|
|
42
|
+
def columns(self):
|
|
43
|
+
self._fields = {f["field_name"]: f["type"] for f in self._pages("GET", "/fields", 100)}
|
|
44
|
+
return list(self._fields)
|
|
45
|
+
|
|
46
|
+
def read(self):
|
|
47
|
+
"""返回 (列名, 记录, record_id);全空的记录跳过。"""
|
|
48
|
+
header = self.columns()
|
|
49
|
+
rows, refs = [], []
|
|
50
|
+
for it in self._pages("POST", "/records/search", 500, json={}):
|
|
51
|
+
row = self._row(it["fields"])
|
|
52
|
+
if any(row.values()):
|
|
53
|
+
rows.append(row)
|
|
54
|
+
refs.append(it["record_id"])
|
|
55
|
+
return header, rows, refs
|
|
56
|
+
|
|
57
|
+
def lookup(self, key, values):
|
|
58
|
+
"""主键第一列取值在 values 里的记录。返回 (记录, record_id, 表的总记录数)。
|
|
59
|
+
|
|
60
|
+
按条件查询,每次 OR 50 个值、并发 5 个请求;主键不是文本类或值太多时退回读全表。
|
|
61
|
+
"""
|
|
62
|
+
self.columns()
|
|
63
|
+
values = sorted(v for v in values if v)
|
|
64
|
+
if self._fields.get(key[0]) not in _FILTERABLE or len(values) > _LOOKUP_MAX:
|
|
65
|
+
_, rows, refs = self.read()
|
|
66
|
+
return rows, refs, len(rows)
|
|
67
|
+
|
|
68
|
+
def search(chunk):
|
|
69
|
+
flt = {"conjunction": "or", "conditions": [
|
|
70
|
+
{"field_name": key[0], "operator": "is", "value": [v]} for v in chunk]}
|
|
71
|
+
return [(it["record_id"], self._row(it["fields"]))
|
|
72
|
+
for it in self._pages("POST", "/records/search", 500, json={"filter": flt})]
|
|
73
|
+
|
|
74
|
+
chunks = [values[i:i + _OR_MAX] for i in range(0, len(values), _OR_MAX)]
|
|
75
|
+
with ThreadPoolExecutor(_WORKERS) as pool:
|
|
76
|
+
total = pool.submit(self._total)
|
|
77
|
+
found = [hit for part in pool.map(search, chunks) for hit in part]
|
|
78
|
+
return [r for _, r in found], [ref for ref, _ in found], total.result()
|
|
79
|
+
|
|
80
|
+
def _total(self):
|
|
81
|
+
return call("POST", self._base + "/records/search", token=self.auth.token(),
|
|
82
|
+
params={"page_size": 1}, json={})["data"].get("total", 0)
|
|
83
|
+
|
|
84
|
+
def fetch(self, refs):
|
|
85
|
+
"""只读指定的记录:{record_id: 记录},已删除的不在结果里。"""
|
|
86
|
+
self.columns()
|
|
87
|
+
out = {}
|
|
88
|
+
for i in range(0, len(refs), 100):
|
|
89
|
+
data = self._post("/records/batch_get", {"record_ids": refs[i:i + 100]})
|
|
90
|
+
for it in data.get("records") or []:
|
|
91
|
+
out[it["record_id"]] = self._row(it.get("fields") or {})
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
def writable(self, name):
|
|
95
|
+
return self._fields.get(name) in _WRITABLE
|
|
96
|
+
|
|
97
|
+
def canon(self, name, s):
|
|
98
|
+
"""本地文本按字段类型规整成读回来的样子(如 1.50 → 1.5),值不合法就报错。"""
|
|
99
|
+
if not s or not self.writable(name):
|
|
100
|
+
return s
|
|
101
|
+
return self._text(name, self._value(name, s))
|
|
102
|
+
|
|
103
|
+
def update(self, cells):
|
|
104
|
+
fields = {}
|
|
105
|
+
for ref, col, val in cells:
|
|
106
|
+
fields.setdefault(ref, {})[col] = self._value(col, val)
|
|
107
|
+
recs = [{"record_id": r, "fields": f} for r, f in fields.items()]
|
|
108
|
+
for i in range(0, len(recs), _BATCH):
|
|
109
|
+
self._post("/records/batch_update", {"records": recs[i:i + _BATCH]})
|
|
110
|
+
|
|
111
|
+
def create(self, rows):
|
|
112
|
+
"""分批新增,每批写完 yield 这批的 record_id。"""
|
|
113
|
+
for i in range(0, len(rows), _BATCH):
|
|
114
|
+
recs = [{"fields": {c: self._value(c, v) for c, v in r.items() if v}}
|
|
115
|
+
for r in rows[i:i + _BATCH]]
|
|
116
|
+
data = self._post("/records/batch_create", {"records": recs})
|
|
117
|
+
yield [r["record_id"] for r in data["records"]]
|
|
118
|
+
|
|
119
|
+
def delete(self, refs):
|
|
120
|
+
for i in range(0, len(refs), _BATCH):
|
|
121
|
+
self._post("/records/batch_delete", {"records": refs[i:i + _BATCH]})
|
|
122
|
+
|
|
123
|
+
def add_fields(self, names):
|
|
124
|
+
for n in names:
|
|
125
|
+
self._post("/fields", {"field_name": n, "type": 1})
|
|
126
|
+
self._fields[n] = 1
|
|
127
|
+
|
|
128
|
+
# -- 值转换 --
|
|
129
|
+
|
|
130
|
+
def _row(self, fields):
|
|
131
|
+
return {h: self._text(h, fields.get(h)) for h in self._fields}
|
|
132
|
+
|
|
133
|
+
def _text(self, name, v):
|
|
134
|
+
if self._fields.get(name) in _DATES and isinstance(v, (int, float)):
|
|
135
|
+
dt = datetime.fromtimestamp(v / 1000)
|
|
136
|
+
return dt.strftime("%Y-%m-%d" if dt.time() == datetime.min.time() else "%Y-%m-%d %H:%M")
|
|
137
|
+
return cell_str(v)
|
|
138
|
+
|
|
139
|
+
def _value(self, name, s):
|
|
140
|
+
"""文本 → 飞书字段值;空串表示清空。"""
|
|
141
|
+
if not s:
|
|
142
|
+
return None
|
|
143
|
+
t = self._fields[name]
|
|
144
|
+
if t == 2:
|
|
145
|
+
s = s.replace(",", "")
|
|
146
|
+
try:
|
|
147
|
+
return int(s)
|
|
148
|
+
except ValueError:
|
|
149
|
+
pass
|
|
150
|
+
try:
|
|
151
|
+
n = float(s)
|
|
152
|
+
except ValueError:
|
|
153
|
+
raise LarkError(f"{s!r} 不是数字") from None
|
|
154
|
+
return int(n) if n.is_integer() else n
|
|
155
|
+
if t == 4:
|
|
156
|
+
return [x.strip() for x in s.split(",") if x.strip()]
|
|
157
|
+
if t == 5:
|
|
158
|
+
for fmt in _DATE_FORMATS:
|
|
159
|
+
try:
|
|
160
|
+
return int(datetime.strptime(s, fmt).timestamp() * 1000)
|
|
161
|
+
except ValueError:
|
|
162
|
+
pass
|
|
163
|
+
raise LarkError(f"{s!r} 不是日期(支持 2026-01-02、2026/01/02、可带 HH:MM[:SS])")
|
|
164
|
+
if t == 7:
|
|
165
|
+
if s.lower() in _TRUE:
|
|
166
|
+
return True
|
|
167
|
+
if s.lower() in _FALSE:
|
|
168
|
+
return False
|
|
169
|
+
raise LarkError(f"{s!r} 不是勾选值(支持 true/false、是/否、1/0)")
|
|
170
|
+
return s
|
|
171
|
+
|
|
172
|
+
# -- HTTP --
|
|
173
|
+
|
|
174
|
+
def _pages(self, method, path, size, json=None):
|
|
175
|
+
page = None
|
|
176
|
+
while True:
|
|
177
|
+
params = {"page_size": size, **({"page_token": page} if page else {})}
|
|
178
|
+
data = call(method, self._base + path, token=self.auth.token(),
|
|
179
|
+
params=params, json=json)["data"]
|
|
180
|
+
yield from data.get("items") or []
|
|
181
|
+
if not data.get("has_more"):
|
|
182
|
+
return
|
|
183
|
+
page = data["page_token"]
|
|
184
|
+
|
|
185
|
+
def _post(self, path, payload):
|
|
186
|
+
return call("POST", self._base + path, token=self.auth.token(), json=payload)["data"]
|
larksync/diff.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""差异计算:按主键匹配本地行和在线行(两种表共用)。"""
|
|
2
|
+
import csv
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
from datetime import date, datetime, time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from .http import LarkError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Change:
|
|
13
|
+
key: tuple
|
|
14
|
+
column: str
|
|
15
|
+
old: str
|
|
16
|
+
new: str
|
|
17
|
+
ref: object = field(default=None, repr=False) # 在线行的 ref,写入时定位用
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Plan:
|
|
21
|
+
"""diff 的结果。apply 只写 changed 和 added,写之前可以随意删减。
|
|
22
|
+
|
|
23
|
+
默认只填空:在线已有不同值的格子放进 conflicts,不写;人工确认后
|
|
24
|
+
移入 changed 才会写(全部覆盖:plan.changed += plan.conflicts)。
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, table, key, changed, conflicts, added, unmatched):
|
|
28
|
+
self._table = table
|
|
29
|
+
self.key = key
|
|
30
|
+
self.changed = changed # list[Change]:在线为空、本地有值
|
|
31
|
+
self.conflicts = conflicts # list[Change]:在线已有不同的值(警告)
|
|
32
|
+
self.added = added # list[dict]:本地有、在线没有的行
|
|
33
|
+
self.unmatched = unmatched # int:在线有、本地没有的行数(只提示,不删)
|
|
34
|
+
|
|
35
|
+
def __repr__(self):
|
|
36
|
+
return (f"补空 {len(self.changed)} 格 / 新增 {len(self.added)} 行 / "
|
|
37
|
+
f"不一致 {len(self.conflicts)} 格(未写入)/ 未匹配 {self.unmatched} 行")
|
|
38
|
+
|
|
39
|
+
def apply(self):
|
|
40
|
+
"""写入飞书,返回写入记录 id(用于 Table.undo)。"""
|
|
41
|
+
return self._table._apply(self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load(source):
|
|
45
|
+
"""CSV / xlsx 的路径或字节、或 list[dict] → (列名, 行);值统一成去掉首尾空白的字符串。
|
|
46
|
+
|
|
47
|
+
xlsx 读第一个(打开时显示的)工作表,第 1 行是表头。
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(source, (str, os.PathLike)):
|
|
50
|
+
with open(source, "rb") as f:
|
|
51
|
+
source = f.read()
|
|
52
|
+
if isinstance(source, bytes) and source[:4] == b"PK\x03\x04": # xlsx 本质是 zip
|
|
53
|
+
raw, rows = _xlsx(source)
|
|
54
|
+
elif isinstance(source, bytes):
|
|
55
|
+
reader = csv.DictReader(io.StringIO(decode(source), newline=""))
|
|
56
|
+
raw = reader.fieldnames or []
|
|
57
|
+
rows = list(reader)
|
|
58
|
+
else:
|
|
59
|
+
rows = list(source)
|
|
60
|
+
raw = list(dict.fromkeys(k for r in rows for k in r))
|
|
61
|
+
header = [str(h).strip() for h in raw if h is not None and str(h).strip()]
|
|
62
|
+
dupes = sorted({h for h in header if header.count(h) > 1})
|
|
63
|
+
if dupes:
|
|
64
|
+
raise LarkError(f"本地数据的列名重复:{'、'.join(dupes)}")
|
|
65
|
+
rows = [{str(k).strip(): "" if v is None else str(v).strip()
|
|
66
|
+
for k, v in r.items() if k is not None and str(k).strip()} for r in rows]
|
|
67
|
+
return header, rows
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _xlsx(data):
|
|
71
|
+
from openpyxl import load_workbook # 只有读 xlsx 时才需要
|
|
72
|
+
|
|
73
|
+
ws = load_workbook(io.BytesIO(data), read_only=True, data_only=True).active
|
|
74
|
+
grid = [[_xlsx_cell(v) for v in row] for row in ws.iter_rows(values_only=True)]
|
|
75
|
+
if not grid:
|
|
76
|
+
return [], []
|
|
77
|
+
header = grid[0]
|
|
78
|
+
return header, [dict(zip(header, r)) for r in grid[1:] if any(r)]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _xlsx_cell(v):
|
|
82
|
+
"""按单元格显示的样子转文本:整数不带 .0,日期不带 00:00:00。"""
|
|
83
|
+
if v is None:
|
|
84
|
+
return ""
|
|
85
|
+
if isinstance(v, bool):
|
|
86
|
+
return "true" if v else "false"
|
|
87
|
+
if isinstance(v, float) and v.is_integer():
|
|
88
|
+
return str(int(v))
|
|
89
|
+
if isinstance(v, datetime):
|
|
90
|
+
return v.strftime("%Y-%m-%d" if v.time() == time() else "%Y-%m-%d %H:%M:%S")
|
|
91
|
+
if isinstance(v, date):
|
|
92
|
+
return v.isoformat()
|
|
93
|
+
return str(v)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decode(data):
|
|
97
|
+
"""UTF-8(含 BOM)优先,不行再按 GBK:Excel 中文版另存的 CSV 默认是 GBK。"""
|
|
98
|
+
try:
|
|
99
|
+
return data.decode("utf-8-sig")
|
|
100
|
+
except UnicodeDecodeError:
|
|
101
|
+
try:
|
|
102
|
+
return data.decode("gb18030")
|
|
103
|
+
except UnicodeDecodeError:
|
|
104
|
+
raise LarkError("CSV 编码认不出来:请另存为 UTF-8 编码的 CSV") from None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def compute(online_rows, online_refs, local_rows, key, columns):
|
|
108
|
+
"""返回 (补空, 不一致, 新增的本地行, 匹配上的行数)。本地为空的格子不参与(不会清空在线值)。"""
|
|
109
|
+
online = keyed(online_rows, key, online_refs)
|
|
110
|
+
local = keyed(local_rows, key, local=True)
|
|
111
|
+
changed, conflicts = [], []
|
|
112
|
+
for k, (_, r) in local.items():
|
|
113
|
+
if k not in online:
|
|
114
|
+
continue
|
|
115
|
+
ref, o = online[k]
|
|
116
|
+
for c in columns:
|
|
117
|
+
new, old = r.get(c, ""), o.get(c, "")
|
|
118
|
+
if new and new != old:
|
|
119
|
+
(conflicts if old else changed).append(Change(k, c, old, new, ref))
|
|
120
|
+
added = [r for k, (_, r) in local.items() if k not in online]
|
|
121
|
+
return changed, conflicts, added, sum(1 for k in local if k in online)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def keyed(rows, key, refs=None, local=False):
|
|
125
|
+
"""按主键建索引 {key: (ref, row)}。主键为空:在线行跳过,本地行报错;主键重复报错。"""
|
|
126
|
+
idx, dupes, empty = {}, [], []
|
|
127
|
+
for i, r in enumerate(rows):
|
|
128
|
+
k = tuple(r.get(c, "") for c in key)
|
|
129
|
+
if not any(k):
|
|
130
|
+
empty.append(i + 1)
|
|
131
|
+
continue
|
|
132
|
+
if k in idx:
|
|
133
|
+
dupes.append(k)
|
|
134
|
+
continue
|
|
135
|
+
idx[k] = (refs[i] if refs else i, r)
|
|
136
|
+
where = "本地数据" if local else "在线表"
|
|
137
|
+
if local and empty:
|
|
138
|
+
raise LarkError(f"{where}第 {brief(map(str, empty), sep='、')} 条的主键为空")
|
|
139
|
+
if dupes:
|
|
140
|
+
uniq = list(dict.fromkeys(dupes))
|
|
141
|
+
raise LarkError(f"{where}按「{' + '.join(key)}」匹配时有 {len(uniq)} 个值重复,例如:"
|
|
142
|
+
f"{'、'.join(show_key(k) for k in uniq[:5])}。主键要能唯一确定一行,"
|
|
143
|
+
f"可以再多勾选一个字段组成联合主键")
|
|
144
|
+
return idx
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def show_key(k):
|
|
148
|
+
return "/".join(k)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def brief(lines, n=20, sep="\n"):
|
|
152
|
+
lines = list(lines)
|
|
153
|
+
more = f"{sep}…共 {len(lines)} 处" if len(lines) > n else ""
|
|
154
|
+
return sep.join(lines[:n]) + more
|
larksync/history.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""写入记录:每次写飞书之前先落盘,撤销时据此还原。"""
|
|
2
|
+
import secrets
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
|
|
6
|
+
from .auth import CONFIG_DIR, read_json, write_json
|
|
7
|
+
from .http import LarkError
|
|
8
|
+
|
|
9
|
+
DIR = CONFIG_DIR / "history"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def new(url, target, key, changed, added):
|
|
13
|
+
rec = {
|
|
14
|
+
"id": time.strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(2),
|
|
15
|
+
"time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
16
|
+
"url": url,
|
|
17
|
+
"target": target,
|
|
18
|
+
"key": list(key),
|
|
19
|
+
"status": "pending", # pending → done → undone;中途失败会停在 pending
|
|
20
|
+
"changed": [asdict(c) for c in changed],
|
|
21
|
+
"added": added,
|
|
22
|
+
"created": [], # 新增行的 ref,和 added 一一对应,每批写完就更新
|
|
23
|
+
}
|
|
24
|
+
save(rec)
|
|
25
|
+
return rec
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save(rec):
|
|
29
|
+
write_json(DIR / f"{rec['id']}.json", rec)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load(record_id):
|
|
33
|
+
rec = read_json(DIR / f"{record_id}.json")
|
|
34
|
+
if not rec:
|
|
35
|
+
raise LarkError(f"找不到写入记录 {record_id}")
|
|
36
|
+
return rec
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def records():
|
|
40
|
+
"""所有写入记录,新的在前。"""
|
|
41
|
+
return [read_json(p) for p in sorted(DIR.glob("*.json"), reverse=True)]
|
larksync/http.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""飞书开放平台 HTTP:网络错误和限流自动退避重试,错误码统一转成 LarkError。"""
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
BASE = "https://open.feishu.cn/open-apis"
|
|
8
|
+
_TRIES = 5
|
|
9
|
+
_RATE_LIMITED = {99991400, 1254290, 1254291} # 频控、多维表格写冲突
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LarkError(Exception):
|
|
13
|
+
def __init__(self, msg, code=None):
|
|
14
|
+
super().__init__(msg)
|
|
15
|
+
self.code = code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_client = None
|
|
19
|
+
_client_pid = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _http():
|
|
23
|
+
# fork 出来的子进程不能复用父进程的连接池
|
|
24
|
+
global _client, _client_pid
|
|
25
|
+
if _client is None or _client_pid != os.getpid():
|
|
26
|
+
_client = httpx.Client(base_url=BASE, timeout=30)
|
|
27
|
+
_client_pid = os.getpid()
|
|
28
|
+
return _client
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def call(method, path, *, token=None, params=None, json=None):
|
|
32
|
+
"""返回飞书响应的完整 body(code == 0)。"""
|
|
33
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
34
|
+
for attempt in range(_TRIES):
|
|
35
|
+
last = attempt == _TRIES - 1
|
|
36
|
+
try:
|
|
37
|
+
resp = _http().request(method, path, headers=headers, params=params, json=json)
|
|
38
|
+
except httpx.TransportError as e:
|
|
39
|
+
if last:
|
|
40
|
+
raise LarkError(f"网络错误:{e}") from e
|
|
41
|
+
time.sleep(2 ** attempt)
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
body = resp.json()
|
|
45
|
+
except ValueError:
|
|
46
|
+
body = {}
|
|
47
|
+
code = body.get("code")
|
|
48
|
+
retryable = resp.status_code == 429 or resp.status_code >= 500 or code in _RATE_LIMITED
|
|
49
|
+
if retryable and not last:
|
|
50
|
+
time.sleep(2 ** attempt)
|
|
51
|
+
continue
|
|
52
|
+
if code not in (0, None) or resp.is_error:
|
|
53
|
+
shown = code if code is not None else f"HTTP {resp.status_code}"
|
|
54
|
+
raise LarkError(f"飞书接口返回错误 {shown}:{body.get('msg') or resp.text[:300]}", code)
|
|
55
|
+
return body
|
larksync/sheets.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""电子表格后端:第 1 行是表头,下面每行一条记录,行号即 ref。"""
|
|
2
|
+
from .http import LarkError, call
|
|
3
|
+
|
|
4
|
+
_ROWS_PER_READ = 2000
|
|
5
|
+
_CELLS_PER_WRITE = 5000
|
|
6
|
+
_DIM_MAX = 5000 # 一次最多增删的行 / 列数
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Sheets:
|
|
10
|
+
def __init__(self, auth, token, sheet_id=None):
|
|
11
|
+
self.auth, self.token = auth, token
|
|
12
|
+
self._api = f"/sheets/v2/spreadsheets/{token}"
|
|
13
|
+
tabs = self._meta()
|
|
14
|
+
tab = next((t for t in tabs if t.get("sheetId") == sheet_id), None) if sheet_id \
|
|
15
|
+
else (tabs[0] if tabs else None)
|
|
16
|
+
if tab is None:
|
|
17
|
+
ids = [f"{t.get('sheetId')}({t.get('title')})" for t in tabs]
|
|
18
|
+
raise LarkError(f"没有 sheet={sheet_id} 的工作表,现有:{', '.join(ids)}")
|
|
19
|
+
self.sheet_id = tab["sheetId"]
|
|
20
|
+
self.title = tab.get("title", "")
|
|
21
|
+
self.target = f"sheet:{token}:{self.sheet_id}"
|
|
22
|
+
self._size(tab)
|
|
23
|
+
self._col = {} # 列名 → 列下标,每次读取都刷新
|
|
24
|
+
self._last = 1 # 最后一个非空数据行的行号
|
|
25
|
+
|
|
26
|
+
def tabs(self):
|
|
27
|
+
return list(self._tabs)
|
|
28
|
+
|
|
29
|
+
def columns(self):
|
|
30
|
+
self._refresh()
|
|
31
|
+
header = self._grid(1, 1)
|
|
32
|
+
return self._index(header[0] if header else [])
|
|
33
|
+
|
|
34
|
+
def read(self):
|
|
35
|
+
"""返回 (列名, 记录, 行号)。"""
|
|
36
|
+
self._refresh()
|
|
37
|
+
grid = []
|
|
38
|
+
for start in range(1, self._rows + 1, _ROWS_PER_READ):
|
|
39
|
+
grid += self._grid(start, min(start + _ROWS_PER_READ - 1, self._rows))
|
|
40
|
+
header, rows, refs = records(grid)
|
|
41
|
+
self._index(grid[0] if grid else [])
|
|
42
|
+
self._last = max(refs, default=1)
|
|
43
|
+
return header, rows, refs
|
|
44
|
+
|
|
45
|
+
def lookup(self, key, values):
|
|
46
|
+
"""主键第一列取值在 values 里的行。电子表格读全表很快,直接读全表。返回 (记录, 行号, 总行数)。"""
|
|
47
|
+
_, rows, refs = self.read()
|
|
48
|
+
return rows, refs, len(rows)
|
|
49
|
+
|
|
50
|
+
def fetch(self, refs):
|
|
51
|
+
"""{行号: 记录}。电子表格读全表也很快,直接读全表再挑。"""
|
|
52
|
+
_, rows, all_refs = self.read()
|
|
53
|
+
current = dict(zip(all_refs, rows))
|
|
54
|
+
return {r: current[r] for r in refs if r in current}
|
|
55
|
+
|
|
56
|
+
def writable(self, name):
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
def canon(self, name, s):
|
|
60
|
+
return s
|
|
61
|
+
|
|
62
|
+
def update(self, cells):
|
|
63
|
+
"""按列把相邻行合并成一个区域写入。"""
|
|
64
|
+
by_col = {}
|
|
65
|
+
for row, col, val in cells:
|
|
66
|
+
by_col.setdefault(self._col[col], []).append((row, val))
|
|
67
|
+
ranges = []
|
|
68
|
+
for ci, items in by_col.items():
|
|
69
|
+
items.sort()
|
|
70
|
+
run = [items[0]]
|
|
71
|
+
for it in items[1:]:
|
|
72
|
+
if it[0] != run[-1][0] + 1:
|
|
73
|
+
ranges.append(self._range(ci, ci, run[0][0], [[v] for _, v in run]))
|
|
74
|
+
run = []
|
|
75
|
+
run.append(it)
|
|
76
|
+
ranges.append(self._range(ci, ci, run[0][0], [[v] for _, v in run]))
|
|
77
|
+
self._write(ranges)
|
|
78
|
+
|
|
79
|
+
def create(self, rows):
|
|
80
|
+
"""追加到最后一个数据行之后,不够就先加行;每批写完 yield 这批的行号。"""
|
|
81
|
+
if not rows:
|
|
82
|
+
return
|
|
83
|
+
width = max(self._col.values()) + 1
|
|
84
|
+
names = {i: c for c, i in self._col.items()}
|
|
85
|
+
start = self._last + 1
|
|
86
|
+
self._grow("ROWS", start + len(rows) - 1 - self._rows)
|
|
87
|
+
step = max(1, _CELLS_PER_WRITE // width)
|
|
88
|
+
for i in range(0, len(rows), step):
|
|
89
|
+
chunk = rows[i:i + step]
|
|
90
|
+
values = [[r.get(names.get(j), "") for j in range(width)] for r in chunk]
|
|
91
|
+
self._write([self._range(0, width - 1, start + i, values)])
|
|
92
|
+
yield list(range(start + i, start + i + len(chunk)))
|
|
93
|
+
self._last = start + len(rows) - 1
|
|
94
|
+
|
|
95
|
+
def delete(self, refs):
|
|
96
|
+
"""从下往上删,避免删掉上面的行后下面的行号变化。"""
|
|
97
|
+
for hi, lo in _runs(sorted(refs, reverse=True)):
|
|
98
|
+
call("DELETE", f"{self._api}/dimension_range", token=self.auth.token(),
|
|
99
|
+
json={"dimension": {"sheetId": self.sheet_id, "majorDimension": "ROWS",
|
|
100
|
+
"startIndex": lo, "endIndex": hi}})
|
|
101
|
+
|
|
102
|
+
def add_fields(self, names):
|
|
103
|
+
"""在表头最后一列之后追加列名。"""
|
|
104
|
+
if not names:
|
|
105
|
+
return
|
|
106
|
+
start = max(self._col.values(), default=-1) + 1
|
|
107
|
+
self._grow("COLUMNS", start + len(names) - self._cols)
|
|
108
|
+
self._write([self._range(start, start + len(names) - 1, 1, [list(names)])])
|
|
109
|
+
self._col.update({n: start + i for i, n in enumerate(names)})
|
|
110
|
+
|
|
111
|
+
# -- 内部 --
|
|
112
|
+
|
|
113
|
+
def _meta(self):
|
|
114
|
+
data = call("GET", f"{self._api}/metainfo", token=self.auth.token())["data"]
|
|
115
|
+
self.doc_title = (data.get("properties") or {}).get("title", "")
|
|
116
|
+
tabs = data.get("sheets") or []
|
|
117
|
+
self._tabs = [(t.get("sheetId"), t.get("title", "")) for t in tabs]
|
|
118
|
+
return tabs
|
|
119
|
+
|
|
120
|
+
def _refresh(self):
|
|
121
|
+
"""行列数可能被别人改过,读之前先刷新。"""
|
|
122
|
+
tab = next((t for t in self._meta() if t.get("sheetId") == self.sheet_id), None)
|
|
123
|
+
if tab is None:
|
|
124
|
+
raise LarkError(f"工作表 {self.title}({self.sheet_id})已被删除")
|
|
125
|
+
self._size(tab)
|
|
126
|
+
|
|
127
|
+
def _size(self, tab):
|
|
128
|
+
self._rows = int(tab.get("rowCount") or 0)
|
|
129
|
+
self._cols = int(tab.get("columnCount") or 0)
|
|
130
|
+
|
|
131
|
+
def _index(self, header):
|
|
132
|
+
names = [h for h in header if h]
|
|
133
|
+
records([header]) # 顺便检查重复列名
|
|
134
|
+
self._col = {h: i for i, h in enumerate(header) if h}
|
|
135
|
+
return names
|
|
136
|
+
|
|
137
|
+
def _grid(self, start, end):
|
|
138
|
+
if self._cols == 0:
|
|
139
|
+
return []
|
|
140
|
+
body = call("GET", f"{self._api}/values/"
|
|
141
|
+
f"{self.sheet_id}!A{start}:{column_letter(self._cols - 1)}{end}",
|
|
142
|
+
token=self.auth.token(), params={"valueRenderOption": "FormattedValue"})
|
|
143
|
+
return [[cell_str(c) for c in row] for row in body["data"]["valueRange"].get("values") or []]
|
|
144
|
+
|
|
145
|
+
def _range(self, c1, c2, row, values):
|
|
146
|
+
rng = f"{self.sheet_id}!{column_letter(c1)}{row}:{column_letter(c2)}{row + len(values) - 1}"
|
|
147
|
+
return {"range": rng, "values": values}
|
|
148
|
+
|
|
149
|
+
def _write(self, ranges):
|
|
150
|
+
batch, cells = [], 0
|
|
151
|
+
for r in ranges + [None]:
|
|
152
|
+
n = sum(len(v) for v in r["values"]) if r else 0
|
|
153
|
+
if batch and (r is None or cells + n > _CELLS_PER_WRITE):
|
|
154
|
+
call("POST", f"{self._api}/values_batch_update", token=self.auth.token(),
|
|
155
|
+
json={"valueRanges": batch})
|
|
156
|
+
batch, cells = [], 0
|
|
157
|
+
if r:
|
|
158
|
+
batch.append(r)
|
|
159
|
+
cells += n
|
|
160
|
+
|
|
161
|
+
def _grow(self, dim, n):
|
|
162
|
+
"""末尾补 n 行 / 列(n <= 0 时什么都不做)。"""
|
|
163
|
+
while n > 0:
|
|
164
|
+
k = min(n, _DIM_MAX)
|
|
165
|
+
call("POST", f"{self._api}/dimension_range", token=self.auth.token(),
|
|
166
|
+
json={"dimension": {"sheetId": self.sheet_id, "majorDimension": dim, "length": k}})
|
|
167
|
+
if dim == "ROWS":
|
|
168
|
+
self._rows += k
|
|
169
|
+
else:
|
|
170
|
+
self._cols += k
|
|
171
|
+
n -= k
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _runs(desc):
|
|
175
|
+
"""降序行号 → 连续段 [(hi, lo)]。"""
|
|
176
|
+
out = []
|
|
177
|
+
for r in desc:
|
|
178
|
+
if out and out[-1][1] == r + 1:
|
|
179
|
+
out[-1] = (out[-1][0], r)
|
|
180
|
+
else:
|
|
181
|
+
out.append((r, r))
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def records(grid):
|
|
186
|
+
"""表头为空的列跳过,整行为空的行跳过;列名重复直接报错。返回 (列名, 记录, 行号)。"""
|
|
187
|
+
if not grid:
|
|
188
|
+
return [], [], []
|
|
189
|
+
header = grid[0]
|
|
190
|
+
cols = [i for i, h in enumerate(header) if h]
|
|
191
|
+
dupes = sorted({header[i] for i in cols if header.count(header[i]) > 1})
|
|
192
|
+
if dupes:
|
|
193
|
+
raise LarkError(f"表头有重复的列名:{', '.join(dupes)}")
|
|
194
|
+
rows, refs = [], []
|
|
195
|
+
for n, r in enumerate(grid[1:], start=2):
|
|
196
|
+
r = r + [""] * (len(header) - len(r))
|
|
197
|
+
if any(r[i] for i in cols):
|
|
198
|
+
rows.append({header[i]: r[i] for i in cols})
|
|
199
|
+
refs.append(n)
|
|
200
|
+
return [header[i] for i in cols], rows, refs
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cell_str(v):
|
|
204
|
+
"""飞书单元格 / 字段值 → 文本(两种表共用)。"""
|
|
205
|
+
if v is None:
|
|
206
|
+
return ""
|
|
207
|
+
if isinstance(v, bool):
|
|
208
|
+
return "true" if v else "false"
|
|
209
|
+
if isinstance(v, list):
|
|
210
|
+
if v and all(isinstance(x, dict) and "text" in x for x in v): # 富文本分段
|
|
211
|
+
return "".join(str(x["text"]) for x in v).strip()
|
|
212
|
+
return ",".join(s for s in map(cell_str, v) if s) # 多选、人员等
|
|
213
|
+
if isinstance(v, dict): # 链接、人员、附件、公式等
|
|
214
|
+
for k in ("text", "name", "link", "full_address"):
|
|
215
|
+
if v.get(k):
|
|
216
|
+
return str(v[k]).strip()
|
|
217
|
+
return cell_str(v.get("value"))
|
|
218
|
+
if isinstance(v, float) and v.is_integer():
|
|
219
|
+
return str(int(v))
|
|
220
|
+
return str(v).strip()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def column_letter(index):
|
|
224
|
+
"""0 -> A, 25 -> Z, 26 -> AA"""
|
|
225
|
+
out = ""
|
|
226
|
+
while True:
|
|
227
|
+
out = chr(ord("A") + index % 26) + out
|
|
228
|
+
index = index // 26 - 1
|
|
229
|
+
if index < 0:
|
|
230
|
+
return out
|
larksync/table.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Table:用链接打开一张飞书表,拉取 / 按主键更新 / 撤销。"""
|
|
2
|
+
import csv
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from urllib.parse import parse_qs, urlparse
|
|
5
|
+
|
|
6
|
+
from . import history
|
|
7
|
+
from .auth import SCOPES, Auth
|
|
8
|
+
from .bitable import Bitable
|
|
9
|
+
from .diff import Plan, brief, compute, keyed, load, show_key
|
|
10
|
+
from .http import LarkError, call
|
|
11
|
+
from .sheets import Sheets
|
|
12
|
+
|
|
13
|
+
_DENIED = {91403, 131006, 1254302}
|
|
14
|
+
_MISSING_SCOPE = {99991672, 99991679}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Table:
|
|
18
|
+
def __init__(self, url, auth="user", tab=None):
|
|
19
|
+
"""tab:工作表 / 数据表 id,不传就用链接里的 ?sheet= / ?table=,再没有就用第一个。"""
|
|
20
|
+
self.url = url
|
|
21
|
+
self._auth = Auth(auth)
|
|
22
|
+
kind, token, sub = parse_url(url)
|
|
23
|
+
sub = tab or sub
|
|
24
|
+
title = None
|
|
25
|
+
with self._explained():
|
|
26
|
+
if kind == "wiki":
|
|
27
|
+
node = call("GET", "/wiki/v2/spaces/get_node", token=self._auth.token(),
|
|
28
|
+
params={"token": token, "obj_type": "wiki"})["data"]["node"]
|
|
29
|
+
kind, token, title = node["obj_type"], node["obj_token"], node.get("title")
|
|
30
|
+
if kind == "sheet":
|
|
31
|
+
self._backend = Sheets(self._auth, token, sub)
|
|
32
|
+
elif kind == "bitable":
|
|
33
|
+
self._backend = Bitable(self._auth, token, sub)
|
|
34
|
+
else:
|
|
35
|
+
raise LarkError(f"不支持的文档类型 {kind!r},只支持电子表格和多维表格")
|
|
36
|
+
self.title = title or self._backend.doc_title # 文档标题(知识库链接用节点标题)
|
|
37
|
+
self.tab = self._backend.target.split(":")[2] # 当前工作表 / 数据表 id
|
|
38
|
+
|
|
39
|
+
def tabs(self):
|
|
40
|
+
"""同一文档里的所有工作表 / 数据表:[(id, 标题)]。"""
|
|
41
|
+
return self._backend.tabs()
|
|
42
|
+
|
|
43
|
+
def columns(self):
|
|
44
|
+
"""表头(只读表头,不读数据)。"""
|
|
45
|
+
with self._explained():
|
|
46
|
+
return self._backend.columns()
|
|
47
|
+
|
|
48
|
+
def pull(self, path=None):
|
|
49
|
+
"""拉取整张表,返回 list[dict];传 path 时同时写成 CSV(utf-8-sig,Excel 可直接打开)。"""
|
|
50
|
+
with self._explained():
|
|
51
|
+
header, rows, _ = self._backend.read()
|
|
52
|
+
if path is not None:
|
|
53
|
+
with open(path, "w", encoding="utf-8-sig", newline="") as f:
|
|
54
|
+
w = csv.DictWriter(f, fieldnames=header)
|
|
55
|
+
w.writeheader()
|
|
56
|
+
w.writerows(rows)
|
|
57
|
+
return rows
|
|
58
|
+
|
|
59
|
+
def diff(self, source, key, columns=None):
|
|
60
|
+
"""按主键比较本地数据(CSV 路径或 list[dict])和在线表,见 Plan。
|
|
61
|
+
|
|
62
|
+
columns 为空时比较本地所有非主键列;新增行写入本地和在线共有的所有可写列。
|
|
63
|
+
"""
|
|
64
|
+
local_header, local_rows = load(source)
|
|
65
|
+
with self._explained():
|
|
66
|
+
header = self._backend.columns()
|
|
67
|
+
key = [key] if isinstance(key, str) else list(key)
|
|
68
|
+
cols = list(columns) if columns is not None else [c for c in local_header if c not in key]
|
|
69
|
+
for c in key + cols:
|
|
70
|
+
if c not in local_header:
|
|
71
|
+
raise LarkError(f"本地数据里没有列「{c}」")
|
|
72
|
+
if c not in header:
|
|
73
|
+
tip = ";只更新部分列请传 columns=[...]" if columns is None else ""
|
|
74
|
+
raise LarkError(f"在线表里没有列「{c}」{tip}")
|
|
75
|
+
readonly = [c for c in cols if not self._backend.writable(c)]
|
|
76
|
+
if readonly:
|
|
77
|
+
raise LarkError(f"这些列在线表里是只读的,不能更新:{'、'.join(readonly)}")
|
|
78
|
+
|
|
79
|
+
common = [c for c in local_header if c in header]
|
|
80
|
+
local_rows = self._canon(local_rows, common)
|
|
81
|
+
wanted = {k[0] for k in keyed(local_rows, key, local=True)}
|
|
82
|
+
with self._explained(): # 只读主键和本地相同的行,表再大也快
|
|
83
|
+
rows, refs, total = self._backend.lookup(key, wanted)
|
|
84
|
+
changed, conflicts, added, matched = compute(rows, refs, local_rows, key, cols)
|
|
85
|
+
if added and not all(self._backend.writable(c) for c in key):
|
|
86
|
+
raise LarkError("主键列在线表里是只读的,不能新增行")
|
|
87
|
+
writable = [c for c in common if self._backend.writable(c)]
|
|
88
|
+
added = [{c: r[c] for c in writable} for r in added]
|
|
89
|
+
return Plan(self, key, changed, conflicts, added, total - matched)
|
|
90
|
+
|
|
91
|
+
def init(self, source):
|
|
92
|
+
"""用首个 CSV 初始化空表:补齐在线表缺少的列,再写入全部行。返回写入记录 id。"""
|
|
93
|
+
local_header, local_rows = load(source)
|
|
94
|
+
with self._explained():
|
|
95
|
+
header, rows, _ = self._backend.read()
|
|
96
|
+
if rows:
|
|
97
|
+
raise LarkError("表里已经有数据,init 只能用于空表;更新请用 diff")
|
|
98
|
+
self._backend.add_fields([c for c in local_header if c not in header])
|
|
99
|
+
readonly = [c for c in local_header if not self._backend.writable(c)]
|
|
100
|
+
if readonly:
|
|
101
|
+
raise LarkError(f"这些列在线表里是只读的,不能写入:{'、'.join(readonly)}")
|
|
102
|
+
added = self._canon(local_rows, local_header)
|
|
103
|
+
rec = history.new(self.url, self._backend.target, [], [], added)
|
|
104
|
+
self._write(rec, [], added)
|
|
105
|
+
return rec["id"]
|
|
106
|
+
|
|
107
|
+
def undo(self, record_id):
|
|
108
|
+
"""撤销一次写入:还原改过的格子、删掉新增的行。之后又被别人改过的话整体中止。"""
|
|
109
|
+
rec = history.load(record_id)
|
|
110
|
+
if rec["target"] != self._backend.target:
|
|
111
|
+
raise LarkError(f"写入记录 {record_id} 不属于这张表(它属于 {rec['url']})")
|
|
112
|
+
if rec["status"] == "undone":
|
|
113
|
+
raise LarkError(f"写入记录 {record_id} 已经撤销过了")
|
|
114
|
+
with self._explained():
|
|
115
|
+
current = self._backend.fetch([ch["ref"] for ch in rec["changed"]] + rec["created"])
|
|
116
|
+
conflicts, cells, drop = [], [], []
|
|
117
|
+
for ch in rec["changed"]:
|
|
118
|
+
cur = _cell(current.get(ch["ref"]), rec["key"], tuple(ch["key"]), ch["column"])
|
|
119
|
+
if cur == ch["new"]:
|
|
120
|
+
cells.append((ch["ref"], ch["column"], ch["old"]))
|
|
121
|
+
elif cur != ch["old"]: # 等于 old 说明这格当时没写成,跳过
|
|
122
|
+
conflicts.append(_conflict(ch["key"], ch["column"], ch["new"], cur))
|
|
123
|
+
for ref, row in zip(rec["created"], rec["added"]):
|
|
124
|
+
cur = current.get(ref)
|
|
125
|
+
if cur is None:
|
|
126
|
+
continue # 已经被删了
|
|
127
|
+
if any(cur.get(c, "") != v for c, v in row.items()):
|
|
128
|
+
who = show_key([row.get(c, "") for c in rec["key"]]) if rec["key"] else ref
|
|
129
|
+
conflicts.append(f"新增的行 {who} 之后被改过")
|
|
130
|
+
else:
|
|
131
|
+
drop.append(ref)
|
|
132
|
+
if conflicts:
|
|
133
|
+
raise LarkError("撤销会覆盖别人之后的修改,已中止:\n" + brief(conflicts))
|
|
134
|
+
self._backend.update(cells)
|
|
135
|
+
self._backend.delete(drop)
|
|
136
|
+
rec["status"] = "undone"
|
|
137
|
+
history.save(rec)
|
|
138
|
+
|
|
139
|
+
def _apply(self, plan):
|
|
140
|
+
with self._explained():
|
|
141
|
+
current = self._backend.fetch([ch.ref for ch in plan.changed]) # 只读涉及的行
|
|
142
|
+
taken = {}
|
|
143
|
+
if plan.added: # 确认新增的主键没被别人抢先加上
|
|
144
|
+
rows, refs, _ = self._backend.lookup(plan.key, {r[plan.key[0]] for r in plan.added})
|
|
145
|
+
taken = keyed(rows, plan.key, refs)
|
|
146
|
+
conflicts, cells = [], []
|
|
147
|
+
for ch in plan.changed:
|
|
148
|
+
cur = _cell(current.get(ch.ref), plan.key, ch.key, ch.column)
|
|
149
|
+
if cur != ch.old:
|
|
150
|
+
conflicts.append(_conflict(ch.key, ch.column, ch.old, cur))
|
|
151
|
+
else:
|
|
152
|
+
cells.append((ch.ref, ch.column, ch.new))
|
|
153
|
+
for r in plan.added:
|
|
154
|
+
k = tuple(r[c] for c in plan.key)
|
|
155
|
+
if k in taken:
|
|
156
|
+
conflicts.append(f"{show_key(k)}:已经被别人新增")
|
|
157
|
+
if conflicts:
|
|
158
|
+
raise LarkError("在线表在 diff 之后被改过,已中止写入,请重新 diff:\n"
|
|
159
|
+
+ brief(conflicts))
|
|
160
|
+
rec = history.new(self.url, self._backend.target, plan.key, plan.changed, plan.added)
|
|
161
|
+
self._write(rec, cells, plan.added)
|
|
162
|
+
return rec["id"]
|
|
163
|
+
|
|
164
|
+
def _write(self, rec, cells, added):
|
|
165
|
+
self._backend.update(cells)
|
|
166
|
+
for refs in self._backend.create(added):
|
|
167
|
+
rec["created"] += refs
|
|
168
|
+
history.save(rec) # 每批落盘,中途失败也能撤销已写入的部分
|
|
169
|
+
rec["status"] = "done"
|
|
170
|
+
history.save(rec)
|
|
171
|
+
|
|
172
|
+
def _canon(self, rows, cols):
|
|
173
|
+
errors, out = [], []
|
|
174
|
+
for i, r in enumerate(rows, 1):
|
|
175
|
+
r = dict(r)
|
|
176
|
+
for c in cols:
|
|
177
|
+
try:
|
|
178
|
+
r[c] = self._backend.canon(c, r.get(c, ""))
|
|
179
|
+
except LarkError as e:
|
|
180
|
+
errors.append(f"第 {i} 条「{c}」:{e}")
|
|
181
|
+
out.append(r)
|
|
182
|
+
if errors:
|
|
183
|
+
raise LarkError("本地数据里有写不进在线表的值:\n" + brief(errors))
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
@contextmanager
|
|
187
|
+
def _explained(self):
|
|
188
|
+
"""把权限类错误码翻译成能照着做的提示。"""
|
|
189
|
+
try:
|
|
190
|
+
yield
|
|
191
|
+
except LarkError as e:
|
|
192
|
+
if e.code in _DENIED:
|
|
193
|
+
hint = ("把你的应用加为这张表的协作者,或改用 auth=\"user\""
|
|
194
|
+
if self._auth.method == "tenant" else "确认你的飞书账号能编辑这张表")
|
|
195
|
+
raise LarkError(f"没有权限访问 {self.url}:{hint}", e.code) from e
|
|
196
|
+
if e.code in _MISSING_SCOPE:
|
|
197
|
+
raise LarkError(f"应用缺少权限:请在飞书开发者后台开通 {SCOPES} 并发布,"
|
|
198
|
+
f"然后重新登录", e.code) from e
|
|
199
|
+
raise
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _cell(row, key_cols, key, column):
|
|
203
|
+
"""行还在、主键没变时返回该格的值,否则 None(行被删或电子表格行位置变了)。"""
|
|
204
|
+
if row is None or tuple(row.get(c, "") for c in key_cols) != tuple(key):
|
|
205
|
+
return None
|
|
206
|
+
return row.get(column, "")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _conflict(key, column, expected, current):
|
|
210
|
+
now = "(行已被删除)" if current is None else repr(current)
|
|
211
|
+
return f"{show_key(key)}「{column}」:应为 {expected!r},现在是 {now}"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def parse_url(url):
|
|
215
|
+
"""返回 (类型, token, sheet/table id);类型为 wiki / sheet / bitable。"""
|
|
216
|
+
u = urlparse(url.strip())
|
|
217
|
+
parts = [p for p in u.path.split("/") if p]
|
|
218
|
+
q = parse_qs(u.query)
|
|
219
|
+
sub = (q.get("sheet") or q.get("table") or [None])[0]
|
|
220
|
+
kinds = {"wiki": "wiki", "sheets": "sheet", "base": "bitable"}
|
|
221
|
+
if len(parts) >= 2 and parts[-2] in kinds:
|
|
222
|
+
return kinds[parts[-2]], parts[-1], sub
|
|
223
|
+
raise LarkError(f"认不出的飞书链接:{url}(支持 /wiki/、/sheets/、/base/ 链接)")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: larksync
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 飞书电子表格 / 多维表格的拉取与按主键更新(Feishu / Lark sheets & bitable sync)
|
|
5
|
+
Project-URL: Homepage, https://github.com/libcyz/larksync
|
|
6
|
+
Project-URL: Documentation, https://github.com/libcyz/larksync/blob/main/docs/python.md
|
|
7
|
+
Project-URL: Issues, https://github.com/libcyz/larksync/issues
|
|
8
|
+
Author-email: libcyz <2840424020@qq.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: bitable,csv,feishu,lark,spreadsheet,sync,xlsx
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Office/Business
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Requires-Dist: openpyxl>=3.1.5
|
|
25
|
+
Requires-Dist: platformdirs>=4
|
|
26
|
+
Provides-Extra: gui
|
|
27
|
+
Requires-Dist: pywebview>=6.2.1; extra == 'gui'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# larksync
|
|
31
|
+
|
|
32
|
+
这是一个用来同步飞书表格的项目,大多数项目的非技术人员在使用飞书在线表格进行管理的时候经常会出现大量数据需要人工核对,可能又需要去重,于是添加新的表变的非常麻烦,而整理好的csv中如果出现重复的数据,直接复制的话就gameover,当然会有用豆包更新表格的声音,如果你用豆包更新表格,那我不要和你说话。
|
|
33
|
+
|
|
34
|
+
对于技术人员来讲,在pipeline搭建好之后发现跑脚本前需要拉取在线表格更新后的信息,需要折返到对应表格,下载,另存为csv,大概率还会出现XXXX表(1),XXXX表(2),然后就需要在修改脚本路径和重命名表格这两个愚蠢选项中选择一项,诸如此类,larksync应运而生。
|
|
35
|
+
|
|
36
|
+
## 更新规则
|
|
37
|
+
|
|
38
|
+
GUI 和 Python 代码使用同一套规则。
|
|
39
|
+
|
|
40
|
+
- 补空的格子默认写入。
|
|
41
|
+
- 不一致的格子默认不写入。人工确认后,才可以写入。
|
|
42
|
+
- 新增的行由调用方决定是否写入。GUI 默认不勾选新增的行。
|
|
43
|
+
- 本地文件中为空的格子不参与比较。larksync 不清空在线表的值。
|
|
44
|
+
- larksync 不删除在线表的行。撤销时,只删除本次写入新增的行。
|
|
45
|
+
- 写入前,larksync 重新读取涉及的行。预览后有人修改过这些格子时,写入整体中止。
|
|
46
|
+
|
|
47
|
+
## 前提
|
|
48
|
+
|
|
49
|
+
1. 创建一个飞书自建应用。
|
|
50
|
+
2. 为应用开通以下权限:
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"scopes": {
|
|
54
|
+
"tenant": [
|
|
55
|
+
"bitable:app",
|
|
56
|
+
"sheets:spreadsheet",
|
|
57
|
+
"wiki:wiki:readonly"
|
|
58
|
+
],
|
|
59
|
+
"user": [
|
|
60
|
+
"bitable:app",
|
|
61
|
+
"sheets:spreadsheet",
|
|
62
|
+
"wiki:wiki:readonly"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
3. 在应用的重定向 URL 中添加 `http://localhost:8888/callback`。
|
|
68
|
+
4. 发布应用版本。
|
|
69
|
+
|
|
70
|
+
说明:权限修改并发布后,生效可以有几分钟的延迟;每个人必须使用自己的飞书应用,不同的人对在线表的权限不同。
|
|
71
|
+
|
|
72
|
+
## GUI 使用
|
|
73
|
+
|
|
74
|
+
### 首次配置
|
|
75
|
+
|
|
76
|
+
1. 启动 larksync。结果:弹出「飞书应用与登录」窗口。
|
|
77
|
+
2. 填写「App ID」和「App Secret」。
|
|
78
|
+
3. 单击「保存并登录」。结果:浏览器打开飞书授权页。
|
|
79
|
+
4. 在 180 秒内单击「授权」。结果:窗口右上角显示你的飞书用户名。
|
|
80
|
+
|
|
81
|
+
登录状态保存在本机。下次启动时,不需要再登录。
|
|
82
|
+
|
|
83
|
+
## 术语
|
|
84
|
+
|
|
85
|
+
- **主键**:用来匹配行的字段。本地文件的行和在线表的行,主键值相同时视为同一行。
|
|
86
|
+
- **联合主键**:多个字段一起作为主键。一个字段的值有重复时,使用联合主键。
|
|
87
|
+
- **补空**:在线表的格子为空,本地文件有值。
|
|
88
|
+
- **未匹配**:在线表有这一行,本地文件没有这一行。
|
|
89
|
+
|
|
90
|
+
### 用本地文件更新在线表
|
|
91
|
+
|
|
92
|
+
1. 在左侧列表中选择一张在线表。结果:顶部显示表头的前 8 个字段。
|
|
93
|
+
2. 把本地文件拖入虚线框。也可以单击虚线框,选择本地文件。
|
|
94
|
+
3. 在「列对应」中检查每一个 CSV 列对应的字段。同名的列自动对应。
|
|
95
|
+
4. 勾选「主键」。一个字段的值有重复时,勾选多个字段。
|
|
96
|
+
5. 勾选「更新」中要写入的列。表头的「更新」复选框可以全选。
|
|
97
|
+
6. 单击「生成预览」。结果:显示「补空」「不一致」「新增」「未匹配」4 个页签。
|
|
98
|
+
7. 在「不一致」中核对每一个格子。只勾选确认要覆盖的格子。
|
|
99
|
+
8. 在「新增」中勾选确认要增加的行。
|
|
100
|
+
9. 单击「写入飞书」。结果:弹出确认框。
|
|
101
|
+
10. 单击「写入」。结果:显示「已写入飞书」。
|
|
102
|
+
|
|
103
|
+
列对应和主键保存在表格配置中。下次拖入同样格式的本地文件时,自动恢复。
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
### 其他操作
|
|
107
|
+
|
|
108
|
+
- 单击「刷新」:重新读取表头。在线表增加字段后,必须刷新。
|
|
109
|
+
- 单击「移除」:删除本机的表格配置。飞书上的在线表和写入记录不变。
|
|
110
|
+
- 单击右上角的用户名:修改 App ID 或 App Secret,或退出登录。
|
|
111
|
+
|
|
112
|
+
开发者调用详见 [docs/python.md](https://github.com/libcyz/larksync/blob/main/docs/python.md)。
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
larksync/__init__.py,sha256=tX6IyreofXlcyzPGlqXuW-YlVCKqZ7GsJrNZ4_dMdQE,209
|
|
2
|
+
larksync/auth.py,sha256=_ObpI4oGBZjuzY58P6pjSxyTuzRdGFJxdgLTRgvPK5E,7359
|
|
3
|
+
larksync/bitable.py,sha256=OJR55K1_yLPBH9BVGuVQdOSgiRaCZRkcV8CFbGlzH6U,7913
|
|
4
|
+
larksync/diff.py,sha256=d2hK3lbKyg1gLkEAJU2Y4HgPo5Ha7rh4KOW6pfOhBHs,5950
|
|
5
|
+
larksync/history.py,sha256=5CFhGX2a1qjrU_rY0Y6JQ_CS8FJD1OQRo1OtFCqsbC0,1176
|
|
6
|
+
larksync/http.py,sha256=ASV1e-dKTuKVN7c4DFZ7qS6P392_0q5fuCLOFDVfir4,1840
|
|
7
|
+
larksync/sheets.py,sha256=n4QHo6BEIgrxU-q2fsX-kbwqRU8yigOU2dvy5bxT_zk,9070
|
|
8
|
+
larksync/table.py,sha256=-E5BijD8s6GYPbGH5cbnliPqQBa33onBTiCQSOYwdX0,10902
|
|
9
|
+
larksync-0.1.0.dist-info/METADATA,sha256=FruJ41WMvBqIhXQA548jjPfkr3MK3DygdpdpSAu9Xu0,5239
|
|
10
|
+
larksync-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
larksync-0.1.0.dist-info/licenses/LICENSE,sha256=4p3g86FDNGRkEj9t8VzsWU7MIUKzKe5yXw8Ffqd1KkU,1063
|
|
12
|
+
larksync-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 libcyz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|