|
| 1 | +##################################################################### |
| 2 | +# # |
| 3 | +# versions.py # |
| 4 | +# # |
| 5 | +# Copyright 2019, Chris Billington # |
| 6 | +# # |
| 7 | +# This file is part of the labscript suite (see # |
| 8 | +# http://labscriptsuite.org) and is licensed under the Simplified # |
| 9 | +# BSD License. See the license.txt file in the root of the project # |
| 10 | +# for the full license. # |
| 11 | +# # |
| 12 | +##################################################################### |
| 13 | +import sys |
| 14 | +import os |
| 15 | +import importlib |
| 16 | +import tokenize |
| 17 | +import ast |
| 18 | +import setuptools_scm |
| 19 | +import packaging.version |
| 20 | + |
| 21 | +try: |
| 22 | + import importlib.metadata as importlib_metadata |
| 23 | +except ImportError: |
| 24 | + import importlib_metadata |
| 25 | + |
| 26 | + |
| 27 | +class NotFound(object): |
| 28 | + pass |
| 29 | + |
| 30 | + |
| 31 | +class NoVersionInfo(object): |
| 32 | + pass |
| 33 | + |
| 34 | + |
| 35 | +class VersionException(RuntimeError): |
| 36 | + pass |
| 37 | + |
| 38 | + |
| 39 | +class BrokenInstall(RuntimeError): |
| 40 | + pass |
| 41 | + |
| 42 | + |
| 43 | +ERR_BROKEN_INSTALL = """Multiple metadata files for {package} found in {path}; cannot |
| 44 | +reliably get version information. This indicates a previous version of the package was |
| 45 | +not properly removed. You may want to uninstall the package, manually delete remaining |
| 46 | +metadata files/folders, then reinstall the package.""".replace('\n', ' ') |
| 47 | + |
| 48 | + |
| 49 | +def get_import_path(import_name): |
| 50 | + """Get which entry in sys.path a module would be imported from, without importing |
| 51 | + it.""" |
| 52 | + spec = importlib.util.find_spec(import_name) |
| 53 | + if spec is None: |
| 54 | + raise ModuleNotFoundError(import_name) |
| 55 | + location = spec.origin |
| 56 | + if location is None: |
| 57 | + # A namespace package |
| 58 | + msg = "Version checking of namespace packages not implemented" |
| 59 | + raise NotImplementedError(msg) |
| 60 | + if spec.parent: |
| 61 | + # A package: |
| 62 | + return os.path.dirname(os.path.dirname(location)) |
| 63 | + else: |
| 64 | + # A single-file module: |
| 65 | + return os.path.dirname(location) |
| 66 | + |
| 67 | + |
| 68 | +def _get_metadata_version(project_name, import_path): |
| 69 | + """Return the metadata version for a package with the given project name located at |
| 70 | + the given import path, or None if there is no such package.""" |
| 71 | + |
| 72 | + for finder in sys.meta_path: |
| 73 | + if hasattr(finder, 'find_distributions'): |
| 74 | + context = importlib_metadata.DistributionFinder.Context( |
| 75 | + name=project_name, path=[import_path] |
| 76 | + ) |
| 77 | + dists = finder.find_distributions(context) |
| 78 | + dists = list(dists) |
| 79 | + if len(dists) > 1: |
| 80 | + msg = ERR_BROKEN_INSTALL.format(package=project_name, path=import_path) |
| 81 | + raise BrokenInstall(msg) |
| 82 | + if dists: |
| 83 | + return dists[0].version |
| 84 | + |
| 85 | + |
| 86 | +def _get_literal_version(filename): |
| 87 | + """Tokenize a source file and return any __version__ = <version> literal defined in |
| 88 | + it. |
| 89 | + """ |
| 90 | + if not os.path.exists(filename): |
| 91 | + return None |
| 92 | + with open(filename, 'r') as f: |
| 93 | + try: |
| 94 | + tokens = list(tokenize.generate_tokens(f.readline)) |
| 95 | + except tokenize.TokenError: |
| 96 | + tokens = [] |
| 97 | + for i, token in enumerate(tokens[:-2]): |
| 98 | + token_type, token_str, _, _, _ = token |
| 99 | + if token_type == tokenize.NAME and token_str == '__version__': |
| 100 | + next_token_type, next_token_str, _, _, _ = tokens[i + 1] |
| 101 | + if next_token_type == tokenize.OP and next_token_str == '=': |
| 102 | + next_next_token_type, next_next_token_str, _, _, _ = tokens[i + 2] |
| 103 | + if next_next_token_type == tokenize.STRING: |
| 104 | + try: |
| 105 | + version = ast.literal_eval(next_next_token_str) |
| 106 | + if version is not None: |
| 107 | + return version |
| 108 | + except (SyntaxError, ValueError): |
| 109 | + continue |
| 110 | + |
| 111 | + |
| 112 | +def get_version(import_name, project_name=None, import_path=None): |
| 113 | + """Try very hard to get the version of a package without importing it. if |
| 114 | + import_path is not given, first find where it would be imported from, without |
| 115 | + importing it. Then look for metadata in the same import path with the given project |
| 116 | + name (note: this is not always the same as the import name, it is the name for |
| 117 | + example you would ask pip to install). If that is found, return the version info |
| 118 | + from it. Otherwise look for a __version__.py file in the package directory, or a |
| 119 | + __version__ = <version> literal defined in the package source (without executing |
| 120 | + it). |
| 121 | +
|
| 122 | + Return NotFound if the package cannot be found, and NoVersionInfo if the version |
| 123 | + cannot be obtained in the above way, or if it was found but was None.""" |
| 124 | + if project_name is None: |
| 125 | + project_name = import_name |
| 126 | + if '.' in import_name: |
| 127 | + msg = "Version checking of top-level packages only implemented" |
| 128 | + raise NotImplementedError(msg) |
| 129 | + if import_path is None: |
| 130 | + # Find the path where the module lives: |
| 131 | + try: |
| 132 | + import_path = get_import_path(import_name) |
| 133 | + except ImportError: |
| 134 | + return NotFound |
| 135 | + if not os.path.exists(os.path.join(import_path, import_name)): |
| 136 | + return NotFound |
| 137 | + try: |
| 138 | + # Check if setuptools_scm gives us a version number, for the case that it's a |
| 139 | + # git repo or PyPI tarball: |
| 140 | + return setuptools_scm.get_version(import_path) |
| 141 | + except LookupError: |
| 142 | + pass |
| 143 | + # Check if importlib_metadata knows about this module: |
| 144 | + version = _get_metadata_version(project_name, import_path) |
| 145 | + if version is not None: |
| 146 | + return version |
| 147 | + # Check if it has a version literal defined in a __version__.py file: |
| 148 | + version_dot_py = os.path.join(import_path, import_name, '__version__.py') |
| 149 | + version = _get_literal_version(version_dot_py) |
| 150 | + if version is not None: |
| 151 | + return version |
| 152 | + # check if it has a __version__ literal defined in its main module. |
| 153 | + pkg = os.path.join(import_path, import_name) |
| 154 | + if os.path.isdir(pkg): |
| 155 | + module_file = os.path.join(pkg, '__init__.py') |
| 156 | + else: |
| 157 | + module_file = pkg + '.py' |
| 158 | + version = _get_literal_version(module_file) |
| 159 | + if version is not None: |
| 160 | + return version |
| 161 | + return NoVersionInfo |
| 162 | + |
| 163 | + |
| 164 | +def check_version(module_name, at_least, less_than, version=None, project_name=None): |
| 165 | + """Check that the version of the given module is at least and less than the given |
| 166 | + version strings, and raise VersionException if not. Raise VersionException if the |
| 167 | + module was not found or its version could not be determined. This function uses |
| 168 | + get_version to determine version numbers without importing modules. In order to do |
| 169 | + this, project_name must be provided if it differs from module_name. For example, |
| 170 | + pyserial is imported as 'serial', but the project name, as passed to a 'pip install' |
| 171 | + command, is 'pyserial'. Therefore to check the version of pyserial, pass in |
| 172 | + module_name='serial' and project_name='pyserial'. You can also pass in a version |
| 173 | + string yourself, in which case no inspection of packages will take place. |
| 174 | + """ |
| 175 | + if version is None: |
| 176 | + version = get_version(module_name, project_name) |
| 177 | + |
| 178 | + if version is NotFound: |
| 179 | + raise VersionException('Module {} not found'.format(module_name)) |
| 180 | + |
| 181 | + if version is NoVersionInfo: |
| 182 | + raise VersionException( |
| 183 | + 'Could not get version info from module {}'.format(module_name) |
| 184 | + ) |
| 185 | + |
| 186 | + at_least_version, less_than_version, installed_version = [ |
| 187 | + packaging.version.parse(v) for v in [at_least, less_than, version] |
| 188 | + ] |
| 189 | + |
| 190 | + if not at_least_version <= installed_version < less_than_version: |
| 191 | + msg = ( |
| 192 | + '{module_name} {version} found. ' |
| 193 | + + '{at_least} <= {module_name} < {less_than} required.' |
| 194 | + ) |
| 195 | + raise VersionException(msg.format(**locals())) |
| 196 | + |
| 197 | + |
| 198 | +if __name__ == '__main__': |
| 199 | + assert get_version('subprocess') == NoVersionInfo |
| 200 | + assert get_version('plsgtbg') == NotFound |
| 201 | + assert type(get_version('labscript_utils')) in [str, bytes] |
| 202 | + assert type(get_version('numpy')) in [str, bytes] |
| 203 | + assert type(get_version('serial', 'pyserial')) in [str, bytes] |
0 commit comments