Repository navigation
Expand file tree
/
Copy pathversion.py
More file actions
741 lines (647 loc) · 28.8 KB
/
Copy pathversion.py
File metadata and controls
741 lines (647 loc) · 28.8 KB
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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
#! /usr/bin/env python3
# -*- coding: utf-8; py-indent-offset: 4 -*-
#
# Author: Linuxfabrik GmbH, Zurich, Switzerland
# Contact: info (at) linuxfabrik (dot) ch
# https://www.linuxfabrik.ch/
# License: The Unlicense, see LICENSE file.
# https://github.com/Linuxfabrik/lib/blob/main/CONTRIBUTING.md
"""Provides functions for handling software versions."""
__author__ = 'Linuxfabrik GmbH, Zurich/Switzerland'
__version__ = '2026102401'
import datetime
import json
import re
from .globals import STATE_OK, STATE_UNKNOWN, STATE_WARN
# The archive suites and components a distribution maintains itself. Backports, Ubuntu's
# universe (maintained by the community, not by Canonical) and third-party repositories
# are left out: their packages do not get the security support of the release.
_DEB_COMPONENTS = {'debian': ('main',), 'ubuntu': ('main', 'restricted')}
_DEB_SUITES = ('{}', '{}-security', '{}-updates')
def _debian_life_cycle(package_path, os_release, timeout, load_eol):
"""
Return the life cycle Debian or Ubuntu gives the package that installed the file.
A package from the archive of the release is maintained for as long as the release
gets security support: on Debian until the end of its LTS, on Ubuntu until the end of
the standard support of `main` and `restricted`.
Parameters
----------
package_path : str
The resolved path of the file the version was read from.
os_release : dict
`/etc/os-release` as returned by `_os_release()`.
timeout : int
Seconds to wait for `dpkg-query` and `apt-cache`.
load_eol : callable
Returns the endoflife.date entries for a product URL, or `None`.
Returns
-------
tuple (str, str) or None
A description of the life cycle and its end date (`YYYY-MM-DD`), or `None` where
it does not apply: a file no package owns, a package that does not come from the
archive of the release, or a release endoflife.date does not list.
"""
from . import shell
distro = os_release.get('ID', '')
codename = os_release.get('VERSION_CODENAME', '')
version_id = os_release.get('VERSION_ID', '')
if distro not in _DEB_COMPONENTS or not codename or not version_id:
return None
# "python3.10-minimal: /usr/bin/python3.10", or "pkg1, pkg2: path" where several
# packages ship the same directory. A diversion line names no package.
success, result = shell.shell_exec(
['dpkg-query', '--search', package_path], timeout=timeout
)
if not success or result[2] != 0:
return None
name = None
for line in result[0].splitlines():
owner, sep, path = line.partition(': ')
if sep and path == package_path and not owner.startswith('diversion '):
name = owner.split(',')[0].strip().split(':')[0]
break
if not name:
return None
# The sources of the installed version follow the line `*** <version> <pin>`, one
# per line, `<pin> <uri> <suite>/<component> <arch> Packages`, until the next version.
# Verified against apt 2.4 on Ubuntu 22.04 and apt 2.6 on Debian 12.
success, result = shell.shell_exec(['apt-cache', 'policy', name], timeout=timeout)
if not success or result[2] != 0:
return None
suites = [suite.format(codename) for suite in _DEB_SUITES]
from_archive = False
installed = False
for line in result[0].splitlines():
if line.startswith(' *** '):
installed = True
continue
if not installed:
continue
fields = line.split()
if len(fields) != 5 or fields[-1] != 'Packages':
break
suite, _, component = fields[2].partition('/')
if suite in suites and component in _DEB_COMPONENTS[distro]:
from_archive = True
if not from_archive:
return None
cycles = load_eol(f'https://endoflife.date/api/{distro}.json') or []
for item in cycles:
if isinstance(item, dict) and str(item.get('cycle')) == version_id:
eol = item.get('eol')
if isinstance(eol, str) and eol:
label = 'Debian' if distro == 'debian' else 'Ubuntu'
return f'{label} {version_id} package {name}', eol
return None
def _load_eol(product, insecure, no_proxy, proxy, timeout):
"""
Return the endoflife.date entries of a product: cached, online or bundled.
Returns
-------
tuple (list or None, bool, bool)
The entries, whether they come from the bundled offline snapshot, and whether
that is because the Python running the consumer lacks httpx. `None` if the
product is unknown everywhere.
Notes
-----
- A successful online lookup is cached locally for 24 hours. The bundled snapshot is
not cached, so the next call retries the online source instead of masking a
persistent outage.
"""
from . import cache, time, url
try:
eol_data = cache.get(product, filename='linuxfabrik-lib-version.db')
eol = json.loads(eol_data) if eol_data else None
except (json.JSONDecodeError, TypeError):
eol = None
if eol:
return eol, False, False
success, eol = url.fetch_json(
product, insecure=insecure, no_proxy=no_proxy, proxy=proxy, timeout=timeout
)
if success and eol:
cache.set(
product,
json.dumps(eol),
expire=time.now() + 86400,
filename='linuxfabrik-lib-version.db',
)
return eol, False, False
# endoflife.date is unreachable, or could not be asked at all because the Python
# running the consumer lacks httpx (started with the system Python instead
# of the one of its venv). The caller tells the two apart in its message: they are
# fixed in different places.
try:
from . import endoflifedate
return endoflifedate.ENDOFLIFE_DATE[product], True, url.httpx is None
except (ImportError, KeyError):
return None, False, False
def _os_release():
"""Return `ID`, `ID_LIKE` (a list), `VERSION_CODENAME` and `VERSION_ID`."""
from . import disk
success, content = disk.read_file('/etc/os-release')
if not success:
return {}
result = {}
for line in content.splitlines():
key, _, value = line.partition('=')
if key in ('ID', 'ID_LIKE', 'VERSION_CODENAME', 'VERSION_ID'):
result[key] = value.strip().strip('"\'')
result['ID_LIKE'] = result.get('ID_LIKE', '').split()
return result
# The vendor of a rebuild is whatever its packages say ("Red Hat, Inc.", "Rocky",
# "AlmaLinux"), so it is never compared against a list. A package counts as the
# distribution's own if it carries the vendor of the package that installed
# /etc/os-release. EPEL ("Fedora Project") and third-party repositories do not.
_RPM_QUERY_FORMAT = '%{NAME}\\t%{VENDOR}\\t%{VERSION}\\t%{MODULARITYLABEL}\\n'
def _rhel_life_cycle(package_path, timeout):
"""
Return the life cycle Red Hat gives the package that installed the file.
Red Hat maintains the software it ships for a fixed time that has nothing to do with
the end of life upstream announces: Python 3.9 is supported on RHEL 9 until 2032,
years after python.org dropped it.
Parameters
----------
package_path : str
The resolved path of the file the version was read from.
timeout : int
Seconds to wait for `rpm`.
Returns
-------
tuple (str, str) or None
A description of the life cycle and its end date (`YYYY-MM-DD`), or `None` where
it does not apply: no `rpm`, a file no package owns, a package from another
vendor, or a module stream Red Hat's data does not list.
Notes
-----
- The data comes from `rhelappstreams.py`, generated from what Red Hat publishes
for its Application Streams life cycle API.
- A package of the distribution vendor that Red Hat lists with no end date of its
own, or does not list at all, is maintained for the life of the major release.
That is the case for everything in BaseOS, Postfix for example.
"""
from . import shell
packages = []
for path in ('/etc/os-release', package_path):
success, result = shell.shell_exec(
['rpm', '--query', f'--queryformat={_RPM_QUERY_FORMAT}', '--file', path],
timeout=timeout,
)
if not success:
return None
stdout, _, retc = result
fields = stdout.splitlines()[0].split('\t') if stdout else []
if retc != 0 or len(fields) != 4:
return None
packages.append(fields)
(_, os_vendor, os_version, _), (name, vendor, _, modularity) = packages
if vendor != os_vendor:
return None
try:
from . import rhelappstreams
major = int(os_version.split('.')[0])
release = rhelappstreams.RHEL_APP_STREAMS[major]
except (ImportError, KeyError, ValueError):
return None
if modularity and modularity != '(none)':
# "python39:3.9:8100020251218081213:e3a3f2fc" is module, stream, version and
# context, the life cycle belongs to module and stream.
stream = release['modules'].get(':'.join(modularity.split(':')[:2]))
if stream is None:
return None
else:
stream = release['packages'].get(name)
if stream is None or stream[1] is None:
return f'RHEL {major} package {name}', release['eol']
return f'RHEL {major} Application Stream {stream[0]}', stream[1]
def _vendor_life_cycle(package_path, timeout, load_eol):
"""
Return the life cycle the distribution gives the package that installed a file.
Distributions maintain the software they ship for as long as they support the
release or the stream, which has nothing to do with the end of life upstream
announces. Supported are the rebuilds of Red Hat Enterprise Linux, Debian and Ubuntu.
Parameters
----------
package_path : str
The file the version was read from, a binary for example. A bare command name is
looked up in `PATH`, and symlinks are resolved, so `/usr/bin/python3` finds the
package of the interpreter it points to.
timeout : int
Seconds to wait for each package manager query.
load_eol : callable
Returns the endoflife.date entries for a product URL, or `None`.
Returns
-------
tuple (str, str) or None
A description of the life cycle and its end date (`YYYY-MM-DD`), or `None` where
the upstream end of life applies.
"""
import os
import shutil
if not isinstance(package_path, str) or not package_path:
return None
if os.sep not in package_path:
package_path = shutil.which(package_path)
if not package_path:
return None
package_path = os.path.realpath(package_path)
os_release = _os_release()
distro = os_release.get('ID', '')
if distro in _DEB_COMPONENTS:
return _debian_life_cycle(package_path, os_release, timeout, load_eol)
# CentOS Stream calls itself "centos" and carries ID_LIKE="rhel fedora", but runs
# ahead of RHEL with a shorter life, so only the rebuilds of RHEL itself count.
if distro != 'centos' and 'rhel' in [distro, *os_release.get('ID_LIKE', [])]:
return _rhel_life_cycle(package_path, timeout)
return None
def _cycle_parts(cycle):
"""Return the numbers of a release cycle that is a plain version (`3.10`, `22.04`)
as a tuple, and `None` for any other (`11-24h2-e`, `current`)."""
cycle = str(cycle)
# 18 digits at most, as in `version()`: `int()` refuses a string of thousands
if not re.fullmatch(r'\d{1,18}(?:\.\d{1,18})*', cycle):
return None
return tuple(int(part) for part in cycle.split('.'))
def _find_cycle(eol, parts):
"""Return the entry whose cycle names the version `parts`, compared as numbers.
Spelled out as text the lookup missed products such as Ubuntu, which endoflife.date
lists as `22.04` while a version reads `(22, 4, 0)`: a dozen products there pad a part
with a zero (amazon-linux, docker-engine, openwrt, nixos and Ubuntu among them)."""
for item in eol:
if isinstance(item, dict) and _cycle_parts(item.get('cycle')) == tuple(parts):
return item
return None
def _shown(latest, parsed):
"""The version of an available release as the message names it: the way endoflife.date
spells it where a part is zero-padded (`26.04.1`), else as the tuple reads."""
match = re.search(r'\d+(?:[._]\d+)*', str(latest))
spelled = '.'.join(re.split(r'[._]', match.group())[:3]) if match else ''
if re.search(r'(?:^|\.)0\d', spelled):
return spelled
return '.'.join(map(str, parsed))
def check_eol(
product,
version_string,
offset_eol=-30,
check_major=False,
check_minor=False,
check_patch=False,
pattern='%Y-%m-%d',
extended_support=False,
insecure=False,
no_proxy=False,
proxy=None,
timeout=8,
unreachable_severity='ok',
cycle=None,
package_path=None,
):
"""
Check if a software version is End of Life (EOL) by comparing it to endoflife.date data.
This function checks the EOL status based on local cache, online API or bundled definitions.
It reports whether the installed version is outdated, nearing EOL, or fully supported.
Parameters
----------
product : str
Product name or endoflife.date JSON URL.
version_string : str
The version string of the installed software. Without `cycle`, a string that
contains no digit, or `None`, is answered with UNKNOWN, since there is no
version to place.
offset_eol : int, optional
Days before EOL to trigger a warning. Default: `-30`.
check_major : bool, optional
Warn if a newer major version exists.
check_minor : bool, optional
Warn if a newer minor version exists.
check_patch : bool, optional
Warn if a newer patch version exists.
pattern : str, optional
Datetime parsing pattern. Default: `'%Y-%m-%d'`.
extended_support : bool, optional
Check extended support EOL if available.
insecure : bool, optional
Disable SSL certificate verification.
no_proxy : bool, optional
Ignore proxy settings.
proxy : str, optional
Proxy to reach the endoflife.date API through, overriding the one the
environment names. Defaults to `None`, which leaves the choice to the
environment.
timeout : int, optional
Network timeout in seconds. Default: `8`.
unreachable_severity : str, optional
State to report when endoflife.date is
unreachable and the lookup falls back to the bundled offline data. One of `'ok'`, `'warn'`,
`'crit'` or `'unknown'`. Default: `'ok'`.
cycle : str or list of str, optional
The endoflife.date cycle to evaluate, for products whose cycles are not named
after a version number (`'11-24h2-e'`, `'2012-r2'`). A list names candidates
from the most to the least specific, and the first one the data lists is used.
`version_string` then only appears in the message, and neither the newer
release check nor the placement of an unlisted version above or below the
listed cycles takes place. Default: `None`, which derives the cycle from
`version_string`.
package_path : str, optional
The file the version was read from, a binary for example. Where that file comes
from a package the distribution maintains itself, the end of life is the one
the distribution gives it rather than the one upstream announces, and the
message names it: Red Hat's for a package or Application Stream on a rebuild of
Red Hat Enterprise Linux, the end of security support (LTS included) of the
release on Debian, and of the standard support of `main` and `restricted` on
Ubuntu. The newer release check still compares against upstream. Default:
`None`, which always uses the upstream end of life.
Returns
-------
tuple (int, str)
Nagios state and a descriptive status message.
Notes
-----
- A successful online lookup is cached locally for 24 hours. The bundled offline fallback is
not cached, so the next call retries the online source instead of masking a persistent
outage from `unreachable_severity`.
Examples
--------
>>> check_eol('https://endoflife.date/api/python.json', '3.10')
(1, 'EOL 2026-10-01')
"""
# Imported here, not at module level: `check_eol()` is the only consumer of
# these, and importing them eagerly would pull cache, db_sqlite and url into
# every module that only wants the pure `version()` / `version2float()`
# parsers below.
from . import base, time
# `version()` reads a string without a digit ("", "unknown", "n/a") as 0.0.0, which
# sorts below every listed cycle and would be reported as out of support. With a
# named cycle the version string only appears in the message.
if cycle is None and not (
isinstance(version_string, str) and re.search(r'\d', version_string)
):
return STATE_UNKNOWN, f'no version number in "{version_string}"'
now = time.now(as_type='datetime')
eol, used_fallback, missing_httpx = _load_eol(
product, insecure, no_proxy, proxy, timeout
)
if eol is None:
return STATE_UNKNOWN, f'product {product} unknown'
unreachable_state = (
base.str2state(unreachable_severity) if used_fallback else STATE_OK
)
unreachable_note = ''
if used_fallback:
unreachable_note = (
', Python module "httpx" is not installed, using bundled data'
if missing_httpx
else ', endoflife.date unreachable, using bundled data'
)
# only read where no cycle is named, see above
installed = version(version_string) if cycle is None else None
cycles_eoldate = None
if cycle is not None:
# Some products name their cycles after an edition or a servicing channel
# ("11-24h2-e", "23h2-ac") rather than after a version, so no prefix of the
# version string finds them. The caller knows which cycle applies.
for candidate in [cycle] if isinstance(cycle, str) else cycle:
_, cycles_eoldate = base.lookup_lod(eol, 'cycle', candidate)
if cycles_eoldate:
break
else:
for i in range(1, len(installed) + 1):
cycles_eoldate = _find_cycle(eol, installed[:i])
if cycles_eoldate:
break
msg = []
state = STATE_OK
vendor_life_cycle = None
if package_path:
vendor_life_cycle = _vendor_life_cycle(
package_path,
timeout,
lambda distro_product: _load_eol(
distro_product, insecure, no_proxy, proxy, timeout
)[0],
)
if vendor_life_cycle:
# The distribution maintains this build itself, so its date is the one that
# counts, whether or not upstream still lists the cycle.
label, eol_date = vendor_life_cycle
eol_dt = time.timestr2datetime(eol_date, pattern='%Y-%m-%d')
msg.append(
f'{label}, EOL {eol_date} {"+" if offset_eol > 0 else ""}{offset_eol}d'
)
if now > eol_dt + datetime.timedelta(days=offset_eol):
state = STATE_WARN
msg.append(base.state2str(state, prefix=' '))
elif not cycles_eoldate:
# endoflife.date lists the product but not this cycle, and where the installed
# version falls relative to what is listed says what that means. A version above
# everything listed is a host that upstream has not catalogued yet, which nobody
# can act on; one below everything listed is older than the oldest cycle upstream
# still records, and therefore out of support for certain. Only a gap between the
# two is genuinely unknown. Cycles named by the caller have no such order.
oldest, newest = (None, None) if cycle is not None else cycle_bounds(eol)
if newest is not None and installed > newest:
msg.append('newer than anything endoflife.date lists')
elif oldest is not None and installed < oldest:
state = STATE_WARN
msg.append('older than anything endoflife.date lists')
msg.append(base.state2str(state, prefix=' '))
else:
state = STATE_UNKNOWN
msg.append(f'version {version_string} unknown')
else:
eol_key = (
'extendedSupport'
if extended_support and cycles_eoldate.get('extendedSupport')
else 'eol'
)
eol_date = cycles_eoldate.get(eol_key)
# Where full support ends on the day of the end of life (Windows 10 22H2 without
# Extended Security Updates, for example), the EOL below says it all.
support = cycles_eoldate.get('support')
if support and isinstance(support, str) and support != eol_date:
if now > time.timestr2datetime(support, pattern=pattern):
msg.append(f'full support ended on {support}; ')
# The API answers this field in three shapes and they are not interchangeable:
# a date string, `true` (end of life reached, no date given) and `false` (no end
# announced). Reading either boolean as a date is what used to raise a TypeError
# here, and reading `false` as a missing date reported the healthiest answer the
# API gives as a gap in our knowledge.
if isinstance(eol_date, str) and eol_date:
eol_dt = time.timestr2datetime(eol_date, pattern=pattern)
msg.append(f'EOL {eol_date} {"+" if offset_eol > 0 else ""}{offset_eol}d')
if now > eol_dt + datetime.timedelta(days=offset_eol):
state = STATE_WARN
msg.append(base.state2str(state, prefix=' '))
elif eol_date is True:
state = STATE_WARN
msg.append('EOL, no date announced')
msg.append(base.state2str(state, prefix=' '))
else:
msg.append('no EOL announced')
# The `latest` of a named cycle is not comparable to the version string: Windows
# reports the same build number there for every edition of a feature release.
if cycle is not None:
state = base.get_worst(state, unreachable_state)
return state, ''.join(msg) + unreachable_note
try:
latest_versions = [(version(item['latest']), item['latest']) for item in eol]
except (TypeError, KeyError):
state = base.get_worst(state, unreachable_state)
# the parts carry their own separators, as in every other return here
return state, ''.join(msg) + unreachable_note
major, minor, patch = installed
for (v_major, v_minor, v_patch), latest in latest_versions:
available = _shown(latest, (v_major, v_minor, v_patch))
if v_major > major:
msg.append(f', major {available} available')
if check_major:
state = STATE_WARN
break
if v_major == major and v_minor > minor:
msg.append(f', minor {available} available')
if check_minor:
state = STATE_WARN
break
if v_major == major and v_minor == minor and v_patch > patch:
msg.append(f', patch {available} available')
if check_patch:
state = STATE_WARN
break
state = base.get_worst(state, unreachable_state)
return state, ''.join(msg) + unreachable_note
def cycle_bounds(eol):
"""
Return the lowest and highest release cycle a set of endoflife.date entries names.
Used to place a version that has no cycle of its own: above the highest cycle the
data has simply not caught up yet, below the lowest it is older than anything
upstream still records.
Parameters
----------
eol : list
endoflife.date entries, each a dict that may carry a `cycle` key.
Returns
-------
tuple (tuple or None, tuple or None)
The lowest and the highest cycle as comparable version tuples, or `(None, None)`
when no entry names a parsable cycle.
Examples
--------
>>> cycle_bounds([{'cycle': '8.4'}, {'cycle': '5.7'}])
((5, 7, 0), (8, 4, 0))
"""
cycles = []
for item in eol:
if not isinstance(item, dict) or item.get('cycle') is None:
continue
cycle = str(item['cycle'])
# endoflife.date names 166 of its 8444 cycles without a single digit
# ("current", "subscription", "nodejs", ...). `version()` turns those into
# (0, 0, 0) rather than raising, which would drag the lower bound to zero and
# silently disable the "older than anything listed" verdict for that product.
if not any(char.isdigit() for char in cycle):
continue
# A cycle named after an edition or a servicing channel ("11-24h2-e",
# "23h2-ac", "2012-r2") has no order among the version numbers either.
if not re.fullmatch(r'\d{1,18}(?:\.\d{1,18})*', cycle):
continue
cycles.append(version(cycle))
if not cycles:
return None, None
return min(cycles), max(cycles)
def version(ver, maxlen=3):
"""
Parse a version string and return a comparable tuple.
This function converts a (semantic) version string into a tuple of integers. Only the
first run of numbers joined by `.` (or `_`, as in Java's `1.8.0_504`) counts, so a
prefix (`v`, `OpenSSL `) is skipped and a pre-release or build suffix (`rc2`,
`+build5`, `-4-pve`) is dropped instead of being read as further version parts. A
release candidate therefore compares equal to its final release, never above it.
Useful for comparing version numbers.
Parameters
----------
ver : str
A version string (e.g., "v5.13.19-4-pve").
maxlen : int, optional
Desired tuple length. Defaults to `3`.
Returns
-------
tuple
A tuple of integers representing the version, e.g., `(5, 13, 19)`.
Examples
--------
>>> version('1')
(1, 0, 0)
>>> version('1.2')
(1, 2, 0)
>>> version('v5.13.19-4-pve')
(5, 13, 19)
>>> version('v5.13.19-4-pve', maxlen=4)
(5, 13, 19, 0)
>>> version('3.11.0rc2') == version('3.11.0')
True
>>> version('1.8.0_504', maxlen=4)
(1, 8, 0, 504)
>>> version('3.0.7') < version('3.0.11')
True
>>> version(psutil.__version__) >= version('5.3.0')
True
"""
# Deleting every non-digit first would glue neighbouring numbers together:
# '3.11.0rc2' became (3, 11, 2), above the final 3.11.0 and even 3.11.1, and
# 'OpenSSL 3.0.2 15 Mar 2022' became (3, 0, 2152022).
# No library does this job: distutils.version.LooseVersion (gone since Python 3.12)
# ranks 3.11.0rc2 above 3.11.0 and raises TypeError on 'v17.3.2' against '17.3.10';
# packaging.version (PEP 440, packaging 26.3) rejects kernel, Java 8 and OpenSSL
# strings; rpmvercmp reads anything but ranks rc2 above the release as well. None of
# them yields the (major, minor, patch) tuple the endoflife.date cycles are looked
# up by.
# A part is read with 18 digits at most. The string can come from a remote system,
# and Python refuses to convert one of more than 4300 digits with a ValueError
# (`sys.get_int_max_str_digits()`, since 3.11 and the security releases before).
match = re.search(r'\d{1,18}(?:[._]\d{1,18})*', ver)
parts = [int(p) for p in re.split(r'[._]', match.group())] if match else []
# Pad with zeros or truncate to match maxlen
if len(parts) < maxlen:
parts.extend([0] * (maxlen - len(parts)))
else:
parts = parts[:maxlen]
return tuple(parts)
def version2float(ver):
"""
Convert a version string into a single float value, for perfdata.
The version is read like `version()` does, with up to four parts, and each part after
the first gets three decimal places of its own, so the float sorts like the version:
`1.10.0` (1.01) lies above `1.9.0` (1.009). A part above 999 is counted as 999, since
it would otherwise spill into the part before it.
Parameters
----------
ver : str
A version string, e.g., `"Version v17.3.2.0"`.
Returns
-------
float
Version represented as a float.
Raises
------
ValueError
If the input does not contain any digits.
Examples
--------
>>> version2float('Version v17.3.2.0')
17.003002
>>> version2float('Fedora Linux 41 (Workstation Edition)')
41.0
>>> version2float('1.10.0') > version2float('1.9.0')
True
>>> version2float('1.8.0_504')
1.008000504
"""
if not re.search(r'\d', ver):
raise ValueError(f'No digits found in version string: {ver}')
parts = version(ver, maxlen=4)
value = parts[0] + sum(
min(part, 999) / 1000**position for position, part in enumerate(parts[1:], 1)
)
# rounded to the nine decimal places the three parts take, which drops the float
# noise of the division (1.003 instead of 1.0030000000000001)
return round(value, 9)