run update

This commit is contained in:
j 2018-12-15 01:08:54 +01:00
commit 6806bebb7c
607 changed files with 52543 additions and 31832 deletions

View file

@ -1,10 +1,14 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
Utility functions used by the stem library.
"""
import datetime
import stem.prereq
__all__ = [
'conf',
'connection',
@ -17,4 +21,135 @@ __all__ = [
'term',
'test_tools',
'tor_tools',
'datetime_to_unix',
]
# Beginning with Stem 1.7 we take attribute types into account when hashing
# and checking equality. That is to say, if two Stem classes' attributes are
# the same but use different types we no longer consider them to be equal.
# For example...
#
# s1 = Schedule(classes = ['Math', 'Art', 'PE'])
# s2 = Schedule(classes = ('Math', 'Art', 'PE'))
#
# Prior to Stem 1.7 s1 and s2 would be equal, but afterward unless Stem's
# construcotr normalizes the types they won't.
#
# This change in behavior is the right thing to do but carries some risk, so
# we provide the following constant to revert to legacy behavior. If you find
# yourself using it them please let me know (https://www.atagar.com/contact/)
# since this flag will go away in the future.
HASH_TYPES = True
def _hash_value(val):
if not HASH_TYPES:
my_hash = 0
else:
# TODO: I hate doing this but until Python 2.x support is dropped we
# can't readily be strict about bytes vs unicode for attributes. This
# is because test assertions often use strings, and normalizing this
# would require wrapping most with to_unicode() calls.
#
# This hack will go away when we drop Python 2.x support.
if _is_str(val):
my_hash = hash('str')
else:
# Hashing common builtins (ints, bools, etc) provide consistant values but many others vary their value on interpreter invokation.
my_hash = hash(str(type(val)))
if isinstance(val, (tuple, list)):
for v in val:
my_hash = (my_hash * 1024) + hash(v)
elif isinstance(val, dict):
for k in sorted(val.keys()):
my_hash = (my_hash * 2048) + (hash(k) * 1024) + hash(val[k])
else:
my_hash += hash(val)
return my_hash
def _is_str(val):
"""
Check if a value is a string. This will be removed when we no longer provide
backward compatibility for the Python 2.x series.
:param object val: value to be checked
:returns: **True** if the value is some form of string (unicode or bytes),
and **False** otherwise
"""
if stem.prereq.is_python_3():
return isinstance(val, (bytes, str))
else:
return isinstance(val, (bytes, unicode))
def _is_int(val):
"""
Check if a value is an integer. This will be removed when we no longer
provide backward compatibility for the Python 2.x series.
:param object val: value to be checked
:returns: **True** if the value is some form of integer (int or long),
and **False** otherwise
"""
if stem.prereq.is_python_3():
return isinstance(val, int)
else:
return isinstance(val, (int, long))
def datetime_to_unix(timestamp):
"""
Converts a utc datetime object to a unix timestamp.
.. versionadded:: 1.5.0
:param datetime timestamp: timestamp to be converted
:returns: **float** for the unix timestamp of the given datetime object
"""
if stem.prereq._is_python_26():
delta = (timestamp - datetime.datetime(1970, 1, 1))
return delta.days * 86400 + delta.seconds
else:
return (timestamp - datetime.datetime(1970, 1, 1)).total_seconds()
def _hash_attr(obj, *attributes, **kwargs):
"""
Provide a hash value for the given set of attributes.
:param Object obj: object to be hashed
:param list attributes: attribute names to take into account
:param bool cache: persists hash in a '_cached_hash' object attribute
:param class parent: include parent's hash value
"""
is_cached = kwargs.get('cache', False)
parent_class = kwargs.get('parent', None)
cached_hash = getattr(obj, '_cached_hash', None)
if is_cached and cached_hash is not None:
return cached_hash
my_hash = parent_class.__hash__(obj) if parent_class else 0
my_hash = my_hash * 1024 + hash(str(type(obj)))
for attr in attributes:
val = getattr(obj, attr)
my_hash = my_hash * 1024 + _hash_value(val)
if is_cached:
setattr(obj, '_cached_hash', my_hash)
return my_hash

View file

@ -1,4 +1,4 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -161,6 +161,8 @@ import inspect
import os
import threading
import stem.prereq
from stem.util import log
try:
@ -213,9 +215,8 @@ def config_dict(handle, conf_mappings, handler = None):
For more information about how we convert types see our
:func:`~stem.util.conf.Config.get` method.
**The dictionary you get from this is manged by the
:class:`~stem.util.conf.Config` class and should be treated as being
read-only.**
**The dictionary you get from this is manged by the Config class and should
be treated as being read-only.**
:param str handle: unique identifier for a config instance
:param dict conf_mappings: config key/value mappings used as our defaults
@ -274,15 +275,15 @@ def uses_settings(handle, path, lazy_load = True):
config = get_config(handle)
if not lazy_load and not config.get('settings_loaded', False):
if not lazy_load and not config._settings_loaded:
config.load(path)
config.set('settings_loaded', 'true')
config._settings_loaded = True
def decorator(func):
def wrapped(*args, **kwargs):
if lazy_load and not config.get('settings_loaded', False):
if lazy_load and not config._settings_loaded:
config.load(path)
config.set('settings_loaded', 'true')
config._settings_loaded = True
if 'config' in inspect.getargspec(func).args:
return func(*args, config = config, **kwargs)
@ -446,11 +447,14 @@ class Config(object):
#
# Information for what values fail to load and why are reported to
# 'stem.util.log'.
.. versionchanged:: 1.7.0
Class can now be used as a dictionary.
"""
def __init__(self):
self._path = None # location we last loaded from or saved to
self._contents = {} # configuration key/value pairs
self._contents = OrderedDict() # configuration key/value pairs
self._listeners = [] # functors to be notified of config changes
# used for accessing _contents
@ -459,7 +463,10 @@ class Config(object):
# keys that have been requested (used to provide unused config contents)
self._requested_keys = set()
def load(self, path = None):
# flag to support lazy loading in uses_settings()
self._settings_loaded = False
def load(self, path = None, commenting = True):
"""
Reads in the contents of the given path, adding its configuration values
to our current contents. If the path is a directory then this loads each
@ -468,8 +475,16 @@ class Config(object):
.. versionchanged:: 1.3.0
Added support for directories.
.. versionchanged:: 1.3.0
Added the **commenting** argument.
.. versionchanged:: 1.6.0
Avoid loading vim swap files.
:param str path: file or directory path to be loaded, this uses the last
loaded path if not provided
:param bool commenting: ignore line content after a '#' if **True**, read
otherwise
:raises:
* **IOError** if we fail to read the file (it doesn't exist, insufficient
@ -485,6 +500,9 @@ class Config(object):
if os.path.isdir(self._path):
for root, dirnames, filenames in os.walk(self._path):
for filename in filenames:
if filename.endswith('.swp'):
continue # vim swap file
self.load(os.path.join(root, filename))
return
@ -497,7 +515,7 @@ class Config(object):
line = read_contents.pop(0)
# strips any commenting or excess whitespace
comment_start = line.find('#')
comment_start = line.find('#') if commenting else -1
if comment_start != -1:
line = line[:comment_start]
@ -506,14 +524,10 @@ class Config(object):
# parse the key/value pair
if line:
try:
if ' ' in line:
key, value = line.split(' ', 1)
value = value.strip()
except ValueError:
log.debug("Config entry '%s' is expected to be of the format 'Key Value', defaulting to '%s' -> ''" % (line, line))
key, value = line, ''
if not value:
self.set(key, value.strip(), False)
else:
# this might be a multi-line entry, try processing it as such
multiline_buffer = []
@ -523,10 +537,9 @@ class Config(object):
multiline_buffer.append(content)
if multiline_buffer:
self.set(key, '\n'.join(multiline_buffer), False)
continue
self.set(key, value, False)
self.set(line, '\n'.join(multiline_buffer), False)
else:
self.set(line, '', False) # default to a key => '' mapping
def save(self, path = None):
"""
@ -535,7 +548,9 @@ class Config(object):
:param str path: location to be saved to
:raises: **ValueError** if no path was provided and we've never been provided one
:raises:
* **IOError** if we fail to save the file (insufficient permissions, etc)
* **ValueError** if no path was provided and we've never been provided one
"""
if path:
@ -544,8 +559,11 @@ class Config(object):
raise ValueError('Unable to save configuration: no path provided')
with self._contents_lock:
if not os.path.exists(os.path.dirname(self._path)):
os.makedirs(os.path.dirname(self._path))
with open(self._path, 'w') as output_file:
for entry_key in sorted(self.keys()):
for entry_key in self.keys():
for entry_value in self.get_value(entry_key, multiple = True):
# check for multi line entries
if '\n' in entry_value:
@ -612,6 +630,9 @@ class Config(object):
Appends the given key/value configuration mapping, behaving the same as if
we'd loaded this from a configuration file.
.. versionchanged:: 1.5.0
Allow removal of values by overwriting with a **None** value.
:param str key: key for the configuration mapping
:param str,list value: value we're setting the mapping to
:param bool overwrite: replaces the previous value if **True**, otherwise
@ -619,7 +640,14 @@ class Config(object):
"""
with self._contents_lock:
if isinstance(value, str):
unicode_type = str if stem.prereq.is_python_3() else unicode
if value is None:
if overwrite and key in self._contents:
del self._contents[key]
else:
pass # no value so this is a no-op
elif isinstance(value, (bytes, unicode_type)):
if not overwrite and key in self._contents:
self._contents[key].append(value)
else:
@ -636,7 +664,7 @@ class Config(object):
for listener in self._listeners:
listener(self, key)
else:
raise ValueError("Config.set() only accepts str, list, or tuple. Provided value was a '%s'" % type(value))
raise ValueError("Config.set() only accepts str (bytes or unicode), list, or tuple. Provided value was a '%s'" % type(value))
def get(self, key, default = None):
"""
@ -743,3 +771,7 @@ class Config(object):
message_id = 'stem.util.conf.missing_config_key_%s' % key
log.log_once(message_id, log.TRACE, "config entry '%s' not found, defaulting to '%s'" % (key, default))
return default
def __getitem__(self, key):
with self._contents_lock:
return self._contents[key]

View file

@ -1,4 +1,4 @@
# Copyright 2012-2015, Damian Johnson and The Tor Project
# Copyright 2012-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -17,6 +17,8 @@ Connection and networking based utility functions.
is_valid_port - checks if something is a valid representation for a port
is_private_address - checks if an IPv4 address belongs to a private range or not
address_to_int - provides an integer representation of an IP address
expand_ipv6_address - provides an IPv6 address with its collapsed portions expanded
get_mask_ipv4 - provides the mask representation for a given number of bits
get_mask_ipv6 - provides the IPv6 mask representation for a given number of bits
@ -26,9 +28,17 @@ Connection and networking based utility functions.
Method for resolving a process' connections.
.. versionadded:: 1.1.0
.. versionchanged:: 1.4.0
Added **NETSTAT_WINDOWS**.
.. versionchanged:: 1.6.0
Added **BSD_FSTAT**.
.. deprecated:: 1.6.0
The SOCKSTAT connection resolver is proving to be unreliable
(:trac:`23057`), and will be dropped in the 2.0.0 release unless fixed.
==================== ===========
Resolver Description
==================== ===========
@ -37,9 +47,10 @@ Connection and networking based utility functions.
**NETSTAT_WINDOWS** netstat command under Windows
**SS** ss command
**LSOF** lsof command
**SOCKSTAT** sockstat command under *nix
**SOCKSTAT** sockstat command under \*nix
**BSD_SOCKSTAT** sockstat command under FreeBSD
**BSD_PROCSTAT** procstat command under FreeBSD
**BSD_FSTAT** fstat command under OpenBSD
==================== ===========
"""
@ -50,11 +61,11 @@ import os
import platform
import re
import stem.util
import stem.util.proc
import stem.util.system
from stem import str_type
from stem.util import conf, enum, log
from stem.util import conf, enum, log, str_tools
# Connection resolution is risky to log about since it's highly likely to
# contain sensitive information. That said, it's also difficult to get right in
@ -71,17 +82,10 @@ Resolver = enum.Enum(
('LSOF', 'lsof'),
('SOCKSTAT', 'sockstat'),
('BSD_SOCKSTAT', 'sockstat (bsd)'),
('BSD_PROCSTAT', 'procstat (bsd)')
('BSD_PROCSTAT', 'procstat (bsd)'),
('BSD_FSTAT', 'fstat (bsd)')
)
Connection = collections.namedtuple('Connection', [
'local_address',
'local_port',
'remote_address',
'remote_port',
'protocol',
])
FULL_IPv4_MASK = '255.255.255.255'
FULL_IPv6_MASK = 'FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF'
@ -92,8 +96,8 @@ PORT_USES = None # port number => description
RESOLVER_COMMAND = {
Resolver.PROC: '',
# -n = prevents dns lookups, -p = include process
Resolver.NETSTAT: 'netstat -np',
# -n = prevents dns lookups, -p = include process, -W = don't crop addresses (needed for ipv6)
Resolver.NETSTAT: 'netstat -npW',
# -a = show all TCP/UDP connections, -n = numeric addresses and ports, -o = include pid
Resolver.NETSTAT_WINDOWS: 'netstat -ano',
@ -112,62 +116,97 @@ RESOLVER_COMMAND = {
# -f <pid> = process pid
Resolver.BSD_PROCSTAT: 'procstat -f {pid}',
# -p <pid> = process pid
Resolver.BSD_FSTAT: 'fstat -p {pid}',
}
RESOLVER_FILTER = {
Resolver.PROC: '',
# tcp 0 586 192.168.0.1:44284 38.229.79.2:443 ESTABLISHED 15843/tor
Resolver.NETSTAT: '^{protocol}\s+.*\s+{local_address}:{local_port}\s+{remote_address}:{remote_port}\s+ESTABLISHED\s+{pid}/{name}\s*$',
Resolver.NETSTAT: '^{protocol}\s+.*\s+{local}\s+{remote}\s+ESTABLISHED\s+{pid}/{name}\s*$',
# tcp 586 192.168.0.1:44284 38.229.79.2:443 ESTABLISHED 15843
Resolver.NETSTAT_WINDOWS: '^\s*{protocol}\s+{local_address}:{local_port}\s+{remote_address}:{remote_port}\s+ESTABLISHED\s+{pid}\s*$',
Resolver.NETSTAT_WINDOWS: '^\s*{protocol}\s+{local}\s+{remote}\s+ESTABLISHED\s+{pid}\s*$',
# tcp ESTAB 0 0 192.168.0.20:44415 38.229.79.2:443 users:(("tor",15843,9))
Resolver.SS: '^{protocol}\s+ESTAB\s+.*\s+{local_address}:{local_port}\s+{remote_address}:{remote_port}\s+users:\(\("{name}",{pid},[0-9]+\)\)$',
Resolver.SS: '^{protocol}\s+ESTAB\s+.*\s+{local}\s+{remote}\s+users:\(\("{name}",(?:pid=)?{pid},(?:fd=)?[0-9]+\)\)$',
# tor 3873 atagar 45u IPv4 40994 0t0 TCP 10.243.55.20:45724->194.154.227.109:9001 (ESTABLISHED)
Resolver.LSOF: '^{name}\s+{pid}\s+.*\s+{protocol}\s+{local_address}:{local_port}->{remote_address}:{remote_port} \(ESTABLISHED\)$',
Resolver.LSOF: '^{name}\s+{pid}\s+.*\s+{protocol}\s+{local}->{remote} \(ESTABLISHED\)$',
# atagar tor 15843 tcp4 192.168.0.20:44092 68.169.35.102:443 ESTABLISHED
Resolver.SOCKSTAT: '^\S+\s+{name}\s+{pid}\s+{protocol}4\s+{local_address}:{local_port}\s+{remote_address}:{remote_port}\s+ESTABLISHED$',
Resolver.SOCKSTAT: '^\S+\s+{name}\s+{pid}\s+{protocol}4\s+{local}\s+{remote}\s+ESTABLISHED$',
# _tor tor 4397 12 tcp4 172.27.72.202:54011 127.0.0.1:9001
Resolver.BSD_SOCKSTAT: '^\S+\s+{name}\s+{pid}\s+\S+\s+{protocol}4\s+{local_address}:{local_port}\s+{remote_address}:{remote_port}$',
Resolver.BSD_SOCKSTAT: '^\S+\s+{name}\s+{pid}\s+\S+\s+{protocol}4\s+{local}\s+{remote}$',
# 3561 tor 4 s - rw---n-- 2 0 TCP 10.0.0.2:9050 10.0.0.1:22370
Resolver.BSD_PROCSTAT: '^\s*{pid}\s+{name}\s+.*\s+{protocol}\s+{local_address}:{local_port}\s+{remote_address}:{remote_port}$',
Resolver.BSD_PROCSTAT: '^\s*{pid}\s+{name}\s+.*\s+{protocol}\s+{local}\s+{remote}$',
# _tor tor 15843 20* internet stream tcp 0x0 192.168.1.100:36174 --> 4.3.2.1:443
Resolver.BSD_FSTAT: '^\S+\s+{name}\s+{pid}\s+.*\s+{protocol}\s+\S+\s+{local}\s+[-<]-[->]\s+{remote}$',
}
def get_connections(resolver, process_pid = None, process_name = None):
class Connection(collections.namedtuple('Connection', ['local_address', 'local_port', 'remote_address', 'remote_port', 'protocol', 'is_ipv6'])):
"""
Network connection information.
.. versionchanged:: 1.5.0
Added the **is_ipv6** attribute.
:var str local_address: ip address the connection originates from
:var int local_port: port the connection originates from
:var str remote_address: destionation ip address
:var int remote_port: destination port
:var str protocol: protocol of the connection ('tcp', 'udp', etc)
:var bool is_ipv6: addresses are ipv6 if true, and ipv4 otherwise
"""
def get_connections(resolver = None, process_pid = None, process_name = None):
"""
Retrieves a list of the current connections for a given process. This
provides a list of Connection instances, which have five attributes...
* **local_address** (str)
* **local_port** (int)
* **remote_address** (str)
* **remote_port** (int)
* **protocol** (str, generally either 'tcp' or 'udp')
provides a list of :class:`~stem.util.connection.Connection`. Note that
addresses may be IPv4 *or* IPv6 depending on what the platform supports.
.. versionadded:: 1.1.0
:param Resolver resolver: method of connection resolution to use
.. versionchanged:: 1.5.0
Made our resolver argument optional.
.. versionchanged:: 1.5.0
IPv6 support when resolving via proc, netstat, lsof, or ss.
:param Resolver resolver: method of connection resolution to use, if not
provided then one is picked from among those that should likely be
available for the system
:param int process_pid: pid of the process to retrieve
:param str process_name: name of the process to retrieve
:returns: **list** of Connection instances
:returns: **list** of :class:`~stem.util.connection.Connection` instances
:raises:
* **ValueError** if using **Resolver.PROC** or **Resolver.BSD_PROCSTAT**
and the process_pid wasn't provided
* **ValueError** if neither a process_pid nor process_name is provided
* **IOError** if no connections are available or resolution fails
(generally they're indistinguishable). The common causes are the
command being unavailable or permissions.
"""
if not resolver:
available_resolvers = system_resolvers()
if available_resolvers:
resolver = available_resolvers[0]
else:
raise IOError('Unable to determine a connection resolver')
if not process_pid and not process_name:
raise ValueError('You must provide a pid or process name to provide connections for')
def _log(msg):
if LOG_CONNECTION_RESOLUTION:
log.debug(msg)
@ -181,14 +220,20 @@ def get_connections(resolver, process_pid = None, process_name = None):
except ValueError:
raise ValueError('Process pid was non-numeric: %s' % process_pid)
if process_pid is None and process_name and resolver == Resolver.NETSTAT_WINDOWS:
process_pid = stem.util.system.pid_by_name(process_name)
if process_pid is None:
all_pids = stem.util.system.pid_by_name(process_name, True)
if process_pid is None and resolver in (Resolver.NETSTAT_WINDOWS, Resolver.PROC, Resolver.BSD_PROCSTAT):
raise ValueError('%s resolution requires a pid' % resolver)
if len(all_pids) == 0:
if resolver in (Resolver.NETSTAT_WINDOWS, Resolver.PROC, Resolver.BSD_PROCSTAT):
raise IOError("Unable to determine the pid of '%s'. %s requires the pid to provide the connections." % (process_name, resolver))
elif len(all_pids) == 1:
process_pid = all_pids[0]
else:
if resolver in (Resolver.NETSTAT_WINDOWS, Resolver.PROC, Resolver.BSD_PROCSTAT):
raise IOError("There's multiple processes named '%s'. %s requires a single pid to provide the connections." % (process_name, resolver))
if resolver == Resolver.PROC:
return [Connection(*conn) for conn in stem.util.proc.connections(process_pid)]
return stem.util.proc.connections(pid = process_pid)
resolver_command = RESOLVER_COMMAND[resolver].format(pid = process_pid)
@ -199,10 +244,8 @@ def get_connections(resolver, process_pid = None, process_name = None):
resolver_regex_str = RESOLVER_FILTER[resolver].format(
protocol = '(?P<protocol>\S+)',
local_address = '(?P<local_address>[0-9.]+)',
local_port = '(?P<local_port>[0-9]+)',
remote_address = '(?P<remote_address>[0-9.]+)',
remote_port = '(?P<remote_port>[0-9]+)',
local = '(?P<local>[\[\]0-9a-f.:]+)',
remote = '(?P<remote>[\[\]0-9a-f.:]+)',
pid = process_pid if process_pid else '[0-9]*',
name = process_name if process_name else '\S*',
)
@ -213,28 +256,41 @@ def get_connections(resolver, process_pid = None, process_name = None):
connections = []
resolver_regex = re.compile(resolver_regex_str)
def _parse_address_str(addr_type, addr_str, line):
addr, port = addr_str.rsplit(':', 1)
if not is_valid_ipv4_address(addr) and not is_valid_ipv6_address(addr, allow_brackets = True):
_log('Invalid %s address (%s): %s' % (addr_type, addr, line))
return None, None
elif not is_valid_port(port):
_log('Invalid %s port (%s): %s' % (addr_type, port, line))
return None, None
else:
_log('Valid %s:%s: %s' % (addr, port, line))
return addr.lstrip('[').rstrip(']'), int(port)
for line in results:
match = resolver_regex.match(line)
if match:
attr = match.groupdict()
local_addr = attr['local_address']
local_port = int(attr['local_port'])
remote_addr = attr['remote_address']
remote_port = int(attr['remote_port'])
local_addr, local_port = _parse_address_str('local', attr['local'], line)
remote_addr, remote_port = _parse_address_str('remote', attr['remote'], line)
if not (local_addr and local_port and remote_addr and remote_port):
continue # missing or malformed field
protocol = attr['protocol'].lower()
if remote_addr == '0.0.0.0':
continue # procstat response for unestablished connections
if protocol == 'tcp6':
protocol = 'tcp'
if not (is_valid_ipv4_address(local_addr) and is_valid_ipv4_address(remote_addr)):
_log('Invalid address (%s or %s): %s' % (local_addr, remote_addr, line))
elif not (is_valid_port(local_port) and is_valid_port(remote_port)):
_log('Invalid port (%s or %s): %s' % (local_port, remote_port, line))
elif protocol not in ('tcp', 'udp'):
if protocol not in ('tcp', 'udp'):
_log('Unrecognized protocol (%s): %s' % (protocol, line))
continue
conn = Connection(local_addr, local_port, remote_addr, remote_port, protocol)
conn = Connection(local_addr, local_port, remote_addr, remote_port, protocol, is_valid_ipv6_address(local_addr))
connections.append(conn)
_log(str(conn))
@ -261,6 +317,7 @@ def system_resolvers(system = None):
:returns: **list** of :data:`~stem.util.connection.Resolver` instances available on this platform
"""
if system is None:
if stem.util.system.is_gentoo():
system = 'Gentoo'
@ -269,8 +326,10 @@ def system_resolvers(system = None):
if system == 'Windows':
resolvers = [Resolver.NETSTAT_WINDOWS]
elif system in ('Darwin', 'OpenBSD'):
elif system == 'Darwin':
resolvers = [Resolver.LSOF]
elif system == 'OpenBSD':
resolvers = [Resolver.BSD_FSTAT]
elif system == 'FreeBSD':
# Netstat is available, but lacks a '-p' equivalent so we can't associate
# the results to processes. The platform also has a ss command, but it
@ -349,7 +408,9 @@ def is_valid_ipv4_address(address):
:returns: **True** if input is a valid IPv4 address, **False** otherwise
"""
if not isinstance(address, (bytes, str_type)):
if isinstance(address, bytes):
address = str_tools._to_unicode(address)
elif not stem.util._is_str(address):
return False
# checks if theres four period separated values
@ -377,10 +438,31 @@ def is_valid_ipv6_address(address, allow_brackets = False):
:returns: **True** if input is a valid IPv6 address, **False** otherwise
"""
if isinstance(address, bytes):
address = str_tools._to_unicode(address)
elif not stem.util._is_str(address):
return False
if allow_brackets:
if address.startswith('[') and address.endswith(']'):
address = address[1:-1]
if address.count('.') == 3:
# Likely an ipv4-mapped portion. Check that its vaild, then replace with a
# filler.
ipv4_start = address.rfind(':', 0, address.find('.')) + 1
ipv4_end = address.find(':', ipv4_start + 1)
if ipv4_end == -1:
ipv4_end = None # don't crop the last character
if not is_valid_ipv4_address(address[ipv4_start:ipv4_end]):
return False
addr_comp = [address[:ipv4_start - 1] if ipv4_start != 0 else None, 'ff:ff', address[ipv4_end + 1:] if ipv4_end else None]
address = ':'.join(filter(None, addr_comp))
# addresses are made up of eight colon separated groups of four hex digits
# with leading zeros being optional
# https://en.wikipedia.org/wiki/IPv6#Address_format
@ -469,6 +551,24 @@ def is_private_address(address):
return False
def address_to_int(address):
"""
Provides an integer representation of a IPv4 or IPv6 address that can be used
for sorting.
.. versionadded:: 1.5.0
:param str address: IPv4 or IPv6 address
:returns: **int** representation of the address
"""
# TODO: Could be neat to also use this for serialization if we also had an
# int_to_address() function.
return int(_address_to_binary(address), 2)
def expand_ipv6_address(address):
"""
Expands abbreviated IPv6 addresses to their full colon separated hex format.
@ -482,6 +582,9 @@ def expand_ipv6_address(address):
>>> expand_ipv6_address('::')
'0000:0000:0000:0000:0000:0000:0000:0000'
>>> expand_ipv6_address('::ffff:5.9.158.75')
'0000:0000:0000:0000:0000:ffff:0509:9e4b'
:param str address: IPv6 address to be expanded
:raises: **ValueError** if the address can't be expanded due to being malformed
@ -490,6 +593,25 @@ def expand_ipv6_address(address):
if not is_valid_ipv6_address(address):
raise ValueError("'%s' isn't a valid IPv6 address" % address)
# expand ipv4-mapped portions of addresses
if address.count('.') == 3:
ipv4_start = address.rfind(':', 0, address.find('.')) + 1
ipv4_end = address.find(':', ipv4_start + 1)
if ipv4_end == -1:
ipv4_end = None # don't crop the last character
# Converts ipv4 address to its hex ipv6 representation. For instance...
#
# '5.9.158.75' => '0509:9e4b'
ipv4_bin = _address_to_binary(address[ipv4_start:ipv4_end])
groupings = [ipv4_bin[16 * i:16 * (i + 1)] for i in range(2)]
ipv6_snippet = ':'.join(['%04x' % int(group, 2) for group in groupings])
addr_comp = [address[:ipv4_start - 1] if ipv4_start != 0 else None, ipv6_snippet, address[ipv4_end + 1:] if ipv4_end else None]
address = ':'.join(filter(None, addr_comp))
# expands collapsed groupings, there can only be a single '::' in a valid
# address
if '::' in address:
@ -577,7 +699,7 @@ def _get_masked_bits(mask):
raise ValueError("'%s' is an invalid subnet mask" % mask)
# converts octets to binary representation
mask_bin = _get_address_binary(mask)
mask_bin = _address_to_binary(mask)
mask_match = re.match('^(1*)(0*)$', mask_bin)
if mask_match:
@ -599,7 +721,7 @@ def _get_binary(value, bits):
return ''.join([str((value >> y) & 1) for y in range(bits - 1, -1, -1)])
def _get_address_binary(address):
def _address_to_binary(address):
"""
Provides the binary value for an IPv4 or IPv6 address.
@ -644,6 +766,7 @@ def _cryptovariables_equal(x, y):
_hmac_sha256(CRYPTOVARIABLE_EQUALITY_COMPARISON_NONCE, x) ==
_hmac_sha256(CRYPTOVARIABLE_EQUALITY_COMPARISON_NONCE, y))
# TODO: drop with stem 2.x
# We renamed our methods to drop a redundant 'get_*' prefix, so alias the old
# names for backward compatability.

View file

@ -1,4 +1,4 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -40,7 +40,7 @@ constructed as simple type listings...
+- __iter__ - iterator over our enum keys
"""
from stem import str_type
import stem.util
def UppercaseEnum(*args):
@ -76,7 +76,7 @@ class Enum(object):
keys, values = [], []
for entry in args:
if isinstance(entry, (bytes, str_type)):
if stem.util._is_str(entry):
key, val = entry, _to_camel_case(entry)
elif isinstance(entry, tuple) and len(entry) == 2:
key, val = entry

View file

@ -1,4 +1,4 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -88,9 +88,13 @@ DEDUPLICATION_MESSAGE_IDS = set()
class _NullHandler(logging.Handler):
def __init__(self):
logging.Handler.__init__(self, level = logging.FATAL + 5) # disable logging
def emit(self, record):
pass
if not LOGGER.handlers:
LOGGER.addHandler(_NullHandler())
@ -99,7 +103,7 @@ def get_logger():
"""
Provides the stem logger.
:return: **logging.Logger** for stem
:returns: **logging.Logger** for stem
"""
return LOGGER
@ -118,6 +122,22 @@ def logging_level(runlevel):
return logging.FATAL + 5
def is_tracing():
"""
Checks if we're logging at the trace runlevel.
.. versionadded:: 1.6.0
:returns: **True** if we're logging at the trace runlevel and **False** otherwise
"""
for handler in get_logger().handlers:
if handler.level <= logging_level(TRACE):
return True
return False
def escape(message):
"""
Escapes specific sequences for logging (newlines, tabs, carriage returns). If
@ -199,8 +219,8 @@ class LogBuffer(logging.Handler):
Basic log handler that listens for stem events and stores them so they can be
read later. Log entries are cleared as they are read.
.. versionchanged:: 1.4.0
Added the yield_records argument.
.. versionchanged:: 1.4.0
Added the yield_records argument.
"""
def __init__(self, runlevel, yield_records = False):

View file

@ -310,4 +310,5 @@ port 19638 => Ensim
port 23399 => Skype
port 30301 => BitTorrent
port 33434 => traceroute
port 50002 => Electrum Bitcoin SSL

View file

@ -1,4 +1,4 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -54,14 +54,23 @@ import socket
import sys
import time
import stem.prereq
import stem.util.connection
import stem.util.enum
import stem.util.str_tools
from stem.util import log
try:
# added in python 3.2
from functools import lru_cache
# unavailable on windows (#19823)
import pwd
IS_PWD_AVAILABLE = True
except ImportError:
IS_PWD_AVAILABLE = False
if stem.prereq._is_lru_cache_available():
from functools import lru_cache
else:
from stem.util.lru_cache import lru_cache
# os.sysconf is only defined on unix
@ -70,6 +79,9 @@ try:
except AttributeError:
CLOCK_TICKS = None
IS_LITTLE_ENDIAN = sys.byteorder == 'little'
ENCODED_ADDR = {} # cache of encoded ips to their decoded version
Stat = stem.util.enum.Enum(
('COMMAND', 'command'), ('CPU_UTIME', 'utime'),
('CPU_STIME', 'stime'), ('START_TIME', 'start time')
@ -324,38 +336,110 @@ def file_descriptors_used(pid):
raise IOError('Unable to check number of file descriptors used: %s' % exc)
def connections(pid):
def connections(pid = None, user = None):
"""
Queries connection related information from the proc contents. This provides
similar results to netstat, lsof, sockstat, and other connection resolution
utilities (though the lookup is far quicker).
Queries connections from the proc contents. This matches netstat, lsof, and
friends but is much faster. If no **pid** or **user** are provided this
provides all present connections.
:param int pid: process id of the process to be queried
:param int pid: pid to provide connections for
:param str user: username to look up connections for
:returns: A listing of connection tuples of the form **[(local_ipAddr1,
local_port1, foreign_ipAddr1, foreign_port1, protocol), ...]** (addresses
and protocols are strings and ports are ints)
:returns: **list** of :class:`~stem.util.connection.Connection` instances
:raises: **IOError** if it can't be determined
"""
start_time, conn = time.time(), []
if pid:
parameter = 'connections for pid %s' % pid
try:
pid = int(pid)
if pid < 0:
raise IOError("Process pids can't be negative: %s" % pid)
except (ValueError, TypeError):
raise IOError('Process pid was non-numeric: %s' % pid)
elif user:
parameter = 'connections for user %s' % user
else:
parameter = 'all connections'
try:
pid = int(pid)
if not IS_PWD_AVAILABLE:
raise IOError("This requires python's pwd module, which is unavailable on Windows.")
if pid < 0:
raise IOError("Process pids can't be negative: %s" % pid)
except (ValueError, TypeError):
raise IOError('Process pid was non-numeric: %s' % pid)
inodes = _inodes_for_sockets(pid) if pid else set()
process_uid = stem.util.str_tools._to_bytes(str(pwd.getpwnam(user).pw_uid)) if user else None
if pid == 0:
return []
for proc_file_path in ('/proc/net/tcp', '/proc/net/tcp6', '/proc/net/udp', '/proc/net/udp6'):
if proc_file_path.endswith('6') and not os.path.exists(proc_file_path):
continue # ipv6 proc contents are optional
# fetches the inode numbers for socket file descriptors
protocol = proc_file_path[10:].rstrip('6') # 'tcp' or 'udp'
is_ipv6 = proc_file_path.endswith('6')
start_time, parameter = time.time(), 'process connections'
inodes = []
try:
with open(proc_file_path, 'rb') as proc_file:
proc_file.readline() # skip the first line
for fd in os.listdir('/proc/%s/fd' % pid):
for line in proc_file:
_, l_dst, r_dst, status, _, _, _, uid, _, inode = line.split()[:10]
if inodes and inode not in inodes:
continue
elif process_uid and uid != process_uid:
continue
elif protocol == 'tcp' and status != b'01':
continue # skip tcp connections that aren't yet established
div = l_dst.find(b':')
l_addr = _unpack_addr(l_dst[:div])
l_port = int(l_dst[div + 1:], 16)
div = r_dst.find(b':')
r_addr = _unpack_addr(r_dst[:div])
r_port = int(r_dst[div + 1:], 16)
if r_addr == '0.0.0.0' or r_addr == '0000:0000:0000:0000:0000:0000':
continue # no address
elif l_port == 0 or r_port == 0:
continue # no port
conn.append(stem.util.connection.Connection(l_addr, l_port, r_addr, r_port, protocol, is_ipv6))
except IOError as exc:
raise IOError("unable to read '%s': %s" % (proc_file_path, exc))
except Exception as exc:
raise IOError("unable to parse '%s': %s" % (proc_file_path, exc))
_log_runtime(parameter, '/proc/net/[tcp|udp]', start_time)
return conn
except IOError as exc:
_log_failure(parameter, exc)
raise
def _inodes_for_sockets(pid):
"""
Provides inodes in use by a process for its sockets.
:param int pid: process id of the process to be queried
:returns: **set** with inodes for its sockets
:raises: **IOError** if it can't be determined
"""
inodes = set()
try:
fd_contents = os.listdir('/proc/%s/fd' % pid)
except OSError as exc:
raise IOError('Unable to read our file descriptors: %s' % exc)
for fd in fd_contents:
fd_path = '/proc/%s/fd/%s' % (pid, fd)
try:
@ -364,57 +448,18 @@ def connections(pid):
fd_name = os.readlink(fd_path)
if fd_name.startswith('socket:['):
inodes.append(fd_name[8:-1])
inodes.add(stem.util.str_tools._to_bytes(fd_name[8:-1]))
except OSError as exc:
if not os.path.exists(fd_path):
continue # descriptors may shift while we're in the middle of iterating over them
# most likely couldn't be read due to permissions
exc = IOError('unable to determine file descriptor destination (%s): %s' % (exc, fd_path))
_log_failure(parameter, exc)
raise exc
raise IOError('unable to determine file descriptor destination (%s): %s' % (exc, fd_path))
if not inodes:
# unable to fetch any connections for this process
return []
# check for the connection information from the /proc/net contents
conn = []
for proc_file_path in ('/proc/net/tcp', '/proc/net/udp'):
try:
proc_file = open(proc_file_path)
proc_file.readline() # skip the first line
for line in proc_file:
_, l_addr, f_addr, status, _, _, _, _, _, inode = line.split()[:10]
if inode in inodes:
# if a tcp connection, skip if it isn't yet established
if proc_file_path.endswith('/tcp') and status != '01':
continue
local_ip, local_port = _decode_proc_address_encoding(l_addr)
foreign_ip, foreign_port = _decode_proc_address_encoding(f_addr)
protocol = proc_file_path[10:]
conn.append((local_ip, local_port, foreign_ip, foreign_port, protocol))
proc_file.close()
except IOError as exc:
exc = IOError("unable to read '%s': %s" % (proc_file_path, exc))
_log_failure(parameter, exc)
raise exc
except Exception as exc:
exc = IOError("unable to parse '%s': %s" % (proc_file_path, exc))
_log_failure(parameter, exc)
raise exc
_log_runtime(parameter, '/proc/net/[tcp|udp]', start_time)
return conn
return inodes
def _decode_proc_address_encoding(addr):
def _unpack_addr(addr):
"""
Translates an address entry in the /proc/net/* contents to a human readable
form (`reference <http://linuxdevcenter.com/pub/a/linux/2000/11/16/LinuxAdmin.html>`_,
@ -422,35 +467,40 @@ def _decode_proc_address_encoding(addr):
::
"0500000A:0016" -> ("10.0.0.5", 22)
"0500000A" -> "10.0.0.5"
"F804012A4A5190010000000002000000" -> "2a01:4f8:190:514a::2"
:param str addr: proc address entry to be decoded
:returns: **tuple** of the form **(addr, port)**, with addr as a string and port an int
:returns: **str** of the decoded address
"""
ip, port = addr.split(':')
if addr not in ENCODED_ADDR:
if len(addr) == 8:
# IPv4 address
decoded = base64.b16decode(addr)[::-1] if IS_LITTLE_ENDIAN else base64.b16decode(addr)
ENCODED_ADDR[addr] = socket.inet_ntop(socket.AF_INET, decoded)
else:
# IPv6 address
# the port is represented as a two-byte hexadecimal number
port = int(port, 16)
if IS_LITTLE_ENDIAN:
# Group into eight characters, then invert in pairs...
#
# https://trac.torproject.org/projects/tor/ticket/18079#comment:24
if sys.version_info >= (3,):
ip = ip.encode('ascii')
inverted = []
# The IPv4 address portion is a little-endian four-byte hexadecimal number.
# That is, the least significant byte is listed first, so we need to reverse
# the order of the bytes to convert it to an IP address.
#
# This needs to account for the endian ordering as per...
# http://code.google.com/p/psutil/issues/detail?id=201
# https://trac.torproject.org/projects/tor/ticket/4777
for i in range(4):
grouping = addr[8 * i:8 * (i + 1)]
inverted += [grouping[2 * i:2 * (i + 1)] for i in range(4)][::-1]
if sys.byteorder == 'little':
ip = socket.inet_ntop(socket.AF_INET, base64.b16decode(ip)[::-1])
else:
ip = socket.inet_ntop(socket.AF_INET, base64.b16decode(ip))
encoded = b''.join(inverted)
else:
encoded = addr
return (ip, port)
ENCODED_ADDR[addr] = stem.util.connection.expand_ipv6_address(socket.inet_ntop(socket.AF_INET6, base64.b16decode(encoded)))
return ENCODED_ADDR[addr]
def _is_float(*value):
@ -508,7 +558,7 @@ def _get_lines(file_path, line_prefixes, parameter):
return results
except IOError as exc:
_log_failure(parameter, exc)
raise exc
raise
def _log_runtime(parameter, proc_location, start_time):
@ -534,6 +584,7 @@ def _log_failure(parameter, exc):
log.debug('proc call failed (%s): %s' % (parameter, exc))
# TODO: drop with stem 2.x
# We renamed our methods to drop a redundant 'get_*' prefix, so alias the old
# names for backward compatability.

View file

@ -1,4 +1,4 @@
# Copyright 2012-2015, Damian Johnson and The Tor Project
# Copyright 2012-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -27,10 +27,9 @@ import re
import sys
import stem.prereq
import stem.util
import stem.util.enum
from stem import str_type
# label conversion tuples of the form...
# (bits / bytes / seconds, short label, long label)
@ -75,13 +74,13 @@ if stem.prereq.is_python_3():
return msg
else:
def _to_bytes_impl(msg):
if msg is not None and isinstance(msg, str_type):
if msg is not None and isinstance(msg, unicode):
return codecs.latin_1_encode(msg, 'replace')[0]
else:
return msg
def _to_unicode_impl(msg):
if msg is not None and not isinstance(msg, str_type):
if msg is not None and not isinstance(msg, unicode):
return msg.decode('utf-8', 'replace')
else:
return msg
@ -117,6 +116,22 @@ def _to_unicode(msg):
return _to_unicode_impl(msg)
def _to_int(msg):
"""
Serializes a string to a number.
:param str msg: string to be serialized
:returns: **int** representation of the string
"""
if stem.prereq.is_python_3() and isinstance(msg, bytes):
# iterating over bytes in python3 provides ints rather than characters
return sum([pow(256, (len(msg) - i - 1)) * c for (i, c) in enumerate(msg)])
else:
return sum([pow(256, (len(msg) - i - 1)) * ord(c) for (i, c) in enumerate(msg)])
def _to_camel_case(label, divider = '_', joiner = ' '):
"""
Converts the given string to camel case, ie:
@ -145,6 +160,24 @@ def _to_camel_case(label, divider = '_', joiner = ' '):
return joiner.join(words)
def _split_by_length(msg, size):
"""
Splits a string into a list of strings up to the given size.
::
>>> _split_by_length('hello', 2)
['he', 'll', 'o']
:param str msg: string to split
:param int size: number of characters to chunk into
:returns: **list** with chunked string components
"""
return [msg[i:i + size] for i in range(0, len(msg), size)]
# This needs to be defined after _to_camel_case() to avoid a circular
# dependency with the enum module.
@ -210,6 +243,9 @@ def crop(msg, size, min_word_length = 4, min_crop = 0, ending = Ending.ELLIPSE,
# ellipse, and cropping words requires an extra space for hyphens
if ending == Ending.ELLIPSE:
if size < 3:
return ('', msg) if get_remainder else ''
size -= 3
elif min_word_length and ending == Ending.HYPHEN:
min_word_length += 1
@ -262,7 +298,7 @@ def crop(msg, size, min_word_length = 4, min_crop = 0, ending = Ending.ELLIPSE,
return (return_msg, remainder) if get_remainder else return_msg
def size_label(byte_count, decimal = 0, is_long = False, is_bytes = True):
def size_label(byte_count, decimal = 0, is_long = False, is_bytes = True, round = False):
"""
Converts a number of bytes into a human readable label in its most
significant units. For instance, 7500 bytes would return "7 KB". If the
@ -281,18 +317,22 @@ def size_label(byte_count, decimal = 0, is_long = False, is_bytes = True):
>>> size_label(1050, 3, True)
'1.025 Kilobytes'
.. versionchanged:: 1.6.0
Added round argument.
:param int byte_count: number of bytes to be converted
:param int decimal: number of decimal digits to be included
:param bool is_long: expands units label
:param bool is_bytes: provides units in bytes if **True**, bits otherwise
:param bool round: rounds normally if **True**, otherwise rounds down
:returns: **str** with human readable representation of the size
"""
if is_bytes:
return _get_label(SIZE_UNITS_BYTES, byte_count, decimal, is_long)
return _get_label(SIZE_UNITS_BYTES, byte_count, decimal, is_long, round)
else:
return _get_label(SIZE_UNITS_BITS, byte_count, decimal, is_long)
return _get_label(SIZE_UNITS_BITS, byte_count, decimal, is_long, round)
def time_label(seconds, decimal = 0, is_long = False):
@ -456,7 +496,7 @@ def _parse_timestamp(entry):
:raises: **ValueError** if the timestamp is malformed
"""
if not isinstance(entry, (str, str_type)):
if not stem.util._is_str(entry):
raise ValueError('parse_timestamp() input must be a str, got a %s' % type(entry))
try:
@ -482,7 +522,7 @@ def _parse_iso_timestamp(entry):
:raises: **ValueError** if the timestamp is malformed
"""
if not isinstance(entry, (str, str_type)):
if not stem.util._is_str(entry):
raise ValueError('parse_iso_timestamp() input must be a str, got a %s' % type(entry))
# based after suggestions from...
@ -496,7 +536,7 @@ def _parse_iso_timestamp(entry):
if len(microseconds) != 6 or not microseconds.isdigit():
raise ValueError("timestamp's microseconds should be six digits")
if timestamp_str[10] == 'T':
if len(timestamp_str) > 10 and timestamp_str[10] == 'T':
timestamp_str = timestamp_str[:10] + ' ' + timestamp_str[11:]
else:
raise ValueError("timestamp didn't contain delimeter 'T' between date and time")
@ -505,7 +545,7 @@ def _parse_iso_timestamp(entry):
return timestamp + datetime.timedelta(microseconds = int(microseconds))
def _get_label(units, count, decimal, is_long):
def _get_label(units, count, decimal, is_long, round = False):
"""
Provides label corresponding to units of the highest significance in the
provided set. This rounds down (ie, integer truncation after visible units).
@ -515,6 +555,7 @@ def _get_label(units, count, decimal, is_long):
:param int count: number of base units being converted
:param int decimal: decimal precision of label
:param bool is_long: uses the long label if **True**, short label otherwise
:param bool round: rounds normally if **True**, otherwise rounds down
"""
# formatted string for the requested number of digits
@ -529,10 +570,12 @@ def _get_label(units, count, decimal, is_long):
for count_per_unit, short_label, long_label in units:
if count >= count_per_unit:
# Rounding down with a '%f' is a little clunky. Reducing the count so
# it'll divide evenly as the rounded down value.
if not round:
# Rounding down with a '%f' is a little clunky. Reducing the count so
# it'll divide evenly as the rounded down value.
count -= count % (count_per_unit / (10 ** decimal))
count -= count % (count_per_unit / (10 ** decimal))
count_label = label_format % (count / count_per_unit)
if is_long:
@ -548,6 +591,7 @@ def _get_label(units, count, decimal, is_long):
else:
return count_label + short_label
# TODO: drop with stem 2.x
# We renamed our methods to drop a redundant 'get_*' prefix, so alias the old
# names for backward compatability.

View file

@ -1,4 +1,4 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -10,6 +10,10 @@ best-effort, providing **None** if the lookup fails.
Dropped the get_* prefix from several function names. The old names still
work, but are deprecated aliases.
.. versionchanged:: 1.5.0
Added the **SYSTEM_CALL_TIME** global, which tracks total time spent making
system commands.
**Module Overview:**
::
@ -17,16 +21,19 @@ best-effort, providing **None** if the lookup fails.
is_windows - checks if we're running on windows
is_mac - checks if we're running on a mac
is_gentoo - checks if we're running on gentoo
is_slackware - checks if we're running on slackware
is_bsd - checks if we're running on the bsd family of operating systems
is_available - determines if a command is available on this system
is_running - determines if a given process is running
size_of - provides the memory usage of an object
call - runs the given system command and provides back the results
name_by_pid - gets the name for a process by the given pid
pid_by_name - gets the pid for a process by the given name
pid_by_port - gets the pid for a process listening to a given port
pid_by_open_file - gets the pid for the process with an open file
pids_by_user - provides processes owned by a user
cwd - provides the current working directory for a given process
user - provides the user a process is running under
start_time - provides the unix timestamp when the process started
@ -40,25 +47,63 @@ best-effort, providing **None** if the lookup fails.
get_process_name - provides our process' name
set_process_name - changes our process' name
.. data:: Status (enum)
State of a subprocess.
.. versionadded:: 1.6.0
==================== ===========
Status Description
==================== ===========
PENDING not yet started
RUNNING currently being performed
DONE completed successfully
FAILED failed with an exception
==================== ===========
"""
import collections
import ctypes
import ctypes.util
import distutils.spawn
import itertools
import mimetypes
import multiprocessing
import os
import platform
import re
import subprocess
import sys
import tarfile
import threading
import time
import stem.prereq
import stem.util
import stem.util.enum
import stem.util.proc
import stem.util.str_tools
from stem import UNDEFINED, str_type
from stem import UNDEFINED
from stem.util import log
State = stem.util.enum.UppercaseEnum(
'PENDING',
'RUNNING',
'DONE',
'FAILED',
)
SIZE_RECURSES = {
tuple: iter,
list: iter,
collections.deque: iter,
dict: lambda d: itertools.chain.from_iterable(d.items()),
set: iter,
frozenset: iter,
}
# Mapping of commands to if they're available or not.
CMD_AVAILABLE_CACHE = {}
@ -84,6 +129,8 @@ GET_PID_BY_PORT_NETSTAT = 'netstat -npltu'
GET_PID_BY_PORT_SOCKSTAT = 'sockstat -4l -P tcp -p %s'
GET_PID_BY_PORT_LSOF = 'lsof -wnP -iTCP -sTCP:LISTEN'
GET_PID_BY_FILE_LSOF = 'lsof -tw %s'
GET_PIDS_BY_USER_LINUX = 'ps -o pid -u %s'
GET_PIDS_BY_USER_BSD = 'ps -o pid -U %s'
GET_CWD_PWDX = 'pwdx %s'
GET_CWD_LSOF = 'lsof -a -p %s -d cwd -Fn'
GET_BSD_JAIL_ID_PS = 'ps -p %s -o jid'
@ -125,6 +172,143 @@ _PROCESS_NAME = None
_MAX_NAME_LENGTH = -1
# Tracks total time spent shelling out to other commands like 'ps' and
# 'netstat', so we can account for it as part of our cpu time along with
# os.times().
SYSTEM_CALL_TIME = 0.0
SYSTEM_CALL_TIME_LOCK = threading.RLock()
class CallError(OSError):
"""
Error response when making a system call. This is an **OSError** subclass
with additional information about the process. Depending on the nature of the
error not all of these attributes will be available.
:var str msg: exception string
:var str command: command that was ran
:var int exit_status: exit code of the process
:var float runtime: time the command took to run
:var str stdout: stdout of the process
:var str stderr: stderr of the process
"""
def __init__(self, msg, command, exit_status, runtime, stdout, stderr):
self.msg = msg
self.command = command
self.exit_status = exit_status
self.runtime = runtime
self.stdout = stdout
self.stderr = stderr
def __str__(self):
return self.msg
class CallTimeoutError(CallError):
"""
Error response when making a system call that has timed out.
.. versionadded:: 1.6.0
:var float timeout: time we waited
"""
def __init__(self, msg, command, exit_status, runtime, stdout, stderr, timeout):
super(CallTimeoutError, self).__init__(msg, command, exit_status, runtime, stdout, stderr)
self.timeout = timeout
class DaemonTask(object):
"""
Invokes the given function in a subprocess, returning the value.
.. versionadded:: 1.6.0
:var function runner: function to be invoked by the subprocess
:var tuple args: arguments to provide to the subprocess
:var int priority: subprocess nice priority
:var stem.util.system.State status: state of the subprocess
:var float runtime: seconds subprocess took to complete
:var object result: return value of subprocess if successful
:var exception error: exception raised by subprocess if it failed
"""
def __init__(self, runner, args = None, priority = 15, start = False):
self.runner = runner
self.args = args
self.priority = priority
self.status = State.PENDING
self.runtime = None
self.result = None
self.error = None
self._process = None
self._pipe = None
if start:
self.run()
def run(self):
"""
Invokes the task if it hasn't already been started. If it has this is a
no-op.
"""
if self.status == State.PENDING:
self._pipe, child_pipe = multiprocessing.Pipe()
self._process = multiprocessing.Process(target = DaemonTask._run_wrapper, args = (child_pipe, self.priority, self.runner, self.args))
self._process.start()
self.status = State.RUNNING
def join(self):
"""
Provides the result of the daemon task. If still running this blocks until
the task is completed.
:returns: response of the function we ran
:raises: exception raised by the function if it failed with one
"""
if self.status == State.PENDING:
self.run()
if self.status == State.RUNNING:
self._process.join()
response = self._pipe.recv()
self.status = response[0]
self.runtime = response[1]
if self.status == State.DONE:
self.result = response[2]
elif self.status == State.FAILED:
self.error = response[2]
if self.status == State.DONE:
return self.result
elif self.status == State.FAILED:
raise self.error
else:
raise RuntimeError('BUG: unexpected status from daemon task, %s' % self.status)
@staticmethod
def _run_wrapper(conn, priority, runner, args):
start_time = time.time()
os.nice(priority)
try:
result = runner(*args) if args else runner()
conn.send((State.DONE, time.time() - start_time, result))
except Exception as exc:
conn.send((State.FAILED, time.time() - start_time, exc))
finally:
conn.close()
def is_windows():
"""
@ -156,6 +340,16 @@ def is_gentoo():
return os.path.exists('/etc/gentoo-release')
def is_slackware():
"""
Checks if we are running on a Slackware system.
:returns: **bool** to indicate if we're on a Slackware system
"""
return os.path.exists('/etc/slackware-version')
def is_bsd():
"""
Checks if we are within the BSD family of operating systems. This currently
@ -164,7 +358,7 @@ def is_bsd():
:returns: **bool** to indicate if we're on a BSD OS
"""
return platform.system() in ('Darwin', 'FreeBSD', 'OpenBSD')
return platform.system() in ('Darwin', 'FreeBSD', 'OpenBSD', 'NetBSD')
def is_available(command, cached=True):
@ -188,27 +382,49 @@ def is_available(command, cached=True):
command = command.split(' ')[0]
if command in SHELL_COMMANDS:
# we can't actually look it up, so hope the shell really provides it...
return True
return True # we can't actually look it up, so hope the shell really provides it...
elif cached and command in CMD_AVAILABLE_CACHE:
return CMD_AVAILABLE_CACHE[command]
else:
cmd_exists = distutils.spawn.find_executable(command) is not None
CMD_AVAILABLE_CACHE[command] = cmd_exists
return cmd_exists
elif 'PATH' not in os.environ:
return False # lacking a path will cause find_executable() to internally fail
cmd_exists = False
for path in os.environ['PATH'].split(os.pathsep):
cmd_path = os.path.join(path, command)
if is_windows():
cmd_path += '.exe'
if os.path.exists(cmd_path) and os.access(cmd_path, os.X_OK):
cmd_exists = True
break
CMD_AVAILABLE_CACHE[command] = cmd_exists
return cmd_exists
def is_running(command):
"""
Checks for if a process with a given name is running or not.
Checks for if a process with a given name or pid is running.
:param str command: process name to be checked
.. versionchanged:: 1.6.0
Added support for list and pid arguments.
:param str,list,int command: process name if a str, multiple process names if
a list, or pid if an int to be checked
:returns: **True** if the process is running, **False** if it's not among ps
results, and **None** if ps can't be queried
"""
if isinstance(command, int):
try:
os.kill(command, 0)
return True
except OSError:
return False
# Linux and the BSD families have different variants of ps. Guess based on
# the is_bsd() check which to try first, then fall back to the other.
#
@ -236,12 +452,63 @@ def is_running(command):
command_listing = call(secondary_resolver, None)
if command_listing:
command_listing = map(str_type.strip, command_listing)
return command in command_listing
command_listing = [c.strip() for c in command_listing]
if stem.util._is_str(command):
command = [command]
for cmd in command:
if cmd in command_listing:
return True
return False
return None
def size_of(obj, exclude = None):
"""
Provides the `approximate memory usage of an object
<https://code.activestate.com/recipes/577504/>`_. This can recurse tuples,
lists, deques, dicts, and sets. To teach this function to inspect additional
object types expand SIZE_RECURSES...
::
stem.util.system.SIZE_RECURSES[SomeClass] = SomeClass.get_elements
.. versionadded:: 1.6.0
:param object obj: object to provide the size of
:param set exclude: object ids to exclude from size estimation
:returns: **int** with the size of the object in bytes
:raises: **NotImplementedError** if using PyPy
"""
if stem.prereq.is_pypy():
raise NotImplementedError('PyPy does not implement sys.getsizeof()')
if exclude is None:
exclude = set()
elif id(obj) in exclude:
return 0
try:
size = sys.getsizeof(obj)
except TypeError:
size = sys.getsizeof(0) # estimate if object lacks a __sizeof__
exclude.add(id(obj))
if type(obj) in SIZE_RECURSES:
for entry in SIZE_RECURSES[type(obj)](obj):
size += size_of(entry, exclude)
return size
def name_by_pid(pid):
"""
Attempts to determine the name a given process is running under (not
@ -614,6 +881,38 @@ def pid_by_open_file(path):
return None # all queries failed
def pids_by_user(user):
"""
Provides processes owned by a given user.
.. versionadded:: 1.5.0
:param str user: user to look up processes for
:returns: **list** with the process ids, **None** if it can't be determined
"""
# example output:
# atagar@odin:~$ ps -o pid -u avahi
# PID
# 914
# 915
if is_available('ps'):
if is_bsd():
results = call(GET_PIDS_BY_USER_BSD % user, None)
else:
results = call(GET_PIDS_BY_USER_LINUX % user, None)
if results:
try:
return list(map(int, results[1:]))
except ValueError:
pass
return None
def cwd(pid):
"""
Provides the working directory of the given process.
@ -668,8 +967,8 @@ def cwd(pid):
if is_available('lsof'):
results = call(GET_CWD_LSOF % pid, [])
if len(results) == 2 and results[1].startswith('n/'):
lsof_result = results[1][1:].strip()
if len(results) >= 2 and results[-1].startswith('n/'):
lsof_result = results[-1][1:].strip()
# If we lack read permissions for the cwd then it returns...
# p2683
@ -765,7 +1064,7 @@ def tail(target, lines = None):
"""
if isinstance(target, str):
with open(target) as target_file:
with open(target, 'rb') as target_file:
for line in tail(target_file, lines):
yield line
@ -777,13 +1076,13 @@ def tail(target, lines = None):
target.seek(0, 2) # go to the end of the file
block_end_byte = target.tell()
block_number = -1
content = ''
content = b''
while (lines is None or lines > 0) and block_end_byte > 0:
if (block_end_byte - BLOCK_SIZE > 0):
# read the last block we haven't yet read
target.seek(block_number * BLOCK_SIZE, 2)
content, completed_lines = (target.read(BLOCK_SIZE) + content).split('\n', 1)
content, completed_lines = (target.read(BLOCK_SIZE) + content).split(b'\n', 1)
else:
# reached the start of the file, just read what's left
target.seek(0, 0)
@ -794,7 +1093,7 @@ def tail(target, lines = None):
if lines is not None:
lines -= 1
yield line
yield stem.util.str_tools._to_unicode(line)
block_end_byte -= BLOCK_SIZE
block_number -= 1
@ -951,63 +1250,105 @@ def files_with_suffix(base_path, suffix):
yield os.path.join(root, filename)
def call(command, default = UNDEFINED, ignore_exit_status = False):
def call(command, default = UNDEFINED, ignore_exit_status = False, timeout = None, cwd = None, env = None):
"""
call(command, default = UNDEFINED, ignore_exit_status = False)
Issues a command in a subprocess, blocking until completion and returning the
results. This is not actually ran in a shell so pipes and other shell syntax
are not permitted.
.. versionchanged:: 1.5.0
Providing additional information upon failure by raising a CallError. This
is a subclass of OSError, providing backward compatibility.
.. versionchanged:: 1.5.0
Added env argument.
.. versionchanged:: 1.6.0
Added timeout and cwd arguments.
:param str,list command: command to be issued
:param object default: response if the query fails
:param bool ignore_exit_status: reports failure if our command's exit status
was non-zero
:param float timeout: maximum seconds to wait, blocks indefinitely if
**None**
:param dict env: environment variables
:returns: **list** with the lines of output from the command
:raises: **OSError** if this fails and no default was provided
:raises:
* **CallError** if this fails and no default was provided
* **CallTimeoutError** if the timeout is reached without a default
"""
# TODO: in stem 2.x return a struct with stdout, stderr, and runtime instead
global SYSTEM_CALL_TIME
if isinstance(command, str):
command_list = command.split(' ')
else:
command_list = command
command_list = list(map(str, command))
exit_status, runtime, stdout, stderr = None, None, None, None
start_time = time.time()
try:
is_shell_command = command_list[0] in SHELL_COMMANDS
start_time = time.time()
process = subprocess.Popen(command_list, stdout = subprocess.PIPE, stderr = subprocess.PIPE, shell = is_shell_command)
process = subprocess.Popen(command_list, stdout = subprocess.PIPE, stderr = subprocess.PIPE, shell = is_shell_command, cwd = cwd, env = env)
if timeout:
while process.poll() is None:
if time.time() - start_time > timeout:
raise CallTimeoutError("Process didn't finish after %0.1f seconds" % timeout, ' '.join(command_list), None, timeout, '', '', timeout)
time.sleep(0.001)
stdout, stderr = process.communicate()
stdout, stderr = stdout.strip(), stderr.strip()
runtime = time.time() - start_time
log.debug('System call: %s (runtime: %0.2f)' % (command, runtime))
trace_prefix = 'Received from system (%s)' % command
if stdout and stderr:
log.trace(trace_prefix + ', stdout:\n%s\nstderr:\n%s' % (stdout, stderr))
elif stdout:
log.trace(trace_prefix + ', stdout:\n%s' % stdout)
elif stderr:
log.trace(trace_prefix + ', stderr:\n%s' % stderr)
if log.is_tracing():
trace_prefix = 'Received from system (%s)' % command
exit_code = process.poll()
if stdout and stderr:
log.trace(trace_prefix + ', stdout:\n%s\nstderr:\n%s' % (stdout, stderr))
elif stdout:
log.trace(trace_prefix + ', stdout:\n%s' % stdout)
elif stderr:
log.trace(trace_prefix + ', stderr:\n%s' % stderr)
if not ignore_exit_status and exit_code != 0:
raise OSError('%s returned exit status %i' % (command, exit_code))
exit_status = process.poll()
if not ignore_exit_status and exit_status != 0:
raise OSError('%s returned exit status %i' % (command, exit_status))
if stdout:
return stdout.decode('utf-8', 'replace').splitlines()
else:
return []
except CallTimeoutError:
log.debug('System call (timeout): %s (after %0.4fs)' % (command, timeout))
if default != UNDEFINED:
return default
else:
raise
except OSError as exc:
log.debug('System call (failed): %s (error: %s)' % (command, exc))
if default != UNDEFINED:
return default
else:
raise exc
raise CallError(str(exc), ' '.join(command_list), exit_status, runtime, stdout, stderr)
finally:
with SYSTEM_CALL_TIME_LOCK:
SYSTEM_CALL_TIME += time.time() - start_time
def get_process_name():
@ -1150,7 +1491,7 @@ def _set_proc_title(process_name):
libc = ctypes.CDLL(ctypes.util.find_library('c'))
name_buffer = ctypes.create_string_buffer(len(process_name) + 1)
name_buffer.value = process_name
name_buffer.value = process_name.encode()
try:
libc.setproctitle(ctypes.byref(name_buffer))

View file

@ -1,4 +1,4 @@
# Copyright 2011-2015, Damian Johnson and The Tor Project
# Copyright 2011-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -8,12 +8,13 @@ Utilities for working with the terminal.
::
encoding - provides the ANSI escape sequence for a terminal attribute
format - wrap text with ANSI for the given colors or attributes
.. data:: Color (enum)
.. data:: BgColor (enum)
Enumerations for foreground or background terminal color.
Foreground or background terminal colors.
=========== ===========
Color Description
@ -30,15 +31,19 @@ Utilities for working with the terminal.
.. data:: Attr (enum)
Enumerations of terminal text attributes.
Terminal text attributes.
.. versionchanged:: 1.5.0
Added the LINES attribute.
=================== ===========
Attr Description
=================== ===========
**BOLD** heavy typeface
**HILIGHT** inverted foreground and background
**HIGHLIGHT** inverted foreground and background
**UNDERLINE** underlined text
**READLINE_ESCAPE** wrap encodings in `RL_PROMPT_START_IGNORE and RL_PROMPT_END_IGNORE sequences <https://stackoverflow.com/questions/9468435/look-how-to-fix-column-calculation-in-python-readline-if-use-color-prompt>`_
**LINES** formats lines individually
=================== ===========
"""
@ -54,17 +59,52 @@ DISABLE_COLOR_SUPPORT = False
Color = stem.util.enum.Enum(*TERM_COLORS)
BgColor = stem.util.enum.Enum(*['BG_' + color for color in TERM_COLORS])
Attr = stem.util.enum.Enum('BOLD', 'UNDERLINE', 'HILIGHT', 'READLINE_ESCAPE')
Attr = stem.util.enum.Enum('BOLD', 'UNDERLINE', 'HIGHLIGHT', 'READLINE_ESCAPE', 'LINES')
# mappings of terminal attribute enums to their ANSI escape encoding
FG_ENCODING = dict([(list(Color)[i], str(30 + i)) for i in range(8)])
BG_ENCODING = dict([(list(BgColor)[i], str(40 + i)) for i in range(8)])
ATTR_ENCODING = {Attr.BOLD: '1', Attr.UNDERLINE: '4', Attr.HILIGHT: '7'}
ATTR_ENCODING = {Attr.BOLD: '1', Attr.UNDERLINE: '4', Attr.HIGHLIGHT: '7'}
CSI = '\x1B[%sm'
RESET = CSI % '0'
def encoding(*attrs):
"""
Provides the ANSI escape sequence for these terminal color or attributes.
.. versionadded:: 1.5.0
:param list attr: :data:`~stem.util.terminal.Color`,
:data:`~stem.util.terminal.BgColor`, or :data:`~stem.util.terminal.Attr` to
provide an ecoding for
:returns: **str** of the ANSI escape sequence, **None** no attributes are
recognized
"""
term_encodings = []
for attr in attrs:
# TODO: Account for an earlier misspelled attribute. This should be dropped
# in Stem. 2.0.x.
if attr == 'HILIGHT':
attr = 'HIGHLIGHT'
attr = stem.util.str_tools._to_camel_case(attr)
term_encoding = FG_ENCODING.get(attr, None)
term_encoding = BG_ENCODING.get(attr, term_encoding)
term_encoding = ATTR_ENCODING.get(attr, term_encoding)
if term_encoding:
term_encodings.append(term_encoding)
if term_encodings:
return CSI % ';'.join(term_encodings)
def format(msg, *attr):
"""
Simple terminal text formatting using `ANSI escape sequences
@ -75,38 +115,39 @@ def format(msg, *attr):
* `termcolor <https://pypi.python.org/pypi/termcolor>`_
* `colorama <https://pypi.python.org/pypi/colorama>`_
.. versionchanged:: 1.6.0
Normalized return value to be unicode to better support python 2/3
compatibility.
:param str msg: string to be formatted
:param str attr: text attributes, this can be :data:`~stem.util.term.Color`,
:data:`~stem.util.term.BgColor`, or :data:`~stem.util.term.Attr` enums
and are case insensitive (so strings like 'red' are fine)
:returns: **str** wrapped with ANSI escape encodings, starting with the given
:returns: **unicode** wrapped with ANSI escape encodings, starting with the given
attributes and ending with a reset
"""
msg = stem.util.str_tools._to_unicode(msg)
if DISABLE_COLOR_SUPPORT:
return msg
if Attr.LINES in attr:
attr = list(attr)
attr.remove(Attr.LINES)
lines = [format(line, *attr) for line in msg.split('\n')]
return '\n'.join(lines)
# if we have reset sequences in the message then apply our attributes
# after each of them
if RESET in msg:
return ''.join([format(comp, *attr) for comp in msg.split(RESET)])
encodings = []
for text_attr in attr:
text_attr, encoding = stem.util.str_tools._to_camel_case(text_attr), None
encoding = FG_ENCODING.get(text_attr, encoding)
encoding = BG_ENCODING.get(text_attr, encoding)
encoding = ATTR_ENCODING.get(text_attr, encoding)
if encoding:
encodings.append(encoding)
if encodings:
prefix, suffix = CSI % ';'.join(encodings), RESET
prefix, suffix = encoding(*attr), RESET
if prefix:
if Attr.READLINE_ESCAPE in attr:
prefix = '\001%s\002' % prefix
suffix = '\001%s\002' % suffix

View file

@ -1,46 +1,329 @@
# Copyright 2015, Damian Johnson and The Tor Project
# Copyright 2015-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
Helper functions for testing.
Our **stylistic_issues**, **pyflakes_issues**, and **type_check_issues**
respect a 'exclude_paths' in our test config, excluding any absolute paths
matching those regexes. Issue strings can start or end with an asterisk
to match just against the prefix or suffix. For instance...
::
exclude_paths .*/stem/test/data/.*
.. versionadded:: 1.2.0
::
TimedTestRunner - test runner that tracks test runtimes
test_runtimes - provides runtime of tests excuted through TimedTestRunners
clean_orphaned_pyc - delete *.pyc files without corresponding *.py
is_pyflakes_available - checks if pyflakes is available
is_pep8_available - checks if pep8 is available
is_pycodestyle_available - checks if pycodestyle is available
stylistic_issues - checks for PEP8 and other stylistic issues
pyflakes_issues - static checks for problems via pyflakes
stylistic_issues - checks for PEP8 and other stylistic issues
"""
import collections
import linecache
import multiprocessing
import os
import re
import threading
import time
import traceback
import unittest
import stem.prereq
import stem.util.conf
import stem.util.enum
import stem.util.system
CONFIG = stem.util.conf.config_dict('test', {
'pep8.ignore': [],
'pep8.ignore': [], # TODO: drop with stem 2.x, legacy alias for pycodestyle.ignore
'pycodestyle.ignore': [],
'pyflakes.ignore': [],
'exclude_paths': [],
})
Issue = collections.namedtuple('Issue', [
'line_number',
'message',
'line',
])
TEST_RUNTIMES = {}
ASYNC_TESTS = {}
AsyncStatus = stem.util.enum.UppercaseEnum('PENDING', 'RUNNING', 'FINISHED')
AsyncResult = collections.namedtuple('AsyncResult', 'type msg')
# TODO: Providing a copy of SkipTest that works with python 2.6. This will be
# dropped when we remove python 2.6 support.
if stem.prereq._is_python_26():
class SkipTest(Exception):
'Notes that the test was skipped.'
else:
SkipTest = unittest.case.SkipTest
def assert_equal(expected, actual, msg = None):
"""
Function form of a TestCase's assertEqual.
.. versionadded:: 1.6.0
:param object expected: expected value
:param object actual: actual value
:param str msg: message if assertion fails
:raises: **AssertionError** if values aren't equal
"""
if expected != actual:
raise AssertionError("Expected '%s' but was '%s'" % (expected, actual) if msg is None else msg)
def assert_in(expected, actual, msg = None):
"""
Asserts that a given value is within this content.
.. versionadded:: 1.6.0
:param object expected: expected value
:param object actual: actual value
:param str msg: message if assertion fails
:raises: **AssertionError** if the expected value isn't in the actual
"""
if expected not in actual:
raise AssertionError("Expected '%s' to be within '%s'" % (expected, actual) if msg is None else msg)
def skip(msg):
"""
Function form of a TestCase's skipTest.
.. versionadded:: 1.6.0
:param str msg: reason test is being skipped
:raises: **unittest.case.SkipTest** for this reason
"""
raise SkipTest(msg)
def asynchronous(func):
test = stem.util.test_tools.AsyncTest(func)
ASYNC_TESTS[test.name] = test
return test.method
class AsyncTest(object):
"""
Test that's run asychronously. These are functions (no self reference)
performed like the following...
::
class MyTest(unittest.TestCase):
@staticmethod
def run_tests():
MyTest.test_addition = stem.util.test_tools.AsyncTest(MyTest.test_addition).method
@staticmethod
def test_addition():
if 1 + 1 != 2:
raise AssertionError('tisk, tisk')
MyTest.run()
.. versionadded:: 1.6.0
"""
def __init__(self, runner, args = None, threaded = False):
self.name = '%s.%s' % (runner.__module__, runner.__name__)
self._runner = runner
self._runner_args = args
self._threaded = threaded
self.method = lambda test: self.result(test) # method that can be mixed into TestCases
self._process = None
self._process_pipe = None
self._process_lock = threading.RLock()
self._result = None
self._status = AsyncStatus.PENDING
def run(self, *runner_args, **kwargs):
if stem.prereq._is_python_26():
return # not supported under python 2.6
def _wrapper(conn, runner, args):
os.nice(12)
try:
runner(*args) if args else runner()
conn.send(AsyncResult('success', None))
except AssertionError as exc:
conn.send(AsyncResult('failure', str(exc)))
except SkipTest as exc:
conn.send(AsyncResult('skipped', str(exc)))
except:
conn.send(AsyncResult('error', traceback.format_exc()))
finally:
conn.close()
with self._process_lock:
if self._status == AsyncStatus.PENDING:
if runner_args:
self._runner_args = runner_args
if 'threaded' in kwargs:
self._threaded = kwargs['threaded']
self._process_pipe, child_pipe = multiprocessing.Pipe()
if self._threaded:
self._process = threading.Thread(
target = _wrapper,
args = (child_pipe, self._runner, self._runner_args),
name = 'Background test of %s' % self.name,
)
self._process.setDaemon(True)
else:
self._process = multiprocessing.Process(target = _wrapper, args = (child_pipe, self._runner, self._runner_args))
self._process.start()
self._status = AsyncStatus.RUNNING
def pid(self):
with self._process_lock:
return self._process.pid if (self._process and not self._threaded) else None
def join(self):
self.result(None)
def result(self, test):
if stem.prereq._is_python_26():
return # not supported under python 2.6
with self._process_lock:
if self._status == AsyncStatus.PENDING:
self.run()
if self._status == AsyncStatus.RUNNING:
self._result = self._process_pipe.recv()
self._process.join()
self._status = AsyncStatus.FINISHED
if test and self._result.type == 'failure':
test.fail(self._result.msg)
elif test and self._result.type == 'error':
test.fail(self._result.msg)
elif test and self._result.type == 'skipped':
test.skipTest(self._result.msg)
class Issue(collections.namedtuple('Issue', ['line_number', 'message', 'line'])):
"""
Issue encountered by pyflakes or pycodestyle.
:var int line_number: line number the issue occured on
:var str message: description of the issue
:var str line: content of the line the issue is about
"""
class TimedTestRunner(unittest.TextTestRunner):
"""
Test runner that tracks the runtime of individual tests. When tests are run
with this their runtimes are made available through
:func:`stem.util.test_tools.test_runtimes`.
.. versionadded:: 1.6.0
"""
def run(self, test):
for t in test._tests:
original_type = type(t)
class _TestWrapper(original_type):
def run(self, result = None):
start_time = time.time()
result = super(type(self), self).run(result)
TEST_RUNTIMES[self.id()] = time.time() - start_time
return result
# TODO: remove and drop unnecessary 'returns' when dropping python 2.6
# support
def skipTest(self, message):
if not stem.prereq._is_python_26():
return super(original_type, self).skipTest(message)
# TODO: remove when dropping python 2.6 support
def assertItemsEqual(self, expected, actual):
if stem.prereq._is_python_26():
self.assertEqual(set(expected), set(actual))
else:
return super(original_type, self).assertItemsEqual(expected, actual)
def assertRaisesWith(self, exc_type, exc_msg, func, *args, **kwargs):
"""
Asserts the given invokation raises the expected excepiton. This is
similar to unittest's assertRaises and assertRaisesRegexp, but checks
for an exact match.
This method is **not** being vended to external users and may be
changed without notice. If you want this method to be part of our
vended API then please let us know.
"""
return self.assertRaisesRegexp(exc_type, '^%s$' % re.escape(exc_msg), func, *args, **kwargs)
def assertRaisesRegexp(self, exc_type, exc_msg, func, *args, **kwargs):
if stem.prereq._is_python_26():
try:
func(*args, **kwargs)
self.fail('Expected a %s to be raised but nothing was' % exc_type)
except exc_type as exc:
self.assertTrue(re.search(exc_msg, str(exc), re.MULTILINE))
else:
return super(original_type, self).assertRaisesRegexp(exc_type, exc_msg, func, *args, **kwargs)
def id(self):
return '%s.%s.%s' % (original_type.__module__, original_type.__name__, self._testMethodName)
def __str__(self):
return '%s (%s.%s)' % (self._testMethodName, original_type.__module__, original_type.__name__)
t.__class__ = _TestWrapper
return super(TimedTestRunner, self).run(test)
def test_runtimes():
"""
Provides the runtimes of tests executed through TimedTestRunners.
:returns: **dict** of fully qualified test names to floats for the runtime in
seconds
.. versionadded:: 1.6.0
"""
return dict(TEST_RUNTIMES)
def clean_orphaned_pyc(paths):
"""
Deletes any file with a *.pyc extention without a corresponding *.py. This
Deletes any file with a \*.pyc extention without a corresponding \*.py. This
helps to address a common gotcha when deleting python files...
* You delete module 'foo.py' and run the tests to ensure that you haven't
@ -90,50 +373,46 @@ def is_pyflakes_available():
:returns: **True** if we can use pyflakes and **False** otherwise
"""
try:
import pyflakes.api
import pyflakes.reporter
return True
except ImportError:
return False
return _module_exists('pyflakes.api') and _module_exists('pyflakes.reporter')
def is_pep8_available():
def is_pycodestyle_available():
"""
Checks if pep8 is availalbe.
Checks if pycodestyle is availalbe.
:returns: **True** if we can use pep8 and **False** otherwise
:returns: **True** if we can use pycodestyle and **False** otherwise
"""
try:
import pep8
if not hasattr(pep8, 'BaseReport'):
raise ImportError()
return True
except ImportError:
if _module_exists('pycodestyle'):
import pycodestyle
elif _module_exists('pep8'):
import pep8 as pycodestyle
else:
return False
return hasattr(pycodestyle, 'BaseReport')
def stylistic_issues(paths, check_two_space_indents = False, check_newlines = False, check_trailing_whitespace = False, check_exception_keyword = False, prefer_single_quotes = False):
def stylistic_issues(paths, check_newlines = False, check_exception_keyword = False, prefer_single_quotes = False):
"""
Checks for stylistic issues that are an issue according to the parts of PEP8
we conform to. You can suppress PEP8 issues by making a 'test' configuration
that sets 'pep8.ignore'.
we conform to. You can suppress pycodestyle issues by making a 'test'
configuration that sets 'pycodestyle.ignore'.
For example, with a 'test/settings.cfg' of...
::
# PEP8 compliance issues that we're ignoreing...
# pycodestyle compliance issues that we're ignoreing...
#
# * E111 and E121 four space indentations
# * E501 line is over 79 characters
pep8.ignore E111
pep8.ignore E121
pep8.ignore E501
pycodestyle.ignore E111
pycodestyle.ignore E121
pycodestyle.ignore E501
pycodestyle.ignore run_tests.py => E402: import stem.util.enum
... you can then run tests with...
@ -146,9 +425,6 @@ def stylistic_issues(paths, check_two_space_indents = False, check_newlines = Fa
issues = stylistic_issues('my_project')
If a 'exclude_paths' was set in our test config then we exclude any absolute
paths matching those regexes.
.. versionchanged:: 1.3.0
Renamed from get_stylistic_issues() to stylistic_issues(). The old name
still works as an alias, but will be dropped in Stem version 2.0.0.
@ -160,89 +436,106 @@ def stylistic_issues(paths, check_two_space_indents = False, check_newlines = Fa
.. versionchanged:: 1.4.0
Added the prefer_single_quotes option.
.. versionchanged:: 1.6.0
Changed 'pycodestyle.ignore' code snippets to only need to match against
the prefix.
:param list paths: paths to search for stylistic issues
:param bool check_two_space_indents: check for two space indentations and
that no tabs snuck in
:param bool check_newlines: check that we have standard newlines (\\n), not
windows (\\r\\n) nor classic mac (\\r)
:param bool check_trailing_whitespace: check that our lines don't end with
trailing whitespace
:param bool check_exception_keyword: checks that we're using 'as' for
exceptions rather than a comma
:param bool prefer_single_quotes: standardize on using single rather than
double quotes for strings, when reasonable
:returns: **dict** of the form ``path => [(line_number, message)...]``
:returns: dict of paths list of :class:`stem.util.test_tools.Issue` instances
"""
issues = {}
if is_pep8_available():
import pep8
ignore_rules = []
ignore_for_file = []
class StyleReport(pep8.BaseReport):
def __init__(self, options):
super(StyleReport, self).__init__(options)
for rule in CONFIG['pycodestyle.ignore'] + CONFIG['pep8.ignore']:
if '=>' in rule:
path, rule_entry = rule.split('=>', 1)
if ':' in rule_entry:
rule, code = rule_entry.split(':', 1)
ignore_for_file.append((path.strip(), rule.strip(), code.strip()))
else:
ignore_rules.append(rule)
def is_ignored(path, rule, code):
for ignored_path, ignored_rule, ignored_code in ignore_for_file:
if path.endswith(ignored_path) and ignored_rule == rule and code.strip().startswith(ignored_code):
return True
return False
if is_pycodestyle_available():
if _module_exists('pep8'):
import pep8 as pycodestyle
else:
import pycodestyle
class StyleReport(pycodestyle.BaseReport):
def init_file(self, filename, lines, expected, line_offset):
super(StyleReport, self).init_file(filename, lines, expected, line_offset)
if not check_newlines and not check_exception_keyword and not prefer_single_quotes:
return
is_block_comment = False
for index, line in enumerate(lines):
content = line.split('#', 1)[0].strip()
if check_newlines and '\r' in line:
issues.setdefault(filename, []).append(Issue(index + 1, 'contains a windows newline', line))
if not content:
continue # blank line
if '"""' in content:
is_block_comment = not is_block_comment
if check_exception_keyword and content.startswith('except') and content.endswith(', exc:'):
# Python 2.6 - 2.7 supports two forms for exceptions...
#
# except ValueError, exc:
# except ValueError as exc:
#
# The former is the old method and no longer supported in python 3
# going forward.
# TODO: This check only works if the exception variable is called
# 'exc'. We should generalize this via a regex so other names work
# too.
issues.setdefault(filename, []).append(Issue(index + 1, "except clause should use 'as', not comma", line))
if prefer_single_quotes and not is_block_comment:
if '"' in content and "'" not in content and '"""' not in content and not content.endswith('\\'):
# Checking if the line already has any single quotes since that
# usually means double quotes are preferable for the content (for
# instance "I'm hungry"). Also checking for '\' at the end since
# that can indicate a multi-line string.
issues.setdefault(filename, []).append(Issue(index + 1, 'use single rather than double quotes', line))
def error(self, line_number, offset, text, check):
code = super(StyleReport, self).error(line_number, offset, text, check)
if code:
issues.setdefault(self.filename, []).append(Issue(line_number, '%s %s' % (code, text), text))
line = linecache.getline(self.filename, line_number)
style_checker = pep8.StyleGuide(ignore = CONFIG['pep8.ignore'], reporter = StyleReport)
if not is_ignored(self.filename, code, line):
issues.setdefault(self.filename, []).append(Issue(line_number, text, line))
style_checker = pycodestyle.StyleGuide(ignore = ignore_rules, reporter = StyleReport)
style_checker.check_files(list(_python_files(paths)))
if check_two_space_indents or check_newlines or check_trailing_whitespace or check_exception_keyword:
for path in _python_files(paths):
with open(path) as f:
file_contents = f.read()
lines = file_contents.split('\n')
is_block_comment = False
for index, line in enumerate(lines):
whitespace, content = re.match('^(\s*)(.*)$', line).groups()
# TODO: This does not check that block indentations are two spaces
# because differentiating source from string blocks ("""foo""") is more
# of a pita than I want to deal with right now.
if '"""' in content:
is_block_comment = not is_block_comment
if check_two_space_indents and '\t' in whitespace:
issues.setdefault(path, []).append(Issue(index + 1, 'indentation has a tab', line))
elif check_newlines and '\r' in content:
issues.setdefault(path, []).append(Issue(index + 1, 'contains a windows newline', line))
elif check_trailing_whitespace and content != content.rstrip():
issues.setdefault(path, []).append(Issue(index + 1, 'line has trailing whitespace', line))
elif check_exception_keyword and content.lstrip().startswith('except') and content.endswith(', exc:'):
# Python 2.6 - 2.7 supports two forms for exceptions...
#
# except ValueError, exc:
# except ValueError as exc:
#
# The former is the old method and no longer supported in python 3
# going forward.
# TODO: This check only works if the exception variable is called
# 'exc'. We should generalize this via a regex so other names work
# too.
issues.setdefault(path, []).append(Issue(index + 1, "except clause should use 'as', not comma", line))
if prefer_single_quotes and line and not is_block_comment:
content = line.strip().split('#', 1)[0]
if '"' in content and "'" not in content and '"""' not in content and not content.endswith('\\'):
# Checking if the line already has any single quotes since that
# usually means double quotes are preferable for the content (for
# instance "I'm hungry"). Also checking for '\' at the end since
# that can indicate a multi-line string.
issues.setdefault(path, []).append(Issue(index + 1, "use single rather than double quotes", line))
return issues
@ -254,10 +547,7 @@ def pyflakes_issues(paths):
::
pyflakes.ignore stem/util/test_tools.py => 'pyflakes' imported but unused
pyflakes.ignore stem/util/test_tools.py => 'pep8' imported but unused
If a 'exclude_paths' was set in our test config then we exclude any absolute
paths matching those regexes.
pyflakes.ignore stem/util/test_tools.py => 'pycodestyle' imported but unused
.. versionchanged:: 1.3.0
Renamed from get_pyflakes_issues() to pyflakes_issues(). The old name
@ -267,9 +557,12 @@ def pyflakes_issues(paths):
Changing tuples in return value to be namedtuple instances, and adding the
line that had the issue.
.. versionchanged:: 1.5.0
Support matching against prefix or suffix issue strings.
:param list paths: paths to search for problems
:returns: dict of the form ``path => [(line_number, message)...]``
:returns: dict of paths list of :class:`stem.util.test_tools.Issue` instances
"""
issues = {}
@ -300,15 +593,24 @@ def pyflakes_issues(paths):
# path ends with any of them.
for ignored_path, ignored_issues in self._ignored_issues.items():
if path.endswith(ignored_path) and issue in ignored_issues:
return True
if path.endswith(ignored_path):
if issue in ignored_issues:
return True
for prefix in [i[:1] for i in ignored_issues if i.endswith('*')]:
if issue.startswith(prefix):
return True
for suffix in [i[1:] for i in ignored_issues if i.startswith('*')]:
if issue.endswith(suffix):
return True
return False
def _register_issue(self, path, line_number, issue, line):
if not self._is_ignored(path, issue):
if path and line_number and not line:
line = linecache.getline(path, line_number)
line = linecache.getline(path, line_number).strip()
issues.setdefault(path, []).append(Issue(line_number, issue, line))
@ -320,6 +622,22 @@ def pyflakes_issues(paths):
return issues
def _module_exists(module_name):
"""
Checks if a module exists.
:param str module_name: module to check existance of
:returns: **True** if module exists and **False** otherwise
"""
try:
__import__(module_name)
return True
except ImportError:
return False
def _python_files(paths):
for path in paths:
for file_path in stem.util.system.files_with_suffix(path, '.py'):
@ -333,9 +651,12 @@ def _python_files(paths):
if not skip:
yield file_path
# TODO: drop with stem 2.x
# We renamed our methods to drop a redundant 'get_*' prefix, so alias the old
# names for backward compatability.
# names for backward compatability, and account for pep8 being renamed to
# pycodestyle.
get_stylistic_issues = stylistic_issues
get_pyflakes_issues = pyflakes_issues
is_pep8_available = is_pycodestyle_available

View file

@ -1,4 +1,4 @@
# Copyright 2012-2015, Damian Johnson and The Tor Project
# Copyright 2012-2018, Damian Johnson and The Tor Project
# See LICENSE for licensing information
"""
@ -21,6 +21,8 @@ Miscellaneous utility functions for working with tor.
import re
import stem.util.str_tools
# The control-spec defines the following as...
#
# Fingerprint = "$" 40*HEXDIG
@ -54,6 +56,9 @@ def is_valid_fingerprint(entry, check_prefix = False):
:returns: **True** if the string could be a relay fingerprint, **False** otherwise
"""
if isinstance(entry, bytes):
entry = stem.util.str_tools._to_unicode(entry)
try:
if check_prefix:
if not entry or entry[0] != '$':
@ -75,6 +80,9 @@ def is_valid_nickname(entry):
:returns: **True** if the string could be a nickname, **False** otherwise
"""
if isinstance(entry, bytes):
entry = stem.util.str_tools._to_unicode(entry)
try:
return bool(NICKNAME_PATTERN.match(entry))
except TypeError:
@ -88,6 +96,9 @@ def is_valid_circuit_id(entry):
:returns: **True** if the string could be a circuit id, **False** otherwise
"""
if isinstance(entry, bytes):
entry = stem.util.str_tools._to_unicode(entry)
try:
return bool(CIRC_ID_PATTERN.match(entry))
except TypeError:
@ -124,6 +135,9 @@ def is_valid_hidden_service_address(entry):
:returns: **True** if the string could be a hidden service address, **False** otherwise
"""
if isinstance(entry, bytes):
entry = stem.util.str_tools._to_unicode(entry)
try:
return bool(HS_ADDRESS_PATTERN.match(entry))
except TypeError: