Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ omit =

[report]
show_missing = True
skip_covered = True

exclude_lines =
# Have to re-enable the standard pragma
Expand Down
78 changes: 78 additions & 0 deletions lazy_build/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals

import argparse

from lazy_build import config
from lazy_build import context


def build(conf, args):
ctx = context.build_context(conf)

import json
print(json.dumps(ctx.files, indent=True, sort_keys=True))
print('hash:', ctx.hash)


def invalidate(conf, args):
raise NotImplementedError()


ACTIONS = {
'build': build,
'invalidate': invalidate,
}


def main(argv=None):
parser = argparse.ArgumentParser(
description='Cache build artifacts based on files on disk.',
)
parser.add_argument(
'--context', nargs='+', required=True,
help='file or directory to include in the build context',
)
parser.add_argument(
'--ignore', nargs='+', required=False,
help='paths to exclude when creating the build context',
)
parser.add_argument(
'--output', nargs='+', required=True,
help='file or directory to consider as output from a successful build',
)
parser.add_argument(
'--after-download',
help=(
'command to run after downloading an artifact '
'(for example, to adjust shebangs for the new path)'
),
)
parser.add_argument(
'--dry-run', default=False, action='store_true',
help='say what would be done, without doing it',
)
parser.add_argument(
'--action', choices=ACTIONS.keys(), required=True,
help='action to take',
)
parser.add_argument(
'command', nargs=argparse.REMAINDER,
help='build command to execute',
)

args = parser.parse_args(argv)

# TODO: is there a way we can get argparse to do this for us?
if args.command[0] != '--':
raise ValueError(
'You must separate the command from the other arguments with a --!',
)

del args.command[0]
if len(args.command) == 0:
raise ValueError('You must specify a command!')

conf = config.Config.from_args(args)
return ACTIONS[args.action](conf, args)
36 changes: 36 additions & 0 deletions lazy_build/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals

import collections


class Config(collections.namedtuple('Config', (
'context',
'ignore',
'cache',
))):

__slots__ = ()

@classmethod
def from_args(cls, args):
# TODO: this method should also consider config files

# TODO: this
cache = CacheConfigS3(
bucket='my-cool-bucket',
path='/',
)

return cls(
context=frozenset(args.context or ()),
ignore=frozenset(args.ignore or ()),
cache=cache,
)


CacheConfigS3 = collections.namedtuple('CacheConfigS3', (
'bucket',
'path',
))
90 changes: 90 additions & 0 deletions lazy_build/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals

import collections
import fnmatch
import hashlib
import itertools
import json
import os
import os.path


def hash(data):
return hashlib.sha256(data).hexdigest()


class BuildContext(collections.namedtuple('BuildContext', (
'files',
))):

__slots__ = ()

@property
def hash(self):
return hash(json.dumps(self.files, sort_keys=True).encode('utf8'))


class FileContext(collections.namedtuple('FileContext', (
'type',
'content_hash',
))):

__slots__ = ()

@classmethod
def from_path(cls, path):
assert os.path.lexists(path), path

if os.path.islink(path):
# TODO: does this properly handle non-utf8 paths?
# (how does Python turn those into strings?)
return cls('link', hash(os.readlink(path).encode('utf8')))
else:
with open(path, 'rb') as f:
return cls('file', hash(f.read()))


def should_ignore(patterns, path):
"""gitignore-like pattern matching"""
path = '/' + path

# We need to get all "substring" paths, e.g. for a/b/c we need:
# a; a/b; a/b/c; b; b/c; c
components = path.split('/')
paths = {
'/'.join(components[i:j + 1])
for i in range(len(components))
for j in range(len(components))
}

return any(
fnmatch.fnmatch(path, pattern)
for path, pattern in itertools.product(paths, patterns)
)


def build_context(conf):
ctx = {}
fringe = {
os.path.relpath(os.path.realpath(path)) for path in conf.context
}
explored = set()

while len(fringe) > 0:
path = fringe.pop()
explored.add(path)

if should_ignore(conf.ignore, path):
continue

if os.path.isdir(path) and not os.path.islink(path):
for child in os.listdir(path):
child = os.path.relpath(os.path.join(path, child))
if child not in explored:
fringe.add(child)
else:
ctx[path] = FileContext.from_path(path)

return BuildContext(files=ctx)
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@
],
install_requires=[],
packages=find_packages(exclude=('tests*', 'testing*')),
entry_points={
'console_scripts': ['lazy-build = lazy_build.cli:main'],
},
)
50 changes: 50 additions & 0 deletions tests/context_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import
from __future__ import unicode_literals

import pytest

from lazy_build import config
from lazy_build import context


@pytest.mark.parametrize(('patterns', 'path'), (
({'/venv'}, 'venv'),
({'venv'}, 'venv'),
({'venv'}, 'this/is/some/venv'),
({'venv'}, 'this/is/some/venv/with/a/file'),
({'venv/with'}, 'this/is/some/venv/with/a/file'),
({'*.swp'}, 'something.swp'),
({'*.swp'}, 'hello/there/something.swp'),
))
def test_should_ignore_true(patterns, path):
assert context.should_ignore(patterns, path) is True


@pytest.mark.parametrize(('patterns', 'path'), (
({'/venv'}, 'this/is/some/venv'),
({'a/venv'}, 'this/is/some/venv'),
({'venv'}, 'venv2'),
))
def test_should_ignore_false(patterns, path):
assert context.should_ignore(patterns, path) is False


def test_build_context_simple(tmpdir):
tmpdir.chdir()
conf = config.Config(
context={tmpdir.strpath, tmpdir.join('a').strpath},
ignore={'d'},
cache=None,
)
tmpdir.join('a').write(b'foo')
tmpdir.join('b').mkdir()
tmpdir.join('b/c').write(b'bar')
tmpdir.join('d').mkdir()
tmpdir.join('d/e').write(b'baz')
tmpdir.join('f').mksymlinkto('/etc/passwd')
assert context.build_context(conf) == context.BuildContext(files={
'a': context.FileContext('file', context.hash(b'foo')),
'b/c': context.FileContext('file', context.hash(b'bar')),
'f': context.FileContext('link', context.hash(b'/etc/passwd')),
})
7 changes: 6 additions & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ passenv = HOME SSH_AUTH_SOCK USER
commands =
coverage erase
coverage run -m pytest {posargs:tests}
coverage report --fail-under 100
# TODO: --fail-under 100
coverage report
pre-commit install -f --install-hooks
pre-commit run --all-files

[testenv:venv]
skipsdist = true
basepython = /usr/bin/python3.5
envdir = venv
deps =
-rrequirements-dev.txt
-e{toxinidir}
commands =

[flake8]
Expand Down