# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Merge the bundled `[tool.*]` templates into a `pyproject.toml`.
Each {class}`~repomatic.components.ToolConfigComponent` ships its section in
the native format of its tool, under `repomatic/data/`. A missing section is
installed from the template. An existing one is left alone, or re-derived from
the template on every sync while the local additions are grafted back on,
depending on its {class}`~repomatic.components.SyncMode`.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
import tomlrt
from ..bundle import get_data_content
from ..components import (
COMPONENTS,
InitDefault,
SyncMode,
ToolConfigComponent,
)
from ..pyproject import read_pyproject_toml
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Mapping
from typing import Any
PYPROJECT_FMT_INDENT = 2
"""Spaces `pyproject-fmt` puts before each row of a multi-line array, its default.
`_graft_local_additions` lays out a rebuilt multi-line array at this indent.
tomlrt's own fallback is four spaces, which the `format-pyproject` job rewrites
in a pull request of its own.
"""
TOOL_CONFIG_COMPONENTS: dict[str, ToolConfigComponent] = {
comp.name: comp for comp in COMPONENTS if isinstance(comp, ToolConfigComponent)
}
"""The tool config components, by name: what {func}`init_config` accepts."""
_MISSING = object()
"""Sentinel marking a key absent from the template.
A legitimate template value of `None` is then not mistaken for "missing"
during the merge walk.
"""
def _strip_header_comments(native_source: str) -> str:
"""Drop the file-level header block from a native-format template.
Templates open with header comments explaining the standalone format
(`bumpversion.toml` and the others); those do not belong in the `[tool.X]`
section grafted into `pyproject.toml`. Everything below them does, per-key
comments included: they are the only documentation a downstream
`pyproject.toml` ever carries for a key it did not write.
The header is the leading comment run **terminated by a blank line**, and
the blank goes with it. A leading comment run running straight into a key
belongs to that key and is kept, which is what separates the two shapes a
template may open with:
```toml
# Header, blank line below. | # Header, key right below.
| preview = true
# Comment owned by the key. |
default.extend-identifiers = {} |
```
Skipping to the first non-comment line instead would swallow the second
comment along with the header, and leave the first key of the template
undocumented downstream. The blank-line convention is pinned by
`test_native_templates_separate_header`.
```{note}
Use `splitlines(keepends=True)`, not `"\\n".join(splitlines(...))`: the
template's trailing newline carries over to the final KV slot's EOL
trivia, and a synced section that happens to land at end-of-document
needs that newline to survive so the rendered file ends with `\\n`. The
invariant is pinned by `test_update_tool_config_preserves_trailing_newline`.
```
"""
lines = native_source.splitlines(keepends=True)
header_end = 0
while header_end < len(lines) and lines[header_end].lstrip().startswith("#"):
header_end += 1
# No blank line below means the run documents the first key, not the file.
if header_end >= len(lines) or lines[header_end].strip():
return native_source
while header_end < len(lines) and not lines[header_end].strip():
header_end += 1
return "".join(lines[header_end:])
def _template_section(comp: ToolConfigComponent) -> tomlrt.Document:
"""Parse the bundled template of *comp*, without its file-level header.
The template is kept as a parsed `Document`, not lifted via
`Table.section()`: installing or assigning a `Document` routes through
tomlrt's trivia-preserving clone path
([dimbleby/tomlrt#171](https://github.com/dimbleby/tomlrt/issues/171)),
so standalone comments above scalar keys and inline-array bracket pad carry
over with the values.
"""
return tomlrt.loads(_strip_header_comments(get_data_content(comp.source_file)))
def _entry_identity(
entry: Mapping[str, Any], identity_keys: tuple[str, ...]
) -> tuple[tuple[str, Any], ...]:
"""Return the slot identity of an array-of-tables entry.
The identity is the tuple of `(key, value)` pairs for the `identity_keys`
the entry actually carries, so two entries map to the same slot when they
agree on every identity key they share. See
{attr}`~repomatic.components.ToolConfigComponent.graft_identity_keys`.
"""
return tuple((key, entry[key]) for key in identity_keys if key in entry)
def _carry_comments(
source: Any, source_slot: str | int, target: Any, target_slot: str | int
) -> None:
"""Copy the comments beside one slot of *source* onto one slot of *target*.
Both containers are tomlrt tables or arrays. A value grafted from one
arrives bare: the comment block above it and the comment after it belong
to the container's layout, not to the value. tomlrt keeps both in views
keyed by slot, the key of a table entry or the position of an array item.
Writing a comment on an item turns a single-line *target* array into one
item per line, since a comment needs a line of its own.
tomlrt writes an end-of-line comment one space after its value, where
`pyproject-fmt` puts two. {func}`_same_modulo_comment_gaps` keeps that
difference from reading as a change.
"""
block = source.leading_block.get(source_slot)
if block:
target.leading_block[target_slot] = block
trailing = source.comments.get(source_slot)
if trailing:
target.comments[target_slot] = trailing
def _same_modulo_comment_gaps(first: str, second: str) -> bool:
"""Whether two renderings differ at most in the spaces before a `#`.
{func}`_carry_comments` writes an end-of-line comment one space after its
value, and `pyproject-fmt` moves it to two. Compared exactly, a section
carrying such a comment reads as changed on every sync, so the unattended
`sync-repomatic` and `format-pyproject` jobs would each open a pull request
undoing the other's. A `#` inside a string value is normalized too, which
can only hide a change made of nothing but the spaces before that `#`.
"""
gap = re.compile(r"[ \t]+#")
return gap.sub(" #", first).strip() == gap.sub(" #", second).strip()
def _graft_local_additions(
target: Any,
template: Mapping[str, Any],
existing: Mapping[str, Any],
identity_keys: tuple[str, ...] = (),
) -> None:
"""Graft local-only content from an existing section onto a template table.
Walks the *existing* tomlrt section against the *template* (a plain-dict
view) and copies into *target* (the tomlrt table built from the template)
every piece of local configuration the canonical template does not already
carry:
- **Keys the template does not define** are grafted verbatim, so a
project-specific table like `[tool.typos.type.po]` survives untouched.
- **Tables present in both** are merged recursively, so local keys inside a
table the template also defines are kept (like a downstream entry in
`[tool.typos.default.extend-identifiers]` next to the canonical ones).
- **Arrays present in both** gain their local-only items appended after the
template items. Scalar arrays (`extend-ignore-re`, `serialize`) and
arrays-of-tables with no `identity_keys` use a plain union by value.
When `identity_keys` is set, an array-of-tables entry that shares a slot
identity with a template entry (`[[tool.bumpversion.files]]` whose
canonical `search` has evolved) is dropped in favour of the template
instead of appended as a duplicate.
- **Scalars present in both** are left as the template defines them: the
canonical value wins, which is the point of an ongoing sync.
Grafted keys are copied from *existing* as nodes, so the comments and
inline formatting inside them carry over. The comments beside a grafted key
belong to the parent table's layout, so {func}`_carry_comments` copies them
across. A local-only array item is appended as a value instead: its
comments are copied the same way, but its lexeme is re-emitted in tomlrt's
own style. An array the project wrote one item per row keeps that layout, at
{data}`PYPROJECT_FMT_INDENT`. A comment beside a key the template also
defines is not carried: the template's own comment wins, like its value.
:param target: tomlrt table built from the bundled template, mutated in
place.
:param template: Plain-dict view of the bundled template section.
:param existing: tomlrt view of the local section being synced, walked for
structure and used as the source of grafted nodes.
:param identity_keys: Keys identifying an array-of-tables entry's slot. When
set, a local entry sharing a slot with a template entry is dropped in
favour of the template rather than appended as a duplicate.
"""
for key, existing_value in existing.items():
template_value = template.get(key, _MISSING)
if template_value is _MISSING:
# Key the canonical template does not define: keep it verbatim.
target[key] = existing_value
# The section root parses as a `Document`, which is not a `Table`.
containers = (tomlrt.Document, tomlrt.Table)
if isinstance(existing, containers) and isinstance(target, containers):
_carry_comments(existing, key, target, key)
elif isinstance(template_value, dict) and isinstance(existing_value, dict):
# Standard or inline table: recurse so local-only sub-keys survive.
_graft_local_additions(
target[key],
template_value,
existing_value,
identity_keys,
)
elif isinstance(template_value, list) and isinstance(existing_value, list):
# Slots already claimed by the canonical template: a local entry
# mapping to one of these is a (possibly stale) copy of a template
# entry, so the template wins and the local copy is not re-added.
template_slots = (
{
_entry_identity(item, identity_keys)
for item in template_value
if isinstance(item, dict)
}
if identity_keys
else set()
)
# Array in both: append local-only items, preserving their order.
grafted = {
index: item
for index, item in enumerate(existing_value)
# Same slot as a canonical entry: superseded by the template.
if not (
isinstance(item, dict)
and _entry_identity(item, identity_keys) in template_slots
)
and item not in template_value
}
if list(existing_value) == [*template_value, *grafted.values()]:
# The merge changes nothing, so keep the array the project
# wrote instead of rebuilding an equal one. Rebuilding re-emits
# every item in tomlrt's own style, because iterating an array
# yields decoded values rather than the nodes carrying their
# source lexeme: a literal `'base32 "[0-9a-z]{52}"'` comes back
# as an escaped `"base32 \"[0-9a-z]{52}\""`. Same content, and
# `pyproject-fmt` rewrites it straight back, so the unattended
# `sync-repomatic` and `format-pyproject` jobs each open a pull
# request undoing the other's, forever.
target[key] = existing_value
continue
if (
isinstance(existing_value, tomlrt.Array)
and isinstance(target[key], tomlrt.Array)
and existing_value.multiline
and not target[key].multiline
):
# Keep the one-row-per-item layout the project wrote. On one
# line, tomlrt breaks the array into rows itself when a carried
# comment needs a line of its own, but at four spaces.
target[key].set_multiline(multiline=True, indent=PYPROJECT_FMT_INDENT)
for index, item in grafted.items():
target[key].append(item)
if isinstance(existing_value, tomlrt.Array) and isinstance(
target[key], tomlrt.Array
):
_carry_comments(
existing_value, index, target[key], len(target[key]) - 1
)
# Scalar in both: the canonical template value wins, nothing to graft.
def _overlay_owned_keys(existing_section: Any, comp: ToolConfigComponent) -> None:
"""Update only the template's keys in place within an existing section.
The overlay sync for a partial template ({attr}`ToolConfigComponent.overlay`):
the template owns a few policy keys inside an otherwise project-owned
section. Each template key's value is written into the existing section,
updating it in place when present (key position and surrounding trivia
preserved) or appending it when absent. Every other key the project defines
is left untouched, so the merged section keeps its order and stays a
`pyproject-fmt` fixpoint. This is the inverse of the rebuild-and-graft path:
there the template is the base and local keys graft on; here the existing
section is the base and the template overlays its owned keys.
:param existing_section: The live `[tool.X]` table being overlaid, mutated
in place.
:param comp: The component whose owned keys are being synced.
"""
for key, value in _template_section(comp).to_dict().items():
existing_section[key] = value
def _rebuild_section(tool_table: Any, comp: ToolConfigComponent) -> None:
"""Re-derive an existing `[tool.X]` section from the bundled template.
Rebuilds the section from the canonical template, then grafts local-only
configuration back on via {func}`_graft_local_additions`: keys the template
omits, extra items in shared arrays, and extra keys in shared nested
tables. The template wins on shared scalars and on array-of-tables entries
that share a `graft_identity_keys` slot; `comp.preserved_keys` overrides
that for named top-level keys, like `current_version`.
:param tool_table: The `[tool]` table holding the section, mutated in place.
:param comp: The component whose config is being synced.
"""
existing_section = tool_table[comp.tool_name]
new_section = _template_section(comp)
# Graft before assigning: the assignment deep-clones `new_section`, so
# grafted entries must be in place first to carry over with their comments.
_graft_local_additions(
new_section,
new_section.to_dict(),
existing_section,
comp.graft_identity_keys,
)
# `preserved_keys` are authoritative locally: the local value overrides
# the template placeholder.
for key in comp.preserved_keys:
if key in existing_section:
new_section[key] = existing_section[key]
tool_table[comp.tool_name] = new_section
def _update_tool_config(
doc: tomlrt.Document, content: str, comp: ToolConfigComponent
) -> str | None:
"""Re-derive the existing section of *comp* in *doc* from its template.
The ongoing sync: {func}`_rebuild_section` rebuilds the section and grafts
the local additions back on, or, for a partial template
({attr}`~repomatic.components.ToolConfigComponent.overlay`),
{func}`_overlay_owned_keys` updates only the keys the template owns.
:param doc: The parsed `pyproject.toml`, whose `[tool]` table holds the
section. Mutated in place.
:param content: The text *doc* was parsed from, for the no-op check.
:param comp: The component whose section is synced.
:return: The new content, or `None` when the section needs no change.
"""
tool_table = doc["tool"]
if comp.overlay:
_overlay_owned_keys(tool_table[comp.tool_name], comp)
else:
_rebuild_section(tool_table, comp)
modified = tomlrt.dumps(doc)
if _same_modulo_comment_gaps(modified, content):
logging.info(f"[{comp.tool_section}] already up to date.")
return None
logging.info(f"Synced [{comp.tool_section}] from bundled template.")
return modified
[docs]
def init_config(config_type: str, pyproject_path: Path | None = None) -> str | None:
"""Merge the template of one tool config into a `pyproject.toml` file.
Reads the file and hands it to {func}`merge_tool_config`. The file itself is
left alone: the caller writes the returned content.
:param config_type: The component name (like `"ruff"` or `"bumpversion"`).
:param pyproject_path: Path to `pyproject.toml`. Defaults to
`./pyproject.toml`.
:return: The modified `pyproject.toml` content, or `None` when no change is
needed or the file does not exist.
:raises ValueError: If no tool config component carries that name.
"""
comp = TOOL_CONFIG_COMPONENTS.get(config_type)
if comp is None:
supported = ", ".join(TOOL_CONFIG_COMPONENTS)
msg = f"Unknown config type: {config_type!r}. Supported: {supported}"
raise ValueError(msg)
if pyproject_path is None:
pyproject_path = Path("pyproject.toml")
if not pyproject_path.exists():
logging.error(f"File not found: {pyproject_path}")
return None
return merge_tool_config(pyproject_path.read_text(encoding="UTF-8"), comp)
[docs]
def adopted_ongoing_configs(output_dir: Path) -> set[str]:
"""Return the ongoing tool configs whose section `pyproject.toml` already carries.
{attr}`~repomatic.components.InitDefault.EXPLICIT` governs *adoption*, not
upkeep: it keeps a bare `init` from pushing `[tool.typos]` onto a repository
that never asked for one. Once the section is there the repository has
asked, so an {attr}`~repomatic.components.SyncMode.ONGOING` component rejoins
the bare-init set and resumes tracking the bundled template.
Without this the two flags cancel out. The only sync that ever runs
unattended is the bare `init` the `sync-repomatic` job calls, so an ONGOING
section is otherwise re-derived only when a human types its component name,
and a `[tool.typos]` written by hand sits indefinitely beside a bundled
template it never adopts a single rule from.
{attr}`~repomatic.components.SyncMode.BOOTSTRAP` components stay out: their
template is a starting point the repository owns outright after the first
write, and re-selecting one would revert deliberate local edits.
:param output_dir: Repository root holding `pyproject.toml`.
:return: Component names to add to a bare `init` selection. Empty when the
file is absent, unparsable, or carries no `[tool]` table.
"""
tool_table = read_pyproject_toml(output_dir).get("tool", {})
if not tool_table:
return set()
return {
comp.name
for comp in SyncMode.ONGOING.components()
if comp.init_default is InitDefault.EXPLICIT and comp.tool_name in tool_table
}