Skip to content

Commit

Permalink
Add kitipy core components
Browse files Browse the repository at this point in the history
  • Loading branch information
Albin Kerouanton committed Oct 7, 2019
1 parent b1c266d commit da6af6d
Show file tree
Hide file tree
Showing 22 changed files with 2,306 additions and 0 deletions.
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
root = true

[*]
insert_final_newline = true

[*.py]
indent_size = 4
indent_style = space
7 changes: 7 additions & 0 deletions kitipy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .dispatcher import Dispatcher
from .context import Context, pass_context, get_current_context, get_current_executor
from .executor import Executor, InteractiveWarningPolicy
from .groups import Task, Group, RootCommand, root, task, group
from .utils import append_cmd_flags, load_config_file, normalize_config, set_up_file_transfer_listeners, wait_for

from . import filters
195 changes: 195 additions & 0 deletions kitipy/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import click
import subprocess
from typing import Any, Dict, List, Optional
from .dispatcher import Dispatcher
from .executor import Executor


class Context(object):
"""Kitipy context is the global object carrying the kitipy Executor used to
ubiquitously run commands on local and remote targets, as well as the stack
and stage objects loaded by command groups and the dispatcher used to update
the CLI based on executor events.
It's acting as a global Facade, such that you generally don't need to
interact with other kitipy or click objects.
As both kitipy and click exposes their own Context object, you might wonder
what's the fundamental difference between them, here it is:
* As said above, kitipy Context carry everything about how and where to
execute shell commands, on either local or remote targets. As such, it
has a central place in kitipy and is what you interact with within
kitipy tasks.
* In the other hand, the click Context is here to carry details about CLI
commands and options, and to actually parse and navigate the command
tree made of kitipy tasks or regular click commands. As kitipy is a
super-set of click features, click.Context actually embeds the
kitipy.Context object.
You generally don't need to instantiate it by yourself, as this is
handled by RootCommand which can be created through the kitipy.root()
decorator.
"""
def __init__(self,
config: Dict,
executor: Executor,
dispatcher: Dispatcher,
stage: Optional[Dict[Any, Any]] = None,
stack=None):
"""
Args:
config (Dict):
Normalized kitipy config (see normalize_config()).
executor (kitipy.Executor):
The command executor used to ubiquitously run commands on local
and remote targets.
dispatcher (kitipy.Dispatcher):
The event dispatcher used by the executor to signal events
about file transfers and any other event that shall produce
something on the CLI. This is used to decouple SSH matters
from the CLI.
stage (Optional[Dict[Any, Any]]):
This is the config for the stage in use.
There might be no stage available when the Context is built. In
such case, it can be set afterwards. The stage can be loaded
through kitipy.load_stage(), but this is handled
automatically by creating a stack-scoped command group through
kitipy.command() or kctx.command() decorators.
stack (Optional[kitipy.docker.BaseStack]):
This is the stack object representing the Compose/Swarm stack
in use.
There might be no stack available when the Context is built. In
such case, it can be set afterwards. The stack can be loaded
through kitipy.docker.load_stack(), but this is handled
automatically by creating a stack-scoped command group through
kitipy.command() or kctx.command() decorators.
"""
self.config = config
self.stage = stage
self.stack = stack
self.executor = executor
self.dispatcher = dispatcher

def run(self, cmd: str, **kwargs) -> subprocess.CompletedProcess:
"""This method is the way to ubiquitously run a command on either local
or remote target, depending on how the executor was set.
Args:
cmd (str): The command to run.
**kwargs: See Executor.run() options for more details.
Raises:
paramiko.SSHException:
When the SSH client fail to run the command. Note that this
won't be raised when the command could not be found or it
exits with code > 0 though, but only when something fails at
the SSH client/server lower level.
Returns:
subprocess.CompletedProcess
"""
return self.executor.run(cmd, **kwargs)

