66 Commits
Author SHA1 Message Date
jpic b48e997a4b Fixing push 2020-10-08 01:24:53 +02:00
jpic 3a45cdef4a Fix up cmd 2020-10-07 23:55:10 +02:00
jpic bd3bf37643 Target level cleaning had been forgotten
... when action level cleaning was added

Test both while we're at it, as this is pretty critical for actions to
clean up properly
2020-10-07 23:15:14 +02:00
jpic e196d321c5 Commenting docker job for now 2020-10-05 18:41:16 +02:00
jpic 422da7043f test 2020-10-05 18:38:03 +02:00
jpic 1854d7116c Fix shlax.cli (finally) 2020-10-05 18:30:00 +02:00
jpic c8cdbf38bf Cache invalidation failures must not stop build
Images may still be in use by running containers
2020-10-05 18:29:59 +02:00
jpic 5f1afd89ee Prevent print traceback for failed commands 2020-10-05 18:29:15 +02:00
jpic 8ff4239a65 Forward CLI arguments 2020-10-05 18:28:53 +02:00
jpic 7ea6e10282 Fix Pod.build to unshare prior to building async 2020-08-28 18:43:56 +02:00
jpic e97e900fc6 Add target argument only if present in sig 2020-08-28 18:39:16 +02:00
jpic 7e9ec37103 Readability 2020-08-28 18:38:11 +02:00
jpic d9e74c2538 Add container inspect 2020-08-28 18:37:25 +02:00
jpic 9124e93dca Layers refactor 2020-08-08 16:08:01 +02:00
jpic cd9ccec19c Support configured containers in start 2020-08-08 16:07:15 +02:00
jpic 73af20ded7 Fix TargetArgument implementation to stay optional 2020-08-08 15:54:02 +02:00
jpic f127f27ecd Add exec command 2020-08-02 22:19:13 +02:00
jpic 222eda3746 Skip report display for only one result 2020-08-02 20:11:24 +02:00
jpic c38ad7e089 Getting on par with docker-compose 2020-08-02 20:11:07 +02:00
jpic bc7cb9c355 Remove artificial centering from output labels 2020-08-02 20:10:19 +02:00
jpic 96c533fc1a Name parallel executor 2020-08-02 20:09:40 +02:00
jpic 8c75cac48a Add Buildah.Env and Buildah.Config 2020-08-02 06:52:39 +02:00
jpic cc20c7b205 New write with bash heredoc 2020-08-02 06:52:03 +02:00
jpic 50b266013b Let an action mark itself as skipped 2020-08-02 06:51:25 +02:00
jpic 75747a8ee9 Container: support not having a build job and new methods 2020-08-02 06:51:07 +02:00
jpic 9fab1b2411 Fix user action 2020-08-02 06:50:42 +02:00
jpic 77f0d1f67b Run(root) 2020-08-02 06:50:33 +02:00
jpic 189c4c3856 Test build-itself 2020-07-06 08:58:36 +02:00
jpic 59d652710a Update TargetArgument docs 2020-06-11 18:39:43 +02:00
jpic ea2f212787 Mention command line 2020-06-11 18:13:01 +02:00
jpic f78be1cb56 Fix title order 2020-06-11 18:12:02 +02:00
jpic 0255655615 Lolnote 2020-06-11 18:11:06 +02:00
jpic f1df4dcc69 Going to maintain a little news at the top of the README 2020-06-11 18:08:59 +02:00
jpic 4317cf9cd4 Do not try to commit a container that has not been created because layers are uptodate 2020-06-11 17:44:18 +02:00
150736f4f4 Merge branch 'mostadvanced' into 'master'
Mostadvanced

