├── .gitignore ├── .markdownlint.json ├── .pre-commit-config.yaml ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── bin └── venv_update.py ├── config.template.yaml ├── mealpy ├── __init__.py ├── __main__.py ├── config.py └── mealpy.py ├── pylintrc ├── requirements-bootstrap.txt ├── requirements-dev-minimal.txt ├── requirements-dev.txt ├── requirements-minimal.txt ├── requirements.txt ├── setup.cfg └── tests ├── __init__.py ├── config_test.py ├── conftest.py └── mealpy_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | /coverage-html/ 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # Misc 127 | .*.swp 128 | .idea/ 129 | /config.yaml 130 | /cookies.txt 131 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "line-length": { 4 | "line_length": 120 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude: ^bin/ 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v1.2.3 6 | hooks: 7 | - id: autopep8-wrapper 8 | - id: check-added-large-files 9 | - id: check-byte-order-marker 10 | - id: check-case-conflict 11 | - id: check-docstring-first 12 | - id: check-json 13 | - id: check-merge-conflict 14 | - id: check-symlinks 15 | - id: check-xml 16 | - id: check-yaml 17 | - id: debug-statements 18 | - id: detect-aws-credentials 19 | args: 20 | - --allow-missing-credentials 21 | - id: detect-private-key 22 | - id: double-quote-string-fixer 23 | - id: end-of-file-fixer 24 | - id: fix-encoding-pragma 25 | args: 26 | - --remove 27 | - id: forbid-new-submodules 28 | - id: mixed-line-ending 29 | - id: name-tests-test 30 | - id: pretty-format-json 31 | args: [ 32 | --autofix, 33 | --no-sort-keys, 34 | --indent, '4' 35 | ] 36 | - id: requirements-txt-fixer 37 | - id: sort-simple-yaml 38 | - id: trailing-whitespace 39 | - repo: https://github.com/asottile/reorder_python_imports 40 | rev: v1.0.1 41 | hooks: 42 | - id: reorder-python-imports 43 | - repo: https://github.com/asottile/add-trailing-comma 44 | rev: v0.6.4 45 | hooks: 46 | - id: add-trailing-comma 47 | - repo: local 48 | hooks: 49 | - id: pylint 50 | name: Pylint 51 | entry: venv/bin/pylint 52 | language: system 53 | types: [python] 54 | - repo: https://github.com/pre-commit/mirrors-autopep8 55 | rev: v1.4.3 56 | hooks: 57 | - id: autopep8 58 | - repo: https://github.com/PyCQA/flake8 59 | rev: 3.7.5 60 | hooks: 61 | - id: flake8 62 | - repo: https://github.com/igorshubovych/markdownlint-cli 63 | rev: v0.14.0 64 | hooks: 65 | - id: markdownlint 66 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | dist: xenial 3 | language: python 4 | python: 5 | - '3.7' 6 | - '3.6' 7 | install: 8 | - pip install coveralls 9 | - make venv 10 | script: 11 | - make test 12 | - if test "$TRAVIS_PYTHON_VERSION" == '3.7'; then venv/bin/check-requirements; fi 13 | after_success: 14 | - coveralls 15 | cache: 16 | pip: true 17 | directories: 18 | - $HOME/.cache/pre-commit 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Edmund Mok 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := test 2 | PYTHON := python3 3 | 4 | .PHONY: venv 5 | venv: 6 | bin/venv_update.py \ 7 | venv= -p $(PYTHON) venv \ 8 | install= -r requirements-dev.txt -rrequirements.txt \ 9 | bootstrap-deps= -r requirements-bootstrap.txt \ 10 | >/dev/null 11 | venv/bin/pre-commit install --install-hooks 12 | 13 | 14 | # Non-necessary dev environment tooling 15 | .PHONY: venv-dev 16 | venv-dev: venv 17 | venv/bin/pip install ipython pudb pytest-sugar pytest-testmon pytest-watch 18 | 19 | 20 | .PHONY: test 21 | test: venv 22 | venv/bin/coverage run -m pytest --strict tests/ 23 | venv/bin/coverage report --fail-under 64 --omit 'tests/*' 24 | venv/bin/coverage report --fail-under 100 --include 'tests/*' 25 | venv/bin/pre-commit run --all-files 26 | 27 | 28 | # On TravisCI, test dependencies are pinned against against xenial python 3.7 29 | .PHONY: test-ci 30 | test-ci: PYTHON = python3.7 31 | test-ci: test 32 | venv/bin/check-requirements 33 | 34 | .PHONY: clean 35 | clean: ## Clean working directory 36 | find . -iname '*.pyc' | xargs rm -f 37 | rm -rf ./venv 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mealpy 2 | 3 | [![Build Status](https://travis-ci.org/edmundmok/mealpy.svg?branch=master)](https://travis-ci.org/edmundmok/mealpy) 4 | [![Coveralls github](https://img.shields.io/coveralls/github/edmundmok/mealpy.svg)](https://img.shields.io/coveralls/github/edmundmok/mealpy?branch=master) 5 | ![license](https://img.shields.io/github/license/edmundmok/mealpy.svg) 6 | 7 | Reserve your meals on MealPal automatically, as soon as the kitchen opens. 8 | Never miss your favourite MealPal meals again! 9 | 10 | ## Description 11 | 12 | *[MealPal](https://www.mealpal.com) offers lunch and dinner subscriptions giving you access to the best restaurants 13 | for less than $6 per meal.* 14 | 15 | This script automates the ordering process by allowing you to specify your desired restaurant and pickup timing in 16 | advance. Just run the script before the MealPal kitchen opens at 5pm to get your order, and beat the competition to 17 | getting the meals from popular restaurants! 18 | 19 | ## Installation 20 | 21 | Install virtualenv with all required dependencies and activate it: 22 | 23 | ```bash 24 | make venv 25 | source venv/bin/activate 26 | ``` 27 | 28 | ## Quickstart 29 | 30 | ```bash 31 | python -m mealpy --help 32 | ``` 33 | 34 | ### Reserve a meal 35 | 36 | ```bash 37 | # python -m mealpy.py reserve RESTAURANT RESERVATION_TIME CITY 38 | python -m mealpy reserve "Coast Poke Counter - Battery St." "12:15pm-12:30pm" "San Francisco" 39 | ``` 40 | 41 | ## Files 42 | 43 | ### Configuration 44 | 45 | Upon the first run, a config will be created in $XDG_CONFIG_HOME (~/.config/mealpy) from the [template](config.template.yaml). 46 | You'll can override the default values. 47 | 48 | ### Cookies 49 | 50 | This script stores cookies created from initial login. 51 | This is how the script can rerun without re-asking every time. 52 | This can be found in $XDG_CACHE_HOME (~/.cache/mealpy). 53 | -------------------------------------------------------------------------------- /bin/venv_update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | '''\ 4 | usage: venv-update [-hV] [options] 5 | 6 | Update a (possibly non-existent) virtualenv directory using a pip requirements 7 | file. When this script completes, the virtualenv directory should contain the 8 | same packages as if it were deleted then rebuilt. 9 | 10 | venv-update uses "trailing equal" options (e.g. venv=) to delimit groups of 11 | (conventional, dashed) options to pass to wrapped commands (virtualenv and pip). 12 | 13 | Options: 14 | venv= parameters are passed to virtualenv 15 | default: {venv=} 16 | install= options to pip-command 17 | default: {install=} 18 | pip-command= is run after the virtualenv directory is bootstrapped 19 | default: {pip-command=} 20 | bootstrap-deps= dependencies to install before pip-command= is run 21 | default: {bootstrap-deps=} 22 | 23 | Examples: 24 | # install requirements.txt to "venv" 25 | venv-update 26 | 27 | # install requirements.txt to "myenv" 28 | venv-update venv= myenv 29 | 30 | # install requirements.txt to "myenv" using Python 3.4 31 | venv-update venv= -ppython3.4 myenv 32 | 33 | # install myreqs.txt to "venv" 34 | venv-update install= -r myreqs.txt 35 | 36 | # install requirements.txt to "venv", verbosely 37 | venv-update venv= venv -vvv install= -r requirements.txt -vvv 38 | 39 | # install requirements.txt to "venv", without pip-faster --update --prune 40 | venv-update pip-command= pip install 41 | 42 | We strongly recommend that you keep the default value of pip-command= in order 43 | to quickly and reproducibly install your requirements. You can override the 44 | packages installed during bootstrapping, prior to pip-command=, by setting 45 | bootstrap-deps= 46 | 47 | Pip options are also controllable via environment variables. 48 | See https://pip.readthedocs.org/en/stable/user_guide/#environment-variables 49 | For example: 50 | PIP_INDEX_URL=https://pypi.example.com/simple venv-update 51 | 52 | Please send issues to: https://github.com/yelp/venv-update 53 | ''' 54 | from __future__ import absolute_import 55 | from __future__ import print_function 56 | from __future__ import unicode_literals 57 | 58 | from os.path import exists 59 | from os.path import join 60 | from subprocess import CalledProcessError 61 | 62 | __version__ = '3.1.1' 63 | DEFAULT_VIRTUALENV_PATH = 'venv' 64 | DEFAULT_OPTION_VALUES = { 65 | 'venv=': (DEFAULT_VIRTUALENV_PATH,), 66 | 'install=': ('-r', 'requirements.txt',), 67 | 'pip-command=': ('pip-faster', 'install', '--upgrade', '--prune'), 68 | 'bootstrap-deps=': ('venv-update==' + __version__,), 69 | } 70 | __doc__ = __doc__.format( 71 | **{key: ' '.join(val) for key, val in DEFAULT_OPTION_VALUES.items()} 72 | ) 73 | 74 | # This script must not rely on anything other than 75 | # stdlib>=2.6 and virtualenv>1.11 76 | 77 | 78 | def parseargs(argv): 79 | '''handle --help, --version and our double-equal ==options''' 80 | args = [] 81 | options = {} 82 | key = None 83 | for arg in argv: 84 | if arg in DEFAULT_OPTION_VALUES: 85 | key = arg.strip('=').replace('-', '_') 86 | options[key] = () 87 | elif key is None: 88 | args.append(arg) 89 | else: 90 | options[key] += (arg,) 91 | 92 | if set(args) & {'-h', '--help'}: 93 | print(__doc__, end='') 94 | exit(0) 95 | elif set(args) & {'-V', '--version'}: 96 | print(__version__) 97 | exit(0) 98 | elif args: 99 | exit('invalid option: %s\nTry --help for more information.' % args[0]) 100 | 101 | return options 102 | 103 | 104 | def timid_relpath(arg): 105 | """convert an argument to a relative path, carefully""" 106 | # TODO-TEST: unit tests 107 | from os.path import isabs, relpath, sep 108 | if isabs(arg): 109 | result = relpath(arg) 110 | if result.count(sep) + 1 < arg.count(sep): 111 | return result 112 | 113 | return arg 114 | 115 | 116 | def shellescape(args): 117 | from pipes import quote 118 | return ' '.join(quote(timid_relpath(arg)) for arg in args) 119 | 120 | 121 | def colorize(cmd): 122 | from os import isatty 123 | 124 | if isatty(1): 125 | template = '\033[36m>\033[m \033[32m{0}\033[m' 126 | else: 127 | template = '> {0}' 128 | 129 | return template.format(shellescape(cmd)) 130 | 131 | 132 | def run(cmd): 133 | from subprocess import check_call 134 | check_call(('echo', colorize(cmd))) 135 | check_call(cmd) 136 | 137 | 138 | def info(msg): 139 | # use a subprocess to ensure correct output interleaving. 140 | from subprocess import check_call 141 | check_call(('echo', msg)) 142 | 143 | 144 | def check_output(cmd): 145 | from subprocess import Popen, PIPE 146 | process = Popen(cmd, stdout=PIPE) 147 | output, _ = process.communicate() 148 | if process.returncode: 149 | raise CalledProcessError(process.returncode, cmd) 150 | else: 151 | assert process.returncode == 0 152 | return output.decode('UTF-8') 153 | 154 | 155 | def samefile(file1, file2): 156 | if not exists(file1) or not exists(file2): 157 | return False 158 | else: 159 | from os.path import samefile 160 | return samefile(file1, file2) 161 | 162 | 163 | def exec_(argv): # never returns 164 | """Wrapper to os.execv which shows the command and runs any atexit handlers (for coverage's sake). 165 | Like os.execv, this function never returns. 166 | """ 167 | # info('EXEC' + colorize(argv)) # TODO: debug logging by environment variable 168 | 169 | # in python3, sys.exitfunc has gone away, and atexit._run_exitfuncs seems to be the only pubic-ish interface 170 | # https://hg.python.org/cpython/file/3.4/Modules/atexitmodule.c#l289 171 | import atexit 172 | atexit._run_exitfuncs() 173 | 174 | from os import execv 175 | execv(argv[0], argv) 176 | 177 | 178 | class Scratch(object): 179 | 180 | def __init__(self): 181 | self.dir = join(user_cache_dir(), 'venv-update', __version__) 182 | self.venv = join(self.dir, 'venv') 183 | self.python = venv_python(self.venv) 184 | self.src = join(self.dir, 'src') 185 | 186 | 187 | def exec_scratch_virtualenv(args): 188 | """ 189 | goals: 190 | - get any random site-packages off of the pythonpath 191 | - ensure we can import virtualenv 192 | - ensure that we're not using the interpreter that we may need to delete 193 | - idempotency: do nothing if the above goals are already met 194 | """ 195 | scratch = Scratch() 196 | if not exists(scratch.python): 197 | run(('virtualenv', scratch.venv)) 198 | 199 | if not exists(join(scratch.src, 'virtualenv.py')): 200 | scratch_python = venv_python(scratch.venv) 201 | # TODO: do we allow user-defined override of which version of virtualenv to install? 202 | tmp = scratch.src + '.tmp' 203 | run((scratch_python, '-m', 'pip.__main__', 'install', 'virtualenv', '--target', tmp)) 204 | 205 | from os import rename 206 | rename(tmp, scratch.src) 207 | 208 | import sys 209 | from os.path import realpath 210 | # We want to compare the paths themselves as sometimes sys.path is the same 211 | # as scratch.venv, but with a suffix of bin/.. 212 | if realpath(sys.prefix) != realpath(scratch.venv): 213 | # TODO-TEST: sometimes we would get a stale version of venv-update 214 | exec_((scratch.python, dotpy(__file__)) + args) # never returns 215 | 216 | # TODO-TEST: the original venv-update's directory was on sys.path (when using symlinking) 217 | sys.path[0] = scratch.src 218 | 219 | 220 | def get_original_path(venv_path): # TODO-TEST: a unit test 221 | """This helps us know whether someone has tried to relocate the virtualenv""" 222 | return check_output(('sh', '-c', '. %s; printf "$VIRTUAL_ENV"' % venv_executable(venv_path, 'activate'))) 223 | 224 | 225 | def has_system_site_packages(interpreter): 226 | # TODO: unit-test 227 | system_site_packages = check_output(( 228 | interpreter, 229 | '-c', 230 | # stolen directly from virtualenv's site.py 231 | """\ 232 | import site, os.path 233 | print( 234 | 0 235 | if os.path.exists( 236 | os.path.join(os.path.dirname(site.__file__), 'no-global-site-packages.txt') 237 | ) else 238 | 1 239 | )""" 240 | )) 241 | system_site_packages = int(system_site_packages) 242 | assert system_site_packages in (0, 1) 243 | return bool(system_site_packages) 244 | 245 | 246 | def get_python_version(interpreter): 247 | if not exists(interpreter): 248 | return None 249 | 250 | cmd = (interpreter, '-c', 'import sys; print(sys.version)') 251 | return check_output(cmd) 252 | 253 | 254 | def invalid_virtualenv_reason(venv_path, source_python, destination_python, options): 255 | try: 256 | orig_path = get_original_path(venv_path) 257 | except CalledProcessError: 258 | return 'could not inspect metadata' 259 | if not samefile(orig_path, venv_path): 260 | return 'virtualenv moved %s -> %s' % (timid_relpath(orig_path), timid_relpath(venv_path)) 261 | elif has_system_site_packages(destination_python) != options.system_site_packages: 262 | return 'system-site-packages changed, to %s' % options.system_site_packages 263 | 264 | if source_python is None: 265 | return 266 | destination_version = get_python_version(destination_python) 267 | source_version = get_python_version(source_python) 268 | if source_version != destination_version: 269 | return 'python version changed %s -> %s' % (destination_version, source_version) 270 | 271 | 272 | def ensure_virtualenv(args, return_values): 273 | """Ensure we have a valid virtualenv.""" 274 | def adjust_options(options, args): 275 | # TODO-TEST: proper error message with no arguments 276 | venv_path = return_values.venv_path = args[0] 277 | 278 | if venv_path == DEFAULT_VIRTUALENV_PATH or options.prompt == '': 279 | from os.path import abspath, basename, dirname 280 | options.prompt = '(%s)' % basename(dirname(abspath(venv_path))) 281 | # end of option munging. 282 | 283 | # there are two python interpreters involved here: 284 | # 1) the interpreter we're instructing virtualenv to copy 285 | if options.python is None: 286 | source_python = None 287 | else: 288 | source_python = virtualenv.resolve_interpreter(options.python) 289 | # 2) the interpreter virtualenv will create 290 | destination_python = venv_python(venv_path) 291 | 292 | if exists(destination_python): 293 | reason = invalid_virtualenv_reason(venv_path, source_python, destination_python, options) 294 | if reason: 295 | info('Removing invalidated virtualenv. (%s)' % reason) 296 | run(('rm', '-rf', venv_path)) 297 | else: 298 | info('Keeping valid virtualenv from previous run.') 299 | raise SystemExit(0) # looks good! we're done here. 300 | 301 | # this is actually a documented extension point: 302 | # http://virtualenv.readthedocs.org/en/latest/reference.html#adjust_options 303 | import virtualenv 304 | virtualenv.adjust_options = adjust_options 305 | 306 | from sys import argv 307 | argv[:] = ('virtualenv',) + args 308 | info(colorize(argv)) 309 | raise_on_failure(virtualenv.main) 310 | # There might not be a venv_path if doing something like "venv= --version" 311 | # and not actually asking virtualenv to make a venv. 312 | if return_values.venv_path is not None: 313 | run(('rm', '-rf', join(return_values.venv_path, 'local'))) 314 | 315 | 316 | def wait_for_all_subprocesses(): 317 | from os import wait 318 | try: 319 | while True: 320 | wait() 321 | except OSError as error: 322 | if error.errno == 10: # no child processes 323 | return 324 | else: 325 | raise 326 | 327 | 328 | def touch(filename, timestamp): 329 | """set the mtime of a file""" 330 | if timestamp is not None: 331 | timestamp = (timestamp, timestamp) # atime, mtime 332 | 333 | from os import utime 334 | utime(filename, timestamp) 335 | 336 | 337 | def mark_venv_valid(venv_path): 338 | wait_for_all_subprocesses() 339 | touch(venv_path, None) 340 | 341 | 342 | def mark_venv_invalid(venv_path): 343 | # LBYL, to attempt to avoid any exception during exception handling 344 | from os.path import isdir 345 | if venv_path and isdir(venv_path): 346 | info('') 347 | info("Something went wrong! Sending '%s' back in time, so make knows it's invalid." % timid_relpath(venv_path)) 348 | wait_for_all_subprocesses() 349 | touch(venv_path, 0) 350 | 351 | 352 | def dotpy(filename): 353 | if filename.endswith(('.pyc', '.pyo', '.pyd')): 354 | return filename[:-1] 355 | else: 356 | return filename 357 | 358 | 359 | def venv_executable(venv_path, executable): 360 | return join(venv_path, 'bin', executable) 361 | 362 | 363 | def venv_python(venv_path): 364 | return venv_executable(venv_path, 'python') 365 | 366 | 367 | def user_cache_dir(): 368 | # stolen from pip.utils.appdirs.user_cache_dir 369 | from os import getenv 370 | from os.path import expanduser 371 | return getenv('XDG_CACHE_HOME', expanduser('~/.cache')) 372 | 373 | 374 | def venv_update( 375 | venv=DEFAULT_OPTION_VALUES['venv='], 376 | install=DEFAULT_OPTION_VALUES['install='], 377 | pip_command=DEFAULT_OPTION_VALUES['pip-command='], 378 | bootstrap_deps=DEFAULT_OPTION_VALUES['bootstrap-deps='], 379 | ): 380 | """we have an arbitrary python interpreter active, (possibly) outside the virtualenv we want. 381 | 382 | make a fresh venv at the right spot, make sure it has pip-faster, and use it 383 | """ 384 | 385 | # SMELL: mutable argument as return value 386 | class return_values(object): 387 | venv_path = None 388 | 389 | try: 390 | ensure_virtualenv(venv, return_values) 391 | if return_values.venv_path is None: 392 | return 393 | # invariant: the final virtualenv exists, with the right python version 394 | raise_on_failure(lambda: pip_faster(return_values.venv_path, pip_command, install, bootstrap_deps)) 395 | except BaseException: 396 | mark_venv_invalid(return_values.venv_path) 397 | raise 398 | else: 399 | mark_venv_valid(return_values.venv_path) 400 | 401 | 402 | def execfile_(filename): 403 | with open(filename) as code: 404 | code = compile(code.read(), filename, 'exec') 405 | exec(code, {'__file__': filename}) 406 | 407 | 408 | def pip_faster(venv_path, pip_command, install, bootstrap_deps): 409 | """install and run pip-faster""" 410 | # activate the virtualenv 411 | execfile_(venv_executable(venv_path, 'activate_this.py')) 412 | 413 | # disable a useless warning 414 | # FIXME: ensure a "true SSLContext" is available 415 | from os import environ 416 | environ['PIP_DISABLE_PIP_VERSION_CHECK'] = '1' 417 | 418 | # we always have to run the bootstrap, because the presense of an 419 | # executable doesn't imply the right version. pip is able to validate the 420 | # version in the fastpath case quickly anyway. 421 | run(('pip', 'install') + bootstrap_deps) 422 | 423 | run(pip_command + install) 424 | 425 | 426 | def raise_on_failure(mainfunc): 427 | """raise if and only if mainfunc fails""" 428 | try: 429 | errors = mainfunc() 430 | if errors: 431 | exit(errors) 432 | except CalledProcessError as error: 433 | exit(error.returncode) 434 | except SystemExit as error: 435 | if error.code: 436 | raise 437 | except KeyboardInterrupt: # I don't plan to test-cover this. :pragma:nocover: 438 | exit(1) 439 | 440 | 441 | def main(): 442 | from sys import argv 443 | args = tuple(argv[1:]) 444 | 445 | # process --help before we create any side-effects. 446 | options = parseargs(args) 447 | exec_scratch_virtualenv(args) 448 | return venv_update(**options) 449 | 450 | 451 | if __name__ == '__main__': 452 | exit(main()) 453 | -------------------------------------------------------------------------------- /config.template.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | email_address: 'example@example.com' 3 | use_keyring: False 4 | -------------------------------------------------------------------------------- /mealpy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundmok/mealpy/5d2eef36d1003b85d048ebab7c16d9987332571e/mealpy/__init__.py -------------------------------------------------------------------------------- /mealpy/__main__.py: -------------------------------------------------------------------------------- 1 | from mealpy.mealpy import cli 2 | 3 | cli(prog_name=__package__) # pylint: disable=unexpected-keyword-arg 4 | -------------------------------------------------------------------------------- /mealpy/config.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | from pathlib import Path 3 | from shutil import copyfileobj 4 | 5 | import strictyaml 6 | import xdg 7 | 8 | ROOT_DIR = Path(__file__).resolve().parent.parent 9 | CACHE_DIR = xdg.XDG_CACHE_HOME / 'mealpy' 10 | CONFIG_DIR = xdg.XDG_CONFIG_HOME / 'mealpy' 11 | 12 | 13 | def initialize_directories(): # pragma: no cover 14 | """Mkdir all directories mealpy uses.""" 15 | 16 | for i in (CACHE_DIR, CONFIG_DIR): 17 | i.mkdir(parents=True, exist_ok=True) 18 | 19 | 20 | def load_config_from_file(config_file: Path): # pragma: no cover 21 | schema = strictyaml.Map({ 22 | 'email_address': strictyaml.Email(), 23 | 'use_keyring': strictyaml.Bool(), 24 | }) 25 | 26 | return strictyaml.load(config_file.read_text(), schema).data 27 | 28 | 29 | @lru_cache(maxsize=1) 30 | def get_config(): 31 | initialize_directories() 32 | 33 | template_config_path = ROOT_DIR / 'config.template.yaml' 34 | assert template_config_path.exists() 35 | 36 | config_path = CONFIG_DIR / 'config.yaml' 37 | 38 | # Create config file if it doesn't already exist 39 | if not config_path.exists(): # pragma: no cover 40 | copyfileobj(template_config_path.open(), config_path.open('w')) 41 | exit( 42 | f'{config_path} has been created.\n' 43 | f'Please update the email_address field in {config_path} with your email address for MealPal.', 44 | ) 45 | 46 | config = load_config_from_file(template_config_path) 47 | config.update(load_config_from_file(config_path)) 48 | return config 49 | -------------------------------------------------------------------------------- /mealpy/mealpy.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import json 3 | import time 4 | from http.cookiejar import MozillaCookieJar 5 | 6 | import click 7 | import requests 8 | import xdg 9 | 10 | from mealpy import config 11 | 12 | 13 | BASE_DOMAIN = 'secure.mealpal.com' 14 | BASE_URL = f'https://{BASE_DOMAIN}' 15 | LOGIN_URL = f'{BASE_URL}/1/login' 16 | CITIES_URL = f'{BASE_URL}/1/functions/getCitiesWithNeighborhoods' 17 | MENU_URL = f'{BASE_URL}/api/v1/cities/{{}}/product_offerings/lunch/menu' 18 | RESERVATION_URL = f'{BASE_URL}/api/v2/reservations' 19 | KITCHEN_URL = f'{BASE_URL}/1/functions/checkKitchen3' 20 | 21 | HEADERS = { 22 | 'Host': BASE_DOMAIN, 23 | 'Origin': BASE_URL, 24 | 'Referer': f'{BASE_URL}/login', 25 | 'Content-Type': 'application/json', 26 | } 27 | 28 | COOKIES_FILENAME = 'cookies.txt' 29 | 30 | 31 | class MealPal: 32 | 33 | def __init__(self): 34 | self.session = requests.Session() 35 | self.session.headers.update(HEADERS) 36 | 37 | def login(self, user, password): 38 | data = { 39 | 'username': user, 40 | 'password': password, 41 | } 42 | request = self.session.post(LOGIN_URL, data=json.dumps(data)) 43 | 44 | request.raise_for_status() 45 | 46 | return request.status_code 47 | 48 | @staticmethod 49 | def get_cities(): 50 | response = requests.post(CITIES_URL) 51 | response.raise_for_status() 52 | 53 | result = response.json()['result'] 54 | 55 | return result 56 | 57 | @staticmethod 58 | def get_schedules(city_name): 59 | city_id = next((i['objectId'] for i in MealPal.get_cities() if i['name'] == city_name), None) 60 | request = requests.get(MENU_URL.format(city_id)) 61 | request.raise_for_status() 62 | return request.json()['schedules'] 63 | 64 | @staticmethod 65 | def get_schedule_by_restaurant_name(restaurant_name, city_name): 66 | restaurant = next( 67 | i 68 | for i in MealPal.get_schedules(city_name) 69 | if i['restaurant']['name'] == restaurant_name 70 | ) 71 | return restaurant 72 | 73 | @staticmethod 74 | def get_schedule_by_meal_name(meal_name, city_name): 75 | return next(i for i in MealPal.get_schedules(city_name) if i['meal']['name'] == meal_name) 76 | 77 | def reserve_meal( 78 | self, 79 | timing, 80 | city_name, 81 | restaurant_name=None, 82 | meal_name=None, 83 | cancel_current_meal=False, 84 | ): # pylint: disable=too-many-arguments 85 | assert restaurant_name or meal_name 86 | if cancel_current_meal: 87 | self.cancel_current_meal() 88 | 89 | if meal_name: 90 | schedule_id = MealPal.get_schedule_by_meal_name(meal_name, city_name)['id'] 91 | else: 92 | schedule_id = MealPal.get_schedule_by_restaurant_name(restaurant_name, city_name)['id'] 93 | 94 | reserve_data = { 95 | 'quantity': 1, 96 | 'schedule_id': schedule_id, 97 | 'pickup_time': timing, 98 | 'source': 'Web', 99 | } 100 | 101 | request = self.session.post(RESERVATION_URL, json=reserve_data) 102 | return request.status_code 103 | 104 | def get_current_meal(self): 105 | request = self.session.post(KITCHEN_URL) 106 | return request.json() 107 | 108 | def cancel_current_meal(self): 109 | raise NotImplementedError() 110 | 111 | 112 | def get_mealpal_credentials(): 113 | email = config.get_config()['email_address'] 114 | password = getpass.getpass('Enter password: ') 115 | return email, password 116 | 117 | 118 | def initialize_mealpal(): 119 | cookies_path = xdg.XDG_CACHE_HOME / 'mealpy' / COOKIES_FILENAME 120 | mealpal = MealPal() 121 | mealpal.session.cookies = MozillaCookieJar() 122 | 123 | if cookies_path.exists(): 124 | try: 125 | mealpal.session.cookies.load(cookies_path, ignore_expires=True, ignore_discard=True) 126 | except UnicodeDecodeError: 127 | pass 128 | else: 129 | # hacky way of validating cookies 130 | sleep_duration = 1 131 | for _ in range(5): 132 | try: 133 | MealPal.get_schedules('San Francisco') 134 | except requests.HTTPError: 135 | # Possible fluke, retry validation 136 | print(f'Login using cookies failed, retrying after {sleep_duration} second(s).') 137 | time.sleep(sleep_duration) 138 | sleep_duration *= 2 139 | else: 140 | print('Login using cookies successful!') 141 | return mealpal 142 | 143 | print('Existing cookies are invalid, please re-enter your login credentials.') 144 | 145 | while True: 146 | email, password = get_mealpal_credentials() 147 | 148 | try: 149 | mealpal.login(email, password) 150 | except requests.HTTPError: 151 | print('Invalid login credentials, please try again!') 152 | else: 153 | break 154 | 155 | # save latest cookies 156 | print(f'Login successful! Saving cookies as {cookies_path}.') 157 | mealpal.session.cookies.save(cookies_path, ignore_discard=True, ignore_expires=True) 158 | 159 | return mealpal 160 | 161 | 162 | @click.group() 163 | def cli(): # pragma: no cover 164 | pass 165 | 166 | 167 | # SCHEDULER = BlockingScheduler() 168 | # @SCHEDULER.scheduled_job('cron', hour=16, minute=59, second=58) 169 | def execute_reserve_meal(restaurant, reservation_time, city): 170 | mealpal = initialize_mealpal() 171 | 172 | while True: 173 | try: 174 | status_code = mealpal.reserve_meal( 175 | reservation_time, 176 | restaurant_name=restaurant, 177 | city_name=city, 178 | ) 179 | if status_code == 200: 180 | print('Reservation success!') 181 | # print('Leave this script running to reschedule again the next day!') 182 | break 183 | else: 184 | print('Reservation error, retrying!') 185 | except IndexError: 186 | print('Retrying...') 187 | time.sleep(0.05) 188 | 189 | # SCHEDULER.start() 190 | 191 | 192 | @cli.command('reserve', short_help='Reserve a meal on MealPal.') 193 | @click.argument('restaurant') 194 | @click.argument('reservation_time') 195 | @click.argument('city') 196 | def reserve(restaurant, reservation_time, city): # pragma: no cover 197 | execute_reserve_meal(restaurant, reservation_time, city) 198 | 199 | 200 | @cli.group(name='list') 201 | def cli_list(): # pragma: no cover 202 | pass 203 | 204 | 205 | @cli_list.command('cities', short_help='List available cities.') 206 | def cli_list_cities(): # pragma: no cover 207 | cities = [i['name'] for i in MealPal.get_cities()] 208 | print('\n'.join(cities)) 209 | 210 | 211 | @cli_list.command('restaurants', short_help='List available restaurants.') 212 | @click.argument('city') 213 | def cli_list_restaurants(city): # pragma: no cover 214 | restaurants = [i['restaurant']['name'] for i in MealPal.get_schedules(city)] 215 | print('\n'.join(restaurants)) 216 | 217 | 218 | @cli_list.command('meals', short_help='List meal choices.') 219 | @click.argument('city') 220 | def cli_list_meals(city): # pragma: no cover 221 | restaurants = [i['meal']['name'] for i in MealPal.get_schedules(city)] 222 | print('\n'.join(restaurants)) 223 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [REPORTS] 2 | output-format = colorized 3 | reports = no 4 | max-line-length=120 5 | 6 | [MESSAGES CONTROL] 7 | disable= 8 | missing-docstring, 9 | fixme 10 | 11 | [DESIGN] 12 | ignored-argument-names=^_.*|^mock_ 13 | -------------------------------------------------------------------------------- /requirements-bootstrap.txt: -------------------------------------------------------------------------------- 1 | pip==18.1.0 2 | pip-custom-platform==0.3.1 3 | pymonkey==0.2.2 4 | venv-update==3.2.0 5 | virtualenv==16.4.0 6 | wheel==0.33.0 7 | -------------------------------------------------------------------------------- /requirements-dev-minimal.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | flake8 3 | pre-commit 4 | pyfakefs 5 | pylint 6 | pytest 7 | pytest-antilru 8 | requirements-tools 9 | responses 10 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | aspy.yaml==1.2.0 2 | astroid==2.2.5 3 | atomicwrites==1.3.0 4 | attrs==19.1.0 5 | cfgv==1.6.0 6 | coverage==4.5.3 7 | entrypoints==0.3 8 | flake8==3.7.7 9 | identify==1.4.2 10 | importlib-metadata==0.9 11 | isort==4.3.18 12 | lazy-object-proxy==1.3.1 13 | mccabe==0.6.1 14 | more-itertools==7.0.0 15 | nodeenv==1.3.3 16 | pluggy==0.9.0 17 | pre-commit==1.15.2 18 | py==1.8.0 19 | pycodestyle==2.5.0 20 | pyfakefs==3.5.8 21 | pyflakes==2.1.1 22 | pylint==2.3.1 23 | pytest==4.4.1 24 | pytest-antilru==1.0.5 25 | PyYAML==5.1 26 | requirements-tools==1.2.1 27 | responses==0.10.6 28 | toml==0.10.0 29 | typed-ast==1.3.5 30 | virtualenv==16.5.0 31 | wrapt==1.11.1 32 | zipp==0.4.0 33 | -------------------------------------------------------------------------------- /requirements-minimal.txt: -------------------------------------------------------------------------------- 1 | apscheduler 2 | click 3 | requests 4 | strictyaml 5 | xdg 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | APScheduler==3.6.0 2 | certifi==2019.3.9 3 | chardet==3.0.4 4 | Click==7.0 5 | idna==2.8 6 | python-dateutil==2.8.0 7 | pytz==2019.1 8 | requests==2.21.0 9 | ruamel.yaml==0.15.94 10 | setuptools==41.0.1 11 | six==1.12.0 12 | strictyaml==1.0.1 13 | tzlocal==1.5.1 14 | urllib3==1.24.3 15 | xdg==4.0.0 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length=120 3 | 4 | [pycodestyle] 5 | max-line-length=120 6 | 7 | [coverage:run] 8 | branch = True 9 | source = 10 | mealpy 11 | tests 12 | omit = 13 | # Don't complain if non-runnable code isn't run 14 | */__main__.py 15 | 16 | [coverage:report] 17 | show_missing = True 18 | skip_covered = True 19 | exclude_lines = 20 | # Have to re-enable the standard pragma 21 | \#\s*pragma: no cover 22 | 23 | # Don't complain if tests don't hit defensive assertion code: 24 | ^\s*raise AssertionError\b 25 | ^\s*raise NotImplementedError\b 26 | ^\s*return NotImplemented\b 27 | ^\s*raise$ 28 | 29 | # Don't complain if non-runnable code isn't run: 30 | ^if __name__ == ['"]__main__['"]:$ 31 | 32 | [coverage:html] 33 | directory = coverage-html 34 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/edmundmok/mealpy/5d2eef36d1003b85d048ebab7c16d9987332571e/tests/__init__.py -------------------------------------------------------------------------------- /tests/config_test.py: -------------------------------------------------------------------------------- 1 | from textwrap import dedent 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from mealpy import config 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def mock_config_template(mock_fs): 11 | """Fixture to bypass pyfakefs and use real template file from repo.""" 12 | template_config_path = config.ROOT_DIR / 'config.template.yaml' 13 | mock_fs.add_real_file(template_config_path) 14 | yield template_config_path 15 | 16 | 17 | @pytest.fixture 18 | def mock_config(mock_fs): 19 | """Fixture to generate a user config file.""" 20 | config_path = config.CONFIG_DIR / 'config.yaml' 21 | 22 | mock_fs.create_file( 23 | config_path, 24 | contents=dedent('''\ 25 | --- 26 | email_address: 'test@test.com' 27 | use_keyring: False 28 | '''), 29 | ) 30 | yield config_path 31 | 32 | 33 | def test_get_config_config_not_exist(mock_config_template, mock_fs): 34 | """Test that config template is copied over to user config.""" 35 | with pytest.raises(SystemExit), \ 36 | mock.patch.object(config, 'copyfileobj') as copyfileobj: 37 | config.get_config() 38 | 39 | assert copyfileobj.called, 'Template should be copied to user config.' 40 | 41 | 42 | def test_get_config_override(mock_config_template, mock_config, mock_fs): 43 | """Test that user config values override default values.""" 44 | _config = config.get_config() 45 | 46 | assert _config['email_address'] == 'test@test.com' 47 | 48 | 49 | @pytest.mark.xfail( 50 | raises=config.strictyaml.YAMLValidationError, 51 | reason='User config values are not optionally merged with default, #23', 52 | ) 53 | def test_get_config_missing_values(mock_config_template, mock_config, mock_fs): # pragma: no cover 54 | """Test that config will default to values from template if user did not override.""" 55 | mock_config.write_text(dedent('''\ 56 | --- 57 | email_address: 'test@test.com' 58 | ''')) 59 | 60 | _config = config.get_config() 61 | 62 | assert _config['email_address'] == 'test@test.com', 'email_address should come from user config override.' 63 | assert not _config['use_keyring'], 'use_keyring should be default value from template.' 64 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import xdg 3 | from pyfakefs.fake_filesystem_unittest import Patcher 4 | 5 | from mealpy import config 6 | 7 | 8 | @pytest.fixture() 9 | def mock_fs(): 10 | """Mock filesystem calls with pyfakefs.""" 11 | 12 | # Ordering matters for reloading modules. Patch upstream dependencies first, otherwise downstream dependencies will 13 | # "cache" before monkey-patching occurs. i.e. config uses xdg, xdg needs to be reloaded first 14 | modules_to_reload = [ 15 | xdg, 16 | config, 17 | ] 18 | 19 | with Patcher(modules_to_reload=modules_to_reload) as patcher: 20 | yield patcher.fs 21 | -------------------------------------------------------------------------------- /tests/mealpy_test.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | from unittest import mock 3 | 4 | import pytest 5 | import requests 6 | import responses 7 | 8 | from mealpy import mealpy 9 | 10 | City = namedtuple('City', 'name objectId') 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def mock_responses(): 15 | with responses.RequestsMock() as _responses: 16 | yield _responses 17 | 18 | 19 | class TestCity: 20 | 21 | @staticmethod 22 | def test_get_cities(mock_responses): 23 | response = { 24 | 'result': [ 25 | { 26 | 'id': 'mock_id1', 27 | 'objectId': 'mock_objectId1', 28 | 'state': 'CA', 29 | 'name': 'San Francisco', 30 | 'city_code': 'SFO', 31 | 'latitude': 'mock_latitude', 32 | 'longitude': 'mock_longitude', 33 | 'timezone': -7, 34 | 'countryCode': 'usa', 35 | 'countryCodeAlphaTwo': 'us', 36 | 'defaultLocale': 'en-US', 37 | 'dinner': False, 38 | 'neighborhoods': [ 39 | { 40 | 'id': 'mock_fidi_id', 41 | 'name': 'Financial District', 42 | }, 43 | { 44 | 'id': 'mock_soma_id', 45 | 'name': 'SoMa', 46 | }, 47 | ], 48 | }, 49 | { 50 | 'id': 'mock_id2', 51 | 'objectId': 'mock_objectId2', 52 | 'state': 'WA', 53 | 'name': 'Seattle', 54 | 'city_code': 'SEA', 55 | 'latitude': 'mock_latitude', 56 | 'longitude': 'mock_longitude', 57 | 'timezone': -7, 58 | 'countryCode': 'usa', 59 | 'countryCodeAlphaTwo': 'us', 60 | 'defaultLocale': 'en-US', 61 | 'dinner': False, 62 | 'neighborhoods': [ 63 | { 64 | 'id': 'mock_belltown_id', 65 | 'name': 'Belltown', 66 | }, 67 | ], 68 | }, 69 | ], 70 | } 71 | 72 | mock_responses.add( 73 | responses.RequestsMock.POST, 74 | mealpy.CITIES_URL, 75 | json=response, 76 | ) 77 | 78 | cities = mealpy.MealPal.get_cities() 79 | city = [i for i in cities if i['name'] == 'San Francisco'][0] 80 | 81 | assert city.items() >= { 82 | 'id': 'mock_id1', 83 | 'state': 'CA', 84 | 'name': 'San Francisco', 85 | }.items() 86 | 87 | @staticmethod 88 | def test_get_cities_bad_response(mock_responses): 89 | mock_responses.add( 90 | responses.RequestsMock.POST, 91 | mealpy.CITIES_URL, 92 | status=400, 93 | ) 94 | 95 | with pytest.raises(requests.exceptions.HTTPError): 96 | mealpy.MealPal.get_cities() 97 | 98 | 99 | class TestLogin: 100 | 101 | @staticmethod 102 | def test_login(mock_responses): 103 | mock_responses.add( 104 | responses.RequestsMock.POST, 105 | mealpy.LOGIN_URL, 106 | status=200, 107 | json={ 108 | 'id': 'GUID', 109 | 'email': 'email', 110 | 'status': 3, 111 | 'firstName': 'first_name', 112 | 'lastName': 'last_name', 113 | 'sessionToken': 'r:GUID', 114 | 'city': { 115 | 'id': 'GUID', 116 | 'name': 'San Francisco', 117 | 'city_code': 'SFO', 118 | 'countryCode': 'usa', 119 | '__type': 'Pointer', 120 | 'className': 'City', 121 | 'objectId': 'GUID', 122 | }, 123 | }, 124 | ) 125 | 126 | mealpal = mealpy.MealPal() 127 | 128 | assert mealpal.login('username', 'password') == 200 129 | 130 | @staticmethod 131 | def test_login_fail(mock_responses): 132 | mock_responses.add( 133 | method=responses.RequestsMock.POST, 134 | url=mealpy.LOGIN_URL, 135 | status=404, 136 | json={ 137 | 'code': 101, 138 | 'error': 'An error occurred while blah blah, try agian.', 139 | }, 140 | ) 141 | 142 | mealpal = mealpy.MealPal() 143 | 144 | with pytest.raises(requests.HTTPError): 145 | mealpal.login('username', 'password') 146 | 147 | 148 | class TestSchedule: 149 | 150 | @staticmethod 151 | @pytest.fixture 152 | def mock_city(): 153 | yield City('mock_city', 'mock_city_object_id') 154 | 155 | @staticmethod 156 | @pytest.fixture 157 | def success_response(): 158 | """A complete response example for MENU_URL endpoint.""" 159 | yield { 160 | 'city': { 161 | 'id': 'GUID', 162 | 'name': 'San Francisco', 163 | 'state': 'CA', 164 | 'time_zone_name': 'America/Los_Angeles', 165 | }, 166 | 'generated_at': '2019-04-01T00:00:00Z', 167 | 'schedules': [{ 168 | 'id': 'GUID', 169 | 'priority': 9, 170 | 'is_featured': True, 171 | 'date': '20190401', 172 | 'meal': { 173 | 'id': 'GUID', 174 | 'name': 'Spam and Eggs', 175 | 'description': 'Soemthign sometlhing python', 176 | 'cuisine': 'asian', 177 | 'image': 'https://example.com/image.jpg', 178 | 'portion': 2, 179 | 'veg': False, 180 | }, 181 | 'restaurant': { 182 | 'id': 'GUID', 183 | 'name': 'RestaurantName', 184 | 'address': 'RestaurantAddress', 185 | 'state': 'CA', 186 | 'latitude': '111.111', 187 | 'longitude': '-111.111', 188 | 'neighborhood': { 189 | 'name': 'Financial District', 190 | 'id': 'GUID', 191 | }, 192 | 'city': { 193 | 'name': 'San Francisco', 194 | 'id': 'GUID', 195 | 'timezone_offset_hours': -7, 196 | }, 197 | 'open': '2019-04-01T00:00:00Z', 198 | 'close': '2019-04-01T00:00:00Z', 199 | 'mpn_open': '2019-04-01T00:00:00Z', 200 | 'mpn_close': '2019-04-01T00:00:00Z', 201 | }, 202 | }], 203 | } 204 | 205 | @staticmethod 206 | @pytest.fixture 207 | def menu_url_response(mock_responses, success_response, mock_city): 208 | mock_responses.add( 209 | responses.RequestsMock.GET, 210 | mealpy.MENU_URL.format(mock_city.objectId), 211 | status=200, 212 | json=success_response, 213 | ) 214 | 215 | yield mock_responses 216 | 217 | @staticmethod 218 | @pytest.fixture 219 | def mock_get_city(mock_responses, mock_city): 220 | mock_responses.add( 221 | method=responses.RequestsMock.POST, 222 | url=mealpy.CITIES_URL, 223 | json={ 224 | 'result': [{ 225 | 'id': 'mock_id1', 226 | 'objectId': mock_city.objectId, 227 | 'name': mock_city.name, 228 | }], 229 | }, 230 | ) 231 | yield 232 | 233 | @staticmethod 234 | @pytest.mark.usefixtures('mock_get_city', 'menu_url_response') 235 | def test_get_schedule_by_restaurant_name(mock_city): 236 | schedule = mealpy.MealPal.get_schedule_by_restaurant_name('RestaurantName', mock_city.name) 237 | 238 | meal = schedule['meal'] 239 | restaurant = schedule['restaurant'] 240 | 241 | assert meal.items() >= { 242 | 'id': 'GUID', 243 | 'name': 'Spam and Eggs', 244 | }.items() 245 | 246 | assert restaurant.items() >= { 247 | 'id': 'GUID', 248 | 'name': 'RestaurantName', 249 | 'address': 'RestaurantAddress', 250 | }.items() 251 | 252 | @staticmethod 253 | @pytest.mark.usefixtures('mock_get_city', 'menu_url_response') 254 | @pytest.mark.xfail( 255 | raises=StopIteration, 256 | reason='#24 Invalid restaurant input not handled', 257 | ) 258 | def test_get_schedule_by_restaurant_name_not_found(mock_city): 259 | mealpy.MealPal.get_schedule_by_restaurant_name('NotFound', mock_city.name) 260 | 261 | @staticmethod 262 | @pytest.mark.usefixtures('mock_get_city', 'menu_url_response') 263 | @pytest.mark.xfail( 264 | raises=StopIteration, 265 | reason='#24 Invalid meal name not handled', 266 | ) 267 | def test_get_schedule_by_meal_name_not_found(mock_city): 268 | mealpy.MealPal.get_schedule_by_meal_name('NotFound', mock_city.name) 269 | 270 | @staticmethod 271 | @pytest.mark.usefixtures('mock_get_city', 'menu_url_response') 272 | def test_get_schedule_by_meal_name(mock_city): 273 | schedule = mealpy.MealPal.get_schedule_by_meal_name('Spam and Eggs', mock_city.name) 274 | 275 | meal = schedule['meal'] 276 | restaurant = schedule['restaurant'] 277 | 278 | assert meal.items() >= { 279 | 'id': 'GUID', 280 | 'name': 'Spam and Eggs', 281 | }.items() 282 | 283 | assert restaurant.items() >= { 284 | 'id': 'GUID', 285 | 'name': 'RestaurantName', 286 | 'address': 'RestaurantAddress', 287 | }.items() 288 | 289 | @staticmethod 290 | @pytest.mark.usefixtures('mock_get_city') 291 | def test_get_schedules_fail(mock_responses, mock_city): 292 | mock_responses.add( 293 | method=responses.RequestsMock.GET, 294 | url=mealpy.MENU_URL.format(mock_city.objectId), 295 | status=400, 296 | ) 297 | 298 | with pytest.raises(requests.HTTPError): 299 | mealpy.MealPal.get_schedules(mock_city.name) 300 | 301 | 302 | class TestCurrentMeal: 303 | 304 | @staticmethod 305 | @pytest.fixture 306 | def current_meal(): 307 | yield { 308 | 'id': 'GUID', 309 | 'createdAt': '2019-03-20T02:53:28.908Z', 310 | 'date': 'March 20, 2019', 311 | 'pickupTime': '12:30-12:45', 312 | 'pickupTimeIso': ['12:30', '12:45'], 313 | 'googleCalendarLink': ( 314 | 'https://www.google.com/calendar/render?action=TEMPLATE&text=Pick Up Lunch from MealPal&' 315 | 'details=Pick up lunch from MealPal: MEALNAME from RESTAURANTNAME\nPickup instructions: BLAHBLAH&' 316 | 'location=ADDRESS, CITY, STATE&dates=20190320T193000Z/20190320T194500Z&sf=true&output=xml' 317 | ), 318 | 'mealpalNow': False, 319 | 'orderNumber': '1111', 320 | 'emojiWord': None, 321 | 'emojiCharacter': None, 322 | 'emojiUrl': None, 323 | 'meal': { 324 | 'id': 'GUID', 325 | 'image': 'https://example.com/image.jpg', 326 | 'description': 'spam, eggs, and bacon. Served on avocado toast. With no toast.', 327 | 'name': 'Spam Eggs', 328 | }, 329 | 'restaurant': { 330 | 'id': 'GUID', 331 | 'name': 'RESTURANTNAME', 332 | 'address': 'ADDRESS', 333 | 'city': { 334 | '__type': 'Object', 335 | 'className': 'cities', 336 | 'createdAt': '2016-06-22T14:33:23.000Z', 337 | 'latitude': '111.111', 338 | 'longitude': '-111.111', 339 | 'name': 'San Francisco', 340 | 'city_code': 'SFO', 341 | 'objectId': 'GUID', 342 | 'state': 'CA', 343 | 'timezone': -7, 344 | 'updatedAt': '2019-03-18T16:08:22.577Z', 345 | }, 346 | 'latitude': '111.1111', 347 | 'longitude': '-111.1111', 348 | 'lunchOpen': '11:30am', 349 | 'lunchClose': '2:30pm', 350 | 'pickupInstructions': 'BLAH BLAH', 351 | 'state': 'CA', 352 | 'timezoneOffset': -7, 353 | 'neighborhood': { 354 | 'id': 'GUID', 355 | 'name': 'SoMa', 356 | }, 357 | }, 358 | 'schedule': { 359 | '__type': 'Object', 360 | 'objectId': 'GUID', 361 | 'className': 'schedules', 362 | 'date': { 363 | '__type': 'Date', 364 | 'iso': '2019-03-20T00:00:00.000Z', 365 | }, 366 | }, 367 | } 368 | 369 | @staticmethod 370 | @pytest.fixture 371 | def success_response_no_reservation(): 372 | yield { 373 | 'result': { 374 | 'status': 'OPEN', 375 | 'kitchenMode': 'classic', 376 | 'time': '19:59', 377 | 'reserveUntil': '2019-03-20T10:30:00-07:00', 378 | 'cancelUntil': '2019-03-20T15:00:00-07:00', 379 | 'kitchenTimes': { 380 | 'openTime': '5pm', 381 | 'openTimeMilitary': 1700, 382 | 'openHourMilitary': 17, 383 | 'openMinutesMilitary': 0, 384 | 'openHour': '5', 385 | 'openMinutes': '00', 386 | 'openPeriod': 'pm', 387 | 'closeTime': '10:30am', 388 | 'closeTimeMilitary': 1030, 389 | 'closeHourMilitary': 10, 390 | 'closeMinutesMilitary': 30, 391 | 'closeHour': '10', 392 | 'closeMinutes': '30', 393 | 'closePeriod': 'am', 394 | 'lateCancelHour': 15, 395 | 'lateCancelMinutes': 0, 396 | }, 397 | 'today': { 398 | '__type': 'Date', 399 | 'iso': '2019-03-20T02:59:42.000Z', 400 | }, 401 | }, 402 | } 403 | 404 | @staticmethod 405 | @pytest.fixture 406 | def kitchen_url_response(mock_responses, success_response_no_reservation): 407 | mock_responses.add( 408 | responses.RequestsMock.POST, 409 | mealpy.KITCHEN_URL, 410 | status=200, 411 | json=success_response_no_reservation, 412 | ) 413 | 414 | yield mock_responses 415 | 416 | @staticmethod 417 | @pytest.fixture 418 | def kitchen_url_response_with_reservation(mock_responses, success_response_no_reservation, current_meal): 419 | success_response_no_reservation['reservation'] = current_meal 420 | 421 | mock_responses.add( 422 | responses.RequestsMock.POST, 423 | mealpy.KITCHEN_URL, 424 | status=200, 425 | json=success_response_no_reservation, 426 | ) 427 | 428 | yield mock_responses 429 | 430 | @staticmethod 431 | @pytest.mark.usefixtures('kitchen_url_response') 432 | def test_get_current_meal_no_meal(): 433 | mealpal = mealpy.MealPal() 434 | 435 | current_meal = mealpal.get_current_meal() 436 | 437 | assert 'reservation' not in current_meal 438 | 439 | @staticmethod 440 | @pytest.mark.usefixtures('kitchen_url_response_with_reservation') 441 | def test_get_current_meal(): 442 | mealpal = mealpy.MealPal() 443 | 444 | current_meal = mealpal.get_current_meal() 445 | 446 | assert current_meal['reservation'].keys() >= { 447 | 'id', 448 | 'pickupTime', 449 | 'orderNumber', 450 | 'meal', 451 | 'restaurant', 452 | 'schedule', 453 | } 454 | 455 | @staticmethod 456 | @pytest.mark.xfail(raises=NotImplementedError) 457 | def test_cancel_current_meal(): 458 | mealpal = mealpy.MealPal() 459 | mealpal.cancel_current_meal() 460 | 461 | 462 | class TestReserve: 463 | 464 | @staticmethod 465 | @pytest.fixture() 466 | def reserve_response(mock_responses): # pragma: no cover 467 | # Current unused 468 | response = { 469 | 'result': { 470 | 'date': 'March 20, 2019', 471 | 'user_id': 'GUID', 472 | 'google_calendar_link': ( 473 | 'https://www.google.com/calendar/render?' 474 | 'action=TEMPLATE&' 475 | 'text=Pick Up Lunch from MealPal&' 476 | 'details=Pick up lunch from MealPal: BLAH BLAH BLAH&' 477 | 'location=LOCATION&' 478 | 'dates=20190320T194500Z/20190320T200000Z&' 479 | 'sf=true&' 480 | 'output=xml' 481 | ), 482 | 'encoded_google_calendar_link': 'URI_ENCODED', 483 | 'schedule': { 484 | 'schedule_id': 'GUID', 485 | 'ordered_quantity': 1, 486 | 'late_canceled_quantity': 0, 487 | 'pickup_window_start': '2019-03-20T12:45:00-07:00', 488 | 'pickup_window_end': '2019-03-20T13:00:00-07:00', 489 | 'google_calendar_link': 'LOL_WHAT_THIS_IS_DUPLICATE', 490 | 'encoded_google_calendar_link': 'ENCODED_URI', 491 | 'order_number': '1111', 492 | 'mealpal_now': False, 493 | 'emoji_word': None, 494 | 'emoji_character': None, 495 | 'emoji_url': None, 496 | 'reserve_until': '2019-03-20T10:30:00-07:00', 497 | 'cancel_until': '2019-03-20T15:00:00-07:00', 498 | 'meal': { 499 | 'name': 'MEAL_NAME', 500 | 'image_url': 'https://example.com/image.jpg', 501 | 'ingredients': 'INGREDIENT_DESCRIPTION', 502 | }, 503 | 'restaurant': { 504 | 'lunch_open_at': '2019-03-20T11:30:00-07:00', 505 | 'lunch_close_at': '2019-03-20T14:30:00-07:00', 506 | 'name': 'RESTAURANT_NAME', 507 | 'address': 'ADDRESS', 508 | 'latitude': '111.111', 509 | 'longitude': '-111.111', 510 | 'pickup_strategy': 'qr_codes', 511 | 'pickup_strategy_set': 'online', 512 | 'pickup_instructions': 'INSTRUCTIONS', 513 | 'city_name': 'San Francisco', 514 | 'city_state': 'CA', 515 | }, 516 | }, 517 | }, 518 | } 519 | 520 | mock_responses.add( 521 | responses.RequestsMock.POST, 522 | mealpy.KITCHEN_URL, 523 | status=200, 524 | json=response, 525 | ) 526 | yield mock_responses 527 | 528 | @staticmethod 529 | @pytest.fixture() 530 | def reserve_response_failed(mock_responses): # pragma: no cover 531 | # Current unused 532 | response = {'error': 'ERROR_RESERVATION_LIMIT'} 533 | mock_responses.add( 534 | responses.RequestsMock.POST, 535 | mealpy.KITCHEN_URL, 536 | status=400, 537 | json=response, 538 | ) 539 | yield mock_responses 540 | 541 | @staticmethod 542 | def test_reserve_meal_by_meal_name(): 543 | mealpal = mealpy.MealPal() 544 | 545 | schedule_id = 1 546 | timing = 'mock_timing' 547 | with mock.patch.object( 548 | mealpy.MealPal, 549 | 'get_schedule_by_meal_name', 550 | return_value={'id': schedule_id}, 551 | ) as mock_get_schedule_by_meal, \ 552 | mock.patch.object(mealpal, 'session') as mock_requests: 553 | mealpal.reserve_meal( 554 | timing, 555 | 'mock_city', 556 | meal_name='meal_name', 557 | ) 558 | 559 | assert mock_get_schedule_by_meal.called 560 | assert mock_requests.post.called_with( 561 | mealpy.RESERVATION_URL, 562 | { 563 | 'quantity': 1, 564 | 'schedule_id': schedule_id, 565 | 'pickup_time': timing, 566 | 'source': 'Web', 567 | }, 568 | ) 569 | 570 | @staticmethod 571 | def test_reserve_meal_by_restaurant_name(): 572 | mealpal = mealpy.MealPal() 573 | 574 | schedule_id = 1 575 | timing = 'mock_timing' 576 | with mock.patch.object( 577 | mealpy.MealPal, 578 | 'get_schedule_by_restaurant_name', 579 | return_value={'id': schedule_id}, 580 | ) as mock_get_schedule_by_restaurant, \ 581 | mock.patch.object(mealpal, 'session') as mock_requests: 582 | mealpal.reserve_meal( 583 | timing, 584 | 'mock_city', 585 | restaurant_name='restaurant_name', 586 | ) 587 | 588 | assert mock_get_schedule_by_restaurant.called 589 | assert mock_requests.post.called_with( 590 | mealpy.RESERVATION_URL, 591 | { 592 | 'quantity': 1, 593 | 'schedule_id': schedule_id, 594 | 'pickup_time': timing, 595 | 'source': 'Web', 596 | }, 597 | ) 598 | 599 | @staticmethod 600 | def test_reserve_meal_missing_params(): 601 | """Need to set restaurant_name or meal_name.""" 602 | mealpal = mealpy.MealPal() 603 | with pytest.raises(AssertionError): 604 | mealpal.reserve_meal(mock.sentinel.timing, mock.sentinel.city) 605 | 606 | @staticmethod 607 | @pytest.mark.xfail(raises=NotImplementedError) 608 | def test_reserve_meal_cancel_meal(): 609 | """Test that meal can be canceled before reserving. 610 | 611 | This test is a little redundant atm. But it'll probably make more sense if cancellation is moved to an cli arg. 612 | At least this gives test coverage. 613 | """ 614 | mealpal = mealpy.MealPal() 615 | 616 | mealpal.reserve_meal( 617 | 'mock_timing', 618 | 'mock_city', 619 | restaurant_name='restaurant_name', 620 | cancel_current_meal=True, 621 | ) 622 | --------------------------------------------------------------------------------