Skip to content

Commit 5b9b3a2

Browse files
committed
Simplify manifest class
1 parent 405a891 commit 5b9b3a2

6 files changed

Lines changed: 158 additions & 102 deletions

File tree

dfetch/commands/add.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def _finalize_add(
253253
return
254254

255255
superproject.manifest.append_project_entry(project_entry)
256-
superproject.manifest.update_dump()
256+
superproject.manifest.dump()
257257
logger.print_info_line(
258258
project_entry.name,
259259
f"Added '{project_entry.name}' to manifest '{superproject.manifest.path}'",

dfetch/commands/freeze.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,5 +135,5 @@ def __call__(self, args: argparse.Namespace) -> None:
135135
manifest_updated = True
136136

137137
if manifest_updated:
138-
superproject.manifest.update_dump()
138+
superproject.manifest.dump()
139139
logger.info(f"Updated manifest ({manifest_path}) in {os.getcwd()}")

dfetch/commands/import_.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,19 @@ def __call__(self, _: argparse.Namespace) -> None:
5858
project.set_remote(remote)
5959
break
6060

61+
single_remote = len(remotes) == 1
62+
project_dicts = []
63+
for p in projects:
64+
d = p.as_yaml()
65+
if single_remote:
66+
d.pop("remote", None)
67+
project_dicts.append(d)
68+
6169
manifest_data = {
6270
"manifest": {
6371
"version": "0.0",
6472
"remotes": [r.as_yaml() for r in remotes],
65-
"projects": [p.as_yaml() for p in projects],
73+
"projects": project_dicts,
6674
}
6775
}
6876
manifest = Manifest.from_yaml(yaml.dump(manifest_data, sort_keys=False))

dfetch/manifest/manifest.py

Lines changed: 125 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,36 @@ def _ensure_unique(seq: list[dict[str, Any]], key: str, context: str) -> None:
6161

6262

6363
def _yaml_str(value: str) -> str | SingleQuotedScalarString:
64-
"""Return SingleQuotedScalarString if value would be misread as non-string."""
65-
if not isinstance(yaml.safe_load(value), str):
64+
"""Return SingleQuotedScalarString if value would be misread as non-string.
65+
66+
If ``yaml.safe_load`` cannot parse *value* at all (e.g. it starts with a
67+
``%`` directive marker) the value is quoted to be safe.
68+
"""
69+
try:
70+
if not isinstance(yaml.safe_load(value), str):
71+
return SingleQuotedScalarString(value)
72+
except yaml.YAMLError:
6673
return SingleQuotedScalarString(value)
6774
return value
6875

6976

77+
def _ensure_blank_line_after(ca_map: CommentedMap, key: str) -> None:
78+
"""Ensure there is exactly one blank line after *key*'s value in *ca_map*.
79+
80+
Blank lines in ruamel are stored as trailing ``CommentToken`` values at
81+
position ``[2]`` of ``ca_map.ca.items[key]``. If no token exists one is
82+
created; if one already exists its trailing newlines are normalised to two
83+
(the line ending of the value itself plus the blank line).
84+
"""
85+
items = ca_map.ca.items.get(key, [None, None, None, None])
86+
token = items[2]
87+
if token is None:
88+
items[2] = CommentToken("\n\n", CommentMark(0), None)
89+
ca_map.ca.items[key] = items
90+
elif not token.value.endswith("\n\n"):
91+
token.value = token.value.rstrip("\n") + "\n\n"
92+
93+
7094
@dataclass
7195
class ManifestEntryLocation:
7296
"""Location of an entry in the manifest file."""
@@ -133,10 +157,15 @@ class ManifestDict(TypedDict, total=True): # pylint: disable=too-many-ancestors
133157
]
134158

135159

136-
class Manifest: # pylint: disable=too-many-instance-attributes
160+
class Manifest:
137161
"""Manifest describing all the modules information.
138162
139163
This class is created from the manifest file a project has.
164+
165+
``self._doc`` is the single source of truth: all state is read from and written
166+
to the underlying YAML document. The only cached fields are ``__path``,
167+
``__relative_path``, ``__version`` (immutable after construction), and
168+
``_default_remote_name`` (also immutable: remotes are never added at runtime).
140169
"""
141170

142171
CURRENT_VERSION = "0.0"
@@ -159,50 +188,54 @@ def __init__(
159188
manifest_data: dict[str, Any] = cast(dict[str, Any], doc.data)["manifest"]
160189
self.__version: str = str(manifest_data.get("version", self.CURRENT_VERSION))
161190

162-
# Ensure version is always written as a quoted string by update_dump().
191+
# Ensure version is always written as a quoted string by dump().
163192
doc["manifest"].as_marked_up()["version"] = SingleQuotedScalarString(
164193
self.__version
165194
)
166195

167-
remotes = manifest_data.get("remotes", [])
168-
projects = manifest_data["projects"]
169-
170-
_ensure_unique(remotes, "name", "manifest.remotes")
171-
_ensure_unique(projects, "name", "manifest.projects")
172-
_ensure_unique(projects, "dst", "manifest.projects")
196+
remotes_raw = manifest_data.get("remotes", [])
197+
projects_raw = manifest_data["projects"]
173198

174-
self._remotes, default_remotes = self._determine_remotes(remotes)
199+
_ensure_unique(remotes_raw, "name", "manifest.remotes")
200+
_ensure_unique(projects_raw, "name", "manifest.projects")
201+
_ensure_unique(projects_raw, "dst", "manifest.projects")
175202

203+
# Determine and cache the default remote name (remotes don't change at runtime).
204+
remotes_dict, default_remotes = self._determine_remotes(remotes_raw)
176205
if not default_remotes:
177-
default_remotes = list(self._remotes.values())[0:1]
178-
206+
default_remotes = list(remotes_dict.values())[0:1]
179207
self._default_remote_name = (
180208
"" if not default_remotes else default_remotes[0].name
181209
)
182210

183-
self._projects = self._init_projects(projects)
211+
# Ensure blank lines appear before 'remotes:' and 'projects:' when dumped.
212+
self._ensure_section_spacing()
213+
214+
# Re-apply quoting to scalars whose style was stripped by strictyaml.
215+
self._normalize_string_scalars()
184216

185-
def _init_projects(
217+
def _build_projects(
186218
self,
187219
projects: Sequence[
188220
ProjectEntryDict
189221
| ProjectEntry
190222
| dict[str, str | list[str] | dict[str, str]]
191223
],
192224
) -> dict[str, ProjectEntry]:
193-
"""Iterate over projects from manifest and initialize ProjectEntries from it.
225+
"""Build a mapping of name → ProjectEntry from raw project data.
194226
195227
Args:
196-
projects (Sequence[
197-
Union[ProjectEntryDict, ProjectEntry, Dict[str, Union[str, list[str], dict[str, str]]]]
198-
]): Iterable with projects
228+
projects: Iterable of project dicts or ProjectEntry objects.
199229
200230
Raises:
201-
RuntimeError: Project unknown
231+
KeyError: A project dict is missing ``name``.
232+
TypeError: A project name is not a string.
233+
RuntimeError: A project references an unknown remote.
202234
203235
Returns:
204-
Dict[str, ProjectEntry]: Dictionary with key: Name of project, Value: ProjectEntry
236+
Dict mapping project name to ProjectEntry.
205237
"""
238+
remotes = {r.name: r for r in self.remotes}
206239
_projects: dict[str, ProjectEntry] = {}
207240

208241
for project in projects:
@@ -223,11 +256,11 @@ def _init_projects(
223256

224257
if last_project.remote:
225258
try:
226-
last_project.set_remote(self._remotes[last_project.remote])
259+
last_project.set_remote(remotes[last_project.remote])
227260
except KeyError as exc:
228261
raise RuntimeError(
229262
f"Remote {last_project.remote} of {last_project.name} wasn't found "
230-
f"in {list(self._remotes.keys())}!",
263+
f"in {list(remotes.keys())}!",
231264
) from exc
232265

233266
return _projects
@@ -310,17 +343,19 @@ def version(self) -> str:
310343
@property
311344
def projects(self) -> Sequence[ProjectEntry]:
312345
"""Get a list of Projects from the manifest."""
313-
return list(self._projects.values())
346+
projects_mu = self._doc["manifest"]["projects"].as_marked_up()
347+
return list(self._build_projects(projects_mu).values())
314348

315349
def selected_projects(self, names: Sequence[str]) -> Sequence[ProjectEntry]:
316350
"""Get a list of Projects from the manifest with the given names."""
317-
projects = (
318-
[p for p in self._projects.values() if p.name in names]
351+
all_projects = self.projects
352+
result = (
353+
[p for p in all_projects if p.name in names]
319354
if names
320-
else list(self._projects.values())
355+
else list(all_projects)
321356
)
322-
self._check_all_names_found(names, projects)
323-
return projects
357+
self._check_all_names_found(names, result)
358+
return result
324359

325360
def _check_all_names_found(
326361
self, names: Sequence[str], projects: Sequence[ProjectEntry]
@@ -331,23 +366,23 @@ def _check_all_names_found(
331366
return
332367
found = {project.name for project in projects}
333368
unfound = [name for name in unique_names if name not in found]
334-
possibles = [project.name for project in self._projects.values()]
369+
possibles = [project.name for project in self.projects]
335370
raise RequestedProjectNotFoundError(unfound, possibles)
336371

337372
@property
338373
def remotes(self) -> Sequence[Remote]:
339374
"""Get a list of Remotes from the manifest."""
340-
return list(self._remotes.values())
375+
manifest_mu = self._doc["manifest"].as_marked_up()
376+
remotes_dict, _ = self._determine_remotes(manifest_mu.get("remotes", []))
377+
return list(remotes_dict.values())
341378

342379
def __repr__(self) -> str:
343380
"""Get string representing this object."""
344381
return str(self._as_dict())
345382

346383
def _as_dict(self) -> dict[str, ManifestDict]:
347384
"""Get this manifest as dict."""
348-
remotes: Sequence[RemoteDict] = [
349-
remote.as_yaml() for remote in self._remotes.values()
350-
]
385+
remotes: Sequence[RemoteDict] = [remote.as_yaml() for remote in self.remotes]
351386

352387
if len(remotes) == 1:
353388
remotes[0].pop("default", None)
@@ -377,26 +412,17 @@ def _as_dict(self) -> dict[str, ManifestDict]:
377412
}
378413
}
379414

380-
def dump(self, path: str) -> None:
381-
"""Dump metadata file to correct path."""
382-
with open(path, "w+", encoding="utf-8") as manifest_file:
383-
yaml.dump(
384-
self._as_dict(),
385-
manifest_file,
386-
Dumper=ManifestDumper,
387-
sort_keys=False,
388-
line_break=os.linesep,
389-
)
390-
391-
def update_dump(self) -> None:
392-
"""Dump the manifest to its path, using the original text as a base to preserve formatting and comments."""
393-
if not self.__path:
394-
raise RuntimeError("Cannot update dump of manifest with no path")
395-
396-
updated_text = self._doc.as_yaml()
415+
def dump(self, path: str | None = None) -> None:
416+
"""Write the manifest to *path*, preserving formatting and comments.
397417
398-
with open(self.__path, "w", encoding="utf-8", newline="") as manifest_file:
399-
manifest_file.write(updated_text)
418+
If *path* is omitted the manifest is written back to the file it was
419+
loaded from. Raises ``RuntimeError`` if no path is available.
420+
"""
421+
target = path or self.__path
422+
if not target:
423+
raise RuntimeError("Cannot dump manifest with no path")
424+
with open(target, "w", encoding="utf-8", newline="") as manifest_file:
425+
manifest_file.write(self._doc.as_yaml())
400426

401427
def find_name_in_manifest(self, name: str) -> ManifestEntryLocation:
402428
"""Find the location of a project name in the manifest.
@@ -417,12 +443,57 @@ def find_name_in_manifest(self, name: str) -> ManifestEntryLocation:
417443
raise RuntimeError(f"{name} was not found in the manifest!")
418444

419445
# ---------------- YAML updates ----------------
446+
def _normalize_string_scalars(self) -> None:
447+
"""Re-apply ``SingleQuotedScalarString`` to any string that would be misread.
448+
449+
strictyaml strips quoting-style information during schema validation, so
450+
a value like ``'176'`` (single-quoted in the source YAML) becomes a plain
451+
Python ``str`` in the ruamel layer. ruamel then serialises it without
452+
quotes, producing the integer ``176``. We walk all top-level string
453+
fields in every project and remote entry and restore the quoting wherever
454+
``_yaml_str`` determines it is needed.
455+
"""
456+
manifest_mu = self._doc["manifest"].as_marked_up()
457+
for entry in manifest_mu.get("projects", []):
458+
for key in list(entry.keys()):
459+
if isinstance(entry[key], str):
460+
entry[key] = _yaml_str(entry[key])
461+
for entry in manifest_mu.get("remotes", []):
462+
for key in list(entry.keys()):
463+
if isinstance(entry[key], str):
464+
entry[key] = _yaml_str(entry[key])
465+
466+
def _ensure_section_spacing(self) -> None:
467+
"""Ensure blank lines appear before ``remotes:`` and ``projects:`` and between entries.
468+
469+
Blank lines in ruamel are stored as trailing ``CommentToken`` values on
470+
the *last field* of each ``CommentedMap`` entry. Specifically:
471+
472+
* Before ``remotes:`` — trailing token on ``version``'s value.
473+
* Before ``projects:`` — trailing token on the last field of the
474+
last remote (or on ``version`` when there
475+
are no remotes).
476+
* Between project entries — trailing token on the last field of every
477+
project entry except the final one.
478+
"""
479+
manifest_mu = self._doc["manifest"].as_marked_up()
480+
_ensure_blank_line_after(manifest_mu, "version")
481+
482+
remotes_mu = manifest_mu.get("remotes")
483+
if remotes_mu:
484+
last_remote = remotes_mu[-1]
485+
_ensure_blank_line_after(last_remote, list(last_remote.keys())[-1])
486+
487+
projects_mu = manifest_mu.get("projects", [])
488+
for project in projects_mu[:-1]: # all entries except the last
489+
_ensure_blank_line_after(project, list(project.keys())[-1])
490+
420491
def append_project_entry(self, project_entry: "ProjectEntry") -> None:
421492
"""Append *project_entry* to the projects list in-memory.
422493
423494
The new entry is formatted the same way as the existing YAML in the
424495
document (2-space indent under ``projects:``). Call
425-
:meth:`update_dump` afterwards to persist the change to disk.
496+
:meth:`dump` afterwards to persist the change to disk.
426497
"""
427498
projects_mu = self._doc["manifest"]["projects"].as_marked_up()
428499
projects_mu.append(CommentedMap(project_entry.as_yaml()))
@@ -433,7 +504,6 @@ def append_project_entry(self, project_entry: "ProjectEntry") -> None:
433504
None,
434505
None,
435506
]
436-
self._projects[project_entry.name] = ProjectEntry.copy(project_entry)
437507

438508
def update_project_version(self, project: ProjectEntry) -> None:
439509
"""Update a project's version in the manifest in-place, preserving layout, comments, and line endings."""
@@ -534,25 +604,3 @@ def find_remote_for_url(self, remote_url: str) -> Remote | None:
534604
if target.startswith(remote_base):
535605
return remote
536606
return None
537-
538-
539-
class ManifestDumper(yaml.SafeDumper): # pylint: disable=too-many-ancestors
540-
"""Dump a manifest YAML.
541-
542-
HACK: insert blank lines between top-level objects
543-
inspired by https://stackoverflow.com/a/44284819/3786245
544-
"""
545-
546-
_last_additional_break = 0
547-
548-
def write_line_break(self, data: Any = None) -> None:
549-
"""Write a line break."""
550-
super().write_line_break(data) # type: ignore[unused-ignore, no-untyped-call]
551-
552-
if len(self.indents) == 2 and getattr(self.event, "value", "") != "version":
553-
super().write_line_break() # type: ignore[unused-ignore, no-untyped-call]
554-
555-
if len(self.indents) == 3 and self._last_additional_break != 2:
556-
super().write_line_break() # type: ignore[unused-ignore, no-untyped-call]
557-
558-
self._last_additional_break = len(self.indents)

0 commit comments

Comments
 (0)