Repository: NTNU-IHB/PythonFMU (python)

Issue:
Change CLI API
> On a different note, I should probably refactor the csv stuff into a separate cli as you did with this one. `pythonfmu-csvbuilder`, which would allow for customization and separation of concern.

This is a great idea. One comment, should we move to a cli api like `pythonfmu _command_ options` rather than `pythonfmu-_command_ options` closer to package manager api?

_Originally posted by @fcollonval in https://github.com/NTNU-IHB/PythonFMU/pull/95#issuecomment-602455771_

Pull request description:
Switch to subcommands cli API
Fix #100 

Patch:
diff --git a/README.md b/README.md
index e03750d..72e3a6f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # PythonFMU
 
-> A lightweight framework that enables the packaging of Python3.x code as co-simulation FMUs.
+> A lightweight framework that enables the packaging of Python 3 code or CSV files as co-simulation FMUs (following FMI version 2.0).
 
 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
 [![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/NTNU-IHB/PythonFMU/issues)
@@ -11,23 +11,24 @@
 
 [![Gitter](https://badges.gitter.im/NTNU-IHB/FMI4j.svg)](https://gitter.im/NTNU-IHB/PythonFMU?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
 
-
 ### How do I build an FMU from python code?
 
-1) Install `pythonfmu` package:
+1. Install `pythonfmu` package:
+
 ```bash
 pip install pythonfmu
 ```
-2) Create a new class extending the `Fmi2Slave` class declared in the `pythonfmu.fmi2slave` module (see below for an example). 
-3) Run `pythonfmu-builder` to create the fmu.
+
+2. Create a new class extending the `Fmi2Slave` class declared in the `pythonfmu.fmi2slave` module (see below for an example).
+3. Run `pythonfmu build` to create the fmu.
 
 ```
-usage: pythonfmu-builder [-h] -f SCRIPT_FILE [-d DEST] [--doc DOCUMENTATION_FOLDER]
-                         [--no-external-tool] [--no-variable-step] [--interpolate-inputs] [--only-one-per-process]
-                         [--handle-state] [--serialize-state] [--use-memory-management]
-                         [Project files [Project files ...]]
+usage: pythonfmu build [-h] -f SCRIPT_FILE [-d DEST] [--doc DOCUMENTATION_FOLDER] [--no-external-tool]
+                       [--no-variable-step] [--interpolate-inputs] [--only-one-per-process] [--handle-state]
+                       [--serialize-state] [--use-memory-management]
+                       [Project files [Project files ...]]
 
-Build a FMU from a Python script.
+Build an FMU from a Python script.
 
 positional arguments:
   Project files         Additional project files required by the Python script.
@@ -39,7 +40,6 @@ optional arguments:
   -d DEST, --dest DEST  Where to save the FMU.
   --doc DOCUMENTATION_FOLDER
                         Documentation folder to include in the FMU.
-                        
   --no-external-tool    If given, needsExecutionTool=false
   --no-variable-step    If given, canHandleVariableCommunicationStepSize=false
   --interpolate-inputs  If given, canInterpolateInputs=true
@@ -51,9 +51,41 @@ optional arguments:
                         If given, canNotUseMemoryManagementFunctions=false
 ```
 
-##### Example: 
+### How do I build an FMU from python code with third-party dependencies?
+
+Often, Python scripts depends on non-builtin libraries like `numpy`, `scipy`, etc.
+_PythonFMU_ does not package a full environment within the FMU.
+However you can package a `requirements.txt` or `environment.yml` file within your FMU following these steps:
+
+1. Install _pythonfmu_ package: `pip install pythonfmu`
+2. Create a new class extending the `Fmi2Slave` class declared in the `pythonfmu.fmi2slave` module (see below for an example).
+3. Create a `requirements.txt` file (to use _pip_ manager) and/or a `environment.yml` file (to use _conda_ manager) that defines your dependencies.
+4. Run `pythonfmu build -f myscript.py requirements.txt` to create the fmu including the dependencies file.
+
+And using `pythonfmu deploy`, end users will be able to update their local Python environment. The steps to achieve that:
+
+1. Install _pythonfmu_ package: `pip install pythonfmu`
+2. Be sure to be in the Python environment to be updated. Then execute `pythonfmu deploy -f my.fmu`
+
+```
+usage: pythonfmu deploy [-h] -f FMU [-e ENVIRONMENT] [{pip,conda}]
+
+Deploy a Python FMU. The command will look in the `resources` folder for one of the following files:
+`requirements.txt` or `environment.yml`. If you specify a environment file but no package manager, `conda` will be selected for `.yaml` and `.yml` otherwise `pip` will be used. The tool assume the Python environment in which the FMU should be executed is the current one.
 
-###### Write the script
+positional arguments:
+  {pip,conda}           Python packages manager
+
+optional arguments:
+  -h, --help            show this help message and exit
+  -f FMU, --file FMU    Path to the Python FMU.
+  -e ENVIRONMENT, --env ENVIRONMENT
+                        Requirements or environment file.
+```
+
+### Example:
+
+#### Write the script
 
 ```python
 
@@ -82,14 +114,14 @@ class PythonSlave(Fmi2Slave):
 
 ```
 
