general cleanup, add calculators (#13)
authorShane Jaroch <chown_tee@proton.me>
Thu, 28 Jul 2022 09:01:05 +0000 (05:01 -0400)
committerGitHub <noreply@github.com>
Thu, 28 Jul 2022 09:01:05 +0000 (05:01 -0400)
56 files changed:
.editorconfig
.github/workflows/coveralls.yml [deleted file]
.github/workflows/install-linux.yml [moved from .github/workflows/test-linux.yml with 73% similarity]
.github/workflows/test-win32.yml [deleted file]
.github/workflows/test.yml [new file with mode: 0644]
.pylintrc
CHANGELOG.rst
MANIFEST.in
Makefile
README.rst
ntclient/LICENSE [deleted file]
ntclient/__init__.py
ntclient/__main__.py
ntclient/argparser/__init__.py
ntclient/argparser/funcs.py
ntclient/argparser/types.py
ntclient/core/nnest.py
ntclient/core/nnr2.py
ntclient/models/__init__.py [new file with mode: 0644]
ntclient/ntsqlite
ntclient/persistence/__init__.py
ntclient/persistence/sql/__init__.py
ntclient/persistence/sql/nt/funcs.py
ntclient/persistence/sql/usda/__init__.py
ntclient/persistence/sql/usda/funcs.py
ntclient/resources/recipe/dinner/burrito-bowl.csv [new file with mode: 0644]
ntclient/resources/recipe/dinner/grass-fed-burger.csv [new file with mode: 0644]
ntclient/resources/recipe/snack/baked-potato-wedges.csv [new file with mode: 0644]
ntclient/resources/recipe/snack/buckwheat-pancake.csv [new file with mode: 0644]
ntclient/resources/recipe/snack/fruit-smoothie.csv [new file with mode: 0644]
ntclient/resources/recipe/tmp/food_amounts.csv [new file with mode: 0644]
ntclient/resources/recipe/tmp/names.csv [new file with mode: 0644]
ntclient/resources/recipe/tmp/servings.csv [new file with mode: 0644]
ntclient/services/__init__.py
ntclient/services/analyze.py
ntclient/services/calculate.py [new file with mode: 0644]
ntclient/services/recipe.py [deleted file]
ntclient/services/recipe/__init__.py [new file with mode: 0644]
ntclient/services/recipe/csv_utils.py [new file with mode: 0644]
ntclient/services/recipe/utils.py [new file with mode: 0644]
ntclient/services/usda.py
ntclient/utils/__init__.py
ntclient/utils/colors.py [new file with mode: 0644]
ntclient/utils/tree.py [new file with mode: 0755]
nutra
requirements-old.txt [new file with mode: 0644]
requirements-test-old.txt [moved from requirements-test-win_xp-ubu1604.txt with 83% similarity]
requirements.txt
setup.cfg
setup.py
tests/__main__.py [deleted file]
tests/services/__init__.py [new file with mode: 0644]
tests/services/test_recipe.py [new file with mode: 0644]
tests/test_cli.py
tests/utils/__init__.py [new file with mode: 0644]
tests/utils/test_tree.py [new file with mode: 0644]

index 149ee88b364039c024e8181966075408cbfe3232..11b1d231fa1772b59c27f31ecefd782dd6179b95 100644 (file)
@@ -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 (file)
index 10deae0..0000000
+++ /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
similarity index 73%
rename from .github/workflows/test-linux.yml
rename to .github/workflows/install-linux.yml
index 7018608d1ae3d533a90b56899bcae6cd23f0004b..40a08a4e2976cc510e8bd8bdb627dd4da804fe51 100644 (file)
@@ -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 (file)
index 0e50030..0000000
+++ /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 (file)
index 0000000..ebac87d
--- /dev/null
@@ -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
index e7345a88cc4b2470be4670db8d5b65848242950d..bb0f16ac24c26378fd353363f879fc13b4e3dfdb 100644 (file)
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,6 +1,6 @@
 [MASTER]
 
-fail-under=9.5
+fail-under=9.75
 
 
 [MESSAGES CONTROL]
index 5482a8c4ac70b12df7bf8d2446c937a30d6c22b2..892dea3bf310a1727b0deb50594e03315d05105a 100644 (file)
@@ -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 <https://semver.org/spec/v2.0.0
 Added
 ~~~~~
 
-- Basic functionality of ``import`` and ``export`` sub-commands.
+- Recipes are stored in a ``csv`` format now with ``uuid`` instead of ``int``,
+  and they can be viewed in a convenient ``tree`` output.
+- Calculate functions for body fat, BMR, 1-rep max, & lean body limits.
+- Example recipe ``csv`` files.
+- ``unittest`` compatibility, not sure this will stay.
+  May revert to ``pytest``.
+
+Changed
+~~~~~~~
+
+- Use a ``CliConfig`` class for storing ``DEBUG`` and ``PAGING`` flags.
+- Requirements versions for ``colorama`` and ``python3.4``.
+- Removed ``F401`` warnings, e.g. importing services into ``__init__``.
+- General refactor of ``argparse`` stuff, but still not complete.
+- Start to split apart some of the original ``test_cli`` functions.
+- Enable more verbose ``mypy`` flags.
 
 
 
index 16472c86062e6095066daa0e299b1fad96e7c27e..01d33110c90a66c128d5fdb617c0f091a61fcf77 100644 (file)
@@ -4,4 +4,6 @@ include requirements-*.txt
 include ntclient/ntsqlite/sql/*.sql
 include ntclient/ntsqlite/sql/data/*.csv
 
-global-exclude nt.sqlite3
+recursive-include ntclient/resources/ *.csv
+
+global-exclude *.sqlite3
index 9731847c61f79cd53fcaa3ae1110907b9d8e19ae..fb6fa9ccbe4bb0883a6a40202dc346b4293c1cb2 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -51,7 +51,7 @@ PIP ?= $(PY_VIRTUAL_INTERPRETER) -m pip
 REQ_OPT := requirements-optional.txt
 REQ_LINT := requirements-lint.txt
 REQ_TEST := requirements-test.txt
-REQ_OLD := requirements-test-win_xp-ubu1604.txt
+REQ_TEST_OLD := requirements-test-old.txt
 
 PIP_OPT_ARGS ?=
 
@@ -62,7 +62,7 @@ _deps:
        - $(PIP) install $(PIP_OPT_ARGS) -r $(REQ_OPT)
        - $(PIP) install $(PIP_OPT_ARGS) -r $(REQ_LINT)
        - $(PIP) install $(PIP_OPT_ARGS) -r $(REQ_TEST) || \
-       echo "TEST REQs failed. Try with '--user' flag, or old version: $(PIP) install -r $(REQ_OLD)"
+       echo "TEST REQs failed. Try with '--user' flag, or old version: $(PIP) install -r $(REQ_TEST_OLD)"
 
 .PHONY: deps
 deps: _venv _deps      ## Install requirements
@@ -86,7 +86,7 @@ YAML_LOCS := ntclient/ntsqlite/.*.yml .github/workflows/ .*.yml
 .PHONY: _lint
 _lint:
        # check formatting: Python
-       pycodestyle --max-line-length=99 --statistics $(LINT_LOCS)
+       pycodestyle --statistics $(LINT_LOCS)
        autopep8 --recursive --diff --max-line-length 88 --exit-code $(LINT_LOCS)
        isort --diff --check $(LINT_LOCS)
        black --check $(LINT_LOCS)
@@ -108,7 +108,7 @@ TEST_HOME := tests/
 MIN_COV := 80
 .PHONY: _test
 _test:
-       coverage run -m pytest -v -s -p no:cacheprovider -o log_cli=true $(TEST_HOME)
+       coverage run -m pytest $(TEST_HOME)
        coverage report
 
 .PHONY: test
@@ -160,7 +160,14 @@ clean:     ## Clean up __pycache__ and leftover bits
        rm -rf build/
        rm -rf nutra.egg-info/
        rm -rf .pytest_cache/ .mypy_cache/
-       find ntclient/ tests/ -name __pycache__ -o -name .coverage -o -name .pytest_cache | xargs rm -rf
+       find ntclient/ tests/ \
+       -name \
+       __pycache__ \
+       -o -name \
+       .coverage \
+       -o -name .mypy_cache \
+       -o -name .pytest_cache \
+       | xargs rm -rf
 
 
 # ---------------------------------------
index f449d57bec55574942fdbde95f9f586c064aca2c..c6dfbfd50f74e7d1cf9683b618102058f7e2c384 100644 (file)
@@ -2,11 +2,31 @@
  nutratracker
 **************
 
+Command line tools for interacting with government food database,
+and analyzing your health trends. The ``SR28`` database includes data
+for ~8500 foods and ~180 nutrients. Customizable with extensions
+and mapping rules built on top.
+
+**Requires**
+
+- Python 3.4.0 or later (``lzma``, ``ssl`` & ``sqlite3`` modules)
+  [Win XP / Ubuntu 14.04].
+- Packages: see ``setup.py``, and ``requirements.txt`` files.
+- Internet connection, to download food database & package dependencies.
+
+See ``nt`` database:   https://github.com/nutratech/nt-sqlite
+
+See ``usda`` database: https://github.com/nutratech/usda-sqlite
+
+
+Details
+#######################################################
+
 .. list-table::
   :widths: 15 25 20
   :header-rows: 1
 
-  * -
+  * - Category
     -
     -
   * - Test / Linux
         :alt: License GPL-3
     -
 
-Command line tools for interacting with government food databases.
 
-*Requires:*
+Linux / macOS requirements (for development)
+#######################################################
 
-- Python 3.4.0 or later (lzma, ssl & sqlite3 modules) [Win XP / Ubuntu 14.04].
-- Packages: see ``setup.py``, and ``requirements.txt`` files.
-- Internet connection, to download food database & package dependencies.
+You will need to install ``make`` and ``gcc`` to build the ``Levenshtein``
+extension.
+
+::
+
+  sudo apt install \
+    make gcc \
+    python3-dev python3-venv \
+    direnv
 
-See nt database:   https://github.com/nutratech/nt-sqlite
 
-See usda database: https://github.com/nutratech/usda-sqlite
+You can add the direnv hook, ``direnv hook bash >>~.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] <command>``
-
-
-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 (file)
index f288702..0000000
+++ /dev/null
@@ -1,674 +0,0 @@
-                    GNU GENERAL PUBLIC LICENSE
-                       Version 3, 29 June 2007
-
- Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
- 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.
-
-    <one line to give the program's name and a brief idea of what it does.>
-    Copyright (C) <year>  <name of author>
-
-    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 <https://www.gnu.org/licenses/>.
-
-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:
-
-    <program>  Copyright (C) <year>  <name of author>
-    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
-<https://www.gnu.org/licenses/>.
-
-  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
-<https://www.gnu.org/licenses/why-not-lgpl.html>.
index fb9f38f5dcbbd0b55f381da88de67e9aa98776ba..7407dfc9f9adb7a1d79b454533f2ed13752a6130 100755 (executable)
@@ -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 <chown_tee@proton.me>
-
-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 <https://www.gnu.org/licenses/>.
 """
-
 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,
+]
index 9ea0794d4ceb6b32cfabc89029427866b5942e24..6f5494fe2875b2158e5c324dcf6279019bfff780 100644 (file)
@@ -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 <chown_tee@proton.me>
-
-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 <https://www.gnu.org/licenses/>.
 """
 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())
index 10ab6d556bdaac7a7d154ec158867928295a1616..f410178693f8e3bf12b3e81c0d1208a1aa33ab28 100644 (file)
@@ -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)
index f076e56f3d5a558e4b0cbedff6726ef5b9de933b..7890169a5b6da0ccc615446c5fdba92c1ea4c480 100644 (file)
-"""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
index aa2d3cd067ced52d9757d1b671d8b54c7abedf11..31753dd7df8189cfb1b01858ceaeb5a7f757076b 100644 (file)
@@ -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
 
index c22d7c72de82c99197d3cb5a6af5c137744d1577..fba911cf37e9e04bba1feb87c8412b2c1dc7bad5 100755 (executable)
@@ -1,4 +1,3 @@
-#!/usr/bin/env python3
 # -*- coding: utf-8 -*-
 """
 Created on Sat Aug 29 19:43:55 2020
index e85cc543569a6e45fc1cc0f1c7b06148991b9a9d..bfe769ec010599294ba5dc623595d025c85f1c34 100755 (executable)
@@ -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 (file)
index 0000000..c4ed89e
--- /dev/null
@@ -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"""
index f76955d55293d166f833befbab2c52447d0cd07e..0eb89de7db73aa68d71da3b4113296ba595500bd 160000 (submodule)
@@ -1 +1 @@
-Subproject commit f76955d55293d166f833befbab2c52447d0cd07e
+Subproject commit 0eb89de7db73aa68d71da3b4113296ba595500bd
index 5a9795964c942db5be0d2e56689ac33706bf264b..717bd0e36e4cbc60e3a2dc41d7dfa5a5f02f7c1f 100644 (file)
@@ -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"
-        )
index 7a6f6ee63e6fd7ce5b0d15f98def4a9e11d35dad..9d603b9bbe69e391eda7b9ae28e8ddafb907d6af 100644 (file)
@@ -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",
index d4b2a2fa7fd5eab4eaa1d06f7b4fc84e5d791e4a..af8a143c78f1412a31456e34374e92fe4129ba04 100644 (file)
@@ -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)
index e940a42bbeebff5d928c8a8e5ec467e672e1666d..b210326c413ff226cd2dbf24c9f2d0678e4c5fb9 100644 (file)
@@ -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__
index 455b55ca42229a2cc554f54a3aff274fb529e653..cc7c5c89342b835fc94d16a6179b2e89a6aab685 100644 (file)
@@ -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 (file)
index 0000000..5afe446
--- /dev/null
@@ -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 (file)
index 0000000..17648a8
--- /dev/null
@@ -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 (file)
index 0000000..672358b
--- /dev/null
@@ -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 (file)
index 0000000..e9f4fdb
--- /dev/null
@@ -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 (file)
index 0000000..2934548
--- /dev/null
@@ -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 (file)
index 0000000..5723824
--- /dev/null
@@ -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 (file)
index 0000000..00eb624
--- /dev/null
@@ -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 (file)
index 0000000..4814e30
--- /dev/null
@@ -0,0 +1,2 @@
+recipe_id,serving,grams
+1,cup,160
index b2f4890e182d2d6354f3920af89ab243bad1d8d8..b540aaf603474e604f3d3a19156f2d80b5e7a7a2 100644 (file)
@@ -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
index 994ec0c87da08d41d5622ebfc83b4b97aebeb0cc..738a3df57ff19d46facffdddb147f8c0511defb8 100755 (executable)
@@ -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 (file)
index 0000000..818631b
--- /dev/null
@@ -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 (file)
index bfcc72c..0000000
+++ /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 (file)
index 0000000..5086ced
--- /dev/null
@@ -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 (file)
index 0000000..39d48b0
--- /dev/null
@@ -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 (file)
index 0000000..63dbafc
--- /dev/null
@@ -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
index 6e8494af9485d017c3a59c8f7e3502aa125151b2..cefeb9b129de8da0bc3d8dcb10a8126e9c2d6cce 100644 (file)
@@ -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)
index 0a03c1be8aedd851824c374f71916e3864366bb0..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -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 (file)
index 0000000..e69de29
diff --git a/ntclient/utils/tree.py b/ntclient/utils/tree.py
new file mode 100755 (executable)
index 0000000..cb72461
--- /dev/null
@@ -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 ba0eb31a2877497718097dc889381591d6e463eb..ff1e9da05f176ca259339693186b7f96c2ec7459 100755 (executable)
--- 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 (file)
index 0000000..73afc09
--- /dev/null
@@ -0,0 +1,4 @@
+argcomplete==1.8.0
+colorama==0.3.6
+fuzzywuzzy==0.3.0
+tabulate==0.4.3
similarity index 83%
rename from requirements-test-win_xp-ubu1604.txt
rename to requirements-test-old.txt
index 39fde645c055bfef0c1d0a40c117e4bef04a04d5..9b665e2b7d71014f95711eb67529399acc8078b2 100644 (file)
@@ -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
index 575e698d3a6342f46f49739dd94adb4cd6927afa..1548cc4f476def909ddf4a3b15649c31771bcc7b 100644 (file)
@@ -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
index 960849d5022d5acb44b1894ef76649c396cac692..aa3344d1a5ce95bc7bdf537f13996e006258e2a8 100644 (file)
--- 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
-
index ca2ef8e52dd9a497dcdd8d799dd2f957603ca771..cd14570bb058dbbfd383689330db511eb0354e3a 100644 (file)
--- 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 (file)
index 895d6d6..0000000
+++ /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 (file)
index 0000000..e69de29
diff --git a/tests/services/test_recipe.py b/tests/services/test_recipe.py
new file mode 100644 (file)
index 0000000..b277857
--- /dev/null
@@ -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}
index fa0583d375e594a02ff0e933726cfeb515f6617b..887203fa7d55b4ad2665627539a63e4f101b7065 100644 (file)
@@ -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 (file)
index 0000000..e69de29
diff --git a/tests/utils/test_tree.py b/tests/utils/test_tree.py
new file mode 100644 (file)
index 0000000..b65a481
--- /dev/null
@@ -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