Coverage for src/somesy/merge.py: 92%
96 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-18 08:48 +0000
1"""Merge harvested metadata into the canonical Somesy project model."""
3from __future__ import annotations
5import logging
6import re
7from collections.abc import Iterable
8from typing import Any
10from somesy.core.models import Entity, PartialProjectMetadata, Person, ProjectMetadata
11from somesy.git.models import GitAuthor, GitMetadata
13logger = logging.getLogger("somesy")
15_SCALAR_FIELDS = (
16 "name",
17 "version",
18 "description",
19 "license",
20 "homepage",
21 "repository",
22 "documentation",
23)
26def _value(value: Any) -> Any:
27 """Convert common endpoint wrapper values to project-model values."""
28 if hasattr(value, "text"):
29 return value.text
30 if isinstance(value, (set, tuple)):
31 return list(value)
32 if isinstance(value, list):
33 return [_value(item) for item in value]
34 return getattr(value, "value", value)
37def _repository(value: Any) -> str | None:
38 """Return a repository URL acceptable to ProjectMetadata when possible."""
39 value = _value(value)
40 if not isinstance(value, str):
41 return None
42 if match := re.fullmatch(r"git@([^:]+):(.+)", value):
43 return f"https://{match[1]}/{match[2]}"
44 if value.startswith("ssh://git@"):
45 return "https://" + value.removeprefix("ssh://git@")
46 return value
49def _merge_person(existing: Person | Entity, incoming: Person | Entity):
50 """Fill missing fields and combine roles for one matching person."""
51 updates: dict[str, Any] = {}
52 for field in type(existing).model_fields:
53 old = getattr(existing, field)
54 new = getattr(incoming, field)
55 if field == "contribution_types":
56 values = list(dict.fromkeys((old or []) + (new or [])))
57 if values != (old or []):
58 updates[field] = values
59 elif field in {"author", "maintainer"}:
60 if new and not old:
61 updates[field] = True
62 elif field == "publication_author":
63 if new is True and old is not True:
64 updates[field] = True
65 elif old is None and new is not None:
66 updates[field] = new
67 elif old in (None, "") and new not in (None, ""):
68 updates[field] = new
69 return existing.model_copy(update=updates) if updates else existing
72def _merge_people(
73 existing: list[Person | Entity], incoming: Iterable[Person | Entity]
74) -> list[Person | Entity]:
75 """Merge people using the model's existing identity heuristics."""
76 result = list(existing)
77 for candidate in incoming:
78 for index, person in enumerate(result):
79 same_person = (
80 isinstance(person, Person)
81 and isinstance(candidate, Person)
82 and person.same_person(candidate)
83 )
84 same_entity = (
85 isinstance(person, Entity)
86 and isinstance(candidate, Entity)
87 and person.same_person(candidate)
88 )
89 if same_person or same_entity:
90 result[index] = _merge_person(person, candidate)
91 break
92 else:
93 result.append(candidate)
94 return result
97def _git_person(author: GitAuthor) -> Person | None:
98 """Convert a Git author into a Somesy Person."""
99 identity = author.name
100 if author.email:
101 identity = f"{identity} <{author.email}>"
102 try:
103 return Person.from_name_email_string(identity).model_copy(
104 update={
105 "author": author.author,
106 "contribution_types": author.contribution_types,
107 }
108 )
109 except (IndexError, ValueError):
110 logger.warning("Cannot convert Git author '%s' to Somesy metadata.", identity)
111 return None
114def merge_metadata(
115 sources: Iterable[dict[str, Any]],
116 git: GitMetadata | None = None,
117 *,
118 allow_incomplete: bool = False,
119) -> ProjectMetadata:
120 """Merge harvested endpoint and Git data, optionally allowing missing fields."""
121 data: dict[str, Any] = {"people": [], "entities": []}
122 keywords: list[str] = []
124 candidates = list(sources)
125 if git is not None:
126 candidates.append(git.model_dump(exclude_none=True))
128 for source in candidates:
129 for field in _SCALAR_FIELDS:
130 value = source.get(field)
131 if field == "repository":
132 value = _repository(value)
133 else:
134 value = _value(value)
135 if value not in (None, "") and field not in data:
136 data[field] = value
138 for keyword in _value(source.get("keywords", [])) or []:
139 if keyword not in keywords:
140 keywords.append(keyword)
142 people = source.get("people", []) or []
143 entities = source.get("entities", []) or []
144 data["people"] = _merge_people(data["people"], people)
145 data["entities"] = _merge_people(data["entities"], entities)
147 for author in source.get("authors", []) or []:
148 if isinstance(author, dict):
149 author = GitAuthor(**author)
150 if person := _git_person(author):
151 data["people"] = _merge_people(data["people"], [person])
153 if keywords:
154 data["keywords"] = keywords
155 model = PartialProjectMetadata if allow_incomplete else ProjectMetadata
156 return model(**data)