Repository: flux-framework/flux-restful-api (language: python)

Issue:
Add Go client
- [x] Python (already written, but I can package it a little better)
- [ ] Go (likely will want for Operator eventually)
Flux job submit needs to support workdir and envars
We currently (for the operator) have this as part of the CRD, however the working directory for running the original container doesn't have to be where the job runs! Let's expose this.


Discussion / hints:

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..7aef53b
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,17 @@
+---
+name: Bug report
+about: Report a bug with flux restful API
+
+---
+
+**Describe the bug**
+
+**To Reproduce**
+Steps to reproduce the behavior:
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Version of action-updater**
+
+Anything else?
diff --git a/.github/ISSUE_TEMPLATE/documentation-or-tutorial-request.md b/.github/ISSUE_TEMPLATE/documentation-or-tutorial-request.md
new file mode 100644
index 0000000..42deb08
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/documentation-or-tutorial-request.md
@@ -0,0 +1,5 @@
+---
+name: Documentation or Tutorial Request
+about: What can we explain better, or what issue did you find?
+
+---
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..8da30ed
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,13 @@
+---
+name: Feature request
+about: Request a feature
+
+---
+
+**Is your feature request related to a problem? Please describe.**
+
+**Describe the solution you'd like**
+A clear and concise description of what you want to happen.
+
+**Describe alternatives you've considered**
+A clear and concise description of any alternative solutions or features you've considered.
diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md
new file mode 100644
index 0000000..4ca926c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/question.md
@@ -0,0 +1,5 @@
+---
+name: Question
+about: What's on your mind?
+
+---
diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml
index db6ac9c..6d30689 100644
--- a/.github/workflows/docs.yaml
+++ b/.github/workflows/docs.yaml
@@ -42,6 +42,10 @@ jobs:
         cd flux-restful-api
         python3 -m pip install -r requirements.txt
         python3 -m pip install -r .github/dev-requirements.txt
+        # Install flux-restful-client for tutorial rendering
+        cd clients/python/
+        python3 -m pip install .[all]
+        cd -
         cd docs
         python3 -m pip install -r requirements.txt
         make html
diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index cd931e9..d7dca27 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -19,7 +19,7 @@ jobs:
     - name: Check Spelling
       uses: crate-ci/typos@7ad296c72fa8265059cc03d1eda562fbdfcd6df2 # v1.9.0
       with:
-        files: ./README.md
+        files: ./README.md ./docs/
 
     - name: Lint and format Python code
       run: |
diff --git a/README.md b/README.md
index a890652..ce436f9 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,9 @@ With Flux RESTful we can:
 3. option to kill or stop the server (intended for Flux Operator)
 4. allow for start with a user name and token (for basic auth)
 
