Skip to content

Add fs.relative_to() function. Closes #2184 #7908

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion docs/markdown/Fs-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,6 @@ suffix
fs.stem('foo/bar/baz.dll') # baz
fs.stem('foo/bar/baz.dll.a') # baz.dll
```

### read
- `read(path, encoding: 'utf-8')` *(since 0.57.0)*:
return a [string](Syntax.md#strings) with the contents of the given `path`.
Expand All @@ -216,6 +215,52 @@ fs.stem('foo/bar/baz.dll.a') # baz.dll
project. If the file specified by `path` is a `files()` object it
cannot refer to a built file.

### relative_to

*since 0.64.0*

Given two paths, returns a version of the first path relative to the second.

Examples:

```meson
# On windows:
fs.relative_to('c:\\proj1\\foo', 'c:\\proj1\\bar') # '..\\foo'
fs.relative_to('c:\\proj1\\foo', 'd:\\proj1\\bar') # ERROR, since the relative path does not exist

# On other systems:
fs.relative_to('/prefix/lib', '/prefix/bin') # '../lib'
fs.relative_to('/prefix/lib/foo', '/prefix') # 'lib/foo'
```

`relative_to` has some optional keyword arguments:

- `native`: Can be set to `true` or `false`. Whether the relative paths are computed
following the rules of the build machine (native: `true`) or the host (native: `false`).

- `allow_absolute`: If set to `true`, instead of giving an error, `relative_to()`
will return the original path. Useful if an absolute path is a reasonable
fallback.

- `if_within`: A path. `relative_to(path1, path2, if_within: path3)` returns
the relative path of `path1` with respect to `path2` if `path1` is found in `path3`.
Otherwise, if `allow_absolute: True` and `path1` is an absolute path, `path1` is
returned. If neither of those conditions are fullfilled an error is raised.

Examples:

```meson
# On windows:
fs.relative_to('c:\\proj1\\foo', 'c:\\proj2\\bar', if_within: 'c:\\proj2') # ERROR, the relative path is outside the "within" argument
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that an error? Doc above says " if path1 is found in path3, otherwise it returns path1 unchanged", so shouldn't this return c:\\proj1\\foo?

Copy link
Author

@zeehio zeehio Nov 2, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example was right, the docs were wrong.

If path1 is not found on path3, one can do two things: either we return path1 or we error.

  • Choosing to always error is the easy path, but it is not convenient to users because as far as I know meson doesn't have a try/catch.
  • Returning path1 as was documented may lead to confusion if both path1 and path2 happen to be relative paths. In that scenario we could not always know if the returned path is the path1 we gave as input (because it wasn't contained in path3) or if the outcome is the result of computing it relative to path2 (because path1 was contained in path3). Therefore the only safe behaviour is to return path1 if it is absolute, or raise an error. But for consistency, absolute paths can only be returned if allow_absolute : true, so that argument would need to be set as well to avoid the error.

I've pushed a commit to clarify the documentation as well

fs.relative_to('c:\\proj1\\foo', 'c:\\proj2\\bar', if_within: 'c:\\proj2', allow_absolute: true) # 'c:\\proj1\\foo'
fs.relative_to('c:\\proj1\\foo', 'd:\\proj1\\bar', allow_absolute: true) # 'c:\\proj1\\foo'

# On other systems:
fs.relative_to('/project1/lib/foo', '/usr/bin', if_within: '/usr') # ERROR: '/project1/lib/foo' not in '/usr'
fs.relative_to('/project1/lib/foo', '/usr/bin', if_within: '/usr', allow_absolute: 'true') # '/project1/lib/foo'
fs.relative_to('/usr/lib/foo', '/usr/bin', if_within: '/usr') # '../lib/foo'
```


### copyfile

Expand Down
10 changes: 10 additions & 0 deletions docs/markdown/snippets/fs-relative-to.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Add fs.relative_to function to get a path relative to another path

Finding what is the path to a directory relative to another directory is now possible.
For instance to find the path to `/foo/lib` as if we were in `/foo/bin` we now can use:

```meson
fs = import('fs')
fs.relative_to('/foo/lib', '/foo/bin')` # '../lib'
```

64 changes: 63 additions & 1 deletion mesonbuild/modules/fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# limitations under the License.

from __future__ import annotations
from pathlib import Path, PurePath, PureWindowsPath
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
import hashlib
import os
import typing as T
Expand All @@ -25,6 +25,7 @@
from ..interpreterbase import FeatureNew, KwargInfo, typed_kwargs, typed_pos_args, noKwargs
from ..mesonlib import (
File,
MachineChoice,
MesonException,
has_path_sep,
path_is_in_root,
Expand Down Expand Up @@ -75,6 +76,7 @@ def __init__(self, interpreter: 'Interpreter') -> None:
'stem': self.stem,
'read': self.read,
'copyfile': self.copyfile,
'relative_to': self.relative_to,
})

