update_library.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316
  1. """Authorized batch merge into a new immutable generation, then atomic pointer switch.
  2. Incoming databases must first be built with the existing validated VOC importer.
  3. Default is a dry plan. Readers continue using the old generation until --apply succeeds.
  4. """
  5. import sys
  6. sys.dont_write_bytecode=True
  7. import argparse
  8. import datetime as dt
  9. from decimal import Decimal
  10. import hashlib
  11. import json
  12. import os
  13. import re
  14. from pathlib import Path
  15. import shutil
  16. import sqlite3
  17. import tempfile
  18. import uuid
  19. import voc_db
  20. import retrieval
  21. from product_insights import ANNOTATION_DDL
  22. ORDER=['batches','assets','collection_requests','brands','categories','source_records','source_links','products',
  23. 'classification_assertions','product_observations','specs','prices','price_sources','reviews','social_posts',
  24. 'social_brand_mentions','policy_documents','policy_clauses','ranking_signals','coverage_observations','quality_issues','review_theme_annotations']
  25. STABLE={'products','reviews','social_posts','policy_documents','policy_clauses'}
  26. MERGE_POLICY='ksf.voc-merge.20261106.v1'
  27. MERGE_DDL='''CREATE TABLE IF NOT EXISTS merge_decisions(
  28. id TEXT PRIMARY KEY, entity TEXT NOT NULL, record_id TEXT NOT NULL, batch_sha256 TEXT NOT NULL,
  29. prior_source_record_id TEXT NOT NULL REFERENCES source_records(id),
  30. incoming_source_record_id TEXT NOT NULL REFERENCES source_records(id),
  31. selected_source_record_id TEXT NOT NULL REFERENCES source_records(id),
  32. decision TEXT NOT NULL CHECK(decision IN ('kept_existing','used_incoming')),
  33. reason_codes_json TEXT NOT NULL CHECK(json_valid(reason_codes_json)), policy_version TEXT NOT NULL);
  34. CREATE INDEX IF NOT EXISTS idx_merge_decisions_record ON merge_decisions(entity,record_id,id);'''
  35. def observation_stamp(value):
  36. """Keep naive wall time separate from UTC; preserve submicrosecond precision."""
  37. if not isinstance(value,str):return None
  38. match=re.fullmatch(r'(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})?',value)
  39. if not match:return None
  40. try:stamp=dt.datetime.fromisoformat(match[1]+(match[3] or '').replace('Z','+00:00'))
  41. except ValueError:return None
  42. aware=stamp.utcoffset() is not None
  43. if aware:stamp=stamp.astimezone(dt.timezone.utc).replace(tzinfo=None)
  44. return aware,stamp,Decimal('0.'+(match[2] or '0'))
  45. def observation_order(old,new):
  46. before,after=observation_stamp(old),observation_stamp(new)
  47. if before is None or after is None or before[0]!=after[0]:return None
  48. # Both naive: compare only their source wall clocks, never label these UTC.
  49. return (after[1:]>before[1:])-(after[1:]<before[1:])
  50. def unknown(value):
  51. return value is None or isinstance(value,str) and value.strip() in (
  52. '', 'null', '{}', '[]', 'unknown', 'unverified', 'unresolved', 'pending',
  53. 'taobao_unverified', 'unspecified_not_verified')
  54. def choose_record(table,old,new):
  55. """Select one coherent source row; never synthesize an untraceable hybrid."""
  56. bookkeeping={'id','source_record_id','collected_at_raw','collected_at_utc','source_time_raw',
  57. 'source_time_utc','source_calendar_date','review_date_utc','published_at_utc','coverage_type'}
  58. fields=set(old)-bookkeeping
  59. missing=sorted(k for k in fields if not unknown(old[k]) and unknown(new[k]))
  60. different=sorted(k for k in fields if not unknown(old[k]) and not unknown(new[k]) and old[k]!=new[k])
  61. identity={'products':{'marketplace','shop_id','brand_code','category_code'},
  62. 'reviews':{'product_id','marketplace_claim','content','purchased_sku_text','rating_raw','rating_scale','review_date_raw','is_default'},
  63. 'policy_clauses':{'document_id','clause','excerpt'}}.get(table,set())
  64. conflicts=sorted(set(different)&identity)
  65. ranks={'source_attribute':3,'rule_title_prefix':1,'rule_title_explicit':1}
  66. degraded=sorted(k for k in ('brand_basis','category_basis') if k in old and
  67. ranks.get(old[k],0)>ranks.get(new[k],0))
  68. order=observation_order(old.get('source_time_raw'),new.get('source_time_raw'))
  69. reasons=[]
  70. if missing:reasons.append('KNOWN_VALUES_WOULD_BE_LOST')
  71. if degraded:reasons.append('CLASSIFICATION_EVIDENCE_WOULD_WEAKEN')
  72. if conflicts:reasons.append('IDENTITY_OR_FACT_CONFLICT')
  73. if order==-1:reasons.append('OLDER_OBSERVATION')
  74. if different and order in (None,0):reasons.append('CHANGED_VALUES_WITHOUT_NEWER_OBSERVATION')
  75. if reasons:decision='kept_existing'
  76. elif all(old[k]==new[k] or unknown(old[k]) and unknown(new[k]) for k in fields) and order!=1:
  77. decision='kept_existing';reasons=['EQUIVALENT_RECORD']
  78. else:decision='used_incoming';reasons=['NEWER_COMPLETE_OBSERVATION' if order==1 else 'NONCONFLICTING_ENRICHMENT']
  79. return decision,{'codes':reasons,'missingFields':missing,'conflictingFields':conflicts,
  80. 'weakerEvidenceFields':degraded,'changedKnownFields':different,'sourceTimeOrder':order}
  81. def digest(p):
  82. with Path(p).open('rb') as f:return voc_db.file_sha256(f)
  83. def canonical(v):return json.dumps(v,ensure_ascii=False,sort_keys=True,separators=(',',':'))
  84. def verified(p):
  85. with voc_db.readonly(p) as c:
  86. voc_db.require(c.execute('PRAGMA integrity_check').fetchone()[0]=='ok' and not c.execute('PRAGMA foreign_key_check').fetchall(),'DATABASE_CHECK_FAILED')
  87. voc_db.require(c.execute('SELECT count(*) FROM retrieval_documents').fetchone()[0]==sum(c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in retrieval.ENTITIES.values()),'RETRIEVAL_COUNT_MISMATCH')
  88. for table in ORDER:
  89. if table=='review_theme_annotations':continue # Optional, backward-compatible enrichment.
  90. voc_db.require(c.execute('SELECT sql FROM sqlite_master WHERE type=? AND name=?',('table',table)).fetchone() is not None,'MISSING_TABLE:'+table)
  91. return dict(c.execute('SELECT * FROM meta'))
  92. def merge(current,incoming,dest,review_correction=None):
  93. dest=Path(dest)
  94. voc_db.require(not dest.exists() and not dest.is_symlink(),'NEW_MERGE_TARGET_REQUIRED')
  95. im=verified(incoming)
  96. correction=None
  97. if review_correction is not None:
  98. from review_correction import validate
  99. correction=validate(current,incoming,review_correction)
  100. if not current:shutil.copyfile(incoming,dest)
  101. else:
  102. with voc_db.readonly(current) as old:
  103. con=sqlite3.connect(dest);old.backup(con);con.close()
  104. c=sqlite3.connect(dest);c.row_factory=sqlite3.Row;c.execute('PRAGMA foreign_keys=ON')
  105. maps={};delta={'added':{},'changed':{},'duplicate':{},'retained':{},
  106. 'mergeReview':{'policy':MERGE_POLICY,'decisions':0,'conflictRecords':0,'reasonCounts':{}}}
  107. if correction is not None:delta['reviewCorrection']=correction
  108. try:
  109. c.executescript(ANNOTATION_DDL)
  110. c.executescript(MERGE_DDL)
  111. c.execute('CREATE TABLE IF NOT EXISTS source_origins(source_record_id TEXT NOT NULL REFERENCES source_records(id),archive_sha256 TEXT NOT NULL,PRIMARY KEY(source_record_id,archive_sha256))')
  112. c.execute('CREATE TABLE IF NOT EXISTS record_versions(entity TEXT NOT NULL,record_id TEXT NOT NULL,batch_sha256 TEXT NOT NULL,record_json TEXT NOT NULL,PRIMARY KEY(entity,record_id,batch_sha256))')
  113. baseline=dict(c.execute('SELECT * FROM meta'))
  114. c.execute('INSERT OR IGNORE INTO source_origins SELECT id,? FROM source_records s WHERE NOT EXISTS (SELECT 1 FROM source_origins o WHERE o.source_record_id=s.id)',(baseline['archive_sha256'],))
  115. if current:
  116. with voc_db.readonly(incoming) as inc:
  117. for table in ORDER:
  118. if not inc.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",(table,)).fetchone():continue
  119. info=c.execute('PRAGMA table_info('+table+')').fetchall()
  120. pk=[r['name'] for r in sorted(info,key=lambda r:r['pk']) if r['pk']]
  121. fks=[dict(r) for r in c.execute('PRAGMA foreign_key_list('+table+')')]
  122. mapping={};maps[table]=mapping
  123. for original in inc.execute('SELECT * FROM '+table):
  124. row=dict(original)
  125. for fk in fks:
  126. value=row[fk['from']]
  127. if value is not None:row[fk['from']]=maps.get(fk['table'],{}).get(value,value)
  128. if table=='quality_issues' and row['entity_type']=='source':
  129. row['entity_id']=maps.get('source_records',{}).get(row['entity_id'],row['entity_id'])
  130. if table=='quality_issues' and row['entity_type'] in ('product','review'):
  131. entity='products' if row['entity_type']=='product' else 'reviews'
  132. candidate=c.execute('SELECT incoming_source_record_id,selected_source_record_id FROM merge_decisions WHERE entity=? AND record_id=? AND batch_sha256=? AND decision=?',
  133. (entity,row['entity_id'],im['archive_sha256'],'kept_existing')).fetchone()
  134. if candidate:
  135. row['detail']=canonical({'scope':'incoming_candidate_not_current_row',
  136. 'candidateSourceRecordId':candidate[0],'selectedSourceRecordId':candidate[1],'originalDetail':row['detail']})
  137. columns=list(row)
  138. old=c.execute('SELECT * FROM '+table+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in pk]).fetchone()
  139. if table=='batches' and old and old['archive_sha256']==row['archive_sha256']:
  140. mapping[original['id']]=old['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
  141. # The same timestamped price observation may be re-exported under a new source locator.
  142. # Unknown-time observations only deduplicate with identical source bytes.
  143. if table=='prices' and not old:
  144. fields=[k for k in columns if k not in ('id','source_record_id','collected_at_raw','collected_at_utc')]
  145. sql='SELECT p.id FROM prices p JOIN source_records s ON s.id=p.source_record_id WHERE '+' AND '.join('p.'+k+' IS ?' for k in fields)
  146. vals=[row[k] for k in fields]
  147. if not row.get('source_time_raw'):
  148. sql+=' AND s.payload_sha256=(SELECT payload_sha256 FROM source_records WHERE id=?)';vals.append(row['source_record_id'])
  149. same_price=c.execute(sql+' LIMIT 1',vals).fetchone()
  150. if same_price:
  151. mapping[original['id']]=same_price['id'];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
  152. if table in ('brands','categories') and old:
  153. voc_db.require(dict(old)==row,'TAXONOMY_CONFLICT:'+table+':'+str(row[pk[0]]))
  154. if old and table in STABLE:
  155. prior=dict(old)
  156. # The current row may have come from an earlier merged batch,
  157. # not the original snapshot's meta.archive_sha256.
  158. origins=[r[0] for r in c.execute('SELECT archive_sha256 FROM source_origins WHERE source_record_id=?',(prior['source_record_id'],))]
  159. for version,batch in [(prior,h) for h in origins]+[(row,im['archive_sha256'])]:
  160. c.execute('INSERT OR IGNORE INTO record_versions VALUES(?,?,?,?)',(table,str(row[pk[0]]),batch,canonical(version)))
  161. decision,reason=choose_record(table,prior,row)
  162. if correction is not None and table=='reviews' and row['id']==correction['recordId']:
  163. reason={**reason,'codes':['SOURCE_VERIFIED_MAIN_APPEND_CORRECTION'],
  164. 'ordinaryConflictFields':reason['conflictingFields'],
  165. 'conflictingFields':[],'correction':correction}
  166. decision='used_incoming'
  167. selected=prior if decision=='kept_existing' else row
  168. c.execute('INSERT OR IGNORE INTO merge_decisions VALUES(?,?,?,?,?,?,?,?,?,?)',(
  169. voc_db.key(MERGE_POLICY,table,row[pk[0]],im['archive_sha256']),table,str(row[pk[0]]),im['archive_sha256'],
  170. prior['source_record_id'],row['source_record_id'],selected['source_record_id'],decision,canonical(reason),MERGE_POLICY))
  171. review=delta['mergeReview'];review['decisions']+=1
  172. review['conflictRecords']+=int(bool(reason['conflictingFields']))
  173. for code in reason['codes']:review['reasonCounts'][code]=review['reasonCounts'].get(code,0)+1
  174. if decision=='kept_existing':
  175. key='duplicate' if reason['codes']==['EQUIVALENT_RECORD'] else 'retained'
  176. delta[key][table]=delta[key].get(table,0)+1
  177. mapping[original[pk[0]]]=prior[pk[0]]
  178. continue
  179. same=old and all(old[k]==v for k,v in row.items() if k!='source_record_id')
  180. if same:
  181. delta['duplicate'][table]=delta['duplicate'].get(table,0)+1
  182. if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
  183. continue
  184. if old and table not in STABLE:
  185. voc_db.require(len(pk)==1 and pk[0] in ('id','path','asset_path'),'CONFLICTING_RELATION:'+table)
  186. col=pk[0]
  187. if table=='assets':row[col]='batches/'+im['archive_sha256'][:16]+'/'+row[col]
  188. elif table=='collection_requests':raise ValueError('CONFLICTING_COLLECTION_REQUEST')
  189. else:row[col]=hashlib.sha256((im['generation']+':'+str(original[col])).encode()).hexdigest()
  190. existing=c.execute('SELECT * FROM '+table+' WHERE '+col+'=?',(row[col],)).fetchone()
  191. voc_db.require(existing is None or dict(existing)==row,'BATCH_ID_COLLISION')
  192. if existing:
  193. mapping[original[col]]=row[col];delta['duplicate'][table]=delta['duplicate'].get(table,0)+1;continue
  194. old=None
  195. if old:
  196. c.execute('UPDATE '+table+' SET '+','.join(k+'=?' for k in columns if k not in pk)+' WHERE '+' AND '.join(k+'=?' for k in pk),[row[k] for k in columns if k not in pk]+[row[k] for k in pk])
  197. delta['changed'][table]=delta['changed'].get(table,0)+1
  198. else:
  199. c.execute('INSERT INTO '+table+'('+','.join(columns)+') VALUES('+','.join('?' for _ in columns)+')',[row[k] for k in columns])
  200. delta['added'][table]=delta['added'].get(table,0)+1
  201. if len(pk)==1:mapping[original[pk[0]]]=row[pk[0]]
  202. for sid in maps['source_records'].values():c.execute('INSERT OR IGNORE INTO source_origins VALUES(?,?)',(sid,im['archive_sha256']))
  203. # Merge dictionaries from the actual validated rows, retaining prior definitions.
  204. tax=json.loads(baseline['taxonomy'])
  205. for key in ('brands','categories'):
  206. known={r['code'] for r in tax[key]}
  207. tax[key].extend(r for r in json.loads(im['taxonomy'])[key] if r['code'] not in known)
  208. c.execute("UPDATE meta SET value=? WHERE key='taxonomy'",(canonical(tax),))
  209. for name in ('retrieval_fts','retrieval_documents'):c.execute('DROP TABLE '+name)
  210. retrieval.create_index(c)
  211. generation=hashlib.sha256(canonical([baseline['generation'],im['generation'],digest(Path(__file__)),delta]).encode()).hexdigest()
  212. c.execute("UPDATE meta SET value=? WHERE key='generation'",(generation,))
  213. else:
  214. delta['added']={t:c.execute('SELECT count(*) FROM '+t).fetchone()[0] for t in ORDER}
  215. c.commit()
  216. finally:c.close()
  217. verified(dest)
  218. return delta
  219. def merge_plan(current,incoming,review_correction=None):
  220. """Run the exact merge in a disposable database; never write the registry."""
  221. fingerprints={str(p):digest(p) for p in (current,incoming) if p}
  222. with tempfile.TemporaryDirectory(prefix='ksf-merge-plan-') as td:
  223. target=Path(td)/'planned.sqlite';delta=merge(current,incoming,target,review_correction)
  224. with voc_db.readonly(target) as c:
  225. # Report only decisions made by this incoming archive, not old history.
  226. archive=verified(incoming)['archive_sha256']
  227. rows=[dict(r) for r in c.execute('SELECT entity,record_id,decision,reason_codes_json,prior_source_record_id,incoming_source_record_id,selected_source_record_id FROM merge_decisions WHERE batch_sha256=? ORDER BY entity,record_id LIMIT 101',(archive,))]
  228. for row in rows:row['reasons']=json.loads(row.pop('reason_codes_json'))
  229. result={'delta':delta,'plannedGeneration':dict(c.execute('SELECT key,value FROM meta'))['generation'],
  230. 'plannedCounts':voc_db.table_counts(c),'recordDecisions':rows[:100],'recordDecisionsTruncated':len(rows)>100}
  231. voc_db.require(all(digest(p)==h for p,h in fingerprints.items()),'INPUT_CHANGED_DURING_MERGE_PLAN')
  232. return {**result,'temporaryFilesUsed':True,'persistentFilesWritten':False,'activeLibraryModified':False,
  233. 'databaseWrites':'temporary-only','inputsUnchanged':True}
  234. def publish(incoming,registry,apply=False,label='资料更新',rollback=None,expected_generation=None,review_correction=None):
  235. import unified_store
  236. if unified_store.configured():
  237. return unified_store.publish(incoming, registry, apply, label, rollback, expected_generation, review_correction)
  238. voc_db.require(review_correction is None or not rollback,'CORRECTION_ROLLBACK_NOT_ALLOWED')
  239. root=Path(registry).resolve();source=Path(incoming).resolve() if incoming else None
  240. pointer=root/'current.json'
  241. prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
  242. if not apply:
  243. voc_db.require(expected_generation is None or (prior or {}).get('generation')==expected_generation,'LIBRARY_GENERATION_CHANGED')
  244. if rollback:
  245. voc_db.require(prior and any(x['generation']==rollback for x in prior['history']),'VERSION_NOT_FOUND')
  246. return {'ok':True,'readOnly':True,'rollbackGeneration':rollback}
  247. meta=verified(source)
  248. current=(root/prior['file']).resolve() if prior else None
  249. if current:voc_db.require(current.is_relative_to(root) and digest(current)==prior['databaseSha256'],'CURRENT_HASH_MISMATCH')
  250. if prior and any(x.get('incomingGeneration')==meta['generation'] for x in prior['history']):
  251. with voc_db.readonly(current) as c:counts=voc_db.table_counts(c)
  252. return {'ok':True,'readOnly':True,'operation':'already-imported','incomingGeneration':meta['generation'],
  253. 'currentGeneration':prior['generation'],'plannedGeneration':prior['generation'],'plannedCounts':counts,
  254. 'temporaryFilesUsed':False,'persistentFilesWritten':False,'activeLibraryModified':False,'databaseWrites':0}
  255. return {'ok':True,'readOnly':True,'incomingGeneration':meta['generation'],'currentGeneration':prior['generation'] if prior else None,
  256. 'mode':'merge-batch' if prior else 'initial','label':label,**merge_plan(current,source,review_correction)}
  257. root.mkdir(parents=True,exist_ok=True);os.chmod(root,0o700)
  258. lock=root/'.update.lock';fd=os.open(lock,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
  259. temp=None;tmp=None
  260. try:
  261. # Re-read after the exclusive lock. Query readers never need this lock.
  262. prior=json.loads(pointer.read_text(encoding='utf-8')) if pointer.exists() else None
  263. voc_db.require(expected_generation is None or (prior or {}).get('generation')==expected_generation,'LIBRARY_GENERATION_CHANGED')
  264. if rollback:
  265. entry=next((x for x in (prior or {}).get('history',[]) if x['generation']==rollback),None)
  266. voc_db.require(entry is not None,'VERSION_NOT_FOUND')
  267. target=(root/entry['file']).resolve();voc_db.require(target.is_relative_to(root),'INVALID_VERSION_PATH')
  268. voc_db.require(digest(target)==entry['databaseSha256'],'ROLLBACK_HASH_MISMATCH');verified(target)
  269. result={**entry,'label':'回退至 '+rollback[:12],'updatedAt':voc_db.now(),'history':prior['history']}
  270. else:
  271. meta=verified(source)
  272. if prior and any(x.get('incomingGeneration')==meta['generation'] for x in prior['history']):
  273. return {'ok':True,'applied':False,'operation':'already-imported','generation':prior['generation']}
  274. current=(root/prior['file']).resolve() if prior else None
  275. if current:
  276. voc_db.require(current.is_relative_to(root) and digest(current)==prior['databaseSha256'],'CURRENT_HASH_MISMATCH')
  277. temp=root/('.build-'+str(uuid.uuid4())+'.sqlite')
  278. delta=merge(current,source,temp,review_correction)
  279. finalmeta=verified(temp);generation=finalmeta['generation']
  280. versions=root/'versions';versions.mkdir(exist_ok=True);os.chmod(versions,0o700)
  281. target=versions/(generation+'.sqlite')
  282. if target.exists():
  283. voc_db.require(digest(target)==digest(temp),'VERSION_ALREADY_EXISTS_DIFFERENT_CONTENT');temp.unlink();temp=None
  284. else:
  285. os.chmod(temp,0o600);temp.rename(target);temp=None
  286. names={'products':'商品','reviews':'评价','prices':'价格观察','specs':'规格','social_posts':'社媒内容','policy_documents':'政策文件','policy_clauses':'条款','ranking_signals':'榜单线索','coverage_observations':'覆盖记录'}
  287. summary='、'.join(names.get(t,t)+'新增'+str(delta['added'].get(t,0))+'/变更'+str(delta['changed'].get(t,0)) for t in retrieval.ENTITIES.values() if delta['added'].get(t,0) or delta['changed'].get(t,0)) or '重复资料,无实体新增'
  288. entry={'generation':generation,'incomingGeneration':meta['generation'],'file':target.relative_to(root).as_posix(),'databaseSha256':digest(target),'updatedAt':voc_db.now(),'label':label,'summary':summary,'delta':delta}
  289. result={**entry,'history':[entry,*((prior or {}).get('history',[]))]}
  290. tmp=root/('.pointer-'+str(uuid.uuid4())+'.json')
  291. with tmp.open('x',encoding='utf-8') as f:f.write(canonical(result)+'\n');f.flush();os.fsync(f.fileno())
  292. os.chmod(tmp,0o600);os.replace(tmp,pointer);tmp=None
  293. return {'ok':True,'applied':True,**result}
  294. finally:
  295. os.close(fd);lock.unlink()
  296. if temp and temp.exists():temp.unlink()
  297. if tmp and tmp.exists():tmp.unlink()
  298. if __name__=='__main__':
  299. p=argparse.ArgumentParser(description=__doc__);p.add_argument('--incoming');p.add_argument('--registry',required=True);p.add_argument('--label',default='资料更新');p.add_argument('--apply',action='store_true');p.add_argument('--rollback');p.add_argument('--expected-generation')
  300. args=p.parse_args()
  301. try:print(json.dumps(publish(args.incoming,args.registry,args.apply,args.label,args.rollback,args.expected_generation),ensure_ascii=False,indent=2))
  302. except Exception as e:
  303. code = str(e)
  304. if os.environ.get('KSF_UNIFIED_DB_CONFIG') and not __import__('re').fullmatch('[A-Z][A-Z0-9_:.-]{0,150}', code):
  305. code = 'UNIFIED_PUBLICATION_FAILED'
  306. print(json.dumps({'ok':False,'error':code},ensure_ascii=False),file=sys.stderr);sys.exit(1)