1. use spurplus to get unique interface for local and remote processes
2. rename executable to tool
3. other minor changes
This commit is contained in:
Chi Song 2020-08-10 11:54:31 +08:00
Родитель 191e2af054
Коммит 09e060b84b
15 изменённых файлов: 267 добавлений и 286 удалений

Просмотреть файл

@ -1,7 +1,6 @@
from lisa import CaseMetadata, SuiteMetadata
from lisa.core.testSuite import TestSuite
from lisa.executable import Uname
from lisa.executable.echo import Echo
from lisa.tool import Echo, Uname
from lisa.util.logger import log

Просмотреть файл

@ -4,10 +4,13 @@ import random
from timeit import default_timer as timer
from typing import Dict, Optional, Type, TypeVar, cast
from lisa.core.executable import Executable
from lisa.core.sshConnection import SshConnection
from lisa.executable import Echo, Uname
import spur
import spurplus
from lisa.core.tool import Tool
from lisa.tool import Echo, Uname
from lisa.util import constants
from lisa.util.connectionInfo import ConnectionInfo
from lisa.util.executableResult import ExecutableResult
from lisa.util.logger import log
from lisa.util.process import Process
@ -28,7 +31,12 @@ class Node:
self.isDefault = isDefault
self.isRemote = isRemote
self.spec = spec
self.connection: Optional[SshConnection] = None
self.connection_info: Optional[ConnectionInfo] = None
self.tempFolder: str = ""
if self.isRemote:
self.shell: Optional[spurplus.SshShell] = None
else:
self.shell: Optional[spur.LocalShell] = None
self._isInitialized: bool = False
self._isLinux: bool = True
@ -37,7 +45,7 @@ class Node:
self.kernelVersion: str = ""
self.hardwarePlatform: str = ""
self.tools: Dict[Type[Executable], Executable] = dict()
self.tools: Dict[Type[Tool], Tool] = dict()
for tool_class in self.builtinTools:
self.tools[tool_class] = tool_class(self)
@ -59,17 +67,54 @@ class Node:
)
return node
def setConnectionInfo(self, **kwargs: str) -> None:
if self.connection is not None:
def setConnectionInfo(
self,
address: str = "",
port: int = 22,
publicAddress: str = "",
publicPort: int = 22,
username: str = "root",
password: str = "",
privateKeyFile: str = "",
) -> None:
if self.connection_info is not None:
raise Exception(
"node is set connection information already, cannot set again"
)
self.connection = SshConnection(**kwargs)
if not address and not publicAddress:
raise Exception("at least one of address and publicAddress need to be set")
elif not address:
address = publicAddress
elif not publicAddress:
publicAddress = address
if not port and not publicPort:
raise Exception("at least one of port and publicPort need to be set")
elif not port:
port = publicPort
elif not publicPort:
publicPort = port
self.connection_info = ConnectionInfo(
publicAddress, publicPort, username, password, privateKeyFile,
)
self.internalAddress = address
self.internalPort = port
def getTool(self, tool_type: Type[T]) -> T:
tool = cast(T, self.tools.get(tool_type))
if tool is None:
raise Exception(f"Tool {tool_type.__name__} is not found on node")
# the Tool is not installed on current node, try to install it.
tool = cast(Tool, T(self))
if not tool.isInstalled:
if tool.canInstall:
tool.install()
if not tool.isInstalled:
raise Exception(
f"Tool {tool_type.__name__} is not found on node, "
f"and cannot be installed or is install failed."
)
return tool
def execute(self, cmd: str, noErrorLog: bool = False) -> ExecutableResult:
@ -106,24 +151,32 @@ class Node:
log.info(f"initialized Windows node '{self.name}', ")
def _execute(self, cmd: str, noErrorLog: bool = False) -> ExecutableResult:
cmd_id = str(random.randint(0, 10000))
cmd_prefix = f"cmd[{str(random.randint(0, 10000))}]"
start_timer = timer()
log.debug(f"cmd[{cmd_id}] remote({self.isRemote}) {cmd}")
if self.isRemote:
# remote
if self.connection is None:
raise Exception(f"cmd[{cmd_id}] remote node has no connection info")
result: ExecutableResult = self.connection.execute(cmd, cmd_id=cmd_id)
else:
# local
process = Process()
with process:
process.start(cmd, cmd_id=cmd_id, noErrorLog=noErrorLog)
result = process.waitResult()
log.debug(f"{cmd_prefix}remote({self.isRemote}) '{cmd}'")
if self.shell is None:
if self.isRemote:
assert self.connection_info is not None
self.shell = spurplus.connect_with_retries(
self.connection_info.address,
port=self.connection_info.port,
username=self.connection_info.username,
password=self.connection_info.password,
private_key_file=self.connection_info.privateKeyFile,
missing_host_key=spur.ssh.MissingHostKey.accept,
)
else:
self.shell = spur.LocalShell()
process = Process(cmd_prefix, self.shell)
process.start(cmd, noErrorLog=noErrorLog)
result = process.waitResult()
end_timer = timer()
log.info(f"cmd[{cmd_id}] executed with {end_timer - start_timer:.3f} sec")
log.info(f"{cmd_prefix}executed with {end_timer - start_timer:.3f} sec")
return result
def close(self) -> None:
if self.connection is not None:
self.connection.close()
if self.shell and isinstance(self.shell, spurplus.SshShell):
self.shell.close()

