File: parse.py

package info (click to toggle)
libzstd 1.5.7%2Bdfsg-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 10,352 kB
  • sloc: ansic: 89,030; sh: 3,788; python: 3,466; cpp: 2,927; makefile: 2,329; asm: 390
file content (255 lines) | stat: -rw-r--r-- 7,859 bytes parent folder | download | duplicates (3)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# SPDX-FileCopyrightText: Peter Pentchev <roam@ringlet.net>
# SPDX-License-Identifier: BSD-2-Clause
"""Parse the configuration file."""

from __future__ import annotations

import dataclasses
import functools
import sys
from typing import TYPE_CHECKING, Any

import typedload.dataloader

from check_build import defs


if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib

if TYPE_CHECKING:
    import logging
    import pathlib
    from typing import Final


@dataclasses.dataclass
class ParseError(defs.CBuildError):
    """An error that occurred while parsing the configuration file."""


@dataclasses.dataclass
class ConfigError(ParseError):
    """An error that occurred while loading or parsing the configuration file."""

    config: pathlib.Path
    """The path to the config file that could not be parsed."""


@dataclasses.dataclass
class ConfigExcError(ConfigError):
    """An error that occurred while loading the config file with an exception attached."""

    err: Exception
    """The exception that occurred while loading the config file."""


@dataclasses.dataclass
class ConfigAbsoluteError(ConfigError):
    """The path to the config file was not an absolute one."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Not an absolute path to the config file: {self.config}"


@dataclasses.dataclass
class ConfigReadError(ConfigExcError):
    """Could not read the config file."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Could not read the {self.config} file: {self.err}"


@dataclasses.dataclass
class ConfigUTF8Error(ConfigExcError):
    """Could not parse the config file as valid UTF-8 text."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Could not parse the {self.config} file as valid UTF-8: {self.err}"


@dataclasses.dataclass
class ConfigTOMLError(ConfigExcError):
    """Could not parse the config file as valid TOML."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Could not parse the {self.config} file as valid TOML: {self.err}"


@dataclasses.dataclass
class ConfigTableError(ConfigError):
    """The config file did not contain a top-level TOML table."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Expected a table as the top-level object in the {self.config} file"


@dataclasses.dataclass
class ConfigParseError(ConfigExcError):
    """Could not parse the config file as per our expected structure."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Could not parse the {self.config} file: {self.err}"


@dataclasses.dataclass
class FormatParseError(ConfigExcError):
    """Could not parse the config file to obtain the format version."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Could not determine the format version of the {self.config} file: {self.err}"


@dataclasses.dataclass
class FormatInvalidError(ConfigError):
    """The format version in the config file did not consist of two integers."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"The format.version.major/minor values in the {self.config} file must be integers"


@dataclasses.dataclass
class FormatUnsupportedError(ConfigError):
    """The format version is not supported by this version of check-build."""

    major: int
    """The major number of the format version."""

    minor: int
    """The minor number of the format version."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Unsupported format version {self.major}.{self.minor} in the {self.config} file"


@dataclasses.dataclass
class UnknownProgramsError(ParseError):
    """The programs specified on the command line are not defined in the config file."""

    unknown: list[str]
    """The unknown programs specified on the command line."""

    def __str__(self) -> str:
        """Provide a human-readable description of the error."""
        return f"Unknown programs specified: {', '.join(self.unknown)}"


@dataclasses.dataclass(frozen=True)
class TCommands:
    """The commands to build and test a program."""

    clean: list[list[str]]
    build: list[list[str]]
    test: list[list[str]]


@dataclasses.dataclass(frozen=True)
class TPrereq:
    """The prerequisites for building a program."""

    programs: list[str] | None = None


@dataclasses.dataclass(frozen=True)
class TProgram:
    """The definition of a single program."""

    executable: str
    commands: TCommands
    prerequisites: TPrereq | None = None


@dataclasses.dataclass(frozen=True)
class TConfig:
    """The top-level parsed configuration settings."""

    program: dict[str, TProgram]


def _validate_format_version(config: pathlib.Path, progs: dict[str, Any]) -> None:
    """Make sure we have the appropriate format.version table."""
    try:
        major, minor = progs["format"]["version"]["major"], progs["format"]["version"]["minor"]
    except (TypeError, KeyError) as err:
        raise FormatParseError(config, err) from err
    if not isinstance(major, int) or not isinstance(minor, int):
        raise FormatInvalidError(config)
    if (major, minor) != (0, 1):
        raise FormatUnsupportedError(config, major, minor)


def _select_programs(cfg: TConfig, programs: tuple[str, ...]) -> list[str]:
    """Validate the specified list of programs or return them all."""
    if not programs:
        return list(cfg.program)

    selected: Final = list(programs)
    unknown: Final = [name for name in selected if name not in cfg.program]
    if unknown:
        raise UnknownProgramsError(unknown)

    return selected


@functools.lru_cache(maxsize=1)
def typed_loader() -> typedload.dataloader.Loader:
    """Instantiate a typed loader that can parse type annotations."""
    return typedload.dataloader.Loader(basiccast=False, failonextra=True, pep563=True)


def load_config(
    *, config: pathlib.Path, force: bool, programs: tuple[str, ...], logger: logging.Logger
) -> defs.Config:
    """Load the configuration file describing the list of programs to build."""
    if not config.is_absolute():
        raise ConfigAbsoluteError(config)
    try:
        contents: Final = config.read_text(encoding="UTF-8")
    except OSError as err:
        raise ConfigReadError(config, err) from err
    except ValueError as err:
        raise ConfigUTF8Error(config, err) from err

    try:
        progs: Final = tomllib.loads(contents)
    except ValueError as err:
        raise ConfigTOMLError(config, err) from err
    if not isinstance(progs, dict):
        raise ConfigTableError(config)

    _validate_format_version(config, progs)
    del progs["format"]
    try:
        cfg: Final = typed_loader().load(progs, TConfig)
    except ValueError as err:
        raise ConfigParseError(config, err) from err

    return defs.Config(
        force=force,
        log=logger,
        program={
            name: defs.Program(
                executable=prog.executable,
                prerequisites=defs.Prerequisites(programs=prog.prerequisites.programs)
                if prog.prerequisites is not None
                else None,
                commands=defs.Commands(
                    clean=prog.commands.clean, build=prog.commands.build, test=prog.commands.test
                ),
            )
            for name, prog in cfg.program.items()
        },
        selected=_select_programs(cfg, programs),
        topdir=config.parent,
    )