Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 89 additions & 28 deletions Lib/platform.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#!/usr/bin/env python3

""" This module tries to retrieve as much platform-identifying data as
possible. It makes this information available via function APIs.

Expand Down Expand Up @@ -33,6 +31,7 @@
#
# <see CVS and SVN checkin messages for history>
#
# 1.0.9 - added invalidate_caches() function to invalidate cached values
# 1.0.8 - changed Windows support to read version from kernel32.dll
# 1.0.7 - added DEV_NULL
# 1.0.6 - added linux_distribution()
Expand Down Expand Up @@ -111,7 +110,7 @@

"""

__version__ = '1.0.8'
__version__ = '1.0.9'

import collections
import os
Expand Down Expand Up @@ -174,6 +173,11 @@ def libc_ver(executable=None, lib='', version='', chunksize=16384):

"""
if not executable:
if sys.platform == "emscripten":
# Emscripten's os.confstr reports that it is glibc, so special case
# it.
ver = ".".join(str(x) for x in sys._emscripten_info.emscripten_version)
return ("emscripten", ver)
try:
ver = os.confstr('CS_GNU_LIBC_VERSION')
# parse 'glibc 2.28' as ('glibc', '2.28')
Expand All @@ -190,22 +194,26 @@ def libc_ver(executable=None, lib='', version='', chunksize=16384):
# sys.executable is not set.
return lib, version

libc_search = re.compile(b'(__libc_init)'
b'|'
b'(GLIBC_([0-9.]+))'
b'|'
br'(libc(_\w+)?\.so(?:\.(\d[0-9.]*))?)', re.ASCII)
libc_search = re.compile(br"""
(__libc_init)
| (GLIBC_([0-9.]+))
| (libc(_\w+)?\.so(?:\.(\d[0-9.]*))?)
| (musl-([0-9.]+))
| ((?:libc\.|ld-)musl(?:-\w+)?.so(?:\.(\d[0-9.]*))?)
""",
re.ASCII | re.VERBOSE)

V = _comparable_version
# We use os.path.realpath()
# here to work around problems with Cygwin not being
# able to open symlinks for reading
executable = os.path.realpath(executable)
ver = None
with open(executable, 'rb') as f:
binary = f.read(chunksize)
pos = 0
while pos < len(binary):
if b'libc' in binary or b'GLIBC' in binary:
if b'libc' in binary or b'GLIBC' in binary or b'musl' in binary:
m = libc_search.search(binary, pos)
else:
m = None
Expand All @@ -217,26 +225,35 @@ def libc_ver(executable=None, lib='', version='', chunksize=16384):
continue
if not m:
break
libcinit, glibc, glibcversion, so, threads, soversion = [
s.decode('latin1') if s is not None else s
for s in m.groups()]
decoded_groups = [s.decode('latin1') if s is not None else s
for s in m.groups()]
(libcinit, glibc, glibcversion, so, threads, soversion,
musl, muslversion, musl_so, musl_sover) = decoded_groups
if libcinit and not lib:
lib = 'libc'
elif glibc:
if lib != 'glibc':
lib = 'glibc'
version = glibcversion
elif V(glibcversion) > V(version):
version = glibcversion
ver = glibcversion
elif V(glibcversion) > V(ver):
ver = glibcversion
elif so:
if lib != 'glibc':
if lib not in ('glibc', 'musl'):
lib = 'libc'
if soversion and (not version or V(soversion) > V(version)):
version = soversion
if threads and version[-len(threads):] != threads:
version = version + threads
if soversion and (not ver or V(soversion) > V(ver)):
ver = soversion
if threads and ver[-len(threads):] != threads:
ver = ver + threads
elif musl:
lib = 'musl'
if not ver or V(muslversion) > V(ver):
ver = muslversion
elif musl_so:
lib = 'musl'
if musl_sover and (not ver or V(musl_sover) > V(ver)):
ver = musl_sover
pos = m.end()
return lib, version
return lib, version if ver is None else ver

def _norm_version(version, build=''):

Expand Down Expand Up @@ -549,7 +566,7 @@ def java_ver(release='', vendor='', vminfo=('', '', ''), osinfo=('', '', '')):
warnings._deprecated('java_ver', remove=(3, 15))
# Import the needed APIs
try:
import java.lang
import java.lang # noqa: F401
except ImportError:
return release, vendor, vminfo, osinfo

Expand Down Expand Up @@ -1192,7 +1209,7 @@ def _sys_version(sys_version=None):
# CPython
cpython_sys_version_parser = re.compile(
r'([\w.+]+)\s*' # "version<space>"
r'(?:experimental free-threading build\s+)?' # "free-threading-build<space>"
r'(?:free-threading build\s+)?' # "free-threading-build<space>"
r'\(#?([^,]+)' # "(#buildno"
r'(?:,\s*([\w ]*)' # ", builddate"
r'(?:,\s*([\w :]*))?)?\)\s*' # ", buildtime)<space>"
Expand Down Expand Up @@ -1449,11 +1466,55 @@ def freedesktop_os_release():
return _os_release_cache.copy()


def invalidate_caches():
"""Invalidate the cached results."""
global _uname_cache
_uname_cache = None

global _os_release_cache
_os_release_cache = None

_sys_version_cache.clear()
_platform_cache.clear()


### Command line interface

if __name__ == '__main__':
# Default is to print the aliased verbose platform string
terse = ('terse' in sys.argv or '--terse' in sys.argv)
aliased = (not 'nonaliased' in sys.argv and not '--nonaliased' in sys.argv)
def _parse_args(args: list[str] | None):
import argparse

parser = argparse.ArgumentParser(color=True)
parser.add_argument("args", nargs="*", choices=["nonaliased", "terse"])
parser.add_argument(
"--terse",
action="store_true",
help=(
"return only the absolute minimum information needed "
"to identify the platform"
),
)
parser.add_argument(
"--nonaliased",
dest="aliased",
action="store_false",
help=(
"disable system/OS name aliasing. If aliasing is enabled, "
"some platforms report system names different from "
"their common names, e.g. SunOS is reported as Solaris"
),
)

return parser.parse_args(args)


def _main(args: list[str] | None = None):
args = _parse_args(args)

terse = args.terse or ("terse" in args.args)
aliased = args.aliased and ('nonaliased' not in args.args)

print(platform(aliased, terse))
sys.exit(0)


if __name__ == "__main__":
_main()
151 changes: 140 additions & 11 deletions Lib/test/test_platform.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import os
import contextlib
import copy
import io
import itertools
import os
import pickle
import platform
import subprocess
Expand Down Expand Up @@ -83,6 +86,38 @@ def clear_caches(self):
platform._uname_cache = None
platform._os_release_cache = None

def test_invalidate_caches(self):
self.clear_caches()

self.assertDictEqual(platform._platform_cache, {})
self.assertDictEqual(platform._sys_version_cache, {})
self.assertIsNone(platform._uname_cache)
self.assertIsNone(platform._os_release_cache)

# fill the cached entries (some have side effects on others)
platform.platform() # for platform._platform_cache
platform.python_implementation() # for platform._sys_version_cache
platform.uname() # for platform._uname_cache

# check that the cache are filled
self.assertNotEqual(platform._platform_cache, {})
self.assertNotEqual(platform._sys_version_cache, {})
self.assertIsNotNone(platform._uname_cache)

try:
platform.freedesktop_os_release()
except OSError:
self.assertIsNone(platform._os_release_cache)
else:
self.assertIsNotNone(platform._os_release_cache)

with self.subTest('clear platform caches'):
platform.invalidate_caches()
self.assertDictEqual(platform._platform_cache, {})
self.assertDictEqual(platform._sys_version_cache, {})
self.assertIsNone(platform._uname_cache)
self.assertIsNone(platform._os_release_cache)

def test_architecture(self):
res = platform.architecture()

Expand Down Expand Up @@ -375,7 +410,7 @@ def test_win32_ver(self):
for v in version.split('.'):
int(v) # should not fail
if csd:
self.assertTrue(csd.startswith('SP'), msg=csd)
self.assertStartsWith(csd, 'SP')
if ptype:
if os.cpu_count() > 1:
self.assertIn('Multiprocessor', ptype)
Expand Down Expand Up @@ -490,8 +525,10 @@ def test_ios_ver(self):
self.assertEqual(override.model, "Whiz")
self.assertTrue(override.is_simulator)

@unittest.skipIf(support.is_emscripten, "Does not apply to Emscripten")
def test_libc_ver(self):
if support.is_emscripten:
assert platform.libc_ver() == ("emscripten", "4.0.12")
return
# check that libc_ver(executable) doesn't raise an exception
if os.path.isdir(sys.executable) and \
os.path.exists(sys.executable+'.exe'):
Expand Down Expand Up @@ -519,6 +556,14 @@ def test_libc_ver(self):
(b'GLIBC_2.9', ('glibc', '2.9')),
(b'libc.so.1.2.5', ('libc', '1.2.5')),
(b'libc_pthread.so.1.2.5', ('libc', '1.2.5_pthread')),
(b'/aports/main/musl/src/musl-1.2.5', ('musl', '1.2.5')),
# musl uses semver, but we accept some variations anyway:
(b'/aports/main/musl/src/musl-12.5', ('musl', '12.5')),
(b'/aports/main/musl/src/musl-1.2.5.7', ('musl', '1.2.5.7')),
(b'libc.musl.so.1', ('musl', '1')),
(b'libc.musl-x86_64.so.1.2.5', ('musl', '1.2.5')),
(b'ld-musl.so.1', ('musl', '1')),
(b'ld-musl-x86_64.so.1.2.5', ('musl', '1.2.5')),
(b'', ('', '')),
):
with open(filename, 'wb') as fp:
Expand All @@ -530,14 +575,37 @@ def test_libc_ver(self):
expected)

# binary containing multiple versions: get the most recent,
# make sure that 1.9 is seen as older than 1.23.4
chunksize = 16384
with open(filename, 'wb') as f:
# test match at chunk boundary
f.write(b'x'*(chunksize - 10))
f.write(b'GLIBC_1.23.4\0GLIBC_1.9\0GLIBC_1.21\0')
self.assertEqual(platform.libc_ver(filename, chunksize=chunksize),
('glibc', '1.23.4'))
# make sure that eg 1.9 is seen as older than 1.23.4, and that
# the arguments don't count even if they are set.
chunksize = 200
for data, expected in (
(b'GLIBC_1.23.4\0GLIBC_1.9\0GLIBC_1.21\0', ('glibc', '1.23.4')),
(b'libc.so.2.4\0libc.so.9\0libc.so.23.1\0', ('libc', '23.1')),
(b'musl-1.4.1\0musl-2.1.1\0musl-2.0.1\0', ('musl', '2.1.1')),
(
b'libc.musl-x86_64.so.1.4.1\0libc.musl-x86_64.so.2.1.1\0libc.musl-x86_64.so.2.0.1',
('musl', '2.1.1'),
),
(
b'ld-musl-x86_64.so.1.4.1\0ld-musl-x86_64.so.2.1.1\0ld-musl-x86_64.so.2.0.1',
('musl', '2.1.1'),
),
(b'no match here, so defaults are used', ('test', '100.1.0')),
):
with open(filename, 'wb') as f:
# test match at chunk boundary
f.write(b'x'*(chunksize - 10))
f.write(data)
self.assertEqual(
expected,
platform.libc_ver(
filename,
lib='test',
version='100.1.0',
chunksize=chunksize,
),
)


def test_android_ver(self):
res = platform.android_ver()
Expand Down Expand Up @@ -690,5 +758,66 @@ def test_parse_os_release(self):
self.assertEqual(len(info["SPECIALS"]), 5)


class CommandLineTest(unittest.TestCase):
def setUp(self):
platform.invalidate_caches()
self.addCleanup(platform.invalidate_caches)

def invoke_platform(self, *flags):
output = io.StringIO()
with contextlib.redirect_stdout(output):
platform._main(args=flags)
return output.getvalue()

def test_unknown_flag(self):
with self.assertRaises(SystemExit):
output = io.StringIO()
# suppress argparse error message
with contextlib.redirect_stderr(output):
_ = self.invoke_platform('--unknown')
self.assertStartsWith(output, "usage: ")

def test_invocation(self):
flags = (
"--terse", "--nonaliased", "terse", "nonaliased"
)

for r in range(len(flags) + 1):
for combination in itertools.combinations(flags, r):
self.invoke_platform(*combination)

def test_arg_parsing(self):
# For backwards compatibility, the `aliased` and `terse` parameters are
# computed based on a combination of positional arguments and flags.
#
# Test that the arguments are correctly passed to the underlying
# `platform.platform()` call.
options = (
(["--nonaliased"], False, False),
(["nonaliased"], False, False),
(["--terse"], True, True),
(["terse"], True, True),
(["nonaliased", "terse"], False, True),
(["--nonaliased", "terse"], False, True),
(["--terse", "nonaliased"], False, True),
)

for flags, aliased, terse in options:
with self.subTest(flags=flags, aliased=aliased, terse=terse):
with mock.patch.object(platform, 'platform') as obj:
self.invoke_platform(*flags)
obj.assert_called_once_with(aliased, terse)

@support.force_not_colorized
def test_help(self):
output = io.StringIO()

with self.assertRaises(SystemExit):
with contextlib.redirect_stdout(output):
platform._main(args=["--help"])

self.assertStartsWith(output.getvalue(), "usage:")


if __name__ == '__main__':
unittest.main()
Loading