Source code for repomatic.init.local_edits
# 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.
"""Spot the local edits `repomatic init` overwrites in a skill or agent copy.
`init` replaces each skill and agent copy it manages with the one the running
release bundles. An edit a repository made to its copy, and never sent
upstream, is lost to that rewrite, and the diff shows the loss as routine sync
churn.
A copy holds no local edit when it matches a file that the release the
repository adopted before the run bundled: the release its upstream `uses:`
pins name. The wheel of that release holds every such file, so `init` reads it
from PyPI and compares digests, in the normalized form `init` writes to disk.
"""
from __future__ import annotations
import hashlib
import logging
from dataclasses import dataclass, field
from functools import cached_property
from io import BytesIO
from pathlib import PurePosixPath
from zipfile import BadZipFile, ZipFile
from ..components import RemovedAsset
from ..file_ops import normalize_text
from ..http import FetchError, get_bytes
from ..pypi import release_wheel
from .upgrade import pins_running_release
CHECKED_COMPONENTS = frozenset(("skills", "subagents"))
"""Components whose copies `init` checks for local edits before it overwrites them.
Skills and agents are prose a repository edits by hand. The other bundled files
differ from the shipped ones for other reasons: `labels` is staged for one
command, and the sync jobs bump the pins of `publish-pypi-action` between two
releases.
"""
[docs]
def shipped_digests(package: str, version: str) -> frozenset[str] | None:
"""The digests of the data files one release of *package* bundled.
Reads the wheel PyPI serves for *version*, once its SHA-256 matches the one
PyPI records. Hashes every text file under the `data/` folder of the
package with {meth}`~repomatic.components.RemovedAsset.digest`, which is
the form `init` writes to disk. The folder layout of the release does not
matter, so a wheel that bundled a skill under another path still counts.
:param package: The PyPI package name.
:param version: The bare version of the release.
:return: The digests, or `None` when the wheel could not be read.
"""
wheel = release_wheel(package, version)
if wheel is None:
return None
url, sha256 = wheel
try:
data = get_bytes(url)
except FetchError as error:
logging.debug(f"Could not download {url}: {error}")
return None
if hashlib.sha256(data).hexdigest() != sha256:
logging.warning(f"{url} does not match the SHA-256 PyPI records for it.")
return None
digests: set[str] = set()
try:
with ZipFile(BytesIO(data)) as archive:
for info in archive.infolist():
if info.is_dir():
continue
if PurePosixPath(info.filename).parts[1:2] != ("data",):
continue
try:
text = archive.read(info).decode("UTF-8")
except UnicodeDecodeError:
# A binary asset: no prose to compare.
continue
digests.add(RemovedAsset.digest(text))
except BadZipFile as error:
logging.warning(f"{url} is not a readable wheel: {error}")
return None
return frozenset(digests)
[docs]
@dataclass
class LocalEditGuard:
"""Spot the overwrites of one `init` run that lose a local edit."""
package: str
"""PyPI name of the upstream package, like `repomatic`."""
previous: str
"""Bare version of the release the repository adopted before the run."""
_reported_unread: bool = field(default=False, init=False)
"""Whether {meth}`check` already reported that {attr}`shipped` is unknown."""
[docs]
@cached_property
def shipped(self) -> frozenset[str] | None:
"""The digests of the files {attr}`previous` bundled.
Read on first use, so a run that overwrites no copy reads nothing.
`None` when the wheel could not be read.
"""
return shipped_digests(self.package, self.previous)
[docs]
def check(self, label: str, current: str, new: str) -> str | None:
"""The warning to give when *new* replaces *current*.
:param label: The repository-relative path of the copy.
:param current: The text of the copy on disk.
:param new: The bundled text about to replace it.
:return: A warning when *current* matches no file {attr}`previous`
bundled. One warning for the whole run when that release cannot be
read. `None` otherwise, as for a change of trailing whitespace only.
"""
if normalize_text(current) == normalize_text(new):
return None
# When the repository already pins the running release, that release
# bundled *new* itself, so any other text is a local edit.
if not pins_running_release(f"v{self.previous}"):
shipped = self.shipped
if shipped is None:
if self._reported_unread:
return None
self._reported_unread = True
return (
f"Could not read the {self.package} `{self.previous}` wheel"
" from PyPI, so no overwritten skill or agent was checked for"
" local edits."
)
if RemovedAsset.digest(current) in shipped:
return None
return (
f"Overwrote local edits in {label}: it matches no file"
f" {self.package} `{self.previous}` bundled. Restore them from git"
" and port them upstream, or the next sync reverts them again."
)