def local(self, cmd: str, **kwargs) -> subprocess.CompletedProcess:
"""Run a command on local host.
This method is particularly useful when you want to run some commands
on local host whereas the Executor is running in remote mode. For
instance, you might want to check if a given git tag or some Docker
images exists on a remote repository/registry before deploying it,
or you might want to fetch the local git author name to log deployment
events somewhere. Such checks are generally better run locally.
Args:
cmd (str): The command to run.
**kwargs: See Executor.run() options for more details.
Raises:
paramiko.SSHException:
When the SSH client fail to run the command. Note that this
won't be raised when the command could not be found or it
exits with code > 0 though, but only when something fails at
the SSH client/server lower level.
Returns:
subprocess.CompletedProcess
"""
return self.executor.local(cmd, **kwargs)

def copy(self, src: str, dest: str):
"""Copy a local file to a given path. If the underlying executor has
been configured to work in remote mode, the given source path will
be copied over network."""
self.executor.copy(src, dest)

def get_stage_names(self):
"""Get the name of all stages in the configuration"""
return self.config['stages'].keys()

def get_stack_names(self):
"""Get the name of all stacks in the configuration"""
return self.config['stacks'].keys()

@property
def is_local(self):
"""Check if current kitipy Executor is in local mode"""
return self.executor.is_local

@property
def is_remote(self):
"""Check if current kitipy Executor is in remote mode"""
return self.executor.is_remote

@property
def meta(self):
"""Meta properties from current click.Context"""
return click.get_current_context().meta

def invoke(self, *args, **kwargs):
"""Call invoke() method on current click.Context"""
return click.get_current_context().invoke(*args, **kwargs)

def echo(self, *args, **kwargs):
"""Call echo() method on current click.Context"""
return click.echo(*args, **kwargs)

def fail(self, message):
"""Call fail() method on current click.Context"""
raise click.ClickException(message)


pass_context = click.make_pass_decorator(Context)


def get_current_context() -> Context:
"""
Find the current kitipy context or raise an error.
Raises:
RuntimeError: When no kitipy context has been found.
Returns:
Context: The current kitipy context.
"""

click_ctx = click.get_current_context()
kctx = click_ctx.find_object(Context)
if kctx is None:
raise RuntimeError('No kitipy context found.')
return kctx


def get_current_executor() -> Executor:
"""
Get the executor from the current kitipy context or raise an error.
Raises:
RuntimeError: When no kitipy context has been found.
Returns:
Executor: The executor of the current kitipy context.
"""

kctx = get_current_context()
return kctx.executor
52 changes: 52 additions & 0 deletions kitipy/dispatcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from typing import Any, Callable, Dict, List


class Dispatcher(object):
"""This dispatcher is mostly used to decouple CLI concerns from SSH/SFTP
handling.
"""
def __init__(self, listeners: Dict[str, List[Callable[..., bool]]] = {}):
"""
Args:
listeners (Dict[str, Callable[..., bool]]):
List of callables taking undefined arguments and returning a
bool associated to event names.
"""
self.__listeners = listeners

def on(self, event_name: str, fn: Callable[..., bool]):
"""Register a listener for a given event name.
Args:
event_name (str):
Name of the event the listeners should be attached to.
fn (Callable[[Any, ...], bool]):
The event listener that should be triggered for the given event
name.
"""

if event_name not in self.__listeners:
self.__listeners[event_name] = []

self.__listeners[event_name].append(fn)

def emit(self, event_name: str, **kwargs: Any):
"""Trigger all the event listeners registered for a given event name.
This dispatcher doesn't support listener priority, so the event
listeners are called in the order they've been registered.
Listeners can either inform the Dispatcher to continue the event
propagation, by returning True, or stop it by returning anything else
or nothing.
Args:
event_name (str): Name of the emitted event
**kwargs: Any arguments associated with the event
"""

if event_name not in self.__listeners:
return

for fn in self.__listeners[event_name]:
if not fn(**kwargs):
return
Loading

0 comments on commit da6af6d

Please sign in to comment.