Record the clustering invocation in a meta file

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:38:31 +08:00
co-authored by Claude Opus 5
parent 15f1843cde
commit 4dc526c4b0
3 changed files with 267 additions and 36 deletions
+9 -3
View File
@@ -59,14 +59,20 @@ Run、Song,一列一筆出現,依三欄排序),供收斂軌跡分析;
實作本步,四種模型六次執行全部無法維持完整分割, 實作本步,四種模型六次執行全部無法維持完整分割,
已棄用(詳見 `decision-log.md` 2026-08-05;棄用的 已棄用(詳見 `decision-log.md` 2026-08-05;棄用的
定義檔止於 git 歷史,見 `git log -- prompts/`)。 定義檔止於 git 歷史,見 `git log -- prompts/`)。
- **產物**份,前綴分別標示來源與結果。 - **產物**份,前綴分別標示來源與結果。
`source-keywords.txt`(進池後的關鍵字,一行一個)與 `source-keywords.txt`(進池後的關鍵字,一行一個)與
`source-provenance.csv`(出處)記錄進來的是什麼; `source-provenance.csv`(出處)記錄進來的是什麼;
`result-keywords.txt`(組名,一行一個)與 `result-keywords.txt`(組名,一行一個)與
`groups.csv`(欄位 Group、Keyword,一列一個成員) `groups.csv`(欄位 Group、Keyword,一列一個成員)
記錄算出來的分割;`keywords-to-merge.json` 記錄算出來的分割;`keywords-to-merge.json`
`{"keywords": [...]}`)是實際交給模型的碼,即組名 `{"keywords": [...]}`)是實際交給模型的碼,即組名
加上先驗主題詞。只有最後一份含研究者的介入。 加上先驗主題詞——六份中只有這一份含研究者的介入。
`meta.json` 記錄執行本身:進池的兩份執行歸檔與其有效
筆數、嵌入模型與釘定 revision、分群參數與組數、外加
的先驗詞、關鍵字總數,以及產生數字的套件版本。凡命令
列上的選擇與環境事實皆在此,不記時間戳與輸入雜湊
——前者使同環境重跑逐位元組可再生,後者只會重述 git
已保證的事。
- **可重現性**:同一輸入、同一釘定模型、同一參數逐次 - **可重現性**:同一輸入、同一釘定模型、同一參數逐次
重現。不同 CPU/BLAS 實作的浮點尾數差異可能使邊界 重現。不同 CPU/BLAS 實作的浮點尾數差異可能使邊界
詞的歸屬翻動,屬已揭露的限制;論文所用碼表逐字 詞的歸屬翻動,屬已揭露的限制;論文所用碼表逐字
@@ -130,7 +136,7 @@ Run、Song,一列一筆出現,依三欄排序),供收斂軌跡分析;
`output.jsonl`(一律以換行字元 `\n` 切行——歌詞含 `output.jsonl`(一律以換行字元 `\n` 切行——歌詞含
U+0085 等控制字元時,`str.splitlines()` 類的通用切行 U+0085 等控制字元時,`str.splitlines()` 類的通用切行
會截斷 JSON 字串,實測踩中),進池後直接分群,一次 會截斷 JSON 字串,實測踩中),進池後直接分群,一次
產出上列份檔案。 產出上列份檔案。
- **步驟 2 → 3 輸入檔**`export-llm-input --extras - **步驟 2 → 3 輸入檔**`export-llm-input --extras
<定案碼表>` 自工作儲存產出步驟 3 的輸入,每筆 <定案碼表>` 自工作儲存產出步驟 3 的輸入,每筆
`{"id": "song-<ID>", "content": <字串>}``content` 為 `{"id": "song-<ID>", "content": <字串>}``content` 為
@@ -5,7 +5,7 @@
"""The deterministic vocabulary-building step. """The deterministic vocabulary-building step.
Goes from the two tagging runs' archives straight to the coding Goes from the two tagging runs' archives straight to the coding
vocabulary, writing five fixed-named artifacts under the output vocabulary, writing six fixed-named artifacts under the output
directory given as the third positional command-line argument. directory given as the third positional command-line argument.
First, the keywords produced by the two runs of the tagging step First, the keywords produced by the two runs of the tagging step
are pooled into the pooled keyword list, per the project's handoff are pooled into the pooled keyword list, per the project's handoff
@@ -16,18 +16,22 @@ sorted, written as a plain text file with one keyword per line, as
every keyword came from for audit purposes as a CSV file, as every keyword came from for audit purposes as a CSV file, as
:data:`SOURCE_PROVENANCE_CSV`; it never enters any LLM input. Then :data:`SOURCE_PROVENANCE_CSV`; it never enters any LLM input. Then
the coding groups are built from the pooled keyword list by the coding groups are built from the pooled keyword list by
sentence-embedding every keyword and clustering the embeddings: the sentence-embedding every keyword and clustering the embeddings into
group membership is written as a CSV file holding the clustering the number of groups given by the required ``--clusters``
result alone, as :data:`RESULT_GROUPS_CSV`. The group name command-line option: the group membership is written as a CSV file
keywords alone are written as a text file, one per line, as holding the clustering result alone, as :data:`RESULT_GROUPS_CSV`.
:data:`RESULT_KEYWORDS_TXT`. The coding keyword set for The group name keywords alone are written as a text file, one per
line, as :data:`RESULT_KEYWORDS_TXT`. The coding keyword set for
``export-llm-input --extras`` is written as a JSON file holding the ``export-llm-input --extras`` is written as a JSON file holding the
group name keywords plus every extra a-priori keyword the caller group name keywords plus every extra a-priori keyword the caller
gives with the repeatable ``--extra-keyword`` command-line option, gives with the repeatable ``--extra-keyword`` command-line option,
as :data:`KEYWORDS_TO_MERGE_JSON`; with no ``--extra-keyword``, it as :data:`KEYWORDS_TO_MERGE_JSON`; with no ``--extra-keyword``, it
holds the group names alone. No default extra keyword is ever holds the group names alone. No default extra keyword is ever
injected; the caller supplies each one consciously. The step is injected; the caller supplies each one consciously. Finally, the
fully deterministic; no LLM call is made. command-line choices and the environment that produced the numbers
-- neither recoverable from the committed inputs and outputs -- are
written as a JSON file, as :data:`META_JSON`. The step is fully
deterministic; no LLM call is made.
""" """
import argparse import argparse
import csv import csv
@@ -40,6 +44,8 @@ from typing import Any
from ..utils import format_duration from ..utils import format_duration
MODEL: str = "sentence-transformers/all-mpnet-base-v2" MODEL: str = "sentence-transformers/all-mpnet-base-v2"
SCRIPT_VERSION: str = "cluster_keywords.py 1.0.0"
"""The script version recorded into :data:`META_JSON`."""
CLUSTER_EXTRA_MESSAGE: str = ( CLUSTER_EXTRA_MESSAGE: str = (
"cluster-keywords requires the optional \"cluster\"" "cluster-keywords requires the optional \"cluster\""
" dependency group; install it with" " dependency group; install it with"
@@ -61,6 +67,9 @@ directory."""
KEYWORDS_TO_MERGE_JSON: str = "keywords-to-merge.json" KEYWORDS_TO_MERGE_JSON: str = "keywords-to-merge.json"
"""The coding keyword set JSON file's fixed name under the output """The coding keyword set JSON file's fixed name under the output
directory.""" directory."""
META_JSON: str = "meta.json"
"""The run metadata JSON file's fixed name under the output
directory."""
type Records = list[tuple[int, dict[str, Any]]] type Records = list[tuple[int, dict[str, Any]]]
"""The valid records of one run: (song ID, keyword mapping) pairs.""" """The valid records of one run: (song ID, keyword mapping) pairs."""
@@ -91,8 +100,8 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
help="the output directory, created if missing, that" help="the output directory, created if missing, that"
f" receives {SOURCE_KEYWORDS_TXT}," f" receives {SOURCE_KEYWORDS_TXT},"
f" {SOURCE_PROVENANCE_CSV}, {RESULT_KEYWORDS_TXT}," f" {SOURCE_PROVENANCE_CSV}, {RESULT_KEYWORDS_TXT},"
f" {RESULT_GROUPS_CSV}, and" f" {RESULT_GROUPS_CSV}, {KEYWORDS_TO_MERGE_JSON},"
f" {KEYWORDS_TO_MERGE_JSON}") f" and {META_JSON}")
parser.add_argument( parser.add_argument(
"--model", default=MODEL, "--model", default=MODEL,
help=f"the sentence embedding model (default \"{MODEL}\")") help=f"the sentence embedding model (default \"{MODEL}\")")
@@ -101,8 +110,9 @@ def parse_args(argv: list[str] | None) -> argparse.Namespace:
help="the model revision to pin (default: unpinned)") help="the model revision to pin (default: unpinned)")
parser.add_argument( parser.add_argument(
"--clusters", type=int, required=True, "--clusters", type=int, required=True,
help="the number of clusters; required, so that the\n" help="the number of clusters; required, as the study's"
"group count is stated on every invocation") " chosen cluster count must be stated on every"
" invocation")
parser.add_argument( parser.add_argument(
"--extra-keyword", dest="extra_keywords", action="append", "--extra-keyword", dest="extra_keywords", action="append",
default=None, default=None,
@@ -364,6 +374,35 @@ def build_groups(keywords: list[str], embeddings: Any,
return groups return groups
def collect_versions() -> dict[str, str]:
"""Collect the versions of the running environment.
:return: The version strings, keyed by "python", "torch",
"transformers", "sentence-transformers",
"scikit-learn", and "numpy".
:raises RuntimeError: When the optional clustering
dependencies are not installed.
"""
try:
import numpy
import sentence_transformers
import sklearn
import torch
import transformers
except ImportError as error:
raise RuntimeError(CLUSTER_EXTRA_MESSAGE) from error
import platform
return {
"python": platform.python_version(),
"torch": str(torch.__version__),
"transformers": str(transformers.__version__),
"sentence-transformers": str(
sentence_transformers.__version__),
"scikit-learn": str(sklearn.__version__),
"numpy": str(numpy.__version__),
}
def write_groups(path: Path, groups: dict[str, list[str]]) -> None: def write_groups(path: Path, groups: dict[str, list[str]]) -> None:
"""Write the group membership CSV file. """Write the group membership CSV file.
@@ -467,20 +506,75 @@ def write_keywords_to_merge(path: Path,
encoding="utf-8") encoding="utf-8")
def build_meta(
run1: tuple[str, Records], run2: tuple[str, Records],
args: argparse.Namespace, keyword_count: int,
extra_keywords: list[str],
versions: dict[str, str]) -> dict[str, Any]:
"""Build the run metadata recorded into :data:`META_JSON`.
:param run1: The first run's label and valid records.
:param run2: The second run's label and valid records.
:param args: The parsed command-line arguments.
:param keyword_count: The number of pooled keywords.
:param extra_keywords: The extra a-priori keywords given via
``--extra-keyword``, in the given order.
:param versions: The version strings of the running
environment, as returned by :func:`collect_versions`.
:return: The metadata, in the documented key order.
"""
return {
"script_version": SCRIPT_VERSION,
"source_runs": [str(args.run_dir_1), str(args.run_dir_2)],
"source_records": [len(run1[1]), len(run2[1])],
"embedding": {
"model": args.model, "revision": args.revision},
"clustering": {
"algorithm": "AgglomerativeClustering",
"linkage": "ward", "metric": "euclidean",
"clusters": args.clusters},
"extra_keywords": extra_keywords,
"keyword_count": keyword_count,
"versions": versions,
}
def write_meta(path: Path, meta: dict[str, Any]) -> None:
"""Write the run metadata JSON file.
Writes a JSON file holding the researcher's command-line
choices and the environment that produced the numbers --
neither recoverable from the committed inputs and outputs --
UTF-8, with a trailing newline. No timestamp or input digest
is recorded, so re-running in the same environment reproduces
the file byte for byte.
:param path: The path of the metadata JSON file to write.
:param meta: The metadata to write, as built by
:func:`build_meta`.
:return: None.
:raises OSError: When the file cannot be written.
"""
path.write_text(
json.dumps(meta, ensure_ascii=False, indent=1) + "\n",
encoding="utf-8")
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
"""Pool the two tagging runs' keywords and cluster them. """Pool the two tagging runs' keywords and cluster them.
Writes the five fixed-named artifacts under the output Writes the six fixed-named artifacts under the output
directory, creating it (with parents) if it does not exist: directory, creating it (with parents) if it does not exist:
the pooled keyword text file and the keyword provenance CSV the pooled keyword text file and the keyword provenance CSV
file; then the group membership CSV file, holding the file; then the group membership CSV file, holding the
clustering result alone; the group name keyword text file, clustering result alone; the group name keyword text file,
holding the same group names as a readable list; and the holding the same group names as a readable list; the coding
coding keyword set JSON file, holding the group names plus keyword set JSON file, holding the group names plus every
every extra keyword given via ``--extra-keyword``. When the extra keyword given via ``--extra-keyword``; and the run
input is rejected, or an extra keyword duplicates a group metadata JSON file, recording the command-line choices and
name or another extra keyword, none of the five files is the environment. When the input is rejected, or an extra
written. keyword duplicates a group name or another extra keyword,
none of the six files is written.
:param argv: The command-line arguments, or None for :param argv: The command-line arguments, or None for
``sys.argv``. ``sys.argv``.
@@ -505,6 +599,7 @@ def main(argv: list[str] | None = None) -> int:
labels: Any = cluster_embeddings(embeddings, args.clusters) labels: Any = cluster_embeddings(embeddings, args.clusters)
groups: dict[str, list[str]] = build_groups( groups: dict[str, list[str]] = build_groups(
keywords, embeddings, labels) keywords, embeddings, labels)
versions: dict[str, str] = collect_versions()
except (RuntimeError, ValueError) as error: except (RuntimeError, ValueError) as error:
print(f"error: {error}", file=sys.stderr) print(f"error: {error}", file=sys.stderr)
return 1 return 1
@@ -525,6 +620,9 @@ def main(argv: list[str] | None = None) -> int:
write_keywords_to_merge( write_keywords_to_merge(
args.output_dir / KEYWORDS_TO_MERGE_JSON, groups, args.output_dir / KEYWORDS_TO_MERGE_JSON, groups,
extra_keywords) extra_keywords)
meta: dict[str, Any] = build_meta(
run1, run2, args, len(keywords), extra_keywords, versions)
write_meta(args.output_dir / META_JSON, meta)
elapsed: str = format_duration(time.monotonic() - started) elapsed: str = format_duration(time.monotonic() - started)
print( print(
f"done: {len(keywords)} keywords pooled from" f"done: {len(keywords)} keywords pooled from"
+141 -14
View File
@@ -51,6 +51,8 @@ class TestClusterKeywords(unittest.TestCase):
self.__keywords_to_merge_json: Path \ self.__keywords_to_merge_json: Path \
= self.__output_dir \ = self.__output_dir \
/ cluster_keywords.KEYWORDS_TO_MERGE_JSON / cluster_keywords.KEYWORDS_TO_MERGE_JSON
self.__meta_json: Path \
= self.__output_dir / cluster_keywords.META_JSON
@staticmethod @staticmethod
def __write_output( def __write_output(
@@ -110,22 +112,40 @@ class TestClusterKeywords(unittest.TestCase):
[vectors[x] for x in keywords], dtype=np.float32) [vectors[x] for x in keywords], dtype=np.float32)
return fake return fake
@staticmethod
def __fake_versions() -> dict[str, str]:
"""Return a fixed version mapping test double.
:return: A fixed mapping with the same keys
:func:`cluster_keywords.collect_versions` returns.
"""
return {
"python": "9.9.9",
"torch": "9.9.9",
"transformers": "9.9.9",
"sentence-transformers": "9.9.9",
"scikit-learn": "9.9.9",
"numpy": "9.9.9",
}
def __run_cluster(self, extra_args: list[str] | None = None, def __run_cluster(self, extra_args: list[str] | None = None,
vectors: Vectors | None = None, vectors: Vectors | None = None,
clusters: str = "2",
) -> tuple[int, str]: ) -> tuple[int, str]:
"""Run the clusterer with a fake encoder and captured """Run the clusterer with a fake encoder, a fake version
standard error. mapping, and captured standard error.
:param extra_args: Extra command-line arguments appended :param extra_args: Extra command-line arguments appended
after the three positional arguments. after the three positional arguments.
:param vectors: The fixed embedding to encode with; the :param vectors: The fixed embedding to encode with; the
two-cluster fixture is used when None. two-cluster fixture is used when None.
:param clusters: The ``--clusters`` option value.
:return: A tuple of the exit status and the standard :return: A tuple of the exit status and the standard
error. error.
""" """
argv: list[str] = [ argv: list[str] = [
str(self.__run1), str(self.__run2), str(self.__run1), str(self.__run2),
str(self.__output_dir)] str(self.__output_dir), "--clusters", clusters]
argv.extend(extra_args or []) argv.extend(extra_args or [])
fake: Any = self.__fake_encode( fake: Any = self.__fake_encode(
vectors if vectors is not None vectors if vectors is not None
@@ -133,11 +153,21 @@ class TestClusterKeywords(unittest.TestCase):
stderr: io.StringIO = io.StringIO() stderr: io.StringIO = io.StringIO()
with mock.patch.object( with mock.patch.object(
cluster_keywords, "encode_keywords", fake), \ cluster_keywords, "encode_keywords", fake), \
mock.patch.object(
cluster_keywords, "collect_versions",
return_value=self.__fake_versions()), \
redirect_stderr(stderr): redirect_stderr(stderr):
status: int = cluster_keywords.main( status: int = cluster_keywords.main(argv)
argv + ["--clusters", "2"])
return status, stderr.getvalue() return status, stderr.getvalue()
def __read_meta(self) -> dict[str, Any]:
"""Read the run metadata JSON file.
:return: The parsed metadata.
"""
return json.loads(
self.__meta_json.read_text(encoding="utf-8"))
def __read_source_keywords(self) -> list[str]: def __read_source_keywords(self) -> list[str]:
"""Read the pooled source keyword text file. """Read the pooled source keyword text file.
@@ -277,6 +307,7 @@ class TestClusterKeywords(unittest.TestCase):
self.assertFalse(self.__result_groups_csv.exists()) self.assertFalse(self.__result_groups_csv.exists())
self.assertFalse(self.__result_keywords_txt.exists()) self.assertFalse(self.__result_keywords_txt.exists())
self.assertFalse(self.__keywords_to_merge_json.exists()) self.assertFalse(self.__keywords_to_merge_json.exists())
self.assertFalse(self.__meta_json.exists())
def test_non_object_text_rejected(self) -> None: def test_non_object_text_rejected(self) -> None:
"""Test that a "text" JSON value that is not an object """Test that a "text" JSON value that is not an object
@@ -297,6 +328,7 @@ class TestClusterKeywords(unittest.TestCase):
self.assertFalse(self.__result_groups_csv.exists()) self.assertFalse(self.__result_groups_csv.exists())
self.assertFalse(self.__result_keywords_txt.exists()) self.assertFalse(self.__result_keywords_txt.exists())
self.assertFalse(self.__keywords_to_merge_json.exists()) self.assertFalse(self.__keywords_to_merge_json.exists())
self.assertFalse(self.__meta_json.exists())
def test_provenance_content_and_ordering(self) -> None: def test_provenance_content_and_ordering(self) -> None:
"""Test the provenance content and its ordering: rows """Test the provenance content and its ordering: rows
@@ -478,15 +510,6 @@ class TestClusterKeywords(unittest.TestCase):
["a-center", "aaa-extra", "b-middle", "zzz-extra"]) ["a-center", "aaa-extra", "b-middle", "zzz-extra"])
self.assertEqual(keywords, sorted(keywords)) self.assertEqual(keywords, sorted(keywords))
def test_missing_clusters_option_rejected(self) -> None:
"""Test that omitting --clusters fails the run."""
with self.assertRaises(SystemExit) as caught, \
redirect_stderr(io.StringIO()):
cluster_keywords.parse_args(
[str(self.__run1), str(self.__run2),
str(self.__output_dir)])
self.assertNotEqual(caught.exception.code, 0)
def test_duplicate_extra_keyword_rejected(self) -> None: def test_duplicate_extra_keyword_rejected(self) -> None:
"""Test that repeating the same ``--extra-keyword`` value """Test that repeating the same ``--extra-keyword`` value
fails the run without writing any output file.""" fails the run without writing any output file."""
@@ -510,6 +533,7 @@ class TestClusterKeywords(unittest.TestCase):
self.assertFalse(self.__result_groups_csv.exists()) self.assertFalse(self.__result_groups_csv.exists())
self.assertFalse(self.__result_keywords_txt.exists()) self.assertFalse(self.__result_keywords_txt.exists())
self.assertFalse(self.__keywords_to_merge_json.exists()) self.assertFalse(self.__keywords_to_merge_json.exists())
self.assertFalse(self.__meta_json.exists())
def test_extra_keyword_duplicating_group_name_rejected( def test_extra_keyword_duplicating_group_name_rejected(
self) -> None: self) -> None:
@@ -535,3 +559,106 @@ class TestClusterKeywords(unittest.TestCase):
self.assertFalse(self.__result_groups_csv.exists()) self.assertFalse(self.__result_groups_csv.exists())
self.assertFalse(self.__result_keywords_txt.exists()) self.assertFalse(self.__result_keywords_txt.exists())
self.assertFalse(self.__keywords_to_merge_json.exists()) self.assertFalse(self.__keywords_to_merge_json.exists())
self.assertFalse(self.__meta_json.exists())
def test_meta_json_records_documented_keys(self) -> None:
"""Test that the metadata JSON file records exactly the
documented keys."""
self.__write_output(self.__run1, [
{"id": "song-1", "text": json.dumps(
{"a-left": 1, "a-center": 1, "a-right": 1})},
])
self.__write_output(self.__run2, [
{"id": "song-2", "text": json.dumps(
{"b-north": 1, "b-middle": 1, "b-south": 1})},
])
status: int
status, _ = self.__run_cluster()
self.assertEqual(status, 0)
meta: dict[str, Any] = self.__read_meta()
self.assertEqual(set(meta.keys()), {
"script_version", "source_runs", "source_records",
"embedding", "clustering", "extra_keywords",
"keyword_count", "versions"})
self.assertEqual(
meta["script_version"], cluster_keywords.SCRIPT_VERSION)
self.assertEqual(
meta["versions"], self.__fake_versions())
def test_meta_json_source_runs_and_records(self) -> None:
"""Test that the metadata records the given run
directories and their valid record counts."""
self.__write_output(self.__run1, [
{"id": "song-1", "text": json.dumps(
{"a-left": 1, "a-center": 1, "a-right": 1})},
{"id": "song-9", "error": "invalid_request_error"},
])
self.__write_output(self.__run2, [
{"id": "song-2", "text": json.dumps(
{"b-north": 1, "b-middle": 1, "b-south": 1})},
])
status: int
status, _ = self.__run_cluster()
self.assertEqual(status, 0)
meta: dict[str, Any] = self.__read_meta()
self.assertEqual(
meta["source_runs"],
[str(self.__run1), str(self.__run2)])
self.assertEqual(meta["source_records"], [1, 1])
def test_meta_json_clustering_and_embedding(self) -> None:
"""Test that the metadata records the given cluster count
and the embedding model and revision."""
self.__write_output(self.__run1, [
{"id": "song-1", "text": json.dumps(
{"a-left": 1, "a-center": 1, "a-right": 1})},
])
self.__write_output(self.__run2, [
{"id": "song-2", "text": json.dumps(
{"b-north": 1, "b-middle": 1, "b-south": 1})},
])
status: int
status, _ = self.__run_cluster(extra_args=[
"--model", "some-model", "--revision", "abc123"])
self.assertEqual(status, 0)
meta: dict[str, Any] = self.__read_meta()
self.assertEqual(meta["clustering"]["clusters"], 2)
self.assertEqual(
meta["clustering"]["algorithm"],
"AgglomerativeClustering")
self.assertEqual(meta["embedding"], {
"model": "some-model", "revision": "abc123"})
def test_meta_json_extra_keywords_order_and_count(self) -> None:
"""Test that the metadata's ``extra_keywords`` reflects the
given options in the given order, and ``keyword_count``
matches the pooled keyword count."""
self.__write_output(self.__run1, [
{"id": "song-1", "text": json.dumps(
{"a-left": 1, "a-center": 1, "a-right": 1})},
])
self.__write_output(self.__run2, [
{"id": "song-2", "text": json.dumps(
{"b-north": 1, "b-middle": 1, "b-south": 1})},
])
status: int
status, _ = self.__run_cluster(extra_args=[
"--extra-keyword", "zzz-extra",
"--extra-keyword", "aaa-extra"])
self.assertEqual(status, 0)
meta: dict[str, Any] = self.__read_meta()
self.assertEqual(
meta["extra_keywords"], ["zzz-extra", "aaa-extra"])
self.assertEqual(meta["keyword_count"], 6)
def test_missing_clusters_option_rejected(self) -> None:
"""Test that omitting ``--clusters`` exits with an
argparse error."""
argv: list[str] = [
str(self.__run1), str(self.__run2),
str(self.__output_dir)]
stderr: io.StringIO = io.StringIO()
with redirect_stderr(stderr):
with self.assertRaises(SystemExit) as context:
cluster_keywords.parse_args(argv)
self.assertNotEqual(context.exception.code, 0)