├── .gitignore ├── LICENSE ├── README.rst ├── pydockerize.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # PyInstaller 26 | # Usually these files are written by a python script from a template 27 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 28 | *.manifest 29 | *.spec 30 | 31 | # Installer logs 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Translations 44 | *.mo 45 | *.pot 46 | 47 | # Django stuff: 48 | *.log 49 | 50 | # Sphinx documentation 51 | docs/_build/ 52 | 53 | # PyBuilder 54 | target/ 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Marc Abramowitz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pydockerize 2 | =========== 3 | 4 | Creates a Docker image from a Python app with a pip ``requirements.txt`` 5 | file 6 | 7 | Given a Python app with a ``requirements.txt`` file, you can trivially 8 | make it into a Docker image. 9 | 10 | Usage 11 | ===== 12 | 13 | :: 14 | 15 | $ pydockerize --help 16 | Usage: pydockerize [OPTIONS] COMMAND1 [ARGS]... [COMMAND2 [ARGS]...]... 17 | 18 | Create Docker images for Python apps 19 | 20 | Options: 21 | --version Show the version and exit. 22 | -b, --base-images TEXT Base docker images (comma-separated list) - 23 | e.g.: "python:2.7-onbuild,python:3.4-onbuild". 24 | Conflicts with --python-versions. 25 | -c, --cmd TEXT Command (CMD) to set in image. Conflicts with 26 | --procfile 27 | -e, --entrypoint TEXT Entry point (ENTRYPOINT) to set in image 28 | -p, --python-versions TEXT Python versions (comma-separated list) - e.g.: 29 | "2.7,3.4". Conflicts with --base-images. 30 | --procfile FILENAME Procfile to get command from. Conflicts with 31 | --cmd. 32 | -t, --tag TEXT Repository name (and optionally a tag) to be 33 | applied to the resulting image in case of 34 | success 35 | -r, --requirement PATH pip requirements file with packages to install 36 | --help Show this message and exit. 37 | 38 | Commands: 39 | build Run `docker build` with Dockerfile(s) from `generate` 40 | generate Write Dockerfile(s) 41 | generatefig Generate fig.yml for fig/Docker Compose (http://fig.sh). 42 | images Show images for repo from --tag 43 | ps List Docker containers 44 | run Run a Docker container 45 | 46 | Usage examples 47 | ============== 48 | 49 | .. code:: bash 50 | 51 | # Assume requirements in requirements.txt. 52 | # Tags built image with directory name (lowercased). 53 | # Container CMD taken from Procfile by default. 54 | $ pydockerize build 55 | 56 | # Add a custom tag to built image 57 | $ pydockerize -t my_cool_app build 58 | 59 | # Specifies a requirements file 60 | $ pydockerize -r requirements-prod.txt build 61 | 62 | # Generate Dockerfile but don't build the image 63 | # Perhaps you want to commit the Dockerfile and have the image built later 64 | # by a CI server. 65 | $ pydockerize generate 66 | 67 | # Specify multiple Python versions to build Docker images for 68 | $ pydockerize --python-versions=2.7,3.4 build 69 | 70 | # Specify a custom command to invoke when running container 71 | # (Default is to get it from Procfile, if it exists) 72 | $ pydockerize --cmd="pserve app.ini" build 73 | 74 | # Show current images for app in current directory 75 | $ pydockerize images 76 | 77 | $ pydockerize run -d 78 | Invoking: docker run -it --name=inventorysvc -v /Users/marca/dev/surveymonkey/inventorysvc:/host -p 6200:6200 -d inventorysvc 79 | fe01097e6b7a35150afce19888b65ad94cd51c9cc256834a6bb22c7c88f881fc 80 | 81 | $ pydockerize ps 82 | CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 83 | fe01097e6b7a inventorysvc:latest "/bin/sh -c 'gunicor 42 seconds ago Up 41 seconds 0.0.0.0:6200->6200/tcp inventorysvc 84 | 85 | Setting the ``CMD`` for image 86 | ============================= 87 | 88 | There are several ways to set the ``CMD``: 89 | 90 | 1. Specify it with ``--cmd``. 91 | 2. Specify a (one-line) ``Procfile`` with ``--procfile`` and it will 92 | grab the command from there. 93 | 3. If you don't specify ``--cmd`` or ``--procfile``, but there is a 94 | ``Procfile`` present it will default to grabbing command from there. 95 | -------------------------------------------------------------------------------- /pydockerize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | import shlex 6 | import subprocess 7 | import textwrap 8 | 9 | import click 10 | 11 | 12 | DEFAULT_BASE_IMAGES = ['python:2.7'] 13 | 14 | 15 | @click.group(chain=True) 16 | @click.version_option() 17 | @click.option('-b', '--base-images', 18 | default=None, 19 | help='Base docker images (comma-separated list) - e.g.: ' 20 | '"python:2.7-onbuild,python:3.4-onbuild". Conflicts with ' 21 | '--python-versions.') 22 | @click.option('-c', '--cmd', 23 | help='Command (CMD) to set in image. Conflicts with --procfile') 24 | @click.option('-e', '--entrypoint', 25 | help='Entry point (ENTRYPOINT) to set in image') 26 | @click.option('-p', '--python-versions', 27 | default=None, 28 | help='Python versions (comma-separated list) - e.g.: ' 29 | '"2.7,3.4". Conflicts with --base-images.') 30 | @click.option('--procfile', type=click.File(), 31 | help='Procfile to get command from. Conflicts with --cmd.') 32 | @click.option('-t', '--tag', 33 | help='Repository name (and optionally a tag) to be applied to ' 34 | 'the resulting image in case of success') 35 | @click.option('-r', '--requirement', 36 | 'requirements_file', type=click.Path(exists=True), 37 | help='pip requirements file with packages to install') 38 | @click.option('-i', '--index-url', 39 | 'index_url', 40 | help='Base URL of Python Package Index.') 41 | @click.pass_context 42 | def pydockerize(ctx, requirements_file, index_url, tag, 43 | cmd, entrypoint, procfile, 44 | base_images=None, python_versions=None): 45 | """Create Docker images for Python apps""" 46 | 47 | if base_images is not None and python_versions is not None: 48 | raise Exception( 49 | 'Cannot specify both --base-images and --python_versions') 50 | 51 | if base_images is not None: 52 | base_images = base_images.split(',') 53 | else: 54 | base_images = DEFAULT_BASE_IMAGES 55 | 56 | if python_versions is not None: 57 | python_versions = python_versions.split(',') 58 | base_images = get_base_images_from_python_versions(python_versions) 59 | 60 | if tag is None: 61 | tag = os.path.basename(os.getcwd()).lower() 62 | 63 | if requirements_file is None: 64 | requirements_file = 'requirements.txt' 65 | if not os.path.exists(requirements_file): 66 | with open(requirements_file, 'w') as f: 67 | f.write(textwrap.dedent("""\ 68 | #!/usr/bin/env pip install -r 69 | # Why requirements.txt? 70 | # See https://caremad.io/2013/07/setup-vs-requirement/ 71 | 72 | -e . 73 | """)) 74 | 75 | ctx.obj = { 76 | 'base_images': base_images, 77 | 'requirements_file': requirements_file, 78 | 'index_url': index_url, 79 | 'cmd': cmd, 80 | 'procfile': procfile, 81 | 'entrypoint': entrypoint, 82 | 'tag': tag, 83 | } 84 | 85 | 86 | @pydockerize.command() 87 | @click.pass_context 88 | def generate(ctx): 89 | """Write Dockerfile(s)""" 90 | 91 | base_images = ctx.obj['base_images'] 92 | requirements_file = ctx.obj['requirements_file'] 93 | index_url = ctx.obj['index_url'] 94 | entrypoint = ctx.obj['entrypoint'] 95 | cmd = ctx.obj['cmd'] 96 | procfile = ctx.obj['procfile'] 97 | 98 | if cmd is not None and procfile is not None: 99 | raise Exception('Cannot specify both --cmd and --procfile') 100 | 101 | if cmd is None and procfile is None and os.path.exists('Procfile'): 102 | procfile = open('Procfile') 103 | 104 | if procfile: 105 | cmd = get_cmd_from_procfile(procfile) 106 | click.echo('generate: Got cmd from %s => %r' % (procfile.name, cmd)) 107 | else: 108 | click.echo('generate: cmd = %r' % cmd) 109 | 110 | click.echo('generate: entrypoint = %r' % entrypoint) 111 | click.echo('generate: base_images = %r' % base_images) 112 | click.echo('generate: requirements_file = %r' % requirements_file) 113 | 114 | base_images_and_filenames = [] 115 | 116 | for base_image in base_images: 117 | filename = get_filename_from_base_image(base_image, base_images) 118 | generate_one(base_image, requirements_file, index_url, filename, 119 | cmd, entrypoint) 120 | base_images_and_filenames.append((base_image, filename)) 121 | 122 | ctx.obj['base_images_and_filenames'] = base_images_and_filenames 123 | 124 | 125 | def get_base_images_from_python_versions(python_versions): 126 | return ['python:%s-onbuild' % python_version 127 | for python_version in python_versions] 128 | 129 | 130 | def get_cmd_from_procfile(procfile): 131 | lines = procfile.readlines() 132 | if len(lines) > 1: 133 | raise Exception( 134 | 'Procfile with multiple lines not supported') 135 | cmd = lines[0].split(':', 1)[1].strip() 136 | env_dict = get_env() 137 | for key, value in env_dict.items(): 138 | cmd = cmd.replace('$' + key, value) 139 | return cmd 140 | 141 | 142 | def get_env(filename='.env'): 143 | if not os.path.exists('.env'): 144 | return {} 145 | 146 | with open('.env') as dotenv_file: 147 | env_dict = parse_dotenv(dotenv_file.read()) 148 | 149 | return env_dict 150 | 151 | 152 | def parse_dotenv(content): 153 | """ 154 | Parse the content of a .env file (a line-delimited KEY=value format) into a 155 | dictionary mapping keys to values. 156 | """ 157 | values = {} 158 | for line in content.splitlines(): 159 | lexer = shlex.shlex(line, posix=True) 160 | lexer.wordchars += '/.+-():' 161 | tokens = list(lexer) 162 | 163 | # parses the assignment statement 164 | if len(tokens) != 3: 165 | continue 166 | name, op, value = tokens 167 | if op != '=': 168 | continue 169 | if not re.match(r'[A-Za-z_][A-Za-z_0-9]*', name): 170 | continue 171 | values[name] = value 172 | 173 | return values 174 | 175 | 176 | def generate_one(base_image, requirements_file, index_url, filename, 177 | cmd, entrypoint): 178 | click.echo('generate_one: base_image = %r' % base_image) 179 | click.echo('generate_one: Writing %s' % filename) 180 | 181 | pip_options = '' 182 | if index_url: 183 | pip_options += '--index-url={index_url}'.format(index_url=index_url) 184 | 185 | with open(filename, 'w+') as f: 186 | f.write(textwrap.dedent("""\ 187 | # This is a Dockerfile 188 | # Dockerfile reference: https://docs.docker.com/reference/builder/ 189 | 190 | FROM {base_image} 191 | 192 | RUN mkdir -p /usr/src/app 193 | WORKDIR /usr/src/app 194 | 195 | # Install necessary Python packages from pip requirements file 196 | # requirements files: http://bit.ly/pip-requirements-files 197 | ADD . /usr/src/app 198 | """.format(base_image=base_image))) 199 | if os.path.exists('requirements.apt'): 200 | f.write(textwrap.dedent("""\ 201 | RUN apt-get update; DEBIAN_FRONTEND=noninteractive xargs apt-get -yq install < requirements.apt 202 | """)) 203 | if os.path.exists(requirements_file): 204 | f.write(textwrap.dedent("""\ 205 | RUN pip install {pip_options} -r {requirements_file} 206 | """.format(requirements_file=requirements_file, 207 | pip_options=pip_options))) 208 | if os.path.exists('setup.py'): 209 | f.write(textwrap.dedent("""\ 210 | RUN pip install {pip_options} -e . 211 | """.format(pip_options=pip_options))) 212 | f.write(textwrap.dedent(""" 213 | # This is so one can mount a volume from the host to give the 214 | # container access to the host's current working directory. E.g.: 215 | # 216 | # - `docker run -v $(pwd):/host` from command-line or ... 217 | # - `volumes: [".:/host"]` in fig.yml 218 | WORKDIR /host 219 | """)) 220 | if entrypoint: 221 | f.write("\nENTRYPOINT %s\n" % entrypoint) 222 | if cmd: 223 | f.write("\nCMD %s\n" % cmd) 224 | 225 | return filename 226 | 227 | 228 | @pydockerize.command( 229 | short_help="Run `docker build` with Dockerfile(s) from `generate`") 230 | @click.pass_context 231 | def build(ctx): 232 | """Run `docker build` with Dockerfile(s) from `generate`""" 233 | 234 | tags_built = [] 235 | tag = ctx.obj['tag'] 236 | base_images = ctx.obj['base_images'] 237 | 238 | click.echo("build: tag = '%s'" % tag) 239 | 240 | if no_dockerfiles_already_exist(base_images): 241 | ctx.invoke(generate) 242 | 243 | for base_image in base_images: 244 | filename = get_filename_from_base_image(base_image, base_images) 245 | tag_built = build_one(tag, base_image, base_images, filename) 246 | tags_built.append(tag_built or '') 247 | 248 | click.secho('build: %d Docker build(s) succeeded: %s' 249 | % (len(base_images), ', '.join(tags_built)), 250 | fg='green') 251 | 252 | ctx.invoke(images) 253 | 254 | 255 | def no_dockerfiles_already_exist(base_images): 256 | for base_image in base_images: 257 | filename = get_filename_from_base_image(base_image, base_images) 258 | if os.path.exists(filename): 259 | return False 260 | 261 | return True 262 | 263 | 264 | @pydockerize.command() 265 | @click.pass_context 266 | def images(ctx): 267 | """Show images for repo from --tag""" 268 | 269 | tag = ctx.obj['tag'] 270 | 271 | if tag: 272 | click.echo('\nShowing Docker images for %s:\n' % tag) 273 | show_docker_images(tag) 274 | 275 | 276 | # Note that `ignore_unknown_options` requires Click >= 4.0 277 | # and Click 4.0 is not released as of this writing 278 | # See http://pocoo-click.readthedocs.org/en/latest/advanced/ 279 | # #forwarding-unknown-options 280 | # (@msabramo 2015-02-13) 281 | @pydockerize.command(context_settings=dict( 282 | ignore_unknown_options=True, 283 | )) 284 | @click.pass_context 285 | @click.argument('docker_run_args', nargs=-1, type=click.UNPROCESSED) 286 | def run(ctx, docker_run_args): 287 | """Run a Docker container""" 288 | 289 | tag = ctx.obj['tag'] 290 | mount_volume_from_host = True 291 | 292 | cmd = get_run_cmd(tag, mount_volume_from_host, docker_run_args) 293 | 294 | click.secho('Invoking: %s' % ' '.join(cmd), fg='yellow') 295 | status = subprocess.call(cmd) 296 | 297 | 298 | @pydockerize.command() 299 | @click.pass_context 300 | def ps(ctx): 301 | """List Docker containers""" 302 | 303 | tag = ctx.obj['tag'] 304 | cmd = "docker ps | GREP_OPTIONS='' egrep '^CONTAINER ID|%s'" % tag 305 | status = subprocess.call(cmd, shell=True) 306 | 307 | 308 | @pydockerize.command( 309 | short_help="Generate fig.yml for fig/Docker Compose (http://fig.sh).") 310 | @click.pass_context 311 | def generatefig(ctx): 312 | """Generate fig.yml for fig/Docker Compose (http://fig.sh).""" 313 | 314 | filename = 'fig.yml' 315 | click.echo('generatefig: Writing %s' % filename) 316 | 317 | env_dict = get_env() 318 | port = env_dict.get('PORT', None) 319 | 320 | if port: 321 | ports_txt = '["{port}:{port}"]'.format(port=port) 322 | else: 323 | ports_txt = '[]' 324 | 325 | tag = ctx.obj['tag'] 326 | cmd = ctx.obj['cmd'] 327 | procfile = ctx.obj['procfile'] 328 | 329 | if cmd is not None and procfile is not None: 330 | raise Exception('Cannot specify both --cmd and --procfile') 331 | 332 | if cmd is None and procfile is None and os.path.exists('Procfile'): 333 | procfile = open('Procfile') 334 | 335 | if procfile: 336 | cmd = get_cmd_from_procfile(procfile) 337 | click.echo('generatefig: Got cmd from %s => %r' % (procfile.name, cmd)) 338 | else: 339 | click.echo('generatefig: cmd = %r' % cmd) 340 | 341 | with open(filename, 'w+') as f: 342 | f.write(textwrap.dedent("""\ 343 | web: 344 | image: {tag} 345 | ports: {ports_txt} 346 | volumes: [".:/host"] 347 | command: "{cmd}" 348 | """).format(tag=tag, 349 | ports_txt=ports_txt, 350 | cmd=cmd)) 351 | 352 | 353 | def get_run_cmd(tag, mount_volume_from_host=True, docker_run_args=None): 354 | cmd = ['docker', 'run'] 355 | cmd.append('-it') 356 | cmd.append('--name=%s' % tag) 357 | 358 | if mount_volume_from_host: 359 | cmd.append('-v') 360 | cmd.append('%s:/host' % os.getcwd()) 361 | 362 | env_dict = get_env() 363 | port = env_dict.get('PORT', None) 364 | 365 | if port: 366 | cmd.append('-p') 367 | cmd.append('%s' % port) 368 | 369 | if not docker_run_args or '-d' not in docker_run_args: 370 | cmd.append('--rm') 371 | 372 | if docker_run_args: 373 | cmd.extend(docker_run_args) 374 | 375 | cmd.append(tag) 376 | 377 | return cmd 378 | 379 | 380 | def build_one(tag, base_image, base_images, filename): 381 | cmd = ['docker', 'build'] 382 | if tag: 383 | if ':' in tag: 384 | raise Exception("':' in tag not supported yet") 385 | tag = tag + ':' + get_tag_from_base_image(base_image, base_images) 386 | cmd.append('--tag') 387 | cmd.append(tag) 388 | if filename != 'Dockerfile': 389 | cmd.append('--file') 390 | cmd.append(filename) 391 | cmd.append('.') 392 | click.echo("build_one: Calling subprocess with cmd = '%s'\n" 393 | % ' '.join(cmd)) 394 | status = subprocess.call(cmd) 395 | if status == 0: 396 | click.secho('build_one: Docker build for tag "%s" succeeded.' % tag, 397 | fg='green') 398 | return tag 399 | else: 400 | click.secho('build_one: Docker build for tag "%s" failed with %d' 401 | % (tag, status), 402 | fg='red') 403 | raise click.Abort() 404 | 405 | 406 | def show_docker_images(repo): 407 | cmd = ['docker', 'images', repo] 408 | return subprocess.call(cmd) 409 | 410 | 411 | def get_filename_from_base_image(base_image, base_images): 412 | if len(base_images) == 1: 413 | return 'Dockerfile' 414 | else: 415 | return 'Dockerfile-' + base_image 416 | 417 | 418 | def get_tag_from_base_image(base_image, base_images): 419 | if len(base_images) == 1: 420 | return 'latest' 421 | 422 | tag = base_image 423 | replacements = {'python:': 'py', '-onbuild': ''} 424 | 425 | for old, new in replacements.items(): 426 | tag = tag.replace(old, new) 427 | 428 | return tag 429 | 430 | 431 | if __name__ == '__main__': 432 | pydockerize() 433 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup 4 | 5 | this_dir = os.path.dirname(__file__) 6 | long_description = "\n" + open(os.path.join(this_dir, 'README.rst')).read() 7 | 8 | setup( 9 | name='pydockerize', 10 | version='0.0.0', 11 | description=('Creates a Docker image from a Python app'), 12 | long_description=long_description, 13 | keywords='Docker', 14 | author='Marc Abramowitz', 15 | author_email='marc@marc-abramowitz.com', 16 | url='https://github.com/msabramo/pydockerize', 17 | py_modules=['pydockerize'], 18 | zip_safe=False, 19 | install_requires=['click'], 20 | entry_points = """\ 21 | [console_scripts] 22 | pydockerize = pydockerize:pydockerize 23 | """, 24 | license='MIT', 25 | classifiers=[ 26 | 'Programming Language :: Python :: 2', 27 | 'Programming Language :: Python :: 2.6', 28 | 'Programming Language :: Python :: 2.7', 29 | 'Programming Language :: Python :: 3', 30 | 'Programming Language :: Python :: 3.2', 31 | 'Programming Language :: Python :: 3.3', 32 | 'Programming Language :: Python :: 3.4', 33 | 'Topic :: Software Development :: Testing', 34 | 'Natural Language :: English', 35 | 'Intended Audience :: Developers', 36 | ], 37 | ) 38 | --------------------------------------------------------------------------------