diff --git a/docs/markdown/Fs-module.md b/docs/markdown/Fs-module.md index d4945e909..df9f305b4 100644 --- a/docs/markdown/Fs-module.md +++ b/docs/markdown/Fs-module.md @@ -199,3 +199,14 @@ 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`. + If the `encoding` keyword argument is not specified, the file specified by + `path` is assumed to be utf-8 encoded. Binary files are not supported. The + provided paths should be relative to the current `meson.current_source_dir()` + or an absolute path outside the build directory is accepted. If the file + specified by `path` changes, this will trigger Meson to reconfigure the + project. If the file specified by `path` is a `files()` object it + cannot refer to a built file. diff --git a/docs/markdown/howtox.md b/docs/markdown/howtox.md index f05f3e8e2..8c8c0c085 100644 --- a/docs/markdown/howtox.md +++ b/docs/markdown/howtox.md @@ -139,6 +139,23 @@ cdata.set('SOMETHING', txt) configure_file(...) ``` +## Generate configuration data from files + +`The [fs module](#Fs-modules) offers the `read` function` which enables adding +the contents of arbitrary files to configuration data (among other uses): + +```meson +fs = import('fs') +cdata = configuration_data() +copyright = fs.read('LICENSE') +cdata.set('COPYRIGHT', copyright) +if build_machine.system() == 'linux' + os_release = fs.read('/etc/os-release') + cdata.set('LINUX_BUILDER', os_release) +endif +configure_file(...) +``` + ## Generate a runnable script with `configure_file` `configure_file` preserves metadata so if your template file has diff --git a/docs/markdown/snippets/fs_read.md b/docs/markdown/snippets/fs_read.md new file mode 100644 index 000000000..05c215a1c --- /dev/null +++ b/docs/markdown/snippets/fs_read.md @@ -0,0 +1,40 @@ +## Support for reading files at configuration time with the `fs` module + +Reading text files during configuration is now supported. This can be done at +any time after `project` has been called + +```meson +project(myproject', 'c') +license_text = run_command( + find_program('python3'), '-c', 'print(open("COPYING").read())' +).stdout().strip() +about_header = configuration_data() +about_header.add('COPYRIGHT', license_text) +about_header.add('ABOUT_STRING', meson.project_name()) +... +``` + +There are several problems with the above approach: +1. It's ugly and confusing +2. If `COPYING` changes after configuration, Meson won't correctly rebuild when + configuration data is based on the data in COPYING +3. It has extra overhead + +`fs.read` replaces the above idiom thus: +```meson +project(myproject', 'c') +fs = import('fs') +license_text = fs.read('COPYING').strip() +about_header = configuration_data() +about_header.add('COPYRIGHT', license_text) +about_header.add('ABOUT_STRING', meson.project_name()) +... +``` + +They are not equivalent, though. Files read with `fs.read` create a +configuration dependency on the file, and so if the `COPYING` file is modified, +Meson will automatically reconfigure, guaranteeing the build is consistent. It +can be used for any properly encoded text files. It supports specification of +non utf-8 encodings too, so if you're stuck with text files in a different +encoding, it can be passed as an argument. See the [`meson` +object](Reference-manual.md#meson-object) documentation for details. diff --git a/mesonbuild/interpreter.py b/mesonbuild/interpreter.py index 0ce0fe8fb..c9b6e9a92 100644 --- a/mesonbuild/interpreter.py +++ b/mesonbuild/interpreter.py @@ -2617,7 +2617,7 @@ class Interpreter(InterpreterBase): def get_build_def_files(self) -> T.List[str]: return self.build_def_files - def add_build_def_file(self, f): + def add_build_def_file(self, f: mesonlib.FileOrString) -> None: # Use relative path for files within source directory, and absolute path # for system files. Skip files within build directory. Also skip not regular # files (e.g. /dev/stdout) Normalize the path to avoid duplicates, this diff --git a/mesonbuild/modules/fs.py b/mesonbuild/modules/fs.py index 2ff256b90..44986f811 100644 --- a/mesonbuild/modules/fs.py +++ b/mesonbuild/modules/fs.py @@ -14,18 +14,25 @@ import typing as T import hashlib +import os from pathlib import Path, PurePath, PureWindowsPath from .. import mlog from . import ExtensionModule from . import ModuleReturnValue -from ..mesonlib import MesonException +from ..mesonlib import ( + File, + FileOrString, + MesonException, + path_is_in_root, +) from ..interpreterbase import FeatureNew from ..interpreterbase import stringArgs, noKwargs if T.TYPE_CHECKING: from ..interpreter import Interpreter, ModuleState + class FSModule(ExtensionModule): def __init__(self, interpreter: 'Interpreter') -> None: @@ -193,5 +200,61 @@ class FSModule(ExtensionModule): new = original.stem return ModuleReturnValue(str(new), []) + #@permittedKwargs({'encoding'}) + @FeatureNew('fs.read', '0.57.0') + def read( + self, + state: 'ModuleState', + args: T.Sequence['FileOrString'], + kwargs: T.Dict[str, T.Any] + ) -> ModuleReturnValue: + ''' + Read a file from the source tree and return its value as a decoded + string. If the encoding is not specified, the file is assumed to be + utf-8 encoded. Paths must be relative by default (to prevent accidents) + and are forbidden to be read from the build directory (to prevent build + loops) + ''' + if len(args) != 1: + raise MesonException('expected single positional arg') + + path = args[0] + if not isinstance(path, (str, File)): + raise MesonException( + ' positional argument must be string or files() object') + + encoding = kwargs.get('encoding', 'utf-8') + if not isinstance(encoding, str): + raise MesonException('`encoding` parameter must be a string') + + src_dir = self.interpreter.environment.source_dir + sub_dir = self.interpreter.subdir + build_dir = self.interpreter.environment.get_build_dir() + + if isinstance(path, File): + if path.is_built: + raise MesonException( + 'fs.read_file does not accept built files() objects') + path = os.path.join(src_dir, path.relative_name()) + else: + if sub_dir: + src_dir = os.path.join(src_dir, sub_dir) + path = os.path.join(src_dir, path) + + path = os.path.abspath(path) + if path_is_in_root(Path(path), Path(build_dir), resolve=True): + raise MesonException('path must not be in the build tree') + try: + with open(path, 'r', encoding=encoding) as f: + data = f.read() + except UnicodeDecodeError: + raise MesonException(f'decoding failed for {path}') + # Reconfigure when this file changes as it can contain data used by any + # part of the build configuration (e.g. `project(..., version: + # fs.read_file('VERSION')` or `configure_file(...)` + self.interpreter.add_build_def_file(path) + return ModuleReturnValue(data, []) + + def initialize(*args: T.Any, **kwargs: T.Any) -> FSModule: return FSModule(*args, **kwargs) diff --git a/test cases/common/241 get_file_contents/.gitattributes b/test cases/common/241 get_file_contents/.gitattributes new file mode 100644 index 000000000..abec47db4 --- /dev/null +++ b/test cases/common/241 get_file_contents/.gitattributes @@ -0,0 +1 @@ +utf-16-text binary diff --git a/test cases/common/241 get_file_contents/VERSION b/test cases/common/241 get_file_contents/VERSION new file mode 100644 index 000000000..26aaba0e8 --- /dev/null +++ b/test cases/common/241 get_file_contents/VERSION @@ -0,0 +1 @@ +1.2.0 diff --git a/test cases/common/241 get_file_contents/meson.build b/test cases/common/241 get_file_contents/meson.build new file mode 100644 index 000000000..a8c68d63f --- /dev/null +++ b/test cases/common/241 get_file_contents/meson.build @@ -0,0 +1,21 @@ +project( + 'meson-fs-read-file', + [], + version: files('VERSION') +) +fs = import('fs') + +assert(fs.read('VERSION').strip() == meson.project_version(), 'file misread') + +expected = ( + '∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i), ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β)' +) +assert( + fs.read('utf-16-text', encoding: 'utf-16').strip() == expected, + 'file was not decoded correctly' +) + +# Make sure we handle `files()` objects properly, too +version_file = files('VERSION') + +subdir('other') diff --git a/test cases/common/241 get_file_contents/other/meson.build b/test cases/common/241 get_file_contents/other/meson.build new file mode 100644 index 000000000..9a7e4be56 --- /dev/null +++ b/test cases/common/241 get_file_contents/other/meson.build @@ -0,0 +1,3 @@ +fs = import('fs') +assert(fs.read(version_file).strip() == '1.2.0') +assert(fs.read('../VERSION').strip() == '1.2.0') diff --git a/test cases/common/241 get_file_contents/utf-16-text b/test cases/common/241 get_file_contents/utf-16-text new file mode 100644 index 000000000..ed1fefe83 Binary files /dev/null and b/test cases/common/241 get_file_contents/utf-16-text differ