1111import asyncio
1212import logging
1313from collections .abc import Mapping , Sequence
14+ from dataclasses import dataclass
1415from datetime import timedelta
1516from http import HTTPStatus
17+ from itertools import chain
1618from typing import TYPE_CHECKING , Any , Final , cast
1719from uuid import UUID
1820
@@ -233,18 +235,56 @@ def _validate_creation_config(
233235 ) from e
234236
235237
238+ @dataclass (frozen = True , slots = True )
239+ class LegacyMountResolution :
240+ """One resolved legacy name-keyed mount entry.
241+
242+ Produced by :meth:`SessionHandler._resolve_legacy_name_mounts` and
243+ consumed by :func:`_merge_resolved_legacy_mounts`. Carries both the
244+ resolved vfolder UUID and any subpath suffix peeled off the legacy
245+ ``"name/subpath"`` key so the merge step needs a single map keyed by
246+ vfolder name.
247+ """
248+
249+ vfid : UUID
250+ subpath : str | None = None
251+
252+
253+ def _split_legacy_mount_key (key : str ) -> tuple [str , str | None ]:
254+ """Peel an optional ``/<subpath>`` suffix off a legacy mount key.
255+
256+ The v1 CLI flattens ``-v vfname/sub:/dest`` into ``source="vfname/sub"``
257+ + a separate ``target="/dest"``; the REST payload then carries the
258+ ``vfname/sub`` source verbatim across ``mounts`` AND the ``mount_map``
259+ / ``mount_options`` keys. This helper recovers the bare vfolder name
260+ so name resolution and dst/opts lookup all key off the same string.
261+ """
262+ name , sep , subpath = key .partition ("/" )
263+ if sep and subpath :
264+ return name , subpath
265+ return key , None
266+
267+
236268def _route_legacy_uuid_mounts (creation_config : dict [str , Any ]) -> dict [str , Any ]:
237- """Lift UUID-shaped strings from legacy mount buckets onto the UUID-keyed
238- buckets, leaving only name-shaped entries for the name resolver.
269+ """Split mixed legacy mount input by key shape so legacy buckets
270+ (``mounts`` / ``mount_map``) carry only names and id buckets
271+ (``mount_ids`` / ``mount_id_map``) carry only UUIDs.
272+
273+ Each entry's key may be a vfolder name or a UUID-shaped string;
274+ UUID-shaped keys are parsed to ``UUID`` and lifted into the id
275+ buckets, names stay in the legacy buckets for the async resolver.
276+ ``mount_options`` is the one exception — both halves coexist on
277+ output until :func:`_merge_resolved_legacy_mounts` re-keys the
278+ name half onto the resolved UUIDs.
239279 """
240280 legacy_mounts = creation_config .get ("mounts" ) or ()
241281 legacy_mount_map = creation_config .get ("mount_map" ) or {}
242282 legacy_mount_options = creation_config .get ("mount_options" ) or {}
243283
244- mount_ids : list [Any ] = list (creation_config .get ("mount_ids" ) or [])
245- mount_id_map : dict [Any , str ] = dict (creation_config .get ("mount_id_map" ) or {})
246- mount_options : dict [Any , Any ] = {}
247- name_mounts : list [Any ] = []
284+ mount_ids : list [UUID ] = list (creation_config .get ("mount_ids" ) or [])
285+ mount_id_map : dict [UUID , str ] = dict (creation_config .get ("mount_id_map" ) or {})
286+ mount_options : dict [str | UUID , Any ] = {}
287+ name_mounts : list [str ] = []
248288 name_mount_map : dict [str , str ] = {}
249289
250290 legacy_mounts_keys = {str (m ) for m in legacy_mounts }
@@ -284,44 +324,77 @@ def _route_legacy_uuid_mounts(creation_config: dict[str, Any]) -> dict[str, Any]
284324
285325def _merge_resolved_legacy_mounts (
286326 creation_config : dict [str , Any ],
287- name_to_id : dict [str , UUID ],
327+ resolutions : Mapping [str , LegacyMountResolution ],
288328) -> dict [str , Any ]:
289- """Merge a `` name → UUID`` resolution into the UUID-keyed buckets of
290- ``creation_config`` and drop the now-resolved name-keyed legacy keys.
329+ """Merge resolved legacy name-keyed mounts into the UUID-keyed buckets
330+ of ``creation_config`` and drop the now-resolved name-keyed legacy keys.
291331
292332 The resolved ids are appended to ``mount_ids`` (skipping duplicates),
293333 and entries from the name-keyed ``mount_map`` / ``mount_options``
294334 dicts are re-keyed onto the resolved UUIDs without overwriting an
295335 explicit UUID-keyed entry the caller already supplied.
336+
337+ When a resolution carries a ``subpath`` (the caller passed a legacy
338+ ``mounts`` entry in the ``"vfname/subdir"`` shape), it is injected
339+ into ``mount_options[uuid]["subpath"]`` so the downstream UUID-keyed
340+ consumer (``prepare_vfolder_mounts``) sees the same shape as a
341+ modern caller supplying ``mount_options`` directly.
296342 """
297- if not name_to_id :
343+ if not resolutions :
298344 return creation_config
299345
300- merged_mount_ids : list [Any ] = list (creation_config .get ("mount_ids" ) or [])
301- existing_uuid_set : set [UUID ] = set ()
302- for rid in merged_mount_ids :
303- try :
304- existing_uuid_set .add (UUID (str (rid )))
305- except (ValueError , TypeError ):
306- continue
307- merged_mount_id_map : dict [Any , str ] = dict (creation_config .get ("mount_id_map" ) or {})
308- merged_mount_options : dict [Any , Any ] = dict (creation_config .get ("mount_options" ) or {})
346+ # ``mount_ids``/``mount_id_map`` are Pydantic-validated to ``list[UUID]``
347+ # / ``dict[UUID, str]`` upstream and ``_route_legacy_uuid_mounts`` only
348+ # ever appends ``UUID`` instances, so trust the typing here.
349+ merged_mount_ids : list [UUID ] = list (creation_config .get ("mount_ids" ) or [])
350+ existing_uuid_set : set [UUID ] = set (merged_mount_ids )
351+ merged_mount_id_map : dict [UUID , str ] = dict (creation_config .get ("mount_id_map" ) or {})
352+ merged_mount_options : dict [str | UUID , Any ] = dict (creation_config .get ("mount_options" ) or {})
309353 legacy_mount_map = creation_config .get ("mount_map" ) or {}
310354 legacy_mount_options = creation_config .get ("mount_options" ) or {}
311355
312- for name , vfid in name_to_id .items ():
356+ # Re-key legacy mount_map/mount_options entries onto the bare vfolder
357+ # name so a CLI-shaped ``{"vfname/sub": "/dest"}`` resolves alongside
358+ # the bare-name lookup. First-wins if both shapes appear for one name.
359+ mount_map_by_name : dict [str , str ] = {}
360+ for raw , dst in legacy_mount_map .items ():
361+ name , _ = _split_legacy_mount_key (str (raw ))
362+ mount_map_by_name .setdefault (name , dst )
363+
364+ mount_options_by_name : dict [str , Any ] = {}
365+ for raw , opts in legacy_mount_options .items ():
366+ name , _ = _split_legacy_mount_key (str (raw ))
367+ mount_options_by_name .setdefault (name , opts )
368+
369+ for name , res in resolutions .items ():
370+ vfid = res .vfid
313371 if vfid not in existing_uuid_set :
314372 merged_mount_ids .append (vfid )
315373 existing_uuid_set .add (vfid )
316- if (dst := legacy_mount_map .get (name )) and vfid not in merged_mount_id_map :
374+ if (dst := mount_map_by_name .get (name )) and vfid not in merged_mount_id_map :
317375 merged_mount_id_map [vfid ] = dst
318- if (opts := legacy_mount_options .get (name )) and vfid not in merged_mount_options :
376+ if (opts := mount_options_by_name .get (name )) and vfid not in merged_mount_options :
319377 merged_mount_options [vfid ] = opts
378+ if res .subpath is not None :
379+ existing_opts = merged_mount_options .get (vfid ) or {}
380+ # Do not clobber a subpath explicitly supplied via the modern
381+ # UUID-keyed ``mount_options`` surface for the same vfolder —
382+ # the explicit value wins over the legacy name/subpath form.
383+ if "subpath" not in existing_opts :
384+ merged_mount_options [vfid ] = {** existing_opts , "subpath" : res .subpath }
385+
386+ # Drop any leftover name-keyed entries from ``mount_options`` — once
387+ # they have been re-keyed onto the resolved UUID, the original
388+ # name-keyed copy is dead weight that downstream consumers (which
389+ # look up by UUID only) would never read.
390+ uuid_keyed_mount_options : dict [UUID , Any ] = {
391+ k : v for k , v in merged_mount_options .items () if isinstance (k , UUID )
392+ }
320393
321394 next_config = dict (creation_config )
322395 next_config ["mount_ids" ] = merged_mount_ids
323396 next_config ["mount_id_map" ] = merged_mount_id_map
324- next_config ["mount_options" ] = merged_mount_options
397+ next_config ["mount_options" ] = uuid_keyed_mount_options
325398 next_config .pop ("mounts" , None )
326399 next_config .pop ("mount_map" , None )
327400 return next_config
@@ -357,74 +430,62 @@ async def _normalize_legacy_mounts(self, validated_config: dict[str, Any]) -> di
357430 if not (legacy_mounts or legacy_mount_map or legacy_mount_options ):
358431 return validated_config
359432 validated_config = _route_legacy_uuid_mounts (validated_config )
360- name_to_id = await self ._resolve_legacy_name_mounts (
433+ resolutions = await self ._resolve_legacy_name_mounts (
361434 validated_config .get ("mounts" ) or (),
362435 validated_config .get ("mount_map" ) or {},
363436 validated_config .get ("mount_options" ) or {},
364437 )
365- return _merge_resolved_legacy_mounts (validated_config , name_to_id )
438+ return _merge_resolved_legacy_mounts (validated_config , resolutions )
366439
367440 async def _resolve_legacy_name_mounts (
368441 self ,
369442 mounts : Sequence [str ],
370443 mount_map : Mapping [str , str ],
371444 mount_options : Mapping [str , Any ],
372- ) -> dict [str , UUID ]:
373- """Resolve legacy v1 CLI name-keyed mount surfaces into a unified
374- ``name → UUID`` mapping.
375-
376- ``mounts`` (list of names), ``mount_map`` (dict keyed by name), and
377- ``mount_options`` (dict keyed by name) are all name-based legacy
378- inputs populated together by ``-v`` on the v1 CLI; they must be
379- re-keyed onto UUIDs together so a name that appears only in
380- ``mount_map`` or ``mount_options`` is not silently dropped
381- downstream.
382-
383- Subpath syntax (``name/subdir``) is rejected here because
384- :class:`MountInfoEntry` carries no subpath field; silently dropping
385- it would mount the wrong directory. Subpath mounts must use the
386- UUID-keyed surface where the storage proxy handles vfsubpath
387- separately.
388-
389- Merging the resolved ids/maps back into ``creation_config`` is the
390- caller's responsibility — see :func:`_merge_resolved_legacy_mounts`.
445+ ) -> dict [str , LegacyMountResolution ]:
446+ """Resolve legacy v1 CLI name-keyed mount surfaces into a map
447+ ``name → LegacyMountResolution(vfid, subpath_or_None)``.
448+
449+ ``mounts`` (list of names, optionally ``"<name>/<subpath>"``),
450+ ``mount_map`` (dict keyed by name, optionally with the same
451+ ``"<name>/<subpath>"`` suffix), and ``mount_options`` (same key
452+ shape) are all name-based legacy inputs populated together by
453+ ``-v vfname[/sub][:dst][,opt]`` on the v1 CLI; the CLI keeps the
454+ slash-with-subpath in the source string verbatim, so the same
455+ ``"<name>/<sub>"`` key can appear across all three surfaces.
456+
457+ We peel that suffix off every key, resolve only the bare vfolder
458+ name, and attach the parsed subpath to the resolution so
459+ :func:`_merge_resolved_legacy_mounts` can inject it into
460+ ``mount_options[uuid]["subpath"]``. Escape validation on the
461+ subpath value is performed downstream by ``prepare_vfolder_mounts``.
391462 """
392463 if not mounts and not mount_map and not mount_options :
393464 return {}
394465
395- subpath_entries = [m for m in mounts if "/" in str (m )]
396- if subpath_entries :
397- raise InvalidAPIParameters (
398- "Legacy 'mounts' field with subpath syntax "
399- f"({ subpath_entries } ) is not supported. "
400- "Use UUID-keyed 'mount_ids' / 'mount_id_map' instead."
401- )
402-
403466 names_to_resolve : list [str ] = []
404467 seen : set [str ] = set ()
405-
406- # ``mounts`` and ``mount_map`` are strictly name-keyed legacy
407- # surfaces — every entry is treated as a vfolder name.
408- for raw in list (mounts ) + list (mount_map .keys ()):
409- name = str (raw )
410- if name in seen :
411- continue
412- seen .add (name )
413- names_to_resolve .append (name )
414-
415- # ``mount_options`` is the single polymorphic field shared by
416- # both legacy (name-keyed) and modern (UUID-string-keyed)
417- # callers; skip keys that already parse as UUID strings since
418- # they are not vfolder names to resolve.
419- for raw in mount_options .keys ():
420- name = str (raw )
421- if name in seen :
422- continue
468+ subpath_by_name : dict [str , str ] = {}
469+
470+ # Walk the three legacy name-keyed surfaces, peel any
471+ # ``"name/subpath"`` suffix, and skip keys whose bare-name parses
472+ # as a UUID — those represent UUID-keyed entries that
473+ # ``_route_legacy_uuid_mounts`` should already have lifted, or
474+ # malformed ``<UUID>/<sub>`` inputs outside the legacy contract.
475+ for raw in chain (mounts , mount_map .keys (), mount_options .keys ()):
476+ name , subpath = _split_legacy_mount_key (str (raw ))
423477 try :
424478 UUID (name )
425479 continue
426480 except (ValueError , TypeError ):
427481 pass
482+ if subpath is not None :
483+ # The same ``-v`` flag populates mounts/mount_map/mount_options
484+ # with the same source, so the subpath should agree across all
485+ # three; if a caller supplies divergent forms, first-wins.
486+ subpath_by_name .setdefault (name , subpath )
487+ if name in seen :
488+ continue
428489 seen .add (name )
429490 names_to_resolve .append (name )
430491
@@ -434,7 +495,10 @@ async def _resolve_legacy_name_mounts(
434495 result = await self ._vfolder .resolve_vfolder_ids_by_names .wait_for_complete (
435496 ResolveIdsByNamesAction (vfolder_names = names_to_resolve )
436497 )
437- return dict (result .name_to_id )
498+ return {
499+ name : LegacyMountResolution (vfid = vfid , subpath = subpath_by_name .get (name ))
500+ for name , vfid in result .name_to_id .items ()
501+ }
438502
439503 # ------------------------------------------------------------------
440504 # create_from_template (POST /_/create-from-template)
0 commit comments