"""
versionfinder/versionfinder.py
The latest version of this package is available at:
<https://github.com/jantman/versionfinder>
################################################################################
Copyright 2016 Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
This file is part of versionfinder.
versionfinder is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
versionfinder is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with versionfinder. If not, see <http://www.gnu.org/licenses/>.
The Copyright and Authors attributions contained herein may not be removed or
otherwise altered, except to add the Author attribution of a contributor to
this work. (Additional Terms pursuant to Section 7b of the GPL v3)
################################################################################
While not legally required, I sincerely request that anyone who finds
bugs please submit them at <https://github.com/jantman/versionfinder> or
to me via email, and that you send any contributions or improvements
either as a pull request on GitHub, or to me via email.
################################################################################
AUTHORS:
Jason Antman <jason@jasonantman.com> <http://www.jasonantman.com>
################################################################################
"""
import sys
import os
import logging
import inspect
from contextlib import contextmanager
import warnings
from .versioninfo import VersionInfo
# Note: we catch all exceptions here because of
# https://github.com/jantman/versionfinder/issues/7 - some pip versions
# throw an import-time AttributeError when running in Lambda, or other
# environments where sys.stdin is None. Per that issue, the right thing to
# do is never fail if pip can't be imported.
# This was fixed in https://github.com/pypa/pip/pull/7118 / pip 19.3
try:
from pip._internal.operations.freeze import FrozenRequirement
except Exception: # nocoverage
try:
from pip._internal import FrozenRequirement
except Exception:
try:
from pip import FrozenRequirement
except Exception:
# this is used within try blocks; NBD if they fail
pass
try:
from pip._internal.utils.misc import get_installed_distributions
except Exception: # nocoverage
try:
from pip._internal import get_installed_distributions
except Exception:
try:
from pip import get_installed_distributions
except Exception:
# this is used within try blocks; NBD if they fail
pass
try:
import pkg_resources
except ImportError:
# this is used within try blocks; NBD if they fail
pass
try:
from git import Repo
except Exception: # nocoverage
# this is used within try blocks; NBD if they fail
pass
logger = logging.getLogger(__name__)
warnings.filterwarnings(
action="always", category=DeprecationWarning, module=__name__
)
[docs]class VersionFinder(object):
def __init__(self, package_name, package_file=None, log=False,
caller_frame=None):
"""
Initialize a VersionFinder to find version information of the named
package, which includes a given file. ``package_file`` must be a Python
file in the package; if not specified, the file calling this class
will be used.
VersionFinder logs rather verbosely to ``logging.debug()`` if ``log``
is True. To simplify use as a library, unless you set ``log`` to True,
versionfinder's logger will be set to a level of ``logging.CRITICAL``,
suppressing all log messages. This will also silence the ``pip`` logger.
:param package_name: name of the package to find information about
:type package_name: str
:param package_file: absolute path to a Python source file in the
package to find information about; if not specified, the file calling
this class will be used
:type package_file: str
:param log: If not set to True, the "versionfinder" and "pip" loggers
will be set to a level of :py:const:`logging.CRITICAL` to suppress
log output. The "pip.subprocessor" logger will be completely disabled.
If set to True, you will see a LOT of debug-level log output, for
debugging the internals of versionfinder.
:type log: bool
:param caller_frame: If the call to this method is wrapped by something
else, this should be the stack frame representing the original caller.
Not used if ``package_file`` is specified. See
:py:func:`versionfinder.find_version` for an example.
:type caller_frame: frame
"""
if not log:
logger.setLevel(logging.CRITICAL)
pip_log = logging.getLogger("pip")
pip_log.setLevel(logging.CRITICAL)
pip_log.propagate = True
pip_s_log = logging.getLogger('pip.subprocessor')
pip_s_log.disabled = True
logger.debug("Finding package version for: %s", package_name)
self.package_name = package_name
if package_file is not None:
logger.debug("Explicit package file: %s", package_file)
self.package_file = package_file
else:
if caller_frame is None:
caller_frame = inspect.stack()[1][0]
self.package_file = os.path.abspath(
inspect.getframeinfo(caller_frame).filename)
logger.debug("Found package_file as: %s", self.package_file)
self.package_dir = os.path.dirname(self.package_file)
logger.debug('package_dir: %s' % self.package_dir)
self._pip_locations = []
self._pkg_resources_locations = []
if (
sys.version_info[0] < 3 or
sys.version_info[0] == 3 and sys.version_info[1] < 5
): # nocoverage
warnings.warn(
'The versionfinder package no longer supports Python %d.%d; '
'please switch to Python 3.5 or newer.' % (
sys.version_info[0], sys.version_info[1]
),
DeprecationWarning
)
[docs] def find_package_version(self):
"""
Find the installed version of the specified package, and as much
information about it as possible (source URL, git ref or tag, etc.)
This attempts, to the best of our ability, to find out if the package
was installed from git, and if so, provide information on the origin
of that git repository and status of the clone. Otherwise, it uses
pip and pkg_resources to find the version and homepage of the installed
distribution.
This class is not a sure-fire method of identifying the source of
the distribution or ensuring AGPL compliance; it simply helps with this
process _iff_ a modified version is installed from an editable git URL
_and_ all changes are pushed up to the publicly-visible origin.
Returns a dict with keys 'version', 'tag', 'commit', and 'url'.
Values are strings or None.
:param package_name: name of the package to find information for
:type package_name: str
:returns: information about the installed version of the package
:rtype: :py:class:`~versionfinder.versioninfo.VersionInfo`
"""
res = {
'pip_version': None,
'pip_url': None,
'pip_requirement': None,
'pkg_resources_version': None,
'pkg_resources_url': None,
'git_tag': None,
'git_commit': None,
'git_remotes': None,
'git_is_dirty': None
}
try:
pip_info = self._find_pip_info()
except Exception:
# we NEVER want this to crash the program
logger.debug(
'Caught exception running _find_pip_info()',
exc_info=True
)
pip_info = {}
logger.debug("pip info: %s", pip_info)
for k, v in pip_info.items():
if v is not None:
res['pip_' + k] = v
try:
pkg_info = self._find_pkg_info()
except Exception:
logger.debug('Caught exception running _find_pkg_info()')
pkg_info = {}
logger.debug("pkg_resources info: %s", pkg_info)
for k, v in pkg_info.items():
res['pkg_resources_' + k] = v
gitdir = self._git_repo_path
if gitdir is not None:
git_info = self._find_git_info(gitdir)
logger.debug("Git info: %s", git_info)
for k, v in git_info.items():
if k == 'dirty':
res['git_is_dirty'] = v
elif k == 'commit':
res['git_commit'] = v
elif k == 'remotes':
res['git_remotes'] = v
elif k == 'tag':
res['git_tag'] = v
else:
logger.debug("Install does not appear to be a git clone")
logger.debug("Final package info: %s", res)
return VersionInfo(**res)
@property
def _git_repo_path(self):
"""
Attempt to determine whether this package is installed via git or not;
if so, return the path to the git repository.
:rtype: str
:returns: path to git repo, or None
"""
logger.debug('Checking for git directory in: %s', self._package_top_dir)
for p in self._package_top_dir:
gitdir = os.path.join(p, '.git')
if os.path.exists(gitdir):
logger.debug('_is_git_clone() true based on %s' % gitdir)
return gitdir
logger.debug('_is_git_clone() false')
return None
[docs] def _find_pkg_info(self):
"""
Find information about the installed package from pkg_resources.
:returns: information from pkg_resources about ``self.package_name``
:rtype: dict
"""
dist = pkg_resources.require(self.package_name)[0]
self._pkg_resources_locations = [dist.location]
ver, url = self._dist_version_url(dist)
return {'version': ver, 'url': url}
[docs] def _find_pip_info(self):
"""
Try to find information about the installed package from pip.
This should be wrapped in a try/except.
:returns: information from pip about ``self.package_name``.
:rtype: dict
"""
res = {}
dist = None
dist_name = self.package_name.replace('_', '-')
logger.debug('Checking for pip distribution named: %s', dist_name)
for d in get_installed_distributions():
if d.project_name == dist_name:
dist = d
if dist is None:
logger.debug('could not find dist matching package_name')
return res
logger.debug('found dist: %s', dist)
self._pip_locations = [dist.location]
ver, url = self._dist_version_url(dist)
res['version'] = ver
res['url'] = url
# this is a bit of an ugly, lazy hack...
try:
req = FrozenRequirement.from_dist(dist, [])
except TypeError: # nocoverage
req = FrozenRequirement.from_dist(dist)
logger.debug('pip FrozenRequirement: %s', req)
res['requirement'] = str(req.req)
return res
[docs] def _dist_version_url(self, dist):
"""
Get version and homepage for a pkg_resources.Distribution
:param dist: the pkg_resources.Distribution to get information for
:returns: 2-tuple of (version, homepage URL)
:rtype: tuple
"""
ver = str(dist.version)
url = None
for line in dist.get_metadata_lines(dist.PKG_INFO):
line = line.strip()
if ':' not in line:
continue
(k, v) = line.split(':', 1)
if k == 'Home-page':
url = v.strip()
return (ver, url)
[docs] def _find_git_info(self, gitdir):
"""
Find information about the git repository, if this file is in a clone.
:param gitdir: path to the git repo's .git directory
:type gitdir: str
:returns: information about the git clone
:rtype: dict
"""
res = {'remotes': None, 'tag': None, 'commit': None, 'dirty': None}
try:
logger.debug('opening %s as git.Repo', gitdir)
repo = Repo(path=gitdir, search_parent_directories=False)
res['commit'] = repo.head.commit.hexsha
res['dirty'] = repo.is_dirty(untracked_files=True)
res['remotes'] = {}
for rmt in repo.remotes:
# each is a git.Remote
urls = [u for u in rmt.urls] # generator
if len(urls) > 0:
res['remotes'][rmt.name] = urls[0]
for tag in repo.tags:
# each is a git.Tag object
if tag.commit.hexsha == res['commit']:
res['tag'] = tag.name
except Exception:
logger.debug('Exception getting git information', exc_info=True)
return res
@property
def _package_top_dir(self):
"""
Find one or more directories that we think may be the top-level
directory of the package; return a list of their absolute paths.
:return: list of possible package top-level directories (absolute paths)
:rtype: list
"""
r = [self.package_dir]
for l in self._pip_locations:
if l is not None:
r.append(l)
for l in self._pkg_resources_locations:
if l is not None:
r.append(l)
return sorted(list(set(r)))
[docs]@contextmanager
def chdir(path):
old_dir = os.getcwd()
logger.debug('with chdir(%s) from %s', path, old_dir)
os.chdir(path)
try:
yield
finally:
os.chdir(old_dir)