Refactor *_gen_project hooks (#1490)

* Fix ./hooks/pre_gen_project.py asking user to select an option once only

+ prettify output

* Fix pre_gen hook not really exiting when it should

* Refactor & prettify ./hooks/post_gen_project.py

* Ensure same POSTGRES_USER is set across environments

+ get rid of env.example in favor of pre-generated .env.
This commit is contained in:
Nikita Shupeyko 2018-02-07 22:52:52 +03:00 committed by GitHub
parent 090c81731b
commit 86e33e8714
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 301 additions and 224 deletions

View File

@ -22,7 +22,7 @@ DJANGO_ADMIN_URL n/a r'^admin/'
DJANGO_CACHES CACHES (default) locmem redis DJANGO_CACHES CACHES (default) locmem redis
DJANGO_DATABASES DATABASES (default) See code See code DJANGO_DATABASES DATABASES (default) See code See code
DJANGO_DEBUG DEBUG True False DJANGO_DEBUG DEBUG True False
DJANGO_SECRET_KEY SECRET_KEY CHANGEME!!! raises error DJANGO_SECRET_KEY SECRET_KEY !!!SET DJANGO_SECRET_KEY!!! raises error
DJANGO_SECURE_BROWSER_XSS_FILTER SECURE_BROWSER_XSS_FILTER n/a True DJANGO_SECURE_BROWSER_XSS_FILTER SECURE_BROWSER_XSS_FILTER n/a True
DJANGO_SECURE_SSL_REDIRECT SECURE_SSL_REDIRECT n/a True DJANGO_SECURE_SSL_REDIRECT SECURE_SSL_REDIRECT n/a True
DJANGO_SECURE_CONTENT_TYPE_NOSNIFF SECURE_CONTENT_TYPE_NOSNIFF n/a True DJANGO_SECURE_CONTENT_TYPE_NOSNIFF SECURE_CONTENT_TYPE_NOSNIFF n/a True

View File

@ -1,238 +1,283 @@
""" """
Does the following: NOTE:
the below code is to be maintained Python 2.x-compatible
as the whole Cookiecutter Django project initialization
can potentially be run in Python 2.x environment
(at least so we presume in `pre_gen_project.py`).
1. Generates and saves random secret key TODO: ? restrict Cookiecutter Django project initialization to Python 3.x environments only
2. Removes the taskapp if celery isn't going to be used
3. Removes the .idea directory if PyCharm isn't going to be used
4. Copy files from /docs/ to {{ cookiecutter.project_slug }}/docs/
TODO: this might have to be moved to a pre_gen_hook
A portion of this code was adopted from Django's standard crypto functions and
utilities, specifically:
https://github.com/django/django/blob/master/django/utils/crypto.py
""" """
from __future__ import print_function
import os import os
import random import random
import shutil import shutil
import string import string
import sys
# Get the root project directory
PROJECT_DIRECTORY = os.path.realpath(os.path.curdir)
# Use the system PRNG if possible
try: try:
# Inspired by
# https://github.com/django/django/blob/master/django/utils/crypto.py
random = random.SystemRandom() random = random.SystemRandom()
using_sysrandom = True using_sysrandom = True
except NotImplementedError: except NotImplementedError:
using_sysrandom = False using_sysrandom = False
PROJECT_DIR_PATH = os.path.realpath(os.path.curdir)
def get_random_string(length=50):
"""
Returns a securely generated random string.
The default length of 12 with the a-z, A-Z, 0-9 character set returns
a 71-bit value. log_2((26+26+10)^12) =~ 71 bits
"""
punctuation = string.punctuation.replace('"', '').replace("'", '')
punctuation = punctuation.replace('\\', '')
if using_sysrandom:
return ''.join(random.choice(
string.digits + string.ascii_letters + punctuation
) for i in range(length))
print(
"Cookiecutter Django couldn't find a secure pseudo-random number generator on your system."
" Please change change your SECRET_KEY variables in conf/settings/local.py and env.example"
" manually."
)
return "CHANGEME!!"
def set_secret_key(setting_file_location): def remove_file(file_path):
# Open locals.py if os.path.exists(file_path):
with open(setting_file_location) as f: os.remove(file_path)
file_ = f.read()
# Generate a SECRET_KEY that matches the Django standard
SECRET_KEY = get_random_string()
# Replace "CHANGEME!!!" with SECRET_KEY
file_ = file_.replace('CHANGEME!!!', SECRET_KEY, 1)
# Write the results to the locals.py module
with open(setting_file_location, 'w') as f:
f.write(file_)
def make_secret_key(project_directory): def remove_open_source_project_only_files():
"""Generates and saves random secret key""" file_names = [
# Determine the local_setting_file_location 'CONTRIBUTORS.txt',
local_setting = os.path.join( ]
project_directory, for file_name in file_names:
'config/settings/local.py' os.remove(os.path.join(PROJECT_DIR_PATH, file_name))
)
# local.py settings file
set_secret_key(local_setting)
env_file = os.path.join(
project_directory,
'env.example'
)
# env.example file
set_secret_key(env_file)
def remove_file(file_name): def remove_gplv3_files():
if os.path.exists(file_name): file_names = [
os.remove(file_name) 'COPYING',
]
for file_name in file_names:
os.remove(os.path.join(PROJECT_DIR_PATH, file_name))
def remove_task_app(project_directory): def remove_pycharm_files():
"""Removes the taskapp if celery isn't going to be used""" idea_dir_path = os.path.join(PROJECT_DIR_PATH, '.idea')
# Determine the local_setting_file_location if os.path.exists(idea_dir_path):
task_app_location = os.path.join( shutil.rmtree(idea_dir_path)
PROJECT_DIRECTORY,
'{{ cookiecutter.project_slug }}/taskapp'
)
shutil.rmtree(task_app_location)
docs_dir_path = os.path.join(PROJECT_DIR_PATH, 'docs', 'pycharm')
def remove_pycharm_dir(project_directory): if os.path.exists(docs_dir_path):
""" shutil.rmtree(docs_dir_path)
Removes directories related to PyCharm
if it isn't going to be used
"""
idea_dir_location = os.path.join(PROJECT_DIRECTORY, '.idea/')
if os.path.exists(idea_dir_location):
shutil.rmtree(idea_dir_location)
docs_dir_location = os.path.join(PROJECT_DIRECTORY, 'docs/pycharm/')
if os.path.exists(docs_dir_location):
shutil.rmtree(docs_dir_location)
def remove_heroku_files():
"""
Removes files needed for heroku if it isn't going to be used
"""
filenames = ["Procfile", "runtime.txt"]
for filename in ["Procfile", "runtime.txt"]:
file_name = os.path.join(PROJECT_DIRECTORY, filename)
remove_file(file_name)
def remove_docker_files(): def remove_docker_files():
""" shutil.rmtree(os.path.join(PROJECT_DIR_PATH, 'compose'))
Removes files needed for docker if it isn't going to be used
"""
for filename in ["local.yml", "production.yml", ".dockerignore"]:
filename = os.path.join(PROJECT_DIRECTORY, filename)
if os.path.exists(filename):
os.remove(filename)
shutil.rmtree(os.path.join( file_names = [
PROJECT_DIRECTORY, "compose" 'local.yml',
)) 'production.yml',
'.dockerignore',
]
for file_name in file_names:
os.remove(os.path.join(PROJECT_DIR_PATH, file_name))
def remove_heroku_files():
file_names = [
'Procfile',
'runtime.txt',
]
for file_name in file_names:
remove_file(os.path.join(PROJECT_DIR_PATH, file_name))
def remove_paas_files():
none_paas_files_left = True
if '{{ cookiecutter.use_heroku }}'.lower() == 'n':
remove_heroku_files()
none_paas_files_left &= True
else:
none_paas_files_left &= False
if none_paas_files_left:
remove_file(os.path.join(PROJECT_DIR_PATH, 'requirements.txt'))
def remove_grunt_files(): def remove_grunt_files():
""" file_names = [
Removes files needed for grunt if it isn't going to be used 'Gruntfile.js',
""" ]
for filename in ["Gruntfile.js"]: for file_name in file_names:
os.remove(os.path.join( os.remove(os.path.join(PROJECT_DIR_PATH, file_name))
PROJECT_DIRECTORY, filename
))
def remove_gulp_files(): def remove_gulp_files():
file_names = [
'gulpfile.js',
]
for file_name in file_names:
os.remove(os.path.join(PROJECT_DIR_PATH, file_name))
def remove_packagejson_file():
file_names = [
'package.json',
]
for file_name in file_names:
os.remove(os.path.join(PROJECT_DIR_PATH, file_name))
def remove_celery_app():
shutil.rmtree(os.path.join(PROJECT_DIR_PATH, '{{ cookiecutter.project_slug }}', 'taskapp'))
def append_to_project_gitignore(path):
gitignore_file_path = os.path.join(PROJECT_DIR_PATH, '.gitignore')
with open(gitignore_file_path, 'a') as gitignore_file:
gitignore_file.write(path)
gitignore_file.write(os.linesep)
def generate_random_string(length,
using_digits=False,
using_ascii_letters=False,
using_punctuation=False):
""" """
Removes files needed for grunt if it isn't going to be used Example:
opting out for 50 symbol-long, [a-z][A-Z][0-9] string
would yield log_2((26+26+50)^50) ~= 334 bit strength.
""" """
for filename in ["gulpfile.js"]: if not using_sysrandom:
os.remove(os.path.join( return None
PROJECT_DIRECTORY, filename
))
def remove_packageJSON_file(): symbols = []
""" if using_digits:
Removes files needed for grunt if it isn't going to be used symbols += string.digits
""" if using_ascii_letters:
for filename in ["package.json"]: symbols += string.ascii_letters
os.remove(os.path.join( if using_punctuation:
PROJECT_DIRECTORY, filename symbols += string.punctuation \
)) .replace('"', '') \
.replace("'", '') \
def remove_copying_files(): .replace('\\', '')
""" return ''.join([random.choice(symbols) for _ in range(length)])
Removes files needed for the GPLv3 licence if it isn't going to be used
"""
for filename in ["COPYING"]:
os.remove(os.path.join(
PROJECT_DIRECTORY, filename
))
# IN PROGRESS def set_flag(file_path,
# def copy_doc_files(project_directory): flag,
# cookiecutters_dir = DEFAULT_CONFIG['cookiecutters_dir'] value=None,
# cookiecutter_django_dir = os.path.join( *args,
# cookiecutters_dir, **kwargs):
# 'cookiecutter-django', if value is None:
# 'docs' random_string = generate_random_string(*args, **kwargs)
# ) if random_string is None:
# target_dir = os.path.join( import sys
# project_directory, sys.stdout.write(
# 'docs' "We couldn't find a secure pseudo-random number generator on your system. "
# ) "Please, make sure to manually {} later.".format(flag)
# for name in os.listdir(cookiecutter_django_dir): )
# if name.endswith('.rst') and not name.startswith('index'): random_string = flag
# src = os.path.join(cookiecutter_django_dir, name) value = random_string
# dst = os.path.join(target_dir, name)
# shutil.copyfile(src, dst)
# 1. Generates and saves random secret key with open(file_path, 'r+') as f:
make_secret_key(PROJECT_DIRECTORY) file_contents = f.read().replace(flag, value)
f.seek(0)
f.write(file_contents)
f.truncate()
# 2. Removes the taskapp if celery isn't going to be used return value
if '{{ cookiecutter.use_celery }}'.lower() == 'n':
remove_task_app(PROJECT_DIRECTORY)
# 3. Removes the .idea directory if PyCharm isn't going to be used
if '{{ cookiecutter.use_pycharm }}'.lower() != 'y':
remove_pycharm_dir(PROJECT_DIRECTORY)
# 4. Removes all heroku files if it isn't going to be used
if '{{ cookiecutter.use_heroku }}'.lower() != 'y':
remove_heroku_files()
# 5. Removes all docker files if it isn't going to be used
if '{{ cookiecutter.use_docker }}'.lower() != 'y':
remove_docker_files()
# 6. Removes all JS task manager files if it isn't going to be used
if '{{ cookiecutter.js_task_runner}}'.lower() == 'gulp':
remove_grunt_files()
elif '{{ cookiecutter.js_task_runner}}'.lower() == 'grunt':
remove_gulp_files()
else:
remove_gulp_files()
remove_grunt_files()
remove_packageJSON_file()
# 9. Display a warning if use_docker and use_grunt are selected. Grunt isn't def set_django_secret_key(file_path):
# supported by our docker config atm. django_secret_key = set_flag(
if '{{ cookiecutter.js_task_runner }}'.lower() in ['grunt', 'gulp'] and '{{ cookiecutter.use_docker }}'.lower() == 'y': file_path,
print( '!!!SET DJANGO_SECRET_KEY!!!',
"You selected to use docker and a JS task runner. This is NOT supported out of the box for now. You " length=50,
"can continue to use the project like you normally would, but you will need to add a " using_digits=True,
"js task runner service to your docker configuration manually." using_ascii_letters=True
) )
return django_secret_key
# 10. Removes files needed for the GPLv3 licence if it isn't going to be used.
if '{{ cookiecutter.open_source_license}}' != 'GPLv3': def set_postgres_user(file_path,
remove_copying_files() value=None):
postgres_user = set_flag(
file_path,
'!!!SET POSTGRES_USER!!!',
value=value,
length=8,
using_ascii_letters=True
)
return postgres_user
def set_postgres_password(file_path):
postgres_password = set_flag(
file_path,
'!!!SET POSTGRES_PASSWORD!!!',
length=42,
using_digits=True,
using_ascii_letters=True
)
return postgres_password
def initialize_dotenv(postgres_user):
# Initializing `env.example` first.
envexample_file_path = os.path.join(PROJECT_DIR_PATH, 'env.example')
set_django_secret_key(envexample_file_path)
set_postgres_user(envexample_file_path, value=postgres_user)
set_postgres_password(envexample_file_path)
# Renaming `env.example` to `.env`.
dotenv_file_path = os.path.join(PROJECT_DIR_PATH, '.env')
shutil.move(envexample_file_path, dotenv_file_path)
def initialize_localyml(postgres_user):
set_postgres_user(os.path.join(PROJECT_DIR_PATH, 'local.yml'), value=postgres_user)
def initialize_local_settings():
set_django_secret_key(os.path.join(PROJECT_DIR_PATH, 'config', 'settings', 'local.py'))
def initialize_test_settings():
set_django_secret_key(os.path.join(PROJECT_DIR_PATH, 'config', 'settings', 'test.py'))
def main():
postgres_user = generate_random_string(length=16, using_ascii_letters=True)
initialize_dotenv(postgres_user)
initialize_localyml(postgres_user)
initialize_local_settings()
initialize_test_settings()
if '{{ cookiecutter.open_source_license }}' == 'Not open source':
remove_open_source_project_only_files()
elif '{{ cookiecutter.open_source_license}}' != 'GPLv3':
remove_gplv3_files()
if '{{ cookiecutter.use_pycharm }}'.lower() == 'n':
remove_pycharm_files()
if '{{ cookiecutter.use_docker }}'.lower() == 'n':
remove_docker_files()
remove_paas_files()
if '{{ cookiecutter.js_task_runner}}'.lower() == 'gulp':
remove_grunt_files()
elif '{{ cookiecutter.js_task_runner}}'.lower() == 'grunt':
remove_gulp_files()
else:
remove_gulp_files()
remove_grunt_files()
remove_packagejson_file()
if '{{ cookiecutter.js_task_runner }}'.lower() in ['grunt', 'gulp'] \
and '{{ cookiecutter.use_docker }}'.lower() == 'y':
TERMINATOR = "\x1b[0m"
INFO = "\x1b[1;33m [INFO]: "
sys.stdout.write(
INFO +
"Docker and {} JS task runner ".format('{{ cookiecutter.js_task_runner }}'.lower().capitalize()) +
"working together not supported yet. "
"You can continue using the generated project like you normally would, "
"however you would need to add a JS task runner service "
"to your Docker Compose configuration manually." +
TERMINATOR
)
if '{{ cookiecutter.use_celery }}'.lower() == 'n':
remove_celery_app()
if __name__ == '__main__':
main()

View File

@ -1,27 +1,59 @@
"""
NOTE:
the below code is to be maintained Python 2.x-compatible
as the whole Cookiecutter Django project initialization
can potentially be run in Python 2.x environment.
TODO: ? restrict Cookiecutter Django project initialization to Python 3.x environments only
"""
project_slug = '{{ cookiecutter.project_slug }}' project_slug = '{{ cookiecutter.project_slug }}'
if hasattr(project_slug, 'isidentifier'): if hasattr(project_slug, 'isidentifier'):
assert project_slug.isidentifier(), 'Project slug should be valid Python identifier!' assert project_slug.isidentifier(), "'{}' project slug is not a valid Python identifier.".format(project_slug)
heroku = '{{ cookiecutter.use_heroku }}'.lower() using_docker = '{{ cookiecutter.use_docker }}'.lower()
docker = '{{ cookiecutter.use_docker }}'.lower() if using_docker == 'n':
TERMINATOR = "\x1b[0m"
WARNING = "\x1b[1;33m [WARNING]: "
INFO = "\x1b[1;33m [INFO]: "
HINT = "\x1b[3;33m"
SUCCESS = "\x1b[1;32m [SUCCESS]: "
if docker == 'n': import sys
import sys
python_major_version = sys.version_info[0] python_major_version = sys.version_info[0]
if python_major_version == 2:
sys.stdout.write(
WARNING +
"Cookiecutter Django does not support Python 2. "
"Stability is guaranteed with Python 3.6+ only, "
"are you sure you want to proceed (y/n)? " +
TERMINATOR
)
yes_options, no_options = frozenset(['y']), frozenset(['n'])
while True:
choice = raw_input().lower()
if choice in yes_options:
break
elif choice in no_options:
sys.stdout.write(
INFO +
"Generation process stopped as requested." +
TERMINATOR
)
sys.exit(1)
else:
sys.stdout.write(
HINT +
"Please respond with {} or {}: ".format(
', '.join(["'{}'".format(o) for o in yes_options if not o == '']),
', '.join(["'{}'".format(o) for o in no_options if not o == ''])
) +
TERMINATOR
)
if python_major_version == 2: sys.stdout.write(
sys.stdout.write("WARNING: Cookiecutter Django does not support Python 2! Stability is guaranteed with Python 3.6+ only. Are you sure you want to proceed? (y/n)") SUCCESS +
"Project initialized, keep up the good work!" +
yes_options = set(['y']) TERMINATOR
no_options = set(['n', '']) )
choice = raw_input().lower()
if choice in no_options:
sys.exit(1)
elif choice in yes_options:
pass
else:
sys.stdout.write("Please respond with %s or %s"
% (', '.join([o for o in yes_options if not o == ''])
, ', '.join([o for o in no_options if not o == ''])))

View File

@ -24,7 +24,7 @@ TEMPLATES[0]['OPTIONS']['debug'] = DEBUG
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
# Note: This key only used for development and testing. # Note: This key only used for development and testing.
SECRET_KEY = env('DJANGO_SECRET_KEY', default='CHANGEME!!!') SECRET_KEY = env('DJANGO_SECRET_KEY', default='!!!SET DJANGO_SECRET_KEY!!!')
# Mail settings # Mail settings
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View File

@ -17,7 +17,7 @@ TEMPLATES[0]['OPTIONS']['debug'] = False
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
# Note: This key only used for development and testing. # Note: This key only used for development and testing.
SECRET_KEY = env('DJANGO_SECRET_KEY', default='CHANGEME!!!') SECRET_KEY = env('DJANGO_SECRET_KEY', default='!!!SET DJANGO_SECRET_KEY!!!')
# Mail settings # Mail settings
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View File

@ -1,7 +1,7 @@
# PostgreSQL # PostgreSQL
POSTGRES_PASSWORD=mysecretpass POSTGRES_PASSWORD=!!!SET POSTGRES_PASSWORD!!!
POSTGRES_USER=postgresuser POSTGRES_USER=!!!SET POSTGRES_USER!!!
CONN_MAX_AGE= CONN_MAX_AGE=
# Domain name, used by caddy # Domain name, used by caddy
@ -11,7 +11,7 @@ DOMAIN_NAME={{ cookiecutter.domain_name }}
# DJANGO_READ_DOT_ENV_FILE=True # DJANGO_READ_DOT_ENV_FILE=True
DJANGO_ADMIN_URL= DJANGO_ADMIN_URL=
DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=CHANGEME!!! DJANGO_SECRET_KEY=!!!SET DJANGO_SECRET_KEY!!!
DJANGO_ALLOWED_HOSTS=.{{ cookiecutter.domain_name }} DJANGO_ALLOWED_HOSTS=.{{ cookiecutter.domain_name }}
# AWS Settings # AWS Settings

View File

@ -15,7 +15,7 @@ services:
volumes: volumes:
- .:/app - .:/app
environment: environment:
- POSTGRES_USER={{cookiecutter.project_slug}} - POSTGRES_USER=!!!SET POSTGRES_USER!!!
- USE_DOCKER=yes - USE_DOCKER=yes
ports: ports:
- "8000:8000" - "8000:8000"
@ -29,7 +29,7 @@ services:
- postgres_data_local:/var/lib/postgresql/data - postgres_data_local:/var/lib/postgresql/data
- postgres_backup_local:/backups - postgres_backup_local:/backups
environment: environment:
- POSTGRES_USER={{cookiecutter.project_slug}} - POSTGRES_USER=!!!SET POSTGRES_USER!!!
{% if cookiecutter.use_mailhog == 'y' %} {% if cookiecutter.use_mailhog == 'y' %}
mailhog: mailhog:
image: mailhog/mailhog:v1.0.0 image: mailhog/mailhog:v1.0.0