-
 This project is new and we look forward to [hearing your feedback](https://github.com/flux-framework/flux-restful-api).
-See our ⭐️ [Documentation](https://flux-framework.github.io/flux-restful-api) ⭐️ to get started!
+See our ⭐️ [Documentation](https://flux-framework.github.io/flux-restful-api) ⭐️ to get started,
+and take a look at the [clients](clients) we provide to interact with the server.
 
 ![img/flux-restful.png](img/flux-restful.png)
 
diff --git a/app/routers/api.py b/app/routers/api.py
index 879559c..27b2991 100644
--- a/app/routers/api.py
+++ b/app/routers/api.py
@@ -68,7 +68,14 @@ async def cancel_job(jobid):
     """
     from app.main import app
 
-    flux.job.cancel(app.handle, jobid)
+    try:
+        flux.job.cancel(app.handle, jobid)
+    # This is usually FileNotFoundError
+    except Exception as e:
+        return JSONResponse(
+            content={"Message": "Job cannot be cancelled: %s." % e}, status_code=400
+        )
+
     return JSONResponse(
         content={"Message": "Job is requested to cancel."}, status_code=200
     )
@@ -78,6 +85,10 @@ async def cancel_job(jobid):
 async def submit_job(request: Request):
     """
     Submit a job to our running cluster.
+
+    The logic for parsing a submission is only required here, so we
+    include everything in this function instead of having separate
+    functions.
     """
     from app.main import app
 
@@ -88,19 +99,49 @@ async def submit_job(request: Request):
         )
 
     # Generate the flux job
-    command = shlex.split(payload["command"])
-    fluxjob = JobspecV1.from_command(command=command)
+    command = payload["command"]
+    if isinstance(command, str):
+        command = shlex.split(command)
+
+    # Optional defaults
+    kwargs = {"command": command}
+    for optional in [
+        "num_tasks",
+        "cores_per_task",
+        "gpus_per_task",
+        "num_nodes",
+        "exclusive",
+    ]:
+        if optional in payload and payload[optional]:
+            kwargs[optional] = payload[optional]
+    fluxjob = JobspecV1.from_command(**kwargs)
+
+    # Does the user want a working directory?
+    workdir = payload.get("workdir")
+    if workdir is not None:
+        fluxjob.workdir = workdir
 
     # A duration of zero (the default) means unlimited
     fluxjob.duration = payload.get("runtime", 0) or 0
 
-    # Ensure the cwd is the snakemake working directory
-    # TODO user should be able to provide envars in payload
-    fluxjob.environment = dict(os.environ)
-    flux_future = flux.job.submit_async(app.handle, fluxjob)
+    # Additional envars in the payload?
+    environment = dict(os.environ)
+    extra_envars = payload.get("envars", {})
+    environment.update(extra_envars)
+    fluxjob.environment = environment
+
+    # Submit the job and return the ID, but allow for error
+    try:
+        flux_future = flux.job.submit_async(app.handle, fluxjob)
+    except Exception as e:
+        result = jsonable_encoder(
+            {"Message": "There was an issue submitting that job.", "Error": str(e)}
+        )
+        return JSONResponse(content=result, status_code=400)
+
     jobid = flux_future.get_id()
 
-    # TODO we should write jobid and other metadata to database?
+    # TODO should we write jobid and other metadata to a database?
     result = jsonable_encoder({"Message": "Job submit.", "id": jobid})
     return JSONResponse(content=result, status_code=200)
 
diff --git a/clients/README.md b/clients/README.md
new file mode 100644
index 0000000..a527a9f
--- /dev/null
+++ b/clients/README.md
@@ -0,0 +1,9 @@
+# Flux RESTful API Clients
+
+These are clients to interact with the Flux RESTFul API! We
+keep them versioned alongside the Flux RESTFul API, the reason being
+that we want them to be updated in sync. This also mirrors
+the practice of the parent project [flux-core](https://github.com/flux-framework/flux-core).
+
+ - [Python](python)
+ - Go (coming soon)
diff --git a/clients/python/.gitignore b/clients/python/.gitignore
new file mode 100644
index 0000000..b898df1
--- /dev/null
+++ b/clients/python/.gitignore
@@ -0,0 +1,13 @@
+bin/
+lib/
+flux_restful_client.egg-info/
+env
+.env
+build
+release
+dist/
+__pycache__
+*.simg
+*.sif
+*.img
+/.eggs
diff --git a/clients/python/CHANGELOG.md b/clients/python/CHANGELOG.md
new file mode 100644
index 0000000..f50dfeb
--- /dev/null
+++ b/clients/python/CHANGELOG.md
@@ -0,0 +1,17 @@
+# CHANGELOG
+
+This is a manually generated log to track changes to the repository for each release.
+Each section should include general headers such as **Implemented enhancements**
+and **Merged pull requests**. Critical items to know are:
+
+ - renamed commands
+ - deprecated / removed commands
+ - changed defaults
+ - backward incompatible changes (recipe file format? image file format?)
+ - migration guidance (how to convert images?)
+ - changed behaviour (recipe sections work differently)
+
+The versions coincide with releases on pip. Only major versions will be released as tags on Github.
+
+## [0.0.x](https://github.com/flux-framework/flux-restful-api/tree/main) (0.0.x)
+ - Project skeleton release (0.0.0)
diff --git a/clients/python/LICENSE b/clients/python/LICENSE
new file mode 100644
index 0000000..0a04128
--- /dev/null
+++ b/clients/python/LICENSE
@@ -0,0 +1,165 @@
+                   GNU LESSER 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.
+
+
+  This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+  0. Additional Definitions.
+
+  As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+  "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+  An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+  A "Combined Work" is a work produced by combining or linking an
+Application with the Library.  The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+  The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+  The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+  1. Exception to Section 3 of the GNU GPL.
+
+  You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+  2. Conveying Modified Versions.
+
+  If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+   a) under this License, provided that you make a good faith effort to
+   ensure that, in the event an Application does not supply the
+   function or data, the facility still operates, and performs
+   whatever part of its purpose remains meaningful, or
+
+   b) under the GNU GPL, with none of the additional permissions of
+   this License applicable to that copy.
+
+  3. Object Code Incorporating Material from Library Header Files.
+
+  The object code form of an Application may incorporate material from
+a header file that is part of the Library.  You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+   a) Give prominent notice with each copy of the object code that the
+   Library is used in it and that the Library and its use are
+   covered by this License.
+
+   b) Accompany the object code with a copy of the GNU GPL and this license
+   document.
+
+  4. Combined Works.
+
+  You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+   a) Give prominent notice with each copy of the Combined Work that
+   the Library is used in it and that the Library and its use are
+   covered by this License.
+
+   b) Accompany the Combined Work with a copy of the GNU GPL and this license
+   document.
+
+   c) For a Combined Work that displays copyright notices during
+   execution, include the copyright notice for the Library among
+   these notices, as well as a reference directing the user to the
+   copies of the GNU GPL and this license document.
+
+   d) Do one of the following:
+
+       0) Convey the Minimal Corresponding Source under the terms of this
+       License, and the Corresponding Application Code in a form
+       suitable for, and under terms that permit, the user to
+       recombine or relink the Application with a modified version of
+       the Linked Version to produce a modified Combined Work, in the
+       manner specified by section 6 of the GNU GPL for conveying
+       Corresponding Source.
+
+       1) Use a suitable shared library mechanism for linking with the
+       Library.  A suitable mechanism is one that (a) uses at run time
+       a copy of the Library already present on the user's computer
+       system, and (b) will operate properly with a modified version
+       of the Library that is interface-compatible with the Linked
+       Version.
+
+   e) Provide Installation Information, but only if you would otherwise
+   be required to provide such information under section 6 of the
+   GNU GPL, and only to the extent that such information is
+   necessary to install and execute a modified version of the
+   Combined Work produced by recombining or relinking the
+   Application with a modified version of the Linked Version. (If
+   you use option 4d0, the Installation Information must accompany
+   the Minimal Corresponding Source and Corresponding Application
+   Code. If you use option 4d1, you must provide the Installation
+   Information in the manner specified by section 6 of the GNU GPL
+   for conveying Corresponding Source.)
+
+  5. Combined Libraries.
+
+  You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+   a) Accompany the combined library with a copy of the same work based
+   on the Library, uncombined with any other library facilities,
+   conveyed under the terms of this License.
+
+   b) Give prominent notice with the combined library that part of it
+   is a work based on the Library, and explaining where to find the
+   accompanying uncombined form of the same work.
+
+  6. Revised Versions of the GNU Lesser General Public License.
+
+  The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser 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
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+  If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/clients/python/MANIFEST.in b/clients/python/MANIFEST.in
new file mode 100644
index 0000000..301ce6f
--- /dev/null
+++ b/clients/python/MANIFEST.in
@@ -0,0 +1,13 @@
+include README.md LICENSE
+recursive-include flux_restful_client *.png
+recursive-include flux_restful_client *.sh
+recursive-include flux_restful_client *
+recursive-include * *.png
+prune env*
+global-exclude .env
+global-exclude *.py[co]
+recursive-exclude .git *
+global-exclude *.img
+global-exclude *.simg
+global-exclude *.sif
+global-exclude __pycache__
diff --git a/clients/python/README.md b/clients/python/README.md
new file mode 100644
index 0000000..4767e86
--- /dev/null
+++ b/clients/python/README.md
@@ -0,0 +1,15 @@
+# Flux RestFul Client (Python!)
+
+[![PyPI version](https://badge.fury.io/py/flux-restful-client.svg)](https://badge.fury.io/py/flux-restful-client)
+[![main](https://github.com/flux-framework/flux-restful-api/actions/workflows/python-tests.yml/badge.svg)](https://github.com/flux-framework/flux-restful-api/actions/workflows/python-tests.yml)
+
+The Flux RESTFul client in Python makes it easy to interact with the [Flux RESTFul API](https://github.com/flux-framework/flux-restful-api).
+
+You can see the [⭐️ Documentation ⭐️](https://flux-framework.org/flux-restful-api/getting_started/user-guide.html) for complete details
+for installation and usage.
+
+#### Release
+
+SPDX-License-Identifier: LGPL-3.0
+
+LLNL-CODE-764420
diff --git a/examples/cancel_job.py b/clients/python/examples/cancel_job.py
similarity index 68%
rename from examples/cancel_job.py
rename to clients/python/examples/cancel_job.py
index 8273ce5..1f996c7 100644
--- a/examples/cancel_job.py
+++ b/clients/python/examples/cancel_job.py
@@ -1,17 +1,12 @@
 #!/usr/bin/env python3
 
 import json
-import os
-import sys
 
-here = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, here)
-
-from flux_restful_client import FluxRestfulClient  # noqa
+from flux_restful_client.main import get_client
 
 
 def main():
-    cli = FluxRestfulClient()
+    cli = get_client()
 
     # Submit the job to flux
     print("Submitting job sleep 60 intending to cancel..")
diff --git a/examples/list_nodes.py b/clients/python/examples/list_nodes.py
similarity index 55%
rename from examples/list_nodes.py
rename to clients/python/examples/list_nodes.py
index daf11e2..b27f657 100644
--- a/examples/list_nodes.py
+++ b/clients/python/examples/list_nodes.py
@@ -1,17 +1,12 @@
 #!/usr/bin/env python3
 
 import json
-import os
-import sys
 
-here = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, here)
-
-from flux_restful_client import FluxRestfulClient  # noqa
+from flux_restful_client.main import get_client
 
 
 def main():
-    cli = FluxRestfulClient()
+    cli = get_client()
 
     # Submit the job to flux
     print("Listing nodes")
diff --git a/examples/stop_service.py b/clients/python/examples/stop_service.py
similarity index 56%
rename from examples/stop_service.py
rename to clients/python/examples/stop_service.py
index 33e7692..fd5ab56 100644
--- a/examples/stop_service.py
+++ b/clients/python/examples/stop_service.py
@@ -1,17 +1,12 @@
 #!/usr/bin/env python3
 
 import json
-import os
-import sys
 
-here = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, here)
-
-from flux_restful_client import FluxRestfulClient  # noqa
+from flux_restful_client.main import get_client
 
 
 def main():
-    cli = FluxRestfulClient()
+    cli = get_client()
 
     # Submit the job to flux
     print("Stopping the service...")
diff --git a/examples/submit_job.py b/clients/python/examples/submit_job.py
similarity index 69%
rename from examples/submit_job.py
rename to clients/python/examples/submit_job.py
index dddeca7..88775c8 100644
--- a/examples/submit_job.py
+++ b/clients/python/examples/submit_job.py
@@ -1,17 +1,12 @@
 #!/usr/bin/env python3
 
 import json
-import os
-import sys
 
-here = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, here)
-
-from flux_restful_client import FluxRestfulClient  # noqa
+from flux_restful_client.main import get_client
 
 
 def main():
-    cli = FluxRestfulClient()
+    cli = get_client()
 
     # Submit the job to flux
     print("😴 Submitting job sleep 60")
diff --git a/examples/submit_job_auth.py b/clients/python/examples/submit_job_auth.py
similarity index 76%
rename from examples/submit_job_auth.py
rename to clients/python/examples/submit_job_auth.py
index 7b4c9b6..8d8d704 100644
--- a/examples/submit_job_auth.py
+++ b/clients/python/examples/submit_job_auth.py
@@ -1,8 +1,8 @@
 #!/usr/bin/env python3
 
 import json
-import os
-import sys
+
+from flux_restful_client.main import get_client
 
 # This is the same example as the submit_job.py, but with authentication
 # added. You should start the server with the following environment
@@ -11,14 +11,9 @@
 # FLUX_TOKEN=12345
 # FLUX_REQUIRE_AUTH=true
 
-here = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, here)
-
-from flux_restful_client import FluxRestfulClient  # noqa
-
 
 def main():
-    cli = FluxRestfulClient()
+    cli = get_client
 
     # Submit the job to flux
     print("😴 Submitting job sleep 60")
diff --git a/examples/submit_list_jobs.py b/clients/python/examples/submit_list_jobs.py
similarity index 62%
rename from examples/submit_list_jobs.py
rename to clients/python/examples/submit_list_jobs.py
index 499893a..5648c43 100644
--- a/examples/submit_list_jobs.py
+++ b/clients/python/examples/submit_list_jobs.py
@@ -1,17 +1,12 @@
 #!/usr/bin/env python3
 
 import json
-import os
-import sys
 
-here = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, here)
-
-from flux_restful_client import FluxRestfulClient  # noqa
+from flux_restful_client.main import get_client
 
 
 def main():
-    cli = FluxRestfulClient()
+    cli = get_client
 
     # Submit the job to flux
     print("Submitting 3 jobs to sleep!")
diff --git a/clients/python/flux_restful_client/__init__.py b/clients/python/flux_restful_client/__init__.py
new file mode 100644
index 0000000..66400eb
--- /dev/null
+++ b/clients/python/flux_restful_client/__init__.py
@@ -0,0 +1,3 @@
+from flux_restful_client.version import __version__
+
+assert __version__
diff --git a/clients/python/flux_restful_client/client/__init__.py b/clients/python/flux_restful_client/client/__init__.py
new file mode 100644
index 0000000..cec2f7e
--- /dev/null
+++ b/clients/python/flux_restful_client/client/__init__.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python
+
+import argparse
+import os
+import sys
+
+import flux_restful_client
+import flux_restful_client.main.schemas as schemas
+from flux_restful_client.logger import setup_logger
+
+
+def add_submit_arguments(command):
+    """
+    Derive the command for argparse directly from the schema
+    """
+    # All all options (based on type) except for command, which comes in "extra"
+    for name, attrs in schemas.submit_properties.items():
+        if name == "command":
+            continue
+        typ = attrs.get("type")
+        default_type = str
+        action = None
+
+        # It's either a string...
+        if typ == "number" or "number" in typ:
+            default_type = int
+        elif typ == "boolean" or "boolean" in typ:
+            default_type = bool
+            # Assume default is always false for now
+            action = "store_true"
+
+        if default_type == bool:
+            command.add_argument(
+                f"--{name}",
+                help=attrs.get("description") or f"The --{name} flag.",
+                action=action,
+            )
+        else:
+            command.add_argument(
+                f"--{name}",
+                help=attrs.get("description") or f"The --{name} flag.",
+                type=default_type,
+            )
+
+
+def get_parser():
+    parser = argparse.ArgumentParser(
+        description="😴 Flux Restful Client Python",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+
+    # Global Variables
+    parser.add_argument(
+        "--debug",
+        dest="debug",
+        help="use verbose logging to debug.",
+        default=False,
+        action="store_true",
+    )
+
+    parser.add_argument(
+        "--quiet",
+        dest="quiet",
+        help="suppress additional output.",
+        default=False,
+        action="store_true",
+    )
+
+    parser.add_argument(
+        "--version",
+        dest="version",
+        help="show software version.",
+        default=False,
+        action="store_true",
+    )
+
+    parser.add_argument(
+        "--settings-file",
+        dest="settings_file",
+        help="custom path to settings file.",
+    )
+
+    # On the fly updates to config params
+    parser.add_argument(
+        "-c",
+        dest="config_params",
+        help=""""customize a config value on the fly to ADD/SET/REMOVE for a command
+action-updater -c set:key:value <command> <args>
+action-updater -c add:listkey:value <command> <args>
+action-updater -c rm:listkey:value""",
+        action="append",
+    )
+
+    subparsers = parser.add_subparsers(
+        help="actions",
+        title="actions",
+        description="actions",
+        dest="command",
+    )
+
+    # print version and exit
+    subparsers.add_parser("version", description="show software version")
+
+    # Submit a job (all extra is the command)
+    submit = subparsers.add_parser(
+        "submit",
+        description="submit a job via the flux restful client",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    add_submit_arguments(submit)
+
+    # Local shell with client loaded
+    shell = subparsers.add_parser(
+        "shell",
+        description="shell into a Python session with a client.",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    shell.add_argument(
+        "--interpreter",
+        "-i",
+        dest="interpreter",
+        help="python interpreter",
+        choices=["ipython", "python", "bpython"],
+        default="ipython",
+    )
+
+    config = subparsers.add_parser(
+        "config",
+        description="update configuration settings. Use set or get to see or set information.",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+
+    config.add_argument(
+        "--central",
+        dest="central",
+        help="make edits to the central config file.",
+        default=False,
+        action="store_true",
+    )
+
+    config.add_argument(
+        "params",
+        nargs="*",
+        help="""Set or get a config value, edit the config, add or remove a list variable, or create a user-specific config.
+flux-restful-cli config set key value
+flux-restful-cli config set key:subkey value
+flux-restful-cli config get key
+flux-restful-cli edit
+flux-restful-cli config inituser""",
+        type=str,
+    )
+
+    cancel = subparsers.add_parser(
+        "cancel",
+        description="cancel a job",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    info = subparsers.add_parser(
+        "info",
+        description="get information for a job",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    subparsers.add_parser(
+        "list-nodes",
+        description="list node that the Flux RESTful API knows about",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    subparsers.add_parser(
+        "list-jobs",
+        description="list jobs that the Flux RESTful API knows about",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    subparsers.add_parser(
+        "stop-service",
+        description="Kill your Flux RESTFul API server (cannot be undone)",
+        formatter_class=argparse.RawTextHelpFormatter,
+    )
+    for command in cancel, info:
+        command.add_argument("jobid", help="The jobid to interact with.")
+
+    return parser
+
+
+def run_flux_restful_client():
+
+    parser = get_parser()
+
+    def help(return_code=0):
+        """print help, including the software version and active client
+        and exit with return code.
+        """
+        version = flux_restful_client.__version__
+
+        print("\nFlux RESTFul Python Client v%s" % version)
+        parser.print_help()
+        sys.exit(return_code)
+
+    # If the user didn't provide any arguments, show the full help
+    if len(sys.argv) == 1:
+        help()
+
+    # If an error occurs while parsing the arguments, the interpreter will exit with value 2
+    args, extra = parser.parse_known_args()
+
+    if args.debug is True:
+        os.environ["MESSAGELEVEL"] = "DEBUG"
+
+    # Show the version and exit
+    if args.command == "version" or args.version:
+        print(flux_restful_client.__version__)
+        sys.exit(0)
+
+    setup_logger(
+        quiet=args.quiet,
+        debug=args.debug,
+    )
+
+    # retrieve subparser (with help) from parser
+    helper = None
+    subparsers_actions = [
+        action
+        for action in parser._actions
+        if isinstance(action, argparse._SubParsersAction)
+    ]
+    for subparsers_action in subparsers_actions:
+        for choice, subparser in subparsers_action.choices.items():
+            if choice == args.command:
+                helper = subparser
+                break
+
+    if args.command == "info":
+        from .info import main
+    elif args.command == "cancel":
+        from .cancel import main
+    elif args.command == "config":
+        from .config import main
+    elif args.command == "stop-service":
+        from .stop import stop_service as main
+    elif args.command == "list-jobs":
+        from .listing import list_jobs as main
+    elif args.command == "list-nodes":
+        from .listing import list_nodes as main
+    elif args.command == "submit":
+        from .submit import main
+    elif args.command == "shell":
+        from .shell import main
+
+    # Pass on to the correct parser
+    return_code = 0
+    try:
+        main(args=args, parser=parser, extra=extra, subparser=helper)
+        sys.exit(return_code)
+    except UnboundLocalError:
+        return_code = 1
+
+    help(return_code)
+
+
+if __name__ == "__main__":
+    run_flux_restful_client()
diff --git a/clients/python/flux_restful_client/client/cancel.py b/clients/python/flux_restful_client/client/cancel.py
new file mode 100644
index 0000000..ae3e867
--- /dev/null
+++ b/clients/python/flux_restful_client/client/cancel.py
@@ -0,0 +1,12 @@
+import json
+
+from flux_restful_client.main import get_client
+
+
+def main(args, parser, extra, subparser):
+    cli = get_client(quiet=args.quiet, settings=args.settings_file)
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+    info = cli.cancel(jobid=args.jobid)
+    print(json.dumps(info, indent=4))
diff --git a/clients/python/flux_restful_client/client/config.py b/clients/python/flux_restful_client/client/config.py
new file mode 100644
index 0000000..f06c90a
--- /dev/null
+++ b/clients/python/flux_restful_client/client/config.py
@@ -0,0 +1,50 @@
+import sys
+
+import flux_restful_client.defaults as defaults
+from flux_restful_client.logger import logger
+from flux_restful_client.main import get_client
+
+
+def main(args, parser, extra, subparser):
+
+    # If nothing provided, show help
+    if not args.params:
+        print(subparser.format_help())
+        sys.exit(0)
+
+    # The first "param" is either set of get
+    command = args.params.pop(0)
+
+    # If the user wants the central config file
+    if args.central:
+        args.settings_file = defaults.default_settings_file
+
+    validate = True if not command == "edit" else False
+    cli = get_client(
+        quiet=args.quiet, settings_file=args.settings_file, validate=validate
+    )
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+
+    # For each new setting, update and save!
+    if command == "inituser":
+        return cli.settings.inituser()
+    if command == "edit":
+        return cli.settings.edit()
+
+    if command in ["set", "add", "remove"]:
+        cli.settings.update_param(command, args.params)
+
+        # Save settings
+        cli.settings.save()
+
+    # For each get request, print the param pair
+    elif command == "get":
+        for key in args.params:
+            value = cli.settings.get(key)
+            value = "is unset" if value is None else value
+            logger.info("%s %s" % (key.ljust(30), value))
+
+    else:
+        logger.error("%s is not a recognized command." % command)
diff --git a/clients/python/flux_restful_client/client/info.py b/clients/python/flux_restful_client/client/info.py
new file mode 100644
index 0000000..5105b10
--- /dev/null
+++ b/clients/python/flux_restful_client/client/info.py
@@ -0,0 +1,12 @@
+import json
+
+from flux_restful_client.main import get_client
+
+
+def main(args, parser, extra, subparser):
+    cli = get_client(quiet=args.quiet, settings=args.settings_file)
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+    info = cli.jobs(jobid=args.jobid)
+    print(json.dumps(info, indent=4))
diff --git a/clients/python/flux_restful_client/client/listing.py b/clients/python/flux_restful_client/client/listing.py
new file mode 100644
index 0000000..9ed3d04
--- /dev/null
+++ b/clients/python/flux_restful_client/client/listing.py
@@ -0,0 +1,21 @@
+import json
+
+from flux_restful_client.main import get_client
+
+
+def list_jobs(args, parser, extra, subparser):
+    cli = get_client(quiet=args.quiet, settings=args.settings_file)
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+    res = cli.jobs()
+    print(json.dumps(res, indent=4))
+
+
+def list_nodes(args, parser, extra, subparser):
+    cli = get_client(quiet=args.quiet, settings=args.settings_file)
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+    res = cli.list_nodes()
+    print(json.dumps(res, indent=4))
diff --git a/clients/python/flux_restful_client/client/shell.py b/clients/python/flux_restful_client/client/shell.py
new file mode 100644
index 0000000..d3a426d
--- /dev/null
+++ b/clients/python/flux_restful_client/client/shell.py
@@ -0,0 +1,61 @@
+def main(args, parser, extra, subparser):
+
+    lookup = {"ipython": ipython, "python": python, "bpython": bpython}
+    shells = ["ipython", "python", "bpython"]
+
+    # The default shell determined by the command line client
+    shell = args.interpreter
+    if shell is not None:
+        shell = shell.lower()
+        if shell in lookup:
+            try:
+                return lookup[shell](args)
+            except ImportError:
+                pass
+
+    # Otherwise present order of liklihood to have on system
+    for shell in shells:
+        try:
+            return lookup[shell](args)
+        except ImportError:
+            pass
+
+
+def create_client(args):
+    from flux_restful_client.main import get_client
+
+    cli = get_client(quiet=args.quiet, settings_file=args.settings_file)
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+    return cli
+
+
+def ipython(args):
+    """
+    Generate an IPython shell with the client.
+    """
+    client = create_client(args)  # noqa
+    from IPython import embed
+
+    embed()
+
+
+def bpython(args):
+    """
+    Generate an bpython shell with the client.
+    """
+    import bpython
+
+    client = create_client(args)
+    bpython.embed(locals_={"client": client})
+
+
+def python(args):
+    """
+    Generate an python shell with the client.
+    """
+    import code
+
+    client = create_client(args)
+    code.interact(local={"client": client})
diff --git a/clients/python/flux_restful_client/client/stop.py b/clients/python/flux_restful_client/client/stop.py
new file mode 100644
index 0000000..ad96f46
--- /dev/null
+++ b/clients/python/flux_restful_client/client/stop.py
@@ -0,0 +1,10 @@
+import json
+
+from flux_restful_client.main import get_client
+
+
+def stop_service(args, parser, extra, subparser):
+    cli = get_client(quiet=args.quiet, settings=args.settings_file)
+    cli.settings.update_params(args.config_params)
+    info = cli.stop_service()
+    print(json.dumps(info, indent=4))
diff --git a/clients/python/flux_restful_client/client/submit.py b/clients/python/flux_restful_client/client/submit.py
new file mode 100644
index 0000000..09b6825
--- /dev/null
+++ b/clients/python/flux_restful_client/client/submit.py
@@ -0,0 +1,30 @@
+import json
+
+from flux_restful_client.logger import logger
+from flux_restful_client.main import get_client
+
+
+def main(args, parser, extra, subparser):
+    cli = get_client(quiet=args.quiet, settings=args.settings_file)
+
+    if not extra:
+        logger.exit("Please include the command that you want to submit!")
+
+    # Update config settings on the fly
+    cli.settings.update_params(args.config_params)
+
+    # The command is the "extra"
+    kwargs = {"command": extra}
+    for arg in [
+        "num_tasks",
+        "cores_per_task",
+        "gpus_per_task",
+        "num_nodes",
+        "exclusive",
+        "workdir",
+    ]:
+        if hasattr(args, arg) and getattr(args, arg, None) is not None:
+            kwargs[arg] = getattr(args, arg)
+
+    res = cli.submit(**kwargs)
+    print(json.dumps(res, indent=4))
diff --git a/clients/python/flux_restful_client/defaults.py b/clients/python/flux_restful_client/defaults.py
new file mode 100644
index 0000000..4f822e8
--- /dev/null
+++ b/clients/python/flux_restful_client/defaults.py
@@ -0,0 +1,14 @@
+import os
+
+import flux_restful_client.utils as utils
+
+install_dir = utils.get_installdir()
+reps = {"$install_dir": install_dir, "$root_dir": os.path.dirname(install_dir)}
+
+# The default settings file in the install root
+default_settings_file = os.path.join(reps["$install_dir"], "settings.yml")
+
+# The user settings file can be created to over-ride default
+user_settings_file = os.path.join(
+    os.path.expanduser("~/.flux-restful-cli"), "settings.yml"
+)
diff --git a/clients/python/flux_restful_client/logger.py b/clients/python/flux_restful_client/logger.py
new file mode 100644
index 0000000..b861c9e
--- /dev/null
+++ b/clients/python/flux_restful_client/logger.py
@@ -0,0 +1,201 @@
+import inspect
+import logging as _logging
+import os
+import platform
+import sys
+import threading
+
+
+class LogColors:
+    PURPLE = "\033[95m"
+    OKBLUE = "\033[94m"
+    OKCYAN = "\033[96m"
+    OKGREEN = "\033[92m"
+    WARNING = "\033[93m"
+    RED = "\033[91m"
+    ENDC = "\033[0m"
+    BOLD = "\033[1m"
+    UNDERLINE = "\033[4m"
+
+
+def underline(msg):
+    """
+    Return an underlined message
+    """
+    return f"{LogColors.UNDERLINE}{msg}{LogColors.ENDC}"
+
+
+def add_prefix(msg, char=">>"):
+    """
+    Add an "OKBLUE" prefix to a message
+    """
+    return f"{LogColors.OKBLUE}{char}{LogColors.ENDC} {msg}"
+
+
+class ColorizingStreamHandler(_logging.StreamHandler):
+
+    BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8)
+    RESET_SEQ = LogColors.ENDC
+    COLOR_SEQ = "\033[%dm"
+    BOLD_SEQ = "\033[1m"
+
+    colors = {
+        "WARNING": YELLOW,
+        "INFO": GREEN,
+        "DEBUG": BLUE,
+        "CRITICAL": RED,
+        "ERROR": RED,
+    }
+
+    def __init__(self, nocolor=False, stream=sys.stderr, use_threads=False):
+        super().__init__(stream=stream)
+        self._output_lock = threading.Lock()
+        self.nocolor = nocolor or not self.can_color_tty()
+
+    def can_color_tty(self):
+        if "TERM" in os.environ and os.environ["TERM"] == "dumb":
+            return False
+        return self.is_tty and not platform.system() == "Windows"
+
+    @property
+    def is_tty(self):
+        isatty = getattr(self.stream, "isatty", None)
+        return isatty and isatty()
+
+    def emit(self, record):
+        with self._output_lock:
+            try:
+                self.format(record)  # add the message to the record
+                self.stream.write(self.decorate(record))
+                self.stream.write(getattr(self, "terminator", "\n"))
+                self.flush()
+            except BrokenPipeError as e:
+                raise e
+            except (KeyboardInterrupt, SystemExit):
+                # ignore any exceptions in these cases as any relevant messages have been printed before
+                pass
+            except Exception:
+                self.handleError(record)
+
+    def decorate(self, record):
+        message = record.message
+        message = [message]
+        if not self.nocolor and record.levelname in self.colors:
+            message.insert(0, self.COLOR_SEQ % (30 + self.colors[record.levelname]))
+            message.append(self.RESET_SEQ)
+        return "".join(message)
+
+
+class Logger:
+    def __init__(self):
+        self.logger = _logging.getLogger(__name__)
+        self.log_handler = [self.text_handler]
+        self.stream_handler = None
+        self.printshellcmds = False
+        self.quiet = False
+        self.logfile = None
+        self.last_msg_was_job_info = False
+        self.logfile_handler = None
+
+    def cleanup(self):
+        if self.logfile_handler is not None:
+            self.logger.removeHandler(self.logfile_handler)
+            self.logfile_handler.close()
+        self.log_handler = [self.text_handler]
+
+    def handler(self, msg):
+        for handler in self.log_handler:
+            handler(msg)
+
+    def set_stream_handler(self, stream_handler):
+        if self.stream_handler is not None:
+            self.logger.removeHandler(self.stream_handler)
+        self.stream_handler = stream_handler
+        self.logger.addHandler(stream_handler)
+
+    def set_level(self, level):
+        self.logger.setLevel(level)
+
+    def location(self, msg):
+        callerframerecord = inspect.stack()[1]
+        frame = callerframerecord[0]
+        info = inspect.getframeinfo(frame)
+        self.debug(
+            "{}: {info.filename}, {info.function}, {info.lineno}".format(msg, info=info)
+        )
+
+    def yellow(self, msg):
+        self.handler(dict(level="info", msg=msg))
+
+    def info(self, msg):
+        self.handler(dict(level="info", msg=msg))
+
+    def warning(self, msg):
+        self.handler(dict(level="warning", msg=msg))
+
+    def debug(self, msg):
+        self.handler(dict(level="debug", msg=msg))
+
+    def error(self, msg):
+        self.handler(dict(level="error", msg=msg))
+
+    def exit(self, msg, return_code=1):
+        self.handler(dict(level="error", msg=msg))
+        sys.exit(return_code)
+
+    def progress(self, done=None, total=None):
+        self.handler(dict(level="progress", done=done, total=total))
+
+    def shellcmd(self, msg):
+        if msg is not None:
+            msg = dict(level="shellcmd", msg=msg)
+            self.handler(msg)
+
+    def text_handler(self, msg):
+        """The default log handler prints the output to the console.
+        Args:
+            msg (dict):     the log message dictionary
+        """
+        level = msg["level"]
+        if level == "info" and not self.quiet:
+            self.logger.info(msg["msg"])
+        if level == "warning":
+            self.logger.warning(msg["msg"])
+        elif level == "error":
+            self.logger.error(msg["msg"])
+        elif level == "debug":
+            self.logger.debug(msg["msg"])
+        elif level == "progress" and not self.quiet:
+            done = msg["done"]
+            total = msg["total"]
+            p = done / total
+            percent_fmt = ("{:.2%}" if p < 0.01 else "{:.0%}").format(p)
+            self.logger.info(
+                "{} of {} steps ({}) done".format(done, total, percent_fmt)
+            )
+        elif level == "shellcmd":
+            if self.printshellcmds:
+                self.logger.warning(msg["msg"])
+
+
+logger = Logger()
+
+
+def setup_logger(
+    quiet=False,
+    printshellcmds=False,
+    nocolor=False,
+    stdout=False,
+    debug=False,
+    use_threads=False,
+):
+    # console output only if no custom logger was specified
+    stream_handler = ColorizingStreamHandler(
+        nocolor=nocolor,
+        stream=sys.stdout if stdout else sys.stderr,
+        use_threads=use_threads,
+    )
+    logger.set_stream_handler(stream_handler)
+    logger.set_level(_logging.DEBUG if debug else _logging.INFO)
+    logger.quiet = quiet
+    logger.printshellcmds = printshellcmds
diff --git a/clients/python/flux_restful_client/main/__init__.py b/clients/python/flux_restful_client/main/__init__.py
new file mode 100644
index 0000000..74b2649
--- /dev/null
+++ b/clients/python/flux_restful_client/main/__init__.py
@@ -0,0 +1,19 @@
+from .settings import Settings
+
+
+def get_client(quiet=False, **kwargs):
+    """
+    Get an actions updater client.
+
+    This will ensure we add all of the default updaters to our client.
+    """
+    validate = kwargs.get("validate", True)
+
+    # Load user settings to add to client, and container technology
+    settings = Settings(kwargs.get("settings_file"), validate)
+
+    from .client import FluxRestfulClient
+
+    FluxRestfulClient.settings = settings
+    client = FluxRestfulClient(quiet=quiet, **kwargs)
+    return client
diff --git a/examples/flux_restful_client.py b/clients/python/flux_restful_client/main/client.py
similarity index 67%
rename from examples/flux_restful_client.py
rename to clients/python/flux_restful_client/main/client.py
index 44fa115..31d62fc 100644
--- a/examples/flux_restful_client.py
+++ b/clients/python/flux_restful_client/main/client.py
@@ -1,20 +1,37 @@
-import base64
-import json
-import logging
 import os
-import re
 from copy import deepcopy
 
+import flux_restful_client.main.schemas as schemas
+import flux_restful_client.utils as utils
+import jsonschema
 import requests
+from flux_restful_client.logger import logger
 
-logger = logging.getLogger(__name__)
+from .settings import Settings
 
 
 class FluxRestfulClient:
-    def __init__(self, host=None, user=None, token=None):
-        self.host = host or "http://127.0.0.1:5000"
-        self.user = user or os.environ.get("FLUX_USER")
-        self.token = token or os.environ.get("FLUX_TOKEN")
+    """
+    Create a FluxRestful Client to interact with the Flux RESTFul API server.
+    """
+
+    def __init__(
+        self,
+        host=None,
+        user=None,
+        token=None,
+        quiet=False,
+        settings_file=None,
+        **kwargs,
+    ):
+
+        # If we don't have default settings, load
+        if not hasattr(self, "settings"):
+            self.settings = Settings(settings_file)
+
+        self.host = host or self.settings.host
+        self.user = user or os.environ.get("FLUX_USER") or self.settings.flux_user
+        self.token = token or os.environ.get("FLUX_TOKEN") or self.settings.flux_token
         self.headers = {}
         if self.user and self.token:
             self.set_basic_auth(self.user, self.token)
@@ -27,7 +44,7 @@ def set_basic_auth(self, username, password):
         """
         A wrapper to adding basic authentication to the Request
         """
-        auth_header = get_basic_auth(username, password)
+        auth_header = utils.get_basic_auth(username, password)
         if isinstance(auth_header, bytes):
             auth_header = auth_header.decode("utf-8")
         self.set_header("Authorization", "Basic %s" % auth_header)
@@ -49,7 +66,6 @@ def do_request(self, endpoint, method="GET", data=None, headers=None):
         self.reset()
 
         headers = headers or self.headers
-        print(self.headers)
         url = "%s/%s" % (self.host, endpoint)
 
         # Make the request and return to calling function, unless requires auth
@@ -89,7 +105,7 @@ def authenticate_request(self, originalResponse):
             return False
 
         # Prepare request to retry
-        h = parse_auth_header(authHeaderRaw)
+        h = utils.parse_auth_header(authHeaderRaw)
         headers.update(
             {
                 "service": h.Service,
@@ -133,7 +149,7 @@ def stop_service(self):
 
     def jobs(self, jobid=None):
         """
-        Given the id for a result, download to file
+        Get a listing of jobs that the Flux RESTful API knows about!
         """
         endpoint = "jobs"
         if jobid:
@@ -144,55 +160,46 @@ def jobs(self, jobid=None):
             return
         return result.json()
 
-    def submit(self, command):
+    def submit(self, command, **kwargs):
         """
-        Given the id for a result, download to file
+        Submit a job to the Flux RESTful API
+
+        Optional kwargs that are accepted include:
+        workdir (str): a working directory for the job
+        num_tasks (int): Number of tasks (defaults to 1)
+        cores_per_task (int): Number of cores per task (default to 1)
+        gpus_per_task (int): Number of gpus per task (defaults to None)
+        num_nodes (int): Number of nodes (defaults to None)
+        exclusive (bool): is the job exclusive? (defaults to False)
         """
+        # Allow the user to provide a list (and stringify everything)
         if isinstance(command, list):
             command = " ".join([str(x) for x in command])
         data = {"command": command}
+        for optional in [
+            "num_tasks",
+            "cores_per_task",
+            "gpus_per_task",
+            "num_nodes",
+            "exclusive",
+            "workdir",
+            "envars",
+        ]:
+
+            # Assume if it's provided, period, the user wants to set it!
+            if optional in kwargs:
+                data[optional] = kwargs[optional]
+
+        # Validate the data first.
+        jsonschema.validate(data, schema=schemas.job_submit_schema)
         result = self.do_request("jobs/submit", "POST", data=data)
         if result.status_code == 404:
             print("There is no job for that identifier.")
             return
         return result.json()
 
+    def __repr__(self):
+        return str(self)
 
-# Helper functions
-
-
-def get_basic_auth(username, password):
-    auth_str = "%s:%s" % (username, password)
-    return base64.b64encode(auth_str.encode("utf-8")).decode("utf-8")
-
-
-def parse_auth_header(authHeaderRaw):
-    """
-    Parse authentication header into pieces
-    """
-    regex = re.compile('([a-zA-z]+)="(.+?)"')
-    matches = regex.findall(authHeaderRaw)
-    lookup = dict()
-    for match in matches:
-        lookup[match[0]] = match[1]
-    return authHeader(lookup)
-
-
-class authHeader:
-    def __init__(self, lookup):
-        """
-        Given a dictionary of values, match them to class attributes
-        """
-        for key in lookup:
-            if key in ["realm", "service", "scope"]:
-                setattr(self, key.capitalize(), lookup[key])
-
-
-def read_file(filename):
-    with open(filename, "r") as fd:
-        content = fd.read()
-    return content
-
-
-def read_json(filename):
-    return json.loads(read_file(filename))
+    def __str__(self):
+        return "[flux-restful-client]"
diff --git a/clients/python/flux_restful_client/main/schemas.py b/clients/python/flux_restful_client/main/schemas.py
new file mode 100644
index 0000000..d0efe25
--- /dev/null
+++ b/clients/python/flux_restful_client/main/schemas.py
@@ -0,0 +1,65 @@
+schema_url = "http://json-schema.org/draft-07/schema"
+
+keyvals = {
+    "type": "object",
+    "patternProperties": {
+        "\\w[\\w-]*": {"type": "string"},
+    },
+}
+
+# A job submission schema
+submit_properties = {
+    "workdir": {"type": ["string", "null"]},
+    "command": {
+        "oneOf": [
+            {
+                "type": "string",
+            },
+            {"type": "array", "items": {"type": ["string", "number"]}},
+        ]
+    },
+    "envars": {
+        "oneOf": [
+            {"type": "null"},
+            keyvals,
+        ]
+    },
+    "num_tasks": {"type": "number"},
+    "cores_per_task": {"type": ["number", "null"]},
+    "gpus_per_task": {"type": ["number", "null"]},
+    "num_nodes": {"type": ["number", "null"]},
+    "exclusive": {"type": ["boolean", "null"]},
+}
+
+job_submit_schema = {
+    "$schema": schema_url,
+    "title": "Flux Job Submit Schema",
+    "type": "object",
+    "required": [
+        "command",
+    ],
+    "properties": submit_properties,
+    "additionalProperties": False,
+}
+
+
+# Currently all of these are required
+settingsProperties = {
+    "flux_token": {"type": ["string", "null"]},
+    "flux_user": {"type": ["string", "null"]},
+    "host": {"type": "string"},
+    "config_editor": {"type": "string"},
+    "workdir": {"type": ["string", "null"]},
+}
+
+settings = {
+    "$schema": schema_url,
+    "title": "Settings Schema",
+    "type": "object",
+    "required": [
+        "host",
+        "config_editor",
+    ],
+    "properties": settingsProperties,
+    "additionalProperties": False,
+}
diff --git a/clients/python/flux_restful_client/main/settings.py b/clients/python/flux_restful_client/main/settings.py
new file mode 100644
index 0000000..ea5445a
--- /dev/null
+++ b/clients/python/flux_restful_client/main/settings.py
@@ -0,0 +1,388 @@
+import flux_restful_client.defaults as defaults
+import flux_restful_client.main.schemas as schemas
+import flux_restful_client.utils as utils
+from flux_restful_client.logger import logger
+
+try:
+    from ruamel_yaml.comments import CommentedSeq
+except ImportError:
+    from ruamel.yaml.comments import CommentedSeq
+
+import os
+import re
+import shutil
+
+import jsonschema
+
+
+def OrderedList(*listing):
+    """
+    Preserve ordering when saved to yaml
+    """
+    ret = CommentedSeq(listing)
+    ret.fa.set_flow_style()
+    return ret
+
+
+class SettingsBase:
+    def __init__(self):
+        """
+        Create a new settings object not requiring a settings file.
+        """
+        self._settings = {}
+        self.settings_file = None
+        self.user_settings = None
+
+    def __str__(self):
+        return "[flux-restful-settings]"
+
+    def __repr__(self):
+        return self.__str__()
+
+    def validate(self):
+        """
+        Validate the loaded settings with jsonschema
+        """
+        jsonschema.validate(instance=self._settings, schema=schemas.settings)
+
+    def inituser(self):
+        """
+        Create a user specific config in user's home.
+        """
+        user_home = os.path.dirname(defaults.user_settings_file)
+        if not os.path.exists(user_home):
+            os.makedirs(user_home)
+        if os.path.exists(defaults.user_settings_file):
+            logger.exit(
+                "%s already exists! Remove first before re-creating."
+                % defaults.user_settings_file
+            )
+        shutil.copyfile(self.settings_file, defaults.user_settings_file)
+        logger.info("Created user settings file %s" % defaults.user_settings_file)
+
+    def edit(self, settings_file=None):
+        """
+        Interactively edit a config (or other) file.
+        """
+        settings_file = settings_file or self.settings_file
+        if not settings_file or not os.path.exists(settings_file):
+            logger.exit("%s does not exist." % settings_file)
+
+        # Discover editor user has preferences for
+        editor = None
+
+        # First try EDITOR and VISUAL envars
+        for envar_name in ["EDITOR", "VISUAL"]:
+            envar = os.environ.get(envar_name)
+            editor = self._find_editor(envar)
+            if editor is not None:
+                break
+
+        # If we get here and no editor, try system default
+        if not editor:
+            editor = self._find_editor(self.config_editor)
+        if not editor:
+            logger.exit(
+                "No editors found! Update with flux-restful-cli config set config_editor:<name>"
+            )
+
+        utils.run_command([editor, settings_file], stream=True)
+
+    def _find_editor(self, path):
+        """
+        Check to see that an editor exists.
+        """
+        if not path:
+            return
+
+        editor = shutil.which(path)
+
+        # Only return the editor name if we find it!
+        if editor:
+            return path
+
+    def get_settings_file(self, settings_file=None):
+        """
+        Get the preferred user settings file, set user settings if exists.
+        """
+        # Only consider user settings if the file exists!
+        user_settings = None
+        if os.path.exists(defaults.user_settings_file):
+            user_settings = defaults.user_settings_file
+
+        # First preference to command line, then user settings, then default
+        return settings_file or user_settings or defaults.default_settings_file
+
+    def load(self, settings_file=None):
+        """
+        Load the settings file into the settings object
+        """
+        # Get the preferred settings flie
+        self.settings_file = self.get_settings_file(settings_file)
+
+        # Exit quickly if the settings file does not exist
+        if not os.path.exists(self.settings_file):
+            logger.exit("%s does not exist." % self.settings_file)
+
+        # Always load default settings first
+        self._settings = utils.read_yaml(defaults.default_settings_file)
+
+        # Update with user or custom settings if not equal to default
+        if self.settings_file != defaults.default_settings_file:
+            self._settings.update(utils.read_yaml(self.settings_file))
+
+    def get(self, key, default=None):
+        """
+        Get a settings value, doing appropriate substitution and expansion.
+        """
+        # This is a reference to a dictionary (object) setting
+        if ":" in key:
+            key, subkey = key.split(":")
+            value = self._settings[key][subkey]
+        else:
+            value = self._settings.get(key, default)
+        return self._substitutions(value)
+
+    def __getattr__(self, key):
+        """
+        A direct get of an attribute, but default to None if doesn't exist
+        """
+        return self.get(key)
+
+    def add(self, key, value):
+        """
+        Add a value to a list parameter
+        """
+        value = self.parse_boolean(value)
+
+        # We can only add to lists
+        current = self._settings.get(key)
+        if current and not isinstance(current, list):
+            logger.exit("You cannot only add to a list variable.")
+        value = self.parse_null(value)
+
+        if value not in current:
+            # Add to the beginning of the list
+            current = [value] + current
+            self._settings[key] = OrderedList()
+            [self._settings[key].append(x) for x in current]
+            self.change_validate(key, value)
+            logger.warning(
+                "Warning: Check with flux-restful-cli config edit - ordering of list can change."
+            )
+
+    def remove(self, key, value):
+        """
+        Remove a value from a list parameter
+        """
+        current = self._settings.get(key)
+        if current and not isinstance(current, list):
+            logger.exit("You cannot only remove from a list variable.")
+        if not current or value not in current:
+            logger.exit("%s is not in %s" % (value, key))
+        current.pop(current.index(value))
+        self._settings[key] = current
+        self.change_validate(key, current)
+        logger.warning(
+            "Warning: Check with flux-restful-cli config edit - ordering of list can change."
+        )
+
+    def parse_boolean(self, value):
+        """
+        If the value is True/False, ensure we return a boolean
+        """
+        if isinstance(value, str) and value.lower() == "true":
+            value = True
+        elif isinstance(value, str) and value.lower() == "false":
+            value = False
+        return value
+
+    def parse_null(self, value):
+        """
+        Given a null or none from the command line, ensure parsed as None type
+        """
+        if isinstance(value, str) and value.lower() in ["none", "null"]:
+            return None
+
+        # Ensure we strip strings
+        if isinstance(value, str):
+            value = value.strip()
+        return value
+
+    def set(self, key, value):
+        """
+        Set a setting based on key and value. If the key has :, it's nested
+        """
+        while ":" in key:
+            value = str(value)
+            key, extra = key.split(":", 1)
+            value = f"{extra}:{value}"
+
+        # List values not allowed for set
+        current = self._settings.get(key)
+        if current and isinstance(current, list):
+            logger.exit("You cannot use 'set' for a list. Use add/remove instead.")
+
+        # This is a reference to a dictionary (object) setting
+        # We assume only one level of nesting allowed
+        if isinstance(value, str) and ":" in value:
+            subkey, value = value.split(":")
+            value = self.parse_boolean(value)
+            value = self.parse_null(value)
+            self._settings[key][subkey] = value
+        else:
+            value = self.parse_boolean(value)
+            value = self.parse_null(value)
+            self._settings[key] = value
+
+        # Validate and catch error message cleanly
+        self.change_validate(key, value)
+
+    def change_validate(self, key, value):
+        """
+        A courtesy function to validate a new config addition.
+        """
+        # Don't allow the user to add a setting not known
+        try:
+            self.validate()
+        except jsonschema.exceptions.ValidationError as error:
+            logger.exit(
+                "%s:%s cannot be added to config: %s" % (key, value, error.message)
+            )
+
+    @property
+    def filesystem_registry(self):
+        """
+        Return the first found filesystem registry
+        """
+        for path in self.registry:
+            if path.startswith("http") or not os.path.exists(path):
+                continue
+            return path
+
+    def ensure_filesystem_registry(self):
+        """
+        Ensure that the settings has a filesystem registry.
+        """
+        found = False
+        for path in self.registry:
+            if path.startswith("http") or not os.path.exists(path):
+                continue
+            found = True
+
+        # Cut out early if registry isn't on the filesystem
+        if not found:
+            logger.exit(
+                "This command is only supported for a filesystem registry! Add one or use --registry."
+            )
+
+    def _substitutions(self, value):
+        """
+        Given a value, make substitutions
+        """
+        if isinstance(value, bool) or not value:
+            return value
+
+        # Currently dicts only support boolean or null so we return as is
+        elif isinstance(value, dict):
+            return value
+
+        for rep, repvalue in defaults.reps.items():
+            if isinstance(value, list):
+                value = [x.replace(rep, repvalue) for x in value]
+            elif isinstance(value, str):
+                value = value.replace(rep, repvalue)
+
+        return value
+
+    def delete(self, key):
+        if key in self._settings:
+            del self._settings[key]
+
+    def save(self, filename=None):
+        """
+        Save settings, but do not change order of anything.
+        """
+        filename = filename or self.settings_file
+        if not filename:
+            logger.exit("A filename is required to save to.")
+        utils.write_yaml(self._settings, filename)
+
+    def __iter__(self):
+        for key, value in self.__dict__.items():
+            yield key, value
+
+    def update_params(self, params):
+        """
+        Update a configuration on the fly (no save) only for set/add/remove.
+        Unlike the traditional set/get/add functions, this function expects
+        each entry in the params list to start with the action, e.g.:
+
+        set:name:value
+        add:name:value
+        rm:name:value
+        """
+        # Cut out early if params not provided
+        if not params:
+            return
+
+        for param in params:
+            if not re.search("^(add|set|rm)", param, re.IGNORECASE) or ":" not in param:
+                logger.warning(
+                    "Parameter update request must start with (add|set|rm):, skipping %s"
+                )
+            command, param = param.split(":", 1)
+            self.update_param(command.lower(), param)
+
+    def update_param(self, command, param):
+        """
+        Given a parameter, update the configuration on the fly if it's in set/add/remove
+        """
+        # If we are given a list, assume is key and value at end
+        if isinstance(param, list):
+            # If one given, assume old format
+            if len(param) == 1:
+                param = param[0]
+            elif len(param) == 2:
+                key, value = param
+            elif len(param) != 2:
+                logger.exit(
+                    f"When providing a list, it must be a [key, value]. Found {param}"
+                )
+
+        # With a string, assume splittling by :
+        if isinstance(param, str):
+            if ":" not in param:
+                logger.exit(
+                    "Param %s is missing a :, should be key:value pair. Skipping."
+                    % param
+                )
+            key, value = param.rsplit(":", 1)
+
+        if command == "set":
+            self.set(key, value)
+            logger.info("Updated %s to be %s" % (key, value))
+        elif command == "add":
+            self.add(key, value)
+            logger.info("Added %s to %s" % (key, value))
+        elif command == "remove":
+            self.remove(key, value)
+            logger.info("Removed %s from %s" % (key, value))
+
+
+class Settings(SettingsBase):
+    """
+    The settings class is a wrapper for easily parsing a settings.yml file.
+
+    We parse into a query-able class. It also gives us control to update settings,
+    meaning we change the values and then write them to file. It's basically
+    a dictionary-like class with extra functions.
+    """
+
+    def __init__(self, settings_file, validate=True):
+        """
+        Create a new settings object, which requires a settings file to load
+        """
+        self.load(settings_file)
+        if validate:
+            self.validate()
diff --git a/clients/python/flux_restful_client/settings.yml b/clients/python/flux_restful_client/settings.yml
new file mode 100644
index 0000000..106ce32
--- /dev/null
+++ b/clients/python/flux_restful_client/settings.yml
@@ -0,0 +1,16 @@
+# The flux-restful-client settings file allows for finer control
+# over settings. For most people, you shouldn't need to change these defaults
+
+# config editor
+config_editor: vim
+
+# Default host and port (e.g., API baseurl)
+host: "http://127.0.0.1:5000"
+
+# Default working directory to use in the container
+workdir: null
+
+# Default flux user and token
+# This is recommended to get from the environment and not write to file
+flux_user: null
+flux_token: null
diff --git a/clients/python/flux_restful_client/utils/__init__.py b/clients/python/flux_restful_client/utils/__init__.py
new file mode 100644
index 0000000..5a8cfe2
--- /dev/null
+++ b/clients/python/flux_restful_client/utils/__init__.py
@@ -0,0 +1,18 @@
+from .auth import authHeader, get_basic_auth, parse_auth_header
+from .fileio import (
+    copyfile,
+    get_tmpdir,
+    get_tmpfile,
+    get_yaml_string,
+    mkdir_p,
+    mkdirp,
+    print_json,
+    read_file,
+    read_json,
+    read_yaml,
+    recursive_find,
+    write_file,
+    write_json,
+    write_yaml,
+)
+from .terminal import confirm_action, get_installdir, run_command
diff --git a/clients/python/flux_restful_client/utils/auth.py b/clients/python/flux_restful_client/utils/auth.py
new file mode 100644
index 0000000..322da9b
--- /dev/null
+++ b/clients/python/flux_restful_client/utils/auth.py
@@ -0,0 +1,29 @@
+import base64
+import re
+
+
+def get_basic_auth(username, password):
+    auth_str = "%s:%s" % (username, password)
+    return base64.b64encode(auth_str.encode("utf-8")).decode("utf-8")
+
+
+def parse_auth_header(authHeaderRaw):
+    """
+    Parse authentication header into pieces
+    """
+    regex = re.compile('([a-zA-z]+)="(.+?)"')
+    matches = regex.findall(authHeaderRaw)
+    lookup = dict()
+    for match in matches:
+        lookup[match[0]] = match[1]
+    return authHeader(lookup)
+
+
+class authHeader:
+    def __init__(self, lookup):
+        """
+        Given a dictionary of values, match them to class attributes
+        """
+        for key in lookup:
+            if key in ["realm", "service", "scope"]:
+                setattr(self, key.capitalize(), lookup[key])
diff --git a/clients/python/flux_restful_client/utils/fileio.py b/clients/python/flux_restful_client/utils/fileio.py
new file mode 100644
index 0000000..f2d2493
--- /dev/null
+++ b/clients/python/flux_restful_client/utils/fileio.py
@@ -0,0 +1,177 @@
+import errno
+import io
+import json
+import os
+import re
+import shutil
+import stat
+import tempfile
+
+from flux_restful_client.logger import logger
+
+try:
+    from ruamel_yaml import YAML
+except ImportError:
+    from ruamel.yaml import YAML
+
+
+def mkdirp(dirnames):
+    """
+    Create one or more directories
+    """
+    for dirname in dirnames:
+        mkdir_p(dirname)
+
+
+def mkdir_p(path):
+    """
+    Make a directory path if it does not exist, akin to mkdir -p
+    """
+    try:
+        os.makedirs(path)
+    except OSError as e:
+        if e.errno == errno.EEXIST and os.path.isdir(path):
+            pass
+        else:
+            logger.exit("Error creating path %s, exiting." % path)
+
+
+def get_tmpfile(tmpdir=None, prefix=""):
+    """
+    Get a temporary file with an optional prefix.
+    """
+
+    # First priority for the base goes to the user requested.
+    tmpdir = get_tmpdir(tmpdir)
+
+    # If tmpdir is set, add to prefix
+    if tmpdir:
+        prefix = os.path.join(tmpdir, os.path.basename(prefix))
+
+    fd, tmp_file = tempfile.mkstemp(prefix=prefix)
+    os.close(fd)
+
+    return tmp_file
+
+
+def get_tmpdir(tmpdir=None, prefix="", create=True):
+    """
+    Get a temporary directory for an operation.
+    """
+    tmpdir = tmpdir or tempfile.gettempdir()
+    prefix = prefix or "action-updater-tmp"
+    prefix = "%s.%s" % (prefix, next(tempfile._get_candidate_names()))
+    tmpdir = os.path.join(tmpdir, prefix)
+
+    if not os.path.exists(tmpdir) and create is True:
+        os.mkdir(tmpdir)
+
+    return tmpdir
+
+
+def recursive_find(base, pattern=None):
+    """
+    Find filenames that match a particular pattern, and yield them.
+    """
+    # We can identify modules by finding module.lua
+    for root, _, files in os.walk(base):
+        for file in files:
+            fullpath = os.path.abspath(os.path.join(root, file))
+            if pattern and not re.search(pattern, fullpath):
+                continue
+            yield fullpath
+
+
+def copyfile(source, destination, force=True):
+    """
+    Copy a file from a source to its destination.
+    """
+    # Case 1: It's already there, we aren't replacing it :)
+    if source == destination and force is False:
+        return destination
+
+    # Case 2: It's already there, we ARE replacing it :)
+    if os.path.exists(destination) and force is True:
+        os.remove(destination)
+
+    shutil.copyfile(source, destination)
+    return destination
+
+
+def write_file(filename, content, mode="w", exec=False):
+    """
+    Write content to a filename
+    """
+    with open(filename, mode) as filey:
+        filey.writelines(content)
+    if exec:
+        st = os.stat(filename)
+
+        # Execute / search permissions for the user and others
+        os.chmod(filename, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+    return filename
+
+
+def write_json(json_obj, filename, mode="w"):
+    """
+    Write json to a filename
+    """
+    with open(filename, mode) as filey:
+        filey.writelines(print_json(json_obj))
+    return filename
+
+
+def print_json(json_obj):
+    """
+    Print json pretty
+    """
+    return json.dumps(json_obj, indent=4, separators=(",", ": "))
+
+
+def write_yaml(obj, filename, line_length=None):
+    """
+    Save yaml to file, also preserving comments.
+    """
+    yaml = YAML()
+    yaml.preserve_quotes = True
+
+    with open(filename, "w") as fd:
+        yaml.dump(obj, fd)
+
+
+def get_yaml_string(obj):
+    """
+    Get yaml output as string
+    """
+    # Prepare to dump formatted yaml
+    yaml = YAML()
+    yaml.preserve_quotes = True
+    out = io.StringIO()
+    yaml.dump(obj, out)
+    return out.getvalue()
+
+
+def read_yaml(filename):
+    """
+    Load a yaml from file, roundtrip to preserve comments
+    """
+    yaml = YAML()
+    with open(filename, "r") as fd:
+        content = yaml.load(fd.read())
+    return content
+
+
+def read_file(filename, mode="r"):
+    """
+    Read a file.
+    """
+    with open(filename, mode) as filey:
+        content = filey.read()
+    return content
+
+
+def read_json(filename, mode="r"):
+    """
+    Read a json file to a dictionary.
+    """
+    return json.loads(read_file(filename))
diff --git a/clients/python/flux_restful_client/utils/terminal.py b/clients/python/flux_restful_client/utils/terminal.py
new file mode 100644
index 0000000..255075a
--- /dev/null
+++ b/clients/python/flux_restful_client/utils/terminal.py
@@ -0,0 +1,60 @@
+import os
+from subprocess import PIPE, STDOUT, Popen
+
+
+def get_installdir():
+    """
+    get the installation directory of the application
+    """
+    return os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
+
+
+def run_command(cmd, sudo=False, stream=False):
+    """run_command uses subprocess to send a command to the terminal.
+
+    Parameters
+    ==========
+    cmd: the command to send, should be a list for subprocess
+    error_message: the error message to give to user if fails,
+    if none specified, will alert that command failed.
+
+    """
+    stdout = PIPE if not stream else None
+    if sudo is True:
+        cmd = ["sudo"] + cmd
+
+    try:
+        output = Popen(cmd, stderr=STDOUT, stdout=stdout)
+
+    except FileNotFoundError:
+        cmd.pop(0)
+        output = Popen(cmd, stderr=STDOUT, stdout=PIPE)
+
+    t = output.communicate()[0], output.returncode
+    output = {"message": t[0], "return_code": t[1]}
+
+    if isinstance(output["message"], bytes):
+        output["message"] = output["message"].decode("utf-8")
+
+    return output
+
+
+def confirm_action(question, force=False):
+    """confirm if the user wants to perform a certain action
+
+    Parameters
+    ==========
+    question: the question that will be asked
+    force: if the user wants to skip the prompt
+    """
+    if force is True:
+        return True
+
+    response = input(question + " (yes/no)? ")
+    while len(response) < 1 or response[0].lower().strip() not in "ynyesno":
+        response = input("Please answer yes or no: ")
+
+    if response[0].lower().strip() in "no":
+        return False
+
+    return True
diff --git a/clients/python/flux_restful_client/version.py b/clients/python/flux_restful_client/version.py
new file mode 100644
index 0000000..1c3f343
--- /dev/null
+++ b/clients/python/flux_restful_client/version.py
@@ -0,0 +1,23 @@
+__version__ = "0.0.0"
+AUTHOR = "Vanessa Sochat"
+EMAIL = "vsoch@users.noreply.github.com"
+NAME = "flux-restful-client"
+PACKAGE_URL = (
+    "https://github.com/flux-framework/flux-restful-api/tree/main/clients/python"
+)
+KEYWORDS = "flux, flux framework, client, RESTFul API"
+DESCRIPTION = "Python functions and command line equivalent for interacting with Flux RESTful API."
+LICENSE = "LICENSE"
+
+################################################################################
+# Global requirements
+
+INSTALL_REQUIRES = (
+    # Required to maintain comments in files
+    ("ruamel.yaml", {"min_version": None}),
+    ("requests", {"min_version": None}),
+    ("jsonschema", {"min_version": None}),
+)
+
+TESTS_REQUIRES = (("pytest", {"min_version": "4.6.2"}),)
+INSTALL_REQUIRES_ALL = INSTALL_REQUIRES + TESTS_REQUIRES
diff --git a/clients/python/setup.py b/clients/python/setup.py
new file mode 100644
index 0000000..75eb6c2
--- /dev/null
+++ b/clients/python/setup.py
@@ -0,0 +1,99 @@
+import os
+
+from setuptools import find_packages, setup
+
+
+def get_lookup():
+    lookup = dict()
+    version_file = os.path.join("flux_restful_client", "version.py")
+    with open(version_file) as filey:
+        exec(filey.read(), lookup)
+    return lookup
+
+
+# Read in requirements
+def get_reqs(lookup=None, key="INSTALL_REQUIRES"):
+    if lookup is None:
+        lookup = get_lookup()
+
+    install_requires = []
+    for module in lookup[key]:
+        module_name = module[0]
+        module_meta = module[1]
+        if "exact_version" in module_meta:
+            dependency = "%s==%s" % (module_name, module_meta["exact_version"])
+        elif "min_version" in module_meta:
+            if module_meta["min_version"] is None:
+                dependency = module_name
+            else:
+                dependency = "%s>=%s" % (module_name, module_meta["min_version"])
+        install_requires.append(dependency)
+    return install_requires
+
+
+# Make sure everything is relative to setup.py
+install_path = os.path.dirname(os.path.abspath(__file__))
+os.chdir(install_path)
+
+# Get version information from the lookup
+lookup = get_lookup()
+VERSION = lookup["__version__"]
+NAME = lookup["NAME"]
+AUTHOR = lookup["AUTHOR"]
+EMAIL = lookup["EMAIL"]
+PACKAGE_URL = lookup["PACKAGE_URL"]
+KEYWORDS = lookup["KEYWORDS"]
+DESCRIPTION = lookup["DESCRIPTION"]
+LICENSE = lookup["LICENSE"]
+
+# Try to read description, otherwise fallback to short description
+try:
+    with open("README.md") as filey:
+        LONG_DESCRIPTION = filey.read()
+except FileNotFoundError:
+    LONG_DESCRIPTION = DESCRIPTION
+
+
+if __name__ == "__main__":
+
+    INSTALL_REQUIRES = get_reqs(lookup)
+    TESTS_REQUIRES = get_reqs(lookup, "TESTS_REQUIRES")
+    INSTALL_REQUIRES_ALL = get_reqs(lookup, "INSTALL_REQUIRES_ALL")
+
+    setup(
+        name=NAME,
+        version=VERSION,
+        author=AUTHOR,
+        author_email=EMAIL,
+        maintainer=AUTHOR,
+        packages=find_packages(),
+        include_package_data=True,
+        zip_safe=False,
+        url=PACKAGE_URL,
+        license=LICENSE,
+        description=DESCRIPTION,
+        long_description=LONG_DESCRIPTION,
+        long_description_content_type="text/markdown",
+        keywords=KEYWORDS,
+        setup_requires=["pytest-runner"],
+        install_requires=INSTALL_REQUIRES,
+        tests_require=TESTS_REQUIRES,
+        extras_require={
+            "all": [INSTALL_REQUIRES_ALL],
+        },
+        classifiers=[
+            "Intended Audience :: Science/Research",
+            "Intended Audience :: Developers",
+            "License :: OSI Approved :: GNU Lesser General Public License v3 (LGPLv3)",
+            "Programming Language :: Python",
+            "Topic :: Software Development",
+            "Topic :: Scientific/Engineering",
+            "Operating System :: Unix",
+            "Programming Language :: Python :: 3.7",
+        ],
+        entry_points={
+            "console_scripts": [
+                "flux-restful-cli=flux_restful_client.client:run_flux_restful_client"
+            ]
+        },
+    )
diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html
index 7e618b6..8159f5e 100644
--- a/docs/_templates/layout.html
+++ b/docs/_templates/layout.html
@@ -41,7 +41,10 @@
   'GRAD' 0,
   'opsz' 48
 }
-</style>
+.sphx-glr-script-out .highlight pre {
+  background-color: #def0ff !important;
+}
+  </style>
 
 
 {# Call the parent block #}
diff --git a/docs/auto_examples/api_tutorial.ipynb b/docs/auto_examples/api_tutorial.ipynb
index a9b7ccb..b59e110 100644
--- a/docs/auto_examples/api_tutorial.ipynb
+++ b/docs/auto_examples/api_tutorial.ipynb
@@ -26,7 +26,7 @@
       },
       "outputs": [],
       "source": [
-        "import json\nimport os\nimport sys\nimport matplotlib.pyplot as plt\n\n# This is expected to be rendered from docs root\nhere = os.path.dirname(os.path.abspath(os.getcwd()))\nroot = os.path.dirname(here)\n\n# This is here for the nice thumbnail :)\nimage = plt.imread(os.path.join(here, \"images\", \"logo.png\"))\nfig = plt.imshow(image)\nplt.axis('off')\nplt.show()\n\n# This directory has the Python client\nsys.path.insert(0, os.path.join(root, \"examples\"))\n\nfrom flux_restful_client import FluxRestfulClient  # noqa"
+        "import json\nimport os\nimport sys\n\nimport matplotlib.pyplot as plt\nfrom flux_restful_client.main import get_client\n\n# This is expected to be rendered from docs root\nhere = os.path.dirname(os.path.abspath(os.getcwd()))\n\n# This is here for the nice thumbnail :)\nimage = plt.imread(os.path.join(here, \"images\", \"logo.png\"))\nfig = plt.imshow(image)\nplt.axis(\"off\")\nplt.show()"
       ]
     },
     {
@@ -44,7 +44,7 @@
       },
       "outputs": [],
       "source": [
-        "cli = FluxRestfulClient()"
+        "cli = get_client()"
       ]
     },
     {
@@ -62,7 +62,7 @@
       },
       "outputs": [],
       "source": [
-        "print(\"Listing nodes\")\nres = cli.list_nodes();\nif res:\n    print(json.dumps(res, indent=4))"
+        "print(\"Listing nodes\")\nres = cli.list_nodes()\nif res:\n    print(json.dumps(res, indent=4))"
       ]
     },
     {
@@ -80,7 +80,7 @@
       },
       "outputs": [],
       "source": [
-        "print(\"\ud83d\ude34 Submitting job sleep 60\")\nres = cli.submit(command=[\"sleep\", 60]);\n\n# This is an indication something went wrong - detail has an error.\nif res and \"detail\" in res:\n    print(res['detail']);\n    sys.exit();"
+        "print(\"\ud83d\ude34 Submitting job sleep 60\")\nres = cli.submit(command=[\"sleep\", 60])\n\n# This is an indication something went wrong - detail has an error.\nif res and \"detail\" in res:\n    print(res[\"detail\"])\n    sys.exit()"
       ]
     },
     {
@@ -105,7 +105,7 @@
       },
       "outputs": [],
       "source": [
-        "print(\"\ud83c\udf53 Getting job info...\")\nres = cli.jobs(res[\"id\"]);\nif res:\n    print(json.dumps(res, indent=4))"
+        "print(\"\ud83c\udf53 Getting job info...\")\nres = cli.jobs(res[\"id\"])\nif res:\n    print(json.dumps(res, indent=4))"
       ]
     },
     {
@@ -123,7 +123,7 @@
       },
       "outputs": [],
       "source": [
-        "print(\"Submitting 3 jobs to sleep!\")\nfor time in [10, 20, 30]:\n    cli.submit(command=[\"sleep\", time]);\nres = cli.jobs();\nif res:\n    print(json.dumps(res, indent=4))"
+        "print(\"Submitting 3 jobs to sleep!\")\nfor time in [10, 20, 30]:\n    cli.submit(command=[\"sleep\", time])\nres = cli.jobs()\nif res:\n    print(json.dumps(res, indent=4))"
       ]
     },
     {
@@ -141,7 +141,7 @@
       },
       "outputs": [],
       "source": [
-        "print(\"Submitting job sleep 60 intending to cancel..\")\nres = cli.submit(command=[\"sleep\", 60]);\nif res:\n    print(json.dumps(res, indent=4))\n    print(\"Requesting job cancel..\")\n    res = cli.cancel(res[\"id\"]);\n    print(json.dumps(res, indent=4))"
+        "print(\"Submitting job sleep 60 intending to cancel..\")\nres = cli.submit(command=[\"sleep\", 60])\nif res:\n    print(json.dumps(res, indent=4))\n    print(\"Requesting job cancel..\")\n    res = cli.cancel(res[\"id\"])\n    print(json.dumps(res, indent=4))"
       ]
     },
     {
diff --git a/docs/auto_examples/api_tutorial.py b/docs/auto_examples/api_tutorial.py
index db57b0a..153be2a 100644
--- a/docs/auto_examples/api_tutorial.py
+++ b/docs/auto_examples/api_tutorial.py
@@ -13,10 +13,10 @@
 import sys
 
 import matplotlib.pyplot as plt
+from flux_restful_client.main import get_client
 
 # This is expected to be rendered from docs root
 here = os.path.dirname(os.path.abspath(os.getcwd()))
-root = os.path.dirname(here)
 
 # This is here for the nice thumbnail :)
 image = plt.imread(os.path.join(here, "images", "logo.png"))
@@ -24,17 +24,12 @@
 plt.axis("off")
 plt.show()
 
-# This directory has the Python client
-sys.path.insert(0, os.path.join(root, "examples"))
-
-from flux_restful_client import FluxRestfulClient  # noqa
-
 #%%
 # Here we instantiate a client. If you need authentication, this can optionally take
 # a user and token, or also derive from the FLUX_USER and FLUX_TOKEN in the
 # environment.
 
-cli = FluxRestfulClient()
+cli = get_client()
 
 #%%
 # Let's list the nodes in our cluster!
diff --git a/docs/auto_examples/api_tutorial.py.md5 b/docs/auto_examples/api_tutorial.py.md5
index b0f015d..219bb34 100644
--- a/docs/auto_examples/api_tutorial.py.md5
+++ b/docs/auto_examples/api_tutorial.py.md5
@@ -1,1 +1,1 @@
-f93e1f15dcd5b915c91764084c9d59b5
+d8f70026580a06a451b0e658b603ae3b
diff --git a/docs/auto_examples/api_tutorial.rst b/docs/auto_examples/api_tutorial.rst
index 3bb61f7..7d3cb59 100644
--- a/docs/auto_examples/api_tutorial.rst
+++ b/docs/auto_examples/api_tutorial.rst
@@ -25,7 +25,7 @@ This small tutorial walks through the basics of using an API.
 The most basic thing to do is submit a job to the API,
 list, get statuses, cancel. We can use our example client for this.
 
-.. GENERATED FROM PYTHON SOURCE LINES 10-31
+.. GENERATED FROM PYTHON SOURCE LINES 10-27
 
 .. code-block:: default
 
@@ -33,23 +33,19 @@ list, get statuses, cancel. We can use our example client for this.
     import json
     import os
     import sys
+
     import matplotlib.pyplot as plt
+    from flux_restful_client.main import get_client
 
     # This is expected to be rendered from docs root
     here = os.path.dirname(os.path.abspath(os.getcwd()))
-    root = os.path.dirname(here)
 
     # This is here for the nice thumbnail :)
     image = plt.imread(os.path.join(here, "images", "logo.png"))
     fig = plt.imshow(image)
-    plt.axis('off')
+    plt.axis("off")
     plt.show()
 
-    # This directory has the Python client
-    sys.path.insert(0, os.path.join(root, "examples"))
-
-    from flux_restful_client import FluxRestfulClient  # noqa
-
 
 
 
@@ -62,18 +58,18 @@ list, get statuses, cancel. We can use our example client for this.
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 32-35
+.. GENERATED FROM PYTHON SOURCE LINES 28-31
 
 Here we instantiate a client. If you need authentication, this can optionally take
 a user and token, or also derive from the FLUX_USER and FLUX_TOKEN in the
 environment.
 
-.. GENERATED FROM PYTHON SOURCE LINES 35-38
+.. GENERATED FROM PYTHON SOURCE LINES 31-34
 
 .. code-block:: default
 
 
-    cli = FluxRestfulClient()
+    cli = get_client()
 
 
 
@@ -82,16 +78,16 @@ environment.
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 39-40
+.. GENERATED FROM PYTHON SOURCE LINES 35-36
 
 Let's list the nodes in our cluster!
 
-.. GENERATED FROM PYTHON SOURCE LINES 40-46
+.. GENERATED FROM PYTHON SOURCE LINES 36-42
 
 .. code-block:: default
 
     print("Listing nodes")
-    res = cli.list_nodes();
+    res = cli.list_nodes()
     if res:
         print(json.dumps(res, indent=4))
 
@@ -105,32 +101,31 @@ Let's list the nodes in our cluster!
  .. code-block:: none
 
     Listing nodes
-    {}
     {
         "nodes": [
-            "a6856cb188a9"
+            "3a2a7a9428e6"
         ]
     }
 
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 47-48
+.. GENERATED FROM PYTHON SOURCE LINES 43-44
 
 Now let's submit a job to Flux.
 
-.. GENERATED FROM PYTHON SOURCE LINES 48-57
+.. GENERATED FROM PYTHON SOURCE LINES 44-53
 
 .. code-block:: default
 
 
     print("😴 Submitting job sleep 60")
-    res = cli.submit(command=["sleep", 60]);
+    res = cli.submit(command=["sleep", 60])
 
     # This is an indication something went wrong - detail has an error.
     if res and "detail" in res:
-        print(res['detail']);
-        sys.exit();
+        print(res["detail"])
+        sys.exit()
 
 
 
@@ -141,12 +136,11 @@ Now let's submit a job to Flux.
  .. code-block:: none
 
     😴 Submitting job sleep 60
-    {}
 
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 58-64
+.. GENERATED FROM PYTHON SOURCE LINES 54-60
 
 To require auth, the server should be startup with these variables
 in the environment (and the first two found by the client here)
@@ -155,16 +149,16 @@ FLUX_USER=fluxuser
 FLUX_TOKEN=12345
 FLUX_REQUIRE_AUTH=true
 
-.. GENERATED FROM PYTHON SOURCE LINES 66-67
+.. GENERATED FROM PYTHON SOURCE LINES 62-63
 
 And finally, let's get job info.
 
-.. GENERATED FROM PYTHON SOURCE LINES 67-72
+.. GENERATED FROM PYTHON SOURCE LINES 63-68
 
 .. code-block:: default
 
     print("🍓 Getting job info...")
-    res = cli.jobs(res["id"]);
+    res = cli.jobs(res["id"])
     if res:
         print(json.dumps(res, indent=4))
 
@@ -177,16 +171,15 @@ And finally, let's get job info.
  .. code-block:: none
 
     🍓 Getting job info...
-    {}
     {
         "job": {
-            "id": 24466415419392,
+            "id": 13428886339584,
             "userid": 0,
             "urgency": 16,
             "priority": 16,
-            "t_submit": 1667781617.2412803,
-            "t_depend": 1667781617.2412803,
-            "t_run": 1667781617.2555447,
+            "t_submit": 1667955436.1993887,
+            "t_depend": 1667955436.1993887,
+            "t_run": 1667955436.2116482,
             "state": 16,
             "name": "sleep",
             "ntasks": 1,
@@ -194,27 +187,27 @@ And finally, let's get job info.
             "duration": 0.0,
             "nnodes": 1,
             "ranks": "0",
-            "nodelist": "a6856cb188a9",
-            "expiration": 4821381617.0
+            "nodelist": "3a2a7a9428e6",
+            "expiration": 4821555436.0
         }
     }
 
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 73-75
+.. GENERATED FROM PYTHON SOURCE LINES 69-71
 
 Now let's submit three jobs in unison so we can list them back!
 Submit the job to flux
 
-.. GENERATED FROM PYTHON SOURCE LINES 75-82
+.. GENERATED FROM PYTHON SOURCE LINES 71-78
 
 .. code-block:: default
 
     print("Submitting 3 jobs to sleep!")
     for time in [10, 20, 30]:
-        cli.submit(command=["sleep", time]);
-    res = cli.jobs();
+        cli.submit(command=["sleep", time])
+    res = cli.jobs()
     if res:
         print(json.dumps(res, indent=4))
 
@@ -227,98 +220,34 @@ Submit the job to flux
  .. code-block:: none
 
     Submitting 3 jobs to sleep!
-    {}
-    {}
-    {}
-    {}
     {
         "jobs": [
             {
-                "id": 24468630011904
-            },
-            {
-                "id": 24467992477696
-            },
-            {
-                "id": 24467136839680
-            },
-            {
-                "id": 24466415419392
-            },
-            {
-                "id": 19772888580096
-            },
-            {
-                "id": 19774398529536
-            },
-            {
-                "id": 19773911990272
-            },
-            {
-                "id": 19773425451008
-            },
-            {
-                "id": 19775019286528
-            },
-            {
-                "id": 12969610051584
-            },
-            {
-                "id": 12971103223808
-            },
-            {
-                "id": 12970599907328
-            },
-            {
-                "id": 12970130145280
-            },
-            {
-                "id": 12971740758016
-            },
-            {
-                "id": 11126901309440
-            },
-            {
-                "id": 11128327372800
+                "id": 13430681501696
             },
             {
-                "id": 11127857610752
+                "id": 13430010413056
             },
             {
-                "id": 11127421403136
+                "id": 13429473542144
             },
             {
-                "id": 11128998461440
+                "id": 13428886339584
             },
             {
-                "id": 5668031430656
+                "id": 24310185984
             },
             {
-                "id": 5669742706688
+                "id": 25987907584
             },
             {
-                "id": 5669205835776
+                "id": 25467813888
             },
             {
-                "id": 5668652187648
+                "id": 24914165760
             },
             {
-                "id": 5670447349760
-            },
-            {
-                "id": 3678102618112
-            },
-            {
-                "id": 3679629344768
-            },
-            {
-                "id": 3679109251072
-            },
-            {
-                "id": 3678639489024
-            },
-            {
-                "id": 3680250101760
+                "id": 26625441792
             }
         ]
     }
@@ -326,20 +255,20 @@ Submit the job to flux
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 83-84
+.. GENERATED FROM PYTHON SOURCE LINES 79-80
 
 Finally, let's submit and cancel a job
 
-.. GENERATED FROM PYTHON SOURCE LINES 84-92
+.. GENERATED FROM PYTHON SOURCE LINES 80-88
 
 .. code-block:: default
 
     print("Submitting job sleep 60 intending to cancel..")
-    res = cli.submit(command=["sleep", 60]);
+    res = cli.submit(command=["sleep", 60])
     if res:
         print(json.dumps(res, indent=4))
         print("Requesting job cancel..")
-        res = cli.cancel(res["id"]);
+        res = cli.cancel(res["id"])
         print(json.dumps(res, indent=4))
 
 
@@ -351,13 +280,11 @@ Finally, let's submit and cancel a job
  .. code-block:: none
 
     Submitting job sleep 60 intending to cancel..
-    {}
     {
         "Message": "Job submit.",
-        "id": 24469485649920
+        "id": 13431470030848
     }
     Requesting job cancel..
-    {}
     {
         "Message": "Job is requested to cancel."
     }
@@ -365,11 +292,11 @@ Finally, let's submit and cancel a job
 
 
 
-.. GENERATED FROM PYTHON SOURCE LINES 93-94
+.. GENERATED FROM PYTHON SOURCE LINES 89-90
 
 And this would be how you stop your cluster service
 
-.. GENERATED FROM PYTHON SOURCE LINES 94-95
+.. GENERATED FROM PYTHON SOURCE LINES 90-92
 
 .. code-block:: default
 
@@ -378,6 +305,7 @@ And this would be how you stop your cluster service
 
 
 
+
 .. rst-class:: sphx-glr-script-out
 
  .. code-block:: none
@@ -390,7 +318,7 @@ And this would be how you stop your cluster service
 
 .. rst-class:: sphx-glr-timing
 
-   **Total running time of the script:** ( 0 minutes  0.577 seconds)
+   **Total running time of the script:** ( 0 minutes  0.508 seconds)
 
 
 .. _sphx_glr_download_auto_examples_api_tutorial.py:
diff --git a/docs/auto_examples/api_tutorial_codeobj.pickle b/docs/auto_examples/api_tutorial_codeobj.pickle
index 68ed794..a709c2d 100644
Binary files a/docs/auto_examples/api_tutorial_codeobj.pickle and b/docs/auto_examples/api_tutorial_codeobj.pickle differ
diff --git a/docs/auto_examples/auto_examples_jupyter.zip b/docs/auto_examples/auto_examples_jupyter.zip
index 8c9208d..d45605d 100644
Binary files a/docs/auto_examples/auto_examples_jupyter.zip and b/docs/auto_examples/auto_examples_jupyter.zip differ
diff --git a/docs/auto_examples/auto_examples_python.zip b/docs/auto_examples/auto_examples_python.zip
index b30faf4..6208666 100644
Binary files a/docs/auto_examples/auto_examples_python.zip and b/docs/auto_examples/auto_examples_python.zip differ
diff --git a/docs/auto_examples/index.rst b/docs/auto_examples/index.rst
index ec4ebdd..d266c69 100644
--- a/docs/auto_examples/index.rst
+++ b/docs/auto_examples/index.rst
@@ -18,6 +18,7 @@ This gallery consists of introductory examples and general examples
 of using the Flux RESTFul API in Python.
 
 
+
 .. raw:: html
 
     <div class="sphx-glr-thumbnails">
diff --git a/docs/auto_examples/sg_execution_times.rst b/docs/auto_examples/sg_execution_times.rst
index e46521b..9c912a8 100644
--- a/docs/auto_examples/sg_execution_times.rst
+++ b/docs/auto_examples/sg_execution_times.rst
@@ -5,8 +5,8 @@
 
 Computation times
 =================
-**00:00.577** total execution time for **auto_examples** files:
+**00:00.508** total execution time for **auto_examples** files:
 
 +---------------------------------------------------------------------+-----------+--------+
-| :ref:`sphx_glr_auto_examples_api_tutorial.py` (``api_tutorial.py``) | 00:00.577 | 0.0 MB |
+| :ref:`sphx_glr_auto_examples_api_tutorial.py` (``api_tutorial.py``) | 00:00.508 | 0.0 MB |
 +---------------------------------------------------------------------+-----------+--------+
diff --git a/docs/examples/api_tutorial.py b/docs/examples/api_tutorial.py
index db57b0a..153be2a 100644
--- a/docs/examples/api_tutorial.py
+++ b/docs/examples/api_tutorial.py
@@ -13,10 +13,10 @@
 import sys
 
 import matplotlib.pyplot as plt
+from flux_restful_client.main import get_client
 
 # This is expected to be rendered from docs root
 here = os.path.dirname(os.path.abspath(os.getcwd()))
-root = os.path.dirname(here)
 
 # This is here for the nice thumbnail :)
 image = plt.imread(os.path.join(here, "images", "logo.png"))
@@ -24,17 +24,12 @@
 plt.axis("off")
 plt.show()
 
-# This directory has the Python client
-sys.path.insert(0, os.path.join(root, "examples"))
-
-from flux_restful_client import FluxRestfulClient  # noqa
-
 #%%
 # Here we instantiate a client. If you need authentication, this can optionally take
 # a user and token, or also derive from the FLUX_USER and FLUX_TOKEN in the
 # environment.
 
-cli = FluxRestfulClient()
+cli = get_client()
 
 #%%
 # Let's list the nodes in our cluster!
diff --git a/docs/getting_started/api.md b/docs/getting_started/api.md
index 628ebf5..e2233fc 100644
--- a/docs/getting_started/api.md
+++ b/docs/getting_started/api.md
@@ -26,10 +26,25 @@ Request for a job cancellation based on identifier.
 
 ### POST `/jobs/submit`
 
-Submit a new job, required metadata in the post is:
+Submit a new job
+
+#### Payload
+
+**Required** parameters in the post are:
+
+ - command (string or list): a string of the command to run (required)
+ - runtime (int): maximum runtime allowed (defaults to 0, unlimited, optional)
+
+**Optional** parameters include:
+
+- workdir (str): a working directory for the job. Defaults to "None" which is unset.
+- envars (dict): A set of key, value pairs (dict) of environment variables to add
+- num_tasks (int): Number of tasks (defaults to 1)
+- cores_per_task (int): Number of cores per task (default to 1)
+- gpus_per_task (int): Number of gpus per task (defaults to None)
+- num_nodes (int): Number of nodes (defaults to None)
+- exclusive (bool): is the job exclusive? (defaults to False)
 
- - command: a string of the command to run (required)
- - runtime: maximum runtime allowed (defaults to 0, unlimited, optional)
 
 ## Nodes
 
diff --git a/docs/getting_started/developer-guide.md b/docs/getting_started/developer-guide.md
index bbd9a5b..c79d2db 100644
--- a/docs/getting_started/developer-guide.md
+++ b/docs/getting_started/developer-guide.md
@@ -59,33 +59,6 @@ $ docker run --name flux-restful -d --rm -it -p 5000:5000 ghcr.io/flux-framework
 $ docker stop flux-restful
 ```
 
-Either way, you can open your host to [http://127.0.0.1:5000](http://127.0.0.1:5000)
-to see the very simple interface! This currently has API documentation ([openapi](https://fastapi.tiangolo.com/advanced/extending-openapi/))
-and we will soon add a table of jobs.
-
-![img/portal.png](img/portal.png)
-
-Once you have the server running, you can test out the example scripts!
-You'll minimally need requests installed:
-
-```bash
-$ pip install requests
-```
-
-Note there is more documentation about this in the user guide.
-You should export the same flux user and password token if you added authentication:
-
-```bash
-$ export FLUX_USER=fluxuser
-$ export FLUX_TOKEN=12345
-```
-```bash
-$ python
-```
-
-
-See the [examples](examples) folder for more functionality.
-
 ### Local
 
 You can use this setup locally (if you have flux and Python available) or within the Dev Container
@@ -93,7 +66,7 @@ in a VSCode environment.
 
 #### 1. Install
 
-Install dependencies:
+After cloning the repository, install dependencies:
 
 ```bash
 $ python -m venv env
@@ -141,45 +114,18 @@ export FLUX_TOKEN=123456
 export FLUX_USER_AUTH=true
 ```
 
-#### 4. Interactions
-
-After starting, you can open your browser to [http://127.0.0.1:5000](http://127.0.0.1:5000)
-to view the app interface. API endpoints are shown below, and see [examples](examples)
-for a full client and example scripts to interact with your server. E.g., here
-is an example:
-
-```console
-$ python3 examples/submit_job.py
-```
-
-To use authentication, you can either export your user and token to the environment, as before
-(and they will be found by the client):
-
-```bash
-export FLUX_USER=$USER
-export FLUX_TOKEN=123456
-```
-or if you cannot do this, the client can accept the username and password token at creation or
-after it:
+### Interactions
 
-```python
-# Make sure the directory with the client is in sys.path
-from flux_restful_client import FluxRestfulClient
+Regardless of how you install, you can open your host to [http://127.0.0.1:5000](http://127.0.0.1:5000)
+to see the very simple interface! This currently has API documentation ([openapi](https://fastapi.tiangolo.com/advanced/extending-openapi/))
+and we will soon add a table of jobs.
 
-# create with username and token
-cli = FluxRestfulClient(user="fluxuser", token="12345")
+![img/portal.png](img/portal.png)
 
-# Add or update after creation
-cli.set_basic_auth(user="fluxuser", token="12345")
-```
-In addition, you can set the hostname (which defaults to localhost on port 5000):
+Once you have the server running, you can use an example client to interact
+with the server. See our [User Guide](https://flux-framework.org/flux-restful-api/getting_started/user-guide.html) for these instructions.
 
-```python
-# create with username and token
-cli = FluxRestfulClient('http://127.0.0.1:5000')
-```
 
-See the [examples](examples) folder for a mix of example (one with submit with auth)!
 
 ## Code Linting
 
@@ -229,7 +175,7 @@ Finally, we recommend you use the same development environment also to build and
 documentation. The reason is because we import the app to derive docstrings,
 and this will require having Flux.
 
-**NOTE** to build the documentation you will need an unathenticated flux endpoint
+**NOTE** to build the documentation you will need an unauthenticated flux endpoint
 running. E.g., in another terminal:
 
 ```bash
diff --git a/docs/getting_started/user-guide.md b/docs/getting_started/user-guide.md
index 798f825..c024ebc 100644
--- a/docs/getting_started/user-guide.md
+++ b/docs/getting_started/user-guide.md
@@ -2,10 +2,289 @@
 
 Welcome to the Flux RESTful API user guide! If you come here, we are assuming
 that you have a deployment available to use to interact with. If you need
-to deploy your own API, see the Developer Documentation.
+to deploy your own API, see the [Developer Documentation](https://flux-framework.org/flux-restful-api/getting_started/developer-guide.html).
+We provide [clients](https://github.com/flux-framework/flux-restful-api/tree/main/clients) to interact with
+a running server, each described below. The Python client is also included in our [Tutorials](https://flux-framework.org/flux-restful-api/auto_examples/index.html).
 
-**under development**
+## Environment
 
-For now please see our robust [examples](https://github.com/flux-framework/flux-restful-api/tree/main/examples) folder! We will likely be added
-more detail here if/when we release the Python client separately (and
-likely will make a Go client as well).
+You should either have a Flux user and token from the server you created, or provided to you
+by an administrator. Currently Flux RESTFul API only supports the single user case,
+however this could be extended for other use cases (please [reach out](https://github.com/flux-framework/flux-restful-api/issues)
+to talk about design).
+
+```bash
+$ export FLUX_USER=fluxuser
+$ export FLUX_TOKEN=12345
+```
+
+You really should only be interacting with a server that doesn't require authentication if you are a developer.
+From here, continue reading the user guide for different language clients,
+or see our Python [examples](https://github.com/flux-framework/flux-restful-api/tree/main/clients/python/examples) folder.
+
+## Python
+
+### Installation
+
+You likely want this installed in a virtual environment:
+
+```bash
+$ python -m venv env
+$ source env/bin/activate
+```
+
+You can install the client from pip:
+
+```bash
+$ pip install flux-restful-client
+```
+
+Or from the repository directly:
+
+```bash
+$ git clone https://github.com/flux-framework/flux-restful-api
+$ cd flux-restful-api/clients/python
+# Install to your Python install
+$ pip install .
+
+# Development version using the code here
+$ pip install -e .
+```
+
+### Command Line Client
+
+Although most of you will want to interact with the client in Python, we also install
+a simple command line client to let you "one off" a quick command without needing to write
+a script. Here are examples:
+
+#### Submit
+
+Submit usage is as follows:
+
+```bash
+$ flux-restful-cli submit --help
+```
+```console
+usage: flux-restful-cli submit [-h] [--workdir WORKDIR] [--num_tasks NUM_TASKS] [--cores_per_task CORES_PER_TASK] [--gpus_per_task GPUS_PER_TASK] [--num_nodes NUM_NODES] [--exclusive]
+
+submit a job via the flux restful client
+
+optional arguments:
+  -h, --help            show this help message and exit
+  --workdir WORKDIR     The --workdir flag.
+  --num_tasks NUM_TASKS
+                        The --num_tasks flag.
+  --cores_per_task CORES_PER_TASK
+                        The --cores_per_task flag.
+  --gpus_per_task GPUS_PER_TASK
+                        The --gpus_per_task flag.
+  --num_nodes NUM_NODES
+                        The --num_nodes flag.
+  --exclusive           The --exclusive flag.
+```
+
+The only requirement is the command, which you can add to the end of the submit request.
+Here is submitting a basic job to sleep.
+
+```bash
+$ flux-restful-cli submit sleep 60
+```
+```console
+{
+    "Message": "Job submit.",
+    "id": 12402053611520
+}
+```
+
+### Cancel
+
+While we can't get confirmation of cancel, we can request it.
+
+```bash
+$ flux-restful-cli cancel --help
+```
+```console
+usage: flux-restful-cli cancel [-h] jobid
+
+cancel a job
+
+positional arguments:
+  jobid       The jobid to interact with.
+
+optional arguments:
+  -h, --help  show this help message and exit
+```
+
+Here is an example to cancel the job we just submit!
+
+```bash
+$ flux-restful-cli cancel 12402053611520
+```
+```console
+{
+    "Message": "Job is requested to cancel."
+}
+```
+
+### Job Info
+
+Get information on a specific job:
+
+```bash
+$ flux-restful-cli info 12402053611520
+```
+```console
+{
+    "job": {
+        "id": 12402053611520,
+        "userid": 1234,
+        "urgency": 16,
+        "priority": 16,
+        "t_submit": 1667941663.3324268,
+        "t_depend": 1667941663.3324268,
+        "t_run": 1667941663.3549669,
+        "t_cleanup": 1667941723.4129763,
+        "t_inactive": 1667941723.4203393,
+        "state": 64,
+        "name": "sleep",
+        "ntasks": 1,
+        "nnodes": 1,
+        "ranks": "3",
+        "nodelist": "flux-sample-3",
+        "success": true,
+        "exception_occurred": false,
+        "result": 1,
+        "expiration": 1668546463.0,
+        "annotations": {
+            "sched": {
+                "queue": "default"
+            }
+        },
+        "waitstatus": 0
+    }
+}
+```
+
+### List Jobs
+
+You can list jobs:
+
+```bash
+$ flux-restful-cli list-jobs
+```
+```console
+{
+    "jobs": [
+        {
+            "id": 22883485089792
+        },
+        {
+            "id": 12402053611520
+        }
+    ]
+}
+```
+
+### List Nodes
+
+Or you can list node available (these are running inside a Flux Operator!)
+
+```bash
+$ flux-restful-cli list-nodes
+```
+```console
+{
+    "nodes": [
+        "flux-sample-3",
+        "flux-sample-0",
+        "flux-sample-2",
+        "flux-sample-1"
+    ]
+}
+```
+
+### Shell
+
+Or get an interactive shell to play with the client from within Python (discussed next)
+
+
+```bash
+$ flux-restful-cli shell
+```
+```python
+In [2]: client
+Out[2]: [flux-restful-client]
+
+In [3]: client.submit
+Out[3]: <bound method FluxRestfulClient.submit of [flux-restful-client]>
+```
+
+### Config
+
+There are many ways to interact with your settings config (e.g., `inituser` creates one in your user home to customize):
+
+```bash
+$ flux-restful-cli config --help
+```
+```console
+usage: flux-restful-cli config [-h] [--central] [params ...]
+
+update configuration settings. Use set or get to see or set information.
+
+positional arguments:
+  params      Set or get a config value, edit the config, add or remove a list variable, or create a user-specific config.
+              flux-restful-cli config set key value
+              flux-restful-cli config set key:subkey value
+              flux-restful-cli config get key
+              flux-restful-cli edit
+              flux-restful-cli config inituser
+
+optional arguments:
+  -h, --help  show this help message and exit
+  --central   make edits to the central config file.
+```
+
+Here is an example to edit:
+
+```bash
+$ flux-restful-cli config edit
+```
+
+### Stop Service
+
+Finally, to stop your service (this is a special functionality intended for the operator!)
+
+```bash
+$ flux-restful-cli stop-service
+```
+(this isn't tested yet with the operator - might have a bug or two!)
+
+
+### Within Python
+
+See our [tutorial](https://flux-framework.org/flux-restful-api/auto_examples/api_tutorial.html#sphx-glr-auto-examples-api-tutorial-py) for this complete
+example!
+
+```python
+# Make sure the directory with the client is in sys.path
+from flux_restful_client import get_client
+
+# create with username and token
+cli = get_client(user="fluxuser", token="12345")
+
+# Add or update after creation
+cli.set_basic_auth(user="fluxuser", token="12345")
+```
+
+In addition, you can set the hostname (which defaults to localhost on port 5000):
+
+```python
+# create with username and token
+cli = get_client('http://127.0.0.1:5000')
+```
+
+See the [examples](https://github.com/flux-framework/flux-restful-api/tree/main/client/python/examples) folder for a mix of example (one with submit with auth)!
+
+## Go
+
+Coming soon!
diff --git a/docs/requirements.txt b/docs/requirements.txt
index f9c149b..b20086e 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -13,3 +13,4 @@ sphinx-copybutton
 readthedocs-sphinx-search
 sphinx-gallery
 myst-parser
+flux-restful-client
diff --git a/setup.cfg b/setup.cfg
index 9141ba1..f070cdc 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,6 +3,4 @@ exclude = env
 max-line-length = 100
 ignore = E1 E2 E5 W5
 per-file-ignores =
-    shpc/utils/__init__.py:F401
-    shpc/main/container/__init__.py:F401
-    shpc/main/registry/__init__.py:F401
+    clients/python/flux_restful_client/utils/__init__.py:F401


Test patch:
diff --git a/.github/workflows/python-tests.yaml b/.github/workflows/python-tests.yaml
new file mode 100644
index 0000000..ddbcc9c
--- /dev/null
+++ b/.github/workflows/python-tests.yaml
@@ -0,0 +1,44 @@
+name: Test Python flux-restful-api
+
+on:
+  push:
+    branches-ignore:
+    - main
+    - gh-pages
+
+jobs:
+  test-noauth:
+    runs-on: ubuntu-latest
+    services:
+      api:
+        image: ghcr.io/flux-framework/flux-restful-api:latest
+        ports:
+          - 5000:5000
+    steps:
+    - uses: actions/checkout@v3
+    - name: Run tests without auth
+      run: |
+        cd clients/python
+        pip install -e .[all]
+        pytest -xs flux_restful_client/tests/test_api.py
+
+  test-auth:
+    runs-on: ubuntu-latest
+    services:
+      api:
+        env:
+          FLUX_USER: fluxuser
+          FLUX_TOKEN: "12345"
+          FLUX_REQUIRE_AUTH: true
+        image: ghcr.io/flux-framework/flux-restful-api:latest
+        ports:
+          - 5000:5000
+    steps:
+    - uses: actions/checkout@v3
+    - name: Run tests with auth
+      run: |
+        cd clients/python
+        pip install -e .[all]
+        export FLUX_USER=fluxuser
+        export FLUX_TOKEN=12345
+        pytest -xs flux_restful_client/tests/test_api.py
diff --git a/clients/python/flux_restful_client/tests/test_api.py b/clients/python/flux_restful_client/tests/test_api.py
new file mode 100644
index 0000000..2a79da7
--- /dev/null
+++ b/clients/python/flux_restful_client/tests/test_api.py
@@ -0,0 +1,54 @@
+from flux_restful_client.main import get_client
+
+
+def test_list_nodes():
+    """
+    Test listing nodes
+    """
+    client = get_client()
+    response = client.list_nodes()
+    assert "nodes" in response
+    assert response["nodes"]
+    assert len(response["nodes"]) == 1
+
+
+def test_submit_list_job():
+    """
+    Test a manual submission
+    """
+    client = get_client()
+
+    # No jobs are submit!
+    response = client.jobs()
+    assert isinstance(response, dict)
+    assert "jobs" in response
+    assert not response["jobs"]
+
+    # Now submit a job, ensure we get one job back
+    response = client.submit("sleep 5")
+    assert isinstance(response, dict)
+    for key in ["Message", "id"]:
+        assert key in response
+    jobid = response["id"]
+
+    # Now we should see jobs
+    response = client.jobs()
+    assert response["jobs"]
+    assert len(response["jobs"]) == 1
+    assert response["jobs"][0]["id"] == jobid
+
+
+def test_cancel_job():
+    client = get_client()
+    response = client.submit("sleep 10")
+    jobid = response["id"]
+    response = client.cancel(jobid)
+    assert "Message" in response
+    assert "requested to cancel" in response["Message"]
+
+    # Give time to cancel
+    # This will be re-enabled with new container build
+    # time.sleep(3)
+    # response = client.cancel(jobid)
+    # assert "Message" in response
+    # assert "cannot be cancelled" in response["Message"]
diff --git a/tests/test_api_auth.py b/tests/test_api_auth.py
index 818e4b5..3f4f37d 100644
--- a/tests/test_api_auth.py
+++ b/tests/test_api_auth.py
@@ -1,3 +1,4 @@
+import base64
 import os
 import sys
 
@@ -5,13 +6,9 @@
 
 here = os.path.abspath(os.path.dirname(__file__))
 root = os.path.dirname(here)
-examples = os.path.join(root, "examples")
 
-sys.path.insert(0, examples)
 sys.path.insert(0, root)
 
-from flux_restful_client import get_basic_auth  # noqa
-
 # Define authentication in environment for server
 flux_user = "fluxuser"
 flux_token = "12345"
@@ -25,6 +22,11 @@
 client = TestClient(app)
 
 
+def get_basic_auth(username, password):
+    auth_str = "%s:%s" % (username, password)
+    return base64.b64encode(auth_str.encode("utf-8")).decode("utf-8")
+
+
 def get_headers():
     auth_header = get_basic_auth(flux_user, flux_token)
     if isinstance(auth_header, bytes):