-
Notifications
You must be signed in to change notification settings - Fork 0
Re-add non-boilerplate code and add tests #6
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
Re-add non-boilerplate code and add tests #6
Conversation
87995cd to
b429198
Compare
b429198 to
89f5331
Compare
d2bab8e to
368d67b
Compare
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.
Would this benefit from following a more standard script pattern?
I think there's an opportunity here to make it clearer that this is a script, which would help understanding how it works and also avoid all of the testing woes noted in the ticket.
We have a pretty common pattern in a lot of our scripts (along with chmod +x for extra points) which I think we can follow here:
#!/usr/bin/env python
from argparse import ArgumentParser
parser = ArgumentParser(description="A tool for ...")
...
def main():
args = parser.parse_args()
...
if __name__ == '__main__':
main()That won't work verbatim here as there are more complex arg parsing requirements, but the general structure of the script could be followed. This has a few nice features:
- It's obvious it's intended to be used as a script (not a library)
- The docs for how to use it are integrated and front and center
- You can use it directly on the command line, or test it
This would avoid having to install it into the env in order to test it.
Other stuff
The rest is mostly just random naming suggestions.
The only thing I'd say is definitely worth thinking about is whether pre-baked hashes in the tests is a better way to go. At the moment they use the code which is indirectly under test is used to setup the fixture.
Another approach might be to have tests which explicitly drive it from the outside, and call the method twice. Changing things inbetween or not. This way you'd only be testing the externally observable behavior without having to bake in any hashes if that made you unconfortable.
| parallel = true | ||
| source = ["pip_sync_faster", "tests/unit"] | ||
| omit = [ | ||
| "*/pip_sync_faster/__main__.py", |
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'll go into the cookiecutter, __main__.py will always be omitted from coverage
|
This is ready for re-review:
File layoutI spent some time thinking about the best file layout for the cookiecutter to use for packages like this one that have a command line interface. This is what I came up with: If you install the package and run For packages that don't have a CLI the cookiecutter will use the same layout but with the CLI-related files missing: Here's why I went for this layout:
I like that we can change around the internals of the package without breaking its interface. For example we could change the contents of |
a9bf7c3 to
dddaf1b
Compare
It's enough for a console script entry point to just return an int and setuptools makes sure that the command exits with that int as its status. This does mean that we have to add a sys.exit() to __main__.py so that it mirrors the same behaviour.
A more appropriate/specific name for this particular project.
| [options.entry_points] | ||
| console_scripts = | ||
| pip-sync-faster = pip_sync_faster.main:entry_point | ||
| pip-sync-faster = pip_sync_faster.cli:cli |
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'll update this in the cookiecutter: main.py::entry_point() renamed to cli.py::cli()
| import sys | ||
|
|
||
| from pip_sync_faster.cli import cli | ||
|
|
||
| sys.exit(cli()) |
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.
It'll be a good idea to move this __main__.py into the cookiecutter where it can be implemented correctly once and for all. For one thing we can implement __main__.py in the idiomatic way (as here), and we can omit it from test coverage. Also it's actually not as trivial to implement as you might expect:
cli() is used as both the setuptools entry point function (specified in setup.cfg) and the top-level function for __main__.py to call. A setuptools entry point function is expected to return something appropriate for being passed to sys.exit(): None or 0 to exit successfully, an int to exit with that error code, or any printable object (such as a string) that will be printed to stderr before exiting with 1. You can see this by looking at the pip-sync-faster script that setuptools generates when you install the package:
$ cat .tox/dev/bin/pip-sync-faster
#!/home/seanh/Projects/pip-sync-faster/.tox/dev/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from pip_sync_faster.cli import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())
A __main__.py or an if __name__ == "__main__" block should do the same thing: sys.exit(cli()).
This means that cli() itself shouldn't call sys.exit(), it should just return None or 0 to exit successfully or return 1 or return "Some error message" to exit with an error
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.
TIL about sys.exit with a function, nice.
| from pip_sync_faster.sync import sync | ||
|
|
||
|
|
||
| def cli(_argv=None): # pylint:disable=inconsistent-return-statements |
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.
PyLint will force you to put return 0 or return None in every branch of the function (including at the very bottom) which just seems annoying for an entry point function.
| "src_files", nargs="*", help="the requirements.txt files to synchronize" | ||
| ) | ||
|
|
||
| args = parser.parse_known_args(_argv) |
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.
_argv (which is used by the tests) defaults to None and parse_known_args(None) causes argparse to parse the args from sys.argv
| # Replace the cached hashes file with one containing the correct hashes for | ||
| # the requirements files that pip-sync-faster was called with this time. | ||
| with open(cached_hashes_path, "w", encoding="utf-8") as handle: | ||
| json.dump(hashes, handle) |
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.
When you see it split out into its own file like this the actual core logic of pip-sync-faster is very simple (and only about 40 lines of code without comments and docstrings)
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 works as expected. It's easy to understand and comes with good docs 👍
| import sys | ||
|
|
||
| from pip_sync_faster.cli import cli | ||
|
|
||
| sys.exit(cli()) |
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.
TIL about sys.exit with a function, nice.
|
|
||
|
|
||
| def sync(src_files): | ||
| cached_hashes_path = Path(environ["VIRTUAL_ENV"]) / "pip_sync_faster.json" |
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 reckon environ["VIRTUAL_ENV"] is the right place to store it. It would be just noise in the project's root.
|
|
||
| def get_hash(path): | ||
| """Return the hash of the given file.""" | ||
| hashobj = hashlib.sha512() |
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.
You could probably squeeze a few microseconds switching to sha256, if git considers that future-proof should be enough for this.
It doesn't probably matter.
This commit applies suggestions from the code review in #6
This re-adds the non-boilerplate files that #5 (which this PR is based on) removed in order to facilitate code review. Also adds tests.
Once again a review of the meat is required (
README.md,src/*andtests/*) and the cookiecutter stuff (.cookiecutter/*) can be reviewed when we review the cookiecutter itself.How to test manually
The pip-sync-faster concept is pretty simple: it calls
pip-syncand stashes hashes of the requirements files in apip_sync_faster.jsonfile within the venv. If you run it again with the same requirements files and you haven't changed any of the requirements files then it won't callpip-syncagain. If any of the requirements files has changed or if you call it with a different set of requirements files (even a subset of the ones you called it with last time) then it will callpip-syncagain.The CLI of
pip-sync-fasteris the same as that ofpip-syncand it passes all command line options and arguments blindly topip-sync. It also echoespip-sync's exit status.To test it:
Create and activate a venv
Create some test requirements files
Install
pip-sync-fasterin the venv:It should be pretty obvious to see that it's running pip-sync:
There is one testing gotcha:
pip-sync-fastermust itself be in the requirements file, otherwise it'll uninstall itself (it'll callpip-sync requirements.txtwhich'll uninstall anything that's not inrequirements.txt, includingpip-sync-faster).In testing this is a double-gotcha because even if
requirements.txtcontainspip-sync-fasterit will replace the local development version with a copy from PyPI.So in testing a local development version you have to reinstall it every time:
If you run
pip-sync-fasteragain with the same requirements file it won't callpip-sync:Other things to test:
If you modify the requirements file and then re-call
pip-sync-fasterit will callpip-syncIf you call it with a different requirements file it will call
pip-sync. If you then call it with the first requirements file again it'll callpip-syncagainCalling it with multiple requirements files. If you call it again without changing any of the requirements files it won't call
pip-sync. If you change any of them it willIf you call it with a different set of requirements files (even a subset of what you previously called it with) it will call
pip-sync