8000 Add option to use templates from Zip files or Zip URLs by freakboy3742 · Pull Request #961 · cookiecutter/cookiecutter · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

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

Merged
merged 20 commits into from
Oct 14, 2017

Conversation

freakboy3742
Copy link
Contributor

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:

  • Developer uploads a Zip file to a private S3 bucket.
  • Developer deploys a website to sell their template.
  • User buys a license from the website
  • Developer website provides the user a public url, protected by username/password or access credentials of some kind
  • Any attempt to access the public URL is validated against the sales database. This can limit template uses to a particular count, or only allow access for a period of time, or any other scheme that the developer chooses.

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.

@codecov-io
Copy link
codecov-io commented Jun 17, 2017

Codecov Report

Merging #961 into master will not change coverage.
The diff coverage is 100%.

Impacted file tree graph

@@          Coverage Diff          @@
##           master   #961   +/-   ##
=====================================
  Coverage     100%   100%           
=====================================
  Files          17     18    +1     
  Lines         695    780   +85     
=====================================
+ Hits          695    780   +85
Impacted Files Coverage Δ
cookiecutter/cli.py 100% <ø> (ø) ⬆️
cookiecutter/repository.py 100% <100%> (ø) ⬆️
cookiecutter/main.py 100% <100%> (ø) ⬆️
cookiecutter/vcs.py 100% <100%> (ø) ⬆️
cookiecutter/zipfile.py 100% <100%> (ø)
cookiecutter/utils.py 100% <100%> (ø) ⬆️
cookiecutter/prompt.py 100% <100%> (ø) ⬆️
cookiecutter/exceptions.py 100% <100%> (ø) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 22df3a8...3936a80. Read the comment docs.

@hackebrot
< 8000 summary data-view-component="true" class="timeline-comment-action Link--secondary Button--link Button--medium Button"> Copy link
Member

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?

@hackebrot hackebrot added the enhancement This issue/PR relates to a feature request. label Jun 22, 2017
@michaeljoseph
Copy link
Contributor

Yeah, I'm 💯👍 on this- I'll try to find some time to review this weekend...

Copy link
Member
@hackebrot hackebrot left a 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.

@@ -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."""
Copy link
Member

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.

ok_to_delete = read_user_yes_no(question, 'yes')

if ok_to_delete:
if os.path.isdir(path):
Copy link
Member

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?

Copy link
Contributor Author

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.

# 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)
Copy link
Member

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". 🤔

Copy link
Member

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

Copy link
Contributor Author

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:

  1. Keep both (what is currently implemented)
  2. Keep the zip file, and unpack to a temp directory
  3. 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?

Copy link
Contributor

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 😉

Copy link
Contributor

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?)

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)
Copy link
Member

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.

# 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]
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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(
Copy link
Member

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

Copy link
Contributor
@michaeljoseph michaeljoseph left 6DB6 a 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 🥇

autospec=True
)

def mock_download():
Copy link
Contributor

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?

return ok_to_delete


def unzip(zip_url, is_url, clone_to_dir='.', no_input=False):
Copy link
Contributor

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?

# 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)
Copy link
Contributor

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 😉

# 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)
Copy link
Contributor

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?)

# 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]
Copy link
Contributor

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')
Copy link
Contributor

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?

clone_to_dir=str(clone_to_dir)
)

assert output_dir == os.path.join(str(clone_to_dir), 'fake-repo-tmpl')
Copy link
Contributor

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')

@theodesp
Copy link
theodesp commented Aug 9, 2017

@freakboy3742 Can you review the requested changes?

@freakboy3742
Copy link
Contributor Author

@theodesp My apologies - I've been snowed under with other work. I'll take a look ASAP.

@freakboy3742
Copy link
Contributor Author

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:

  • Updated docstrings as requested
  • Combined the vcs and zipfile versions of prompt_and_delete, and moved the result to the utils package (it can't be in repository as suggested because of circular dependencies)
  • Added an extra question to prompt_or_delete step that allows the user to reuse an existing template, instead of forcing a re-download. This is necessary for using cookiecutter on planes, on unreliable conference WiFi, etc.
  • Improved error handling for badly formed or invalid zip archives
  • Restructured some tests to make better use of pytest.mark.parametrize
  • Modify zipfile behavior to:
    • Keep the original zipfile (if downloaded from a URL) in the .cookiecutter directory;
    • When generating a template, unpack the zipfile into a temporary directory; and
    • Delete the unpacked contents after use.
      This ensures that if the user retrieves a Zipfile repository, they won't have an unzipped version visible on their system for any longer than is necessary to generate the templated output.
  • Added ability to extract password-protected Zipfiles. Combined with the previous change, this means you can deliver a commercial template in a password-protected Zipfile. End users will only ever have access to generated output, not the raw template itself. This solution won't stop determined attackers, but it's strong enough to foil casual attempts at circumventing a commercial license.

@freakboy3742
Copy link
Contributor Author

Regarding the coverage results - there are 4 lines missed. Two of those lines are in cookiecutter/utils.py and were pre-existing coverage misses. The other two lines are in cookiecutter/zipfile.py, but aren't coverable - they're a backwards compatible import shim for the naming of the zipfile.BadZipFile exception (Lines 9-10) .

Copy link
Member
@hackebrot hackebrot left a 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,
Copy link
Member

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.

@@ -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')
Copy link
Member

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').

from zipfile import ZipFile
try:
from zipfile import BadZipFile
except ImportError:
Copy link
Member

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
Copy link
Member

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!!! 📝 🙇

@freakboy3742
Copy link
Contributor Author

@hackebrot Thanks for the review - changes have been made!

@hackebrot
Copy link
Member

Thank you, @freakboy3742! 🙇

@hackebrot
Copy link
Member

Thank you for your work @freakboy3742! 🙇 🍪

@hackebrot hackebrot merged commit a9d00ec into cookiecutter:master Oct 14, 2017
hackebrot added a commit that referenced this pull request Oct 14, 2017
@hackebrot hackebrot mentioned this pull request Oct 15, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement This issue/PR relates to a feature request.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants
0