diff --git a/graphify/detect.py b/graphify/detect.py index c2638a0ef..42008a55d 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -442,6 +442,14 @@ def classify_file(path: Path) -> FileType | None: from graphify.manifest_ingest import is_package_manifest_path if is_package_manifest_path(path): return FileType.CODE + # Kubernetes / ArgoCD / External-Secrets manifests are deterministic YAML, so + # they take the AST path too. Sniffed by CONTENT (apiVersion + kind), never by + # extension: .yaml stays a DOC extension so ordinary YAML (CI configs, docs + # data) keeps going to the LLM path. Runs after _is_sensitive() in detect(), + # so a secrets.yaml is dropped before it can reach this branch. + from graphify.extractors.k8s import is_k8s_manifest_path + if is_k8s_manifest_path(path): + return FileType.CODE # Compound extensions must be checked before simple suffix lookup if path.name.lower().endswith(".blade.php"): return FileType.CODE diff --git a/graphify/extract.py b/graphify/extract.py index a18a9b1c3..893548fb3 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -43,6 +43,7 @@ from graphify.extractors.fortran import _cpp_preprocess, extract_fortran # noqa: F401 from graphify.extractors.go import extract_go # noqa: F401 from graphify.extractors.json_config import extract_json # noqa: F401 +from graphify.extractors.k8s import extract_k8s # noqa: F401 from graphify.extractors.markdown import extract_markdown # noqa: F401 from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form # noqa: F401 from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 @@ -3923,6 +3924,10 @@ def add_existing_edge(edge: dict) -> None: ".tf": extract_terraform, ".tfvars": extract_terraform, ".hcl": extract_terraform, + # Only reached for YAML that detect.classify_file sniffed as a k8s manifest + # (apiVersion + kind); ordinary .yaml stays a document on the LLM path. + ".yaml": extract_k8s, + ".yml": extract_k8s, ".dm": extract_dm, ".dme": extract_dm, ".dmi": extract_dmi, @@ -3952,6 +3957,8 @@ def add_existing_edge(edge: dict) -> None: ".hcl": "terraform", ".dm": "dm", ".dme": "dm", + ".yaml": "k8s", + ".yml": "k8s", } diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..7d4c0aa70 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -20,6 +20,7 @@ from graphify.extractors.go import extract_go from graphify.extractors.json_config import extract_json from graphify.extractors.julia import extract_julia +from graphify.extractors.k8s import extract_k8s from graphify.extractors.markdown import extract_markdown from graphify.extractors.objc import extract_objc from graphify.extractors.pascal import extract_pascal @@ -48,6 +49,7 @@ "go": extract_go, "json": extract_json, "julia": extract_julia, + "k8s": extract_k8s, "lazarus_form": extract_lazarus_form, "markdown": extract_markdown, "objc": extract_objc, diff --git a/graphify/extractors/k8s.py b/graphify/extractors/k8s.py new file mode 100644 index 000000000..57c67cf62 --- /dev/null +++ b/graphify/extractors/k8s.py @@ -0,0 +1,284 @@ +"""Kubernetes / Helm-values / ArgoCD-GitOps YAML extractor. + +Covers the manifests that are VALID YAML: plain k8s objects, ArgoCD +Application/ApplicationSet/AppProject, External Secrets, and Helm `values*.yaml`. +Helm *templates* (charts/*/templates/*.yaml) are deliberately out of scope — Go +template directives (`{{- if .Values.x }}`) make them unparseable as YAML, so +they are left to a later pass rather than parsed lossily here. + +Known gaps (deliberate, so the graph does not overstate what it knows): + +- An ApplicationSet's git generator yields a `generates_from` edge to the glob + it scans (`gitops/apps/*.yaml`), NOT to the files that glob matches. Resolving + it would mean globbing the filesystem from inside a single-file extractor, + which breaks per-file caching and determinism. The glob edge still shows which + registry each environment reads — the fan-out is one `ls` away. +- Registry-style YAML that carries neither `apiVersion` nor `kind` (an ArgoCD + fleet entry is often just `app: `) is indistinguishable from arbitrary + config, so it stays on the document path. +- Objects are addressed `.` globally, so same-named objects in + different clusters or namespaces (one `AppProject.platform` per environment) + collapse into a single node. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.base import _make_id + +# A k8s/ArgoCD manifest is identified by content, not by extension: `.yaml` is a +# DOC extension and must STAY one for ordinary YAML (CI configs, front-matter, +# docs data), which belongs on the LLM path. Only files carrying both +# `apiVersion:` and `kind:` at the start of a line are rerouted to the AST path. +# Mirrors manifest_ingest.is_package_manifest_path, which routes package +# manifests (apm.yml, pyproject.toml) to CODE for the same reason (#1377). +_APIVERSION_RE = re.compile(r"^apiVersion:\s*\S", re.MULTILINE) +_KIND_RE = re.compile(r"^kind:\s*\S", re.MULTILINE) + +# Only the head of the file is sniffed — detect() calls this for every YAML in +# the corpus, so it must stay cheap. A manifest declares both keys in its first +# document; 8 KiB clears even a heavily commented one. +_SNIFF_BYTES = 8192 + + +def is_k8s_manifest_path(path: Path) -> bool: + """True if `path` is a YAML file whose content looks like a k8s manifest.""" + if path.suffix.lower() not in (".yaml", ".yml"): + return False + try: + with path.open("r", encoding="utf-8", errors="replace") as fh: + head = fh.read(_SNIFF_BYTES) + except OSError: + return False + return bool(_APIVERSION_RE.search(head) and _KIND_RE.search(head)) + +# Injected into every mapping by _LineLoader so nodes get a real source_location. +# Skipped during the walk so it never looks like a reference field. +_LINE_KEY = "__graphify_line__" + +# Scalar-valued reference fields: `key: ` -> an object of a fixed kind. +# Bare `name:` is deliberately absent — it identifies the enclosing object +# rather than referencing another one, and treating it as a ref would make +# every manifest point at itself. +_SCALAR_REFS: dict[str, tuple[str, str]] = { + "project": ("AppProject", "references"), + "serviceAccountName": ("ServiceAccount", "references"), + "secretName": ("Secret", "references"), + "claimName": ("PersistentVolumeClaim", "references"), + "ingressClassName": ("IngressClass", "references"), + "storageClassName": ("StorageClass", "references"), + "priorityClassName": ("PriorityClass", "references"), +} + +# Mapping-valued reference fields: `key: {name: }`, optionally carrying its +# own `kind:` (secretStoreRef/storeRef pick ClusterSecretStore vs SecretStore +# that way). A None default means "read the kind from the mapping itself". +_MAPPING_REFS: dict[str, tuple[str | None, str]] = { + "secretRef": ("Secret", "references"), + "secretKeyRef": ("Secret", "references"), + "configMapRef": ("ConfigMap", "references"), + "configMapKeyRef": ("ConfigMap", "references"), + "serviceAccountRef": ("ServiceAccount", "references"), + "secretStoreRef": (None, "references"), + "storeRef": (None, "references"), +} + +def _line_loader(): + """SafeLoader that records each mapping's 1-based start line. + + PyYAML drops position information during construction, so without this every + node would have to share the file's L1. Constructed lazily because pyyaml is + an optional extra. + """ + import yaml + + class _LineLoader(yaml.SafeLoader): + pass + + def _construct_mapping(loader, node, deep=False): + mapping = yaml.SafeLoader.construct_mapping(loader, node, deep=deep) + mapping[_LINE_KEY] = node.start_mark.line + 1 + return mapping + + _LineLoader.add_constructor( + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping + ) + return _LineLoader + + +def extract_k8s(path: Path) -> dict: + """Extract Kubernetes/ArgoCD objects and the references between them. + + Nodes: one per YAML document carrying `apiVersion` + `kind`, addressed + `.` (e.g. `Application.web-api-dev`), plus the + file itself and referenced-but-undeclared objects (a `project: platform` in one + file resolves to the `AppProject.platform` declared in another). + + Edges: `contains` (file -> object), `references` (the _SCALAR_REFS / + _MAPPING_REFS table), `in_namespace`, `deploys_to` (Application -> + destination namespace), `deploys_chart` (Application -> spec.source.path), + `generates_from` (ApplicationSet -> its git generator's files/directories + globs) and `produces` (ExternalSecret -> the Secret it materializes). + + Node IDs are scoped GLOBALLY by `.`, not per-file or per- + directory, because that is how Kubernetes itself resolves a reference: an + ApplicationSet's `project: platform` means the AppProject named `platform`, + wherever it is declared. Global scoping is what lets those cross-file edges + survive the per-file extraction merge. Known limitation: same-named objects + in different clusters or namespaces (three `AppProject.platform` files, one + per env) collapse into one node. + + Helm templates are NOT handled — see the module docstring. + """ + try: + import yaml + except ImportError: + return {"nodes": [], "edges": [], "error": "pyyaml not installed. Run: pip install 'graphifyy[k8s]'"} + + try: + loader = _line_loader() + source = path.read_text(encoding="utf-8", errors="replace") + docs = list(yaml.load_all(source, Loader=loader)) + except Exception as e: + # Helm templates and other non-YAML land here; an unparseable file + # yields nothing rather than aborting the whole extraction run. + return {"nodes": [], "edges": [], "error": str(e)} + + str_path = str(path) + file_nid = _make_id(str_path) + + nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code", + "source_file": str_path, "source_location": None}] + edges: list[dict] = [] + seen_ids: set[str] = {file_nid} + seen_edges: set[tuple[str, str, str]] = set() + + def _addr_id(address: str) -> str: + return _make_id("k8s", address) + + def _add_node(address: str, line: int, *, declared: bool) -> str: + """Add (or reuse) a node for `address`. `declared` marks a real + definition in this file, which also earns a `contains` edge from it; + a reference-only target gets a node so the edge has somewhere to land, + and the file that declares it will fill in the location on merge.""" + nid = _addr_id(address) + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({"id": nid, "label": address, "file_type": "code", + "source_file": str_path if declared else "", + "source_location": f"L{line}" if declared else None}) + if declared: + _add_edge(file_nid, address, "contains", line) + return nid + + def _add_edge(src: str, address: str, relation: str, line: int) -> None: + tgt = _addr_id(address) + if src == tgt: + return + key = (src, tgt, relation) + if key in seen_edges: + return + seen_edges.add(key) + edges.append({"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0}) + + def _ref(owner: str, kind: str, name, relation: str, line: int) -> None: + if not isinstance(name, str) or not name.strip(): + return + address = f"{kind}.{name.strip()}" + _add_node(address, line, declared=False) + _add_edge(owner, address, relation, line) + + def _line_of(mapping, fallback: int) -> int: + if isinstance(mapping, dict): + got = mapping.get(_LINE_KEY) + if isinstance(got, int): + return got + return fallback + + def _walk(value, owner: str, line: int) -> None: + """Recursively scan a document for reference fields. + + Depth-agnostic on purpose: an ApplicationSet nests a whole Application + spec under `spec.template.spec`, so matching on key NAME rather than a + fixed path picks those up with no ApplicationSet-specific code (DRY). + """ + if isinstance(value, list): + for item in value: + _walk(item, owner, line) + return + if not isinstance(value, dict): + return + + here = _line_of(value, line) + + for key, val in value.items(): + if key == _LINE_KEY: + continue + kline = _line_of(val, here) + + if key in _SCALAR_REFS and isinstance(val, str): + kind, relation = _SCALAR_REFS[key] + _ref(owner, kind, val, relation, kline) + elif key in _MAPPING_REFS and isinstance(val, dict): + kind, relation = _MAPPING_REFS[key] + _ref(owner, kind or val.get("kind", "Secret"), val.get("name"), relation, kline) + elif key == "destination" and isinstance(val, dict): + # ArgoCD Application/ApplicationSet target cluster+namespace. + _ref(owner, "Namespace", val.get("namespace"), "deploys_to", kline) + elif key in ("source", "sources") and isinstance(val, (dict, list)): + for src in (val if isinstance(val, list) else [val]): + if isinstance(src, dict) and isinstance(src.get("path"), str): + _path_ref(owner, src["path"], "deploys_chart", _line_of(src, kline)) + elif key == "git" and isinstance(val, dict): + # ApplicationSet git generator — `files:`/`directories:` globs are + # the registry an AppSet fans out over. This is the edge that + # makes "which apps reach prod?" answerable. + for bucket in ("files", "directories"): + for entry in val.get(bucket) or []: + if isinstance(entry, dict) and isinstance(entry.get("path"), str): + _path_ref(owner, entry["path"], "generates_from", _line_of(entry, kline)) + elif key == "target" and isinstance(val, dict) and isinstance(val.get("name"), str): + # ExternalSecret materializes a Secret of this name. + _ref(owner, "Secret", val["name"], "produces", kline) + + _walk(val, owner, kline) + + def _path_ref(owner: str, raw: str, relation: str, line: int) -> None: + """Reference to a repo path (a chart dir, or a generator glob). + + Kept as its own `path.` address rather than resolved to a file + node: a generator path is usually a glob (`gitops/apps/*.yaml`) and a + chart path is often templated (`charts/{{.app}}`), so neither maps to a + single real file. Naming them keeps the relationship queryable. + """ + raw = raw.strip() + if not raw: + return + address = f"path.{raw}" + _add_node(address, line, declared=False) + _add_edge(owner, address, relation, line) + + for doc in docs: + if not isinstance(doc, dict): + continue + kind = doc.get("kind") + meta = doc.get("metadata") + if not isinstance(kind, str) or not kind.strip() or not doc.get("apiVersion"): + continue + name = meta.get("name") if isinstance(meta, dict) else None + if not isinstance(name, str) or not name.strip(): + continue + + line = _line_of(doc, 1) + address = f"{kind.strip()}.{name.strip()}" + owner = _add_node(address, line, declared=True) + + if isinstance(meta, dict) and isinstance(meta.get("namespace"), str): + _ref(owner, "Namespace", meta["namespace"], "in_namespace", _line_of(meta, line)) + + _walk(doc.get("spec"), owner, line) + + return {"nodes": nodes, "edges": edges} diff --git a/pyproject.toml b/pyproject.toml index f64ae84f0..209759863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,12 @@ gemini = ["openai", "tiktoken"] openai = ["openai", "tiktoken"] chinese = ["jieba"] sql = ["tree-sitter-sql"] +# extract_k8s() parses Kubernetes/ArgoCD manifests and Helm values with PyYAML. +# Optional (like sql/terraform above): without it those files are detected and +# skipped with the standard missing-extra warning rather than failing the run. +k8s = [ + "pyyaml>=6.0", +] # extract_pascal() uses tree-sitter-pascal for AST-quality extraction (more # accurate calls/inherits edges) and falls back to a regex extractor when it is # absent (#781), so this stays optional. Unlike tree-sitter-dm below, it ships @@ -80,7 +86,7 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] +all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "pyyaml>=6.0"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/test_extractors_registry.py b/tests/test_extractors_registry.py index db647201f..4802cdb86 100644 --- a/tests/test_extractors_registry.py +++ b/tests/test_extractors_registry.py @@ -42,3 +42,12 @@ def test_terraform_migrated(): assert facade.extract_terraform is extract_terraform assert LANGUAGE_EXTRACTORS["terraform"] is extract_terraform + + +def test_k8s_registered(): + # extract_k8s is a NEW extractor (not a migration), but it obeys the same + # contract: one object, reachable identically via the facade and registry. + from graphify.extractors.k8s import extract_k8s + + assert facade.extract_k8s is extract_k8s + assert LANGUAGE_EXTRACTORS["k8s"] is extract_k8s diff --git a/tests/test_k8s.py b/tests/test_k8s.py new file mode 100644 index 000000000..f0fedb62a --- /dev/null +++ b/tests/test_k8s.py @@ -0,0 +1,197 @@ +"""Tests for the Kubernetes / ArgoCD / Helm-values YAML extractor.""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from graphify.detect import FileType, classify_file +from graphify.extract import extract_k8s +from graphify.extractors.k8s import is_k8s_manifest_path + +pytest.importorskip("yaml", reason="k8s extractor needs the [k8s] extra (pyyaml)") + + +def _write(tmp_path: Path, name: str, body: str) -> Path: + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + return p + + +def _labels(r) -> set[str]: + return {n["label"] for n in r["nodes"]} + + +def _rel_pairs(r, relation: str) -> set[tuple[str, str]]: + lab = {n["id"]: n["label"] for n in r["nodes"]} + return { + (lab.get(e["source"], e["source"]), lab.get(e["target"], e["target"])) + for e in r["edges"] + if e["relation"] == relation + } + + +APPSET = """\ +# leading comment +apiVersion: argoproj.io/v1alpha1 +kind: ApplicationSet +metadata: + name: fleet-apps-prod + namespace: argocd +spec: + generators: + - git: + repoURL: https://github.com/acme/monorepo.git + files: + - path: gitops/apps/*.yaml + template: + spec: + project: platform-prod + source: + path: "charts/{{.app}}" + destination: + namespace: prod-apps +""" + +EXTERNAL_SECRET = """\ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: api-runtime + namespace: dev-apps +spec: + secretStoreRef: + kind: ClusterSecretStore + name: aws-secrets-manager + target: + name: api-runtime-secrets +""" + +DEPLOYMENT = """\ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: api +spec: + template: + spec: + serviceAccountName: api-sa + containers: + - name: api + envFrom: + - configMapRef: + name: api-config + - secretRef: + name: api-runtime-secrets +""" + + +def test_object_becomes_a_kind_dot_name_node(tmp_path): + r = extract_k8s(_write(tmp_path, "appset.yaml", APPSET)) + assert r.get("error") is None + assert "ApplicationSet.fleet-apps-prod" in _labels(r) + assert ("appset.yaml", "ApplicationSet.fleet-apps-prod") in _rel_pairs(r, "contains") + + +def test_git_generator_glob_becomes_generates_from(tmp_path): + # The edge that makes "which apps does this env deploy?" answerable. + r = extract_k8s(_write(tmp_path, "appset.yaml", APPSET)) + assert ("ApplicationSet.fleet-apps-prod", "path.gitops/apps/*.yaml") in _rel_pairs( + r, "generates_from" + ) + + +def test_refs_nested_under_applicationset_template_are_found(tmp_path): + # An ApplicationSet nests a whole Application spec under spec.template.spec. + # The walk matches on key name at any depth, so these need no special case. + r = extract_k8s(_write(tmp_path, "appset.yaml", APPSET)) + assert ("ApplicationSet.fleet-apps-prod", "AppProject.platform-prod") in _rel_pairs( + r, "references" + ) + assert ("ApplicationSet.fleet-apps-prod", "Namespace.prod-apps") in _rel_pairs( + r, "deploys_to" + ) + assert ("ApplicationSet.fleet-apps-prod", "path.charts/{{.app}}") in _rel_pairs( + r, "deploys_chart" + ) + + +def test_external_secret_store_and_produced_secret(tmp_path): + r = extract_k8s(_write(tmp_path, "es.yaml", EXTERNAL_SECRET)) + # kind comes from the secretStoreRef mapping itself, not a hardcoded default. + assert ("ExternalSecret.api-runtime", "ClusterSecretStore.aws-secrets-manager") in _rel_pairs( + r, "references" + ) + assert ("ExternalSecret.api-runtime", "Secret.api-runtime-secrets") in _rel_pairs( + r, "produces" + ) + assert ("ExternalSecret.api-runtime", "Namespace.dev-apps") in _rel_pairs( + r, "in_namespace" + ) + + +def test_workload_config_and_secret_refs(tmp_path): + r = extract_k8s(_write(tmp_path, "deploy.yaml", DEPLOYMENT)) + refs = _rel_pairs(r, "references") + assert ("Deployment.api", "ConfigMap.api-config") in refs + assert ("Deployment.api", "Secret.api-runtime-secrets") in refs + assert ("Deployment.api", "ServiceAccount.api-sa") in refs + + +def test_cross_file_reference_resolves_by_global_address(tmp_path): + # The whole point of global kind.name scoping: an AppProject declared in one + # file is the SAME node the ApplicationSet in another file points at. + proj = """\ +apiVersion: argoproj.io/v1alpha1 +kind: AppProject +metadata: + name: platform-prod +spec: + description: prod +""" + a = extract_k8s(_write(tmp_path, "appset.yaml", APPSET)) + b = extract_k8s(_write(tmp_path, "project.yaml", proj)) + target = next( + e["target"] for e in a["edges"] + if e["relation"] == "references" + ) + declared = next(n["id"] for n in b["nodes"] if n["label"] == "AppProject.platform-prod") + assert target == declared + + +def test_multi_document_file(tmp_path): + r = extract_k8s(_write(tmp_path, "multi.yaml", DEPLOYMENT + "---\n" + EXTERNAL_SECRET)) + assert "Deployment.api" in _labels(r) + assert "ExternalSecret.api-runtime" in _labels(r) + + +def test_source_locations_are_real_lines(tmp_path): + r = extract_k8s(_write(tmp_path, "appset.yaml", APPSET)) + node = next(n for n in r["nodes"] if n["label"] == "ApplicationSet.fleet-apps-prod") + # Line 2 — after the leading comment. Not L1, which is what a loader without + # position tracking would report for everything. + assert node["source_location"] == "L2" + + +def test_helm_template_is_skipped_not_crashed(tmp_path): + # Go template directives make this invalid YAML. It must degrade to an empty + # result with an error string, never raise. + tpl = "{{- if .Values.ingress.enabled }}\nkind: Ingress\n{{- end }}\n" + r = extract_k8s(_write(tmp_path, "ingress.yaml", tpl)) + assert r["nodes"] == [] and r["edges"] == [] + assert r.get("error") + + +def test_plain_yaml_is_not_a_manifest(tmp_path): + # A CI config has `name:`/`on:` but no apiVersion+kind — it must stay a + # document so it keeps going to the LLM path. + ci = _write(tmp_path, "ci.yml", "name: build\non: [push]\njobs:\n a:\n runs-on: ubuntu\n") + assert is_k8s_manifest_path(ci) is False + assert classify_file(ci) is FileType.DOCUMENT + + +def test_manifest_is_sniffed_and_routed_to_code(tmp_path): + manifest = _write(tmp_path, "appset.yaml", APPSET) + assert is_k8s_manifest_path(manifest) is True + assert classify_file(manifest) is FileType.CODE diff --git a/uv.lock b/uv.lock index 088ebbbdc..9f542c99f 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.6" +version = "0.9.20" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1143,6 +1143,7 @@ all = [ { name = "openpyxl" }, { name = "pypdf" }, { name = "python-docx" }, + { name = "pyyaml" }, { name = "starlette" }, { name = "tiktoken" }, { name = "tree-sitter-dm" }, @@ -1174,6 +1175,9 @@ gemini = [ google = [ { name = "openpyxl" }, ] +k8s = [ + { name = "pyyaml" }, +] kimi = [ { name = "openai" }, { name = "tiktoken" }, @@ -1285,6 +1289,8 @@ requires-dist = [ { name = "pypdf", marker = "extra == 'pdf'", specifier = ">=6.12.0" }, { name = "python-docx", marker = "extra == 'all'" }, { name = "python-docx", marker = "extra == 'office'" }, + { name = "pyyaml", marker = "extra == 'all'", specifier = ">=6.0" }, + { name = "pyyaml", marker = "extra == 'k8s'", specifier = ">=6.0" }, { name = "rapidfuzz", specifier = ">=3.0" }, { name = "starlette", marker = "extra == 'all'", specifier = ">=1.3.1" }, { name = "starlette", marker = "extra == 'mcp'", specifier = ">=1.3.1" }, @@ -1331,7 +1337,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "k8s", "pascal", "dm", "terraform", "all"] [package.metadata.requires-dev] dev = [