#!/usr/bin/env python3 """ledger.py — 台帳 unity_ledger_v1.csv に鍵をかけて1行足す(依頼 v12 の 3 章) 夜は実行ファイルを何本も同時に走らせるので、台帳に同時に書かないよう鍵をかける。 ここは **台帳を壊すと戻しにくい所** なので、決まりを狭くしてある。 決まり(C# の ServiceSimLedger と同じ) ・**追記だけ**。前の行は読むだけで、書き換えない・消さない ・同じ run_folder の行は2度書かない ・列の並びは **実体の1行め(ヘッダ)が正**。 C# 側が「ヘッダとコードの列が1字でも違えば追記しない」を守っているので、 実体のヘッダをそのまま信じれば、C# と Python の列がずれることはない。 そのうえで、頭の並びと ledger_schema_version があることだけ確かめる ・値にカンマ・引用符・改行は入れない(CSV を壊さないため) 鍵 unity_ledger_v1.lock を O_EXCL で作れたら持ち主。閉じるときに消す。 作れなければ少し待って何度か試す。 C# から new FileStream(lock, FileMode.CreateNew) で取るのと同じやり方なので、 Unity 側(依頼 v12 の 3 章)と同じ鍵を取り合える。 持ち主が落ちて鍵が残ったときは、中の時刻を見て古ければ1度だけ壊す。 """ import csv import errno import os import socket import time LEDGER_NAME = 'unity_ledger_v1.csv' LOCK_NAME = 'unity_ledger_v1.lock' # ヘッダの頭の並び(ここだけは変わらない前提で確かめる) HEADER_PREFIX = ('timestamp', 'run_folder', 'status', 'unity_cs', 'layout_generation', 'params_model') # これが無ければ台帳と見なさない HEADER_MUST_HAVE = ('seed', 'store', 'robots', 'ledger_schema_version') LOCK_STALE_SEC = 120.0 # これより古い鍵は、持ち主が落ちたと見て壊す class LedgerError(Exception): pass # ---------------- 鍵 ---------------- class Lock(object): """with Lock(dir) as lk: ... の形で使う""" def __init__(self, unity_dir, timeout=60.0, poll=0.25, log=None): self.path = os.path.join(unity_dir, LOCK_NAME) self.timeout = timeout self.poll = poll self._fd = None self._log = log def _note(self, msg): if self._log: self._log(msg) def __enter__(self): end = time.time() + self.timeout broke = False while True: try: self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) os.write(self._fd, ('%d %s %f\n' % (os.getpid(), socket.gethostname(), time.time())).encode('utf-8')) return self except OSError as e: if e.errno != errno.EEXIST: raise # もう誰かが持っている if not broke and self._stale(): broke = True self._note('台帳の鍵が %.0f 秒より古いので壊します: %s' % (LOCK_STALE_SEC, self.path)) try: os.unlink(self.path) except OSError: pass continue if time.time() >= end: raise LedgerError('台帳の鍵を %.0f 秒待っても取れません: %s' % (self.timeout, self.path)) time.sleep(self.poll) def _stale(self): try: age = time.time() - os.path.getmtime(self.path) except OSError: return False return age > LOCK_STALE_SEC def __exit__(self, *exc): if self._fd is not None: try: os.close(self._fd) except OSError: pass self._fd = None try: os.unlink(self.path) except OSError: pass return False # ---------------- 読む ---------------- def ledger_path(unity_dir): return os.path.join(unity_dir, LEDGER_NAME) def read_header(unity_dir): """実体の1行めを列の一覧で返す。台帳の形でなければ LedgerError""" p = ledger_path(unity_dir) if not os.path.exists(p): raise LedgerError('台帳がありません: %s' % p) with open(p, encoding='utf-8') as f: line = f.readline() if not line: raise LedgerError('台帳が空です(ヘッダがありません): %s' % p) cols = line.rstrip('\r\n').split(',') if tuple(cols[:len(HEADER_PREFIX)]) != HEADER_PREFIX: raise LedgerError('台帳のヘッダの頭が違います: %s\n 実体: %s\n 待っていたもの: %s' % (p, ','.join(cols[:len(HEADER_PREFIX)]), ','.join(HEADER_PREFIX))) missing = [c for c in HEADER_MUST_HAVE if c not in cols] if missing: raise LedgerError('台帳のヘッダに %s がありません: %s' % (missing, p)) return cols def read_rows(unity_dir): """台帳の行を run_folder → 行(dict)で返す。読むだけ""" p = ledger_path(unity_dir) out = {} if not os.path.exists(p): return out with open(p, encoding='utf-8') as f: for r in csv.DictReader(f): folder = (r.get('run_folder') or '').strip() if folder: out[folder] = r return out def has_row(unity_dir, run_folder): return run_folder in read_rows(unity_dir) def completed_folders(unity_dir, status='completed'): return {k for k, r in read_rows(unity_dir).items() if (r.get('status') or '').strip() == status} # ---------------- 書く ---------------- def clean(value): """CSV を壊さない字にする(C# の ServiceSimLedger.Clean と同じ)""" if value is None: return '' s = str(value) s = s.replace(',', ' ').replace('"', ' ') s = s.replace('\r', ' ').replace('\n', ' ') return s.strip() def append_row(unity_dir, row, log=None, timeout=60.0): """ 台帳に1行足す。足したら True、足さなかったら False(理由は log に出す)。 row は 列名 → 値。ヘッダに無い列名が入っていたら LedgerError(打ち間違いを通さない)。 鍵は呼ぶ側で取らず、ここで取る。 """ def note(msg): if log: log(msg) folder = clean(row.get('run_folder')) if not folder: raise LedgerError('run_folder が空なので足しません(照合キーです)') with Lock(unity_dir, timeout=timeout, log=log): cols = read_header(unity_dir) # 鍵を取ってから読む unknown = [k for k in row if k not in cols] if unknown: raise LedgerError('台帳に無い列名です: %s' % ', '.join(sorted(unknown))) if folder in read_rows(unity_dir): note('もう台帳にあるので足しません(2度書かない): %s' % folder) return False line = ','.join(clean(row.get(c)) for c in cols) if line.count(',') != len(cols) - 1: raise LedgerError('列の数が合いません(%d 列のはず)' % len(cols)) p = ledger_path(unity_dir) # 1行めが改行で終わっていない台帳に足すと行がくっつくので、確かめてから足す with open(p, 'rb') as f: if f.seek(0, os.SEEK_END) > 0: f.seek(-1, os.SEEK_END) needs_nl = f.read(1) != b'\n' else: needs_nl = False with open(p, 'a', encoding='utf-8', newline='') as f: if needs_nl: f.write('\n') f.write(line + '\n') f.flush() os.fsync(f.fileno()) note('台帳に1行足しました: %s / %s' % (folder, clean(row.get('status')))) return True