diff --git a/src/databricks/labs/blueprint/paths.py b/src/databricks/labs/blueprint/paths.py index d6b6fe5..875cc93 100644 --- a/src/databricks/labs/blueprint/paths.py +++ b/src/databricks/labs/blueprint/paths.py @@ -7,10 +7,11 @@ import os import posixpath import re -import sys +from abc import abstractmethod +from collections.abc import Iterable, Sequence from io import BytesIO, StringIO from pathlib import Path, PurePath -from typing import NoReturn +from typing import NoReturn, TypeVar from urllib.parse import quote_from_bytes as urlquote_from_bytes from databricks.sdk import WorkspaceClient @@ -26,90 +27,6 @@ logger = logging.getLogger(__name__) -class _DatabricksFlavour: - # adapted from pathlib._Flavour, where we ignore support for drives, as we - # don't have that concept in Databricks. We also ignore support for Windows - # paths, as we only support POSIX paths in Databricks. - - sep = "/" - altsep = "" - has_drv = False - pathmod = posixpath - is_supported = True - - def __init__(self, ws: WorkspaceClient): - self.join = self.sep.join - self._ws = ws - - def parse_parts(self, parts: list[str]) -> tuple[str, str, list[str]]: - # adapted from pathlib._Flavour.parse_parts, - # where we ignore support for drives, as we - # don't have that concept in Databricks - parsed = [] - drv = root = "" - for part in reversed(parts): - if not part: - continue - drv, root, rel = self.splitroot(part) - if self.sep not in rel: - if rel and rel != ".": - parsed.append(sys.intern(rel)) - continue - for part_ in reversed(rel.split(self.sep)): - if part_ and part_ != ".": - parsed.append(sys.intern(part_)) - if drv or root: - parsed.append(drv + root) - parsed.reverse() - return drv, root, parsed - - @staticmethod - def join_parsed_parts( - drv: str, - root: str, - parts: list[str], - _, - root2: str, - parts2: list[str], - ) -> tuple[str, str, list[str]]: - # adapted from pathlib.PurePosixPath, where we ignore support for drives, - # as we don't have that concept in Databricks - if root2: - return drv, root2, [drv + root2] + parts2[1:] - return drv, root, parts + parts2 - - @staticmethod - def splitroot(part, sep=sep) -> tuple[str, str, str]: - if part and part[0] == sep: - stripped_part = part.lstrip(sep) - if len(part) - len(stripped_part) == 2: - return "", sep * 2, stripped_part - return "", sep, stripped_part - return "", "", part - - @staticmethod - def casefold(value: str) -> str: - return value - - @staticmethod - def casefold_parts(parts: list[str]) -> list[str]: - return parts - - @staticmethod - def compile_pattern(pattern: str): - return re.compile(fnmatch.translate(pattern)).fullmatch - - @staticmethod - def is_reserved(_) -> bool: - return False - - def make_uri(self, path) -> str: - return self._ws.config.host + "#workspace" + urlquote_from_bytes(bytes(path)) - - def __repr__(self): - return f"<{self.__class__.__name__} for {self._ws}>" - - def _na(fn: str): def _inner(*_, **__): __tracebackhide__ = True # pylint: disable=unused-variable @@ -118,75 +35,6 @@ def _inner(*_, **__): return _inner -class _ScandirItem: - def __init__(self, object_info): - self._object_info = object_info - - def __fspath__(self): - return self._object_info.path - - def is_dir(self, follow_symlinks=False): # pylint: disable=unused-argument - # follow_symlinks is for compatibility with Python 3.11 - return self._object_info.object_type == ObjectType.DIRECTORY - - def is_file(self, follow_symlinks=False): # pylint: disable=unused-argument - # follow_symlinks is for compatibility with Python 3.11 - # TODO: check if we want to show notebooks as files - return self._object_info.object_type == ObjectType.FILE - - def is_symlink(self): - return False - - @property - def name(self): - return os.path.basename(self._object_info.path) - - -class _ScandirIterator: - def __init__(self, objects): - self._it = objects - - def __iter__(self): - for object_info in self._it: - yield _ScandirItem(object_info) - - def __enter__(self): - return self - - def __exit__(self, *args): - pass - - -class _DatabricksAccessor: - chmod = _na("accessor.chmod") - getcwd = _na("accessor.getcwd") - group = _na("accessor.group") - link = _na("accessor.link") - mkdir = _na("accessor.mkdir") - owner = _na("accessor.owner") - readlink = _na("accessor.readlink") - realpath = _na("accessor.realpath") - rename = _na("accessor.rename") - replace = _na("accessor.replace") - rmdir = _na("accessor.rmdir") - stat = _na("accessor.stat") - symlink = _na("accessor.symlink") - unlink = _na("accessor.unlink") - - def __init__(self, ws: WorkspaceClient): - self._ws = ws - - def __repr__(self): - return f"<{self.__class__.__name__} for {self._ws}>" - - def scandir(self, path): - objects = self._ws.workspace.list(path) - return _ScandirIterator(objects) - - def listdir(self, path): - return [item.name for item in self.scandir(path)] - - class _UploadIO(abc.ABC): def __init__(self, ws: WorkspaceClient, path: str): self._ws = ws @@ -296,6 +144,16 @@ def __init__(self, ws: WorkspaceClient, *args) -> None: # pylint: disable=super self._path_parts = path_parts self._ws = ws + @classmethod + def _from_object_info(cls, ws: WorkspaceClient, object_info: ObjectInfo): + """Special (internal-only) constructor that creates an instance based on ObjectInfo.""" + if not object_info.path: + msg = f"Cannot initialise within object path: {object_info}" + raise ValueError(msg) + path = cls(ws, object_info.path) + path._cached_object_info = object_info + return path + @staticmethod def _to_raw_paths(*args) -> list[str]: raw_paths: list[str] = [] @@ -565,12 +423,6 @@ def __rtruediv__(self, other): except TypeError: return NotImplemented - @classmethod - def _compile_pattern(cls, pattern: str, case_sensitive: bool) -> re.Pattern: - flags = 0 if case_sensitive else re.IGNORECASE - regex = fnmatch.translate(pattern) - return re.compile(regex, flags=flags) - def match(self, path_pattern, *, case_sensitive=None): # Convert the pattern to a fake path (with globs) to help with matching parts. if not isinstance(path_pattern, PurePath): @@ -578,18 +430,17 @@ def match(self, path_pattern, *, case_sensitive=None): # Default to false if not specified. if case_sensitive is None: case_sensitive = True - # Reverse the parts. - path_parts = self.parts + pattern_parts = path_pattern.parts - # Error if not pattern_parts: raise ValueError("empty pattern") - # Impossible matches. + # Short-circuit on situations where a match is logically impossible. + path_parts = self.parts if len(path_parts) < len(pattern_parts) or len(path_parts) > len(pattern_parts) and path_pattern.anchor: return False - # Check each part. + # Check each part, starting from the end. for path_part, pattern_part in zip(reversed(path_parts), reversed(pattern_parts)): - pattern = self._compile_pattern(pattern_part, case_sensitive=case_sensitive) + pattern = _PatternSelector.compile_pattern(pattern_part, case_sensitive=case_sensitive) if not pattern.match(path_part): return False return True @@ -715,11 +566,6 @@ def is_file(self): except DatabricksError: return False - def _scandir(self): - # TODO: Not yet invoked; work-in-progress. - objects = self._ws.workspace.list(self.as_posix()) - return _ScandirIterator(objects) - def expanduser(self): # Expand ~ (but NOT ~user) constructs. if not (self._drv or self._root) and self._path_parts and self._path_parts[0][:1] == "~": @@ -741,3 +587,163 @@ def is_notebook(self): return self._object_info.object_type == ObjectType.NOTEBOOK except DatabricksError: return False + + def iterdir(self): + for child in self._ws.workspace.list(self.as_posix()): + yield self._from_object_info(self._ws, child) + + def _prepare_pattern(self, pattern) -> Sequence[str]: + if not pattern: + raise ValueError("Glob pattern must not be empty.") + parsed_pattern = self.with_segments(pattern) + if parsed_pattern.anchor: + msg = f"Non-relative patterns are unsupported: {pattern}" + raise NotImplementedError(msg) + pattern_parts = parsed_pattern._path_parts # pylint: disable=protected-access + if ".." in pattern_parts: + msg = f"Parent traversal is not supported: {pattern}" + raise ValueError(msg) + if pattern[-1] == self.parser.sep: + pattern_parts = (*pattern_parts, "") + return pattern_parts + + def glob(self, pattern, *, case_sensitive=None): + pattern_parts = self._prepare_pattern(pattern) + selector = _Selector.parse(pattern_parts, case_sensitive=case_sensitive if case_sensitive is not None else True) + yield from selector(self) + + def rglob(self, pattern, *, case_sensitive=None): + pattern_parts = ("**", *self._prepare_pattern(pattern)) + selector = _Selector.parse(pattern_parts, case_sensitive=case_sensitive if case_sensitive is not None else True) + yield from selector(self) + + +T = TypeVar("T", bound="Path") + + +class _Selector(abc.ABC): + @classmethod + def parse(cls, pattern_parts: Sequence[str], *, case_sensitive: bool) -> _Selector: + # The pattern language is: + # - '**' matches any number (including zero) of file or directory segments. Must be the entire segment. + # - '*' match any number of characters within a single segment. + # - '?' match a single character within a segment. + # - '[seq]' match a single character against the class (within a segment). + # - '[!seq]' negative match for a single character against the class (within a segment). + # - A trailing '/' (which presents here as a trailing empty segment) matches only directories. + # There is no explicit escaping mechanism; literal matches against special characters above are possible as + # character classes, for example: [*] + # + # Some sharp edges: + # - Multiple '**' segments are allowed. + # - Normally the '..' segment is allowed. (This can be used to match against siblings, for + # example: /home/bob/../jane/) However WorspacePath (and DBFS) do not support '..' traversal in paths. + # - Normally '.' is allowed, but eliminated before we reach this method. + match pattern_parts: + case ["**", *tail]: + return _RecursivePatternSelector(tail, case_sensitive=case_sensitive) + case [head, *tail] if case_sensitive and not _PatternSelector.needs_pattern(head): + return _LiteralSelector(head, tail, case_sensitive=case_sensitive) + case [head, *tail]: + if "**" in head: + raise ValueError("Invalid pattern: '**' can only be a complete path component") + return _PatternSelector(head, tail, case_sensitive=case_sensitive) + case []: + return _TerminalSelector() + raise ValueError(f"Glob pattern unsupported: {pattern_parts}") + + @abstractmethod + def __call__(self, path: T) -> Iterable[T]: + raise NotImplementedError() + + +class _TerminalSelector(_Selector): + def __call__(self, path: T) -> Iterable[T]: + yield path + + +class _NonTerminalSelector(_Selector): + __slots__ = ( + "_dir_only", + "_child_selector", + ) + + def __init__(self, child_pattern_parts: Sequence[str], *, case_sensitive: bool) -> None: + super().__init__() + if child_pattern_parts: + self._child_selector = self.parse(child_pattern_parts, case_sensitive=case_sensitive) + self._dir_only = True + else: + self._child_selector = _TerminalSelector() + self._dir_only = False + + def __call__(self, path: T) -> Iterable[T]: + if path.is_dir(): + yield from self._select_children(path) + + @abstractmethod + def _select_children(self, path: T) -> Iterable[T]: + raise NotImplementedError() + + +class _LiteralSelector(_NonTerminalSelector): + __slots__ = ("_literal_path",) + + def __init__(self, path: str, child_pattern_parts: Sequence[str], case_sensitive: bool) -> None: + super().__init__(child_pattern_parts, case_sensitive=case_sensitive) + self._literal_path = path + + def _select_children(self, path: T) -> Iterable[T]: + candidate = path / self._literal_path + if self._dir_only and candidate.is_dir() or candidate.exists(): + yield from self._child_selector(candidate) + + +class _PatternSelector(_NonTerminalSelector): + __slots__ = ("_pattern",) + + # The special set of characters that indicate a glob pattern isn't a trivial literal. + # Ref: https://docs.python.org/3/library/fnmatch.html#module-fnmatch + _glob_specials = re.compile("[*?\\[\\]]") + + @classmethod + def needs_pattern(cls, pattern: str) -> bool: + return cls._glob_specials.search(pattern) is not None + + @classmethod + def compile_pattern(cls, pattern: str, case_sensitive: bool) -> re.Pattern: + flags = 0 if case_sensitive else re.IGNORECASE + regex = fnmatch.translate(pattern) + return re.compile(regex, flags=flags) + + def __init__(self, pattern: str, child_pattern_parts: Sequence[str], case_sensitive: bool) -> None: + super().__init__(child_pattern_parts, case_sensitive=case_sensitive) + self._pattern = self.compile_pattern(pattern, case_sensitive=case_sensitive) + + def _select_children(self, path: T) -> Iterable[T]: + candidates = list(path.iterdir()) + for candidate in candidates: + if self._dir_only and not candidate.is_dir(): + continue + if self._pattern.match(candidate.name): + yield from self._child_selector(candidate) + + +class _RecursivePatternSelector(_NonTerminalSelector): + def __init__(self, child_pattern_parts: Sequence[str], case_sensitive: bool) -> None: + super().__init__(child_pattern_parts, case_sensitive=case_sensitive) + + def _all_directories(self, path: T) -> Iterable[T]: + # Depth-first traversal of directory tree, visiting this node first. + yield path + children = [child for child in path.iterdir() if child.is_dir()] + for child in children: + yield from self._all_directories(child) + + def _select_children(self, path: T) -> Iterable[T]: + yielded = set() + for starting_point in self._all_directories(path): + for candidate in self._child_selector(starting_point): + if candidate not in yielded: + yielded.add(candidate) + yield candidate diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 76747f7..1a16f14 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -1,10 +1,12 @@ import os +from collections.abc import Iterator from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath from unittest.mock import create_autospec, patch import pytest from databricks.sdk import WorkspaceClient from databricks.sdk.errors import NotFound +from databricks.sdk.mixins.workspace import WorkspaceExt from databricks.sdk.service.workspace import ( ImportFormat, Language, @@ -386,6 +388,28 @@ def test_match() -> None: assert not WorkspacePath(ws, "/foo/bar/file.txt").match("/**/*.txt") +def test_iterdir() -> None: + """Test that iterating through a directory works.""" + ws = create_autospec(WorkspaceClient) + ws.workspace.list.return_value = iter( + ( + ObjectInfo(path="/home/bob"), + ObjectInfo(path="/home/jane"), + ObjectInfo(path="/home/ted"), + ObjectInfo(path="/home/fred"), + ) + ) + + children = set(WorkspacePath(ws, "/home").iterdir()) + + assert children == { + WorkspacePath(ws, "/home/bob"), + WorkspacePath(ws, "/home/jane"), + WorkspacePath(ws, "/home/ted"), + WorkspacePath(ws, "/home/fred"), + } + + def test_exists_when_path_exists(): ws = create_autospec(WorkspaceClient) workspace_path = WorkspacePath(ws, "/test/path") @@ -692,7 +716,214 @@ def test_is_notebook_when_databricks_error_occurs(): assert workspace_path.is_notebook() is False -@pytest.mark.xfail(reason="Implementation pending.") +class StubWorkspaceFilesystem: + """Stub the basic Workspace filesystem operations.""" + + __slots__ = ("_paths",) + + def __init__(self, *known_paths: str | ObjectInfo) -> None: + """Initialize a virtual filesystem with a set of known paths. + + Each known path can either be a string or a complete ObjectInfo instance; if a string then the path is + treated as a directory if it has a trailing-/ or a file otherwise. + """ + fs_entries = [self._normalize_path(p) for p in known_paths] + keyed_entries = {o.path: o for o in fs_entries if o.path is not None} + self._normalize_paths(keyed_entries) + self._paths = keyed_entries + + @classmethod + def _normalize_path(cls, path: str | ObjectInfo) -> ObjectInfo: + if isinstance(path, ObjectInfo): + return path + return ObjectInfo( + path=path.rstrip("/") if path != "/" else path, + object_type=ObjectType.DIRECTORY if path.endswith("/") else ObjectType.FILE, + ) + + @classmethod + def _normalize_paths(cls, paths: dict[str, ObjectInfo]) -> None: + """Validate entries are absolute and that intermediate directories are both present and typed correctly.""" + for p in list(paths): + for parent in PurePath(p).parents: + path = str(parent) + paths.setdefault(path, ObjectInfo(path=path, object_type=ObjectType.DIRECTORY)) + + def _stub_get_status(self, path: str) -> ObjectInfo: + object_info = self._paths.get(path) + if object_info is None: + msg = f"Simulated path not found: {path}" + raise NotFound(msg) + return object_info + + def _stub_list( + self, path: str, *, notebooks_modified_after: int | None = None, recursive: bool | None = False, **kwargs + ) -> Iterator[ObjectInfo]: + path = path.rstrip("/") + path_len = len(path) + for candidate, object_info in self._paths.items(): + # Only direct children, and excluding the path itself. + if ( + len(candidate) > (path_len + 1) + and candidate[:path_len] == path + and candidate[path_len] == "/" + and "/" not in candidate[path_len + 1 :] + ): + yield object_info + + def mock(self) -> WorkspaceExt: + m = create_autospec(WorkspaceExt) + m.get_status.side_effect = self._stub_get_status + m.list.side_effect = self._stub_list + return m + + +def test_globbing_literals() -> None: + """Verify that trivial (literal) globs for one or more path segments match (or doesn't).""" + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem("/home/bob/bin/labs", "/etc/passwd").mock() + + assert set(WorkspacePath(ws, "/home").glob("jane")) == set() + assert set(WorkspacePath(ws, "/home").glob("bob")) == {WorkspacePath(ws, "/home/bob")} + assert set(WorkspacePath(ws, "/home").glob("bob/")) == {WorkspacePath(ws, "/home/bob")} + assert set(WorkspacePath(ws, "/home").glob("bob/bin")) == {WorkspacePath(ws, "/home/bob/bin")} + assert set(WorkspacePath(ws, "/home").glob("bob/bin/labs/")) == set() + assert set(WorkspacePath(ws, "/etc").glob("passwd")) == {WorkspacePath(ws, "/etc/passwd")} + + +def test_globbing_empty_error() -> None: + """Verify that an empty glob triggers an immediate error.""" + ws = create_autospec(WorkspaceClient) + + with pytest.raises(ValueError, match="must not be empty"): + _ = set(WorkspacePath(ws, "/etc/passwd").glob("")) + + +def test_globbing_absolute_error() -> None: + """Verify that absolute-path globs triggers an immediate error.""" + ws = create_autospec(WorkspaceClient) + + with pytest.raises(NotImplementedError, match="Non-relative patterns are unsupported"): + _ = set(WorkspacePath(ws, "/").glob("/tmp/*")) + + +def test_globbing_patterns() -> None: + """Verify that globbing with globs works as expected, including across multiple path segments.""" + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem( + "/home/bob/bin/labs", + "/home/bob/bin/databricks", + "/home/bot/", + "/home/bat/", + "/etc/passwd", + ).mock() + + assert set(WorkspacePath(ws, "/home").glob("*")) == { + WorkspacePath(ws, "/home/bob"), + WorkspacePath(ws, "/home/bot"), + WorkspacePath(ws, "/home/bat"), + } + assert set(WorkspacePath(ws, "/home/bob/bin").glob("*")) == { + WorkspacePath(ws, "/home/bob/bin/databricks"), + WorkspacePath(ws, "/home/bob/bin/labs"), + } + assert set(WorkspacePath(ws, "/home/bob").glob("*/*")) == { + WorkspacePath(ws, "/home/bob/bin/databricks"), + WorkspacePath(ws, "/home/bob/bin/labs"), + } + assert set(WorkspacePath(ws, "/home/bob/bin").glob("*a*")) == { + WorkspacePath(ws, "/home/bob/bin/databricks"), + WorkspacePath(ws, "/home/bob/bin/labs"), + } + assert set(WorkspacePath(ws, "/home").glob("bo[bt]")) == { + WorkspacePath(ws, "/home/bob"), + WorkspacePath(ws, "/home/bot"), + } + assert set(WorkspacePath(ws, "/home").glob("b[!o]t")) == {WorkspacePath(ws, "/home/bat")} + + +def test_glob_trailing_slash() -> None: + """Verify that globs with a trailing slash only match directories.""" + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem( + "/home/bob/bin/labs", + "/home/bob/bin/databricks", + "/home/bob/.profile", + ).mock() + + assert set(WorkspacePath(ws, "/home/bob").glob("*/")) == {WorkspacePath(ws, "/home/bob/bin")} + assert set(WorkspacePath(ws, "/home").glob("bob/*/")) == {WorkspacePath(ws, "/home/bob/bin")} + # Negative test. + assert WorkspacePath(ws, "/home/bob/.profile") in set(WorkspacePath(ws, "/home").glob("bob/*")) + + +def test_glob_parent_path_traversal_error() -> None: + """Globs are normally allowed to include /../ segments to traverse directories; these aren't supported though.""" + ws = create_autospec(WorkspaceClient) + + with pytest.raises(ValueError, match="Parent traversal is not supported"): + _ = set(WorkspacePath(ws, "/usr").glob("sbin/../bin")) + + +def test_recursive_glob() -> None: + """Verify that recursive globs work properly.""" + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem( + "/home/bob/bin/labs", + "/usr/local/bin/labs", + "/usr/local/sbin/labs", + ).mock() + + assert set(WorkspacePath(ws, "/").glob("**/bin/labs")) == { + WorkspacePath(ws, "/home/bob/bin/labs"), + WorkspacePath(ws, "/usr/local/bin/labs"), + } + assert set(WorkspacePath(ws, "/").glob("usr/**/labs")) == { + WorkspacePath(ws, "/usr/local/bin/labs"), + WorkspacePath(ws, "/usr/local/sbin/labs"), + } + assert set(WorkspacePath(ws, "/").glob("usr/**")) == { + WorkspacePath(ws, "/usr"), + WorkspacePath(ws, "/usr/local"), + WorkspacePath(ws, "/usr/local/bin"), + WorkspacePath(ws, "/usr/local/sbin"), + } + + +def test_double_recursive_glob() -> None: + """Verify that double-recursive globs work as expected without duplicate results.""" + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem( + "/some/long/path/with/repeated/path/segments/present", + ).mock() + + assert tuple(WorkspacePath(ws, "/").glob("**/path/**/present")) == ( + WorkspacePath(ws, "/some/long/path/with/repeated/path/segments/present"), + ) + + +def test_glob_case_insensitive() -> None: + """As of python 3.12, globbing is allowed to be case-insensitive irrespective of the underlying filesystem. Check this.""" + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem( + "/home/bob/bin/labs", + "/home/bob/bin/databricks", + "/home/bot/", + "/home/bat/", + "/etc/passwd", + ).mock() + + assert set(WorkspacePath(ws, "/home").glob("B*t", case_sensitive=False)) == { + WorkspacePath(ws, "/home/bot"), + WorkspacePath(ws, "/home/bat"), + } + assert set(WorkspacePath(ws, "/home").glob("bO[TB]", case_sensitive=False)) == { + WorkspacePath(ws, "/home/bot"), + WorkspacePath(ws, "/home/bob"), + } + assert set(WorkspacePath(ws, "/etc").glob("PasSWd", case_sensitive=False)) == {WorkspacePath(ws, "/etc/passwd")} + + def test_globbing_when_nested_json_files_exist(): ws = create_autospec(WorkspaceClient) workspace_path = WorkspacePath(ws, "/test/path") @@ -711,3 +942,16 @@ def test_globbing_when_nested_json_files_exist(): ] result = [str(p) for p in workspace_path.glob("*/*.json")] assert result == ["/test/path/dir1/file1.json", "/test/path/dir2/file2.json"] + + +def test_rglob() -> None: + ws = create_autospec(WorkspaceClient) + ws.workspace = StubWorkspaceFilesystem( + "/test/path/dir1/file1.json", + "/test/path/dir2/file2.json", + ).mock() + + assert set(WorkspacePath(ws, "/test").rglob("*.json")) == { + WorkspacePath(ws, "/test/path/dir1/file1.json"), + WorkspacePath(ws, "/test/path/dir2/file2.json"), + }