def _absolute_dir(self, state: 'ModuleState', arg: 'FileOrString') -> Path:
Expand Down Expand Up @@ -123,6 +125,66 @@ def as_posix(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str,
"""
return PureWindowsPath(args[0]).as_posix()

@FeatureNew('fs.relative_to', '0.64.0')
@typed_pos_args('fs.relative_to', (str, File), (str, File))
@typed_kwargs(
'fs.relative_to',
KwargInfo('native', bool, default=False),
KwargInfo('allow_absolute', bool, default=False),
KwargInfo('if_within', (str, File, NoneType)),
)
def relative_to(self, state: 'ModuleState', args: T.Sequence[str], kwargs: T.Dict[str, T.Any]) -> ModuleReturnValue:
"""
this function returns a version of the path given by the first argument relative to
the path given in the second argument.
`native` controls whether paths use rules in the host or build environment
`allow_absolute` will return the original path instead of returning an error when the relative path can't be computed
`if_within` a third optional path. When it is given, if the path is within `if_within`, then the relative path is
computed as usual. Otherwise, if `allow_absolute: True` and the first argument is an absolute path, the first
argument is returned. If neither of those conditions are fullfilled an error is raised.
"""
if len(args) != 2:
raise MesonException('fs.relative_to takes two arguments and optionally a "within" and a "native" argument.')
# pathlib requires to use PureWindowsPath for Windows paths for absolute and relative
# path computations
for_machine = self.interpreter.machine_from_native_kwarg(kwargs)
if for_machine == MachineChoice.BUILD:
system = state.build_machine.system
else:
system = state.host_machine.system
if system == "windows":
path_class = PureWindowsPath # type: T.Union[T.Type[PurePosixPath], T.Type[PureWindowsPath]]
else:
path_class = PurePosixPath

path_to = path_class(args[0])
path_from = path_class(args[1])
if_within = kwargs.get("if_within")
if if_within is not None:
path_within = path_class(if_within)
# Return path_to if it is not relative to path_within
try:
path_to.relative_to(path_within)
except ValueError:
if kwargs["allow_absolute"]:
if path_to.is_absolute():
return ModuleReturnValue(str(path_to).replace('\\', '/'), [])
raise MesonException(f"{path_to} is not within {if_within} and it is not an absolute path")
raise MesonException(f"{path_to} is not within {if_within}" +
"Use the \"allow_absolute: true\" argument if you want an absolute path instead of an error.")
try:
x = os.path.relpath(path_to, path_from).replace('\\', '/')
except ValueError:
if kwargs["allow_absolute"]:
if path_to.is_absolute():
return ModuleReturnValue(str(path_to).replace('\\', '/'), [])
raise MesonException(f"{path_to} and {path_from} do not have a common root and path1 is not an absolute path.")
raise MesonException(f"{path_to} and {path_from} do not have a common root " +
"or one is absolute and the other one is relative." +
"Use the \"allow_absolute: true\" argument if you want an absolute path instead of an error.")
else:
return ModuleReturnValue(str(x), [])

@noKwargs
@typed_pos_args('fs.exists', str)
def exists(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str, T.Any]) -> bool:
Expand Down
19 changes: 19 additions & 0 deletions test cases/common/260 relpath/meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
project('relpath', [])

fs = import('fs')

if host_machine.system() == 'windows'
assert(fs.relative_to('c:\\proj1\\foo', 'c:\\proj1\\bar') == '..\\foo', 'Path with windows common prefix is broken')
#expect_error(fs.relative_to('c:\\proj1\\foo', 'd:\\proj1\\bar') == 'c:\\proj1\\foo', 'Error was expected because relative path does not exist')
#expect_error(fs.relative_to('c:\\proj1\\foo', 'c:\\proj2\\bar', if_within: 'c:\\proj2'), 'Error expected, path1 is outside `if_within`)
assert(fs.relative_to('c:\\proj1\\foo', 'd:\\proj1\\bar', allow_absolute: true) == 'c:\\proj1\\foo', 'Path falls back if allow absolute is true')
assert(fs.relative_to('c:\\proj1\\foo', 'c:\\proj2\\bar', if_within: 'c:\\proj2', allow_absolute: true) == 'c:\\proj1\\foo', 'Path falls back to path1 if allow_absolute is True')
else
assert(fs.relative_to('/prefix/lib/foo', '/prefix') == 'lib/foo', 'Path inside source is broken')
assert(fs.relative_to('/prefix/lib', '/prefix/bin') == '../lib', 'Path with common prefix with source is broken')
assert(fs.relative_to('/usr/lib/foo', '/usr/bin') == '../lib/foo', 'Path with common prefix with source is broken')
assert(fs.relative_to('/usr/lib/foo', '/usr/bin', if_within: '/usr') == '../lib/foo', 'Path with common prefix using `if_within` is broken')
#expect_error(fs.relative_to('/project1/lib/foo', '/usr/bin', if_within: '/usr'), 'Expected error because /project1 is not in /usr')
assert(fs.relative_to('/project1/lib/foo', '/usr/bin', if_within: '/usr', allow_absolute: true) == '/project1/lib/foo', 'Expected path1 because allow_absolute is true')
endif

28 changes: 28 additions & 0 deletions unittests/failuretests.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,31 @@ def test_override_resolved_dependency(self):
def test_error_func(self):
self.assertMesonRaises("error('a', 'b', ['c', ['d', {'e': 'f'}]], 'g')",
r"Problem encountered: a b \['c', \['d', {'e' : 'f'}\]\] g")

@unittest.skipIf(is_windows(), 'Windows is not POSIX')
def test_posix_relative_to_errors(self):
self.assertMesonRaises(
"""
fs = import('fs')
fs.relative_to('/project1/lib/foo', '/usr/bin', if_within: '/usr')
""",
'/project1/lib/foo is not within /usr.*'
)

@unittest.skipIf(not is_windows(), 'POSIX is not Windows')
def test_windows_relative_to_errors(self):
self.assertMesonRaises(
"""
fs = import('fs')
fs.relative_to('c:\\proj1\\foo', 'd:\\proj1\\bar')
""",
'c:\\proj1\\foo and d:\\proj1\\bar do not have a common root.*'
)
self.assertMesonRaises(
"""
fs = import('fs')
fs.relative_to('c:\\proj1\\foo', 'c:\\proj2\\bar', if_within: 'c:\\proj2')
""",
'c:\\proj1\\foo is not within c:\\proj2.*'
)