-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add option to use templates from Zip files or Zip URLs #961
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Codecov Report
@@ Coverage Diff @@
## master #961 +/- ##
=====================================
Coverage 100% 100%
=====================================
Files 17 18 +1
Lines 695 780 +85
=====================================
+ Hits 695 780 +85
Continue to review full report at Codecov.
|
Hey @freakboy3742! 👋 Thanks for this PR! I like the idea and would be happy to add this functionality. I haven't had the time to give this a thorough review yet. Hopefully I'll find some time during/after PyData Berlin and EuroPython. 🤞 What do you think @audreyr @pydanny @michaeljoseph? |
Yeah, I'm 💯👍 on this- I'll try to find some time to review this weekend... |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for your work on this @freakboy3742! 🙇 I made some comments, if could have a look please.
My main questions is whether we want to keep the downloaded zip files and how properly clean up after cookiecutter generated the project.
cookiecutter/repository.py
Outdated
@@ -23,6 +24,11 @@ def is_repo_url(value): | |||
return bool(REPO_REGEX.match(value)) | |||
|
|||
|
|||
def is_zip_file(value): | |||
"""Return True if value is a repository URL.""" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doc string needs updated.
cookiecutter/zipfile.py
Outdated
ok_to_delete = read_user_yes_no(question, 'yes') | ||
|
||
if ok_to_delete: | ||
if os.path.isdir(path): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can probably remove this as zip archives are always files?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Zip archives are; but the unpacked zip directory isn't. There's a second usage of this function on line 81 to purge an unpacked zip directory.
cookiecutter/zipfile.py
Outdated
# Build the name of the cached zipfile, | ||
# and prompt to delete if it already exists. | ||
identifier = zip_url.rsplit('/', 1)[1] | ||
zip_path = os.path.join(clone_to_dir, identifier) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we should maybe download the zip archive to a temp directory, so ~/.cookiecutters
(or w/e dir the user specified) will only contain "template directories". 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could then also delete the zip archive once it has been extracted
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did think about this; I ended up keeping both on the "what's a few more bytes?" principle. Effectively, we have three options:
- Keep both (what is currently implemented)
- Keep the zip file, and unpack to a temp directory
- Keep the unpacked victory, and delete the zip file
Moving to (2) is a simple solution; but (3) gives us the opportunity (at some point in the future) use a password encoded zip file, adding to the potential security for commercial templates.
So - any preferences?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm voting for (3), largely because the mechanics of downloading and unzipping are just a means to an end (the template).
Also, I like my victory unpacked 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this would also make it a no-brainer to subsequently re-use prompt_and_delete
(cookiecutter.repository
is probably the natural destination of this refactor?)
cookiecutter/zipfile.py
Outdated
zip_path = os.path.join(clone_to_dir, identifier) | ||
|
||
if os.path.exists(zip_path): | ||
ok_to_delete = prompt_and_delete(zip_path, no_input=no_input) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Related to above comment, we could prompt the user for deletion if the unzipped template with its identifier matches a directory in their cookiecutters_dir
and then prompt for confirmation to delete.
cookiecutter/zipfile.py
Outdated
# prompt for deletion. If we've previously OK'd deletion, | ||
# don't ask again. | ||
zip_file = ZipFile(zip_path) | ||
unzip_name = zip_file.namelist()[0][:-1] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should have some extra check here that the zipfile contains only one directory and error if it contains more than one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I'll beef up the validation here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps we should also attempt to catch exceptions related to ZipFile
errors to wrap in a custom exception?
return request.param | ||
|
||
|
||
def test_zipfile_unzip( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd probably go with a marker here:
@pytest.mark.parametrize('template, is_url', [
('/path/to/zipfile.zip', False),
('https://example.com/path/to/zipfile.zip', True),
('http://example.com/path/to/zipfile.zip', True),
])
def test_zipfile_unzip(mocker, template, is_url, user_config_data):
pass
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a great PR, thanks @freakboy3742 🥇
tests/zipfile/test_unzip.py
Outdated
autospec=True | ||
) | ||
|
||
def mock_download(): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This inline function can be removed in favour of the existing module level one right?
cookiecutter/zipfile.py
Outdated
return ok_to_delete | ||
|
||
|
||
def unzip(zip_url, is_url, clone_to_dir='.', no_input=False): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you add a docstring for this function please?
cookiecutter/zipfile.py
Outdated
# Build the name of the cached zipfile, | ||
# and prompt to delete if it already exists. | ||
identifier = zip_url.rsplit('/', 1)[1] | ||
zip_path = os.path.join(clone_to_dir, identifier) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm voting for (3), largely because the mechanics of downloading and unzipping are just a means to an end (the template).
Also, I like my victory unpacked 😉
cookiecutter/zipfile.py
Outdated
# Build the name of the cached zipfile, | ||
# and prompt to delete if it already exists. | ||
identifier = zip_url.rsplit('/', 1)[1] | ||
zip_path = os.path.join(clone_to_dir, identifier) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this would also make it a no-brainer to subsequently re-use prompt_and_delete
(cookiecutter.repository
is probably the natural destination of this refactor?)
cookiecutter/zipfile.py
Outdated
# prompt for deletion. If we've previously OK'd deletion, | ||
# don't ask again. | ||
zip_file = ZipFile(zip_path) | ||
unzip_name = zip_file.namelist()[0][:-1] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps we should also attempt to catch exceptions related to ZipFile
errors to wrap in a custom exception?
return_value=True, | ||
autospec=True | ||
) | ||
dir = tmpdir.mkdir('repo') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's avoid shadowing builtin names?
tests/zipfile/test_unzip.py
Outdated
clone_to_dir=str(clone_to_dir) | ||
) | ||
|
||
assert output_dir == os.path.join(str(clone_to_dir), 'fake-repo-tmpl') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here and below, we can use the py.path.local
api for consistency: clone_to_dir.join('fake-repo-tmpl')
@freakboy3742 Can you review the requested changes? |
@theodesp My apologies - I've been snowed under with other work. I'll take a look ASAP. |
Apologies for the delay in updating this patch. I've now addressed the issues raised in the two reviews by @michaeljoseph and @hackebrot, and added a couple of extra improvements for good measure:
|
Regarding the coverage results - there are 4 lines missed. Two of those lines are in |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work @freakboy3742! 👏
Only a few minor changes and then this should be ready to go 🚀
@@ -116,6 +116,7 @@ def main( | |||
output_dir=output_dir, | |||
config_file=config_file, | |||
default_config=default_config, | |||
password=os.environ.get('COOKIECUTTER_REPO_PASSWORD') | |||
) | |||
except (OutputDirExistsException, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to catch InvalidZipRepository
exceptions here.
cookiecutter/repository.py
Outdated
@@ -23,6 +24,11 @@ def is_repo_url(value): | |||
return bool(REPO_REGEX.match(value)) | |||
|
|||
|
|||
def is_zip_file(value): | |||
"""Return True if value is a zip file.""" | |||
return value.endswith('.zip') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIK file extensions on Windows are not case sensitive, so maybe we want to change this return value.lower().endswith('.zip')
.
cookiecutter/zipfile.py
Outdated
from zipfile import ZipFile | ||
try: | ||
from zipfile import BadZipFile | ||
except ImportError: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you please add a comment mentioning that BadZipfile
was deprecated in Python 3.2?
@@ -69,6 +69,42 @@ type of repo that you want to use prepending `hg+` or `git+` to repo url:: | |||
|
|||
$ cookiecutter hg+https://example.com/repo | |||
|
|||
Works with Zip files |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I love that you always write great documentation for your changes, @freakboy3742!!! 📝 🙇
@hackebrot Thanks for the review - changes have been made! |
Thank you, @freakboy3742! 🙇 |
Thank you for your work @freakboy3742! 🙇 🍪 |
There are two reasons to add this feature:
Firstly, zip files are a convenient way to distribute a large number of files in a fixed structure. This means a cookiecutter can literally be a single file, rather than a directory, making it easier to distribute.
Secondly, this can be used to address #845 without cookiecutter itself needing to incorporate commercialisation features. The potential commercialisation path is as follows:
Of course, this won't stop a malicious user from copying the template from the cache or anything like that - but from a "make it easy to do the right thing" perspective, this would be sufficient to monetise a template.