Source code for repomatic.metadata.output

# 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.

"""Serialization of the metadata a workflow reads.

{meth}`~repomatic.metadata.core.Metadata.dump` computes the values, and
{class}`Dialect` writes them in the shape a consumer parses: GitHub Actions step
outputs, or plain JSON.
"""

from __future__ import annotations

import json
from collections.abc import Iterable
from enum import StrEnum
from pathlib import Path

from ..github.outputs import format_multiline_output
from ..matrix.model import Matrix

TYPE_CHECKING = False
if TYPE_CHECKING:
    from typing import Any


[docs] class JSONMetadata(json.JSONEncoder): """JSON encoder for metadata values: a matrix and a path serialize too."""
[docs] def default(self, o: Any) -> Any: if isinstance(o, Matrix): return o.matrix() if isinstance(o, Path): return str(o) return super().default(o)
[docs] def format_github_value(value: Any) -> str: """Transform Python value to GitHub-friendly, JSON-like, console string. Renders: - `str` as-is - `None` into empty string - `bool` into lower-cased string - `Matrix` into JSON string - `Iterable` of mixed strings and `Path` into a serialized space-separated string, where `Path` items are double-quoted - other `Iterable` into a JSON string - `dict[str, str]` into a JSON object string A mapping serializes as a JSON object so a workflow can index it by key (`fromJSON(...).runner_arch[matrix.os]`), which an array of pairs cannot do in a GitHub expression. Only a flat string-to-string mapping is accepted: a nested one is almost always a `Matrix` or a config dataclass, and the assertion below keeps it from serializing into a shape no consumer expects. ```{todo} Widen the JSON branch beyond an iterable of `dict[str, str]` and a flat `dict[str, str]`, the two shapes it asserts on today, when a metadata key needs a richer one. ``` """ # Structured metadata to be rendered as JSON. if isinstance(value, Matrix): return str(value) # Convert non-strings. if not isinstance(value, str): if value is None: value = "" elif isinstance(value, bool): value = str(value).lower() elif isinstance(value, int): value = str(value) elif isinstance(value, dict): assert all( isinstance(k, str) and isinstance(v, str) for k, v in value.items() ), ( f"Unsupported mapping value: {value!r}. Only a flat " "string-to-string mapping serializes as a JSON object; " "wrap a richer one in a Matrix, or leave its config field out " "of METADATA_CONFIG_FIELDS so its subcommand reads it instead." ) value = json.dumps(value) elif isinstance(value, Iterable): # Cast all items to strings, wrapping Path items with double-quotes. if all(isinstance(i, (str, Path)) for i in value): items = ((f'"{i}"' if isinstance(i, Path) else str(i)) for i in value) value = " ".join(items) else: assert all( isinstance(i, dict) and all( isinstance(k, str) and isinstance(v, str) for k, v in i.items() ) for i in value ), f"Unsupported iterable value: {value!r}" value = json.dumps(value) else: raise NotImplementedError( f"GitHub formatting for {type(value).__name__}: {value!r}. " "A nested config dataclass stays out of METADATA_CONFIG_FIELDS, " "and out of the metadata output." ) return str(value)
def _is_word_list(value: Any) -> bool: """Whether *value* is a list of strings or paths, empty lists included. The `github-json` dialect joins such a list into one space-separated string. GitHub Actions turns a JSON array into the word `Array` when an expression splices it into a `run:` or `env:` value, so a file list must arrive as a string. An empty list joins into an empty string, which an `if:` gate reads as false. A list of mappings stays a JSON array instead: a workflow hands it to `strategy.matrix.include` as it is, with no second `fromJSON()` to unwrap a string. """ return isinstance(value, (list, tuple)) and all( isinstance(item, (str, Path)) for item in value )
[docs] class Dialect(StrEnum): """Output dialect for metadata serialization.""" github = "github" github_json = "github-json" json = "json"
[docs] def serialize(self, metadata: dict[str, Any]) -> str: """Render *metadata* in this dialect. :param metadata: Raw key-to-value mapping from {meth}`~repomatic.metadata.core.Metadata.dump`. :return: The serialized payload. """ if self is Dialect.github: lines = [] for env_name, value in metadata.items(): env_value = format_github_value(value) # A value spanning several lines goes in a heredoc. That path # also trims a value past the size a consuming step can receive. if len(env_value.splitlines()) > 1: lines.append(format_multiline_output(env_name, env_value)) else: lines.append(f"{env_name}={env_value}") return "".join(f"{line}\n" for line in lines) if self is Dialect.github_json: # Bundle all metadata into a single `metadata` output key as JSON. # Downstream jobs access values via # `fromJSON(needs.metadata.outputs.metadata).key`, # eliminating the need for per-key `outputs:` declarations. formatted = { key: format_github_value(value) if _is_word_list(value) else value for key, value in metadata.items() } json_str = json.dumps(formatted, cls=JSONMetadata, separators=(",", ":")) return f"metadata={json_str}\n" assert self is Dialect.json return json.dumps(metadata, cls=JSONMetadata, indent=2)