Este script foi desenvolvido para otimizar as definições YAML do kicad-footprint-generator.
Ele analisa um arquivo YAML de footprint, encontra valores que são idênticos na maioria das entradas e os move para um único elemento base compartilhado. Cada footprint individual então mantém apenas os parâmetros que realmente diferem da base, o que mantém o arquivo mais curto e reduz a duplicação.
Por exemplo, dada uma entrada mínima como esta:
original.yaml
Vertical:
pad_width: 1.0
pad_height: 2.0
solder_mask_margin: 0.05
Horizontal:
pad_width: 1.0
pad_height: 2.0
solder_mask_margin: 0.05
special_option: trueo script pode extrair os valores comuns em uma base e reescrever o arquivo aproximadamente assim:
processed.yaml
base: &base
pad_width: 1.0
pad_height: 2.0
solder_mask_margin: 0.05
Vertical:
<<: *base
Horizontal:
<<: *base
special_option: trueIsso é essencialmente o que ele faz para os YAMLs muito maiores do gerador KiCad.
yaml_extract_common_base.py
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 Uli Köhler <gitlab@techoverflow.net>
# SPDX-License-Identifier: CC0-1.0
"""Extrair e consolidar valores comuns de arquivos YAML do gerador KiCad."""
from __future__ import annotations
import argparse
import copy
import importlib
import io
import os
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
try:
_ruamel_yaml = importlib.import_module("ruamel.yaml")
_ruamel_comments = importlib.import_module("ruamel.yaml.comments")
except ImportError as exc: # pragma: no cover - dependência ausente em tempo de execução
raise SystemExit(
"O pacote ruamel.yaml é necessário. Instale-o via 'pip install ruamel.yaml'."
) from exc
YAML = _ruamel_yaml.YAML
CommentedMap = _ruamel_comments.CommentedMap
CommentedSeq = _ruamel_comments.CommentedSeq
BASE_COMMENT_MARKER = "Base configuration extracted by YAMLCommon"
RT_YAML = YAML(typ="rt")
RT_YAML.preserve_quotes = True
RT_YAML.allow_duplicate_keys = True
RT_YAML.width = 4096
RT_YAML.indent(mapping=2, sequence=4, offset=2)
@dataclass
class Entry:
"""Representa uma única entrada de footprint no arquivo YAML personalizado."""
leading_lines: List[str]
separator_lines: List[str]
name_line: Optional[str]
after_name_lines: List[str]
root_key: str
inner_map: CommentedMap
outer_map: CommentedMap
inherits_base: bool = False
defines_anchor: bool = False
@dataclass
class ParsedFile:
"""Mantém as partes estruturais do arquivo analisado."""
prefix_lines: List[str]
entries: List[Entry]
suffix_lines: List[str]
existing_base: Optional[CommentedMap] = None
def is_block_start(line: str) -> bool:
"""Retorna True se *line* marca o início de um bloco de footprint."""
stripped = line.strip()
if not stripped or line.startswith(" "):
return False
if ":" not in stripped:
return False
key = stripped.split(":", 1)[0].strip()
return bool(key)
def is_name_line(line: str) -> bool:
"""Heurística que reconhece a linha do nome do footprint."""
stripped = line.strip()
if not stripped or ":" in stripped:
return False
if stripped.startswith("#") or stripped.startswith("-"):
return False
return line.startswith(" ")
def parse_file(text: str, anchor_name: str) -> ParsedFile:
"""Analisa o arquivo YAML não padrão em entradas estruturadas."""
lines = text.splitlines(keepends=True)
idx = 0
prefix_lines: List[str] = []
suffix_lines: List[str] = []
entries: List[Entry] = []
first_block = True
while True:
interim: List[str] = []
while idx < len(lines) and not is_block_start(lines[idx]):
interim.append(lines[idx])
idx += 1
if idx >= len(lines):
if first_block:
prefix_lines = interim
else:
suffix_lines = interim
break
if first_block:
prefix_lines = interim
leading_lines: List[str] = []
first_block = False
else:
leading_lines = interim
block_lines: List[str] = []
while idx < len(lines) and lines[idx].strip() != "":
block_lines.append(lines[idx])
idx += 1
defines_anchor = any(f"&{anchor_name}" in line for line in block_lines)
block_lines, inherits_base = strip_merge_marker(block_lines, anchor_name)
block_text = "".join(block_lines)
if not block_text.strip():
raise ValueError("Bloco vazio encontrado; o formato do arquivo pode não ser suportado.")
separator_lines: List[str] = []
while idx < len(lines) and lines[idx].strip() == "":
separator_lines.append(lines[idx])
idx += 1
while idx < len(lines) and lines[idx].lstrip().startswith("#"):
separator_lines.append(lines[idx])
idx += 1
name_line: Optional[str] = None
if idx < len(lines) and is_name_line(lines[idx]):
name_line = lines[idx]
idx += 1
after_name_lines: List[str] = []
while idx < len(lines) and not is_block_start(lines[idx]):
after_name_lines.append(lines[idx])
idx += 1
data = RT_YAML.load(block_text)
if not isinstance(data, CommentedMap) or len(data) != 1:
raise ValueError("Cada bloco deve ser um mapeamento com uma única chave raiz (ex.: Vertical).")
root_key = next(iter(data))
inner_map = data[root_key]
if not isinstance(inner_map, CommentedMap):
raise ValueError("Entradas de nível superior devem mapear para um dicionário de parâmetros.")
entries.append(
Entry(
leading_lines=leading_lines,
separator_lines=separator_lines,
name_line=name_line,
after_name_lines=after_name_lines,
root_key=root_key,
inner_map=inner_map,
outer_map=data,
inherits_base=inherits_base,
defines_anchor=defines_anchor,
)
)
return ParsedFile(prefix_lines=prefix_lines, entries=entries, suffix_lines=suffix_lines)
def strip_merge_marker(block_lines: List[str], anchor_name: str) -> tuple[List[str], bool]:
"""Remove referências de mesclagem à âncora compartilhada de um bloco YAML."""
filtered: List[str] = []
removed = False
target = f"<<: *{anchor_name}"
for line in block_lines:
if line.strip() == target:
removed = True
continue
filtered.append(line)
return filtered, removed
def strip_generated_base(parsed: ParsedFile, anchor_name: str) -> ParsedFile:
"""Remove qualquer bloco base gerado anteriormente e o comentário associado."""
clean_prefix = [line for line in parsed.prefix_lines if BASE_COMMENT_MARKER not in line]
filtered_entries: List[Entry] = []
base_map: Optional[CommentedMap] = parsed.existing_base
for entry in parsed.entries:
if entry.defines_anchor:
base_map = entry.inner_map
continue
filtered_entries.append(entry)
return ParsedFile(
prefix_lines=clean_prefix,
entries=filtered_entries,
suffix_lines=parsed.suffix_lines,
existing_base=base_map,
)
def ensure_base_inheritance(parsed: ParsedFile) -> None:
"""Marca entradas como herdando da base se elas não possuem chaves da base."""
if not parsed.existing_base:
return
for entry in parsed.entries:
if entry.inherits_base:
continue
if not entry_supports_base(entry.inner_map, parsed.existing_base):
entry.inherits_base = True
def normalize(value):
"""Converte nós ruamel em objetos Python simples para verificações de igualdade."""
if isinstance(value, CommentedMap):
return {k: normalize(v) for k, v in value.items()}
if isinstance(value, CommentedSeq):
return [normalize(v) for v in value]
return value
def merged_with_base(base_map: CommentedMap, overrides: CommentedMap) -> CommentedMap:
"""Retorna uma cópia profunda de *base_map* atualizada com *overrides*."""
result = copy.deepcopy(base_map)
for key, value in overrides.items():
if (
key in result
and isinstance(result[key], CommentedMap)
and isinstance(value, CommentedMap)
):
result[key] = merged_with_base(result[key], value)
else:
result[key] = copy.deepcopy(value)
return result
def values_equal(lhs, rhs) -> bool:
"""Igualdade profunda que ignora metadados específicos do ruamel."""
return normalize(lhs) == normalize(rhs)
def _round_number(value, digits: Optional[int]):
"""Arredonda o valor numérico *value* para *digits* casas decimais, se solicitado.
Se *digits* for None, o valor é retornado inalterado. Valores não numéricos
também são retornados inalterados.
"""
if digits is None:
return value
try:
# Trata ints/floats deixando outros tipos inalterados
if isinstance(value, (int, float)):
return round(value, digits)
except TypeError:
pass
return value
def _round_node(node, digits: Optional[int]):
"""Arredonda recursivamente todos os escalares numéricos dentro de *node* in-place."""
if digits is None:
return node
if isinstance(node, CommentedMap):
for key, val in list(node.items()):
node[key] = _round_node(val, digits)
return node
if isinstance(node, CommentedSeq):
for idx, val in enumerate(list(node)):
node[idx] = _round_node(val, digits)
return node
return _round_number(node, digits)
def collect_common(
entries: List[Entry],
threshold: float,
fallback_base: Optional[CommentedMap] = None,
) -> CommentedMap:
"""Retorna um mapeamento com chaves que são comuns entre as entradas."""
total = len(entries)
result = CommentedMap()
if total == 0:
return result
occurrences: dict[str, List[object]] = {}
for entry in entries:
items = entry.inner_map.items()
if entry.inherits_base and fallback_base is not None:
combined = merged_with_base(fallback_base, entry.inner_map)
items = combined.items()
for key, value in items:
occurrences.setdefault(key, []).append(value)
for key, values in occurrences.items():
ratio = len(values) / total
if ratio < threshold:
continue
first = values[0]
if all(values_equal(first, other) for other in values[1:]):
result[key] = copy.deepcopy(first)
return result
def entry_supports_base(entry_map: CommentedMap, base_map: CommentedMap) -> bool:
"""Verifica se *entry_map* contém todas as chaves encontradas em *base_map*."""
for key, base_value in base_map.items():
if key not in entry_map:
return False
entry_value = entry_map[key]
if isinstance(base_value, CommentedMap) and isinstance(entry_value, CommentedMap):
if not entry_supports_base(entry_value, base_value):
return False
elif isinstance(base_value, CommentedSeq) and isinstance(entry_value, CommentedSeq):
if not values_equal(entry_value, base_value):
return False
else:
if not values_equal(entry_value, base_value):
return False
return True
def build_overrides(entry_map: CommentedMap, base_map: CommentedMap) -> CommentedMap:
"""Retorna o subconjunto de *entry_map* que difere de *base_map*."""
overrides = CommentedMap()
for key, entry_value in entry_map.items():
if key not in base_map:
overrides[key] = copy.deepcopy(entry_value)
continue
base_value = base_map[key]
if isinstance(entry_value, CommentedMap) and isinstance(base_value, CommentedMap):
child = build_overrides(entry_value, base_value)
if child:
overrides[key] = child
continue
if isinstance(entry_value, CommentedSeq) and isinstance(base_value, CommentedSeq):
if not values_equal(entry_value, base_value):
overrides[key] = copy.deepcopy(entry_value)
continue
if not values_equal(entry_value, base_value):
overrides[key] = copy.deepcopy(entry_value)
return overrides
def dump_map(node: CommentedMap) -> str:
"""Renderiza *node* de volta para texto YAML."""
buffer = io.StringIO()
RT_YAML.dump(node, buffer)
return buffer.getvalue()
def _supports_color(stream) -> bool:
"""Retorna True se *stream* provavelmente suporta códigos de cor ANSI."""
return bool(getattr(stream, "isatty", lambda: False)()) and os.environ.get("TERM") not in {None, "", "dumb"}
def _colorize(text: str, color_code: str = "31") -> str:
"""Envolve *text* em códigos de cor ANSI se o stderr atual os suportar."""
if _supports_color(sys.stderr):
return f"\033[{color_code}m{text}\033[0m"
return text
def inject_merge_line(rendered: str, anchor_name: str, indent: str = " ") -> str:
"""Insere uma referência de mesclagem YAML logo após a chave raiz."""
lines = rendered.splitlines(keepends=True)
if not lines:
return f"{indent}<<: *{anchor_name}\n"
if not lines[0].endswith("\n"):
lines[0] += "\n"
merge_line = f"{indent}<<: *{anchor_name}\n"
lines.insert(1, merge_line)
return "".join(lines)
def render_with_merge(root_key: str, overrides: CommentedMap, anchor_name: str) -> str:
"""Renderiza um mapa de entrada precedido por uma referência de mesclagem."""
if overrides:
container = CommentedMap()
container[root_key] = overrides
return inject_merge_line(dump_map(container), anchor_name)
return f"{root_key}:\n <<: *{anchor_name}\n"
def rewrite(
parsed: ParsedFile,
base_map: Optional[CommentedMap],
threshold: float,
anchor_name: str,
round_digits: Optional[int],
target_path: Path,
) -> None:
"""Reescreve o arquivo YAML com um bloco base seguido por sobrescritas por entrada."""
fragments: List[str] = []
fragments.extend(parsed.prefix_lines)
base_inner = None
if base_map and len(base_map):
base_inner = copy.deepcopy(base_map)
# Aplica arredondamento à base compartilhada antes de ancorar, se solicitado.
if round_digits is not None:
_round_node(base_inner, round_digits)
base_inner.yaml_set_anchor(anchor_name, always_dump=True)
# O elemento base deve sempre ser nomeado "base" no
# YAML gerado, independentemente da chave raiz original.
base_container = CommentedMap()
base_container["base"] = base_inner
base_container.yaml_set_start_comment(
f"Base configuration extracted by YAMLCommon (threshold={threshold:.2f})",
indent=0,
)
fragments.append(dump_map(base_container))
fragments.append("\n")
for entry in parsed.entries:
fragments.extend(entry.leading_lines)
# Se temos uma base gerada e a entrada ou suporta explicitamente
# essa base ou foi marcada anteriormente como herdando da base
# (ex.: porque faltavam chaves da base), sempre renderize a entrada
# usando uma referência de mesclagem. Isso garante que toda entrada que
# removeu valores relativos à base ainda seja declarada como derivando dela.
entry_map_for_write = copy.deepcopy(entry.outer_map)
if base_inner and (entry_supports_base(entry.inner_map, base_inner) or entry.inherits_base):
# Constrói sobrescritas relativas à base (já arredondada).
inner_copy = copy.deepcopy(entry.inner_map)
if round_digits is not None:
_round_node(inner_copy, round_digits)
overrides = build_overrides(inner_copy, base_inner)
# Se não há sobrescritas (ou seja, a entrada é idêntica à
# base), ainda assim emite uma mesclagem para que a entrada permaneça presente.
fragments.append(render_with_merge(entry.root_key, overrides, anchor_name))
else:
# Sem base ou não suportada pela base: arredonda todo o mapeamento da entrada.
if round_digits is not None:
_round_node(entry_map_for_write, round_digits)
fragments.append(dump_map(entry_map_for_write))
fragments.extend(entry.separator_lines)
if entry.name_line:
fragments.append(entry.name_line if entry.name_line.endswith("\n") else entry.name_line + "\n")
fragments.extend(entry.after_name_lines)
fragments.extend(parsed.suffix_lines)
target_path.write_text("".join(fragments), encoding="utf-8")
def print_base(base_map: Optional[CommentedMap], threshold: float) -> None:
"""Exibe o elemento base extraído na saída padrão."""
if not base_map:
print(f"Nenhum atributo base atingiu o limite ({threshold:.2f}).")
return
container = CommentedMap()
container["base"] = base_map
print(f"Elemento base extraído (limite={threshold:.2f}):")
sys.stdout.write(dump_map(container))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("yaml_file", type=Path, help="Caminho para o arquivo YAML de footprint")
parser.add_argument(
"-t",
"--threshold",
type=float,
default=0.9,
help="Fração de entradas em que uma chave deve aparecer (padrão: 0.9)",
)
parser.add_argument(
"-r",
"--round",
type=int,
default=None,
help=(
"Arredondar todos os valores numéricos para o número dado de casas "
"decimais antes de escrever a saída."
),
)
parser.add_argument(
"-o",
"--output",
type=Path,
help=(
"Caminho de saída para o arquivo YAML reescrito (padrão: sobrescrever o arquivo de entrada, "
"requer --overwrite)."
),
)
parser.add_argument(
"--overwrite",
action="store_true",
help=(
"Permitir sobrescrever o arquivo YAML original; efetivo apenas quando "
"--output é definido como o caminho do arquivo original."
),
)
parser.add_argument(
"--anchor-name",
default="base",
help="Nome da âncora usado para o mapeamento base compartilhado (padrão: base)",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not 0 < args.threshold <= 1:
raise SystemExit("O limite deve estar dentro de (0, 1].")
text = args.yaml_file.read_text(encoding="utf-8")
parsed = parse_file(text, args.anchor_name)
parsed = strip_generated_base(parsed, args.anchor_name)
ensure_base_inheritance(parsed)
if not parsed.entries:
raise SystemExit("Nenhuma entrada foi encontrada no arquivo fornecido.")
common_map = collect_common(parsed.entries, args.threshold, parsed.existing_base)
if parsed.existing_base:
base_for_write = copy.deepcopy(parsed.existing_base)
if common_map:
additional = build_overrides(common_map, base_for_write)
if additional:
base_for_write = merged_with_base(base_for_write, additional)
else:
base_for_write = copy.deepcopy(common_map) if common_map else None
print_base(base_for_write, args.threshold)
target = args.output or args.yaml_file
write_requires_overwrite = target.resolve() == args.yaml_file.resolve()
if write_requires_overwrite and not args.overwrite:
warning = (
"--overwrite não foi fornecido; o arquivo de entrada foi deixado inalterado e nenhuma saída foi escrita."
)
print(_colorize(warning), file=sys.stderr)
return
rewrite(
parsed,
base_for_write if base_for_write else None,
args.threshold,
args.anchor_name,
args.round,
target,
)
if __name__ == "__main__":
main()
Check out similar posts by category:
Python
If this post helped you, please consider buying me a coffee or donating via PayPal to support research & publishing of new posts on TechOverflow