diff --git a/.gitignore b/.gitignore index c6f9a44..d86c80e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .vscode/settings.json +bashf.egg-info/* diff --git a/Bashfile b/Bashfile new file mode 100644 index 0000000..d2b9df2 --- /dev/null +++ b/Bashfile @@ -0,0 +1,4 @@ +test: hello + echo 'bye' +hello: + echo 'hi' diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..324d54b --- /dev/null +++ b/Pipfile @@ -0,0 +1,16 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] +black = "*" + +[packages] +bashf = {editable = true,path = "."} + +[requires] +python_version = "3.7" + +[pipenv] +allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..c7db048 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,111 @@ +{ + "_meta": { + "hash": { + "sha256": "586d266e0093a64d666ab313fd620746db0d04d1dc6da5a184ffc6e3bf2ee04c" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.7" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "bash.py": { + "hashes": [ + "sha256:108279834ec18c6597806393c3779076b07013c1cdbc3ffd9197dc04985ac8ba", + "sha256:be5a86449866642adea843c08b0075990f36294ad42681eb41e9924307e3b4af" + ], + "version": "==0.4.2" + }, + "bashf": { + "editable": true, + "path": "." + }, + "click": { + "hashes": [ + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + ], + "version": "==7.0" + }, + "colorama": { + "hashes": [ + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + ], + "version": "==0.4.1" + }, + "crayons": { + "hashes": [ + "sha256:41f0843815a8e3ac6fb445b7970d8b9c766e6f164092d84e7ea809b4c91418ec", + "sha256:8edcadb7f197e25f2cc094aec5bf7f1b6001d3f76c82d56f8d46f6fb1405554f" + ], + "version": "==0.2.0" + }, + "delegator.py": { + "hashes": [ + "sha256:814657d96b98a244c479e3d5f6e9e850ac333e85f807d6bc846e72bbb2537806", + "sha256:e6cc9cedab9ae59b169ee0422e17231adedadb144e63c0b5a60e6ff8adf8521b" + ], + "version": "==0.1.1" + }, + "pexpect": { + "hashes": [ + "sha256:2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1", + "sha256:9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb" + ], + "version": "==4.7.0" + }, + "ptyprocess": { + "hashes": [ + "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", + "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f" + ], + "version": "==0.6.0" + } + }, + "develop": { + "appdirs": { + "hashes": [ + "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", + "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" + ], + "version": "==1.4.3" + }, + "attrs": { + "hashes": [ + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + ], + "version": "==19.1.0" + }, + "black": { + "hashes": [ + "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", + "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c" + ], + "index": "pypi", + "version": "==19.3b0" + }, + "click": { + "hashes": [ + "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", + "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7" + ], + "version": "==7.0" + }, + "toml": { + "hashes": [ + "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", + "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" + ], + "version": "==0.10.0" + } + } +} diff --git a/README.md b/README.md index f3a08c9..c6014f5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # Bashfile: Like Make + Bash, Combined. I love using `Makefile`s for one-off tasks in projects. The problem with doing this, is you can't use familiar bash–isms when doing so, as GNU Make doesn't use the familiar Bash sytnax. This project seeks to bridge these works. + diff --git a/bashf/__init__.py b/bashf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bashf/bashfile.py b/bashf/bashfile.py new file mode 100644 index 0000000..fd911c4 --- /dev/null +++ b/bashf/bashfile.py @@ -0,0 +1,114 @@ +import os +import json + +import bash + + +class NoBashfileFound(RuntimeError): + pass + + +class TaskNotInBashfile(ValueError): + pass + + +class TaskScript: + def __init__(self, *, bashfile, name): + self.bashfile = bashfile + self.name = name + + def __repr__(self): + return f"" + + @property + def declaration_line(self): + for line in self.bashfile.source_lines: + if line.startswith(self.name): + return line + + @property + def depends_on(self): + # TODO: return script objects. + task_names = self.declaration_line.split(':')[1].split() + return [TaskScript(bashfile=self.bashfile, name=n) for n in task_names] + + @classmethod + def from_declaration_line(Class, s, *, bashfile): + name = s.split(':')[0].strip() + return Class(bashfile=bashfile, name=name) + + def run(self): + bash.run(self.task_script) + + +class Bashfile: + def __init__(self, *, path): + self.path = path + self.environ = {} + if not os.path.exists(path): + raise NoBashfileFound() + + def add_environ(self, key, value): + self.environ[key] = value + + def add_environ_json(self, s): + try: + j = json.loads(s) + except json.JSONDecodeError: + assert os.path.exists(s) + # Assume a path was passed, instead. + with open(s, 'r') as f: + j = json.load(f) + + self.environ.update(j) + + @property + def home_path(self): + return os.path.abspath(os.path.dirname(self.path)) + + @classmethod + def find( + Class, + *, + filename='Bashfile', + root=os.getcwd(), + max_depth=4, + topdown=True, + ): + """Returns the path of a Pipfile in parent directories.""" + i = 0 + for c, d, f in os.walk(root, topdown=topdown): + if i > max_depth: + raise NoBashfileFound('No {filename} found!') + elif filename in f: + return Class(path=os.path.join(c, filename)) + i += 1 + + @property + def source_text(self): + with open(self.path, 'r') as f: + return f.read() + + @property + def source_lines(self): + return self.source_text.split('\n') + + @staticmethod + def _is_declaration_line(line): + return not (line.startswith(' ') or line.startswith('\t')) + + @property + def scripts(self): + def iter_task_lines(): + for line in self.source_lines: + if line: + if self._is_declaration_line(line): + yield line.rstrip() + + scripts = {} + for line in iter_task_lines(): + if self._is_declaration_line(line): + script = TaskScript.from_declaration_line(line, bashfile=self) + scripts[script.name] = script + + return scripts diff --git a/bashf/cli.py b/bashf/cli.py new file mode 100644 index 0000000..d8c0a3c --- /dev/null +++ b/bashf/cli.py @@ -0,0 +1,89 @@ +import click +import crayons +from .bashfile import Bashfile + + +@click.command() +@click.argument( + 'task', + type=click.STRING, + default='__LIST_ALL__', + envvar="BASHFILE_TASK", + # required=False, +) +@click.option( + '--bashfile', + '-b', + default='__BASHFILE__', + envvar='BASHFILE_PATH', + nargs=1, + type=click.Path(), +) +@click.option('--list', '-l', '_list', default=False, is_flag=True) +@click.option('--debug', default=False, is_flag=True, hidden=True) +@click.option('--shellcheck', default=False, is_flag=True, hidden=True) +@click.option( + '--environ', + '-e', + nargs=2, + type=click.STRING, + multiple=True, + help='task environment variable (can be passed multiple times).', +) +@click.option( + '--arg', + '-a', + nargs=1, + type=click.STRING, + multiple=True, + help='task ARGV arguments (can be passed multiple times).', +) +@click.option( + '--environ-json', + '-j', + nargs=1, + type=click.STRING, + help='environment variables, in JSON format.', +) +def task( + *, task, bashfile, arg, _list, environ, environ_json, shellcheck, debug +): + """bashf — Bashfile runner (the familiar Bash/Make hybrid).""" + # Default to list behavior, when no task is provided. + + if task == '__LIST_ALL__': + _list = True + task = None + + if bashfile == '__BASHFILE__': + bashfile = Bashfile.find(root='.') + + if environ_json: + bashfile.add_environ_json(environ_json) + + for env in environ: + key, value = env[:] + if debug: + click.echo( + f" Setting environ: {crayons.red(key)} {crayons.white('=')} {value}.", + err=True, + ) + bashfile.add_environ(key, value) + + print(bashfile.scripts) + # print(locals()) + + +def entrypoint(): + try: + main() + except KeyboardInterrupt: + print('ool beans.') + + +def main(): + task() + + +if __name__ == '__main__': + entrypoint() diff --git a/setup.py b/setup.py index e69de29..e1d292a 100644 --- a/setup.py +++ b/setup.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Note: To use the 'upload' functionality of this file, you must: +# $ pipenv install twine --dev + +import io +import os +import sys +from shutil import rmtree + +from setuptools import find_packages, setup, Command + +# Package meta-data. +NAME = 'bashf' +DESCRIPTION = 'The familar Make / Bash hybrid.' +URL = 'https://github.com/kennethreitz/bashfile' +EMAIL = 'me@kennethreitz.org' +AUTHOR = 'Kenneth Reitz' +REQUIRES_PYTHON = '>=3.6.0' +VERSION = '0.1.0' + +# What packages are required for this module to be executed? +REQUIRED = ['click', 'delegator.py', 'crayons', 'bash.py'] + +# What packages are optional? +EXTRAS = { + # 'fancy feature': ['django'], +} + +# The rest you shouldn't have to touch too much :) +# ------------------------------------------------ +# Except, perhaps the License and Trove Classifiers! +# If you do change the License, remember to change the Trove Classifier for that! + +here = os.path.abspath(os.path.dirname(__file__)) + +# Import the README and use it as the long-description. +# Note: this will only work if 'README.md' is present in your MANIFEST.in file! +try: + with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = '\n' + f.read() +except FileNotFoundError: + long_description = DESCRIPTION + +# Load the package's __version__.py module as a dictionary. +about = {} +if not VERSION: + project_slug = NAME.lower().replace("-", "_").replace(" ", "_") + with open(os.path.join(here, project_slug, '__version__.py')) as f: + exec(f.read(), about) +else: + about['__version__'] = VERSION + + +class UploadCommand(Command): + """Support setup.py upload.""" + + description = 'Build and publish the package.' + user_options = [] + + @staticmethod + def status(s): + """Prints things in bold.""" + print('\033[1m{0}\033[0m'.format(s)) + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + try: + self.status('Removing previous builds…') + rmtree(os.path.join(here, 'dist')) + except OSError: + pass + + self.status('Building Source and Wheel (universal) distribution…') + os.system( + '{0} setup.py sdist bdist_wheel --universal'.format(sys.executable) + ) + + self.status('Uploading the package to PyPI via Twine…') + os.system('twine upload dist/*') + + self.status('Pushing git tags…') + os.system('git tag v{0}'.format(about['__version__'])) + os.system('git push --tags') + + sys.exit() + + +# Where the magic happens: +setup( + name=NAME, + version=about['__version__'], + description=DESCRIPTION, + long_description=long_description, + long_description_content_type='text/markdown', + author=AUTHOR, + author_email=EMAIL, + python_requires=REQUIRES_PYTHON, + url=URL, + packages=find_packages( + exclude=["tests", "*.tests", "*.tests.*", "tests.*"] + ), + # If your package is a single module, use this instead of 'packages': + # py_modules=['mypackage'], + entry_points={'console_scripts': ['bashf=bashf.cli:entrypoint']}, + install_requires=REQUIRED, + extras_require=EXTRAS, + include_package_data=True, + license='MIT', + classifiers=[ + # Trove classifiers + # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: Implementation :: CPython', + 'Programming Language :: Python :: Implementation :: PyPy', + ], + # $ setup.py publish support. + cmdclass={'upload': UploadCommand}, +)