See merge request oss/shlax!4
2020-06-11 17:43:06 +02:00
jpic d2784cc87b Fixing container config support 2020-06-11 17:40:01 +02:00
jpic 62f1f5d162 Commiting build-itself job until i re-implement docker commiting 2020-06-11 17:37:09 +02:00
jpic cfe75d2873 Clean on KeyboardInterrupt, proper call in shlax Command 2020-06-11 03:51:28 +02:00
jpic 5f451a786f Package action working both on SSH host and Buildah guest now 2020-06-11 03:19:01 +02:00
jpic 3b6a4f3fc4 Added ssh target 2020-06-01 15:41:22 +02:00
jpic 879bb6f79e Silence pip output 2020-05-31 13:24:31 +02:00
jpic e733694f2d A bit of work on the command line 2020-05-31 13:22:36 +02:00
jpic 0ec287c977 Tag/build/push refactor 2020-05-31 12:53:57 +02:00
jpic 833cef7527 Typo in ci 2020-05-31 12:28:49 +02:00
jpic 28f555bcf3 Try more build 2020-05-31 12:23:48 +02:00
jpic 363bdb1493 Fix CI command to build 2020-05-31 05:15:15 +02:00
jpic 1373196eb5 Buildah __str__ 2020-05-31 03:51:13 +02:00
jpic da7b7191c9 Missing await in test function 2020-05-31 03:44:57 +02:00
jpic 1660acbcbb Pass status directly to clean 2020-05-31 03:44:51 +02:00
jpic 9c3790e438 Improve Buildah clean method 2020-05-31 03:40:00 +02:00
jpic f5c7e0b1a1 Set the action result prior to calling clean 2020-05-31 03:39:42 +02:00
jpic 3039f75179 Pip action implementation 2020-05-31 03:39:26 +02:00
jpic 637d49e1ab Bugfix: legacy code would prevent containers from shuting down after build 2020-05-31 02:45:24 +02:00
jpic ea4be19a86 Proper cache invalidation 2020-05-31 02:44:57 +02:00
jpic befd01cb03 Proper traceback prints 2020-05-31 02:44:13 +02:00
jpic fdd0ff6532 Copy action: refactor, caching, filtering 2020-05-31 02:43:37 +02:00
jpic 074546bdda Work on the CLI story 2020-05-31 00:10:57 +02:00
jpic 6a6e474a1e Adding Copy/User/Pip actions again 2020-05-31 00:00:25 +02:00
jpic 3eb0f22ef9 Add CLI to execute Actions on the fly 2020-05-31 00:00:25 +02:00
jpic d68fdf8d5d Proper render method actions 2020-05-31 00:00:25 +02:00
jpic 2dc00dc2cd fixup! Add layer caching 2020-05-31 00:00:25 +02:00
jpic 02e9ac6683 Replace Localhost with plain Target, ensure parent presence 2020-05-31 00:00:25 +02:00
jpic 700d13876a Add layer caching 2020-05-31 00:00:25 +02:00
jpic a6f2c9fb07 Add Proc.quiet 2020-05-31 00:00:25 +02:00
jpic 407240e2a2 Add Package.upgrade option 2020-05-31 00:00:25 +02:00
852f8551af Core rewrite
See merge request oss/shlax!2
2020-04-22 03:41:16 +02:00
23 changed files with 1114 additions and 232 deletions
+11 -6
View File
@@ -2,15 +2,20 @@ build:
cache:
key: cache
paths: [.cache]
image: quay.io/buildah/stable
image: yourlabs/buildah
script:
- dnf install -y curl python38
- curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
- python3.8 get-pip.py
- pip3.8 install -U --user -e .[cli]
- CACHE_DIR=$(pwd)/.cache ~/.local/bin/shlax ./shlaxfile.py build
- pip3 install -U --user -e .[cli]
- CACHE_DIR=$(pwd)/.cache python3 ./shlaxfile.py build push
stage: build
build-itself:
cache:
key: cache
paths: [.cache]
image: quay.io/yourlabs/shlax:$CI_COMMIT_SHORT_SHA
script: python3 ./shlaxfile.py build
stage: test
test:
image: yourlabs/python
stage: build
+65 -11
View File
@@ -1,9 +1,32 @@
# Shlax: Pythonic automation tool
Shlax is a Python framework for system automation, initially with the purpose
of replacing docker, docker-compose and ansible with a single tool, with the
purpose of code-reuse. It may be viewed as "async fabric rewrite by a
megalomanic Django fanboy".
of replacing docker, docker-compose and ansible with a single tool with the
purpose of code-reuse made possible by target abstraction.
## Development status: Design state
I got the thing to work with an ugly PoC that I basically brute-forced, I'm
currently rewriting the codebase with a proper design.
The stories are in development in this order:
- replacing docker build, that's in the state of polishing
- replacing docker-compose, not in use but the PoC works so far
- replacing ansible, also working in working PoC state, the shlax command line
demonstrates
This project is supposed to unblock me from adding the CI feature to the
Sentry/GitLab/Portainer implementation I'm doing in pure python on top of
Django, CRUDLFA+ and Ryzom (isomorphic components in Python to replace
templates). So, as you can see, I'm really deep in it with a strong
determination.
Shlax builds its container itself, so check the shlaxfile.py of this repository
to see what it currently looks like, and check the build job of the CI pipeline
to see the output.
# Design
The pattern resolves around two moving parts: Actions and Targets.
@@ -187,12 +210,13 @@ class Docker(Target):
return await self.parent.exec(*['docker', 'exec', self.name] + args)
```
Don't worry about `self.parent` being set, it is enforced to `Localhost` if
unset so that we always have something that actually spawns a process in the
chain ;)
This also means that you always need a parent with an exec implementation,
there are two:
The result of that design is that the following use cases are open for
business:
- Localhost, executes on localhost
- Stub, for testing
The result of that design is that the following use cases are available:
```python
# This action installs my favorite package on any distro
@@ -215,14 +239,44 @@ Ssh(host='yourhost')(build)
# Or on a server behingh a bastion:
# ssh yourbastion ssh yourhost build exec apt install python3
Ssh(host='bastion')(Ssh(host='yourhost')(build))
Localhost()(Ssh(host='bastion')(Ssh(host='yourhost')(build))
# That's going to do the same
Ssh(
Localhost(Ssh(
Ssh(
build,
host='yourhost'
),
host='bastion'
)()
))()
```
## CLI
You can execute Shlax actions directly on the command line with the `shlax` CLI
command.
For your own Shlaxfiles, you can build your CLI with your favorite CLI
framework. If you decide to use `cli2`, then Shlax provides a thin layer on top
of it: Group and Command objects made for Shlax objects.
For example:
```python
yourcontainer = Container(
build=Buildah(
User('app', '/app', 1000),
Packages('python', 'unzip', 'findutils'),
Copy('setup.py', 'yourdir', '/app'),
base='archlinux',
commit='yourimage',
),
)
if __name__ == '__main__':
print(Group(doc=__doc__).load(yourcontainer).entry_point())
```
The above will execute a cli2 command with each method of yourcontainer as a
sub-command.
+2 -2
View File
@@ -7,7 +7,7 @@ setup(
setup_requires='setupmeta',
extras_require=dict(
cli=[
'cli2',
'cli2>=2.3.0',
],
test=[
'pytest',
@@ -24,7 +24,7 @@ setup(
python_requires='>=3',
entry_points={
'console_scripts': [
'shlax = shlax.cli:cli',
'shlax = shlax.cli:cli.entry_point',
],
},
)
+56
View File
@@ -0,0 +1,56 @@
import asyncio
import binascii
import os
class Copy:
def __init__(self, *args):
self.src = args[:-1]
self.dst = args[-1]
def listfiles(self):
if getattr(self, '_listfiles', None):
return self._listfiles
result = []
for src in self.src:
if os.path.isfile(src):
result.append(src)
continue
for root, dirs, files in os.walk(src):
if '__pycache__' in root:
continue
result += [
os.path.join(root, f)
for f in files
if not f.endswith('.pyc')
]
self._listfiles = result
return result
async def __call__(self, target):
await target.mkdir(self.dst)
for path in self.listfiles():
if os.path.isdir(path):
await target.mkdir(os.path.join(self.dst, path))
elif '/' in path:
dirname = os.path.join(
self.dst,
'/'.join(path.split('/')[:-1])
)
await target.mkdir(dirname)
await target.copy(path, dirname)
else:
await target.copy(path, self.dst)
def __str__(self):
return f'Copy({", ".join(self.src)}, {self.dst})'
async def cachekey(self):
async def chksum(path):
with open(path, 'rb') as f:
return (path, str(binascii.crc32(f.read())))
results = await asyncio.gather(*[chksum(f) for f in self.listfiles()])
return {path: chks for path, chks in results}
+47 -34
View File
@@ -37,11 +37,13 @@ class Packages:
update='pacman -Sy',
upgrade='pacman -Su --noconfirm',
install='pacman -S --noconfirm',
lastupdate='stat -c %Y /var/lib/pacman/sync/core.db',
),
dnf=dict(
update='dnf makecache --assumeyes',
upgrade='dnf upgrade --best --assumeyes --skip-broken', # noqa
install='dnf install --setopt=install_weak_deps=False --best --assumeyes', # noqa
lastupdate='stat -c %Y /var/cache/dnf/* | head -n1',
),
yum=dict(
update='yum update',
@@ -52,53 +54,52 @@ class Packages:
installed = []
def __init__(self, *packages):
def __init__(self, *packages, upgrade=False):
self.packages = []
self.upgrade = upgrade
for package in packages:
line = dedent(package).strip().replace('\n', ' ')
self.packages += line.split(' ')
@property
def cache_root(self):
async def cache_setup(self, target):
if 'CACHE_DIR' in os.environ:
return os.path.join(os.getenv('CACHE_DIR'))
self.cache_root = os.path.join(os.getenv('CACHE_DIR'))
else:
return os.path.join(os.getenv('HOME'), '.cache')
self.cache_root = os.path.join(await target.parent.getenv('HOME'), '.cache')
# run pkgmgr_setup functions ie. apk_setup
await getattr(self, self.mgr + '_setup')(target)
async def update(self, target):
# run pkgmgr_setup functions ie. apk_setup
cachedir = await getattr(self, self.mgr + '_setup')(target)
# lastupdate = await target.exec(self.cmds['lastupdate'], raises=False)
# lastupdate = int(lastupdate.out) if lastupdate.rc == 0 else None
lastupdate = None
if os.path.exists(cachedir + '/lastupdate'):
with open(cachedir + '/lastupdate', 'r') as f:
try:
lastupdate = int(f.read().strip())
except:
pass
if not os.path.exists(cachedir):
os.makedirs(cachedir)
now = int(datetime.now().strftime('%s'))
# cache for a week
if not lastupdate or now - lastupdate > 604800:
await target.rexec(self.cmds['update'])
return
# disabling with the above return call until needed again
# might have to rewrite this to not have our own lockfile
# or find a better place on the filesystem
# also make sure the lockfile is actually needed when running on
# targets that don't have isguest=True
if not lastupdate or now - lastupdate > 604800:
# crude lockfile implementation, should work against *most*
# race-conditions ...
lockfile = cachedir + '/update.lock'
if not os.path.exists(lockfile):
with open(lockfile, 'w+') as f:
f.write(str(os.getpid()))
if not await target.parent.exists(lockfile):
await target.parent.write(lockfile, str(os.getpid()))
try:
await target.rexec(self.cmds['update'])
finally:
os.unlink(lockfile)
await target.parent.rm(lockfile)
with open(cachedir + '/lastupdate', 'w+') as f:
f.write(str(now))
await target.parent.write(cachedir + '/lastupdate', str(now))
else:
while os.path.exists(lockfile):
while await target.parent.exists(lockfile):
print(f'{self.target} | Waiting for {lockfile} ...')
await asyncio.sleep(1)
@@ -115,8 +116,15 @@ class Packages:
raise Exception('Packages does not yet support this distro')
self.cmds = self.mgrs[self.mgr]
if target.isguest:
# we're going to mount
await self.cache_setup(target)
await self.update(target)
await target.rexec(self.cmds['upgrade'])
if self.upgrade:
await target.rexec(self.cmds['upgrade'])
packages = []
for package in self.packages:
@@ -145,19 +153,24 @@ class Packages:
return cachedir
async def apt_setup(self, target):
codename = (await self.rexec(
f'source {self.mnt}/etc/os-release; echo $VERSION_CODENAME'
codename = (await target.rexec(
f'source /etc/os-release; echo $VERSION_CODENAME'
)).out
cachedir = os.path.join(self.cache_root, self.mgr, codename)
await self.rexec('rm /etc/apt/apt.conf.d/docker-clean')
cache_archives = os.path.join(cachedir, 'archives')
await self.mount(cache_archives, f'/var/cache/apt/archives')
await target.mount(cache_archives, f'/var/cache/apt/archives')
cache_lists = os.path.join(cachedir, 'lists')
await self.mount(cache_lists, f'/var/lib/apt/lists')
await target.mount(cache_lists, f'/var/lib/apt/lists')
return cachedir
async def pacman_setup(self, target):
return self.cache_root + '/pacman'
cachedir = os.path.join(self.cache_root, self.mgr)
await target.mkdir(cachedir + '/cache', cachedir + '/sync')
await target.mount(cachedir + '/sync', '/var/lib/pacman/sync')
await target.mount(cachedir + '/cache', '/var/cache/pacman')
if await target.host.exists('/etc/pacman.d/mirrorlist'):
await target.copy('/etc/pacman.d/mirrorlist', '/etc/pacman.d/mirrorlist')
def __repr__(self):
return f'Packages({self.packages})'
def __str__(self):
return f'Packages({self.packages}, upgrade={self.upgrade})'
+3
View File
@@ -9,3 +9,6 @@ class Parallel:
return await asyncio.gather(*[
target(action) for action in self.actions
])
def __str__(self):
return 'Parallel executor'
+72
View File
@@ -0,0 +1,72 @@
from glob import glob
import os
from urllib import request
from .base import Action
class Pip(Action):
"""Pip abstraction layer."""
def __init__(self, *pip_packages):
self.pip_packages = pip_packages
async def __call__(self, target):
# ensure python presence
results = await target.which('python3', 'python')
if results:
python = results[0]
else:
raise Exception('Could not find pip nor python')
# ensure pip module presence
result = await target.exec(
python, '-m', 'pip',
raises=False, quiet=True
)
if result.rc != 0:
if not os.path.exists('get-pip.py'):
req = request.urlopen(
'https://bootstrap.pypa.io/get-pip.py'
)
content = req.read()
with open('get-pip.py', 'wb+') as f:
f.write(content)
await target.copy('get-pip.py', '.')
await target.exec(python, 'get-pip.py')
# choose a cache directory
if 'CACHE_DIR' in os.environ:
cache = os.path.join(os.getenv('CACHE_DIR'), 'pip')
else:
cache = os.path.join(os.getenv('HOME'), '.cache', 'pip')
# and mount it
if getattr(target, 'mount', None):
# we are in a target which shares a mount command
await target.mount(cache, '/root/.cache/pip')
source = []
nonsource = []
for package in self.pip_packages:
if os.path.exists(package):
source.append(package)
else:
nonsource.append(package)
if nonsource:
await target.exec(
python, '-m', 'pip',
'install', '--upgrade',
*nonsource
)
if source:
await target.exec(
python, '-m', 'pip',
'install', '--upgrade', '--editable',
*source
)
def __str__(self):
return f'Pip({", ".join(self.pip_packages)})'
+6 -2
View File
@@ -1,11 +1,15 @@
class Run:
def __init__(self, cmd):
def __init__(self, cmd, root=False):
self.cmd = cmd
self.root = root
async def __call__(self, target):
self.proc = await target.exec(self.cmd)
if self.root:
self.proc = await target.rexec(self.cmd)
else:
self.proc = await target.exec(self.cmd)
def __str__(self):
return f'Run({self.cmd})'
+44
View File
@@ -0,0 +1,44 @@
import os
import re
from .packages import Packages
class User:
"""
Create a user.
Example:
User('app', '/app', getenv('_CONTAINERS_ROOTLESS_UID', 1000)),
_CONTAINERS_ROOTLESS_UID allows to get your UID during build, which happens
in buildah unshare.
"""
def __init__(self, username, home, uid):
self.username = username
self.home = home
self.uid = uid
def __str__(self):
return f'User({self.username}, {self.home}, {self.uid})'
async def __call__(self, target):
result = await target.rexec('id', self.uid, raises=False)
if result.rc == 0:
old = re.match('.*\(([^)]*)\).*', result.out).group(1)
await target.rexec(
'usermod',
'-d', self.home,
'-l', self.username,
old
)
else:
await target.rexec(
'useradd',
'-d', self.home,
'-u', self.uid,
self.username
)
await target.mkdir(self.home)
await target.rexec('chown', self.uid, self.home)
+99 -34
View File
@@ -5,6 +5,7 @@ Shlax executes mostly in 3 ways:
- With the name of a module in shlax.repo: a community maintained shlaxfile
"""
import ast
import asyncio
import cli2
import glob
import inspect
@@ -12,44 +13,108 @@ import importlib
import os
import sys
class ConsoleScript(cli2.ConsoleScript):
def __call__(self):
repo = os.path.join(os.path.dirname(__file__), 'repo')
if len(self.argv) > 1:
repofile = os.path.join(repo, sys.argv[1] + '.py')
if os.path.isfile(self.argv[1]):
self.argv = sys.argv[1:]
self.load_shlaxfile(sys.argv[1])
elif os.path.isfile(repofile):
self.argv = sys.argv[1:]
self.load_shlaxfile(repofile)
else:
raise Exception('File not found ' + sys.argv[1])
else:
available = glob.glob(os.path.join(repo, '*.py'))
return super().__call__()
from .proc import ProcFailure
def load_shlaxfile(self, path):
with open(path) as f:
src = f.read()
tree = ast.parse(src)
class Group(cli2.Group):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.cmdclass = Command
members = []
for node in tree.body:
if not isinstance(node, ast.Assign):
class TargetArgument(cli2.Argument):
"""
Target to execute on: localhost by default, target=@ssh_host for ssh.
"""
def __init__(self, cmd, param, doc=None, color=None, default=None):
from shlax.targets.base import Target
super().__init__(cmd, param, doc=self.__doc__, default=Target())
self.alias = ['target', 't']
def cast(self, value):
from shlax.targets.ssh import Ssh
user, host = value.split('@')
return Ssh(host=host, user=user)
def match(self, arg):
return arg if isinstance(arg, str) and '@' in arg else None
class Command(cli2.Command):
def setargs(self):
super().setargs()
if 'target' in self.sig.parameters:
self['target'] = TargetArgument(
self,
self.sig.parameters['target'],
)
if 'actions' in self:
del self['actions']
def __call__(self, *argv):
result = None
try:
result = super().__call__(*argv)
except ProcFailure:
# just output the failure without TB, as command was already
# printed anyway
pass
self['target'].value.output.results(self['target'].value)
return result
class ActionCommand(cli2.Command):
def setargs(self):
super().setargs()
self['target'] = TargetArgument(
self,
inspect.Parameter('target', inspect.Parameter.KEYWORD_ONLY),
)
def call(self, *args, **kwargs):
self.target = self.target(*args, **kwargs)
return super().call(self['target'].value)
class ConsoleScript(Group):
def __call__(self, *argv):
self.load_actions()
#self.load_shlaxfiles() # wip
return super().__call__(*argv)
def load_shlaxfiles(self):
filesdir = os.path.dirname(__file__) + '/shlaxfiles/'
for filename in os.listdir(filesdir):
filepath = filesdir + filename
if not os.path.isfile(filepath):
continue
if not isinstance(node.value, ast.Call):
with open(filepath, 'r') as f:
tree = ast.parse(f.read())
group = self.group(filename[:-3])
main = Group(doc=__doc__).load(shlax)
def load_actions(self):
actionsdir = os.path.dirname(__file__) + '/actions/'
for filename in os.listdir(actionsdir):
filepath = actionsdir + filename
if not os.path.isfile(filepath):
continue
members.append(node.targets[0].id)
with open(filepath, 'r') as f:
tree = ast.parse(f.read())
cls = [
node
for node in tree.body
if isinstance(node, ast.ClassDef)
]
if not cls:
continue
mod = importlib.import_module('shlax.actions.' + filename[:-3])
cls = getattr(mod, cls[0].name)
self.add(cls, name=filename[:-3], cmdclass=ActionCommand)
spec = importlib.util.spec_from_file_location('shlaxfile', sys.argv[1])
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
for member in members:
from shlax.targets.localhost import Localhost
self[member] = cli2.Callable(member, Localhost(getattr(mod, member)))
cli = ConsoleScript(__doc__)
cli = ConsoleScript(doc=__doc__)
+129
View File
@@ -0,0 +1,129 @@
import copy
import os
from .podman import Podman
from .image import Image
class Container:
def __init__(self, build=None, image=None, env=None, volumes=None):
self.build = build
self.image = image or self.build.image
if isinstance(self.image, str):
self.image = Image(self.image)
self.volumes = volumes or {}
self.env = env or {}
prefix = os.getcwd().split('/')[-1]
repo = self.image.repository.replace('/', '-')
if prefix == repo:
self.name = repo
else:
self.name = '-'.join([prefix, repo])
self.pod = None
@property
def full_name(self):
if self.pod:
return '-'.join([self.pod.name, self.name])
return self.name
async def up(self, target, *args):
"""Start the container foreground"""
podman = Podman(target)
if self.pod:
pod = None
for _ in await podman.pod.ps():
if _['Name'] == self.pod.name:
pod = _
break
if not pod:
await podman.pod.create('--name', self.pod.name)
args = list(args) + ['--pod', self.pod.name]
# skip if already up
for result in await podman.ps('-a'):
for name in result['Names']:
if name == self.full_name:
if result['State'] == 'running':
target.output.info(f'{self.full_name} already running')
return
elif result['State'] in ('exited', 'configured'):
target.output.info(f'{self.full_name} starting')
startargs = ['podman', 'start']
if '-d' not in args:
startargs.append('--attach')
startargs.append(self.full_name)
await target.exec(*startargs)
return
cmd = [
'podman',
'run',
] + list(args)
for src, dest in self.volumes.items():
cmd += ['--volume', ':'.join([src, dest])]
for src, dest in self.env.items():
cmd += ['--env', '='.join([src, str(dest)])]
cmd += [
'--name',
self.full_name,
str(self.image),
]
await target.exec(*cmd)
async def start(self, target):
"""Start the container background"""
await self.up(target, '-d')
async def stop(self, target):
"""Start the container"""
await target.exec('podman', 'stop', self.full_name)
async def inspect(self, target):
"""Inspect container"""
await target.exec('podman', 'inspect', self.full_name)
async def logs(self, target):
"""Show container logs"""
await target.exec('podman', 'logs', self.full_name)
async def exec(self, target, cmd=None):
"""Execute a command in the container"""
cmd = cmd or 'bash'
if cmd.endswith('sh'):
import os
os.execvp(
'/usr/bin/podman',
[
'podman',
'exec',
'-it',
self.full_name,
cmd,
]
)
result = await target.exec(
'podman',
'exec',
self.full_name,
cmd,
)
async def down(self, target):
"""Start the container"""
await target.exec('podman', 'rm', '-f', self.full_name, raises=False)
async def apply(self, target):
"""Start the container"""
if self.build:
await target(self.build)
await target(self.down)
await target(self.start)
def __str__(self):
return f'Container(name={self.name}, image={self.image}, volumes={self.volumes})'
+46 -30
View File
@@ -1,32 +1,52 @@
import json
import os
import re
class Image:
ENV_TAGS = (
# gitlab
'CI_COMMIT_SHORT_SHA',
'CI_COMMIT_REF_NAME',
'CI_COMMIT_TAG',
# CircleCI
'CIRCLE_SHA1',
'CIRCLE_TAG',
'CIRCLE_BRANCH',
# contributions welcome here
)
class Layers(set):
def __init__(self, image):
self.image = image
async def ls(self, target):
"""Fetch layers from localhost"""
ret = set()
results = await target.parent.exec(
'buildah images --json',
quiet=True,
)
results = json.loads(results.out)
prefix = 'localhost/' + self.image.repository + ':layer-'
for result in results:
if not result.get('names', None):
continue
for name in result['names']:
if name.startswith(prefix):
self.add(name)
return self
async def rm(self, target, tags=None):
"""Drop layers for this image"""
if tags is None:
tags = [layer for layer in await self.ls(target)]
await target.exec('podman', 'rmi', *tags, raises=False)
class Image:
PATTERN = re.compile(
'^((?P<backend>[a-z]*)://)?((?P<registry>[^/]*[.][^/]*)/)?((?P<repository>[^:]+))?(:(?P<tags>.*))?$' # noqa
, re.I
)
def __init__(self, arg=None, format=None, backend=None, registry=None, repository=None, tags=None):
def __init__(self, arg=None, format=None, backend=None, registry=None,
repository=None, tags=None):
self.arg = arg
self.format = format
self.backend = backend
self.registry = registry
self.repository = repository
self.tags = tags or []
self.layers = Layers(self)
match = re.match(self.PATTERN, arg)
if match:
@@ -44,12 +64,6 @@ class Image:
if self.registry == 'docker.io':
self.format = 'docker'
# figure tags from CI vars
for name in self.ENV_TAGS:
value = os.getenv(name)
if value:
self.tags.append(value)
# filter out tags which resolved to None
self.tags = [t for t in self.tags if t]
@@ -57,20 +71,22 @@ class Image:
if not self.tags:
self.tags = ['latest']
async def __call__(self, action, *args, **kwargs):
args = list(args)
return await action.exec(*args, **self.kwargs)
def __str__(self):
return f'{self.repository}:{self.tags[-1]}'
async def push(self, *args, **kwargs):
user = os.getenv('DOCKER_USER')
passwd = os.getenv('DOCKER_PASS')
action = kwargs.get('action', self)
async def push(self, target):
user = os.getenv('IMAGES_USER')
passwd = os.getenv('IMAGES_PASS')
if user and passwd:
action.output.cmd('buildah login -u ... -p ...' + self.registry)
await action.exec('buildah', 'login', '-u', user, '-p', passwd, self.registry or 'docker.io', debug=False)
target.output.cmd('buildah login -u ... -p ...' + self.registry)
await target.parent.exec(
'buildah', 'login', '-u', user, '-p', passwd,
self.registry or 'docker.io', debug=False)
for tag in self.tags:
await action.exec('buildah', 'push', f'{self.repository}:{tag}')
await target.parent.exec(
'buildah',
'push',
self.repository + ':final',
f'{self.registry}/{self.repository}:{tag}'
)
+44 -21
View File
@@ -1,5 +1,6 @@
import re
import sys
import types
from .colors import colors
@@ -25,15 +26,16 @@ class Output:
def colorize(self, code, content):
return self.color(code) + content + self.color()
def colorized(self):
if hasattr(self.subject, 'colorized'):
return self.subject.colorized(self.colors)
def colorized(self, action):
if hasattr(action, 'colorized'):
return action.colorized(self.colors)
elif isinstance(action, types.MethodType):
return f'{action.__self__}.{action.__name__}'
else:
return str(self.subject)
return str(action)
def __init__(
self,
subject=None,
prefix=None,
regexps=None,
debug='cmd,visit,out',
@@ -41,7 +43,6 @@ class Output:
flush=None,
**kwargs
):
self.subject = subject
self.prefix = prefix
self.debug = debug
self.prefix_length = 0
@@ -114,59 +115,81 @@ class Output:
return line
def test(self):
def test(self, action):
self(''.join([
self.colors['purplebold'],
'! TEST ',
'! TEST ',
self.colors['reset'],
self.colorized(),
self.colorized(action),
'\n',
]))
def clean(self):
def clean(self, action):
if self.debug:
self(''.join([
self.colors['bluebold'],
'+ CLEAN ',
'+ CLEAN ',
self.colors['reset'],
self.colorized(),
self.colorized(action),
'\n',
]))
def start(self):
def start(self, action):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['orangebold'],
' START ',
'⚠ START ',
self.colors['reset'],
self.colorized(),
self.colorized(action),
'\n',
]))
def success(self):
def info(self, text):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['cyanbold'],
'➤ INFO ',
self.colors['reset'],
text,
'\n',
]))
def skip(self, action):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['yellowbold'],
'↪️ SKIP ',
self.colors['reset'],
self.colorized(action),
'\n',
]))
def success(self, action):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['greenbold'],
'✔ SUCCESS ',
self.colors['reset'],
self.colorized(),
self.colorized(action),
'\n',
]))
def fail(self, exception=None):
def fail(self, action, exception=None):
if self.debug is True or 'visit' in str(self.debug):
self(''.join([
self.colors['redbold'],
'✘ FAIL ',
self.colors['reset'],
self.colorized(),
self.colorized(action),
'\n',
]))
def results(self):
def results(self, action):
if len(action.results) < 2:
return
success = 0
fail = 0
for result in self.subject.results:
for result in action.results:
if result.status == 'success':
success += 1
if result.status == 'failure':
+78
View File
@@ -0,0 +1,78 @@
import cli2
import json
import os
import sys
from shlax.targets.base import Target
from shlax.actions.parallel import Parallel
from shlax.proc import Proc
from .podman import Podman
class Pod:
"""Help text"""
def __init__(self, **containers):
self.containers = containers
for name, container in self.containers.items():
container.pod = self
container.name = name
self.name = os.getcwd().split('/')[-1]
async def _call(self, target, method, *names):
methods = [
getattr(container, method)
for name, container in self.containers.items()
if not names or name in names
]
await target(Parallel(*methods))
async def build(self, target, *names):
"""Build container images"""
if not (Proc.test or os.getuid() == 0):
os.execvp('buildah', ['buildah', 'unshare'] + sys.argv)
else:
await self._call(target, 'build', *names)
async def down(self, target, *names):
"""Delete container images"""
await self._call(target, 'down', *names)
async def start(self, target, *names):
"""Start container images"""
await self._call(target, 'start', *names)
async def logs(self, target, *names):
"""Start container images"""
await self._call(target, 'logs', *names)
async def ps(self, target):
"""Show containers and volumes"""
containers = []
names = []
for container in await Podman(target).ps('-a'):
for name in container['Names']:
if name.startswith(self.name + '-'):
container['Name'] = name
containers.append(container)
names.append(name)
for name, container in self.containers.items():
full_name = '-'.join([self.name, container.name])
if full_name in names:
continue
containers.append(dict(
Name=full_name,
State='not created',
))
cli2.Table(
['Name', 'State'],
*[
(container['Name'], container['State'])
for container in containers
]
).print()
def __str__(self):
return f'Pod({self.name})'
+20
View File
@@ -0,0 +1,20 @@
import json
class Podman(list):
def __init__(self, target, *args):
self.target = target
super().__init__(args or ['podman'])
def __getattr__(self, command):
if command.startswith('_'):
return super().__getattr__(command)
return Podman(self.target, *self + [command])
async def __call__(self, *args, **kwargs):
cmd = self + list(args) + [
f'--{k}={v}' for k, v in kwargs.items()
]
if 'ps' in cmd:
cmd += ['--format=json']
return (await self.target.exec(*cmd, quiet=True)).json
+12 -9
View File
@@ -32,21 +32,21 @@ class PrefixStreamProtocol(asyncio.subprocess.SubprocessStreamProtocol):
make asynchronous output readable.
"""
def __init__(self, output, *args, **kwargs):
self.output = output
def __init__(self, proc, *args, **kwargs):
self.proc = proc
super().__init__(*args, **kwargs)
def pipe_data_received(self, fd, data):
if self.output.debug is True or 'out' in str(self.output.debug):
if self.proc.output.debug is True or 'out' in str(self.proc.output.debug):
if fd in (1, 2):
self.output(data)
self.proc.output(data)
super().pipe_data_received(fd, data)
def protocol_factory(output):
def protocol_factory(proc):
def _p():
return PrefixStreamProtocol(
output,
proc,
limit=asyncio.streams._DEFAULT_LIMIT,
loop=asyncio.events.get_event_loop()
)
@@ -69,8 +69,11 @@ class Proc:
"""
test = False
def __init__(self, *args, prefix=None, raises=True, output=None):
self.output = output or Output()
def __init__(self, *args, prefix=None, raises=True, output=None, quiet=False):
if quiet:
self.output = Output(debug=False)
else:
self.output = output or Output()
self.cmd = ' '.join(args)
self.args = args
self.prefix = prefix
@@ -112,7 +115,7 @@ class Proc:
loop = asyncio.events.get_event_loop()
transport, protocol = await loop.subprocess_exec(
protocol_factory(self.output), *self.args)
protocol_factory(self), *self.args)
self.proc = asyncio.subprocess.Process(transport, protocol, loop)
self.called = True
+11
View File
@@ -3,5 +3,16 @@ from .targets.buildah import Buildah
from .targets.localhost import Localhost
from .targets.stub import Stub
from .actions.copy import Copy
from .actions.packages import Packages
from .actions.run import Run
from .actions.pip import Pip
from .actions.parallel import Parallel
from .actions.user import User
from .cli import Command, Group
from .container import Container
from .pod import Pod
from os import getenv, environ
+147 -32
View File
@@ -1,5 +1,9 @@
import asyncio
import copy
from pathlib import Path
import os
import re
import sys
from ..output import Output
from ..proc import Proc
@@ -7,21 +11,34 @@ from ..result import Result, Results
class Target:
def __init__(self, *actions, **options):
isguest = False
def __init__(self, *actions, root=None):
self.actions = actions
self.options = options
self.results = []
self.output = Output(self, **self.options)
self.output = Output()
self.parent = None
self.root = root or ''
def __str__(self):
return 'localhost'
@property
def parent(self):
return self._parent or Target()
@parent.setter
def parent(self, value):
self._parent = value
@property
def caller(self):
"""Traverse parents and return the top-levels Target."""
if not self.parent:
if not self._parent:
return self
caller = self.parent
while caller.parent:
caller = caller.parent
caller = self._parent
while caller._parent:
caller = caller._parent
return caller
async def __call__(self, *actions, target=None):
@@ -30,33 +47,47 @@ class Target:
# the calling target
self.parent = target
result = Result(self, self)
result.status = 'success'
for action in actions or self.actions:
result = Result(self, action)
self.output = Output(action, **self.options)
self.output.start()
try:
await action(target=self)
except Exception as e:
self.output.fail(e)
if await self.action(action, reraise=bool(actions)):
result.status = 'failure'
result.exception = e
if actions:
# nested call, re-raise
raise
else:
break
else:
self.output.success()
result.status = 'success'
finally:
self.caller.results.append(result)
break
clean = getattr(action, 'clean', None)
if clean:
action.result = result
self.output.clean()
await clean(self)
if getattr(self, 'clean', None):
self.output.clean(self)
await self.clean(self, result)
async def action(self, action, reraise=False):
result = Result(self, action)
self.output.start(action)
try:
await action(target=self)
except Exception as e:
self.output.fail(action, e)
result.status = 'failure'
result.exception = e
if reraise:
# nested call, re-raise
raise
else:
import traceback
traceback.print_exception(type(e), e, sys.exc_info()[2])
return True
else:
if getattr(action, 'skipped', False):
self.output.skip(action)
else:
self.output.success(action)
result.status = 'success'
finally:
self.caller.results.append(result)
clean = getattr(action, 'clean', None)
if clean:
self.output.clean(action)
await clean(self, result)
async def rexec(self, *args, **kwargs):
kwargs['user'] = 'root'
@@ -76,5 +107,89 @@ class Target:
result.append(match.group(1))
return result
def shargs(self, *args, **kwargs):
user = kwargs.pop('user', None)
args = [str(arg) for arg in args if args is not None]
if args and ' ' in args[0]:
if len(args) == 1:
args = ['sh', '-euc', args[0]]
else:
args = ['sh', '-euc'] + list(args)
if user == 'root':
args = ['sudo'] + args
elif user:
args = ['sudo', '-u', user] + args
return args, kwargs
if self.parent:
return self.parent.shargs(*args, **kwargs)
else:
return args, kwargs
async def exec(self, *args, **kwargs):
raise NotImplemented()
kwargs['output'] = self.output
args, kwargs = self.shargs(*args, **kwargs)
proc = await Proc(*args, **kwargs)()
if kwargs.get('wait', True):
await proc.wait()
return proc
@property
def root(self):
return self._root
@root.setter
def root(self, value):
self._root = Path(value) if value else ''
@property
def host(self):
current = self
while current.isguest:
current = self.parent
return current
def path(self, path):
if not self.root:
return path
if str(path).startswith('/'):
path = str(path)[1:]
return str(self.root / path)
async def mkdir(self, *paths):
if '_mkdir' not in self.__dict__:
self._mkdir = []
make = [str(path) for path in paths if str(path) not in self._mkdir]
if make:
await self.exec('mkdir', '-p', *make)
self._mkdir += make
async def copy(self, *args):
return await self.exec('cp', '-a', *args)
async def exists(self, path):
return (await self.exec('ls ' + self.path(path), raises=False)).rc == 0
async def read(self, path):
return (await self.exec('cat', self.path(path))).out
async def write(self, path, content, **kwargs):
return await self.exec(
f'cat > {self.path(path)} <<EOF\n'
+ content
+ '\nEOF',
**kwargs
)
async def rm(self, path):
return await self.exec('rm', self.path(path))
async def getenv(self, key):
return (await self.exec('echo $' + key)).out
async def getcwd(self):
return (await self.exec('pwd')).out
+162 -39
View File
@@ -1,3 +1,7 @@
import asyncio
import copy
import hashlib
import json
import os
import sys
from pathlib import Path
@@ -9,26 +13,21 @@ from ..proc import Proc
class Buildah(Target):
def __init__(self,
*actions,
base=None, commit=None,
cmd=None,
**options):
"""Build container image with buildah"""
isguest = True
def __init__(self, *actions, base=None, commit=None):
self.base = base or 'alpine'
self.image = Image(commit) if commit else None
self.ctr = None
self.mnt = None
self.root = None
self.mounts = dict()
self.config = dict(
cmd=cmd or 'sh',
)
# Always consider localhost as parent for now
self.parent = Target()
super().__init__(*actions, **options)
super().__init__(*actions)
def is_runnable(self):
return Proc.test or os.getuid() == 0
@@ -36,36 +35,106 @@ class Buildah(Target):
def __str__(self):
if not self.is_runnable():
return 'Replacing with: buildah unshare ' + ' '.join(sys.argv)
return 'Buildah image builder'
return f'Buildah({self.image})'
async def __call__(self, *actions, target=None, push: bool=False):
if target:
self.parent = target
self.push = push
async def __call__(self, *actions, target=None):
self.parent = target
if not self.is_runnable():
os.execvp('buildah', ['buildah', 'unshare'] + sys.argv)
# program has been replaced
return # process has been replaced
layers = await self.image.layers.ls(self)
keep = await self.cache_setup(self.image.layers, *actions)
keepnames = [*map(lambda x: 'localhost/' + str(x), keep)]
self.invalidate = [name for name in self.image.layers if name not in keepnames]
if self.invalidate:
self.output.info('Invalidating old layers')
await self.image.layers.rm(self.parent, self.invalidate)
if actions:
actions = actions[len(keep):]
if not actions:
return self.output.success('Image up to date')
else:
self.actions = self.actions[len(keep):]
if not self.actions:
return self.output.success('Image up to date')
self.ctr = (await self.parent.exec('buildah', 'from', self.base)).out
self.mnt = Path((await self.parent.exec('buildah', 'mount', self.ctr)).out)
await super().__call__()
self.root = Path((await self.parent.exec('buildah', 'mount', self.ctr)).out)
async def clean(self, target):
for src, dst in self.mounts.items():
await self.parent.exec('umount', self.mnt / str(dst)[1:])
return await super().__call__(*actions)
if self.result.status == 'success':
await self.commit()
if os.getenv('BUILDAH_PUSH'):
await self.image.push(target)
async def cache_setup(self, layers, *actions):
keep = []
self.image_previous = Image(self.base)
for action in actions or self.actions:
action_image = await self.action_image(action)
name = 'localhost/' + str(action_image)
if name in layers:
self.base = self.image_previous = action_image
keep.append(action_image)
self.output.skip(
f'Found layer for {action}: {action_image.tags[0]}'
)
else:
break
return keep
if self.mnt is not None:
async def action_image(self, action):
prefix = str(self.image_previous)
for tag in self.image_previous.tags:
if tag.startswith('layer-'):
prefix = tag
break
if hasattr(action, 'cachekey'):
action_key = action.cachekey()
if asyncio.iscoroutine(action_key):
action_key = str(await action_key)
else:
action_key = str(action)
key = prefix + action_key
sha1 = hashlib.sha1(key.encode('ascii'))
action_image = copy.deepcopy(self.image)
action_image.tags = ['layer-' + sha1.hexdigest()]
return action_image
async def action(self, action, reraise=False):
stop = await super().action(action, reraise)
if not stop:
action_image = await self.action_image(action)
self.output.info(f'Commiting {action_image} for {action}')
await self.parent.exec(
'buildah',
'commit',
'--format=' + action_image.format,
self.ctr,
action_image,
)
self.image_previous = action_image
return stop
async def clean(self, target, result):
if self.ctr is not None:
for src, dst in self.mounts.items():
await self.parent.exec('umount', self.root / str(dst)[1:])
await self.parent.exec('buildah', 'umount', self.ctr)
if result.status == 'success' and self.ctr:
await self.commit()
if self.push:
await self.image.push(target)
if self.ctr is not None:
await self.parent.exec('buildah', 'rm', self.ctr)
async def mount(self, src, dst):
"""Mount a host directory into the container."""
target = self.mnt / str(dst)[1:]
target = self.root / str(dst)[1:]
await self.parent.exec(f'mkdir -p {src} {target}')
await self.parent.exec(f'mount -o bind {src} {target}')
self.mounts[src] = dst
@@ -79,24 +148,78 @@ class Buildah(Target):
return await self.parent.exec(*_args, **kwargs)
async def commit(self):
if not self.image:
return
await self.parent.exec(
f'buildah commit {self.ctr} {self.image.repository}:final'
)
for key, value in self.config.items():
await self.parent.exec(f'buildah config --{key} "{value}" {self.ctr}')
ENV_TAGS = (
# gitlab
'CI_COMMIT_SHORT_SHA',
'CI_COMMIT_REF_NAME',
'CI_COMMIT_TAG',
# CircleCI
'CIRCLE_SHA1',
'CIRCLE_TAG',
'CIRCLE_BRANCH',
# contributions welcome here
)
self.sha = (await self.exec(
'buildah',
'commit',
'--format=' + self.image.format,
self.ctr,
buildah=False,
)).out
# figure tags from CI vars
for name in ENV_TAGS:
value = os.getenv(name)
if value:
self.image.tags.append(value)
if self.image.tags:
tags = [f'{self.image.repository}:{tag}' for tag in self.image.tags]
else:
tags = [self.image.repository]
for tag in tags:
await self.parent.exec('buildah', 'tag', self.sha, tag)
await self.parent.exec('buildah', 'tag', self.image.repository + ':final', *tags)
async def mkdir(self, *paths):
return await self.parent.mkdir(*[self.path(path) for path in paths])
async def copy(self, *args):
return await self.parent.copy(*args[:-1], self.path(args[-1]))
async def write(self, path, content):
return await self.write(path, content)
async def write(self, path, content, **kwargs):
return await self.exec(
f'cat > {path} <<EOF\n'
+ content
+ '\nEOF',
**kwargs
)
class Config:
def __init__(self, **config):
self.config = config
async def __call__(self, target):
for key, value in self.config.items():
await target.parent.exec(
f'buildah config --{key} "{value}" {target.ctr}'
)
def __str__(self):
return f'Buildah.Config({self.config})'
class Env:
def __init__(self, **env):
self.env = env
async def __call__(self, target):
for key, value in self.env.items():
await target.parent.exec(
'buildah',
'config',
'--env',
f'{key}={value}',
target.ctr,
)
def __str__(self):
return f'Buildah.Env({self.env})'
+15
View File
@@ -0,0 +1,15 @@
from .base import Target
class Ssh(Target):
def __init__(self, *actions, host, user=None):
self.host = host
self.user = user
super().__init__(*actions)
async def exec(self, *args, user=None, **kwargs):
_args = ['ssh', self.host]
if user == 'root':
_args += ['sudo']
_args += [' '.join([str(a) for a in args])]
return await self.parent.exec(*_args, **kwargs)
+13 -5
View File
@@ -1,12 +1,20 @@
#!/usr/bin/env shlax
#!/usr/bin/env python
"""
Shlaxfile for shlax itself.
"""
from shlax.shortcuts import *
build = Buildah(
Run('echo hi'),
Packages('python38'),
base='quay.io/podman/stable',
shlax = Container(
build=Buildah(
Packages('python38', 'buildah', 'unzip', 'findutils', upgrade=False),
Copy('setup.py', 'shlax', '/app'),
Pip('/app'),
base='quay.io/podman/stable',
commit='quay.io/yourlabs/shlax',
),
)
if __name__ == '__main__':
print(Group(doc=__doc__).load(shlax).entry_point())
-6
View File
@@ -25,9 +25,3 @@ def test_args(arg, expected):
im = Image(arg)
for k, v in expected.items():
assert getattr(im, k) == v
def test_args_env():
os.environ['IMAGE_TEST_ARGS_ENV'] = 'foo'
Image.ENV_TAGS = ['IMAGE_TEST_ARGS_ENV']
im = Image('re/po:x,y')
assert im.tags == ['x', 'y', 'foo']
+32 -1
View File
@@ -68,10 +68,41 @@ async def test_parallel():
@pytest.mark.asyncio
async def test_function():
async def hello(target):
target.exec('hello')
await target.exec('hello')
await Stub()(hello)
@pytest.mark.asyncio
async def test_action_clean():
class Example:
def __init__(self):
self.was_called = False
async def clean(self, target, result):
self.was_called = True
async def __call__(self, target):
raise Exception('lol')
action = Example()
target = Stub()
with pytest.raises(Exception):
await target(action)
assert action.was_called
@pytest.mark.asyncio
async def test_target_clean():
class Example(Stub):
def __init__(self, action):
self.was_called = False
super().__init__(action)
async def clean(self, target, result):
self.was_called = True
target = Example(Error())
await target()
assert target.was_called
@pytest.mark.asyncio
async def test_method():
class Example: