fc-data 0.2.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.
Files changed (87) hide show
  1. datasmith/__init__.py +330 -0
  2. datasmith/__init__.pyi +194 -0
  3. datasmith/agents/__init__.py +31 -0
  4. datasmith/agents/classifiers.py +272 -0
  5. datasmith/agents/codex.py +25 -0
  6. datasmith/agents/config.py +108 -0
  7. datasmith/agents/extractors.py +197 -0
  8. datasmith/agents/installed/README.md +52 -0
  9. datasmith/agents/installed/__init__.py +22 -0
  10. datasmith/agents/installed/base.py +240 -0
  11. datasmith/agents/installed/claude.py +134 -0
  12. datasmith/agents/installed/codex.py +91 -0
  13. datasmith/agents/installed/gemini.py +118 -0
  14. datasmith/agents/installed/none.py +27 -0
  15. datasmith/agents/sandbox.py +547 -0
  16. datasmith/agents/synthesizer.py +439 -0
  17. datasmith/agents/templates/AGENTS.md.j2 +150 -0
  18. datasmith/agents/templates/sandbox_verify.py +428 -0
  19. datasmith/docker/__init__.py +31 -0
  20. datasmith/docker/context.py +112 -0
  21. datasmith/docker/images.py +158 -0
  22. datasmith/docker/publish.py +56 -0
  23. datasmith/docker/templates/Dockerfile.base +26 -0
  24. datasmith/docker/templates/Dockerfile.pr +42 -0
  25. datasmith/docker/templates/Dockerfile.repo +11 -0
  26. datasmith/docker/templates/docker_build_base.sh +780 -0
  27. datasmith/docker/templates/docker_build_env.sh +309 -0
  28. datasmith/docker/templates/docker_build_final.sh +106 -0
  29. datasmith/docker/templates/docker_build_pkg.sh +99 -0
  30. datasmith/docker/templates/docker_build_run.sh +124 -0
  31. datasmith/docker/templates/entrypoint.sh +62 -0
  32. datasmith/docker/templates/parser.py +1405 -0
  33. datasmith/docker/templates/profile.sh +199 -0
  34. datasmith/docker/templates/pytest_runner.py +692 -0
  35. datasmith/docker/templates/run-tests.sh +197 -0
  36. datasmith/docker/verifiers.py +131 -0
  37. datasmith/filters.py +154 -0
  38. datasmith/github/__init__.py +22 -0
  39. datasmith/github/client.py +333 -0
  40. datasmith/github/hooks.py +50 -0
  41. datasmith/github/links.py +110 -0
  42. datasmith/github/models.py +206 -0
  43. datasmith/github/render.py +173 -0
  44. datasmith/github/search.py +66 -0
  45. datasmith/github/templates/comment.md.j2 +5 -0
  46. datasmith/github/templates/final.md.j2 +66 -0
  47. datasmith/github/templates/issues.md.j2 +21 -0
  48. datasmith/github/templates/repo.md.j2 +1 -0
  49. datasmith/preflight.py +162 -0
  50. datasmith/publish/__init__.py +13 -0
  51. datasmith/publish/huggingface.py +104 -0
  52. datasmith/publish/pipeline.py +60 -0
  53. datasmith/publish/records.py +91 -0
  54. datasmith/py.typed +1 -0
  55. datasmith/resolution/__init__.py +14 -0
  56. datasmith/resolution/blocklist.py +145 -0
  57. datasmith/resolution/cache.py +120 -0
  58. datasmith/resolution/constants.py +277 -0
  59. datasmith/resolution/dependency_resolver.py +174 -0
  60. datasmith/resolution/git_utils.py +378 -0
  61. datasmith/resolution/import_analyzer.py +66 -0
  62. datasmith/resolution/metadata_parser.py +412 -0
  63. datasmith/resolution/models.py +41 -0
  64. datasmith/resolution/orchestrator.py +522 -0
  65. datasmith/resolution/package_filters.py +312 -0
  66. datasmith/resolution/python_manager.py +110 -0
  67. datasmith/runners/__init__.py +15 -0
  68. datasmith/runners/base.py +112 -0
  69. datasmith/runners/classify_prs.py +48 -0
  70. datasmith/runners/render_problems.py +113 -0
  71. datasmith/runners/resolve_packages.py +66 -0
  72. datasmith/runners/scrape_commits.py +166 -0
  73. datasmith/runners/scrape_repos.py +44 -0
  74. datasmith/runners/synthesize_images.py +310 -0
  75. datasmith/update/__init__.py +5 -0
  76. datasmith/update/cli.py +169 -0
  77. datasmith/update/offline.py +173 -0
  78. datasmith/update/pipeline.py +497 -0
  79. datasmith/utils/__init__.py +18 -0
  80. datasmith/utils/core.py +67 -0
  81. datasmith/utils/db.py +156 -0
  82. datasmith/utils/tokens.py +65 -0
  83. fc_data-0.2.0.dist-info/METADATA +441 -0
  84. fc_data-0.2.0.dist-info/RECORD +87 -0
  85. fc_data-0.2.0.dist-info/WHEEL +4 -0
  86. fc_data-0.2.0.dist-info/entry_points.txt +2 -0
  87. fc_data-0.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,412 @@