-###### Create the FMU 
+#### Create the FMU
 
 ```
-pythonfmu-builder -f pythonslave.py myproject
+pythonfmu build -f pythonslave.py myproject
 ```
 
 In this example a python class named `PythonSlave` that extends `Fmi2Slave` is declared in a file named `pythonslave.py`,
-where `myproject` is an optional folder containing additional project files required by the python script. 
+where `myproject` is an optional folder containing additional project files required by the python script.
 Project folders such as this will be recursively copied into the FMU. Multiple project files/folders may be added.
 
 ### Test it online
@@ -98,20 +130,19 @@ Project folders such as this will be recursively copied into the FMU. Multiple p
 
 ### Note
 
-PythonFMU does not bundle Python, which makes it a tool coupling solution. 
-This means that you can not expect the generated FMU to work on a different system (The system would need a compatible Python version and libraries). 
+PythonFMU does not bundle Python, which makes it a tool coupling solution.
+This means that you can not expect the generated FMU to work on a different system (The system would need a compatible Python version and libraries).
 But to ease its usage the wrapper uses the limited Python API, making the pre-built binaries for Linux and Windows 64-bits
 compatible with any Python 3 environment. If you need to compile the wrapper for a specific configuration,
-you will need CMake and a C++ compiler. The commands for building the wrapper on Linux and on Windows can be seen in 
+you will need CMake and a C++ compiler. The commands for building the wrapper on Linux and on Windows can be seen in
 the [GitHub workflow](./.github/workflows/main.yml).
 
 PythonFMU does not automatically resolve 3rd party dependencies. If your code includes e.g. `numpy`, the target system also needs to have `numpy` installed.
 
-***
+---
 
 Would you rather build FMUs in Java? Check out [FMI4j](https://github.com/NTNU-IHB/FMI4j)!  
-Need to distribute your FMUs? [FMU-proxy](https://github.com/NTNU-IHB/FMU-proxy) to the rescue! 
-
+Need to distribute your FMUs? [FMU-proxy](https://github.com/NTNU-IHB/FMU-proxy) to the rescue!
 
 ### Credits
 
diff --git a/examples/demo.ipynb b/examples/demo.ipynb
index 6b4368b..2948928 100644
--- a/examples/demo.ipynb
+++ b/examples/demo.ipynb
@@ -92,7 +92,7 @@
    "source": [
     "## Generate the FMU\n",
     "\n",
-    "The FMU is generate through the command line tool `pythonfmu-builder`."
+    "The FMU is generated through the command line tool `pythonfmu build`."
    ]
   },
   {
@@ -101,7 +101,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "!pythonfmu-builder -f ./demoslave.py --no-external-tool"
+    "!pythonfmu build -f ./demoslave.py --no-external-tool"
    ]
   },
   {
@@ -124,7 +124,7 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "usage: pythonfmu-builder [-h] [-v] -f SCRIPT_FILE [-d DEST]\n",
+      "usage: pythonfmu build [-h] [-v] -f SCRIPT_FILE [-d DEST]\n",
       "                         [--doc DOCUMENTATION_FOLDER] [--no-external-tool]\n",
       "                         [--no-variable-step] [--interpolate-inputs]\n",
       "                         [--only-one-per-process] [--handle-state]\n",
@@ -158,7 +158,7 @@
     }
    ],
    "source": [
-    "!pythonfmu-builder -h"
+    "!pythonfmu build -h"
    ]
   },
   {
@@ -268,7 +268,7 @@
     "\n",
     "Python script can use third-party libraries. PythonFMU does not include such dependencies within the FMU. So it is up to the end user to installed the dependent packages.\n",
     "\n",
-    "However to help the process, you could include as project file in your FMU a requirement file. Then the end user will be able to install the dependencies using the `pythonfmu-deploy` tool. The tool supports for now two packages manager: `pip` and `conda`. Moreover if you use the default convention for naming the requirement file (i.e. *requirements.txt* for `pip` and *environment.yml* for `conda`), the end user won't need to specify the file name nor the package manager to be used.\n",
+    "However to help the process, you could include as project file in your FMU a requirement file. Then the end user will be able to install the dependencies using the `pythonfmu deploy` tool. The tool supports for now two packages manager: `pip` and `conda`. Moreover if you use the default convention for naming the requirement file (i.e. *requirements.txt* for `pip` and *environment.yml* for `conda`), the end user won't need to specify the file name nor the package manager to be used.\n",
     "\n",
     "In the following paragraphs, an example requiring `scipy` will be demonstated. The Python facade is defined in the `scipyslave.py` file and the requirement are provided in `environment.yml` for usage with `conda`."
    ]