Просмотреть файл

@ -1,119 +0,0 @@
import logging
from typing import Optional
import paramiko # type: ignore
from lisa.util.connectionInfo import ConnectionInfo
from lisa.util.executableResult import ExecutableResult
from lisa.util.logger import log_lines
class SshConnection:
def __init__(
self,
address: str = "",
port: int = 22,
publicAddress: str = "",
publicPort: int = 22,
username: str = "root",
password: str = "",
privateKeyFile: str = "",
) -> None:
self.address = address
self.port = port
self.publicAddress = publicAddress
self.publicPort = publicPort
self.username = username
self.password = password
self.privateKeyFile = privateKeyFile
if not self.address and not self.publicAddress:
raise Exception("at least one of address and publicAddress need to be set")
elif not self.address:
self.address = self.publicAddress
elif not self.publicAddress:
self.publicAddress = self.address
if not self.port and not self.publicPort:
raise Exception("at least one of port and publicPort need to be set")
elif not self.port:
self.port = self.publicPort
elif not self.publicPort:
self.publicPort = self.port
self._connectionInfo = ConnectionInfo(
self.address, self.port, self.username, self.password, self.privateKeyFile
)
self._publicConnectionInfo = ConnectionInfo(
self.publicAddress,
self.publicPort,
self.username,
self.password,
self.privateKeyFile,
)
self._connection: Optional[paramiko.SSHClient] = None
self._publicConnection: Optional[paramiko.SSHClient] = None
self._isConnected: bool = False
self._isPublicConnected: bool = False
@property
def connectionInfo(self) -> ConnectionInfo:
return self._connectionInfo
@property
def publicConnectionInfo(self) -> ConnectionInfo:
return self._publicConnectionInfo
def execute(
self, cmd: str, noErrorLog: bool = False, cmd_id: str = ""
) -> ExecutableResult:
client = self.connect()
_, stdout_file, stderr_file = client.exec_command(cmd)
exit_code: int = stdout_file.channel.recv_exit_status()
stdout: str = stdout_file.read().decode("utf-8").strip()
log_lines(logging.INFO, stdout, prefix=f"cmd[{cmd_id}]stdout: ")
stderr: str = stderr_file.read().decode("utf-8").strip()
if noErrorLog:
log_level = logging.INFO
else:
log_level = logging.ERROR
# fix, cannot print them together
log_lines(log_level, stderr, prefix=f"cmd[{cmd_id}]stderr: ")
result = ExecutableResult(stdout, stderr, exit_code)
return result
def connect(self, isPublic: bool = True) -> paramiko.SSHClient:
if isPublic:
connection = self._publicConnection
connectionInfo = self.publicConnectionInfo
else:
connection = self._connection
connectionInfo = self.connectionInfo
if connection is None:
connection = paramiko.SSHClient()
connection.set_missing_host_key_policy(paramiko.client.AutoAddPolicy)
connection.connect(
connectionInfo.address,
port=connectionInfo.port,
username=connectionInfo.username,
password=connectionInfo.password,
key_filename=connectionInfo.privateKeyFile,
look_for_keys=False,
)
if isPublic:
self._publicConnection = connection
else:
self._connection = connection
return connection
def close(self) -> None:
if self._connection is not None:
self._connection.close()
self._connection = None
if self._publicConnection is not None:
self._publicConnection.close()
self._publicConnection = None

Просмотреть файл

