PK ! % vf tmn/__init__.pyimport logging
__version__ = '0.2.0'
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter('[%(levelname)s] %(message)s'))
logger = logging.getLogger('tmn')
logger.addHandler(handler)
logger.setLevel('CRITICAL')
PK ! <]6 tmn/configuration.pyimport logging
import sys
import uuid
import docker
from clint import resources
from slugify import slugify
from tmn import display
from tmn.elements.network import Network
from tmn.elements.service import Service
from tmn.elements.volume import Volume
from tmn.environments import environments
logger = logging.getLogger('tmn')
resources.init('tomochain', 'tmn')
class Configuration:
"""docstring for Configuration."""
def __init__(self, name: str = None, net: str = None,
pkey: str = None, start: bool = False,
docker_url: str = None) -> None:
self.networks = {}
self.services = {}
self.volumes = {}
self.id = self._new_id()
self.name = name
self.net = net
self.pkey = pkey
if not docker_url:
self.client = docker.from_env()
else:
self.client = docker.DockerClient(base_url=docker_url)
try:
self.client.ping()
except Exception as e:
logger.error(e)
display.error_docker()
sys.exit()
if resources.user.read('name'):
self._load()
elif start:
self._write()
else:
display.error_start_not_initialized()
sys.exit()
self._compose()
def _new_id(self) -> str:
return uuid.uuid4().hex[:6]
def _load(self) -> None:
if self.name or self.net or self.pkey:
display.warning_ignoring_start_options(self.name)
self.name = resources.user.read('name')
self.net = resources.user.read('net')
self.pkey = resources.user.read('pkey')
# this is a dirty fix for retro compatiblity
# can be removed in some future version
# old `tmn` don't write the `net` option to disk
# when comming from an old version, net is not defined
# this screw with the update command
if not self.net:
pass
def _write(self) -> None:
if not self.name:
display.error_start_option_required('--name')
sys.exit()
elif not self.net:
display.error_start_option_required('--net')
sys.exit()
elif not self.pkey:
display.error_start_option_required('--pkey')
sys.exit()
self._validate()
resources.user.write('id', self.id)
resources.user.write('name', self.name)
resources.user.write('net', self.net)
def _compose(self) -> None:
self.networks['tmn'] = Network(
name='{}_tmn'.format(self.name),
client=self.client
)
self.volumes['chaindata'] = Volume(
name='{}_chaindata'.format(self.name),
client=self.client
)
self.services['metrics'] = Service(
name='{}_metrics'.format(self.name),
hostname='{}_{}'.format(self.name, self.id),
image='tomochain/telegraf:testnet',
network=self.networks['tmn'].name,
volumes={
'/var/run/docker.sock': {
'bind': '/var/run/docker.sock', 'mode': 'ro'
},
'/sys': {'bind': '/rootfs/sys', 'mode': 'ro'},
'/proc': {'bind': '/rootfs/proc', 'mode': 'ro'},
'/etc': {'bind': '/rootfs/etc', 'mode': 'ro'}
},
client=self.client
)
self.services['tomochain'] = Service(
name='{}_tomochain'.format(self.name),
image='tomochain/node:testnet',
network=self.networks['tmn'].name,
environment={'IDENTITY': '{}_{}'.format(self.name, self.id)},
volumes={
self.volumes['chaindata'].name: {
'bind': '/tomochain/data', 'mode': 'rw'
}
},
ports={'30303/udp': 30303, '30303/tcp': 30303},
client=self.client
)
#######################################################################
# this is a dirty fix for retro compatiblity #
# can be removed in some future version #
# old `tmn` don't write the `net` option to disk #
# when comming from an old version, net is not defined #
# it screw with the update command #
#######################################################################
if not self.net:
if self.services['tomochain'].image.split(':')[1] == 'testnet':
self.net = 'testnet'
else:
self.net = 'devnet'
#######################################################################
for container, variables in environments[self.net].items():
for variable, value in variables.items():
self.services[container].add_environment(
name=variable,
value=value
)
def _validate(self) -> None:
self.name = slugify(self.name)
if len(self.name) < 5 or len(self.name) > 30:
display.error_validation_option('--name', '5 to 30 characters '
'slug')
sys.exit()
if len(self.pkey) != 64:
display.error_validation_option('--pkey', '64 characters hex '
'string')
sys.exit()
def remove(self) -> None:
resources.user.delete('id')
resources.user.delete('name')
resources.user.delete('net')
PK ! q<͋ tmn/display.pyimport pastel
pastel.add_style('hg', 'green')
pastel.add_style('hgb', 'green', options=['bold'])
pastel.add_style('hy', 'yellow')
pastel.add_style('hyb', 'yellow', options=['bold'])
pastel.add_style('link', 'yellow', options=['underscore'])
pastel.add_style('und', options=['underscore'])
pastel.add_style('warning', 'yellow')
pastel.add_style('error', 'red')
help_url = 'https://docs.tomochain.com/get-started/run-node'
def newline(number: int = 1) -> None:
"Print newlines"
print('\n'*number, end='')
def style(function):
"Print and colorize strings with `pastel`"
def wrapper(*args, **kwargs) -> None:
print(pastel.colorize(function(*args, **kwargs)))
return wrapper
def style_no_new_line(function):
"Print and colorize strings with `pastel`. No newline."
def wrapper(*args) -> None:
print(pastel.colorize(function(*args)), end='', flush=True)
return wrapper
@style
def link(msg: str, url: str) -> str:
"Return a pastel formated string for browser links"
return '{msg} {url}'.format(
msg=msg,
url=url
)
def link_docs() -> None:
"Custom link message for documentation"
link('Documentation on running a masternode:', help_url)
@style
def title(msg: str) -> str:
"Return a pastel formated title string"
return '{msg}\n'.format(
msg=msg
)
def title_start_masternode(name: str) -> None:
"Title when starting a masternode"
title('Starting masternode {}:'.format(name))
def title_stop_masternode(name: str) -> None:
"Title when stopping a masternode"
title('Stopping masternode {}:'.format(name))
def title_status_masternode(name: str) -> None:
"Title when viewing a masternode status"
title('Masternode {} status:'.format(name))
def title_inspect_masternode(name: str) -> None:
"Title when inspecting a masternode"
title('Masternode {} details:'.format(name))
def title_update_masternode(name: str) -> None:
"Title when updating a masternode"
title('Updating masternode {}:'.format(name))
def title_remove_masternode(name: str) -> None:
"Title when removing a masternode"
title('Removing masternode {}:'.format(name))
@style
def subtitle(msg: str) -> str:
"Return a pastel formated subtitle string"
return '{msg}\n'.format(
msg=msg
)
def subtitle_create_volumes() -> None:
"Subtitle when creating volumes"
subtitle('Volumes')
def subtitle_remove_volumes() -> None:
"Subtitle when removing volumes"
subtitle('Volumes')
def subtitle_create_networks() -> None:
"Subtitle when creating networks"
subtitle('Networks')
def subtitle_remove_networks() -> None:
"Subtitle when removing networks"
subtitle('Networks')
def subtitle_create_containers() -> None:
"Subtitle when creating containers"
subtitle('Containers')
def subtitle_remove_containers() -> None:
"Subtitle when removing containers"
subtitle('Containers')
@style
def detail(msg, content: str, indent: int = 1) -> str:
"Return a pastel formated detail"
return (' '*indent
+ '{msg}:\n'.format(msg=msg)
+ ' '*indent
+ '{content}'.format(content=content))
def detail_identity(content: str) -> None:
"Custom detail message for the masternode identity"
detail('Unique identity', content)
def detail_coinbase(content: str) -> None:
"Custom detail message for the masternode coinbase address"
detail('Coinbase address (account public key)', content)
@style_no_new_line
def step(msg: str, indent: int = 1) -> str:
"Return a pastel formated step with indentation."
step = ' '*indent + '- {msg}... '.format(
msg=msg
)
return step
def step_create_volume(name: str) -> None:
"Custom step message for docker volumes creation"
step('Creating {name}'.format(
name=name
))
def step_remove_volume(name: str) -> None:
"Custom step message for docker volumes removal"
step('Removing {name}'.format(
name=name
))
def step_create_network(name: str) -> None:
"Custom step message for docker networks creation"
step('Creating {name}'.format(
name=name
))
def step_remove_network(name: str) -> None:
"Custom step message for docker networks creation"
step('Removing {name}'.format(
name=name
))
def step_create_container(name: str) -> None:
"Custom step message for docker container creation"
step('Creating {name}'.format(
name=name
))
def step_start_container(name: str) -> None:
"Custom step message for docker container starting"
step('Starting {name}'.format(
name=name
))
def step_remove_container(name: str) -> None:
"Custom step message for docker container starting"
step('Removing {name}'.format(
name=name
))
def step_stop_container(name: str) -> None:
"Custom step message for docker container stopping"
step('Stopping {name}'.format(
name=name
))
@style
def step_close(msg: str, color: str = 'green') -> str:
"Return a pastel formated end of step"
return '{msg}>'.format(
msg=msg,
color=color
)
@style
def status(name: str = '', status: str = 'absent', id: str = '',
status_color: str = 'red') -> str:
"Return a pastel formated end of step"
if id:
return ' {name}\t{status}(>{id})>'.format(
name=name,
status=status,
color=status_color,
id=id
)
else:
return ' {name}\t{status}>'.format(
name=name,
status=status,
color=status_color,
)
@style
def warning(msg: str, newline: bool = True) -> str:
"Return a pastel formated string for warnings"
before = ''
if newline:
before = '\n'
return before + '! warning: {msg}\n'.format(
msg=msg
)
def warning_ignoring_start_options(name: str) -> None:
"Custom warning when tmn is ignoring the start options"
warning(
'masternode {} is already configured\n'.format(name)
+ ' '
+ 'ignoring start options\n'
)
def warning_remove_masternode(name: str) -> None:
"Custom warning when tmn is removing masternode but no confirmation"
warning(
'you are about to remove masternode {}\n'.format(name)
+ ' '
+ 'this will permanently delete its data\n'
+ ' '
+ 'to confirm use the --confirm flag'
)
@style
def error(msg: str) -> str:
"Return a pastel formated string for errors"
return (
'\n! error: {msg}\n'.format(msg=msg)
+ ' '
+ 'need help? {}'.format(help_url)
)
def error_docker() -> None:
"Custom error when docker is not accessible"
error('could not access the docker daemon')
def error_docker_api() -> None:
"Custom error when docker is not accessible"
error('something went wrong while doing stuff with docker')
def error_start_not_initialized() -> None:
"Custom error when `tmn start` has never been used with `--name` option"
error(
'tmn doesn\'t manage any masternode yet\n'
' please use '
'tmn start --name to get started'
)
def error_start_option_required(option: str) -> None:
"Custom error when `tmn start` is used with name but not the other options"
error(
'{} is required when starting a new masternode'
.format(option)
)
def error_validation_option(option: str, format: str) -> None:
"Custom error when an option format is not valide"
error(
'{} is not valid\n'.format(option)
+ ' it should be a {}'.format(format)
)
PK ! kK tmn/elements/__init__.pyfrom tmn.elements.network import Network
from tmn.elements.service import Service
from tmn.elements.volume import Volume
__all__ = ['Network', 'Service', 'Volume']
PK ! `H tmn/elements/network.pyimport logging
import docker
logger = logging.getLogger('tmn')
class Network:
"""docstring for Network."""
def __init__(self, client: docker.DockerClient, name: str) -> None:
self.name = name
self.network = None
self.client = client
try:
self.network = self.client.networks.get(self.name)
except docker.errors.NotFound as e:
logger.debug('network {} not yet created ({})'
.format(self.name, e))
except docker.errors.APIError as e:
logger.error(e)
def create(self) -> bool:
"create docker network"
try:
if self.network:
return True
else:
self.client.networks.create(self.name)
return True
except docker.errors.APIError as e:
logger.error(e)
except docker.errors.ConnectionRefusedError as e:
logger.error(e)
def remove(self) -> bool:
"delete docker network"
try:
if self.network:
self.network.remove()
self.network = None
return True
else:
return True
except docker.errors.APIError as e:
logger.error(e)
return False
PK ! Js s tmn/elements/service.pyimport logging
from typing import Dict, Union
import docker
logger = logging.getLogger('tmn')
class Service:
"""docstring for Service."""
def __init__(
self,
client: docker.DockerClient,
name: str,
image: str = None,
hostname: str = None,
network: str = None,
environment: Dict[str, str] = {},
volumes: Dict[str, Dict[str, str]] = {},
ports: Dict[str, Dict[str, str]] = {},
log_driver: str = 'json-file',
log_opts: Dict[str, str] = {'max-size': '3g'}
) -> None:
self.container = False
self.image = image
self.name = name
self.environment = environment
self.network = network
self.hostname = hostname
self.volumes = volumes
self.ports = ports
self.log_driver = log_driver
self.log_opts = log_opts
self.client = client
try:
self.container = self.client.containers.get(self.name)
except docker.errors.NotFound as e:
logger.debug('container {} not yet created ({})'
.format(self.name, e))
except docker.errors.APIError as e:
logger.error(e)
def add_environment(self, name: str, value: str) -> None:
"add a new environment to the service"
self.environment[name] = value
def add_volume(self, source: str, target: str, mode: str = 'rw') -> None:
"add a new volume to the service"
self.volumes[source] = {'bind': target, 'mode': mode}
def add_port(self, source: str, target: str) -> None:
"add a new port mapping to the service"
self.ports[source] = target
def create(self) -> bool:
"create the service container"
try:
if self.container:
return True
else:
self.client.images.pull(self.image)
self.container = self.client.containers.create(
image=self.image,
name=self.name,
hostname=self.hostname,
network=self.network,
environment=self.environment,
volumes=self.volumes,
log_config={'type': self.log_driver,
'config': self.log_opts},
detach=True
)
return True
except docker.errors.APIError as e:
logger.error(e)
return False
def start(self) -> bool:
"start the service container"
try:
if self.container:
self.container.reload()
if self.container.status in ['running', 'restarting']:
return True
elif self.container.status in ['paused']:
self.container.unpause()
return True
else:
self.container.start()
return True
else:
return False
except docker.errors.APIError as e:
logger.error(e)
return False
def status(self) -> Union[str, bool]:
"return the status of the service container"
try:
if self.container:
self.container.reload()
return self.container.status
else:
return 'absent'
except docker.errors.APIError as e:
logger.error(e)
return False
def execute(self, command: str) -> Union[str, bool]:
"return the result of a command on the service container"
try:
if self.container:
self.container.reload()
return self.container.exec_run(
'/bin/sh -c "{}"'.format(command)
).output.decode("utf-8")
else:
return False
except docker.errors.APIError as e:
logger.error(e)
return False
def stop(self) -> bool:
"stop the service container"
try:
if self.container:
self.container.reload()
if self.container.status in ['created', 'exited', 'dead']:
return True
else:
self.container.stop()
return True
else:
return True
except docker.errors.APIError as e:
logger.error(e)
return False
def remove(self) -> bool:
"stop the service container"
try:
if self.container:
self.container.remove(force=True)
self.container = None
return True
else:
return True
except docker.errors.APIError as e:
logger.error(e)
return False
def update(self) -> bool:
"update the service container"
try:
if self.container:
self.container.remove(force=True)
return self.create() and self.start()
else:
return False
except docker.errors.APIError as e:
logger.error(e)
return False
PK ! C tmn/elements/volume.pyimport logging
import docker
logger = logging.getLogger('tmn')
class Volume:
"""docstring for Volume."""
def __init__(self, client: docker.DockerClient, name: str) -> None:
self.name = name
self.volume = None
self.client = client
try:
self.volume = self.client.volumes.get(self.name)
except docker.errors.NotFound as e:
logger.debug('volume {} not yet created ({})'
.format(self.name, e))
except docker.errors.APIError as e:
logger.error(e)
def create(self) -> bool:
"create docker volumes"
try:
if self.volume:
return True
else:
self.client.volumes.create(self.name)
return True
except docker.errors.APIError as e:
logger.error(e)
return False
def remove(self) -> bool:
"delete docker volume"
try:
if self.volume:
self.volume.remove(force=True)
self.volume = None
return True
else:
return True
except docker.errors.APIError as e:
logger.error(e)
return False
PK ! }WJ>a a tmn/environments.pyenvironments = {
'testnet': {
'tomochain': {
'BOOTNODES': (
'enode://4d3c2cc0ce7135c1778c6f1cfda623ab44b4b6db55289543d48ec'
'fde7d7111fd420c42174a9f2fea511a04cf6eac4ec69b4456bfaaae0e5bd2'
'36107d3172b013@52.221.28.223:30301,enode://298780104303fcdb37'
'a84c5702ebd9ec660971629f68a933fd91f7350c54eea0e294b0857f1fd2e'
'8dba2869fcc36b83e6de553c386cf4ff26f19672955d9f312@13.251.101.'
'216:30301,enode://46dba3a8721c589bede3c134d755eb1a38ae7c5a4c6'
'9249b8317c55adc8d46a369f98b06514ecec4b4ff150712085176818d18f5'
'9a9e6311a52dbe68cff5b2ae@13.250.94.232:30301'
),
'NETSTATS_HOST': 'stats.testnet.tomochain.com',
'NETSTATS_PORT': '443',
'NETWORK_ID': '89',
'WS_SECRET': (
'anna-coal-flee-carrie-zip-hhhh-tarry-laue-felon-rhine'
)
},
'metrics': {
'METRICS_ENDPOINT': 'https://metrics.testnet.tomochain.com'
}
},
'devnet': {
'tomochain': {
'BOOTNODES': (
'enode://f3d3d5d6cd0fdde8996722ff5b5a92f331029b2dcbdb9748f50db'
'1421851a939eb660bf81a7ec7f359454aa0fd65fe4c03ae5c6bb2382b34df'
'aaca7eb6ecaf4e@52.77.194.164:30301,enode://34b923ddfcba1bfafd'
'd1ac7a030436f9fbdc565919189f5e62c8cadd798c239b5807a26ab7f6b96'
'a44200eb0399d1ebc2d9c1be94d2a774c8cc7660ff4c10367@13.228.93.2'
'32:30301,enode://e2604862d18049e025f294d63d537f9f54309ff09e45'
'ed69ff4f18c984831f5ef45370053355301e3a4da95aba2698c6116f4d2a3'
'47e5a5e0a3152ac0ae0f574@18.136.42.72:30301'
),
'NETSTATS_HOST': 'stats.devnet.tomochain.com',
'NETSTATS_PORT': '443',
'NETWORK_ID': '90',
'WS_SECRET': (
'torn-fcc-caper-drool-jelly-zip-din-fraud-rater-darn'
)
},
'metrics': {
'METRICS_ENDPOINT': 'https://metrics.devnet.tomochain.com'
}
}
}
PK ! m*
tmn/tmn.pyimport logging
import sys
import click
from tmn import display
from tmn import __version__
from tmn.configuration import Configuration
logger = logging.getLogger('tmn')
docker_url = None
@click.group(help=('Tomo MasterNode (tmn) is a cli tool to help you run a Tomo'
'chain masternode'))
@click.option('--debug', is_flag=True, help='Enable debug mode')
@click.option('--docker', metavar='URL', help='Url to the docker server')
@click.version_option(version=__version__)
def main(debug: bool, docker: str) -> None:
"Cli entrypoint"
global docker_url
if debug:
logger.setLevel('DEBUG')
logger.debug('Debugging enabled')
docker_url = docker
@click.command(help='Display TomoChain documentation link')
def docs() -> None:
"Link to the documentation"
display.link_docs()
@click.command(help='Start your TomoChain masternode')
@click.option('--name', metavar='NAME', help='Your masternode\'s name')
@click.option('--net', type=click.Choice(['testnet', 'devnet']),
help='The environment your masternode will connect to')
@click.option('--pkey', metavar='KEY', help=('Private key of the account your '
'masternode will collect rewards '
'on'))
def start(name: str, net: str, pkey: str) -> None:
"Start the containers needed to run a masternode"
configuration = Configuration(name=name, net=net, pkey=pkey, start=True,
docker_url=docker_url)
display.title_start_masternode(configuration.name)
# volumes
display.subtitle_create_volumes()
for _, value in configuration.volumes.items():
display.step_create_volume(value.name)
if value.create():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# networks
display.subtitle_create_networks()
for _, value in configuration.networks.items():
display.step_create_network(value.name)
if value.create():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# container
# create
display.subtitle_create_containers()
for _, value in configuration.services.items():
display.step_create_container(value.name)
if value.create():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# start
for _, value in configuration.services.items():
display.step_start_container(value.name)
if value.start():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
@click.command(help='Stop your TomoChain masternode')
def stop() -> None:
"Stop the masternode containers"
configuration = Configuration(docker_url=docker_url)
display.title_stop_masternode(configuration.name)
for _, service in configuration.services.items():
display.step_stop_container(service.name)
if service.stop():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
@click.command(help='Show the status of your TomoChain masternode')
def status() -> None:
"Show the status of the masternode containers"
configuration = Configuration(docker_url=docker_url)
display.title_status_masternode(configuration.name)
for _, service in configuration.services.items():
status = service.status()
if status and status == 'absent':
display.status(
name=service.name
)
if status and status in ['running']:
display.status(
name=service.name,
status=status,
id=service.container.short_id,
status_color='green'
)
elif status:
display.status(
name=service.name,
status=status,
id=service.container.short_id,
)
else:
display.status(
name=service.name,
status='error'
)
display.newline()
@click.command(help='Show details about your TomoChain masternode')
def inspect() -> None:
"Show details about the tomochain masternode"
configuration = Configuration(docker_url=docker_url)
display.title_inspect_masternode(configuration.name)
identity = configuration.services['tomochain'].execute(
'echo $IDENTITY'
) or 'container not running'
display.detail_identity(identity)
display.newline()
coinbase = configuration.services['tomochain'].execute(
'tomo account list --keystore keystore 2> /dev/null | head -n 1 | cut '
'-d"{" -f 2 | cut -d"}" -f 1'
)
if coinbase:
coinbase = '0x{}'.format(coinbase)
else:
coinbase = 'container not running'
display.detail_coinbase(coinbase)
display.newline()
@click.command(help='Update your masternode')
def update() -> None:
"Update the tomochain masternode with the lastest images"
configuration = Configuration(docker_url=docker_url)
display.title_update_masternode(configuration.name)
display.subtitle_remove_containers()
# containers
# stop
for _, service in configuration.services.items():
display.step_stop_container(service.name)
if service.stop():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# remove
for _, service in configuration.services.items():
display.step_remove_container(service.name)
if service.remove():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# create
for _, value in configuration.services.items():
display.step_create_container(value.name)
if value.create():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# start
for _, value in configuration.services.items():
display.step_start_container(value.name)
if value.start():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
@click.command(help='Remove your TomoChain masternode')
@click.option('--confirm', is_flag=True)
def remove(confirm: bool) -> None:
"Remove the masternode (containers, networks volumes)"
configuration = Configuration(docker_url=docker_url)
if not confirm:
display.warning_remove_masternode(configuration.name)
sys.exit()
display.title_remove_masternode(configuration.name)
display.subtitle_remove_containers()
# containers
# stop
for _, service in configuration.services.items():
display.step_stop_container(service.name)
if service.stop():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# remove
for _, service in configuration.services.items():
display.step_remove_container(service.name)
if service.remove():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# networks
display.subtitle_remove_networks()
for _, network in configuration.networks.items():
display.step_remove_network(network.name)
if network.remove():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
# volumes
display.subtitle_remove_volumes()
for _, volume in configuration.volumes.items():
display.step_remove_volume(volume.name)
if volume.remove():
display.step_close('✔')
else:
display.step_close('✗', 'red')
display.newline()
configuration.remove()
main.add_command(docs)
main.add_command(start)
main.add_command(stop)
main.add_command(status)
main.add_command(inspect)
main.add_command(update)
main.add_command(remove)
PK !H
# $ $ tmn-0.2.0.dist-info/entry_points.txtN+I/N.,()*ͳb= M PK !HW"T T tmn-0.2.0.dist-info/WHEEL
A
н#J."jm)Afb~
ڡ5G7hiޅF4+-3ڦ/̖?XPK !Hz[ tmn-0.2.0.dist-info/METADATAXmOH_1W*]QcJiOBKQ*ecoT̬'rTy}1"KZd3:/uxÅvGY*g|1\;n|pZYÓEY1S.NUbl@fc:}]փs_LEQDq yЉMj'@da,hrNK]@dRx>+x,*uWk.|Bg ͊ф +!5Յ'?ERX9e*jQs+=ЛD4 "WZeF:@K0H~=A}=@EbcE?pTH8;>vʉϥ,YǤ"E.RwL10F%Eh4E@˧2[l(R=s#OXxc`-$S?c9=>ۻ|;Bw/rKX!\_0#K?V6SM'AE-1# a3K'O+<b%mp}zvytzrO߽;9ËI ~&
639s;ZBxT,Km5vlO] I~R>aQ5K)%dI6"-;K};pv
1_
o<ַܬ
ה1zoz IDF;al)n(Ki3!WxHihzΜ|wfg&pݥ%Xnw!i(Sy 2Q9Q;vDχXc.EbR@\X.Ϛ+Fd0e@/.Gu<7^.Xf)̃&,zP
m
R3T7C϶'۫f+fTujP<"<`'Y΄$T*i DJpdJX
BۅC4V9Ff _so0mǜt7!t 0ʼnc, f_EDM?7@ )RN=
=oi\,QyX7nBw2ktqlbR¡ȨkWX[)>r4!M)kt$-Է^ӷz.Eߔ3Wȡ