@@ -282,7 +282,7 @@
      "name": "stdout",
      "output_type": "stream",
      "text": [
-      "usage: pythonfmu-deploy [-h] [-v] -f FMU [-e ENVIRONMENT] [{pip,conda}]\n",
+      "usage: pythonfmu deploy [-h] [-v] -f FMU [-e ENVIRONMENT] [{pip,conda}]\n",
       "\n",
       "Deploy a Python FMU. The command will look in the `resources` folder for one\n",
       "of the following files: - `requirements.txt`: Install dependencies using pip\n",
@@ -305,7 +305,7 @@
     }
    ],
    "source": [
-    "!pythonfmu-deploy -h"
+    "!pythonfmu deploy -h"
    ]
   },
   {
@@ -321,7 +321,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "!pythonfmu-builder -f sympyslave.py environment.yml --no-external-tool"
+    "!pythonfmu build -f sympyslave.py environment.yml --no-external-tool"
    ]
   },
   {
@@ -386,7 +386,7 @@
     }
    ],
    "source": [
-    "!pythonfmu-deploy -f SympySlave.fmu"
+    "!pythonfmu deploy -f SympySlave.fmu"
    ]
   },
   {
diff --git a/pythonfmu/__main__.py b/pythonfmu/__main__.py
index 5cf603a..da264d4 100644
--- a/pythonfmu/__main__.py
+++ b/pythonfmu/__main__.py
@@ -1,4 +1,59 @@
-from .builder import main
+import argparse
+
+from pythonfmu import builder, csvbuilder, deploy
+from ._version import __version__
+
+
+def cli_main():
+    parser = argparse.ArgumentParser(prog="pythonfmu")
+
+    parser.add_argument("-V", "--version", action="version", version=__version__)
+
+    def default_execution(**kwargs):
+        print("A subcommand must be provided.\n")
+        parser.print_help()
+
+    parser.set_defaults(execute=default_execution)
+
+    subparsers = parser.add_subparsers(
+        title="Subcommands", description="Call `pythonfmu _command_ -h` to get more help."
+    )
+
+    build_parser = subparsers.add_parser(
+        "build",
+        description="Build an FMU from a Python script.",
+        help="Build an FMU from a Python script."
+    )
+    builder.create_command_parser(build_parser)
+
+    csv_parser = subparsers.add_parser(
+        "buildcsv",
+        description="Build an FMU from a CSV file.",
+        help="Build an FMU from a CSV file."
+    )
+    csvbuilder.create_command_parser(csv_parser)
+
+    deploy_parser = subparsers.add_parser(
+        "deploy",
+        description="""Deploy a Python FMU.
+
+        The command will look in the `resources` folder for one of the following files:
+        `requirements.txt` or `environment.yml`.
+
+        If you specify a environment file but no package manager, `conda` will be selected
+        for `.yaml` and `.yml` otherwise `pip` will be used.
+
+        The tool assume the Python environment in which the FMU should be executed
+        is the current one.
+        """,
+        help="Install Python FMU dependencies."
+    )
+    deploy.create_command_parser(deploy_parser)
+
+    options = vars(parser.parse_args())
+    execute = options.pop("execute")
+    execute(**options)
+
 
 if __name__ == "__main__":
-    main()
+    cli_main()
diff --git a/pythonfmu/builder.py b/pythonfmu/builder.py
index 86db100..a96d51b 100644
--- a/pythonfmu/builder.py
+++ b/pythonfmu/builder.py
@@ -13,7 +13,6 @@ from typing import Iterable, Optional, Tuple, Union
 from xml.dom.minidom import parseString
 from xml.etree.ElementTree import Element, SubElement, tostring
 from .osutil import get_lib_extension, get_platform
-from ._version import __version__
 from .fmi2slave import FMI2_MODEL_OPTIONS, Fmi2Slave
 
 FilePath = Union[str, Path]
@@ -198,24 +197,13 @@ class FmuBuilder:
         return src_binaries.exists() and len(list(src_binaries.glob(f"*.{lib_ext}"))) >= 1
 
 
-def main():
-    parser = argparse.ArgumentParser(
-        prog="pythonfmu-builder", description="Build an FMU from a Python script."
-    )
-
-    parser.add_argument(
-        "-v",
-        "--version",
-        action="version",
-        version=__version__
-    )
-
+def create_command_parser(parser: argparse.ArgumentParser):
     parser.add_argument(
         "-f",
         "--file",
         dest="script_file",
         help="Path to the Python script.",
-        required=True,
+        required=True
     )
 
     parser.add_argument(
@@ -226,7 +214,7 @@ def main():
         "--doc",
         dest="documentation_folder",
         help="Documentation folder to include in the FMU.",
-        default=None,
+        default=None
     )
 
     for option in FMI2_MODEL_OPTIONS:
@@ -243,8 +231,7 @@ def main():
         metavar="Project files",
         nargs="*",
         help="Additional project files required by the Python script.",
-        default=set(),
+        default=set()
     )
 