@ -9,7 +9,7 @@ if TYPE_CHECKING:
from lisa.core.node import Node
class Executable(ABC):
class Tool(ABC):
def __init__(self, node: Node) -> None:
self.node: Node = node
self.initialize()
@ -30,7 +30,7 @@ class Executable(ABC):
def installed(self) -> bool:
raise NotImplementedError()
def install(self) -> None:
def install(self) -> bool:
pass
def run(
@ -42,5 +42,5 @@ class Executable(ABC):
class ExecutableException(Exception):
def __init__(self, exe: Executable, message: str):
def __init__(self, exe: Tool, message: str):
self.message = f"{exe.command}: {message}"

Просмотреть файл

@ -1,5 +0,0 @@
from lisa.core.executable import Executable
class Ping(Executable):
pass

Просмотреть файл

@ -1,4 +1,3 @@
import os
import sys
from datetime import datetime
from logging import DEBUG, INFO
@ -12,10 +11,10 @@ from lisa.util.logger import init_log, log
@retry(FileExistsError, tries=10, delay=0) # type: ignore
def create_result_path() -> Path:
def create_run_root_path() -> Path:
date = datetime.utcnow().strftime("%Y%m%d")
time = datetime.utcnow().strftime("%H%M%S-%f")[:-3]
current_path = f"runtime/results/{date}/{date}-{time}"
current_path = f"runtime/runs/{date}/{date}-{time}"
path_obj = Path(current_path)
if path_obj.exists():
raise FileExistsError(f"{current_path} exists, and not found an unique path.")
@ -23,10 +22,10 @@ def create_result_path() -> Path:
def main() -> None:
# create result path
result_path = create_result_path().absolute()
result_path.mkdir(parents=True)
env.set_env(env.RESULT_PATH, str(result_path))
# create run root path
run_root_path = create_run_root_path().absolute()
run_root_path.mkdir(parents=True)
env.set_env(env.KEY_RUN_ROOT_PATH, str(run_root_path))
args = parse_args()
@ -34,7 +33,7 @@ def main() -> None:
log.info(f"Python version: {sys.version}")
log.info(f"local time: {datetime.now().astimezone()}")
log.info(f"command line args: {sys.argv}")
log.info(f"result path: {env.get_env(env.RESULT_PATH)}")
log.info(f"run root path: {env.get_env(env.KEY_RUN_ROOT_PATH)}")
if args.debug:
log_level = DEBUG
@ -53,5 +52,4 @@ if __name__ == "__main__":
log.exception(exception)
exitCode = -1
finally:
# force all threads end.
os._exit(exitCode)
sys.exit(exitCode)

Просмотреть файл

Просмотреть файл

@ -1,7 +1,7 @@
from lisa.core.executable import Executable
from lisa.core.tool import Tool
class Echo(Executable):
class Echo(Tool):
@property
def command(self) -> str:
return "echo"

5
lisa/tool/ping.py Normal file
Просмотреть файл

@ -0,0 +1,5 @@
from lisa.core.tool import Tool
class Ping(Tool):
pass

Просмотреть файл

@ -1,10 +1,10 @@
import re
from typing import Tuple
from lisa.core.executable import Executable
from lisa.core.tool import Tool
class Uname(Executable):
class Uname(Tool):
def initialize(self) -> None:
self.key_info_pattern = re.compile(
r"(?P<release>[^ ]*?) (?P<version>[\w\W]*) (?P<platform>[\w_]+?)$"

Просмотреть файл

@ -1,10 +1,21 @@
import os
from pathlib import PurePath
RESULT_PATH = "RESULT_PATH"
WORKING_PATH = "working"
KEY_RUN_ROOT_PATH = "RUN_ROOT_PATH"
__prefix = "LISA_"
def get_run_root_path() -> PurePath:
return get_env(KEY_RUN_ROOT_PATH)
def get_working_path() -> PurePath:
return get_run_root_path().joinpath(WORKING_PATH)
def set_env(name: str, value: str, isSecret: bool = False) -> None:
name = f"{__prefix}{name}"
os.environ[name] = value

Просмотреть файл

@ -3,13 +3,13 @@ import os
import time
# to prevent circular import, hard code it here.
env_result_path = "LISA_RESULT_PATH"
env_key_run_root_path = "LISA_RUN_ROOT_PATH"
def log_lines(logLevel: int, content: str, prefix: str = "") -> None:
for line in content.splitlines(False):
if prefix:
log.log(logLevel, f"{prefix} {line}")
log.log(logLevel, f"{prefix}{line}")
else:
log.log(logLevel, line)
@ -21,7 +21,7 @@ def init_log() -> None:
format=format,
datefmt="%m%d %H:%M:%S",
handlers=[
logging.FileHandler(f"{os.getenv(env_result_path)}/lisa-host.log"),
logging.FileHandler(f"{os.getenv(env_key_run_root_path)}/lisa-host.log"),
logging.StreamHandler(),
],
)

Просмотреть файл

@ -1,16 +1,14 @@
import logging
import os
import subprocess
import shlex
import time
from threading import Thread
from timeit import default_timer as timer
from types import TracebackType
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, cast
from typing import TYPE_CHECKING, Dict, Optional, Type, Union
import psutil # type: ignore
import spur
from spurplus import SshShell # type: ignore
from lisa.util.executableResult import ExecutableResult
from lisa.util.logger import log, log_lines
from lisa.util.logger import log
if TYPE_CHECKING:
BaseExceptionType = Type[BaseException]
@ -18,96 +16,70 @@ else:
BaseExceptionType = bool
class LogPipe(Thread):
def __init__(self, level: int, cmd_id: str = ""):
"""Setup the object with a logger and a loglevel
and start the thread
"""
Thread.__init__(self)
self.output: str = ""
self.daemon = False
class LogWriter:
def __init__(self, level: int, cmd_prefix: str = ""):
self.level = level
self.fdRead, self.fdWrite = os.pipe()
self.pipeReader = os.fdopen(self.fdRead)
self.isReadCompleted = False
self.isClosed = False
self.cmd_id = cmd_id
self.start()
self.cmd_prefix = cmd_prefix
self.buffer: str = ""
def fileno(self) -> int:
"""Return the write file descriptor of the pipe
"""
return self.fdWrite
def write(self, message: str):
if message == "\n":
log.log(self.level, f"{self. cmd_prefix}{self.buffer}")
self.buffer = ""
else:
self.buffer = "".join([self.buffer, message])
def run(self) -> None:
"""Run the thread, logging everything.
"""
output = self.pipeReader.read()
self.output = "".join([self.output, output])
log_lines(self.level, output, prefix=f"cmd[{self.cmd_id}]")
self.pipeReader.close()
self.isReadCompleted = True
def close(self) -> None:
"""Close the write end of the pipe.
"""
if not self.isClosed:
os.close(self.fdWrite)
self.isClosed = True
def close(self):
if len(self.buffer) > 0:
log.log(self.level, f"{self.cmd_prefix}{self.buffer}")
class Process:
def __init__(self) -> None:
self.process: Optional[subprocess.Popen[Any]] = None
self.exitCode: Optional[int] = None
self.log_pipe: Optional[LogPipe] = None
self._running: bool = False
def __enter__(self) -> None:
pass
def __exit__(
self,
exc_type: Optional[BaseExceptionType],
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
def __init__(
self, cmd_prefix: str, shell: Union[SshShell, spur.LocalShell]
) -> None:
self.close()
# the shell can be LocalShell or SshShell
self.shell = shell
self.cmd_prefix = cmd_prefix
self._running: bool = False
def start(
self,
command: str,
cwd: Optional[str] = None,
new_envs: Optional[Dict[str, str]] = None,
cmd_id: str = "",
noErrorLog: bool = False,
) -> None:
"""
command include all parameters also.
"""
dictEnv = dict(os.environ.copy())
if new_envs is not None:
for key, value in new_envs.items():
dictEnv[key] = value
self.stdout_pipe = LogPipe(logging.INFO, cmd_id)
self.stdout_writer = LogWriter(logging.INFO, f"{self.cmd_prefix}stdout: ")
if noErrorLog:
logLevel = logging.INFO
else:
logLevel = logging.ERROR
self.stderr_pipe = LogPipe(logLevel, cmd_id)
self.process = subprocess.Popen(
command,
shell=True,
stdout=cast(int, self.stdout_pipe),
stderr=cast(int, self.stderr_pipe),
cwd=cwd,
env=cast(Optional[Dict[str, str]], dictEnv),
)
self._running = True
if self.process is not None:
log.debug(f"process {self.process.pid} started")
self.stderr_writer = LogWriter(logLevel, f"{self.cmd_prefix}stderr: ")
split_command = shlex.split(command)
log.debug(f"split command: {split_command}")
try:
self.process = self.shell.spawn(
command=split_command,
stdout=self.stdout_writer,
stderr=self.stderr_writer,
cwd=cwd,
update_env=new_envs,
allow_error=True,
store_pid=True,
encoding="utf-8",
)
self._running = True
log.debug(f"{self.cmd_prefix}started")
except (FileNotFoundError, spur.errors.NoSuchCommandError) as identifier:
# FileNotFoundError: not found command on Windows
# NoSuchCommandError: not found command on remote Linux
self.process = ExecutableResult("", identifier.strerror, 1,)
log.debug(f"{self.cmd_prefix} not found command")
def waitResult(self, timeout: float = 600) -> ExecutableResult:
budget_time = timeout
@ -120,48 +92,29 @@ class Process:
if budget_time < 0:
if self.process is not None:
log.warn(f"process {self.process.pid} timeout in {timeout} sec")
log.warn(f"{self.cmd_prefix}timeout in {timeout} sec, and killed")
self.stop()
# close to get pipe complete
self.close()
if not isinstance(self.process, ExecutableResult):
assert self.process
proces_result = self.process.wait_for_result()
self.stdout_writer.close()
self.stderr_writer.close()
result = ExecutableResult(
proces_result.output.strip(),
proces_result.stderr_output.strip(),
proces_result.return_code,
)
else:
result = self.process
# wait all content flushed
while (
not self.stdout_pipe.isReadCompleted or not self.stderr_pipe.isReadCompleted
):
time.sleep(0.01)
return ExecutableResult(
self.stdout_pipe.output.strip(),
self.stderr_pipe.output.strip(),
self.exitCode,
)
return result
def stop(self) -> None:
if self.process is not None:
children = cast(
List[psutil.Process], psutil.Process(self.process.pid).children(True)
)
for child in children:
child.terminate()
self.process.terminate()
log.debug(f"process {self.process.pid} stopped")
def close(self) -> None:
if self.stdout_pipe is not None:
self.stdout_pipe.close()
if self.stderr_pipe is not None:
self.stderr_pipe.close()
if self.process and not isinstance(self.process, ExecutableResult):
self.process.send_signal(9)
def isRunning(self) -> bool:
self.exitCode = self.getExitCode()
if self.exitCode is not None and self.process is not None:
if self._running:
log.debug(f"process {self.process.pid} exited: {self.exitCode}")
self._running = False
if self._running:
self._running = self.process.is_running()
return self._running
def getExitCode(self) -> Optional[int]:
if self.process is not None:
self.exitCode = self.process.poll()
return self.exitCode

89
poetry.lock сгенерированный
Просмотреть файл

@ -6,6 +6,20 @@ optional = false
python-versions = "*"
version = "1.4.4"
[[package]]
category = "main"
description = "Annotate AST trees with source code positions"
name = "asttokens"
optional = false
python-versions = "*"
version = "2.0.4"
[package.dependencies]
six = "*"
[package.extras]
test = ["astroid", "pytest"]
[[package]]
category = "dev"
description = "Classes Without Boilerplate"
@ -158,6 +172,20 @@ version = ">=4.3.5,<5"
[package.extras]
test = ["pytest (>=4.0.2,<6)"]
[[package]]
category = "main"
description = "Provide design-by-contract with informative violation messages."
name = "icontract"
optional = false
python-versions = "*"
version = "2.3.4"
[package.dependencies]
asttokens = ">=2,<3"
[package.extras]
dev = ["mypy (0.750)", "pylint (2.3.1)", "yapf (0.20.2)", "tox (>=3.0.0)", "pydocstyle (>=2.1.1,<3)", "coverage (>=4.5.1,<5)", "docutils (>=0.14,<1)", "pygments (>=2.2.0,<3)", "dpcontracts (0.6.0)", "tabulate (>=0.8.7,<1)", "py-cpuinfo (>=5.0.0,<6)"]
[[package]]
category = "dev"
description = "A Python utility / library to sort Python imports."
@ -466,6 +494,46 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
version = "1.15.0"
[[package]]
category = "main"
description = "Run commands and manipulate files locally or over SSH using the same interface"
name = "spur"
optional = false
python-versions = "*"
version = "0.3.20"
[package.dependencies]
paramiko = ">=1.13.1,<3"
[[package]]
category = "main"
description = "Manage remote machines and file operations over SSH."
name = "spurplus"
optional = false
python-versions = "*"
version = "2.3.3"
[package.dependencies]
icontract = ">=2.0.1,<3"
spur = "0.3.20"
temppathlib = ">=1.0.3,<2"
typing_extensions = ">=3.6.2.1"
[package.extras]
dev = ["mypy (0.620)", "pylint (1.8.2)", "yapf (0.20.2)", "tox (>=3.0.0)", "coverage (>=4.5.1,<5)", "pydocstyle (>=2.1.1,<3)"]
[[package]]
category = "main"
description = "Wraps tempfile to give you pathlib.Path."
name = "temppathlib"
optional = false
python-versions = "*"
version = "1.0.3"
[package.extras]
dev = ["mypy (0.570)", "pylint (1.8.2)", "yapf (0.20.2)", "tox (>=3.0.0)"]
test = ["tox (>=3.0.0)"]
[[package]]
category = "dev"
description = "A collection of helpers and mock objects for unit tests and doc tests."
@ -496,7 +564,7 @@ python-versions = "*"
version = "1.4.1"
[[package]]
category = "dev"
category = "main"
description = "Backported and Experimental Type Hints for Python 3.5+"
name = "typing-extensions"
optional = false
@ -513,7 +581,7 @@ python-versions = "*"
version = "1.35"
[metadata]
content-hash = "59cde0dfa40d3e9d1494ed53142ff22b698a890bb024defd051d4fd1145caa0e"
content-hash = "47bdcdb43ab99e6e4c725e65fd612356d1f74c86032b19d610aa27503d88f560"
lock-version = "1.0"
python-versions = "^3.8"
@ -522,6 +590,10 @@ appdirs = [
{file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
{file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"},
]
asttokens = [
{file = "asttokens-2.0.4-py2.py3-none-any.whl", hash = "sha256:766d3352908730efb20b95ae22db0f1cb1bedb67c6071fcffb5c236ea673f2f7"},
{file = "asttokens-2.0.4.tar.gz", hash = "sha256:a42e57e28f2ac1c85ed9b1f84109401427e5c63c04f61d15b8842b027eec5128"},
]
attrs = [
{file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
{file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
@ -624,6 +696,9 @@ flake8-isort = [
{file = "flake8-isort-3.0.1.tar.gz", hash = "sha256:5d976da513cc390232ad5a9bb54aee8a092466a15f442d91dfc525834bee727a"},
{file = "flake8_isort-3.0.1-py2.py3-none-any.whl", hash = "sha256:df1dd6dd73f6a8b128c9c783356627231783cccc82c13c6dc343d1a5a491699b"},
]
icontract = [
{file = "icontract-2.3.4.tar.gz", hash = "sha256:5e45f7fcf957375163d63ef9b34a0413a15024f35029fd4b1f2ec21d8463879f"},
]
isort = [
{file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"},
{file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"},
@ -788,6 +863,16 @@ six = [
{file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
{file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
]
spur = [
{file = "spur-0.3.20-py2.py3-none-any.whl", hash = "sha256:f9446f418f419c046f49543ca55b35eff2e96b13cf7e0cf8605d657e5fa530ee"},
{file = "spur-0.3.20.tar.gz", hash = "sha256:f359e0573c0e4aaf8427494d6e67bc2bfac4f0c719e47f05e4750921b5804760"},
]
spurplus = [
{file = "spurplus-2.3.3.tar.gz", hash = "sha256:71c734a0827a68235d5b610c3570c3abc0c6be56708a340ba7bebc0a6eb77e92"},
]
temppathlib = [
{file = "temppathlib-1.0.3.tar.gz", hash = "sha256:58eaea9190639591f5005289e128b3b822eb5a3341d538ffdb7e67a73526421a"},
]
testfixtures = [
{file = "testfixtures-6.14.1-py2.py3-none-any.whl", hash = "sha256:30566e24a1b34e4d3f8c13abf62557d01eeb4480bcb8f1745467bfb0d415a7d9"},
{file = "testfixtures-6.14.1.tar.gz", hash = "sha256:58d2b3146d93bc5ddb0cd24e0ccacb13e29bdb61e5c81235c58f7b8ee4470366"},

Просмотреть файл

@ -13,6 +13,7 @@ regex = "^2020.7.14"
retry = "^0.9.2"
paramiko = "^2.7.1"
singleton-decorator = "^1.0.0"
spurplus = "^2.3.3"
[tool.poetry.dev-dependencies]
black = "^19.10b0"