610 lines
22 KiB
Python
610 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare or compile a deidentified G7 human voice-gain intake.
|
|
|
|
Template mode creates empty, explicitly non-evidence input files. Compile mode
|
|
accepts only deidentified metrics, recomputes reliability and production gates,
|
|
and writes a final pack only when every production requirement passes. Console
|
|
output never includes input paths, participant/labeler keys, labels, or values
|
|
from invalid cells.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
API_ROOT = REPO_ROOT / "apps/api"
|
|
if str(API_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(API_ROOT))
|
|
|
|
from app.contracts.g7_external_evidence import ( # noqa: E402
|
|
G7HumanVoiceGainEvidencePack,
|
|
)
|
|
from app.services.g7_voice_gain_evidence import ( # noqa: E402
|
|
evaluate_human_voice_gain,
|
|
)
|
|
|
|
|
|
MANIFEST_NAME = "manifest.json"
|
|
PARTICIPANTS_NAME = "participants.csv"
|
|
LABELERS_NAME = "labelers.csv"
|
|
OBSERVATIONS_NAME = "observations.csv"
|
|
README_NAME = "README.md"
|
|
INTAKE_SCHEMA_VERSION = "g7_human_voice_gain_intake_v1"
|
|
|
|
MANIFEST_KEYS = {
|
|
"intake_schema_version",
|
|
"template_only",
|
|
"provenance",
|
|
"text_only_model",
|
|
"voice_enabled_model",
|
|
"power_plan",
|
|
"reported_icc",
|
|
"reported_categorical_kappa",
|
|
"reliability_report_sha256",
|
|
}
|
|
PARTICIPANT_FIELDS = (
|
|
"participant_key",
|
|
"split",
|
|
"consent_receipt_sha256",
|
|
)
|
|
LABELER_FIELDS = (
|
|
"labeler_key",
|
|
"blinded_to_model_condition",
|
|
"blinded_to_other_labelers",
|
|
"labeled_independently",
|
|
"attestation_sha256",
|
|
)
|
|
OBSERVATION_FIELDS = (
|
|
"observation_id",
|
|
"participant_key",
|
|
"session_key",
|
|
"axis",
|
|
"text_only_status",
|
|
"text_only_score",
|
|
"voice_enabled_status",
|
|
"voice_enabled_score",
|
|
"labeler_key",
|
|
"label_score",
|
|
"label_category",
|
|
)
|
|
|
|
DEIDENTIFIED_PARTICIPANT_RE = re.compile(
|
|
r"^(?:calibration|held)-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$"
|
|
)
|
|
DEIDENTIFIED_LABELER_RE = re.compile(r"^labeler-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$")
|
|
DEIDENTIFIED_SESSION_RE = re.compile(r"^session-[A-Za-z0-9][A-Za-z0-9._:-]{2,95}$")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class IntakeError(Exception):
|
|
code: str
|
|
source: str = "intake"
|
|
row: int | None = None
|
|
field: str | None = None
|
|
|
|
|
|
def _base_report() -> dict[str, Any]:
|
|
return {
|
|
"schema_version": "vignette.g7-human-voice-gain-intake.v1",
|
|
"passed": False,
|
|
"pack_written": False,
|
|
"clinical_claim_allowed": False,
|
|
"privacy_boundary": {
|
|
"input_paths_logged": False,
|
|
"participant_keys_logged": False,
|
|
"labeler_keys_logged": False,
|
|
"labels_logged": False,
|
|
"raw_cell_values_logged": False,
|
|
"raw_audio_or_transcript_accepted": False,
|
|
},
|
|
"errors": [],
|
|
"result": {},
|
|
}
|
|
|
|
|
|
def _safe_error(error: IntakeError) -> dict[str, Any]:
|
|
result: dict[str, Any] = {"code": error.code, "source": error.source}
|
|
if error.row is not None:
|
|
result["row"] = error.row
|
|
if error.field is not None:
|
|
result["field"] = error.field
|
|
return result
|
|
|
|
|
|
def _json_pointer(location: tuple[int | str, ...]) -> str:
|
|
if not location:
|
|
return "/"
|
|
return "/" + "/".join(
|
|
str(item).replace("~", "~0").replace("/", "~1") for item in location
|
|
)
|
|
|
|
|
|
def _validation_errors(error: ValidationError) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"code": f"contract:{item['type']}",
|
|
"source": "compiled_pack",
|
|
"pointer": _json_pointer(tuple(item["loc"])),
|
|
}
|
|
for item in error.errors(
|
|
include_url=False,
|
|
include_context=False,
|
|
include_input=False,
|
|
)
|
|
]
|
|
|
|
|
|
def _require_exact_keys(payload: dict[str, Any]) -> None:
|
|
if set(payload) != MANIFEST_KEYS:
|
|
raise IntakeError("manifest_fields_invalid", source=MANIFEST_NAME)
|
|
|
|
|
|
def _read_manifest(directory: Path) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads((directory / MANIFEST_NAME).read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise IntakeError(
|
|
f"manifest_read:{type(exc).__name__}", source=MANIFEST_NAME
|
|
) from None
|
|
if not isinstance(value, dict):
|
|
raise IntakeError("manifest_object_required", source=MANIFEST_NAME)
|
|
_require_exact_keys(value)
|
|
if value.get("intake_schema_version") != INTAKE_SCHEMA_VERSION:
|
|
raise IntakeError("intake_schema_version_invalid", source=MANIFEST_NAME)
|
|
if value.get("template_only") is not False:
|
|
raise IntakeError("template_cannot_compile", source=MANIFEST_NAME)
|
|
return value
|
|
|
|
|
|
def _read_csv(
|
|
directory: Path, filename: str, fields: tuple[str, ...]
|
|
) -> list[dict[str, str]]:
|
|
try:
|
|
with (directory / filename).open(
|
|
"r", encoding="utf-8-sig", newline=""
|
|
) as handle:
|
|
reader = csv.DictReader(handle)
|
|
if reader.fieldnames != list(fields):
|
|
raise IntakeError("csv_headers_invalid", source=filename)
|
|
rows = []
|
|
for row_number, row in enumerate(reader, start=2):
|
|
if None in row or any(value is None for value in row.values()):
|
|
raise IntakeError(
|
|
"csv_shape_invalid", source=filename, row=row_number
|
|
)
|
|
if any(len(value) > 256 for value in row.values()):
|
|
raise IntakeError(
|
|
"csv_cell_too_long", source=filename, row=row_number
|
|
)
|
|
rows.append(dict(row))
|
|
except IntakeError:
|
|
raise
|
|
except (OSError, UnicodeError, csv.Error) as exc:
|
|
raise IntakeError(f"csv_read:{type(exc).__name__}", source=filename) from None
|
|
if not rows:
|
|
raise IntakeError("csv_rows_required", source=filename)
|
|
return rows
|
|
|
|
|
|
def _parse_true(value: str, *, source: str, row: int, field: str) -> bool:
|
|
if value != "true":
|
|
raise IntakeError("literal_true_required", source, row, field)
|
|
return True
|
|
|
|
|
|
def _parse_score(
|
|
value: str,
|
|
*,
|
|
nullable: bool,
|
|
source: str,
|
|
row: int,
|
|
field: str,
|
|
) -> float | None:
|
|
if value == "" and nullable:
|
|
return None
|
|
try:
|
|
result = float(value)
|
|
except ValueError:
|
|
raise IntakeError("score_invalid", source, row, field) from None
|
|
if not 0.0 <= result <= 1.0:
|
|
raise IntakeError("score_out_of_range", source, row, field)
|
|
return result
|
|
|
|
|
|
def _parse_participants(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
participants = []
|
|
for row_number, row in enumerate(rows, start=2):
|
|
key = row["participant_key"]
|
|
split = row["split"]
|
|
if DEIDENTIFIED_PARTICIPANT_RE.fullmatch(key) is None:
|
|
raise IntakeError(
|
|
"deidentified_participant_key_required",
|
|
PARTICIPANTS_NAME,
|
|
row_number,
|
|
"participant_key",
|
|
)
|
|
expected_prefix = "calibration-" if split == "calibration" else "held-"
|
|
if split not in {"calibration", "held_out"} or not key.startswith(
|
|
expected_prefix
|
|
):
|
|
raise IntakeError(
|
|
"participant_split_key_mismatch",
|
|
PARTICIPANTS_NAME,
|
|
row_number,
|
|
"split",
|
|
)
|
|
participants.append(row)
|
|
return participants
|
|
|
|
|
|
def _parse_labelers(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
labelers = []
|
|
for row_number, row in enumerate(rows, start=2):
|
|
key = row["labeler_key"]
|
|
if DEIDENTIFIED_LABELER_RE.fullmatch(key) is None:
|
|
raise IntakeError(
|
|
"deidentified_labeler_key_required",
|
|
LABELERS_NAME,
|
|
row_number,
|
|
"labeler_key",
|
|
)
|
|
labelers.append(
|
|
{
|
|
"labeler_key": key,
|
|
"blinded_to_model_condition": _parse_true(
|
|
row["blinded_to_model_condition"],
|
|
source=LABELERS_NAME,
|
|
row=row_number,
|
|
field="blinded_to_model_condition",
|
|
),
|
|
"blinded_to_other_labelers": _parse_true(
|
|
row["blinded_to_other_labelers"],
|
|
source=LABELERS_NAME,
|
|
row=row_number,
|
|
field="blinded_to_other_labelers",
|
|
),
|
|
"labeled_independently": _parse_true(
|
|
row["labeled_independently"],
|
|
source=LABELERS_NAME,
|
|
row=row_number,
|
|
field="labeled_independently",
|
|
),
|
|
"attestation_sha256": row["attestation_sha256"],
|
|
}
|
|
)
|
|
return labelers
|
|
|
|
|
|
def _parse_observations(rows: list[dict[str, str]]) -> list[dict[str, Any]]:
|
|
grouped: dict[tuple[str, str, str, str], dict[str, Any]] = {}
|
|
order: list[tuple[str, str, str, str]] = []
|
|
for row_number, row in enumerate(rows, start=2):
|
|
participant_key = row["participant_key"]
|
|
session_key = row["session_key"]
|
|
labeler_key = row["labeler_key"]
|
|
if DEIDENTIFIED_PARTICIPANT_RE.fullmatch(participant_key) is None:
|
|
raise IntakeError(
|
|
"deidentified_participant_key_required",
|
|
OBSERVATIONS_NAME,
|
|
row_number,
|
|
"participant_key",
|
|
)
|
|
if DEIDENTIFIED_SESSION_RE.fullmatch(session_key) is None:
|
|
raise IntakeError(
|
|
"deidentified_session_key_required",
|
|
OBSERVATIONS_NAME,
|
|
row_number,
|
|
"session_key",
|
|
)
|
|
if DEIDENTIFIED_LABELER_RE.fullmatch(labeler_key) is None:
|
|
raise IntakeError(
|
|
"deidentified_labeler_key_required",
|
|
OBSERVATIONS_NAME,
|
|
row_number,
|
|
"labeler_key",
|
|
)
|
|
key = (
|
|
row["observation_id"],
|
|
participant_key,
|
|
session_key,
|
|
row["axis"],
|
|
)
|
|
core = {
|
|
"observation_id": row["observation_id"],
|
|
"participant_key": participant_key,
|
|
"session_key": session_key,
|
|
"axis": row["axis"],
|
|
"text_only_status": row["text_only_status"],
|
|
"text_only_score": _parse_score(
|
|
row["text_only_score"],
|
|
nullable=True,
|
|
source=OBSERVATIONS_NAME,
|
|
row=row_number,
|
|
field="text_only_score",
|
|
),
|
|
"voice_enabled_status": row["voice_enabled_status"],
|
|
"voice_enabled_score": _parse_score(
|
|
row["voice_enabled_score"],
|
|
nullable=True,
|
|
source=OBSERVATIONS_NAME,
|
|
row=row_number,
|
|
field="voice_enabled_score",
|
|
),
|
|
"raw_audio_included": False,
|
|
"transcript_included": False,
|
|
}
|
|
if key not in grouped:
|
|
grouped[key] = {**core, "labels": []}
|
|
order.append(key)
|
|
elif any(grouped[key][name] != value for name, value in core.items()):
|
|
raise IntakeError(
|
|
"observation_core_mismatch", OBSERVATIONS_NAME, row_number
|
|
)
|
|
grouped[key]["labels"].append(
|
|
{
|
|
"labeler_key": labeler_key,
|
|
"score": _parse_score(
|
|
row["label_score"],
|
|
nullable=False,
|
|
source=OBSERVATIONS_NAME,
|
|
row=row_number,
|
|
field="label_score",
|
|
),
|
|
"category": row["label_category"],
|
|
}
|
|
)
|
|
return [grouped[key] for key in order]
|
|
|
|
|
|
def _draft_pack(directory: Path) -> dict[str, Any]:
|
|
manifest = _read_manifest(directory)
|
|
participants = _parse_participants(
|
|
_read_csv(directory, PARTICIPANTS_NAME, PARTICIPANT_FIELDS)
|
|
)
|
|
labelers = _parse_labelers(_read_csv(directory, LABELERS_NAME, LABELER_FIELDS))
|
|
observations = _parse_observations(
|
|
_read_csv(directory, OBSERVATIONS_NAME, OBSERVATION_FIELDS)
|
|
)
|
|
labeler_keys = [item["labeler_key"] for item in labelers]
|
|
return {
|
|
"provenance": manifest["provenance"],
|
|
"text_only_model": manifest["text_only_model"],
|
|
"voice_enabled_model": manifest["voice_enabled_model"],
|
|
"power_plan": manifest["power_plan"],
|
|
"participants": participants,
|
|
"labeler_attestations": labelers,
|
|
"reliability": {
|
|
"labeler_keys": labeler_keys,
|
|
"reported_icc": manifest["reported_icc"],
|
|
"reported_categorical_kappa": manifest["reported_categorical_kappa"],
|
|
"report_sha256": manifest["reliability_report_sha256"],
|
|
},
|
|
"observations": observations,
|
|
}
|
|
|
|
|
|
def compile_intake(directory: Path) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
report = _base_report()
|
|
try:
|
|
payload = _draft_pack(directory)
|
|
pack = G7HumanVoiceGainEvidencePack.model_validate(payload)
|
|
result = evaluate_human_voice_gain(pack)
|
|
except IntakeError as exc:
|
|
report["errors"] = [_safe_error(exc)]
|
|
return report, None
|
|
except ValidationError as exc:
|
|
report["errors"] = _validation_errors(exc)
|
|
return report, None
|
|
except Exception as exc:
|
|
report["errors"] = [
|
|
{"code": f"evaluation:{type(exc).__name__}", "source": "compiled_pack"}
|
|
]
|
|
return report, None
|
|
|
|
report["passed"] = result.passed
|
|
report["result"] = result.model_dump(mode="json")
|
|
if not result.passed:
|
|
return report, None
|
|
return report, pack.model_dump(mode="json")
|
|
|
|
|
|
def _atomic_create_json(path: Path, payload: dict[str, Any]) -> None:
|
|
if path.exists():
|
|
raise IntakeError("output_exists", source="output")
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
dir=path.parent,
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as handle:
|
|
temporary = Path(handle.name)
|
|
json.dump(payload, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
try:
|
|
os.link(temporary, path)
|
|
except FileExistsError:
|
|
raise IntakeError("output_exists", source="output") from None
|
|
except OSError as exc:
|
|
raise IntakeError(
|
|
f"output_link:{type(exc).__name__}", source="output"
|
|
) from None
|
|
try:
|
|
temporary.unlink()
|
|
except OSError as exc:
|
|
path.unlink(missing_ok=True)
|
|
raise IntakeError(
|
|
f"output_cleanup:{type(exc).__name__}", source="output"
|
|
) from None
|
|
temporary = None
|
|
finally:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
|
|
|
|
def create_template(directory: Path) -> None:
|
|
if directory.exists():
|
|
raise IntakeError("template_directory_exists", source="template")
|
|
directory.mkdir(parents=True)
|
|
manifest = {
|
|
"intake_schema_version": INTAKE_SCHEMA_VERSION,
|
|
"template_only": True,
|
|
"provenance": {
|
|
"protocol_sha256": "",
|
|
"consent_protocol_sha256": "",
|
|
"dataset_manifest_sha256": "",
|
|
"split_manifest_sha256": "",
|
|
"labeling_protocol_sha256": "",
|
|
"analysis_plan_sha256": "",
|
|
"registered_at": "",
|
|
"held_out_labels_opened_at": "",
|
|
},
|
|
"text_only_model": {
|
|
"role": "text_only_baseline",
|
|
"provider": "",
|
|
"model_id": "",
|
|
"model_version": "",
|
|
"artifact_sha256": "",
|
|
"configuration_sha256": "",
|
|
},
|
|
"voice_enabled_model": {
|
|
"role": "voice_enabled_candidate",
|
|
"provider": "",
|
|
"model_id": "",
|
|
"model_version": "",
|
|
"artifact_sha256": "",
|
|
"configuration_sha256": "",
|
|
},
|
|
"power_plan": {
|
|
"primary_metric": "paired_one_minus_mae_gain",
|
|
"clustering_unit": "participant",
|
|
"required_held_out_participants": 30,
|
|
"required_held_out_sessions": 50,
|
|
"required_paired_axis_observations": 150,
|
|
"alpha": 0.05,
|
|
"target_power": 0.8,
|
|
"minimally_detectable_gain": 0.01,
|
|
"planned_bootstrap_samples": 10000,
|
|
},
|
|
"reported_icc": None,
|
|
"reported_categorical_kappa": None,
|
|
"reliability_report_sha256": "",
|
|
}
|
|
(directory / MANIFEST_NAME).write_text(
|
|
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
for filename, fields in (
|
|
(PARTICIPANTS_NAME, PARTICIPANT_FIELDS),
|
|
(LABELERS_NAME, LABELER_FIELDS),
|
|
(OBSERVATIONS_NAME, OBSERVATION_FIELDS),
|
|
):
|
|
with (directory / filename).open("w", encoding="utf-8", newline="") as handle:
|
|
csv.writer(handle, lineterminator="\n").writerow(fields)
|
|
(directory / README_NAME).write_text(
|
|
"# G7 human voice-gain intake\n\n"
|
|
"## 이 폴더의 상태\n\n"
|
|
"이 디렉터리는 빈 입력 틀이지 증거가 아니다. 이름, 이메일, 원음, 축어록, "
|
|
"자유서술을 넣지 마라. 최종 pack도 임상 효과를 주장할 수 없다.\n\n"
|
|
"## 작성 순서\n\n"
|
|
"1. held-out 라벨을 열기 전에 protocol, consent, split, labeling, analysis plan을 "
|
|
"사전등록하고 각 산출물의 SHA-256을 고정한다.\n"
|
|
"2. `manifest.json`의 provenance, 두 모델 identity, reliability report를 실제 값으로 "
|
|
"채우고 `template_only`를 `false`로 바꾼다. `registered_at`은 "
|
|
"`held_out_labels_opened_at`보다 빨라야 한다.\n"
|
|
"3. 아래 CSV 계약에 맞춰 비식별 키와 수치만 입력한다.\n"
|
|
"4. compile 명령으로 production gate를 다시 계산한다. 성공한 경우에만 최종 pack이 "
|
|
"원자적으로 생성된다.\n"
|
|
"5. standalone checker로 최종 pack을 한 번 더 검증한 뒤 G7 runner에 전달한다.\n\n"
|
|
"## CSV 계약\n\n"
|
|
"- `participants.csv`: `split`은 `calibration` 또는 `held_out`이다. key는 각각 "
|
|
"`calibration-` 또는 `held-`로 시작하고 consent receipt SHA-256은 참가자마다 고유해야 한다.\n"
|
|
"- `labelers.csv`: 최소 2명의 독립 평가자를 선언한다. 세 blind/independent 열은 모두 "
|
|
"소문자 `true`여야 하고 attestation SHA-256을 넣는다.\n"
|
|
"- `observations.csv`: `axis`는 `goal`, `task`, `bond` 중 하나다. 각 held-out 회기는 "
|
|
"세 축을 모두 가져야 하며 모든 행에 동일한 blind labeler panel을 사용한다.\n"
|
|
"- prediction `status`는 `observed`, `missing`, `error` 중 하나다. `observed`일 때만 "
|
|
"0 이상 1 이하 score를 쓰고, `missing` 또는 `error`면 score 셀을 비운다.\n"
|
|
"- `label_score`는 0 이상 1 이하이고 `label_category`는 labeling protocol에 "
|
|
"사전 정의한 동일 taxonomy의 ASCII token을 사용한다.\n\n"
|
|
"## Production gate\n\n"
|
|
"최소 calibration 참가자 1명, held-out 참가자 30명, 완전 paired held-out 회기 50개, "
|
|
"paired 축 150개, blind labeler 2명이 필요하다. compiler는 ICC(A,1) 0.75 이상, "
|
|
"categorical kappa 0.70 이상, paired gain 0.01 이상, participant-cluster bootstrap "
|
|
"10,000회의 95% CI lower가 0보다 큰지 행에서 다시 계산한다. 한 조건이라도 결측이면 "
|
|
"양 조건을 최대오류로 처리해 gain 부풀림을 막는다.\n\n"
|
|
"## 실행\n\n"
|
|
"```powershell\n"
|
|
"python -X utf8 -B scripts/prepare-g7-human-voice-gain-intake.py "
|
|
"--compile <intake-dir> --out <pack.json>\n"
|
|
"python -X utf8 -B scripts/check-g7-human-voice-gain.py --input <pack.json>\n"
|
|
"```\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
result = argparse.ArgumentParser(description=__doc__)
|
|
mode = result.add_mutually_exclusive_group(required=True)
|
|
mode.add_argument("--create-template", type=Path, metavar="DIR")
|
|
mode.add_argument("--compile", type=Path, metavar="DIR")
|
|
result.add_argument("--out", type=Path, help="final pack JSON; compile only")
|
|
return result
|
|
|
|
|
|
def main(argv: Iterable[str] | None = None) -> int:
|
|
args = parser().parse_args(list(argv) if argv is not None else None)
|
|
report = _base_report()
|
|
if args.create_template is not None:
|
|
if args.out is not None:
|
|
parser().error("--out is compile-only")
|
|
try:
|
|
create_template(args.create_template)
|
|
except IntakeError as exc:
|
|
report["errors"] = [_safe_error(exc)]
|
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
return 1
|
|
report["template_created"] = True
|
|
report["template_is_evidence"] = False
|
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
return 0
|
|
|
|
if args.out is None:
|
|
parser().error("--compile requires --out")
|
|
report, pack_payload = compile_intake(args.compile)
|
|
if pack_payload is not None:
|
|
try:
|
|
_atomic_create_json(args.out, pack_payload)
|
|
except IntakeError as exc:
|
|
report["passed"] = False
|
|
report["errors"] = [_safe_error(exc)]
|
|
else:
|
|
report["pack_written"] = True
|
|
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
|
return 0 if report["passed"] is True and report["pack_written"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|