-    options = vars(parser.parse_args())
-    FmuBuilder.build_FMU(**options)
+    parser.set_defaults(execute=FmuBuilder.build_FMU)
diff --git a/pythonfmu/csvbuilder.py b/pythonfmu/csvbuilder.py
index 3849a6d..c7f5bd6 100644
--- a/pythonfmu/csvbuilder.py
+++ b/pythonfmu/csvbuilder.py
@@ -2,7 +2,6 @@ import argparse
 import tempfile
 from pathlib import Path
 from typing import Union
-from ._version import __version__
 from .fmi2slave import FMI2_MODEL_OPTIONS
 from .builder import FmuBuilder
 
@@ -10,7 +9,7 @@ FilePath = Union[str, Path]
 
 
 def create_csv_slave(csv_file: FilePath):
-    classname = csv_file.stem
+    classname = csv_file.stem.capitalize()
     filename = csv_file.name
     return f"""
 import re
@@ -190,24 +189,14 @@ class CsvFmuBuilder:
             return FmuBuilder.build_FMU(**options)
 
 
-def main():
-    parser = argparse.ArgumentParser(
-        prog="pythonfmu-csvbuilder", description="Build an FMU from a Python script or CSV file."
-    )
-
-    parser.add_argument(
-        "-v",
-        "--version",
-        action="version",
-        version=__version__
-    )
+def create_command_parser(parser: argparse.ArgumentParser):
 
     parser.add_argument(
         "-f",
         "--file",
         dest="script_file",
         help="Path to the CSV file.",
-        required=True,
+        required=True
     )
 
     parser.add_argument(
@@ -218,7 +207,7 @@ def main():
         "--doc",
         dest="documentation_folder",
         help="Documentation folder to include in the FMU.",
-        default=None,
+        default=None
     )
 
     for option in FMI2_MODEL_OPTIONS:
@@ -230,5 +219,4 @@ def main():
             action=action
         )
 
-    options = vars(parser.parse_args())
-    CsvFmuBuilder.build_FMU(**options)
+    parser.set_defaults(execute=CsvFmuBuilder.build_FMU)
diff --git a/pythonfmu/deploy.py b/pythonfmu/deploy.py
index 32936f6..830926a 100644
--- a/pythonfmu/deploy.py
+++ b/pythonfmu/deploy.py
@@ -8,7 +8,6 @@ from pathlib import Path
 from tempfile import TemporaryDirectory
 from typing import Union
 
-from ._version import __version__
 from .enums import PackageManager
 
 
@@ -21,57 +20,57 @@ ENVIRONMENT_FILES = {
 
 
 def deploy(
-    fmu_file: Union[str, Path],
-    environment_filename: Union[str, Path, None] = None,
+    fmu: Union[str, Path],
+    environment: Union[str, Path, None] = None,
     package_manager: Union[str, PackageManager, None] = None
 ) -> None:
     """Install Python dependency packages from requirement file shipped within the FMU.
     
     Args:
