@@ -61,12 +61,36 @@ def _ensure_unique(seq: list[dict[str, Any]], key: str, context: str) -> None:
6161
6262
6363def _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
7195class 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