1# Copyright 2019 The Meson development team
2
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6
7#     http://www.apache.org/licenses/LICENSE-2.0
8
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15import typing as T
16import hashlib
17import os
18from pathlib import Path, PurePath, PureWindowsPath
19
20from .. import mlog
21from . import ExtensionModule
22from ..mesonlib import (
23    File,
24    FileOrString,
25    MesonException,
26    path_is_in_root,
27)
28from ..interpreterbase import FeatureNew, KwargInfo, typed_kwargs, typed_pos_args, noKwargs
29
30if T.TYPE_CHECKING:
31    from . import ModuleState
32    from ..interpreter import Interpreter
33
34    from typing_extensions import TypedDict
35
36    class ReadKwArgs(TypedDict):
37        """Keyword Arguments for fs.read."""
38
39        encoding: str
40
41
42class FSModule(ExtensionModule):
43
44    def __init__(self, interpreter: 'Interpreter') -> None:
45        super().__init__(interpreter)
46        self.methods.update({
47            'expanduser': self.expanduser,
48            'is_absolute': self.is_absolute,
49            'as_posix': self.as_posix,
50            'exists': self.exists,
51            'is_symlink': self.is_symlink,
52            'is_file': self.is_file,
53            'is_dir': self.is_dir,
54            'hash': self.hash,
55            'size': self.size,
56            'is_samepath': self.is_samepath,
57            'replace_suffix': self.replace_suffix,
58            'parent': self.parent,
59            'name': self.name,
60            'stem': self.stem,
61            'read': self.read,
62        })
63
64    def _absolute_dir(self, state: 'ModuleState', arg: 'FileOrString') -> Path:
65        """
66        make an absolute path from a relative path, WITHOUT resolving symlinks
67        """
68        if isinstance(arg, File):
69            return Path(arg.absolute_path(state.source_root, self.interpreter.environment.get_build_dir()))
70        return Path(state.source_root) / Path(state.subdir) / Path(arg).expanduser()
71
72    def _resolve_dir(self, state: 'ModuleState', arg: 'FileOrString') -> Path:
73        """
74        resolves symlinks and makes absolute a directory relative to calling meson.build,
75        if not already absolute
76        """
77        path = self._absolute_dir(state, arg)
78        try:
79            # accommodate unresolvable paths e.g. symlink loops
80            path = path.resolve()
81        except Exception:
82            # return the best we could do
83            pass
84        return path
85
86    @noKwargs
87    @FeatureNew('fs.expanduser', '0.54.0')
88    @typed_pos_args('fs.expanduser', str)
89    def expanduser(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str, T.Any]) -> str:
90        return str(Path(args[0]).expanduser())
91
92    @noKwargs
93    @FeatureNew('fs.is_absolute', '0.54.0')
94    @typed_pos_args('fs.is_absolute', (str, File))
95    def is_absolute(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: T.Dict[str, T.Any]) -> bool:
96        if isinstance(args[0], File):
97            FeatureNew('fs.is_absolute_file', '0.59.0').use(state.subproject)
98        return PurePath(str(args[0])).is_absolute()
99
100    @noKwargs
101    @FeatureNew('fs.as_posix', '0.54.0')
102    @typed_pos_args('fs.as_posix', str)
103    def as_posix(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str, T.Any]) -> str:
104        """
105        this function assumes you are passing a Windows path, even if on a Unix-like system
106        and so ALL '\' are turned to '/', even if you meant to escape a character
107        """
108        return PureWindowsPath(args[0]).as_posix()
109
110    @noKwargs
111    @typed_pos_args('fs.exists', str)
112    def exists(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str, T.Any]) -> bool:
113        return self._resolve_dir(state, args[0]).exists()
114
115    @noKwargs
116    @typed_pos_args('fs.is_symlink', (str, File))
117    def is_symlink(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: T.Dict[str, T.Any]) -> bool:
118        if isinstance(args[0], File):
119            FeatureNew('fs.is_symlink_file', '0.59.0').use(state.subproject)
120        return self._absolute_dir(state, args[0]).is_symlink()
121
122    @noKwargs
123    @typed_pos_args('fs.is_file', str)
124    def is_file(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str, T.Any]) -> bool:
125        return self._resolve_dir(state, args[0]).is_file()
126
127    @noKwargs
128    @typed_pos_args('fs.is_dir', str)
129    def is_dir(self, state: 'ModuleState', args: T.Tuple[str], kwargs: T.Dict[str, T.Any]) -> bool:
130        return self._resolve_dir(state, args[0]).is_dir()
131
132    @noKwargs
133    @typed_pos_args('fs.hash', (str, File), str)
134    def hash(self, state: 'ModuleState', args: T.Tuple['FileOrString', str], kwargs: T.Dict[str, T.Any]) -> str:
135        if isinstance(args[0], File):
136            FeatureNew('fs.hash_file', '0.59.0').use(state.subproject)
137        file = self._resolve_dir(state, args[0])
138        if not file.is_file():
139            raise MesonException(f'{file} is not a file and therefore cannot be hashed')
140        try:
141            h = hashlib.new(args[1])
142        except ValueError:
143            raise MesonException('hash algorithm {} is not available'.format(args[1]))
144        mlog.debug('computing {} sum of {} size {} bytes'.format(args[1], file, file.stat().st_size))
145        h.update(file.read_bytes())
146        return h.hexdigest()
147
148    @noKwargs
149    @typed_pos_args('fs.size', (str, File))
150    def size(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: T.Dict[str, T.Any]) -> int:
151        if isinstance(args[0], File):
152            FeatureNew('fs.size_file', '0.59.0').use(state.subproject)
153        file = self._resolve_dir(state, args[0])
154        if not file.is_file():
155            raise MesonException(f'{file} is not a file and therefore cannot be sized')
156        try:
157            return file.stat().st_size
158        except ValueError:
159            raise MesonException('{} size could not be determined'.format(args[0]))
160
161    @noKwargs
162    @typed_pos_args('fs.is_samepath', (str, File), (str, File))
163    def is_samepath(self, state: 'ModuleState', args: T.Tuple['FileOrString', 'FileOrString'], kwargs: T.Dict[str, T.Any]) -> bool:
164        if isinstance(args[0], File) or isinstance(args[1], File):
165            FeatureNew('fs.is_samepath_file', '0.59.0').use(state.subproject)
166        file1 = self._resolve_dir(state, args[0])
167        file2 = self._resolve_dir(state, args[1])
168        if not file1.exists():
169            return False
170        if not file2.exists():
171            return False
172        try:
173            return file1.samefile(file2)
174        except OSError:
175            return False
176
177    @noKwargs
178    @typed_pos_args('fs.replace_suffix', (str, File), str)
179    def replace_suffix(self, state: 'ModuleState', args: T.Tuple['FileOrString', str], kwargs: T.Dict[str, T.Any]) -> str:
180        if isinstance(args[0], File):
181            FeatureNew('fs.replace_suffix_file', '0.59.0').use(state.subproject)
182        original = PurePath(str(args[0]))
183        new = original.with_suffix(args[1])
184        return str(new)
185
186    @noKwargs
187    @typed_pos_args('fs.parent', (str, File))
188    def parent(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: T.Dict[str, T.Any]) -> str:
189        if isinstance(args[0], File):
190            FeatureNew('fs.parent_file', '0.59.0').use(state.subproject)
191        original = PurePath(str(args[0]))
192        new = original.parent
193        return str(new)
194
195    @noKwargs
196    @typed_pos_args('fs.name', (str, File))
197    def name(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: T.Dict[str, T.Any]) -> str:
198        if isinstance(args[0], File):
199            FeatureNew('fs.name_file', '0.59.0').use(state.subproject)
200        original = PurePath(str(args[0]))
201        new = original.name
202        return str(new)
203
204    @noKwargs
205    @typed_pos_args('fs.stem', (str, File))
206    @FeatureNew('fs.stem', '0.54.0')
207    def stem(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: T.Dict[str, T.Any]) -> str:
208        if isinstance(args[0], File):
209            FeatureNew('fs.stem_file', '0.59.0').use(state.subproject)
210        original = PurePath(str(args[0]))
211        new = original.stem
212        return str(new)
213
214    @FeatureNew('fs.read', '0.57.0')
215    @typed_pos_args('fs.read', (str, File))
216    @typed_kwargs('fs.read', KwargInfo('encoding', str, default='utf-8'))
217    def read(self, state: 'ModuleState', args: T.Tuple['FileOrString'], kwargs: 'ReadKwArgs') -> str:
218        """Read a file from the source tree and return its value as a decoded
219        string.
220
221        If the encoding is not specified, the file is assumed to be utf-8
222        encoded. Paths must be relative by default (to prevent accidents) and
223        are forbidden to be read from the build directory (to prevent build
224        loops)
225        """
226        path = args[0]
227        encoding = kwargs['encoding']
228        src_dir = self.interpreter.environment.source_dir
229        sub_dir = self.interpreter.subdir
230        build_dir = self.interpreter.environment.get_build_dir()
231
232        if isinstance(path, File):
233            if path.is_built:
234                raise MesonException(
235                    'fs.read_file does not accept built files() objects')
236            path = os.path.join(src_dir, path.relative_name())
237        else:
238            if sub_dir:
239                src_dir = os.path.join(src_dir, sub_dir)
240            path = os.path.join(src_dir, path)
241
242        path = os.path.abspath(path)
243        if path_is_in_root(Path(path), Path(build_dir), resolve=True):
244            raise MesonException('path must not be in the build tree')
245        try:
246            with open(path, encoding=encoding) as f:
247                data = f.read()
248        except UnicodeDecodeError:
249            raise MesonException(f'decoding failed for {path}')
250        # Reconfigure when this file changes as it can contain data used by any
251        # part of the build configuration (e.g. `project(..., version:
252        # fs.read_file('VERSION')` or `configure_file(...)`
253        self.interpreter.add_build_def_file(path)
254        return data
255
256
257def initialize(*args: T.Any, **kwargs: T.Any) -> FSModule:
258    return FSModule(*args, **kwargs)
259