-        fmu_file (str or pathlib.Path) : FMU file path
-        environment_filename (str or pathlib.Path) : optional, requirements file within the `resources` folder of the FMU
+        fmu (str or pathlib.Path) : FMU file path
+        environment (str or pathlib.Path) : optional, requirements file within the `resources` folder of the FMU
         package_manager (str) : optional, Python package manager
     """
-    fmu_file = Path(fmu_file)
+    fmu = Path(fmu)
     manager = None
     if package_manager is not None:
         manager = PackageManager(package_manager)
 
     env_content = None
-    environment = None
-    with zipfile.ZipFile(fmu_file) as files:
+    environment_file = None
+    with zipfile.ZipFile(fmu) as files:
         names = files.namelist()
 
-        environment = None
-        if environment_filename is None:
+        environment_file = None
+        if environment is None:
             for spec in ENVIRONMENT_FILES:
                 test = Path("resources") / spec
                 if test.as_posix() in names:
-                    environment = test
+                    environment_file = test
                     manager = manager or ENVIRONMENT_FILES[spec]
                     break
-            if environment is None:
+            if environment_file is None:
                 raise ValueError("Unable to find requirement file in the FMU resources folder.")
         else:
-            environment = Path("resources") / environment_filename
-            if environment.as_posix() not in names:
-                raise ValueError(f"Unable to find requirement file {environment_filename!s} in the FMU resources folder.")
+            environment_file = Path("resources") / environment
+            if environment_file.as_posix() not in names:
+                raise ValueError(f"Unable to find requirement file {environment!s} in the FMU resources folder.")
 
             if manager is None:
-                if environment_filename in ENVIRONMENT_FILES:
-                    manager = ENVIRONMENT_FILES[environment_filename]
-                elif environment_filename.endswith(".yaml") or environment_filename.endswith(".yml"):
+                if environment in ENVIRONMENT_FILES:
+                    manager = ENVIRONMENT_FILES[environment]
+                elif environment.endswith(".yaml") or environment.endswith(".yml"):
                     manager = PackageManager.conda
                 else:
                     manager = PackageManager.pip
 
-        with files.open(environment.as_posix(), mode="r") as env_file:
+        with files.open(environment_file.as_posix(), mode="r") as env_file:
             env_content = env_file.read()
 
     with TemporaryDirectory() as tmp:
         tempd = Path(tmp)
 
-        copy_env = tempd / environment.name
+        copy_env = tempd / environment_file.name
         copy_env.write_bytes(env_content)
 
         if manager == PackageManager.pip:
@@ -91,29 +90,7 @@ def deploy(
             )
 
 
-def main():
-
-    parser = argparse.ArgumentParser(
-        prog="pythonfmu-deploy", 
-        description="""Deploy a Python FMU.
-
-        The command will look in the `resources` folder for one of the following files:
-        `requirements.txt` or `environment.yml`.
-
-        If you specify a environment file but no package manager, `conda` will be selected
-        for `.yaml` and `.yml` otherwise `pip` will be used.
-
-        The tool assume the Python environment in which the FMU should be executed
-        is the current one.
-        """
-    )
-
-    parser.add_argument(
-        "-v",
-        "--version",
-        action="version",
-        version=__version__
-    )
+def create_command_parser(parser: argparse.ArgumentParser):
 
     parser.add_argument(
         "-f",
@@ -138,5 +115,4 @@ def main():
         help="Python packages manager"
     )
 
-    args = parser.parse_args()
-    deploy(args.fmu, args.environment, args.package_manager)
+    parser.set_defaults(execute=deploy)
diff --git a/setup.cfg b/setup.cfg
index cfc7fb8..293ca01 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,9 +40,7 @@ pythonfmu =
 
 [options.entry_points]
 console_scripts = 
-	pythonfmu-builder = pythonfmu.builder:main
-	pythonfmu-deploy = pythonfmu.deploy:main
-	pythonfmu-csvbuilder = pythonfmu.csvbuilder:main
+	pythonfmu = pythonfmu.__main__:cli_main
 
 [options.extras_require]
 tests = 


Test patch:
diff --git a/pythonfmu/tests/test_deploy.py b/pythonfmu/tests/test_deploy.py
index 979662b..7d8137b 100644
--- a/pythonfmu/tests/test_deploy.py
+++ b/pythonfmu/tests/test_deploy.py
@@ -40,10 +40,10 @@ scipy
     with patch("subprocess.run") as run:
         if isinstance(expected, type) and issubclass(expected, Exception):
             with pytest.raises(expected):
-                deploy(fmu, environment_filename=test_requirements, package_manager=test_manager)
+                deploy(fmu, environment=test_requirements, package_manager=test_manager)
             run.assert_not_called()
         else:
-            deploy(fmu, environment_filename=test_requirements, package_manager=test_manager)
+            deploy(fmu, environment=test_requirements, package_manager=test_manager)
 
             run.assert_called_once()
             assert (test_manager or expected) in " ".join(run.call_args[0][0])