Coverage for src/somesy/merge.py: 92%
95 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +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, 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]], git: GitMetadata | None = None
116) -> ProjectMetadata:
117 """Merge harvested endpoint data and Git data into ProjectMetadata."""
118 data: dict[str, Any] = {"people": [], "entities": []}
119 keywords: list[str] = []
121 candidates = list(sources)
122 if git is not None:
123 candidates.append(git.model_dump(exclude_none=True))
125 for source in candidates:
126 for field in _SCALAR_FIELDS:
127 value = source.get(field)
128 if field == "repository":
129 value = _repository(value)
130 else:
131 value = _value(value)
132 if value not in (None, "") and field not in data:
133 data[field] = value
135 for keyword in _value(source.get("keywords", [])) or []:
136 if keyword not in keywords:
137 keywords.append(keyword)
139 people = source.get("people", []) or []
140 entities = source.get("entities", []) or []
141 data["people"] = _merge_people(data["people"], people)
142 data["entities"] = _merge_people(data["entities"], entities)
144 for author in source.get("authors", []) or []:
145 if isinstance(author, dict):
146 author = GitAuthor(**author)
147 if person := _git_person(author):
148 data["people"] = _merge_people(data["people"], [person])
150 if keywords:
151 data["keywords"] = keywords
152 return ProjectMetadata(**data)