veridret 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
veridret/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """
4
+ VerIdRet (*Ver*sion-*Id*entifier *Ret*riever),
5
+ a program that retrieves the version identifiers
6
+ of the locally installed and upstream versions of software.
7
+ """
8
+
9
+ import functools
10
+ import os
11
+ import os.path
12
+ import typing
13
+ from typing import Final
14
+
15
+ __all__: Final = [
16
+ 'get_config_dirs',
17
+ ]
18
+
19
+ __version__: Final = '1'
20
+
21
+ PATH_LIST_SEP: Final = ':'
22
+
23
+ @typing.overload
24
+ def getenv_if_abs_path[T](key: str, default: T) -> str | T:
25
+ ...
26
+ @typing.overload
27
+ def getenv_if_abs_path(key: str) -> str | None:
28
+ ...
29
+ def getenv_if_abs_path[T](
30
+ key: str, default: T | None = None,
31
+ ) -> str | T | None:
32
+ value = os.getenv(key)
33
+ if value is None or not os.path.isabs(value):
34
+ return default
35
+ return value
36
+
37
+ @functools.cache
38
+ def get_config_dirs() -> tuple[str, ...]:
39
+ """
40
+ Get the paths to the program's configuration directories.
41
+
42
+ :return:
43
+ A tuple of the paths to the program's configuration directories,
44
+ in order of decreasing priority.
45
+ """
46
+
47
+ config_dirs = os.getenv('VERIDRET_CONFIG')
48
+ if config_dirs is not None:
49
+ return tuple(config_dirs.split(PATH_LIST_SEP))
50
+
51
+ xdg_config_home = getenv_if_abs_path('XDG_CONFIG_HOME')
52
+ if xdg_config_home is None:
53
+ home_dir = getenv_if_abs_path('HOME', '/')
54
+ xdg_config_home = os.path.join(home_dir, '.config')
55
+
56
+ system_config_home = '/etc'
57
+
58
+ return tuple(
59
+ os.path.join(x, 'veridret')
60
+ for x in (xdg_config_home, system_config_home)
61
+ )
veridret/__main__.py ADDED
@@ -0,0 +1,302 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """
4
+ The program's entry point.
5
+ """
6
+
7
+ import dataclasses
8
+ import enum
9
+ import json
10
+ import io
11
+ import sys
12
+ import threading
13
+ from collections.abc import MutableSequence, Sequence
14
+ from contextlib import ExitStack
15
+ from queue import Queue, ShutDown as QueueShutDown
16
+ from typing import Any, Final, Never, TypeIs
17
+
18
+ import regex
19
+
20
+ import structlog
21
+
22
+ import veridret.json
23
+ from veridret._cli import parse as parse_args
24
+ from veridret._config import Config
25
+ from veridret._logging import init as init_logging
26
+ from veridret._sw import Config as SwConfig
27
+ from veridret._unset import Unset
28
+ from veridret._version_info import Fields as VersionInfoFields, VersionInfo
29
+ from veridret.logging import Logger
30
+
31
+ __all__: Final = [
32
+ 'main',
33
+ ]
34
+
35
+ g_logger: Final[Logger] = structlog.get_logger(logger_name=__name__)
36
+
37
+ def json_seq_dump(
38
+ obj: object, fp: io.Writer[str], **kwargs: Any,
39
+ ) -> None:
40
+ fp.write('\N{RECORD SEPARATOR}')
41
+ json.dump(obj, fp, **kwargs)
42
+ fp.write('\n')
43
+
44
+ def sw_proc_thread_main_impl(
45
+ *,
46
+ queue: Queue[tuple[str, SwConfig]],
47
+ version_info_fields: VersionInfoFields,
48
+ output_lock: threading.Lock,
49
+ ) -> bool:
50
+ name_field_mask = version_info_fields & VersionInfoFields.NAME
51
+ ok = True
52
+
53
+ while True:
54
+ try:
55
+ name, config = queue.get()
56
+ except QueueShutDown:
57
+ break
58
+
59
+ with ExitStack() as exit_stack:
60
+ exit_stack.callback(queue.task_done)
61
+
62
+ logger = g_logger.bind(
63
+ software={
64
+ 'name': name,
65
+ 'config': config,
66
+ },
67
+ requested_version_info_fields=version_info_fields,
68
+ )
69
+
70
+ logger.info('processing software')
71
+
72
+ version_info = VersionInfo.compute(config, version_info_fields)
73
+ actual_fields = version_info.field_mask() | name_field_mask
74
+ if version_info_fields not in actual_fields:
75
+ ok = False
76
+ logger.error(
77
+ 'cannot compute all requested version-info fields',
78
+ actual_version_info_fields=actual_fields,
79
+ cause='(previous error(s))',
80
+ )
81
+
82
+ version_info_with_name = {
83
+ 'name': name if name_field_mask else Unset(),
84
+ 'local': version_info.local,
85
+ 'upstream': version_info.upstream,
86
+ 'comparison': version_info.comparison,
87
+ }
88
+
89
+ output_obj = {
90
+ k: v
91
+ for k, v in version_info_with_name.items()
92
+ if not isinstance(v, Unset)
93
+ }
94
+
95
+ try:
96
+ with output_lock:
97
+ json_seq_dump(
98
+ output_obj,
99
+ sys.stdout,
100
+ skipkeys=True,
101
+ separators=(',', ':'),
102
+ default=veridret.json.to_directly_encodable,
103
+ )
104
+ # pylint: disable-next=broad-exception-caught
105
+ except Exception as e:
106
+ ok = False
107
+ logger.error('cannot output version info', cause=e)
108
+
109
+ logger.info('done processing software')
110
+
111
+ return ok
112
+
113
+ def sw_proc_thread_main(
114
+ index: int,
115
+ *,
116
+ queue: Queue[tuple[str, SwConfig]],
117
+ oks: MutableSequence[bool],
118
+ version_info_fields: VersionInfoFields,
119
+ output_lock: threading.Lock,
120
+ ) -> None:
121
+ oks[index] = sw_proc_thread_main_impl(
122
+ queue=queue,
123
+ version_info_fields=version_info_fields,
124
+ output_lock=output_lock,
125
+ )
126
+
127
+ @dataclasses.dataclass(kw_only=True, frozen=True)
128
+ class SwProcessingThreadInfo:
129
+ threads: Sequence[threading.Thread]
130
+ oks: MutableSequence[bool]
131
+
132
+ def create_sw_proc_threads(
133
+ n: int,
134
+ queue: Queue[tuple[str, SwConfig]],
135
+ version_info_fields: VersionInfoFields,
136
+ ) -> SwProcessingThreadInfo:
137
+ oks = [False] * n
138
+ output_lock = threading.Lock()
139
+ threads = tuple(
140
+ threading.Thread(
141
+ target=sw_proc_thread_main,
142
+ args=(i,),
143
+ kwargs={
144
+ 'queue': queue,
145
+ 'oks': oks,
146
+ 'version_info_fields': version_info_fields,
147
+ 'output_lock': output_lock,
148
+ },
149
+ )
150
+ for i in range(n)
151
+ )
152
+ return SwProcessingThreadInfo(threads=threads, oks=oks)
153
+
154
+ @dataclasses.dataclass(frozen=True)
155
+ class SoftwareNamePatternFilter:
156
+ pattern: regex.Pattern[str] | None = None
157
+
158
+ def __call__(self, name: str) -> bool:
159
+ return self.pattern is None or bool(self.pattern.search(name))
160
+
161
+ def load_and_enqueue_software(
162
+ queue: Queue[tuple[str, SwConfig]],
163
+ name_filter: SoftwareNamePatternFilter,
164
+ ) -> bool:
165
+ logger = g_logger.bind()
166
+
167
+ ok = True
168
+
169
+ def sw_filter(
170
+ software: tuple[str, SwConfig | None] | None,
171
+ ) -> TypeIs[tuple[str, SwConfig]]:
172
+ nonlocal ok
173
+ match software:
174
+ case name, config:
175
+ if not name_filter(name):
176
+ logger.debug(
177
+ 'discarding software from processing',
178
+ software=name,
179
+ software_name_pattern=name_filter.pattern,
180
+ cause='software-name pattern mismatch',
181
+ )
182
+ return False
183
+ match config:
184
+ case SwConfig():
185
+ return True
186
+ case None:
187
+ ok = False
188
+ return False
189
+ case None:
190
+ ok = False
191
+ return False
192
+
193
+ logger.debug('enqueuing software for processing')
194
+
195
+ sw_iter = (x for x in SwConfig.load_all() if sw_filter(x))
196
+ for name, config in sw_iter:
197
+ sw_logger = logger.bind(
198
+ software={
199
+ 'name': name,
200
+ 'config': config,
201
+ },
202
+ )
203
+
204
+ sw_logger.debug(
205
+ 'enqueuing software for processing',
206
+ )
207
+ queue.put((name, config))
208
+ sw_logger.debug(
209
+ 'done enqueuing software for processing',
210
+ )
211
+
212
+ logger.debug('done enqueuing software for processing')
213
+
214
+ return ok
215
+
216
+ class ExitCode(enum.Enum):
217
+ SUCCESS = 0
218
+ FAILURE = 1
219
+
220
+ def __int__(self) -> int:
221
+ return self.value
222
+
223
+ def main() -> Never:
224
+ """
225
+ Run the program
226
+ and then terminate the current process.
227
+
228
+ This function is the program's entry point
229
+ and terminates the current process upon completion.
230
+ If the program's command-line arguments are incorrect,
231
+ then the exit code is 2;
232
+ otherwise,
233
+ if program encounteres unrecoverable errors,
234
+ then the exit code is 1;
235
+ otherwise,
236
+ the exit code is 0.
237
+
238
+ :return: This function does not return.
239
+ """
240
+
241
+ args = parse_args()
242
+
243
+ init_logging(
244
+ min_level=args.min_logging_level,
245
+ fmt=args.log_format,
246
+ )
247
+
248
+ logger = g_logger.bind()
249
+
250
+ config = Config.load(
251
+ max_job_count=args.max_job_count,
252
+ job_queue_capacity=args.job_queue_capacity,
253
+ )
254
+
255
+ sw_queue: Queue[tuple[str, SwConfig]] = Queue(
256
+ config.job_queue_capacity,
257
+ )
258
+
259
+ sw_proc_thread_info = create_sw_proc_threads(
260
+ config.max_job_count, sw_queue, args.version_info_fields,
261
+ )
262
+ sw_proc_threads = sw_proc_thread_info.threads
263
+ sw_proc_thread_oks = sw_proc_thread_info.oks
264
+
265
+ logger.debug('starting software-processing threads')
266
+ for i, t in enumerate(sw_proc_threads):
267
+ logger.debug(
268
+ 'starting software-processing thread',
269
+ software_processing_thread=t,
270
+ software_processing_thread_index=i,
271
+ )
272
+ t.start()
273
+ logger.debug('done starting software-processing threads')
274
+
275
+ sw_loading_ok = load_and_enqueue_software(
276
+ sw_queue,
277
+ SoftwareNamePatternFilter(args.software_name_pattern),
278
+ )
279
+
280
+ logger.debug('shutting down software queue')
281
+ sw_queue.shutdown()
282
+
283
+ logger.debug('joining software-processing threads')
284
+ for i, t in enumerate(sw_proc_threads):
285
+ logger.debug(
286
+ 'joining software-processing thread',
287
+ software_processing_thread_index=i,
288
+ software_processing_thread=t,
289
+ )
290
+ t.join()
291
+ logger.debug('done joining software-processing threads')
292
+
293
+ ok = sw_loading_ok and all(sw_proc_thread_oks)
294
+ if not ok:
295
+ logger.error('one or more unrecoverable errors occurred')
296
+
297
+ exit_code = ExitCode.SUCCESS if ok else ExitCode.FAILURE
298
+ logger.info('exitting', exit_code=exit_code)
299
+ sys.exit(int(exit_code))
300
+
301
+ if __name__ == '__main__':
302
+ main()
veridret/_cli.py ADDED
@@ -0,0 +1,240 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """
4
+ The program's command-line interface.
5
+ """
6
+
7
+ import argparse
8
+ import dataclasses
9
+ import enum
10
+ import functools
11
+ import logging
12
+ import typing
13
+ from typing import Final
14
+
15
+ import regex
16
+
17
+ import veridret
18
+ from veridret._logging import Format as LogFormat
19
+ from veridret._version_info import Fields as VersionInfoFields
20
+ from veridret.logging import Level as RealLoggingLevel
21
+
22
+ __all__: Final = [
23
+ 'Args',
24
+ 'parse',
25
+ ]
26
+
27
+ def parse_regex(pattern: str) -> regex.Pattern[str]:
28
+ try:
29
+ return regex.compile(pattern)
30
+ except regex.error as e:
31
+ raise argparse.ArgumentTypeError(
32
+ f'invalid regular expression: {pattern!r}: {e}',
33
+ ) from e
34
+
35
+ def parse_version_info_fields(fields: str) -> VersionInfoFields:
36
+ def acc_func(
37
+ acc: VersionInfoFields, index_char_pair: tuple[int, str],
38
+ ) -> VersionInfoFields:
39
+ match index_char_pair:
40
+ case _, 'n':
41
+ field = VersionInfoFields.NAME
42
+ case _, 'l':
43
+ field = VersionInfoFields.LOCAL
44
+ case _, 'u':
45
+ field = VersionInfoFields.UPSTREAM
46
+ case _, 'c':
47
+ field = VersionInfoFields.COMPARISON
48
+ case i, c:
49
+ raise ValueError(f'invalid character at position {i}: {c!r}')
50
+ return acc | field
51
+
52
+ try:
53
+ return functools.reduce(
54
+ acc_func,
55
+ enumerate(fields),
56
+ initial=VersionInfoFields(0),
57
+ )
58
+ except ValueError as e:
59
+ raise (
60
+ argparse.ArgumentTypeError(f'invalid FIELDS: {fields!r}: {e}')
61
+ ) from e
62
+
63
+ class LoggingLevel(enum.Enum):
64
+ DEBUG = enum.auto()
65
+ INFO = enum.auto()
66
+ WARNING = enum.auto()
67
+ ERROR = enum.auto()
68
+ CRITICAL = enum.auto()
69
+ OFF = enum.auto()
70
+
71
+ @typing.override
72
+ def __str__(self) -> str:
73
+ return self.name.lower()
74
+
75
+ def get_logging_level(self) -> RealLoggingLevel | None:
76
+ match self:
77
+ case LoggingLevel.OFF:
78
+ return None
79
+ case _:
80
+ return getattr(logging, self.name)
81
+
82
+ def parse_enum_uppercase[T: enum.Enum](value: str, cls: type[T]) -> T:
83
+ try:
84
+ return cls[value.upper()]
85
+ except KeyError as e:
86
+ choices = ', '.join(repr(x.name.lower()) for x in cls)
87
+ raise argparse.ArgumentTypeError(
88
+ f'invalid choice: {value!r} (choose from {choices})',
89
+ ) from e
90
+
91
+ @dataclasses.dataclass(kw_only=True)
92
+ class Args:
93
+ """
94
+ The parsed arguments of the program.
95
+ """
96
+
97
+ software_name_pattern: regex.Pattern[str] | None
98
+ version_info_fields: VersionInfoFields
99
+ max_job_count: int | None
100
+ job_queue_capacity: int | None
101
+ log_format: LogFormat
102
+ min_logging_level: RealLoggingLevel | None
103
+
104
+ def parse() -> Args:
105
+ """
106
+ Parse the arguments to the program.
107
+
108
+ This function terminates the current process
109
+ if it cannot parse the program arguments into an ``Args``,
110
+ such as if the program arguments are incorrect
111
+ or if they include the command-line option ``--help``.
112
+
113
+ :return: The parsed program arguments.
114
+ """
115
+
116
+ parser = argparse.ArgumentParser(
117
+ description='''
118
+ VerIdRet (Version-Identifier Retriever)
119
+ is a program that retrieves the version identifiers
120
+ of the locally installed and upstream versions of software.
121
+ The program outputs the version information
122
+ of each piece of software configured with it
123
+ to standard output as a record,
124
+ specifically a JSON object,
125
+ in a JSON stream of MIME type application/json-seq.
126
+ ''',
127
+ )
128
+ parser.add_argument(
129
+ '-V', '--version',
130
+ action='version',
131
+ version=f'VerIdRet {veridret.__version__}',
132
+ help='''
133
+ show the program's version information and exit
134
+ ''',
135
+ )
136
+ parser.add_argument(
137
+ '-n', '--name',
138
+ type=parse_regex,
139
+ metavar='PATTERN',
140
+ help='''
141
+ process only software any substring of whose name matches this pattern,
142
+ whose syntax and semantics
143
+ are those of the patterns in the Python package "regex",
144
+ overriding the default behavior
145
+ of processing all software regardless of name
146
+ ''',
147
+ )
148
+ parser.add_argument(
149
+ '-f', '--fields',
150
+ type=parse_version_info_fields,
151
+ default='nluc',
152
+ help='''
153
+ what fields to place in the record,
154
+ a JSON object,
155
+ of each piece of software's version information
156
+ that is outputted to standard output;
157
+ each character of FIELDS represents
158
+ a field to include in the record,
159
+ the allowed characters being
160
+ 'n' for the name of the software
161
+ as the field "name",
162
+ 'l' for the locally installed version of the software
163
+ as the field "local",
164
+ 'u' for the upstream version of the software
165
+ as the field "upstream",
166
+ and 'c' for the comparison,
167
+ of the locally installed to the upstream version of the software,
168
+ as the field "comparison";
169
+ each field occurs at most once in the record,
170
+ no matter how many times its character occurs in FIELDS;
171
+ if computing the value of a field specified in FIELDS fails,
172
+ then the record will not contain that field
173
+ (computing the value of the field "name" never fails)
174
+ [default: %(default)r]
175
+ ''',
176
+ )
177
+ parser.add_argument(
178
+ '-j', '--jobs',
179
+ type=int,
180
+ metavar='N',
181
+ help='''
182
+ the maximum number of pieces of software to process in parallel;
183
+ overrides the setting 'max_job_count'
184
+ in the 'veridret.toml' configuration files
185
+ ''',
186
+ )
187
+ parser.add_argument(
188
+ '-Q', '--queue-capacity',
189
+ type=int,
190
+ metavar='N',
191
+ help='''
192
+ the maximum number of unprocessed pieces of software
193
+ to have queued for eventual processing;
194
+ overrides the setting 'job_queue_capacity'
195
+ in the 'veridret.toml' configuration files
196
+ ''',
197
+ )
198
+ parser.add_argument(
199
+ '-F', '--log-format',
200
+ metavar=f'{{{','.join(x.name.lower() for x in LogFormat)}}}',
201
+ type=lambda x: parse_enum_uppercase(x, LogFormat),
202
+ default=LogFormat.HUMAN,
203
+ help='''
204
+ the program's log's format;
205
+ case-insensitive;
206
+ 'human' writes each log entry to standard error upon a new line
207
+ and formats the entry in a human-readable way;
208
+ 'json' formats each log entry
209
+ as a JSON object of the form
210
+ {
211
+ "event": "<log-message>",
212
+ "level": "<logging-level>"
213
+ [, ...]
214
+ }
215
+ and writes such objects together to standard error
216
+ as a JSON stream of MIME type application/json-seq
217
+ [default: '%(default)s']
218
+ ''',
219
+ )
220
+ parser.add_argument(
221
+ '-m', '--min-logging-level', '--logging-level',
222
+ metavar=f'{{{','.join(x.name.lower() for x in LoggingLevel)}}}',
223
+ type=lambda x: parse_enum_uppercase(x, LoggingLevel),
224
+ default=LoggingLevel.INFO,
225
+ help='''
226
+ the minimum logging level;
227
+ case-insensitive
228
+ [default: '%(default)s']
229
+ ''',
230
+ )
231
+
232
+ args = parser.parse_args()
233
+ return Args(
234
+ software_name_pattern=args.name,
235
+ version_info_fields=args.fields,
236
+ max_job_count=args.jobs,
237
+ job_queue_capacity=args.queue_capacity,
238
+ log_format=args.log_format,
239
+ min_logging_level=args.min_logging_level.get_logging_level(),
240
+ )
@@ -0,0 +1,95 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+
3
+ """
4
+ An enumeration representing
5
+ the result of an ordered or unordered comparison.
6
+ """
7
+
8
+ import enum
9
+ import typing
10
+ from typing import Any, Final
11
+
12
+ __all__: Final = [
13
+ 'Comparison',
14
+ ]
15
+
16
+ class Comparison(enum.Enum):
17
+ """
18
+ An enumeration representing
19
+ the result of an ordered or unordered comparison.
20
+ """
21
+
22
+ LT = enum.auto()
23
+ EQ = enum.auto()
24
+ GT = enum.auto()
25
+ NE = enum.auto()
26
+
27
+ @typing.override
28
+ def __str__(self) -> str:
29
+ """
30
+ Convert this ``Comparison`` to a human-readable string.
31
+
32
+ :param self: This ``Comparison``.
33
+ :return: ``self.name``.
34
+ """
35
+
36
+ return self.name
37
+
38
+ def __neg__(self) -> Comparison:
39
+ """
40
+ Negate, or reflect, this ``Comparison``.
41
+
42
+ ``Comparison.LT`` and ``Comparison.GT`` are each other's reflection
43
+ while ``Comparison.EQ`` and ``Comparison.NE``
44
+ are each their own reflection.
45
+
46
+ :param self: This ``Comparison``.
47
+ :return: The negation, or reflection, of ``self``.
48
+ """
49
+
50
+ match self:
51
+ case Comparison.LT:
52
+ return Comparison.GT
53
+ case Comparison.EQ | Comparison.NE:
54
+ return self
55
+ case Comparison.GT:
56
+ return Comparison.LT
57
+
58
+ @staticmethod
59
+ def from_object_comparison(a: object, b: object) -> Comparison:
60
+ """
61
+ Compute the ``Comparison`` value
62
+ corresponding to the comparison between two objects.
63
+
64
+ :param a: The left-hand object to compare.
65
+ :param b: The right-hand object to compare.
66
+ :return:
67
+ If ``a < b`` does not raise a ``TypeError``,
68
+ then,
69
+ if ``a < b`` is true,
70
+ then the function returns ``Comparison.LT``,
71
+ else,
72
+ if ``a == b`` is true,
73
+ then the function returns ``Comparison.EQ``,
74
+ else the function returns ``Comparison.GT``;
75
+ if ``a < b`` does raise a ``TypeError``,
76
+ then,
77
+ if ``a == b`` is true,
78
+ then the function returns ``Comparison.EQ``,
79
+ else the function returns ``Comparison.NE``.
80
+ :raise Exception:
81
+ If ``a < b`` or ``a == b`` raises an exception,
82
+ then that exception is propagated,
83
+ except for a ``TypeError`` raised by ``a < b``.
84
+ """
85
+
86
+ try:
87
+ lt = typing.cast(Any, a) < b
88
+ except TypeError:
89
+ return Comparison.EQ if a == b else Comparison.NE
90
+
91
+ return (
92
+ Comparison.LT if lt
93
+ else Comparison.EQ if a == b
94
+ else Comparison.GT
95
+ )