From: Shane Jaroch Date: Thu, 28 Jul 2022 09:01:05 +0000 (-0400) Subject: general cleanup, add calculators (#13) X-Git-Tag: v0.2.6~2 X-Git-Url: https://git.nutra.tk/v1?a=commitdiff_plain;h=f93e1095f1adfacf5428b91eef60359dddd1514d;p=nutratech%2Fcli.git general cleanup, add calculators (#13) --- diff --git a/.editorconfig b/.editorconfig index 149ee88..11b1d23 100644 --- a/.editorconfig +++ b/.editorconfig @@ -25,7 +25,7 @@ max_line_length = 120 [*.md] -max_line_length = 90 +max_line_length = 85 trim_trailing_whitespace = false diff --git a/.github/workflows/coveralls.yml b/.github/workflows/coveralls.yml deleted file mode 100644 index 10deae0..0000000 --- a/.github/workflows/coveralls.yml +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: coveralls - -"on": - push: {} - -jobs: - cov-submit: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Reload Cache / pip - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install requirements - run: | - pip install coveralls==3.3.1 - pip install -r requirements.txt - pip install -r requirements-test.txt - - # TODO: Tests for: python-argcomplete (tab-completion) - - name: Test - run: | - export NUTRA_HOME=$(pwd)/tests/.nutra.test - make _test - - - name: Submit coverage report / coveralls - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: coveralls --service=github diff --git a/.github/workflows/test-linux.yml b/.github/workflows/install-linux.yml similarity index 73% rename from .github/workflows/test-linux.yml rename to .github/workflows/install-linux.yml index 7018608..40a08a4 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/install-linux.yml @@ -1,5 +1,5 @@ --- -name: test-linux +name: install-linux "on": push: {} @@ -10,7 +10,7 @@ jobs: strategy: matrix: - python-version: ["3.4.10", "3.6", "3.8", "3.10"] + python-version: ["3.4", "3.6", "3.8", "3.10"] steps: - name: Checkout @@ -39,10 +39,5 @@ jobs: - name: Basic Tests / CLI / Integration run: | n -v - nutra -d init -y - nutra --no-pager nt - nutra --no-pager sort -c 789 - nutra --no-pager search ultraviolet mushrooms - nutra --no-pager anl 9050 + nutra -d recipe init nutra --no-pager recipe - nutra day tests/resources/day/human-test.csv diff --git a/.github/workflows/test-win32.yml b/.github/workflows/test-win32.yml deleted file mode 100644 index 0e50030..0000000 --- a/.github/workflows/test-win32.yml +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: test-win32 - -"on": - push: {} - -jobs: - windows-latest: - runs-on: windows-latest - - steps: - - name: Configure Line Endings / git / LF - run: | - git config --global core.autocrlf input - git config --global core.eol lf - - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Reload Cache / pip - uses: actions/cache@v3 - with: - path: ~\AppData\Local\pip\Cache - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install - run: make install - - - name: Basic Tests / CLI / Integration - run: | - n -v - nutra -d init -y - nutra --no-pager nt - nutra --no-pager sort -c 789 - nutra --no-pager search ultraviolet mushrooms - nutra --no-pager anl 9050 - nutra --no-pager recipe - nutra day tests/resources/day/human-test.csv diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ebac87d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +--- +name: test + +"on": + push: {} + +jobs: + test: + runs-on: [self-hosted, dev-east] + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + submodules: recursive + + - name: Cloc + run: make extras/cloc + + - name: Install requirements + run: | + /usr/bin/python3 -m pip install -r requirements.txt + /usr/bin/python3 -m pip install -r requirements-test.txt + + - name: Test + run: PATH=~/.local/bin:$PATH make _test + + - name: Submit coverage report / coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: PATH=~/.local/bin:$PATH coveralls --service=github diff --git a/.pylintrc b/.pylintrc index e7345a8..bb0f16a 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,6 +1,6 @@ [MASTER] -fail-under=9.5 +fail-under=9.75 [MESSAGES CONTROL] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5482a8c..892dea3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,6 +1,6 @@ -*********** - ChangeLog -*********** +************ + Change Log +************ All notable changes to this project will be documented in this file. @@ -15,7 +15,22 @@ and this project adheres to `Semantic Versioning >~.bashrc``. +Only run this once. + Plugin Development -================== +####################################################### + +You can develop plugins (or data modifications sets) that +are imported and built on the base (or core) installation. + + +Supporting Old Versions of Python +####################################################### + +The old requirements can still be tested on modern interpreters. +Simply install them with this (inside your ``venv`` environment). + +:: + + pip install -r requirements-old.txt + +This won't guarantee compatibility for every version, but it will help. +We provide a wide range. The oldest version of ``tabulate`` is from 2013. + +To use an old interpreter (Python 3.4 does not have the ``typing`` module! +Only ``collections.abc``.) you may need to use +a virtual machine or install old SSL libraries or enter a similar messy state. +My preference is for VirtualBox images, where +I manually test Windows XP & Ubuntu 14.04. -We're looking to start developing plugins or data modifications sets that -can be imported and built on the base installation, which remains pure. Notes -===== +####################################################### On Windows you should check the box during the Python installer to include ``Scripts`` directory in your ``$PATH``. This can be done manually after installation too. +Windows users may also have differing results if they install for all users +(as an administrator) vs. installing just for themselves. It may change the +location of installed scripts, and affect the ``$PATH`` variable being +correctly populated for prior installs. + Linux may need to install ``python-dev`` package to build ``python-Levenshtein``. +I am currently debating making this an optional dependency to avoid +confusing install failures for people without ``gcc`` or ``python3-dev``. -Windows users may not be able to install ``python-Levenshtein``. +I'm also currently working on doing phased installs of dependencies based on +the host Python version, since some of the old versions of pip have trouble +finding something that works, and again, spit out confusing errors. -Mac and Linux developers will do well to install ``direnv``. +Windows users may not be able to install ``python-Levenshtein``. Main program works 100%, but ``test`` and ``lint`` may break on older operating systems (Ubuntu 14.04, Windows XP). + Install PyPi release (from pip) -=============================== +####################################################### .. code-block:: bash - pip install nutra + pip install -U nutra (**Specify:** flag ``-U`` to upgrade, or ``--pre`` for development releases) + Using the source code directly -============================== +####################################################### Clone down, initialize ``nt-sqlite`` submodule, and install requirements: .. code-block:: bash @@ -106,7 +165,7 @@ Clone down, initialize ``nt-sqlite`` submodule, and install requirements: ./nutra -h -Initialize the DBs (nt and usda). +Initialize the DBs (``nt`` and ``usda``). .. code-block:: bash @@ -115,13 +174,16 @@ Initialize the DBs (nt and usda). # Or install and run as package script make install - nutra init + n init If installed (or inside ``cli``) folder, the program can also run -with ``python -m ntclient`` +with ``python -m ntclient``. + +You may need to set the ``PY_SYS_INTERPRETER`` value for the ``Makefile`` +if trying to install other than with ``/usr/bin/python3``. -Building the PyPi release -######################### +Building the PyPi release (sdist) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: bash @@ -129,36 +191,40 @@ Building the PyPi release make build # python3 setup.py --quiet sdist twine upload dist/nutra-X.X.X.tar.gz + Linting & Tests -=============== +####################################################### -Install the dependencies (``make deps``) and then: +Install the dependencies (``make deps``). Now you can lint & test. .. code-block:: bash # source .venv/bin/activate # uncomment if NOT using direnv make format lint test + ArgComplete (tab completion / autocomplete) -=========================================== +####################################################### + +The ``argcomplete`` package will be installed alongside. -After installing nutra, argcomplete package should also be installed. Linux, macOS, and Linux Subsystem for Windows -############################################# +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Simply run the following out of a ``bash`` shell. Check their page for more specifics on using other shells, e.g. ``zsh``, ``fish``, or ``tsh``. .. code-block:: bash - activate-global-python-argcomplete + activate-global-python-argcomplete --user -Then you can press tab to fill in or complete subcommands +Then you can press tab to fill in or complete sub-commands and to list argument flags. + Windows (Git Bash) -################## +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This can work with git bash too. I followed the instructions on their README. @@ -183,49 +249,23 @@ And my ``~/.bashrc`` file looks like this. **NOTE:** This is a work in progress, we are adding more autocomplete functions. + Currently Supported Data -======================== +####################################################### **USDA Stock database** - Standard reference database (SR28) `[7794 foods]` - **Relative USDA Extensions** - Flavonoid, Isoflavonoids, and Proanthocyanidins `[1352 foods]` + Usage -===== +####################################################### Requires internet connection to download initial datasets. Run ``nutra init`` for this step. -Run the ``nutra`` script to output usage. - -Usage: ``nutra [options] `` - - -Commands -######## - -:: - - usage: nutra [-h] [-v] [-d] [--no-pager] - {init,nt,search,sort,anl,day,recipe} ... - - optional arguments: - -h, --help show this help message and exit - -v, --version show program's version number and exit - -d, --debug enable detailed error messages - --no-pager disable paging (print full output) - - nutra subcommands: - {init,nt,search,sort,anl,day,recipe} - init setup profiles, USDA and NT database - nt list out nutrients and their info - search search foods by name, list overview info - sort sort foods by nutrient ID - anl analyze food(s) - day analyze a DAY.csv file, RDAs optional - recipe list and analyze recipes +Run the ``n`` script to output usage. diff --git a/ntclient/LICENSE b/ntclient/LICENSE deleted file mode 100644 index f288702..0000000 --- a/ntclient/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/ntclient/__init__.py b/ntclient/__init__.py index fb9f38f..7407dfc 100755 --- a/ntclient/__init__.py +++ b/ntclient/__init__.py @@ -1,41 +1,33 @@ # -*- coding: utf-8 -*- """ +Package info, database targets, paging/debug flags, PROJECT_ROOT, + and other configurations. + Created on Fri Jan 31 16:01:31 2020 @author: shane - -This file is part of nutra, a nutrient analysis program. - https://github.com/nutratech/cli - https://pypi.org/project/nutra/ - -nutra is an extensible nutrient analysis and composition application. -Copyright (C) 2018-2022 Shane Jaroch - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . """ - import argparse import os import platform import shutil import sys +from enum import Enum + +try: + from colorama import Fore, Style + from colorama import init as colorama_init + + COLORAMA_CAPABLE = True + colorama_init() +except ImportError: + COLORAMA_CAPABLE = False from ntclient.ntsqlite.sql import NT_DB_NAME # Package info __title__ = "nutra" -__version__ = "0.2.5" +__version__ = "0.2.6.dev5" __author__ = "Shane Jaroch" __email__ = "chown_tee@proton.me" __license__ = "GPL v3" @@ -48,27 +40,27 @@ __db_target_usda__ = "0.0.8" USDA_XZ_SHA256 = "25dba8428ced42d646bec704981d3a95dc7943240254e884aad37d59eee9616a" # Global variables -ROOT_DIR = os.path.abspath(os.path.dirname(__file__)) +PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) NUTRA_HOME = os.getenv("NUTRA_HOME", os.path.join(os.path.expanduser("~"), ".nutra")) USDA_DB_NAME = "usda.sqlite" # NOTE: NT_DB_NAME = "nt.sqlite3" is defined in ntclient.ntsqlite.sql -DEBUG = False -PAGING = True -NTSQLITE_BUILDPATH = os.path.join(ROOT_DIR, "ntsqlite", "sql", NT_DB_NAME) +NTSQLITE_BUILDPATH = os.path.join(PROJECT_ROOT, "ntsqlite", "sql", NT_DB_NAME) NTSQLITE_DESTINATION = os.path.join(NUTRA_HOME, NT_DB_NAME) # Check Python version -PY_MIN_VER = (3, 4, 0) +PY_MIN_VER = (3, 4, 3) PY_SYS_VER = sys.version_info[0:3] PY_MIN_STR = ".".join(str(x) for x in PY_MIN_VER) PY_SYS_STR = ".".join(str(x) for x in PY_SYS_VER) if PY_SYS_VER < PY_MIN_VER: - print("ERROR: %s requires Python %s or later to run" % (__title__, PY_MIN_STR)) - print("HINT: You're running Python %s" % PY_SYS_STR) - sys.exit(1) + # TODO: make this testable with: `class CliConfig`? + raise RuntimeError( + "ERROR: %s requires Python %s or later to run" % (__title__, PY_MIN_STR), + "HINT: You're running Python %s" % PY_SYS_STR, + ) -# Buffer truncation +# Console size, don't print more than it BUFFER_WD = shutil.get_terminal_size()[0] BUFFER_HT = shutil.get_terminal_size()[1] @@ -86,31 +78,230 @@ DEFAULT_SEARCH_H_BUFFER = ( ) -# NOTE: wip -# class CLIConfig: -# def __init__(self): -# prop1 = True -# usda_sqlite -# nt_sqlitedriver +################################################################################ +# CLI config class (settings & preferences / defaults) +################################################################################ +class RdaColors(Enum): + """ + Stores values for report colors. + Default values: + Acceptable =Cyan + Overage =Magenta (Dim) + Low =Yellow + Critically Low =Red (Dim) + TODO: make configurable in SQLite or prefs.json + """ + + THRESH_WARN = 0.7 + THRESH_CRIT = 0.4 + THRESH_OVER = 1.9 + + if COLORAMA_CAPABLE: + COLOR_WARN = Fore.YELLOW + COLOR_CRIT = Style.DIM + Fore.RED + COLOR_OVER = Style.DIM + Fore.MAGENTA + + COLOR_DEFAULT = Fore.CYAN + + COLOR_RESET_ALL = Style.RESET_ALL + + # Used in macro bars + COLOR_YELLOW = Fore.YELLOW + COLOR_BLUE = Fore.BLUE + COLOR_RED = Fore.RED + else: + COLOR_WARN = str() # type: ignore + COLOR_CRIT = str() # type: ignore + COLOR_OVER = str() # type: ignore + + COLOR_DEFAULT = str() # type: ignore + + COLOR_RESET_ALL = str() # type: ignore + + COLOR_YELLOW = str() # type: ignore + COLOR_BLUE = str() # type: ignore + COLOR_RED = str() # type: ignore + + +class _CliConfig: + """Mutable global store for configuration values""" + + def __init__(self, debug: bool = False, paging: bool = True) -> None: + self.debug = debug + self.paging = paging + + # TODO: respect a prefs.json, or similar config file. + self.thresh_warn = RdaColors.THRESH_WARN.value + self.thresh_crit = RdaColors.THRESH_CRIT.value + self.thresh_over = RdaColors.THRESH_OVER.value + + self.color_warn = RdaColors.COLOR_WARN.value + self.color_crit = RdaColors.COLOR_CRIT.value + self.color_over = RdaColors.COLOR_OVER.value + self.color_default = RdaColors.COLOR_DEFAULT.value + + self.color_reset_all = RdaColors.COLOR_RESET_ALL.value + self.color_yellow = RdaColors.COLOR_YELLOW.value + self.color_red = RdaColors.COLOR_RED.value + self.color_blue = RdaColors.COLOR_BLUE.value + + def set_flags(self, args: argparse.Namespace) -> None: + """ + Sets flags: + {DEBUG, PAGING} + from main (after arg parse). Accessible throughout package. + Must be re-imported globally. + """ + + self.debug = args.debug + self.paging = not args.no_pager + + if self.debug: + print("Console size: %sh x %sw" % (BUFFER_HT, BUFFER_WD)) + + +# Create the shared instance object +CLI_CONFIG = _CliConfig() # TODO: # Nested nutrient tree, like: -# http://www.whfoods.com/genpage.php?tname=nutrientprofile&dbid=132 +# http://www.whfoods.com/genpage.php?tname=nutrientprofile&dbid=132 # Attempt to record errors in failed try/catch block (bottom of __main__.py) # Make use of argcomplete.warn(msg) ? -def set_flags(args: argparse.Namespace) -> None: +################################################################################ +# Validation Enums +################################################################################ +class Gender(Enum): + """ + A validator and Enum class for gender inputs; used in several calculations. + @note: floating point -1 to 1, or 0 to 1... for non-binary? + """ + + MALE = "m" + FEMALE = "f" + + +class ActivityFactor(Enum): + """ + Used in BMR calculations. + Different activity levels: {0.200, 0.375, 0.550, 0.725, 0.900} + + Activity Factor\n + ------------------------\n + 0.200 = sedentary (little or no exercise) + + 0.375 = lightly active + (light exercise/sports 1-3 days/week, approx. 590 Cal/day) + + 0.550 = moderately active + (moderate exercise/sports 3-5 days/week, approx. 870 Cal/day) + + 0.725 = very active + (hard exercise/sports 6-7 days a week, approx. 1150 Cal/day) + + 0.900 = extremely active + (very hard exercise/sports and physical job, approx. 1580 Cal/day) + + @todo: Verify the accuracy of these "names". Access by index? + """ + + SEDENTARY = {1: 0.2} + MILDLY_ACTIVE = {2: 0.375} + ACTIVE = {3: 0.55} + HIGHLY_ACTIVE = {4: 0.725} + INTENSELY_ACTIVE = {5: 0.9} + + +def activity_factor_from_index(activity_factor: int) -> float: """ - Sets - DEBUG flag - PAGING flag - from main (after arg parse). Accessible throughout package + Gets ActivityFactor Enum by float value if it exists, else raise ValueError. + Basically just verifies the float is among the allowed values, and re-returns it. """ - global DEBUG, PAGING # pylint: disable=global-statement - DEBUG = args.debug - PAGING = not args.no_pager + for enum_entry in ActivityFactor: + if activity_factor in enum_entry.value: + return float(enum_entry.value[activity_factor]) + # TODO: custom exception. And handle in main file? + raise ValueError("No such ActivityFactor for value: %s" % activity_factor) + + +################################################################################ +# Nutrient IDs +################################################################################ +NUTR_ID_KCAL = 208 + +NUTR_ID_PROTEIN = 203 + +NUTR_ID_CARBS = 205 +NUTR_ID_SUGAR = 269 +NUTR_ID_FIBER = 291 + +NUTR_ID_FAT_TOT = 204 +NUTR_ID_FAT_SAT = 606 +NUTR_ID_FAT_MONO = 645 +NUTR_ID_FAT_POLY = 646 + +NUTR_IDS_FLAVONES = [ + 710, + 711, + 712, + 713, + 714, + 715, + 716, + 734, + 735, + 736, + 737, + 738, + 731, + 740, + 741, + 742, + 743, + 745, + 749, + 750, + 751, + 752, + 753, + 755, + 756, + 758, + 759, + 762, + 770, + 773, + 785, + 786, + 788, + 789, + 791, + 792, + 793, + 794, +] - if DEBUG: - print("Console size: %sh x %sw" % (BUFFER_HT, BUFFER_WD)) +NUTR_IDS_AMINOS = [ + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 516, + 517, + 518, + 521, +] diff --git a/ntclient/__main__.py b/ntclient/__main__.py index 9ea0794..6f5494f 100644 --- a/ntclient/__main__.py +++ b/ntclient/__main__.py @@ -1,52 +1,32 @@ # -*- coding: utf-8 -*- """ +Main module which is called by scripts. +Top-level argument parsing logic; error handling. + Created on Fri Jan 31 16:02:19 2020 @author: shane - -This file is part of nutra, a nutrient analysis program. - https://github.com/nutratech/cli - https://pypi.org/project/nutra/ - -nutra is an extensible nutrient analysis and composition application. -Copyright (C) 2018-2022 Shane Jaroch - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . """ import argparse -import sys import time from urllib.error import HTTPError, URLError import argcomplete -from colorama import init as colorama_init from ntclient import ( + CLI_CONFIG, __db_target_nt__, __db_target_usda__, + __email__, __title__, + __url__, __version__, - set_flags, ) from ntclient.argparser import build_subcommands -from ntclient.persistence import persistence_init from ntclient.utils.exceptions import SqlException -colorama_init() - -def build_argparser() -> argparse.ArgumentParser: +def build_arg_parser() -> argparse.ArgumentParser: """Adds all subparsers and parsing logic""" arg_parser = argparse.ArgumentParser(prog=__title__) @@ -80,7 +60,7 @@ def main(args: list = None) -> int: """ start_time = time.time() - arg_parser = build_argparser() + arg_parser = build_arg_parser() argcomplete.autocomplete(arg_parser) def parse_args() -> argparse.Namespace: @@ -92,9 +72,11 @@ def main(args: list = None) -> int: def func(parser: argparse.Namespace) -> tuple: """Executes a function for a given argument call to the parser""" if hasattr(parser, "func"): - # More than an empty command, so initialize the storage folder - persistence_init() + # Print help for nested commands + if parser.func.__name__ == "print_help": + return 0, parser.func() + # Collect non-default args args_dict = dict(vars(parser)) for expected_arg in ["func", "debug", "no_pager"]: args_dict.pop(expected_arg) @@ -111,40 +93,35 @@ def main(args: list = None) -> int: # Build the parser, set flags _parser = parse_args() - set_flags(_parser) - from ntclient import DEBUG # pylint: disable=import-outside-toplevel + CLI_CONFIG.set_flags(_parser) - # TODO: bug reporting? # Try to run the function exit_code = 1 try: exit_code, *_results = func(_parser) - return exit_code except SqlException as sql_exception: print("Issue with an sqlite database: " + repr(sql_exception)) - if DEBUG: + if CLI_CONFIG.debug: raise except HTTPError as http_error: err_msg = "{0}: {1}".format(http_error.code, repr(http_error)) print("Server response error, try again: " + err_msg) - if DEBUG: + if CLI_CONFIG.debug: raise except URLError as url_error: print("Connection error, check your internet: " + repr(url_error.reason)) - if DEBUG: + if CLI_CONFIG.debug: raise except Exception as exception: # pylint: disable=broad-except - print("There was an unforeseen error: " + repr(exception)) - if DEBUG: + print("Unforeseen error, run with -d for more info: " + repr(exception)) + print("You can open an issue here: %s" % __url__) + print("Or send me an email with the debug output: %s" % __email__) + if CLI_CONFIG.debug: raise finally: - if DEBUG: - exc_time = time.time() - start_time # type: ignore + if CLI_CONFIG.debug: + exc_time = time.time() - start_time print("\nExecuted in: %s ms" % round(exc_time * 1000, 1)) print("Exit code: %s" % exit_code) return exit_code - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/ntclient/argparser/__init__.py b/ntclient/argparser/__init__.py index 10ab6d5..f410178 100644 --- a/ntclient/argparser/__init__.py +++ b/ntclient/argparser/__init__.py @@ -1,12 +1,20 @@ -"""Main module for things related to argparse""" +# -*- coding: utf-8 -*- +""" +Created on Tue May 25 13:08:55 2021 -0400 + +@author: shane +Main module for things related to argparse +""" import argparse from ntclient.argparser import funcs as parser_funcs from ntclient.argparser import types +# noinspection PyUnresolvedReferences,PyProtectedMember def build_subcommands(subparsers: argparse._SubParsersAction) -> None: """Attaches subcommands to main parser""" + build_init_subcommand(subparsers) build_nt_subcommand(subparsers) build_search_subcommand(subparsers) @@ -14,13 +22,16 @@ def build_subcommands(subparsers: argparse._SubParsersAction) -> None: build_analyze_subcommand(subparsers) build_day_subcommand(subparsers) build_recipe_subcommand(subparsers) + build_calc_subcommand(subparsers) ################################################################################ # Methods to build subparsers, and attach back to main arg_parser ################################################################################ +# noinspection PyUnresolvedReferences,PyProtectedMember def build_init_subcommand(subparsers: argparse._SubParsersAction) -> None: """Self running init command""" + init_parser = subparsers.add_parser( "init", help="setup profiles, USDA and NT database" ) @@ -33,16 +44,20 @@ def build_init_subcommand(subparsers: argparse._SubParsersAction) -> None: init_parser.set_defaults(func=parser_funcs.init) +# noinspection PyUnresolvedReferences,PyProtectedMember def build_nt_subcommand(subparsers: argparse._SubParsersAction) -> None: """Lists out nutrients details with computed totals and averages""" + nutrient_parser = subparsers.add_parser( "nt", help="list out nutrients and their info" ) nutrient_parser.set_defaults(func=parser_funcs.nutrients) +# noinspection PyUnresolvedReferences,PyProtectedMember def build_search_subcommand(subparsers: argparse._SubParsersAction) -> None: """Search: terms [terms ... ]""" + search_parser = subparsers.add_parser( "search", help="search foods by name, list overview info" ) @@ -67,9 +82,12 @@ def build_search_subcommand(subparsers: argparse._SubParsersAction) -> None: search_parser.set_defaults(func=parser_funcs.search) +# noinspection PyUnresolvedReferences,PyProtectedMember def build_sort_subcommand(subparsers: argparse._SubParsersAction) -> None: """Sort foods ranked by nutr_id, per 100g or 200kcal""" + sort_parser = subparsers.add_parser("sort", help="sort foods by nutrient ID") + sort_parser.add_argument( "-c", dest="kcal", @@ -87,9 +105,12 @@ def build_sort_subcommand(subparsers: argparse._SubParsersAction) -> None: sort_parser.set_defaults(func=parser_funcs.sort) +# noinspection PyUnresolvedReferences,PyProtectedMember def build_analyze_subcommand(subparsers: argparse._SubParsersAction) -> None: """Analyzes (foods only for now)""" + analyze_parser = subparsers.add_parser("anl", help="analyze food(s)") + analyze_parser.add_argument( "-g", dest="grams", @@ -100,8 +121,10 @@ def build_analyze_subcommand(subparsers: argparse._SubParsersAction) -> None: analyze_parser.set_defaults(func=parser_funcs.analyze) +# noinspection PyUnresolvedReferences,PyProtectedMember def build_day_subcommand(subparsers: argparse._SubParsersAction) -> None: """Analyzes a DAY.csv, uses new colored progress bar spec""" + day_parser = subparsers.add_parser( "day", help="analyze a DAY.csv file, RDAs optional" ) @@ -122,33 +145,175 @@ def build_day_subcommand(subparsers: argparse._SubParsersAction) -> None: day_parser.set_defaults(func=parser_funcs.day) +# noinspection PyUnresolvedReferences,PyProtectedMember def build_recipe_subcommand(subparsers: argparse._SubParsersAction) -> None: """View, add, edit, delete recipes""" + recipe_parser = subparsers.add_parser("recipe", help="list and analyze recipes") + recipe_parser.set_defaults(func=parser_funcs.recipes) + recipe_subparsers = recipe_parser.add_subparsers(title="recipe subcommands") + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Init + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + recipe_init_parser = recipe_subparsers.add_parser( + "init", help="create recipe folder, copy stock data in" + ) + recipe_init_parser.add_argument( + "--force", + "-f", + action="store_true", + help="forcibly remove and re-copy stock/core data", + ) + recipe_init_parser.set_defaults(func=parser_funcs.recipes_init) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Analyze + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # TODO: tab-completion for not just cwd, but also inject for RECIPE_HOME + # TODO: support analysis for multiple file path(s) in one call recipe_anl_parser = recipe_subparsers.add_parser( "anl", help="view and analyze for recipe" ) recipe_anl_parser.add_argument( - "recipe_id", type=int, help="view (and analyze) recipe by ID" + "path", type=str, help="view (and analyze) recipe by file path" ) recipe_anl_parser.set_defaults(func=parser_funcs.recipe) - recipe_import_parser = recipe_subparsers.add_parser("import", help="add a recipe") - recipe_import_parser.add_argument( - "path", - type=types.file_or_dir_path, - help="path to recipe.csv (or folder with multiple CSV files)", + +# noinspection PyUnresolvedReferences,PyProtectedMember +def build_calc_subcommand(subparsers: argparse._SubParsersAction) -> None: + """BMR, 1 rep-max, and other calculators""" + + calc_parser = subparsers.add_parser( + "calc", help="find you 1 rep max, body fat, BMR" ) - recipe_import_parser.set_defaults(func=parser_funcs.recipe_import) - # TODO: edit.. support renaming, and overwriting/re-importing food_amts (from CSV) + calc_subparsers = calc_parser.add_subparsers(title="recipe subcommands") + calc_parser.set_defaults(func=calc_parser.print_help) - recipe_delete_parser = recipe_subparsers.add_parser( - "delete", help="delete a recipe(s) by ID or range" + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # 1-rep max + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + calc_1rm_parser = calc_subparsers.add_parser( + "1rm", help="calculate 1 rep maxes, by different equations" ) - recipe_delete_parser.add_argument("recipe_id", type=int, help="delete recipe by ID") - recipe_delete_parser.set_defaults(func=parser_funcs.recipe_delete) + calc_1rm_parser.add_argument("weight", type=float, help="weight (lbs or kg)") + calc_1rm_parser.add_argument("reps", type=int, help="number of reps performed") + calc_1rm_parser.set_defaults(func=parser_funcs.calc_1rm) - recipe_parser.set_defaults(func=parser_funcs.recipes) + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # BMR + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + calc_bmr_parser = calc_subparsers.add_parser( + "bmr", help="calculate BMR and TDEE values" + ) + calc_bmr_parser.add_argument( + "-F", dest="female_gender", action="store_true", help="Female gender" + ) + # TODO: optional (union) with age / dob + calc_bmr_parser.add_argument("-a", type=str, dest="age", help="e.g. 95") + calc_bmr_parser.add_argument("-ht", type=float, dest="height", help="height (cm)") + calc_bmr_parser.add_argument("-bf", dest="body_fat", type=float, help="e.g. 0.16") + calc_bmr_parser.add_argument( + "-wt", dest="weight", type=float, required=True, help="weight (kg)" + ) + calc_bmr_parser.add_argument( + "-x", + type=int, + dest="activity_factor", + required=True, + help="1 thru 5, sedentary thru intense", + ) + calc_bmr_parser.set_defaults(func=parser_funcs.calc_bmr) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Body fat + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + calc_bf_parser = calc_subparsers.add_parser( + "bf", + help="calculate body fat %% with Navy, 3-Site, 7-Site", + ) + calc_bf_parser.add_argument( + "-F", dest="female_gender", action="store_true", help="Female gender" + ) + calc_bf_parser.add_argument( + "-a", type=int, dest="age", help="e.g. 95 [3-Site & 7-Site]" + ) + calc_bf_parser.add_argument( + "-ht", type=float, dest="height", help="height (cm) [Navy]" + ) + + calc_bf_parser.add_argument( + "-w", type=float, dest="waist", help="waist (cm) [Navy]" + ) + calc_bf_parser.add_argument("-n", type=float, dest="neck", help="neck (cm) [Navy]") + calc_bf_parser.add_argument( + "-hip", type=float, dest="hip", help="hip (cm) [Navy / FEMALE only]" + ) + + calc_bf_parser.add_argument( + "chest", + type=int, + nargs="?", + help="pectoral (mm) -[3-Site skin caliper measurement]", + ) + calc_bf_parser.add_argument( + "abd", + type=int, + nargs="?", + help="abdominal (mm) [3-Site skin caliper measurement]", + ) + calc_bf_parser.add_argument( + "thigh", + type=int, + nargs="?", + help="thigh (mm) --- [3-Site skin caliper measurement]", + ) + calc_bf_parser.add_argument( + "tricep", + type=int, + nargs="?", + help="triceps (mm) - [7-Site skin caliper measurement]", + ) + calc_bf_parser.add_argument( + "sub", + type=int, + nargs="?", + help="sub (mm) ----- [7-Site skin caliper measurement]", + ) + calc_bf_parser.add_argument( + "sup", + type=int, + nargs="?", + help="sup (mm) ----- [7-Site skin caliper measurement]", + ) + calc_bf_parser.add_argument( + "mid", + type=int, + nargs="?", + help="mid (mm) ----- [7-Site skin caliper measurement]", + ) + calc_bf_parser.set_defaults(func=parser_funcs.calc_body_fat) + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Lean body limits (young male) + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + calc_lbl_parser = calc_subparsers.add_parser( + "lbl", help="lean body limits (young, male)" + ) + calc_lbl_parser.add_argument("height", type=float, help="height (cm)") + calc_lbl_parser.add_argument( + "desired_bf", + type=float, + nargs="?", + help="e.g. 0.12 -[eric_helms & casey_butt]", + ) + calc_lbl_parser.add_argument( + "wrist", type=float, nargs="?", help="wrist (cm) [casey_butt]" + ) + calc_lbl_parser.add_argument( + "ankle", type=float, nargs="?", help="ankle (cm) [casey_butt]" + ) + calc_lbl_parser.set_defaults(func=parser_funcs.calc_lbm_limits) diff --git a/ntclient/argparser/funcs.py b/ntclient/argparser/funcs.py index f076e56..7890169 100644 --- a/ntclient/argparser/funcs.py +++ b/ntclient/argparser/funcs.py @@ -1,78 +1,326 @@ -"""Current home to subparsers and service-level logic""" +# -*- coding: utf-8 -*- +""" +Current home to subparsers and service-level logic. +These functions all return a tuple of (exit_code: int, results: list|dict). + +Created on Sat Jul 18 16:30:28 2020 -0400 + +@author: shane +""" import argparse import os +import traceback + +from tabulate import tabulate -from ntclient import services +import ntclient.services.analyze +import ntclient.services.recipe.utils +import ntclient.services.usda +from ntclient import CLI_CONFIG, Gender, activity_factor_from_index +from ntclient.services import calculate as calc def init(args: argparse.Namespace) -> tuple: """Wrapper init method for persistence stuff""" - return services.init(yes=args.yes) + return ntclient.services.init(yes=args.yes) -################################################################################ +############################################################################## # Nutrients, search and sort -################################################################################ -def nutrients(): # type: ignore +############################################################################## +def nutrients() -> tuple: """List nutrients""" - return services.usda.list_nutrients() + return ntclient.services.usda.list_nutrients() def search(args: argparse.Namespace) -> tuple: """Searches all dbs, foods, recipes, recent items and favorites.""" if args.top: - return services.usda.search( + return ntclient.services.usda.search( words=args.terms, fdgrp_id=args.fdgrp_id, limit=args.top ) - return services.usda.search(words=args.terms, fdgrp_id=args.fdgrp_id) + return ntclient.services.usda.search(words=args.terms, fdgrp_id=args.fdgrp_id) def sort(args: argparse.Namespace) -> tuple: """Sorts based on nutrient id""" if args.top: - return services.usda.sort_foods(args.nutr_id, by_kcal=args.kcal, limit=args.top) - return services.usda.sort_foods(args.nutr_id, by_kcal=args.kcal) + return ntclient.services.usda.sort_foods( + args.nutr_id, by_kcal=args.kcal, limit=args.top + ) + return ntclient.services.usda.sort_foods(args.nutr_id, by_kcal=args.kcal) -################################################################################ +############################################################################## # Analysis and Day scoring -################################################################################ +############################################################################## def analyze(args: argparse.Namespace) -> tuple: """Analyze a food""" - food_ids = args.food_id - grams = args.grams + # exc: ValueError, + food_ids = set(args.food_id) + grams = float(args.grams) if args.grams else 0.0 - return services.analyze.foods_analyze(food_ids, grams) + return ntclient.services.analyze.foods_analyze(food_ids, grams) def day(args: argparse.Namespace) -> tuple: """Analyze a day's worth of meals""" - day_csv_paths = args.food_log - day_csv_paths = [os.path.expanduser(x) for x in day_csv_paths] - rda_csv_path = os.path.expanduser(args.rda) if args.rda else None + day_csv_paths = [str(os.path.expanduser(x)) for x in args.food_log] + rda_csv_path = str(os.path.expanduser(args.rda)) if args.rda else str() - return services.analyze.day_analyze(day_csv_paths, rda_csv_path=rda_csv_path) + return ntclient.services.analyze.day_analyze( + day_csv_paths, rda_csv_path=rda_csv_path + ) -################################################################################ +############################################################################## # Recipes -################################################################################ +############################################################################## +def recipes_init(args: argparse.Namespace) -> tuple: + """Copy example/stock data into RECIPE_HOME""" + _force = args.force + + return ntclient.services.recipe.utils.recipes_init(_force=_force) + + def recipes() -> tuple: - """Return recipes""" - return services.recipe.recipes_overview() + """Show all, in tree or detail view""" + return ntclient.services.recipe.utils.recipes_overview() def recipe(args: argparse.Namespace) -> tuple: - """Return recipe view (analysis)""" - return services.recipe.recipe_overview(args.recipe_id) + """ + View and analyze a single (or a range) + @todo: argcomplete based on RECIPE_HOME folder + @todo: use as default command? Currently this is reached by `nutra recipe anl` + """ + recipe_path = args.path + + return ntclient.services.recipe.utils.recipe_overview(recipe_path=recipe_path) + + +############################################################################## +# Calculators +############################################################################## +def calc_1rm(args: argparse.Namespace) -> tuple: + """Perform 1-rep max calculations""" + + weight = float(args.weight) + print("Weight: %s" % weight) + reps = int(args.reps) + print("Reps: %s" % reps) + + _epley = calc.orm_epley(weight, reps) + _brzycki = calc.orm_brzycki(weight, reps) + _dos_remedios = calc.orm_dos_remedios(weight, reps) + + result = {"epley": _epley, "brzycki": _brzycki, "dos_remedios": _dos_remedios} + + # TODO: fourth column: average or `avg` column too. + # Prepare table rows, to display all 3 results in one table + _all = [] + for _rep in _epley.keys(): + row = [_rep] + for _calc, _values in result.items(): + try: + # Round down for now + row.append(int(_values[_rep])) + except KeyError: + row.append(None) + _all.append(row) + + # Print results + print() + print("Results for: epley, brzycki, and dos_remedios") + if "errMsg" in _dos_remedios: + print("WARN: Dos Remedios failed: %s" % _dos_remedios["errMsg"]) + print() + _table = tabulate(_all, headers=["n", "epl", "brz", "rmds"]) + print(_table) + + return 0, result + + +def calc_bmr(args: argparse.Namespace) -> tuple: + """ + Perform BMR & TDEE calculations + + Example POST: + { + "weight": 71, + "height": 177, + "gender": "MALE", + "dob": 725864400, + "bodyFat": 0.14, + "activityFactor": 0.55 + } + """ + + activity_factor = activity_factor_from_index(args.activity_factor) + print("Activity factor: %s" % activity_factor) + weight = float(args.weight) # kg + print("Weight: %s kg" % weight) + + # TODO: require these all for any? Or do exception handling & optional args like bf? + try: + _katch_mcardle = calc.bmr_katch_mcardle(activity_factor, weight, args=args) + except (KeyError, TypeError, ValueError): + _katch_mcardle = { + "errMsg": "Katch McArdle failed, requires: " + "activity_factor, weight, body_fat." + } + try: + _cunningham = calc.bmr_cunningham(activity_factor, weight, args=args) + except (KeyError, TypeError, ValueError): + _cunningham = { + "errMsg": "Cunningham failed, requires: activity_factor, weight, body_fat." + } + try: + _mifflin_st_jeor = calc.bmr_mifflin_st_jeor(activity_factor, weight, args=args) + except (KeyError, TypeError, ValueError): + _mifflin_st_jeor = { + "errMsg": "Mifflin St Jeor failed, requires: " + "activity_factor, weight, gender, height, & age." + } + try: + _harris_benedict = calc.bmr_harris_benedict(activity_factor, weight, args=args) + except (KeyError, TypeError, ValueError): + _harris_benedict = { + "errMsg": "Harris Benedict failed, requires: " + "activity_factor, weight, gender, height, & age." + } + + result = { + "katch_mcardle": _katch_mcardle, + "cunningham": _cunningham, + "mifflin_st_jeor": _mifflin_st_jeor, + "harris_benedict": _harris_benedict, + } + + # Prepare the table for printing + headers = ("Equation", "BMR", "TDEE") + rows = [] + for _equation, _calculation in result.items(): + row = [_equation] + row.extend(_calculation.values()) + rows.append(row) + + _katch_mcardle_table = tabulate(rows, headers=headers, tablefmt="simple") + print(_katch_mcardle_table) + + return 0, result + + +def calc_body_fat(args: argparse.Namespace) -> tuple: + """ + Perform body fat calculations for Navy, 3-Site, and 7-Site. + + Example POST. @note FEMALE, also includes "hip" (cm) + { + "gender": "MALE", + "age": 29, + "height": 178, + "waist": 80, + "neck": 36.8, + // also: hip, if FEMALE + "chest": 5, + "abd": 6, + "thigh": 9, + "tricep": 6, + "sub": 8, + "sup": 7, + "mid": 4 + } + """ + + gender = Gender.FEMALE if args.female_gender else Gender.MALE + print("Gender: %s" % gender) + try: + _navy = calc.bf_navy(gender, args) + except (TypeError, ValueError): + print() + if CLI_CONFIG.debug: + traceback.print_exc() + print( + "WARN: Navy failed, requires: gender, height, waist, neck, " + "and (if female) hip." + ) + _navy = 0.0 + try: + _3site = calc.bf_3site(gender, args) + except (TypeError, ValueError): + print() + if CLI_CONFIG.debug: + traceback.print_exc() + print( + "WARN: 3-Site failed, requires: gender, age, chest (mm), " + "abdominal (mm), and thigh (mm)." + ) + _3site = 0.0 + try: + _7site = calc.bf_7site(gender, args) + except (TypeError, ValueError): + print() + if CLI_CONFIG.debug: + traceback.print_exc() + print( + "WARN: 7-Site failed, requires: gender, age, chest (mm), " + "abdominal (mm), thigh (mm), tricep (mm), sub (mm), sup (mm), and mid (mm)." + ) + _7site = 0.0 + + _table = tabulate([(_navy, _3site, _7site)], headers=["Navy", "3-Site", "7-Site"]) + print() + print() + print(_table) + + return 0, {"navy": _navy, "threeSite": _3site, "sevenSite": _7site} + + +def calc_lbm_limits(args: argparse.Namespace) -> tuple: + """ + Perform body fat calculations for Navy, 3-Site, and 7-Site. + + Example POST. + { + "height": 179, + "desired-bf": 0.12, + "wrist": 17.2, + "ankle": 21.5 + } + """ + + height = float(args.height) + + # Perform calculations & handle errors + _berkhan = calc.lbl_berkhan(height) + _eric_helms = calc.lbl_eric_helms(height, args) + _casey_butt = calc.lbl_casey_butt(height, args) + result = {"berkhan": _berkhan, "helms": _eric_helms, "casey": _casey_butt} -def recipe_import(args: argparse.Namespace) -> tuple: - """Add a recipe""" - # TODO: custom serving sizes, not always in grams? - return services.recipe.recipe_import(args.path) + headers = [ + "eq", + "condition", + "weight", + "lbm", + "chest", + "arm", + "forearm", + "neck", + "thigh", + "calf", + ] + rows = [] + for _calc, _result in result.items(): + _values = list(_result.values()) + row = [_calc] + row.extend(_values) + while len(row) < len(headers): + row.append(str()) + rows.append(row) + _table = tabulate(rows, headers=headers, tablefmt="pretty") + print(_table) -def recipe_delete(args: argparse.Namespace) -> tuple: - """Delete a recipe""" - return services.recipe.recipe_delete(args.recipe_id) + return 0, result diff --git a/ntclient/argparser/types.py b/ntclient/argparser/types.py index aa2d3cd..31753dd 100644 --- a/ntclient/argparser/types.py +++ b/ntclient/argparser/types.py @@ -1,4 +1,10 @@ -"""Custom types for argparse validation""" +# -*- coding: utf-8 -*- +""" +Created on Mon May 31 09:19:00 2021 -0400 + +@author: shane +Custom types for argparse validation +""" import argparse import os diff --git a/ntclient/core/nnest.py b/ntclient/core/nnest.py index c22d7c7..fba911c 100755 --- a/ntclient/core/nnest.py +++ b/ntclient/core/nnest.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Created on Sat Aug 29 19:43:55 2020 diff --git a/ntclient/core/nnr2.py b/ntclient/core/nnr2.py index e85cc54..bfe769e 100755 --- a/ntclient/core/nnr2.py +++ b/ntclient/core/nnr2.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Created on Fri Jul 31 21:23:51 2020 diff --git a/ntclient/models/__init__.py b/ntclient/models/__init__.py new file mode 100644 index 0000000..c4ed89e --- /dev/null +++ b/ntclient/models/__init__.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +""" +Created on Fri Jul 22 15:34:07 2022 + +@author: shane +Classes, structures for storing, displaying, and editing data. +""" +import csv + +from ntclient import CLI_CONFIG + + +class Recipe: + """Allows reading up CSV, filtering by UUID, and displaying detail view""" + + def __init__(self, file_path: str) -> None: + """Initialize entity""" + + self.file_path = file_path + self.csv_reader = csv.DictReader(str()) + + # Defined now, populated later + self.headers = tuple() # type: ignore + self.rows = tuple() # type: ignore + + self.uuid = str() + + self.food_data = {} # type: ignore + + def process_data(self) -> None: + """ + Parses out the raw CSV input read in during self.__init__() + TODO: test this with an empty CSV file + @todo: CliConfig class, to avoid these non top-level import shenanigans + """ + + # Read into memory + print("Processing recipe file: %s" % self.file_path) + with open(self.file_path, "r", encoding="utf-8") as _file: + self.csv_reader = csv.DictReader(_file) + self.rows = tuple(self.csv_reader) + + # Validate data + uuids = {x["recipe_id"] for x in self.rows} + if len(uuids) != 1: + print("Found %s keys: %s" % (len(uuids), uuids)) + raise KeyError("FATAL: must have exactly 1 uuid per recipe CSV file!") + self.uuid = list(uuids)[0] + + # exc: ValueError (could not cast int / float) + self.food_data = {int(x["food_id"]): float(x["grams"]) for x in self.rows} + + if CLI_CONFIG.debug: + print("Finished with recipe.") + + def print_analysis(self) -> None: + """Run analysis on a single recipe""" diff --git a/ntclient/ntsqlite b/ntclient/ntsqlite index f76955d..0eb89de 160000 --- a/ntclient/ntsqlite +++ b/ntclient/ntsqlite @@ -1 +1 @@ -Subproject commit f76955d55293d166f833befbab2c52447d0cd07e +Subproject commit 0eb89de7db73aa68d71da3b4113296ba595500bd diff --git a/ntclient/persistence/__init__.py b/ntclient/persistence/__init__.py index 5a97959..717bd0e 100644 --- a/ntclient/persistence/__init__.py +++ b/ntclient/persistence/__init__.py @@ -1,39 +1,10 @@ # -*- coding: utf-8 -*- """ +Home to persistence and storage utilities. +Used to have a prefs.json, but was deleted and is planned to be maintained + in sqlite. + Created on Sat Mar 23 13:09:07 2019 @author: shane """ - -import json -import os - -from ntclient import NUTRA_HOME - -# TODO: init, handle when it doesn't exist yet -# TODO: prompt to create profile if copying default `prefs.json` with PROFILE_ID: -1 -# (non-existent) -PREFS_FILE = os.path.join(NUTRA_HOME, "prefs.json") -PREFS = {} -PROFILE_ID = None - - -def persistence_init() -> None: - """Loads the preferences file and relevant bits""" - global PREFS, PROFILE_ID # pylint: disable=global-statement - from ntclient import DEBUG # pylint: disable=import-outside-toplevel - - if os.path.isfile(PREFS_FILE): - with open(PREFS_FILE, encoding="utf-8") as file_path: - PREFS = json.load(file_path) - else: - if DEBUG: - print("WARN: ~/.nutra/prefs.json doesn't exist, using defaults") - PREFS = {} - - PROFILE_ID = PREFS.get("current_user") - if DEBUG and not PROFILE_ID: - print( - "WARN: ~/.nutra/prefs.json doesn't contain valid PROFILE_ID," - "proceeding in bare mode" - ) diff --git a/ntclient/persistence/sql/__init__.py b/ntclient/persistence/sql/__init__.py index 7a6f6ee..9d603b9 100644 --- a/ntclient/persistence/sql/__init__.py +++ b/ntclient/persistence/sql/__init__.py @@ -2,10 +2,12 @@ import sqlite3 from collections.abc import Sequence - # ------------------------------------------------ # Entry fetching methods # ------------------------------------------------ +from ntclient import CLI_CONFIG + + def sql_entries(sql_result: sqlite3.Cursor) -> list: """Formats and returns a `sql_result()` for console digestion and output""" # TODO: return object: metadata, command, status, errors, etc? @@ -62,11 +64,9 @@ def _prep_query( @return: A sqlite3.Cursor object with populated return values. """ - from ntclient import DEBUG # pylint: disable=import-outside-toplevel - cur = con.cursor() - if DEBUG: + if CLI_CONFIG.debug: print("%s.sqlite3: %s" % (db_name, query)) if values: # TODO: better debug logging, more "control-findable", diff --git a/ntclient/persistence/sql/nt/funcs.py b/ntclient/persistence/sql/nt/funcs.py index d4b2a2f..af8a143 100644 --- a/ntclient/persistence/sql/nt/funcs.py +++ b/ntclient/persistence/sql/nt/funcs.py @@ -1,5 +1,5 @@ """nt.sqlite3 functions module""" -from ntclient.persistence.sql.nt import sql, sql_headers +from ntclient.persistence.sql.nt import sql def sql_nt_next_index(table: str) -> int: @@ -7,54 +7,3 @@ def sql_nt_next_index(table: str) -> int: # noinspection SqlResolve query = "SELECT MAX(id) as max_id FROM %s;" % table # nosec: B608 return int(sql(query)[0]["max_id"]) - - -################################################################################ -# Recipe functions -################################################################################ -def sql_recipe(recipe_id: int) -> list: - """Selects columns for recipe_id""" - query = "SELECT * FROM recipe WHERE id=?;" - return sql(query, values=(recipe_id,)) - - -def sql_recipes() -> tuple: - """Show recipes with selected details""" - query = """ -SELECT - id, - tagname, - name, - COUNT(recipe_id) AS n_foods, - SUM(grams) AS grams, - recipe.created as created -FROM - recipe - LEFT JOIN recipe_dat ON recipe_id = id -GROUP BY - id; -""" - return sql_headers(query) - - -def sql_analyze_recipe(recipe_id: int) -> list: - """Output (nutrient) analysis columns for a given recipe_id""" - query = """ -SELECT - id, - name, - food_id, - grams -FROM - recipe - INNER JOIN recipe_dat ON recipe_id = id - AND id = ?; -""" - return sql(query, values=(recipe_id,)) - - -def sql_recipe_add() -> list: - """TODO: method for adding recipe""" - query = """ -""" - return sql(query) diff --git a/ntclient/persistence/sql/usda/__init__.py b/ntclient/persistence/sql/usda/__init__.py index e940a42..b210326 100644 --- a/ntclient/persistence/sql/usda/__init__.py +++ b/ntclient/persistence/sql/usda/__init__.py @@ -19,6 +19,7 @@ def usda_init(yes: bool = False) -> None: def download_extract_usda() -> None: """Download USDA tarball from BitBucket and extract to storage folder""" + # TODO: move this into separate module, ignore coverage. Avoid SLOW tests if yes or input_agree().lower() == "y": # TODO: save with version in filename? # Don't re-download tarball, just extract? @@ -30,13 +31,14 @@ def usda_init(yes: bool = False) -> None: # Extract the archive with tarfile.open(save_path, mode="r:xz") as usda_sqlite_file: - print("\ntar xvf %s.tar.xz" % USDA_DB_NAME) + print("\n" + "tar xvf %s.tar.xz" % USDA_DB_NAME) usda_sqlite_file.extractall(NUTRA_HOME) print("==> done downloading %s" % USDA_DB_NAME) - # TODO: handle resource moved on Bitbucket - # or version mismatch due to manual overwrite? + # TODO: handle resource moved on Bitbucket, + # or version mismatch due to developer mistake / overwrite? + # And seed mirrors; don't hard code one host here! url = ( "https://bitbucket.org/dasheenster/nutra-utils/downloads/{0}-{1}.tar.xz".format( USDA_DB_NAME, __db_target_usda__ diff --git a/ntclient/persistence/sql/usda/funcs.py b/ntclient/persistence/sql/usda/funcs.py index 455b55c..cc7c5c8 100644 --- a/ntclient/persistence/sql/usda/funcs.py +++ b/ntclient/persistence/sql/usda/funcs.py @@ -1,6 +1,6 @@ """usda.sqlite functions module""" +from ntclient import NUTR_ID_KCAL from ntclient.persistence.sql.usda import sql, sql_headers -from ntclient.utils import NUTR_ID_KCAL ################################################################################ diff --git a/ntclient/resources/recipe/dinner/burrito-bowl.csv b/ntclient/resources/recipe/dinner/burrito-bowl.csv new file mode 100644 index 0000000..5afe446 --- /dev/null +++ b/ntclient/resources/recipe/dinner/burrito-bowl.csv @@ -0,0 +1,11 @@ +recipe_id,food_id,grams,name +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,20045,180,white rice +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,16042,25,pinto beans +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,11282,45,onions +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,11260,45,mushrooms +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,11821,35,bell peppers +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,11233,25,kale +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,23293,85,"beef (grass-fed, 85/15)" +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,11529,40,tomatoes +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,9037,40,avocados +e403ede0-76a1-4992-81f9-2f72e9e4bc0e,1009,20,cheese (cheddar) diff --git a/ntclient/resources/recipe/dinner/grass-fed-burger.csv b/ntclient/resources/recipe/dinner/grass-fed-burger.csv new file mode 100644 index 0000000..17648a8 --- /dev/null +++ b/ntclient/resources/recipe/dinner/grass-fed-burger.csv @@ -0,0 +1,8 @@ +recipe_id,food_id,grams,name (optional) +77d54a8c-0b69-4f10-b9e7-b5f15807a8f0,18351,55,roll (mixed-grain) +77d54a8c-0b69-4f10-b9e7-b5f15807a8f1,23293,85,"beef (grass-fed, 85/15)" +77d54a8c-0b69-4f10-b9e7-b5f15807a8f2,1009,20,cheddar cheese +77d54a8c-0b69-4f10-b9e7-b5f15807a8f3,11251,25,lettuce (romaine) +77d54a8c-0b69-4f10-b9e7-b5f15807a8f4,11529,40,tomatoes +77d54a8c-0b69-4f10-b9e7-b5f15807a8f5,11282,20,onions +77d54a8c-0b69-4f10-b9e7-b5f15807a8f6,9037,40,avocados diff --git a/ntclient/resources/recipe/snack/baked-potato-wedges.csv b/ntclient/resources/recipe/snack/baked-potato-wedges.csv new file mode 100644 index 0000000..672358b --- /dev/null +++ b/ntclient/resources/recipe/snack/baked-potato-wedges.csv @@ -0,0 +1,4 @@ +recipe_id,food_id,grams,name (optional) +bbac2626-83d4-41ca-a1cb-dda5c4bf4707,11355,300,potatoes (red) +bbac2626-83d4-41ca-a1cb-dda5c4bf4707,4053,30,olive oil +bbac2626-83d4-41ca-a1cb-dda5c4bf4707,11297,30,parsley (fresh) diff --git a/ntclient/resources/recipe/snack/buckwheat-pancake.csv b/ntclient/resources/recipe/snack/buckwheat-pancake.csv new file mode 100644 index 0000000..e9f4fdb --- /dev/null +++ b/ntclient/resources/recipe/snack/buckwheat-pancake.csv @@ -0,0 +1,11 @@ +recipe_id,food_id,grams,name (optional) +2b2d0375-fd5a-4544-9da7-189f4da18ed8,20011,60,flour (buckwheat) +2b2d0375-fd5a-4544-9da7-189f4da18ed9,20140,30,flour (spelt) +2b2d0375-fd5a-4544-9da7-189f4da18ed10,20080,30,flour (whole wheat) +2b2d0375-fd5a-4544-9da7-189f4da18ed11,1123,56,egg +2b2d0375-fd5a-4544-9da7-189f4da18ed12,1079,244,milk (2%) +2b2d0375-fd5a-4544-9da7-189f4da18ed13,19911,25,syrup (maple) +2b2d0375-fd5a-4544-9da7-189f4da18ed14,16122,20,protein (soy or whey) +2b2d0375-fd5a-4544-9da7-189f4da18ed15,2047,1.5,salt +2b2d0375-fd5a-4544-9da7-189f4da18ed16,18372,1.5,baking soda +2b2d0375-fd5a-4544-9da7-189f4da18ed8,18370,0.75,baking powder diff --git a/ntclient/resources/recipe/snack/fruit-smoothie.csv b/ntclient/resources/recipe/snack/fruit-smoothie.csv new file mode 100644 index 0000000..2934548 --- /dev/null +++ b/ntclient/resources/recipe/snack/fruit-smoothie.csv @@ -0,0 +1,9 @@ +recipe_id,food_id,grams,name (optional) +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f8,12061,50,almonds +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f9,12220,30,flaxseed +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f10,12012,20,hemp (seed/protein) +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f11,16122,28,protein (soy or whey) +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f12,9050,50,blueberries +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f13,9040,80,bananas +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f14,9176,40,mangos +30e04b8b-3c2b-4b28-a41e-6445b2dbb9f15,1289,140,kefir diff --git a/ntclient/resources/recipe/tmp/food_amounts.csv b/ntclient/resources/recipe/tmp/food_amounts.csv new file mode 100644 index 0000000..5723824 --- /dev/null +++ b/ntclient/resources/recipe/tmp/food_amounts.csv @@ -0,0 +1,43 @@ +recipe_id,food_id,grams,name +1,20045,180,white rice +1,16042,25,pinto beans +1,11282,45,onions +1,11260,45,mushrooms +1,11821,35,bell peppers +1,11233,25,kale +1,23293,85,"beef (grass-fed, 85/15)" +1,11529,40,tomatoes +1,9037,40,avocados +1,1009,20,cheese (cheddar) +,,, +2,18351,55,roll (mixed-grain) +2,23293,85,"beef (grass-fed, 85/15)" +2,1009,20,cheddar cheese +2,11251,25,lettuce (romaine) +2,11529,40,tomatoes +2,11282,20,onions +2,9037,40,avocados +,,, +3,11355,300,potatoes (red) +3,4053,30,olive oil +3,11297,30,parsley (fresh) +,,, +4,20011,60,flour (buckwheat) +4,20140,30,flour (spelt) +4,20080,30,flour (whole wheat) +4,1123,56,egg +4,1079,244,milk (2%) +4,19911,25,syrup (maple) +4,16122,20,protein (soy or whey) +4,2047,1.5,salt +4,18372,1.5,baking soda +4,18370,0.75,baking powder +,,, +5,12061,50,almonds +5,12220,30,flaxseed +5,12012,20,hemp (seed/protein) +5,16122,28,protein (soy or whey) +5,9050,50,blueberries +5,9040,80,bananas +5,9176,40,mangos +5,1289,140,kefir diff --git a/ntclient/resources/recipe/tmp/names.csv b/ntclient/resources/recipe/tmp/names.csv new file mode 100644 index 0000000..00eb624 --- /dev/null +++ b/ntclient/resources/recipe/tmp/names.csv @@ -0,0 +1,6 @@ +id,name +1,Burrito bowl (Everyday) +2,"Burger (Grass fed, Beef)" +3,Baked potato wedges +4,Buckwheat pancake (w/ syrup) +5,Blueberry-hemp Smoothie diff --git a/ntclient/resources/recipe/tmp/servings.csv b/ntclient/resources/recipe/tmp/servings.csv new file mode 100644 index 0000000..4814e30 --- /dev/null +++ b/ntclient/resources/recipe/tmp/servings.csv @@ -0,0 +1,2 @@ +recipe_id,serving,grams +1,cup,160 diff --git a/ntclient/services/__init__.py b/ntclient/services/__init__.py index b2f4890..b540aaf 100644 --- a/ntclient/services/__init__.py +++ b/ntclient/services/__init__.py @@ -5,7 +5,8 @@ from ntclient import NUTRA_HOME from ntclient.ntsqlite.sql import build_ntsqlite from ntclient.persistence.sql.nt import nt_init from ntclient.persistence.sql.usda import usda_init -from ntclient.services import analyze, recipe, usda + +# TODO: rethink the above imports, if this belongs in __init__ or not def init(yes: bool = False) -> tuple: @@ -37,4 +38,11 @@ def init(yes: bool = False) -> tuple: nt_init() print("\nAll checks have passed!") + print( + """ +Nutrient tracker is free software. It comes with NO warranty or guarantee. +You may use it as you please. +You may make changes, as long as you disclose and publish them. + """ + ) return 0, True diff --git a/ntclient/services/analyze.py b/ntclient/services/analyze.py index 994ec0c..738a3df 100755 --- a/ntclient/services/analyze.py +++ b/ntclient/services/analyze.py @@ -8,36 +8,29 @@ Created on Sun Nov 11 23:57:03 2018 import csv from collections import OrderedDict -from colorama import Fore, Style from tabulate import tabulate -from ntclient import BUFFER_WD -from ntclient.persistence.sql.usda.funcs import ( - sql_analyze_foods, - sql_food_details, - sql_nutrients_overview, - sql_servings, -) -from ntclient.utils import ( - COLOR_CRIT, - COLOR_DEFAULT, - COLOR_OVER, - COLOR_WARN, +from ntclient import ( + BUFFER_WD, + CLI_CONFIG, NUTR_ID_CARBS, NUTR_ID_FAT_TOT, NUTR_ID_FIBER, NUTR_ID_KCAL, NUTR_ID_PROTEIN, - THRESH_CRIT, - THRESH_OVER, - THRESH_WARN, +) +from ntclient.persistence.sql.usda.funcs import ( + sql_analyze_foods, + sql_food_details, + sql_nutrients_overview, + sql_servings, ) ################################################################################ # Foods ################################################################################ -def foods_analyze(food_ids: set, grams: int = 0) -> tuple: +def foods_analyze(food_ids: set, grams: float = 0) -> tuple: """ Analyze a list of food_ids against stock RDA values TODO: from ntclient.utils.nutprogbar import nutprogbar @@ -135,12 +128,11 @@ def foods_analyze(food_ids: set, grams: int = 0) -> tuple: ################################################################################ # Day ################################################################################ -def day_analyze(day_csv_paths: str, rda_csv_path: str = str()) -> tuple: +def day_analyze(day_csv_paths: list, rda_csv_path: str = str()) -> tuple: """Analyze a day optionally with custom RDAs, e.g. nutra day ~/.nutra/rocky.csv -r ~/.nutra/dog-rdas-18lbs.csv TODO: Should be a subset of foods_analyze """ - from ntclient import DEBUG # pylint: disable=import-outside-toplevel if rda_csv_path: with open(rda_csv_path, encoding="utf-8") as file_path: @@ -171,7 +163,7 @@ def day_analyze(day_csv_paths: str, rda_csv_path: str = str()) -> tuple: for _nutrient in nutrients_lists: if _nutrient[0] == nutrient_id: _nutrient[1] = _rda - if DEBUG: + if CLI_CONFIG.debug: substr = "{0} {1}".format(_rda, _nutrient[2]).ljust(12) print("INJECT RDA: {0} --> {1}".format(substr, _nutrient[4])) nutrients = {x[0]: x for x in nutrients_lists} @@ -217,11 +209,11 @@ def day_format(analysis: dict, nutrients: dict, buffer: int = 0) -> None: """Formats day analysis for printing to console""" def print_header(header: str) -> None: - print(Fore.CYAN, end="") + print(CLI_CONFIG.color_default, end="") print("~~~~~~~~~~~~~~~~~~~~~~~~~~~") print("--> %s" % header) print("~~~~~~~~~~~~~~~~~~~~~~~~~~~") - print(Style.RESET_ALL) + print(CLI_CONFIG.color_reset_all) def print_macro_bar( _fat: float, _net_carb: float, _pro: float, _kcals_max: float, _buffer: int = 0 @@ -244,21 +236,21 @@ def day_format(analysis: dict, nutrients: dict, buffer: int = 0) -> None: p_buf = " " * (n_pro // 2) + "Pro" + " " * (n_pro - n_pro // 2 - 3) print( " " - + Fore.YELLOW + + CLI_CONFIG.color_yellow + f_buf - + Fore.BLUE + + CLI_CONFIG.color_blue + c_buf - + Fore.RED + + CLI_CONFIG.color_red + p_buf - + Style.RESET_ALL + + CLI_CONFIG.color_reset_all ) # Bars print(" <", end="") - print(Fore.YELLOW + "=" * n_fat, end="") - print(Fore.BLUE + "=" * n_car, end="") - print(Fore.RED + "=" * n_pro, end="") - print(Style.RESET_ALL + ">") + print(CLI_CONFIG.color_yellow + "=" * n_fat, end="") + print(CLI_CONFIG.color_blue + "=" * n_car, end="") + print(CLI_CONFIG.color_red + "=" * n_pro, end="") + print(CLI_CONFIG.color_reset_all + ">") # Calorie footers k_fat = str(round(fat * 9)) @@ -269,13 +261,13 @@ def day_format(analysis: dict, nutrients: dict, buffer: int = 0) -> None: p_buf = " " * (n_pro // 2) + k_pro + " " * (n_pro - n_pro // 2 - len(k_pro)) print( " " - + Fore.YELLOW + + CLI_CONFIG.color_yellow + f_buf - + Fore.BLUE + + CLI_CONFIG.color_blue + c_buf - + Fore.RED + + CLI_CONFIG.color_red + p_buf - + Style.RESET_ALL + + CLI_CONFIG.color_reset_all ) def print_nute_bar(_n_id: int, amount: float, _nutrients: dict) -> tuple: @@ -290,14 +282,14 @@ def day_format(analysis: dict, nutrients: dict, buffer: int = 0) -> None: attain = amount / rda perc = round(100 * attain, 1) - if attain >= THRESH_OVER: - color = COLOR_OVER - elif attain <= THRESH_CRIT: - color = COLOR_CRIT - elif attain <= THRESH_WARN: - color = COLOR_WARN + if attain >= CLI_CONFIG.thresh_over: + color = CLI_CONFIG.color_over + elif attain <= CLI_CONFIG.thresh_crit: + color = CLI_CONFIG.color_crit + elif attain <= CLI_CONFIG.thresh_warn: + color = CLI_CONFIG.color_warn else: - color = COLOR_DEFAULT + color = CLI_CONFIG.color_default # Print detail_amount = "{0}/{1} {2}".format(round(amount, 1), rda, unit).ljust(18) @@ -307,7 +299,7 @@ def day_format(analysis: dict, nutrients: dict, buffer: int = 0) -> None: print(" {0}<".format(color), end="") print("=" * left_pos + " " * (left_index - left_pos) + ">", end="") print(" {0}%\t[{1}]".format(perc, detail_amount), end="") - print(Style.RESET_ALL) + print(CLI_CONFIG.color_reset_all) return True, perc diff --git a/ntclient/services/calculate.py b/ntclient/services/calculate.py new file mode 100644 index 0000000..818631b --- /dev/null +++ b/ntclient/services/calculate.py @@ -0,0 +1,484 @@ +# -*- coding: utf-8 -*- +""" +Calculate service for one rep max, BMR, body fat. + +Created on Tue Aug 11 20:53:14 2020 + +@author: shane +""" +import argparse +import math +from datetime import datetime + +from ntclient import Gender + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# 1 rep max +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +common_n_reps = (1, 2, 3, 5, 6, 8, 10, 12, 15, 20) + + +def orm_epley(weight: float, reps: float) -> dict: + """ + Returns a dict {n_reps: max_weight, ...} + for n_reps: (1, 2, 3, 5, 6, 8, 10, 12, 15, 20) + + 1 RM = weight * (1 + (reps - 1) / 30) + + Source: https://workoutable.com/one-rep-max-calculator/ + """ + + def one_rm() -> float: + _un_rounded_result = weight * (1 + (reps - 1) / 30) + return round(_un_rounded_result, 1) + + def weight_max_reps(target_reps: float) -> float: + _un_rounded_result = one_rm() / (1 + (target_reps - 1) / 30) + return round(_un_rounded_result, 1) + + maxes = {n_reps: weight_max_reps(n_reps) for n_reps in common_n_reps} + return maxes + + +def orm_brzycki(weight: float, reps: float) -> dict: + """ + Returns a dict {n_reps: max_weight, ...} + for n_reps: (1, 2, 3, 5, 6, 8, 10, 12, 15) + + 1 RM = weight * 36 / (37 - reps) + + Source: https://workoutable.com/one-rep-max-calculator/ + """ + + def _one_rm() -> float: + _un_rounded_result = weight * 36 / (37 - reps) + return round(_un_rounded_result, 1) + + one_rm = _one_rm() + + def weight_max_reps(target_reps: float) -> float: + _un_rounded_result = one_rm / (1 + (target_reps - 1) / 30) + return round(_un_rounded_result, 1) + + maxes = {n_reps: weight_max_reps(n_reps) for n_reps in common_n_reps} + return maxes + + +def orm_dos_remedios(weight: float, reps: int) -> dict: + """ + Returns dict {n_reps: max_weight, ...} + for n_reps: (1, 2, 3, 5, 6, 8, 10, 12, 15) + + Or an {"errMsg": "INVALID_RANGE", ...} + + Source: + https://www.peterrobertscoaching.com/blog/the-best-way-to-calculate-1-rep-max + """ + + _common_n_reps = { + 1: 1, + 2: 0.92, + 3: 0.9, + 5: 0.87, + 6: 0.82, + 8: 0.75, + 10: 0.7, + 12: 0.65, + 15: 0.6, + } + + def _one_rm() -> float: + _multiplier = _common_n_reps[reps] + _un_rounded_result = weight / _multiplier + return round(_un_rounded_result, 1) + + try: + one_rm = _one_rm() + except KeyError: + # _logger.debug(traceback.format_exc()) + valid_reps = list(_common_n_reps.keys()) + return { + "errMsg": "INVALID_RANGE — " + + "requires: reps in %s, got %s" % (valid_reps, reps), + } + + def max_weight(target_reps: int) -> float: + _multiplier = _common_n_reps[target_reps] + _un_rounded_result = one_rm * _multiplier + return round(_un_rounded_result, 1) + + return {n_reps: max_weight(n_reps) for n_reps in _common_n_reps} + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# BMR +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# TODO: write true service level calls, which accepts: lbm | (weight & body_fat) +def bmr_katch_mcardle( + activity_factor: float, weight: float, args: argparse.Namespace +) -> dict: + """ + BMR = 370 + (21.6 x Lean Body Mass(kg) ) + + Source: https://www.calculatorpro.com/calculator/katch-mcardle-bmr-calculator/ + Source: https://tdeecalculator.net/about.php + + @param activity_factor: {0.200, 0.375, 0.550, 0.725, 0.900} + @param weight: kg + @param args: Namespace containing: body_fat (to calculate lean mass in kg) + """ + + body_fat = float(args.body_fat) + print("Body fat: %s %%" % (body_fat * 100)) + + lbm = weight * (1 - body_fat) + bmr = 370 + (21.6 * lbm) + tdee = bmr * (1 + activity_factor) + + return { + "bmr": round(bmr), + "tdee": round(tdee), + } + + +def bmr_cunningham( + activity_factor: float, weight: float, args: argparse.Namespace +) -> dict: + """ + Source: https://www.slideshare.net/lsandon/weight-management-in-athletes-lecture + + @param activity_factor: {0.200, 0.375, 0.550, 0.725, 0.900} + @param weight: kg + @param args: Namespace containing: body_fat (to calculate lean mass in kg) + """ + + body_fat = float(args.body_fat) + + lbm = weight * (1 - body_fat) + bmr = 500 + 22 * lbm + tdee = bmr * (1 + activity_factor) + + return { + "bmr": round(bmr), + "tdee": round(tdee), + } + + +def bmr_mifflin_st_jeor( + activity_factor: float, weight: float, args: argparse.Namespace +) -> dict: + """ + Source: https://www.myfeetinmotion.com/mifflin-st-jeor-equation/ + + @param activity_factor: {0.200, 0.375, 0.550, 0.725, 0.900} + @param weight: kg + @param args: Namespace containing: + gender: {'MALE', 'FEMALE'} + height: cm + age: float (years) + """ + + def gender_specific_bmr(_gender: Gender, _bmr: float) -> float: + _second_term = { + Gender.MALE: 5, + Gender.FEMALE: -161, + } + return _bmr + _second_term[_gender] + + gender = Gender.FEMALE if args.female_gender else Gender.MALE + print() + print("Gender: %s" % gender) + + height = float(args.height) + print("Height: %s cm" % height) + age = float(args.age) + print("Age: %s years" % age) + print() + + bmr = 10 * weight + 6.25 + 6.25 * height - 5 * age + + bmr = gender_specific_bmr(gender, bmr) + tdee = bmr * (1 + activity_factor) + + return { + "bmr": round(bmr), + "tdee": round(tdee), + } + + +def bmr_harris_benedict( + activity_factor: float, weight: float, args: argparse.Namespace +) -> dict: + """ + Harris-Benedict = (13.397m + 4.799h - 5.677a) + 88.362 (MEN) + + Harris-Benedict = (9.247m + 3.098h - 4.330a) + 447.593 (WOMEN) + + m: mass (kg), h: height (cm), a: age (years) + + Source: https://tdeecalculator.net/about.php + + @param activity_factor: {0.200, 0.375, 0.550, 0.725, 0.900} + @param weight: kg + @param args: Namespace containing: + gender: {'MALE', 'FEMALE'} + height: cm + age: float (years) + """ + + gender = Gender.FEMALE if args.female_gender else Gender.MALE + + height = float(args.height) + age = float(args.age) + + def gender_specific_bmr(_gender: Gender) -> float: + _gender_specific_bmr = { + Gender.MALE: (13.397 * weight + 4.799 * height - 5.677 * age) + 88.362, + Gender.FEMALE: (9.247 * weight + 3.098 * height - 4.330 * age) + 447.593, + } + return _gender_specific_bmr[_gender] + + bmr = gender_specific_bmr(gender) + tdee = bmr * (1 + activity_factor) + + return { + "bmr": round(bmr), + "tdee": round(tdee), + } + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Body fat +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +def bf_navy(gender: Gender, args: argparse.Namespace) -> float: + """ + @param gender: MALE or FEMALE + @param args: argparse namespace dict containing: + height, waist, neck, and (if female) hip. + All values are in cm. + + @return: float (e.g. 0.17) + + Source: + https://www.thecalculator.co/health/Navy-Method-Body-Fat-Measurement-Calculator-1112.html + """ + + # Navy values + height = float(args.height) + print() + print("Height: %s cm" % height) + + waist = float(args.waist) + print("Waist: %s cm" % waist) + if gender == Gender.FEMALE: + hip = float(args.hip) + print("Hip: %s cm" % hip) + else: + hip = 0.0 # placeholder value, not used for men anyway + neck = float(args.neck) + print("Neck: %s cm" % neck) + + # Compute values + _gender_specific_denominator = { + Gender.MALE: ( + 1.0324 - 0.19077 * math.log10(waist - neck) + 0.15456 * math.log10(height) + ), + Gender.FEMALE: ( + 1.29579 + - 0.35004 * math.log10(waist + hip - neck) + + 0.22100 * math.log10(height) + ), + } + + return round(495 / _gender_specific_denominator[gender] - 450, 2) + + +def bf_3site(gender: Gender, args: argparse.Namespace) -> float: + """ + @param gender: MALE or FEMALE + @param args: dict containing age, and skin manifolds (mm) for + chest, abdominal, and thigh. + + @return: float (e.g. 0.17) + + Source: + https://www.thecalculator.co/health/Body-Fat-Percentage-3-Site-Skinfold-Test-1113.html + """ + + # Shared parameters for skin manifold 3 & 7 site tests + age = float(args.age) + print() + print("Age: %s years" % age) + + # 3-Site values + chest = int(args.chest) + print("Chest: %s mm" % chest) + abd = int(args.abd) + print("Abdominal: %s mm" % abd) + thigh = int(args.thigh) + print("Thigh: %s mm" % thigh) + + # Compute values + st3 = chest + abd + thigh + _gender_specific_denominator = { + Gender.MALE: 1.10938 + - 0.0008267 * st3 + + 0.0000016 * st3 * st3 + - 0.0002574 * age, + Gender.FEMALE: 1.089733 + - 0.0009245 * st3 + + 0.0000025 * st3 * st3 + - 0.0000979 * age, + } + + return round(495 / _gender_specific_denominator[gender] - 450, 2) + + +def bf_7site(gender: Gender, args: argparse.Namespace) -> float: + """ + @param gender: MALE or FEMALE + @param args: dict containing age, and skin manifolds (mm) for + chest, abdominal, thigh, triceps, sub, sup, and mid. + + @return: float (e.g. 0.17) + + Source: + https://www.thecalculator.co/health/Body-Fat-Percentage-7-Site-Skinfold-Calculator-1115.html + """ + + # Shared parameters for skin manifold 3 & 7 site tests + age = float(args.age) + + # 3-Site values + chest = int(args.chest) + abd = int(args.abd) + thigh = int(args.thigh) + + # 7-Site values + tricep = int(args.tricep) + print() + print("Tricep: %s mm" % tricep) + sub = int(args.sub) + print("Sub: %s mm" % sub) + sup = int(args.sup) + print("Sup: %s mm" % sup) + mid = int(args.mid) + print("Mid: %s mm" % mid) + + # Compute values + st7 = chest + abd + thigh + tricep + sub + sup + mid + + _gender_specific_denominator = { + Gender.MALE: 1.112 + - 0.00043499 * st7 + + 0.00000055 * st7 * st7 + - 0.00028826 * age, + Gender.FEMALE: 1.097 + - 0.00046971 * st7 + + 0.00000056 * st7 * st7 + - 0.00012828 * age, + } + + return round(495 / _gender_specific_denominator[gender] - 450, 2) + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Lean body limits (young men) +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +def lbl_berkhan(height: float) -> dict: + """ + Calculate Martin Berkhan's lean body limit for young men. + + Source: https://rippedbody.com/maximum-muscular-potential/ + + @param height: cm + @return: {"condition": "...", weight_range: "abc ~ xyz"} + """ + + _min = round((height - 102) * 2.205, 1) + _max = round((height - 98) * 2.205, 1) + return {"condition": "Contest shape (5-6%)", "weight": "%s ~ %s lbs" % (_min, _max)} + + +def lbl_eric_helms(height: float, args: argparse.Namespace) -> dict: + """ + Calculate Eric Helm's lean body limit for young men. + + Source: + + @param height: cm + @param args: Namespace containing desired_bf, e.g. 0.12 + @return: {"condition": "...", weight_range: "abc ~ xyz"} + """ + + try: + desired_bf = float(args.desired_bf) * 100 + except (KeyError, TypeError): + return {"errMsg": "Eric Helms failed, requires: height, desired_bf."} + + _min = round(4851.00 * height * 0.01 * height * 0.01 / (100.0 - desired_bf), 1) + _max = round(5402.25 * height * 0.01 * height * 0.01 / (100.0 - desired_bf), 1) + return { + "condition": "%s%% body fat" % desired_bf, + "weight": "%s ~ %s lbs" % (_min, _max), + } + + +def lbl_casey_butt(height: float, args: argparse.Namespace) -> dict: + """ + Calculate Casey Butt's lean body limit for young men. Includes muscle measurements. + Some may find these controversial. + + Source: https://fastfoodmacros.com/maximum-muscular-potential-calculator.asp + + @param height: cm + @param args: Namespace containing desired_bf, and wrist & ankle circumference. + @return: dict with lbm, weight, and maximum measurements for muscle groups. + """ + + try: + height /= 2.54 + desired_bf = float(args.desired_bf) + + wrist = float(args.wrist) / 2.54 # convert cm --> inches + ankle = float(args.ankle) / 2.54 # convert cm --> inches + except (KeyError, TypeError): + return { + "errMsg": "Casey Butt failed, requires: height, desired_bf, wrist, & ankle." + } + + lbm = round( + height ** (3 / 2) + * (math.sqrt(wrist) / 22.6670 + math.sqrt(ankle) / 17.0104) + * (1 + desired_bf / 2.24), + 1, + ) + weight = round(lbm / (1 - desired_bf), 1) + + return { + "condition": "%s%% body fat" % (desired_bf * 100), + "weight": "%s lbs" % weight, + "lbm": "%s lbs" % lbm, + "chest": round(1.6817 * wrist + 1.3759 * ankle + 0.3314 * height, 2), + "arm": round(1.2033 * wrist + 0.1236 * height, 2), + "forearm": round(0.9626 * wrist + 0.0989 * height, 2), + "neck": round(1.1424 * wrist + 0.1236 * height, 2), + "thigh": round(1.3868 * ankle + 0.1805 * height, 2), + "calf": round(0.9298 * ankle + 0.1210 * height, 2), + } + + +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# Misc functions +# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +def _age(dob: int) -> float: + """ + Calculate age based on birthday. + + @param dob: birth time in UNIX seconds + @return: age in years + """ + now = datetime.now().timestamp() + years = (now - dob) / (365 * 24 * 3600) + return years diff --git a/ntclient/services/recipe.py b/ntclient/services/recipe.py deleted file mode 100644 index bfcc72c..0000000 --- a/ntclient/services/recipe.py +++ /dev/null @@ -1,131 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Created on Wed Aug 12 15:14:00 2020 - -@author: shane -""" -import csv -import os - -from tabulate import tabulate - -from ntclient.core.nutprogbar import nutprogbar -from ntclient.persistence.sql.nt.funcs import ( - sql_analyze_recipe, - sql_nt_next_index, - sql_recipe, - sql_recipes, -) -from ntclient.persistence.sql.usda.funcs import ( - sql_analyze_foods, - sql_food_details, - sql_nutrients_overview, -) - - -def recipes_overview() -> tuple: - """Shows overview for all recipes""" - recipes = sql_recipes()[1] - - results = [] - for recipe in recipes: - result = { - "id": recipe[0], - "name": recipe[2], - "tagname": recipe[1], - "n_foods": recipe[3], - "weight": recipe[4], - } - results.append(result) - - table = tabulate(results, headers="keys", tablefmt="presto") - print(table) - return 0, results - - -def recipe_overview(recipe_id: int) -> tuple: - """Shows single recipe overview""" - recipe = sql_analyze_recipe(recipe_id) - name = recipe[0][1] - print(name) - - food_ids_dict = {x[2]: x[3] for x in recipe} - food_ids = set(food_ids_dict.keys()) - food_names = {x[0]: x[3] for x in sql_food_details(food_ids)} - food_analyses = sql_analyze_foods(food_ids) - - table = tabulate( - [[food_names[food_id], grams] for food_id, grams in food_ids_dict.items()], - headers=["food", "g"], - ) - print(table) - # tabulate nutrient RDA %s - nutrients = sql_nutrients_overview() - # rdas = {x[0]: x[1] for x in nutrients.values()} - progbars = nutprogbar(food_ids_dict, food_analyses, nutrients) - print(progbars) - - return 0, recipe - - -def recipe_import(file_path: str) -> tuple: - """Import a recipe to SQL database""" - - def extract_id_from_filename(path: str) -> int: - filename = str(os.path.basename(path)) - if ( - "[" in filename - and "]" in filename - and filename.index("[") < filename.index("]") - ): - # TODO: try, raise: print/warn - return int(filename.split("[")[1].split("]")[0]) - return 0 # zero is falsy - - if os.path.isfile(file_path): - # TODO: better logic than this - recipe_id = extract_id_from_filename(file_path) or sql_nt_next_index("recipe") - print(recipe_id) - with open(file_path, encoding="utf-8") as file: - reader = csv.DictReader(file) - # headers = next(reader) - rows = list(reader) - print(rows) - else: # os.path.isdir() - print("not implemented ;]") - return 1, False - - -def recipe_add(name: str, food_amts: dict) -> tuple: - """Add a recipe to SQL database""" - print() - print("New recipe: " + name + "\n") - - food_ids = set(food_amts.keys()) - food_names = {x[0]: x[2] for x in sql_food_details(food_ids)} - - results = [] - for food_id, grams in food_amts.items(): - results.append([food_id, food_names[food_id], grams]) - - table = tabulate(results, headers=["id", "food_name", "grams"], tablefmt="presto") - print(table) - - confirm = input("\nCreate recipe? [Y/n] ") - - if confirm.lower() == "y": - print("not implemented ;]") - return 1, False - - -def recipe_delete(recipe_id: int) -> tuple: - """Deletes recipe by ID, along with any FK constraints""" - recipe = sql_recipe(recipe_id)[0] - - print(recipe[4]) - confirm = input("Do you wish to delete? [Y/n] ") - - if confirm.lower() == "y": - print("not implemented ;]") - return 1, False diff --git a/ntclient/services/recipe/__init__.py b/ntclient/services/recipe/__init__.py new file mode 100644 index 0000000..5086ced --- /dev/null +++ b/ntclient/services/recipe/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Aug 12 15:14:00 2020 + +@author: shane +""" +import os + +from ntclient import NUTRA_HOME, PROJECT_ROOT + +RECIPE_STOCK = os.path.join(PROJECT_ROOT, "resources", "recipe") + +_RECIPE_SUB_PATH = "recipe" +RECIPE_HOME = os.path.join(NUTRA_HOME, _RECIPE_SUB_PATH) diff --git a/ntclient/services/recipe/csv_utils.py b/ntclient/services/recipe/csv_utils.py new file mode 100644 index 0000000..39d48b0 --- /dev/null +++ b/ntclient/services/recipe/csv_utils.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" +Created on Fri Jul 22 15:33:57 2022 + +@author: shane +CSV utilities for reading and processing recipes. + +TODO: copy to & cache in sqlite3, only look to CSV if it doesn't exist? + Well then what if they edit CSV... gah. +""" +import glob + +from ntclient.services.recipe import RECIPE_HOME +from ntclient.utils import tree + + +def csv_files() -> list: + """Returns full filenames for everything under RECIPE_HOME'""" + return glob.glob(RECIPE_HOME + "/**/*.csv", recursive=True) + + +def csv_recipe_print_tree() -> None: + """Print off the recipe tree""" + tree.print_dir(RECIPE_HOME) + + +def csv_print_details() -> None: + """Print off details (as table)""" + print("Not implemented!") + + +def csv_recipes() -> tuple: + """ + Return overview & analysis of a selected recipe + TODO: separate methods to search by uuid OR file_name + """ + _csv_files = csv_files() + print(_csv_files) + return tuple(_csv_files) diff --git a/ntclient/services/recipe/utils.py b/ntclient/services/recipe/utils.py new file mode 100644 index 0000000..63dbafc --- /dev/null +++ b/ntclient/services/recipe/utils.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +""" +Created on Fri Jul 22 17:12:28 2022 + +@author: shane + +Supporting methods for service +""" +import os +import shutil + +from ntclient.models import Recipe +from ntclient.services.recipe import RECIPE_HOME, RECIPE_STOCK, csv_utils + + +def recipes_init(_force: bool = True) -> tuple: + """ + A filesystem function which copies the stock data into + os.path.join(NUTRA_HOME, "recipes") + TODO: put filesystem functions into separate module and ignore in coverage report. + + TODO: check other places, if the tuple is used or just return code. + And potentially create a function or class to return the tuple object + as a named tuple, and easily constructed & recognized. + @return: (exit_code: int, None) + """ + recipes_destination = os.path.join(RECIPE_HOME, "core") + + if _force and os.path.exists(recipes_destination): + print("WARN: force removing core recipes: %s" % recipes_destination) + # NOTE: is this best? + shutil.rmtree(recipes_destination, ignore_errors=True) + + try: + shutil.copytree(RECIPE_STOCK, recipes_destination) + return 0, None + except FileExistsError: + print("ERROR: file/directory exists: %s" % recipes_destination) + print(" remove it, or use the '-f' flag") + return 1, None + + +def recipes_overview() -> tuple: + """ + Shows overview for all recipes. + TODO: Accept recipes input Tuple[tuple], else read from disk. + TODO: option to print tree vs. detail view + + @return: (exit_code: int, None) + """ + + try: + csv_utils.csv_recipe_print_tree() + return 0, None + except FileNotFoundError: + print("WARN: no recipes found, create some or run: nutra recipe init") + return 1, None + + +def recipe_overview(recipe_path: str) -> tuple: + """ + Shows single recipe overview + + @param recipe_path: full path on disk + @return: (exit_code: int, None) + """ + + try: + _recipe = Recipe(recipe_path) + _recipe.process_data() + # TODO: extract relevant bits off, process, use nutprogbar (e.g. day analysis) + return 0, _recipe + except (FileNotFoundError, IndexError) as err: + print("ERROR: %s" % repr(err)) + return 1, None diff --git a/ntclient/services/usda.py b/ntclient/services/usda.py index 6e8494a..cefeb9b 100644 --- a/ntclient/services/usda.py +++ b/ntclient/services/usda.py @@ -10,9 +10,13 @@ import pydoc from tabulate import tabulate from ntclient import ( + CLI_CONFIG, DEFAULT_RESULT_LIMIT, DEFAULT_SEARCH_H_BUFFER, DEFAULT_SORT_H_BUFFER, + NUTR_ID_KCAL, + NUTR_IDS_AMINOS, + NUTR_IDS_FLAVONES, ) from ntclient.persistence.sql.usda.funcs import ( sql_analyze_foods, @@ -21,14 +25,11 @@ from ntclient.persistence.sql.usda.funcs import ( sql_nutrients_overview, sql_sort_helper1, ) -from ntclient.utils import NUTR_ID_KCAL, NUTR_IDS_AMINOS, NUTR_IDS_FLAVONES def list_nutrients() -> tuple: """Lists out nutrients with basic details""" - from ntclient import PAGING # pylint: disable=import-outside-toplevel - headers, nutrients = sql_nutrients_details() # TODO: include in SQL table cache? headers.append("avg_rda") @@ -42,7 +43,7 @@ def list_nutrients() -> tuple: nutrient.append(None) table = tabulate(nutrients, headers=headers, tablefmt="simple") - if PAGING: + if CLI_CONFIG.paging: pydoc.pager(table) else: print(table) diff --git a/ntclient/utils/__init__.py b/ntclient/utils/__init__.py index 0a03c1b..e69de29 100644 --- a/ntclient/utils/__init__.py +++ b/ntclient/utils/__init__.py @@ -1,98 +0,0 @@ -"""Constants and default settings""" -from colorama import Fore, Style - -################################################################################ -# Colors and buffer settings -################################################################################ - -# TODO: make configurable in SQLite or prefs.json - -THRESH_WARN = 0.7 -COLOR_WARN = Fore.YELLOW - -THRESH_CRIT = 0.4 -COLOR_CRIT = Style.DIM + Fore.RED - -THRESH_OVER = 1.9 -COLOR_OVER = Style.DIM + Fore.MAGENTA - -COLOR_DEFAULT = Fore.CYAN - -################################################################################ -# Nutrient IDs -################################################################################ -NUTR_ID_KCAL = 208 - -NUTR_ID_PROTEIN = 203 - -NUTR_ID_CARBS = 205 -NUTR_ID_SUGAR = 269 -NUTR_ID_FIBER = 291 - -NUTR_ID_FAT_TOT = 204 -NUTR_ID_FAT_SAT = 606 -NUTR_ID_FAT_MONO = 645 -NUTR_ID_FAT_POLY = 646 - -NUTR_IDS_FLAVONES = [ - 710, - 711, - 712, - 713, - 714, - 715, - 716, - 734, - 735, - 736, - 737, - 738, - 731, - 740, - 741, - 742, - 743, - 745, - 749, - 750, - 751, - 752, - 753, - 755, - 756, - 758, - 759, - 762, - 770, - 773, - 785, - 786, - 788, - 789, - 791, - 792, - 793, - 794, -] - -NUTR_IDS_AMINOS = [ - 501, - 502, - 503, - 504, - 505, - 506, - 507, - 508, - 509, - 510, - 511, - 512, - 513, - 514, - 515, - 516, - 517, - 518, - 521, -] diff --git a/ntclient/utils/colors.py b/ntclient/utils/colors.py new file mode 100644 index 0000000..e69de29 diff --git a/ntclient/utils/tree.py b/ntclient/utils/tree.py new file mode 100755 index 0000000..cb72461 --- /dev/null +++ b/ntclient/utils/tree.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Python 3 reimplementation of the linux 'tree' utility""" + +import os +import sys + +try: + from colorama import Fore, Style + from colorama import init as colorama_init + + COLORAMA_CAPABLE = True + colorama_init() +except ImportError: + COLORAMA_CAPABLE = False + +chars = {"nw": "\u2514", "nws": "\u251c", "ew": "\u2500", "ns": "\u2502"} + +strs = [ + chars["ns"] + " ", + chars["nws"] + chars["ew"] * 2 + " ", + chars["nw"] + chars["ew"] * 2 + " ", + " ", +] + +if COLORAMA_CAPABLE: + # Colors and termination strings + COLOR_DIR = Style.BRIGHT + Fore.BLUE + COLOR_EXEC = Style.BRIGHT + Fore.GREEN + COLOR_LINK = Style.BRIGHT + Fore.CYAN + COLOR_DEAD_LINK = Style.BRIGHT + Fore.RED +else: + COLOR_DIR = str() + COLOR_EXEC = str() + COLOR_LINK = str() + COLOR_DEAD_LINK = str() + + +def colorize(path: str, full: bool = False) -> str: + """Returns string with color / bold""" + file = path if full else os.path.basename(path) + + if os.path.islink(path): + return "".join( + [ + COLOR_LINK, + file, + Style.RESET_ALL, + " -> ", + colorize(os.readlink(path), full=True), + ] + ) + + if os.path.isdir(path): + return "".join([COLOR_DIR, file, Style.RESET_ALL]) + + if os.access(path, os.X_OK): + return "".join([COLOR_EXEC, file, Style.RESET_ALL]) + + return file + + +# Tree properties - display / print +SHOW_HIDDEN = False +SHOW_SIZE = False +FOLLOW_SYMLINKS = False + + +def print_dir(_dir: str, pre: str = str()) -> tuple: + """ + Prints the whole tree + + TODO: integrate with data sources to display more than just filenames + TODO: filter hidden files, non-CSV files, and hide *.csv extension from files + """ + n_dirs = 0 + n_files = 0 + n_size = 0 + + if not pre: + print(COLOR_DIR + _dir + Style.RESET_ALL) + + dir_len = len(os.listdir(_dir)) - 1 + for i, file in enumerate(sorted(os.listdir(_dir), key=str.lower)): + path = os.path.join(_dir, file) + if file[0] == "." and not SHOW_HIDDEN: + continue + if os.path.isdir(path): + print(pre + strs[2 if i == dir_len else 1] + colorize(path)) + if os.path.islink(path): + n_dirs += 1 + else: + n_d, n_f, n_s = print_dir(path, pre + strs[3 if i == dir_len else 0]) + n_dirs += n_d + 1 + n_files += n_f + n_size += n_s + else: + n_files += 1 + n_size += os.path.getsize(path) + print( + pre + + strs[2 if i == dir_len else 1] + + ("[{:>11}] ".format(n_size) if SHOW_SIZE else "") + + colorize(path) + ) + + # noinspection PyRedundantParentheses + return (n_dirs, n_files, n_size) + + +def main_tree(_args: list = None) -> int: + """Handle input arguments, print off tree""" + n_dirs = 0 + n_files = 0 + + if not _args: + _args = sys.argv + + if len(_args) == 1: + # Used for development + n_dirs, n_files, _size = print_dir("../resources") + else: + for _dir in _args[1:]: + n_d, n_f, _size = print_dir(_dir) + n_dirs += n_d + n_files += n_f + + print() + print( + "{} director{}, {} file{}".format( + n_dirs, "ies" if n_dirs > 1 else "y", n_files, "s" if n_files > 1 else "" + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main_tree()) diff --git a/nutra b/nutra index ba0eb31..ff1e9da 100755 --- a/nutra +++ b/nutra @@ -4,7 +4,7 @@ """ Created on Fri Sep 28 22:25:38 2018 -@author: gamesguru +@author: shane """ import sys diff --git a/requirements-old.txt b/requirements-old.txt new file mode 100644 index 0000000..73afc09 --- /dev/null +++ b/requirements-old.txt @@ -0,0 +1,4 @@ +argcomplete==1.8.0 +colorama==0.3.6 +fuzzywuzzy==0.3.0 +tabulate==0.4.3 diff --git a/requirements-test-win_xp-ubu1604.txt b/requirements-test-old.txt similarity index 83% rename from requirements-test-win_xp-ubu1604.txt rename to requirements-test-old.txt index 39fde64..9b665e2 100644 --- a/requirements-test-win_xp-ubu1604.txt +++ b/requirements-test-old.txt @@ -1,3 +1,3 @@ # Don't update these, they are the last supported versions on winXP, Ubuntu 16.04 & Python 3.4 -coverage==5.5 +coverage<=5.5,>=4.5.4 pytest==3.2.5 diff --git a/requirements.txt b/requirements.txt index 575e698..1548cc4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -argcomplete<=1.12.3,>=1.8.0 -colorama<=0.4.1,>=0.1.16 -fuzzywuzzy<=0.18.0,>=0.3.0 -tabulate<=0.8.9,>=0.4.3 +argcomplete>=1.8.0 +colorama>=0.3.6,<=0.4.1 +fuzzywuzzy>=0.3.0 +tabulate>=0.4.3 diff --git a/setup.cfg b/setup.cfg index 960849d..aa3344d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,11 +3,17 @@ source = ntclient [coverage:report] fail_under = 80 +; precision = 2 show_missing = True skip_empty = True skip_covered = True +omit = + # Unlike the server & db, the CLI doesn't call the sql module. + # It directly imports the `build_ntsqlite()` function. + ntclient/ntsqlite/sql/__main__.py, + [pycodestyle] @@ -18,7 +24,7 @@ max-line-length = 88 [flake8] per-file-ignores = # Allow unused imports in __init__.py files - __init__.py:F401 + ; __init__.py:F401, max-line-length = 88 @@ -28,12 +34,12 @@ ignore = [isort] -line_length=88 -known_first_party=ntclient +line_length = 88 +known_first_party = ntclient # See: https://copdips.com/2020/04/making-isort-compatible-with-black.html -multi_line_output=3 -include_trailing_comma=true +multi_line_output = 3 +include_trailing_comma = True @@ -66,4 +72,3 @@ ignore_missing_imports = True # 3rd party packages missing types [mypy-argcomplete,colorama,coverage,fuzzywuzzy,psycopg2.*,setuptools] ignore_missing_imports = True - diff --git a/setup.py b/setup.py index ca2ef8e..cd14570 100644 --- a/setup.py +++ b/setup.py @@ -42,11 +42,12 @@ CLASSIFIERS = [ "Programming Language :: Unix Shell", ] -# Read me +# ReadMe with open("README.rst", encoding="utf-8") as file: README = file.read() # Requirements +# TODO: check PY_SYS_VER, and decide which requirements for e.g. 3.4, 3.6, 3.10, etc... with open("requirements.txt", encoding="utf-8") as file: REQUIREMENTS = file.read().split() @@ -65,7 +66,7 @@ kwargs = { "install_requires": REQUIREMENTS, "python_requires": ">=%s" % PY_MIN_STR, "zip_safe": False, - "packages": find_packages(exclude=["tests"]), + "packages": find_packages(exclude=["tests", "ntclient.docs"]), "include_package_data": True, "platforms": ["linux", "darwin", "win32"], "description": "Home and office nutrient tracking software", diff --git a/tests/__main__.py b/tests/__main__.py deleted file mode 100644 index 895d6d6..0000000 --- a/tests/__main__.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Allows contributors to run tests on their machine (and in GitHub actions / Travis CI) -""" -import os -import subprocess # nosec: B404 -import sys - -import coverage - - -def main() -> int: - """ - Main test method, callable with `python -m tests` - - 1. Calls a subprocess for `coverage run` - 2. Programmatically invokes coverage.report() - """ - - cmd = "coverage run -m pytest -v -s -p no:cacheprovider tests/" - print(cmd) - subprocess.call(cmd.split(), shell=False) # nosec: B603 - - print("\ncoverage report -m --skip-empty") - cov = coverage.Coverage() - cov.load() - cov.report(show_missing=True, skip_empty=True) - - # Try to clean up - try: - os.remove(".coverage") - except (FileNotFoundError, PermissionError) as error: - print("WARN: failed to remove `.coverage`, %s" % repr(error)) - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/services/test_recipe.py b/tests/services/test_recipe.py new file mode 100644 index 0000000..b277857 --- /dev/null +++ b/tests/services/test_recipe.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" +Created on Wed Jul 20 21:36:43 2022 + +@author: shane +""" +import os +import unittest + +import pytest + +import ntclient.services.recipe.utils as r +from ntclient.services.recipe import RECIPE_STOCK + + +class TestRecipe(unittest.TestCase): + """Tests the recipe service""" + + def test_recipes_init(self): + """Checks the init function, which copies over default data (if not already)""" + + exit_code, _result = r.recipes_init(_force=False) + assert exit_code in {0, 1} + + exit_code, _result = r.recipes_init(_force=True) + assert exit_code in {0, 1} + + def test_recipes_overview(self): + """Test type coercion and one-to-one input/output relationship""" + + exit_code, _ = r.recipes_overview() + assert exit_code == 0 + + @unittest.expectedFailure + @pytest.mark.xfail(reason="Due to a wip refactor") + def test_recipe_overview_throws_exc_for_nonexistent_path(self): + """Raises index error if recipe int id is invalid""" + + # TODO: should we be using guid / uuid instead of integer id? + with pytest.raises(IndexError): + r.recipe_overview("-12345-FAKE-PATH-") + + def test_recipe_overview_might_succeed_for_maybe_existing_id(self): + """Tries check for existing ID, but only can if the user initialized""" + exit_code, _ = r.recipe_overview( + os.path.join(RECIPE_STOCK, "dinner", "burrito-bowl.csv") + ) + assert exit_code in {0, 1} diff --git a/tests/test_cli.py b/tests/test_cli.py index fa0583d..887203f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,24 +1,27 @@ # -*- coding: utf-8 -*- """ +Most of the original tests for the CLI package were developed here. +Need to offload them into special modules. The refactor has started. + Created on Fri Jan 31 15:19:53 2020 @author: shane """ -# pylint: disable=wrong-import-position import os import sys +import unittest import pytest from ntclient import ( + CLI_CONFIG, NTSQLITE_BUILDPATH, NUTRA_HOME, USDA_DB_NAME, __db_target_nt__, __db_target_usda__, - set_flags, ) -from ntclient.__main__ import build_argparser +from ntclient.__main__ import build_arg_parser from ntclient.__main__ import main as nt_main from ntclient.core import nutprogbar from ntclient.ntsqlite.sql import build_ntsqlite @@ -27,269 +30,390 @@ from ntclient.persistence.sql.nt.funcs import sql_nt_next_index from ntclient.persistence.sql.usda import funcs as usda_funcs from ntclient.persistence.sql.usda import sql as _usda_sql from ntclient.persistence.sql.usda import usda_ver -from ntclient.services import init +from ntclient.services import init, usda +from ntclient.services.recipe import RECIPE_HOME from ntclient.utils.exceptions import SqlInvalidVersionError TEST_HOME = os.path.dirname(os.path.abspath(__file__)) -# NOTE: this doesn't work currently, b/c it's already read up (in imports above) -# We're just setting it on the shell, as an env var -# os.environ["NUTRA_HOME"] = os.path.join(TEST_HOME, ".nutra.test") - -# TODO: integration tests.. create user, recipe, log.. analyze & compare -arg_parser = build_argparser() - - -def test_000_init(): - """Tests the SQL/persistence init in real time""" - code, result = init(yes=True) - assert code == 0 - assert result - - -def test_100_usda_sql_funcs(): - """Performs cursory inspection (sanity checks) of usda.sqlite3 image""" - version = usda_ver() - assert version == __db_target_usda__ - result = usda_funcs.sql_nutrients_details() - assert len(result[1]) == 186 - - result = usda_funcs.sql_servings({9050, 9052}) - assert len(result) == 3 - - result = usda_funcs.sql_analyze_foods({23567, 23293}) - assert len(result) == 188 - - result = usda_funcs.sql_sort_foods(789) - assert len(result) == 415 - # result = usda_funcs.sql_sort_foods(789, fdgrp_ids=[100]) - # assert len(result) == 1 - - result = usda_funcs.sql_sort_foods_by_kcal(789) - assert len(result) == 246 - # result = usda_funcs.sql_sort_foods_by_kcal(789, fdgrp_ids=[1100]) - # assert len(result) == 127 - - -def test_200_nt_sql_funcs(): - """Performs cursory inspection (sanity check) of nt.sqlite3 image""" - version = nt_ver() - assert version == __db_target_nt__ - - next_index = sql_nt_next_index("bf_eq") - assert next_index > 0 - - # TODO: add more tests; we used to comb over biometrics here - - -def test_300_argparser_debug_no_paging(): - """Verifies the debug and no_paging flags are set""" - args = arg_parser.parse_args(args=["-d", "--no-pager"]) - set_flags(args) - - assert args.debug is True - assert args.no_pager is True - - from ntclient import DEBUG, PAGING # pylint: disable=import-outside-toplevel - - assert DEBUG is True - assert PAGING is False - - -def test_400_usda_argparser_funcs(): - """Tests udsa functions in argparser.funcs (to varying degrees each)""" - # Init - args = arg_parser.parse_args(args=["init", "-y"]) - assert args.yes is True - code, result = args.func(args=args) - assert code == 0 - assert result - - # Nutrients ( and `--no-pager` flag) - args = arg_parser.parse_args(args=["--no-pager", "nt"]) - set_flags(args) # unnecessary due to already happening, but hey - code, result = args.func() - assert code == 0 - assert len(result) == 186 - - # Search - args = arg_parser.parse_args(args=["search", "grass", "beef"]) - code, result = args.func(args) - assert code == 0 - assert result - # Top 20 (beats injecting BUFFER_HT/DEFAULT_RESULT_LIMIT) - args = arg_parser.parse_args(args=["search", "grass", "beef", "-t", "20"]) - code, result = args.func(args) - assert code == 0 - assert len(result) == 20 - assert result[0]["long_desc"] is not None - - # Sort - args = arg_parser.parse_args(args=["sort", "789"]) - code, result = args.func(args) - assert code == 0 - assert result - # Top 20 - args = arg_parser.parse_args(args=["sort", "789", "-t", "20"]) - code, result = args.func(args) - assert code == 0 - assert len(result) == 20 - assert result[0][4] == "Capers, raw" - - # Anl - args = arg_parser.parse_args(args=["anl", "9053"]) - code, nutrients_rows, servings_rows = args.func(args) - assert code == 0 - assert len(nutrients_rows[0]) == 30 - assert len(servings_rows[0]) == 1 - - # Day - rda_csv_path = os.path.join(TEST_HOME, "resources", "rda", "dog-18lbs.csv") - day_csv_path = os.path.join(TEST_HOME, "resources", "day", "dog.csv") - args = arg_parser.parse_args(args=["day", "-r", rda_csv_path, day_csv_path]) - code, result = args.func(args) - assert code == 0 - assert result[0][213] == 1.295 - assert len(result[0]) == 177 - - -def test_401_invalid_path_day_throws_error(): - """Ensures invalid path throws exception in `day` subcommand""" - invalid_day_csv_path = os.path.join( - TEST_HOME, "resources", "day", "__NONEXISTENT_CSV_FILE__.csv" - ) - with pytest.raises(SystemExit) as sys_exit: - arg_parser.parse_args(args=["day", invalid_day_csv_path]) - assert sys_exit.value.code == 2 - - invalid_rda_csv_path = os.path.join( - TEST_HOME, "resources", "rda", "__NONEXISTENT_CSV_FILE__.csv" - ) - with pytest.raises(SystemExit) as sys_exit: - arg_parser.parse_args( - args=["day", "-r", invalid_rda_csv_path, invalid_day_csv_path] +arg_parser = build_arg_parser() + + +# TODO: attach some env props to it, and re-instantiate a CliConfig() class. +# We're just setting it on the shell, as an env var, before running tests in CI. +# e.g. the equivalent of putting this early in the __init__ file; +# os.environ["NUTRA_HOME"] = os.path.join(TEST_HOME, ".nutra.test") + + +class TestCli(unittest.TestCase): + """ + Original one-stop-shop for testing. + @todo: integration tests.. create user, recipe, log.. analyze & compare + """ + + def test_000_init(self): + """Tests the SQL/persistence init in real time""" + code, result = init(yes=True) + assert code == 0 + assert result + + def test_100_usda_sql_funcs(self): + """Performs cursory inspection (sanity checks) of usda.sqlite3 image""" + version = usda_ver() + assert version == __db_target_usda__ + result = usda_funcs.sql_nutrients_details() + assert len(result[1]) == 186 + + result = usda_funcs.sql_servings({9050, 9052}) + assert len(result) == 3 + + result = usda_funcs.sql_analyze_foods({23567, 23293}) + assert len(result) == 188 + + result = usda_funcs.sql_sort_foods(789) + assert len(result) == 415 + # result = usda_funcs.sql_sort_foods(789, fdgrp_ids=[100]) + # assert len(result) == 1 + + result = usda_funcs.sql_sort_foods_by_kcal(789) + assert len(result) == 246 + # result = usda_funcs.sql_sort_foods_by_kcal(789, fdgrp_ids=[1100]) + # assert len(result) == 127 + + def test_200_nt_sql_funcs(self): + """Performs cursory inspection (sanity check) of nt.sqlite3 image""" + version = nt_ver() + assert version == __db_target_nt__ + + next_index = sql_nt_next_index("bf_eq") + assert next_index > 0 + + # TODO: add more tests; we used to comb over biometrics here + + def test_300_argparser_debug_no_paging(self): + """Verifies the debug and no_paging flags are set""" + args = arg_parser.parse_args(args=["-d", "--no-pager"]) + CLI_CONFIG.set_flags(args) + + assert args.debug is True + assert args.no_pager is True + + assert CLI_CONFIG.debug is True + assert CLI_CONFIG.paging is False + + def test_400_usda_argparser_funcs(self): + """Tests udsa functions in argparser.funcs (to varying degrees each)""" + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Init + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + args = arg_parser.parse_args(args=["init", "-y"]) + assert args.yes is True + code, result = args.func(args=args) + assert code == 0 + assert result + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Nutrients ( and `--no-pager` flag) + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + args = arg_parser.parse_args(args=["--no-pager", "nt"]) + CLI_CONFIG.set_flags(args) # unnecessary due to already happening, but hey + code, result = args.func() + assert code == 0 + assert len(result) == 186 + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Search + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + args = arg_parser.parse_args(args=["search", "grass", "beef"]) + code, result = args.func(args) + assert code == 0 + assert result + + # Top 20 (beats injecting BUFFER_HT/DEFAULT_RESULT_LIMIT) + # -------------------- + args = arg_parser.parse_args(args=["search", "grass", "beef", "-t", "20"]) + code, result = args.func(args) + assert code == 0 + assert len(result) == 20 + assert result[0]["long_desc"] is not None + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Sort + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + args = arg_parser.parse_args(args=["sort", "789"]) + code, result = args.func(args) + assert code == 0 + assert result + + # Top 20 + # -------------------- + args = arg_parser.parse_args(args=["sort", "789", "-t", "20"]) + code, result = args.func(args) + assert code == 0 + assert len(result) == 20 + assert result[0][4] == "Capers, raw" + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Anl + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + args = arg_parser.parse_args(args=["anl", "9053"]) + code, nutrients_rows, servings_rows = args.func(args) + assert code == 0 + assert len(nutrients_rows[0]) == 30 + assert len(servings_rows[0]) == 1 + + def test_410_nt_argparser_funcs(self): + """Tests nt functions in argparser.funcs (to varying degrees each)""" + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Day + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + rda_csv_path = os.path.join(TEST_HOME, "resources", "rda", "dog-18lbs.csv") + day_csv_path = os.path.join(TEST_HOME, "resources", "day", "dog.csv") + args = arg_parser.parse_args(args=["day", "-r", rda_csv_path, day_csv_path]) + code, result = args.func(args) + assert code == 0 + assert result[0][213] == 1.295 + assert len(result[0]) == 177 + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Recipe + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + args = arg_parser.parse_args(args=["recipe", "init", "-f"]) + code, _ = args.func(args) + assert code == 0 + + # Recipes overview + # -------------------- + args = arg_parser.parse_args(args=["recipe"]) + code, _ = args.func() + assert code == 0 + + # Detail view (one recipe) + # -------------------- + args = arg_parser.parse_args( + args=[ + "recipe", + "anl", + os.path.join(RECIPE_HOME, "core", "dinner", "burrito-bowl.csv"), + ] ) - assert sys_exit.value.code == 2 - - -def test_402_nt_argparser_funcs(): - """Tests nt functions in argparser.funcs (to varying degrees each)""" - - -def test_500_main_module(): - """Tests execution of main() and __main__, in __main__.py""" - code = nt_main(args=["--no-pager", "nt"]) - assert code == 0 - - sys.argv = ["./nutra"] - code = nt_main() - assert code == 0 - - with pytest.raises(SystemExit) as system_exit: - nt_main(args=["-h"]) - assert system_exit.value.code == 0 - - # __main__: if args_dict - code = nt_main(args=["anl", "9053", "-g", "80"]) - assert code == 0 - + code, _ = args.func(args) + assert code == 0 + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Calc + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + # 1rm + # ----------------------------------- + args = arg_parser.parse_args(args=["calc", "1rm", "225", "12"]) + code, _ = args.func(args) + assert code == 0 + + # Invalid range for dos_remedios (11 instead of 12) + args = arg_parser.parse_args(args=["calc", "1rm", "225", "11"]) + code, result = args.func(args) + assert code == 0 + assert set(result.keys()) == {"epley", "brzycki", "dos_remedios"} + assert "errMsg" in result["dos_remedios"] + + # BMR + # ----------------------------------- + args = arg_parser.parse_args( + args="calc bmr -a 29 -wt 75 -ht 179 -bf 0.11 -x 3".split() + ) + code, _ = args.func(args) + assert code == 0 + + # Failed / missing optional: height & body_fat (provoke exceptions) + args = arg_parser.parse_args(args="calc bmr -a 29 -wt 75 -x 3".split()) + code, _ = args.func(args) + assert code in {0, 1} + + # Body fat + # ----------------------------------- + + # Navy only + args = arg_parser.parse_args(args="calc bf -ht 178 -w 80 -n 40".split()) + code, result = args.func(args) + assert code == 0 + assert result["navy"] == 10.64 + + # Invalid (failed Navy) + args = arg_parser.parse_args(args="-d calc bf -w 80 -n 40".split()) + CLI_CONFIG.set_flags(args) + code, result = args.func(args) + assert code in {0, 1} # Might be a failed code one day, but returns 0 for now + + # All + args = arg_parser.parse_args( + args="calc bf -ht 179 -w 80 -n 40 -a 29 7 13 10 9 9 11 10".split() + ) + code, result = args.func(args) + assert code == 0 + assert result["navy"] == 10.48 + assert result["threeSite"] == 8.95 + assert result["sevenSite"] == 9.93 + + # Female test + # TODO: better values, and don't require hip above (it's 0) + args = arg_parser.parse_args( + args="calc bf -F -a 29 -ht 178 -w 70 -hip 100 -n 35 " + "15 23 19 14 11 10 9".split() + ) + code, result = args.func(args) + assert code == 0 + assert result["navy"] == 22.58 + + # Lean body limits (young men) + # ----------------------------------- + args = arg_parser.parse_args(args="calc lbl 179 0.1 17.2 21.5".split()) + code, result = args.func(args) + assert code == 0 + # NOTE: wip + print(result) + + def test_415_invalid_path_day_throws_error(self): + """Ensures invalid path throws exception in `day` subcommand""" + invalid_day_csv_path = os.path.join( + TEST_HOME, "resources", "day", "__NONEXISTENT_CSV_FILE__.csv" + ) + with pytest.raises(SystemExit) as sys_exit: + arg_parser.parse_args(args=["day", invalid_day_csv_path]) + assert sys_exit.value.code == 2 -def test_600_sql_integrity_error__service_wip(): - """Provokes IntegrityError in nt.sqlite3""" + invalid_rda_csv_path = os.path.join( + TEST_HOME, "resources", "rda", "__NONEXISTENT_CSV_FILE__.csv" + ) + with pytest.raises(SystemExit) as sys_exit: + arg_parser.parse_args( + args=["day", "-r", invalid_rda_csv_path, invalid_day_csv_path] + ) + assert sys_exit.value.code == 2 + + def test_500_main_module(self): + """Tests execution of main() and __main__, in __main__.py""" + code = nt_main(args=["--no-pager", "nt"]) + assert code == 0 + + # Injection test + sys.argv = ["./nutra"] + code = nt_main() + assert code == 0 + + # -h + with pytest.raises(SystemExit) as system_exit: + nt_main(args=["-h"]) + assert system_exit.value.code == 0 + + # -d + code = nt_main(args=["-d"]) + assert code == 0 + + # __main__: if args_dict + code = nt_main(args=["anl", "9053", "-g", "80"]) + assert code == 0 + + # nested sub-command with no args + code = nt_main(args=["calc"]) + assert code == 0 + + @unittest.skip(reason="Vestigial stub, needs replacement / updating.") + def test_600_sql_integrity_error__service_wip(self): + """Provokes IntegrityError in nt.sqlite3""" + + # TODO: replace with non-biometric test + # from ntclient.services import biometrics + # + # args = arg_parser.parse_args(args=["-d", "bio", "log", "add", "12,12"]) + # biometrics.input = ( + # lambda x: "y" + # ) # mocks input, could also pass `-y` flag or set yes=True + # + # with pytest.raises(sqlite3.IntegrityError) as integrity_error: + # args.func(args) + # assert ( + # integrity_error.value.args[0] + # == "NOT NULL constraint failed: biometric_log.profile_id" + # ) + + def test_700_build_ntsqlite_succeeds(self): + """Verifies the service level call for git submodule""" + try: + os.remove(NTSQLITE_BUILDPATH) + except FileNotFoundError: + pass + assert not os.path.exists(NTSQLITE_BUILDPATH) + + result = build_ntsqlite(verbose=True) + assert result is True + assert os.path.isfile(NTSQLITE_BUILDPATH) + os.remove(NTSQLITE_BUILDPATH) - # TODO: replace with non-biometric test - # from ntclient.services import biometrics # pylint: disable=import-outside-toplevel - # - # args = arg_parser.parse_args(args=["-d", "bio", "log", "add", "12,12"]) - # biometrics.input = ( - # lambda x: "y" - # ) # mocks input, could also pass `-y` flag or set yes=True - # - # with pytest.raises(sqlite3.IntegrityError) as integrity_error: - # args.func(args) - # assert ( - # integrity_error.value.args[0] - # == "NOT NULL constraint failed: biometric_log.profile_id" - # ) + @unittest.skip(reason="Long-running test, want to replace with more 'unit' style") + def test_800_usda_upgrades_or_downgrades(self): + """Ensures the static usda.sqlite3 file can be upgraded/downgraded as needed""" + version = usda_ver() + major, minor, release = version.split(".") + new_release = str(int(release) + 1) + new_version = ".".join([major, minor, new_release]) + _usda_sql( + "INSERT INTO version (version) VALUES (?)", + values=(new_version,), + version_check=False, + ) + code, successful = init(yes=True) + assert code == 0 + assert successful is True -def test_700_build_ntsqlite_succeeds(): - """Verifies the service level call for git submodule""" - try: - os.remove(NTSQLITE_BUILDPATH) - except FileNotFoundError: - pass - assert not os.path.exists(NTSQLITE_BUILDPATH) - - result = build_ntsqlite(verbose=True) - assert result is True - assert os.path.isfile(NTSQLITE_BUILDPATH) - os.remove(NTSQLITE_BUILDPATH) - - -def test_800_usda_upgrades_or_downgrades(): - """Ensures the static usda.sqlite3 file can be upgraded/downgraded as needed""" - version = usda_ver() - major, minor, release = version.split(".") - new_release = str(int(release) + 1) - new_version = ".".join([major, minor, new_release]) - _usda_sql( - "INSERT INTO version (version) VALUES (?)", - values=(new_version,), - version_check=False, - ) - - code, successful = init(yes=True) - assert code == 0 - assert successful is True - - -def test_801_sql_invalid_version_error_if_version_old(): - """Throws base custom SqlException... - TODO: why lines still missing in `coverage` for __main__ ?""" - _usda_sql( - "DELETE FROM version WHERE version=?", - values=(__db_target_usda__,), - version_check=False, - ) - - with pytest.raises(SqlInvalidVersionError) as sql_invalid_version_error: - nt_main(["-d", "nt"]) - assert sql_invalid_version_error is not None - - -def test_802_usda_downloads_fresh_if_missing_or_deleted(): - """Ensure download of usda.sqlite3.tar.xz, if usda.sqlite3 is missing""" - from ntclient.persistence.sql import usda # pylint: disable=import-outside-toplevel - - # TODO: similar for nt.sqlite3? - # Define development standards.. rebuilding, deleting, preserving - # remove whole `.nutra` in a special test? - try: - # TODO: export USDA_DB_PATH at package level, - # don't pepper os.path.join() throughout code? - usda_path = os.path.join(NUTRA_HOME, USDA_DB_NAME) - os.remove(usda_path) - except (FileNotFoundError, PermissionError) as err: - # TODO: resolve PermissionError on Windows - print(repr(err)) + @unittest.skip(reason="Long-running test, want to replace with more 'unit' style") + def test_801_sql_invalid_version_error_if_version_old(self): + """Throws base custom SqlException... + TODO: why lines still missing in `coverage` for __main__ ?""" _usda_sql( - "INSERT INTO version (version) VALUES (?)", + "DELETE FROM version WHERE version=?", values=(__db_target_usda__,), version_check=False, ) - pytest.xfail("PermissionError, are you using Microsoft Windows?") - - usda.input = lambda x: "y" # mocks input, could also pass `-y` flag or set yes=True - code, successful = init() - assert code == 0 - assert successful is True - - -def test_900_nut_rda_bar(): - """Verifies colored/visual output is correctly generated""" - analysis = usda_funcs.sql_analyze_foods(food_ids={1001}) - nutrients = usda_funcs.sql_nutrients_overview() - output = nutprogbar.nutprogbar( - food_amts={1001: 100}, food_analyses=analysis, nutrients=nutrients - ) - assert output + + with pytest.raises(SqlInvalidVersionError) as sql_invalid_version_error: + nt_main(["-d", "nt"]) + assert sql_invalid_version_error is not None + + @unittest.skip(reason="Long-running test, want to replace with more 'unit' style") + def test_802_usda_downloads_fresh_if_missing_or_deleted(self): + """Ensure download of usda.sqlite3.tar.xz, if usda.sqlite3 is missing""" + + # TODO: similar for nt.sqlite3? + # Define development standards.. rebuilding, deleting, preserving + # remove whole `.nutra` in a special test? + try: + # TODO: export USDA_DB_PATH at package level, + # don't pepper os.path.join() throughout code? + usda_path = os.path.join(NUTRA_HOME, USDA_DB_NAME) + os.remove(usda_path) + except (FileNotFoundError, PermissionError) as err: + # TODO: resolve PermissionError on Windows + print(repr(err)) + _usda_sql( + "INSERT INTO version (version) VALUES (?)", + values=(__db_target_usda__,), + version_check=False, + ) + pytest.xfail("PermissionError, are you using Microsoft Windows?") + + # mocks input, could also pass `-y` flag or set yes=True + usda.input = lambda x: "y" + + code, successful = init() + assert code == 0 + assert successful is True + + def test_900_nut_rda_bar(self): + """Verifies colored/visual output is successfully generated""" + analysis = usda_funcs.sql_analyze_foods(food_ids={1001}) + nutrients = usda_funcs.sql_nutrients_overview() + output = nutprogbar.nutprogbar( + food_amts={1001: 100}, food_analyses=analysis, nutrients=nutrients + ) + assert output diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/utils/test_tree.py b/tests/utils/test_tree.py new file mode 100644 index 0000000..b65a481 --- /dev/null +++ b/tests/utils/test_tree.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +""" +Created on Sat Jul 23 09:28:33 2022 + +@author: shane +""" +import unittest + +import pytest + +from ntclient.services.recipe import RECIPE_STOCK +from ntclient.utils import tree + + +class TestTree(unittest.TestCase): + """Try to test remaining bits of tree.py""" + + def test_tree_main(self): + """Tests the main function (mostly a command line utility) for tree.py""" + with pytest.raises(FileNotFoundError): + tree.main_tree() + + def test_tree_main_with_args(self): + """Tests the main function (mostly a command line utility) for tree.py""" + exit_code = tree.main_tree(_args=["tree.py", RECIPE_STOCK]) + assert exit_code == 0