1
+ """Parsing metadata from packaging files (pyproject.toml, setup.cfg, requirements.txt, etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import configparser
6
+ import re
7
+ import shlex
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+
11
+ try:
12
+ import tomllib as _toml # type: ignore[import-not-found,unused-ignore]
13
+ except ImportError:
14
+ import tomli as _toml # type: ignore[no-redef,import-not-found,unused-ignore]
15
+
16
+ from git import Commit
17
+
18
+ from .constants import ENV_YML_NAMES, PYPROJECT, REQ_TXT_REGEX, SETUP_CFG, SETUP_PY
19
+ from .git_utils import materialize_blobs
20
+ from .models import Candidate, CandidateMeta
21
+
22
+
23
+ def parse_requirements_txt(path: Path) -> set[str]:
24
+ """Parse a requirements.txt file and return a set of requirement strings."""
25
+ out: set[str] = set()
26
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
27
+ line = line.strip()
28
+ if not line or line.startswith("#"):
29
+ continue
30
+ out.add(line)
31
+ return out
32
+
33
+
34
+ def parse_pyproject(path: Path) -> CandidateMeta:
35
+ """Parse a pyproject.toml file and extract metadata."""
36
+ raw = _toml.loads(path.read_text(encoding="utf-8", errors="replace"))
37
+ data = cast(dict[str, Any], raw)
38
+ meta = CandidateMeta()
39
+ proj = data.get("project") or {}
40
+ if proj:
41
+ meta.name = proj.get("name") or meta.name
42
+ v = proj.get("version")
43
+ if isinstance(v, str):
44
+ meta.version = v
45
+ deps = proj.get("dependencies") or []
46
+ meta.core_deps.update([d for d in deps if isinstance(d, str)])
47
+ opt = proj.get("optional-dependencies") or {}
48
+ for k, arr in opt.items():
49
+ if isinstance(arr, list):
50
+ meta.extras[k] = {d for d in arr if isinstance(d, str)}
51
+ rp = proj.get("requires-python")
52
+ if isinstance(rp, str):
53
+ meta.requires_python = rp
54
+
55
+ bsys = data.get("build-system") or {}
56
+ breq = bsys.get("requires") or []
57
+ for x in breq:
58
+ if isinstance(x, str):
59
+ meta.build_requires.add(x)
60
+
61
+ return meta
62
+
63
+
64
+ def parse_setup_cfg(path: Path) -> CandidateMeta:
65
+ """Parse a setup.cfg file and extract metadata."""
66
+ cfg = configparser.ConfigParser()
67
+ cfg.read_string(path.read_text(encoding="utf-8", errors="replace"))
68
+ meta = CandidateMeta()
69
+ if cfg.has_section("metadata"):
70
+ meta.name = cfg.get("metadata", "name", fallback=None) or meta.name
71
+ meta.version = cfg.get("metadata", "version", fallback=None) or meta.version
72
+ if cfg.has_section("options"):
73
+ if cfg.has_option("options", "install_requires"):
74
+ reqs = [
75
+ x.strip()
76
+ for x in cfg.get("options", "install_requires", raw=True, fallback="").splitlines()
77
+ if x.strip()
78
+ ]
79
+ meta.core_deps.update(reqs)
80
+ if cfg.has_option("options", "python_requires"):
81
+ meta.requires_python = cfg.get("options", "python_requires", fallback=None) or meta.requires_python
82
+ for sec in cfg.sections():
83
+ if sec.startswith("options.extras_require"):
84
+ if sec == "options.extras_require":
85
+ for k, v in cfg.items(sec):
86
+ arr = [x.strip() for x in v.splitlines() if x.strip()]
87
+ meta.extras[k] = set(arr)
88
+ else:
89
+ _, _, extra = sec.partition(":")
90
+ arr = [x.strip() for x in cfg.get(sec, "__name__", fallback="").splitlines() if x.strip()]
91
+ if arr:
92
+ meta.extras[extra] = set(arr)
93
+ return meta
94
+
95
+
96
+ def parse_setup_py(path: Path) -> CandidateMeta: # noqa: C901
97
+ """Heuristic, safe parser for setup.py (no code execution)."""
98
+ import ast
99
+
100
+ meta = CandidateMeta()
101
+
102
+ try:
103
+ src = path.read_text(encoding="utf-8", errors="replace")
104
+ except Exception:
105
+ return meta
106
+
107
+ try:
108
+ tree = ast.parse(src, filename=str(path))
109
+ except Exception:
110
+ return meta
111
+
112
+ env: dict[str, Any] = {}
113
+
114
+ def safe_eval(node: ast.AST, depth: int = 0) -> Any: # noqa: C901
115
+ if depth > 100:
116
+ raise ValueError("Too deep")
117
+
118
+ if isinstance(node, ast.Constant):
119
+ return node.value
120
+
121
+ if hasattr(ast, "Str") and isinstance(node, ast.Str):
122
+ return node.s
123
+ if hasattr(ast, "Num") and isinstance(node, ast.Num):
124
+ return node.n
125
+ if hasattr(ast, "NameConstant") and isinstance(node, ast.NameConstant):
126
+ return node.value
127
+
128
+ if isinstance(node, ast.Name):
129
+ if node.id in env:
130
+ return env[node.id]
131
+ raise ValueError(f"Unknown name {node.id}")
132
+
133
+ if isinstance(node, (ast.List, ast.Tuple, ast.Set)):
134
+ elts = []
135
+ for e in node.elts:
136
+ elts.append(safe_eval(e, depth + 1))
137
+ return list(elts)
138
+
139
+ if isinstance(node, ast.Dict):
140
+ out: dict[Any, Any] = {}
141
+ for k, v in zip(node.keys, node.values):
142
+ if k is None:
143
+ raise ValueError("Dict unpacking not allowed here")
144
+ key = safe_eval(k, depth + 1)
145
+ val = safe_eval(v, depth + 1)
146
+ out[key] = val
147
+ return out
148
+
149
+ if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
150
+ v = safe_eval(node.operand, depth + 1)
151
+ if isinstance(v, (int, float)) and isinstance(node.op, ast.USub):
152
+ return -v
153
+ if isinstance(v, (int, float)) and isinstance(node.op, ast.UAdd):
154
+ return +v
155
+ raise ValueError("Unsupported unary op")
156
+
157
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
158
+ left = safe_eval(node.left, depth + 1)
159
+ right = safe_eval(node.right, depth + 1)
160
+ if isinstance(left, str) and isinstance(right, str):
161
+ return left + right
162
+ if isinstance(left, list) and isinstance(right, list):
163
+ return left + right
164
+ if isinstance(left, tuple) and isinstance(right, tuple):
165
+ return list(left + right)
166
+ raise ValueError("Unsupported addition types")
167
+
168
+ if isinstance(node, ast.Call):
169
+ func = node.func
170
+ func_name = None
171
+ if isinstance(func, ast.Name):
172
+ func_name = func.id
173
+ elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
174
+ func_name = f"{func.value.id}.{func.attr}"
175
+ if func_name in {"list", "tuple"} and not node.keywords and len(node.args) == 1:
176
+ seq = safe_eval(node.args[0], depth + 1)
177
+ if isinstance(seq, list):
178
+ return list(seq)
179
+ if func_name == "dict" and not node.keywords: # noqa: SIM102
180
+ if len(node.args) == 1:
181
+ seq = safe_eval(node.args[0], depth + 1)
182
+ out2: dict[Any, Any] = {}
183
+ if isinstance(seq, list):
184
+ for item in seq:
185
+ if isinstance(item, (list, tuple)) and len(item) == 2:
186
+ out2[item[0]] = item[1]
187
+ else:
188
+ raise ValueError("Unsupported dict constructor form")
189
+ return out2
190
+ raise ValueError("Calls are not safely evaluable")
191
+
192
+ if isinstance(node, ast.JoinedStr):
193
+ s: list[str] = []
194
+ for v in node.values:
195
+ if isinstance(v, ast.Str):
196
+ s.append(str(v.s))
197
+ elif isinstance(v, ast.Constant) and isinstance(v.value, str):
198
+ s.append(v.value)
199
+ else:
200
+ raise TypeError("Non-literal in f-string")
201
+ return "".join(s)
202
+
203
+ raise ValueError("Unsupported AST node")
204
+
205
+ for node in tree.body:
206
+ try:
207
+ if isinstance(node, ast.Assign):
208
+ val = safe_eval(node.value)
209
+ for target in node.targets:
210
+ if isinstance(target, ast.Name):
211
+ env[target.id] = val
212
+ elif isinstance(target, (ast.Tuple, ast.List)): # noqa: SIM102
213
+ if isinstance(val, (list, tuple)) and len(target.elts) == len(val):
214
+ for elt, v in zip(target.elts, val):
215
+ if isinstance(elt, ast.Name):
216
+ env[elt.id] = v
217
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None:
218
+ env[node.target.id] = safe_eval(node.value)
219
+ except Exception: # noqa: S112
220
+ continue
221
+
222
+ def is_setup_call(call: ast.Call) -> bool:
223
+ f = call.func
224
+ if isinstance(f, ast.Name):
225
+ return f.id == "setup"
226
+ if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
227
+ return f.attr == "setup"
228
+ return False
229
+
230
+ def merge_kwargs_from_starstar(val: Any, into: dict[str, Any]) -> None:
231
+ if isinstance(val, str) and val in env and isinstance(env[val], dict):
232
+ for k, v in env[val].items():
233
+ into.setdefault(k, v)
234
+ elif isinstance(val, dict):
235
+ for k, v in val.items():
236
+ into.setdefault(k, v)
237
+
238
+ setup_kwargs: dict[str, Any] = {}
239
+ for ast_node in ast.walk(tree):
240
+ if isinstance(ast_node, ast.Call) and is_setup_call(ast_node):
241
+ kwargs: dict[str, Any] = {}
242
+ for kw in ast_node.keywords:
243
+ try:
244
+ if kw.arg is None:
245
+ v = safe_eval(kw.value)
246
+ merge_kwargs_from_starstar(v, kwargs)
247
+ else:
248
+ kwargs[kw.arg] = safe_eval(kw.value)
249
+ except Exception: # noqa: S112
250
+ continue
251
+ setup_kwargs = kwargs
252
+
253
+ if setup_kwargs:
254
+ name = setup_kwargs.get("name")
255
+ if isinstance(name, str):
256
+ meta.name = name
257
+ version = setup_kwargs.get("version")
258
+ if isinstance(version, str):
259
+ meta.version = version
260
+ pyreq = setup_kwargs.get("python_requires")
261
+ if isinstance(pyreq, str):
262
+ meta.requires_python = pyreq
263
+ install_requires = setup_kwargs.get("install_requires")
264
+ if isinstance(install_requires, (list, tuple)):
265
+ meta.core_deps.update([x for x in install_requires if isinstance(x, str)])
266
+ extras_require = setup_kwargs.get("extras_require")
267
+ if isinstance(extras_require, dict):
268
+ for k, v in extras_require.items():
269
+ if isinstance(k, str) and isinstance(v, (list, tuple)):
270
+ meta.extras[k] = {x for x in v if isinstance(x, str)}
271
+ setup_requires = setup_kwargs.get("setup_requires")
272
+ if isinstance(setup_requires, (list, tuple)):
273
+ meta.build_requires.update([x for x in setup_requires if isinstance(x, str)])
274
+
275
+ return meta
276
+
277
+
278
+ def parse_conda_env_yaml(path: Path) -> set[str]: # noqa: C901
279
+ """Light parser for environment.yml/.yaml."""
280
+ try:
281
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
282
+ except Exception:
283
+ return set()
284
+ out: set[str] = set()
285
+ in_deps = False
286
+ in_pip = False
287
+ indent_pip = None
288
+ for raw in lines:
289
+ line = raw.rstrip()
290
+ if not line.strip() or line.strip().startswith("#"):
291
+ continue
292
+ if line.startswith("dependencies:"):
293
+ in_deps, in_pip, indent_pip = True, False, None
294
+ continue
295
+ if not in_deps:
296
+ continue
297
+ if re.match(r"\s*-\s*pip\s*:\s*$", line):
298
+ in_pip = True
299
+ indent_pip = len(line) - len(line.lstrip(" "))
300
+ continue
301
+ if in_pip:
302
+ if (len(line) - len(line.lstrip(" "))) > (indent_pip or 0):
303
+ m = re.match(r"\s*-\s*([^\s].+)$", line)
304
+ if m:
305
+ out.add(m.group(1).strip())
306
+ continue
307
+ else:
308
+ in_pip = False
309
+ m = re.match(r"\s*-\s*([A-Za-z0-9_.-]+)(?:[=<>!].*)?$", line)
310
+ if m:
311
+ name = m.group(1)
312
+ if name.lower() not in {"python", "pip", "setuptools", "wheel"}:
313
+ out.add(name)
314
+ return out
315
+
316
+
317
+ def discover_candidates(commit: Commit) -> dict[str, Candidate]: # noqa: C901
318
+ """Discover packaging roots and requirement/conda files across the repo at this commit."""
319
+
320
+ def predicate(rel: str) -> bool:
321
+ base = rel.rsplit("/", 1)[-1]
322
+ if base in (PYPROJECT, SETUP_CFG, SETUP_PY):
323
+ return True
324
+ if base in ENV_YML_NAMES:
325
+ return True
326
+ return bool(REQ_TXT_REGEX.search(rel))
327
+
328
+ blob_map = materialize_blobs(commit, predicate, out_dirname="_pkg_blobs")
329
+ candidates: dict[str, Candidate] = {}
330
+
331
+ def ensure_candidate(root_rel: str) -> Candidate:
332
+ if root_rel not in candidates:
333
+ candidates[root_rel] = Candidate(root_relpath=root_rel)
334
+ return candidates[root_rel]
335
+
336
+ for rel, local_path in blob_map.items():
337
+ root = str(Path(rel).parent or ".")
338
+ cand = ensure_candidate(root)
339
+ name = local_path.name
340
+ if name == PYPROJECT:
341
+ cand.pyproject_path = local_path
342
+ elif name == SETUP_CFG:
343
+ cand.setup_cfg_path = local_path
344
+ elif name == SETUP_PY:
345
+ cand.setup_py_path = local_path
346
+ elif REQ_TXT_REGEX.search(rel):
347
+ cand.req_files.append(local_path)
348
+ elif name in ENV_YML_NAMES:
349
+ cand.env_yamls.append(local_path)
350
+
351
+ return candidates
352
+
353
+
354
+ def analyze_candidate_meta(cand: Candidate) -> CandidateMeta:
355
+ """Analyze a candidate to extract metadata from its packaging files."""
356
+ meta = CandidateMeta()
357
+ if cand.pyproject_path and cand.pyproject_path.exists():
358
+ meta = parse_pyproject(cand.pyproject_path)
359
+ if cand.setup_cfg_path and cand.setup_cfg_path.exists():
360
+ m2 = parse_setup_cfg(cand.setup_cfg_path)
361
+ meta.name = meta.name or m2.name
362
+ meta.version = meta.version or m2.version
363
+ meta.requires_python = meta.requires_python or m2.requires_python
364
+ meta.core_deps.update(m2.core_deps)
365
+ for k, v in m2.extras.items():
366
+ meta.extras.setdefault(k, set()).update(v)
367
+ if cand.setup_py_path and cand.setup_py_path.exists():
368
+ m3 = parse_setup_py(cand.setup_py_path)
369
+ meta.name = meta.name or m3.name
370
+ meta.version = meta.version or m3.version
371
+ meta.requires_python = meta.requires_python or m3.requires_python
372
+ meta.core_deps.update(m3.core_deps)
373
+ for k, v in m3.extras.items():
374
+ meta.extras.setdefault(k, set()).update(v)
375
+ meta.build_requires.update(m3.build_requires)
376
+ for req in cand.req_files:
377
+ meta.core_deps.update(parse_requirements_txt(req))
378
+ for y in cand.env_yamls:
379
+ meta.core_deps.update(parse_conda_env_yaml(y))
380
+ return meta
381
+
382
+
383
+ def select_primary_candidate( # noqa: C901
384
+ repo_name: str, candidates: dict[str, Candidate], install_cmds: set[str], analyzed: dict[str, CandidateMeta]
385
+ ) -> str:
386
+ """Heuristic to select the primary package root from multiple candidates."""
387
+ norm = lambda p: str(Path(p).as_posix().strip("./")) or "."
388
+ paths = []
389
+ for cmd in install_cmds:
390
+ toks = shlex.split(cmd)
391
+ for t in toks:
392
+ base = t.split("[", 1)[0]
393
+ if base.startswith((".", "/")) or "/" in base or base in (".",):
394
+ paths.append(norm(base))
395
+ for p in paths:
396
+ if p in candidates:
397
+ return p
398
+ if len(candidates) == 1:
399
+ return next(iter(candidates.keys()))
400
+ repo_suffix = repo_name.split("/", 1)[-1].lower().replace("_", "-")
401
+ by_name = []
402
+ for root, meta in analyzed.items():
403
+ if meta.name:
404
+ nm = meta.name.lower().replace("_", "-")
405
+ if nm == repo_suffix or nm == repo_suffix.replace("-", ""):
406
+ by_name.append(root)
407
+ if by_name:
408
+ return by_name[0]
409
+ for root, cand in candidates.items():
410
+ if cand.pyproject_path:
411
+ return root
412
+ return sorted(candidates.keys(), key=lambda s: (len(Path(s).parts), s))[0]
@@ -0,0 +1,41 @@
1
+ """Data models for dependency resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class Candidate:
11
+ """Represents a potential package root in a repository."""
12
+
13
+ root_relpath: str
14
+ pyproject_path: Path | None = None
15
+ setup_cfg_path: Path | None = None
16
+ setup_py_path: Path | None = None
17
+ req_files: list[Path] = field(default_factory=list)
18
+ env_yamls: list[Path] = field(default_factory=list) # environment.yml/.yaml
19
+
20
+
21
+ @dataclass
22
+ class CandidateMeta:
23
+ """Metadata extracted from packaging files."""
24
+
25
+ name: str | None = None # PyPI name
26
+ version: str | None = None
27
+ import_name: str | None = None # importable module (when we can guess)
28
+ requires_python: str | None = None
29
+ core_deps: set[str] = field(default_factory=set) # runtime
30
+ extras: dict[str, set[str]] = field(default_factory=dict)
31
+ build_requires: set[str] = field(default_factory=set) # [build-system].requires
32
+
33
+
34
+ @dataclass
35
+ class ASVCfgAggregate:
36
+ """Aggregated configuration from ASV benchmark config files."""
37
+
38
+ pythons: set[tuple[int, ...]] = field(default_factory=set)
39
+ build_commands: set[str] = field(default_factory=set)
40
+ install_commands: set[str] = field(default_factory=set)
41
+ matrix: dict[str, set[str]] = field(default_factory=dict)