Extend class/method docs and publish as user guide 74/3674/10
authorLott, Christopher (cl778h) <cl778h@att.com>
Tue, 12 May 2020 19:05:59 +0000 (15:05 -0400)
committerLott, Christopher (cl778h) <cl778h@att.com>
Wed, 13 May 2020 10:17:02 +0000 (06:17 -0400)
Minor changes to simplify the SDL wrapper methods, none functional.

Bump version to 1.1.2.

Signed-off-by: Lott, Christopher (cl778h) <cl778h@att.com>
Change-Id: Ie1d8ace03315bfd9a75aadc933aa66cf3be3e7ff

docs/conf.py
docs/developer-guide.rst
docs/index.rst
docs/overview.rst
docs/release-notes.rst
docs/rmr_api.rst
docs/user-guide.rst [new file with mode: 0644]
ricxappframe/xapp_frame.py
ricxappframe/xapp_sdl.py
setup.py
tox.ini

index d695a9a..ccad5c3 100755 (executable)
@@ -9,6 +9,9 @@ extensions = ["sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.viewco
 
 # don't alphabetically order
 autodoc_member_order = "bysource"
+# Tell numpydoc NOT to generate a list of class members to silence sphinx warnings like this:
+# simple.py:docstring of simple.simple.Simple.rst:20:autosummary: stub file not found 'simple.simple.Simple.hello'. Check your autosummary_generate setting.
+numpydoc_show_class_members = False
 
 linkcheck_ignore = ["http://localhost.*", "http://127.0.0.1.*", "https://gerrit.o-ran-sc.org.*"]
 
index 06f01b7..6cfe369 100644 (file)
@@ -1,9 +1,13 @@
 .. This work is licensed under a Creative Commons Attribution 4.0 International License.
-.. http://creativecommons.org/licenses/by/4.0
+.. SPDX-License-Identifier: CC-BY-4.0
+.. Copyright (C) 2020 AT&T Intellectual Property
 
 Developer Guide
 ===============
 
+This document explains how to maintain the RIC Xapp framework.
+Information for users of this framework (i.e., Xapp developers) is in the User Guide.
+
 Tech Stack
 ----------
 
index 09912b9..5004b5b 100644 (file)
@@ -12,6 +12,7 @@ xApp Python Framework
 
    installation-guide.rst
    overview.rst
+   user-guide.rst
    rmr_api.rst
    developer-guide.rst
    release-notes.rst
index d087334..9439bd4 100644 (file)
@@ -5,15 +5,19 @@
 Framework Overview
 ==================
 
-This package is a framework for writing Xapps in python. The framework
-reduces the amount of code required in an Xapp by providing common
-features needed by all Python-based Xapps including communication with
-the RIC message router (RMR) and the Shared Data Layer (SDL).
+This package is a framework for writing RAN Intelligent Controller
+(RIC) Xapps in python. The framework reduces the amount of code
+required in an Xapp by providing common features needed by all
+Python-based Xapps including communication with the RIC message router
+(RMR) and the Shared Data Layer (SDL).
 
 The framework was designed to suport many types of Xapps, including
-applications that are purely reactive to RMR messages, and
+applications that are purely reactive to RMR messages, and general
 applications that initiate actions according to other criteria.
 
+For complete documentation see the ReadTheDocs site for
+`xapp-frame-py <https://docs.o-ran-sc.org/projects/o-ran-sc-ric-plt-xapp-frame-py>`_.
+
 Reactive Xapps
 --------------
 
@@ -23,13 +27,13 @@ never takes action at another time.
 
 This type of application is constructed by creating callback functions
 and registering them with the framework by message type.  When an RMR
-message arrives, the appropriate callback is invoked.  An Xapp may
-define and register a separate callback for each expected message
-type.  Every Xapp must define a default callback function, which is
-invoked when a message arrives for which no type-specific callback was
-registered.  An analogy of this is AWS Lambda: "execute this code
-every time an event comes in" (the code to execute can depend on the
-type of event).
+message arrives, the appropriate callback is invoked based on the
+message type.  An Xapp may define and register a separate callback for
+each expected message type.  Every Xapp must define a default callback
+function, which is invoked when a message arrives for which no
+type-specific callback was registered.  An analogy of this is AWS
+Lambda: "execute this code every time an event comes in" (the code to
+execute can depend on the type of event).
 
 General Xapps
 -------------
@@ -37,14 +41,14 @@ General Xapps
 A general Xapp acts according to its own criteria, which may include
 receipt of RMR messages.
 
-This type of application is constructed by creating a function that
-gets invoked by the framework.  Typically that function contains a
-`while (something)` event loop.  If the function returns, the Xapp
-stops.  In this type of Xapp, the Xapp must fetch its own data, either
-from RMR, SDL or other source.  The framework does less work for a
-general application compared to a reactive application.  The framework
-sets up an RMR thread and an SDL connection, then invokes the
-client-provided function.
+This type of application is constructed by creating a single function
+that is invoked by the framework after initialization.  Typically that
+function contains a `while (something)` event loop.  When the function
+returns, the Xapp stops.  In this usage, the Xapp must fetch its own
+data, either from RMR, SDL or other source.  The framework does less
+work for a general application compared to a reactive application; the
+framework only sets up an RMR thread and an SDL connection before
+invoking the client-provided function.
 
 Threading in the Framework
 --------------------------
@@ -61,7 +65,7 @@ the RMR Xapp, but by the client in a general Xapp), the read is done
 from the framework-managed queue.  The framework is implemented this
 way so that a long-running client function (e.g., consume) will not
 block RMR reads.  This is important because RMR is *not* a persistent
-message bus, if an RMR client does not read fast enough, messages can
+message bus; if an RMR client does not read fast enough, messages can
 be lost.  So in this framework the client code is not in the same
 thread as the RMR reads, to ensure that long-running client code will
 not cause message loss.
@@ -80,9 +84,9 @@ The framework provides a default RMR healthcheck probe handler for
 reactive Xapps.  When an RMR healthcheck message arrives, this handler
 checks that the RMR thread is healthy (of course the Xapp cannot even
 reply if the thread is not healthy!), and that the SDL connection is
-healthy.  The handler responds accordingly via RMA.  The Xapp can
+healthy.  The handler responds accordingly via RMR.  The Xapp can
 override this probe handler by registering a new callback for the
-appropriate message type.
+healthcheck message type.
 
 The framework provides no healthcheck handler for general Xapps. Those
 applications must handle healthcheck probe messages appropriately when
@@ -96,7 +100,9 @@ Examples
 --------
 
 Two sample Xapps using this framework are provided in the `examples`
-directory of the git repository.  The first, `ping`, is a general Xapp
+directory of the
+`git repository <https://gerrit.o-ran-sc.org/r/gitweb?p=ric-plt/xapp-frame.git;a=tree>`_.
+The first, `ping`, is a general Xapp
 that defines a main function that reads its RMR mailbox in addition to
 other work.  The second, `pong`, is a reactive Xapp that only takes
 action when a message is received.
index 74348c9..b58b93a 100644 (file)
@@ -11,6 +11,11 @@ The format is based on `Keep a Changelog <http://keepachangelog.com/>`__
 and this project adheres to `Semantic Versioning <http://semver.org/>`__.
 
 
+[1.1.2] - 2020-05-13
+--------------------
+* Extend and publish class and method documentation as user guide in RST
+
+
 [1.1.1] - 2020-05-07
 --------------------
 * Use timeout on queue get method to avoid 100% CPU usage (`RIC-354 <https://jira.o-ran-sc.org/browse/RIC-354>`_)
index a7f0362..69caf59 100644 (file)
@@ -1,3 +1,7 @@
+.. This work is licensed under a Creative Commons Attribution 4.0 International License.
+.. SPDX-License-Identifier: CC-BY-4.0
+.. Copyright (C) 2020 AT&T Intellectual Property
+
 RMR Python Bindings
 ===================
 
diff --git a/docs/user-guide.rst b/docs/user-guide.rst
new file mode 100644 (file)
index 0000000..7147f02
--- /dev/null
@@ -0,0 +1,40 @@
+.. This work is licensed under a Creative Commons Attribution 4.0 International License.
+.. SPDX-License-Identifier: CC-BY-4.0
+.. Copyright (C) 2020 AT&T Intellectual Property
+
+User Guide
+==========
+
+This document explains how to develop an Xapp using the RIC Xapp framework.
+Information for maintainers of this framework is in the Developer Guide.
+
+Xapp writers should use the public classes and methods from the Xapp Python
+framework package as documented below.
+
+
+Class RMRXapp
+-------------
+
+Application writers should extend this class to implement a reactive Xapp;
+also see class Xapp.
+
+.. autoclass:: ricxappframe.xapp_frame.RMRXapp
+    :members:
+
+Class Xapp
+----------
+
+Application writers should extend this class to implement a general Xapp;
+also see class RMRXapp.
+
+.. autoclass:: ricxappframe.xapp_frame.Xapp
+    :members:
+
+
+Class SDLWrapper
+----------------
+
+Application writers may instantiate this class directly to communicate with the SDL service.
+
+.. autoclass:: ricxappframe.xapp_sdl.SDLWrapper
+    :members:
index 4321852..87ab27e 100644 (file)
@@ -169,81 +169,97 @@ class _BaseXapp:
         rmr.rmr_free_msg(sbuf)
 
     # SDL
-    # NOTE, even though these are passthroughs, the seperate SDL wrapper
+    # NOTE, even though these are passthroughs, the separate SDL wrapper
     # is useful for other applications like A1. Therefore, we don't
     # embed that SDLWrapper functionality here so that it can be
     # instantiated on its own.
 
     def sdl_set(self, ns, key, value, usemsgpack=True):
         """
-        set a key
+        Stores a key-value pair,
+        optionally serializing the value to bytes using msgpack.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
+            SDL namespace
         key: string
-            the sdl key
+            SDL key
         value:
-            if usemsgpack is True, value can be anything serializable by msgpack
-            if usemsgpack is False, value must be bytes
-        usemsgpack: boolean (optional)
-            determines whether the value is serialized using msgpack
+            Object or byte array to store.  See the `usemsgpack` parameter.
+        usemsgpack: boolean (optional, default is True)
+            Determines whether the value is serialized using msgpack before storing.
+            If usemsgpack is True, the msgpack function `packb` is invoked
+            on the value to yield a byte array that is then sent to SDL.
+            Stated differently, if usemsgpack is True, the value can be anything
+            that is serializable by msgpack.
+            If usemsgpack is False, the value must be bytes.
         """
         self._sdl.set(ns, key, value, usemsgpack)
 
     def sdl_get(self, ns, key, usemsgpack=True):
         """
-        get a key
+        Gets the value for the specified namespace and key,
+        optionally deserializing stored bytes using msgpack.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
+            SDL namespace
         key: string
-            the sdl key
-        usemsgpack: boolean (optional)
-            if usemsgpack is True, the value is deserialized using msgpack
-            if usemsgpack is False, the value is returned as raw bytes
+            SDL key
+        usemsgpack: boolean (optional, default is True)
+            If usemsgpack is True, the byte array stored by SDL is deserialized
+            using msgpack to yield the original object that was stored.
+            If usemsgpack is False, the byte array stored by SDL is returned
+            without further processing.
 
         Returns
         -------
-        None (if not exist) or see above; depends on usemsgpack
+        Value
+            See the usemsgpack parameter for an explanation of the returned value type.
+            Answers None if the key is not found.
         """
         return self._sdl.get(ns, key, usemsgpack)
 
     def sdl_find_and_get(self, ns, prefix, usemsgpack=True):
         """
-        get all k v pairs that start with prefix
+        Gets all key-value pairs in the specified namespace
+        with keys that start with the specified prefix,
+        optionally deserializing stored bytes using msgpack.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
-        key: string
-            the sdl key
+           SDL namespace
         prefix: string
-            the prefix
-        usemsgpack: boolean (optional)
-            if usemsgpack is True, the value returned is a dict where each value has been deserialized using msgpack
-            if usemsgpack is False, the value returned is as a dict mapping keys to raw bytes
+            the key prefix
+        usemsgpack: boolean (optional, default is True)
+            If usemsgpack is True, the byte array stored by SDL is deserialized
+            using msgpack to yield the original value that was stored.
+            If usemsgpack is False, the byte array stored by SDL is returned
+            without further processing.
 
         Returns
         -------
-        {} (if no keys match) or see above; depends on usemsgpack
+        Dictionary of key-value pairs
+            Each key has the specified prefix.
+            The value object (its type) depends on the usemsgpack parameter,
+            but is either a Python object or raw bytes as discussed above.
+            Answers an empty dictionary if no keys matched the prefix.
         """
         return self._sdl.find_and_get(ns, prefix, usemsgpack)
 
     def sdl_delete(self, ns, key):
         """
-        delete a key
+        Deletes the key-value pair with the specified key in the specified namespace.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
+           SDL namespace
         key: string
-            the sdl key
+            SDL key
         """
         self._sdl.delete(ns, key)
 
@@ -272,29 +288,34 @@ class _BaseXapp:
 
 class RMRXapp(_BaseXapp):
     """
-    Represents an xapp that is purely driven by RMR messages; i.e., when
-    messages are received, the xapp does something. When run is called,
-    the xapp framework waits for rmr messages, and calls the
-    client-provided consume callback on every one.
+    Represents an Xapp that reacts only to RMR messages; i.e., when
+    messages are received, the Xapp does something. When run is called,
+    the xapp framework waits for RMR messages, and calls the appropriate
+    client-registered consume callback on each.
+
+    Parameters
+    ----------
+    default_handler: function
+        A function with the signature (summary, sbuf) to be called
+        when a message type is received for which no other handler is registered.
+    default_handler argument summary: dict
+        The RMR message summary, a dict of key-value pairs
+    default_handler argument sbuf: ctypes c_void_p
+        Pointer to an RMR message buffer. The user must call free on this when done.
+    rmr_port: integer (optional, default is 4562)
+        Initialize RMR to listen on this port
+    rmr_wait_for_ready: boolean (optional, default is True)
+        Wait for RMR to signal ready before starting the dispatch loop
+    use_fake_sdl: boolean (optional, default is False)
+        Use an in-memory store instead of the real SDL service
+    post_init: function (optional, default None)
+        Run this function after the app initializes and before the dispatch loop starts;
+        its signature should be post_init(self)
     """
 
     def __init__(self, default_handler, rmr_port=4562, rmr_wait_for_ready=True, use_fake_sdl=False, post_init=None):
         """
-        Parameters
-        ----------
-        default_handler: function
-            a function with the signature (summary, sbuf) to be called
-            when a message of type message_type is received.
-        summary: dict
-            the rmr message summary
-        sbuf: ctypes c_void_p
-            Pointer to an rmr message buffer. The user must call free on
-            this when done.
-        post_init: function (optional)
-            optionally runs this function after the app initializes and
-        before the run loop; its signature should be post_init(self)
-
-        For the other parameters, see _BaseXapp
+        Also see _BaseXapp
         """
         # init base
         super().__init__(
@@ -343,16 +364,16 @@ class RMRXapp(_BaseXapp):
 
     def run(self, thread=False):
         """
-        This function should be called when the client xapp is ready to
-        wait for its handlers to be called on received messages.
+        This function should be called when the reactive Xapp is ready to start.
+        After start, the Xapp's handlers will be called on received messages.
 
         Parameters
         ----------
-        thread: bool (optional)
+        thread: bool (optional, default is False)
+            If False, execution is not returned and the framework loops forever.
             If True, a thread is started to run the queue read/dispatch loop
             and execution is returned to caller; the thread can be stopped
-            by calling .stop(). If False (the default), execution is not
-            returned and the framework loops forever.
+            by calling the .stop() method.
         """
 
         def loop():
@@ -384,19 +405,28 @@ class RMRXapp(_BaseXapp):
 
 class Xapp(_BaseXapp):
     """
-    Represents an xapp where the client provides a generic function to
-    call, which is mostly likely a loop-forever loop.
+    Represents a generic Xapp where the client provides a function for the framework to call,
+    which usually contains a loop-forever construct.
+
+    Parameters
+    ----------
+    entrypoint: function
+        This function is called when the Xapp class's run method is invoked.
+        The function signature must be just function(self)
+    rmr_port: integer (optional, default is 4562)
+        Initialize RMR to listen on this port
+    rmr_wait_for_ready: boolean (optional, default is True)
+        Wait for RMR to signal ready before starting the dispatch loop
+    use_fake_sdl: boolean (optional, default is False)
+        Use an in-memory store instead of the real SDL service
     """
 
     def __init__(self, entrypoint, rmr_port=4562, rmr_wait_for_ready=True, use_fake_sdl=False):
         """
         Parameters
         ----------
-        entrypoint: function
-            this function is called when the xapp runs; this is the user code.
-            its signature should be function(self)
 
-        For the other parameters, see _BaseXapp
+        For the other parameters, see class _BaseXapp.
         """
         # init base
         super().__init__(rmr_port=rmr_port, rmr_wait_for_ready=rmr_wait_for_ready, use_fake_sdl=use_fake_sdl)
@@ -404,8 +434,7 @@ class Xapp(_BaseXapp):
 
     def run(self):
         """
-        This function should be called when the client xapp is ready to
-        start their code.
+        This function should be called when the general Xapp is ready to start.
         """
         self._entrypoint(self)
 
index f8f2efc..6c4bbf3 100644 (file)
@@ -30,11 +30,10 @@ class SDLWrapper:
     We do not embed the below directly in the Xapp classes because
     this SDL wrapper is useful for other python apps, for example A1
     Mediator uses this verbatim. Therefore, we leave this here as a
-    seperate instantiable object so it can be used outside of xapps
-    too.  One could argue this get moved into *sdl itself*.
+    separate object so it can be used outside of xapps.
 
-    We currently use msgpack for binary (de)serialization:
-    https://msgpack.org/index.html
+    This class optionally uses msgpack for binary (de)serialization:
+    see https://msgpack.org/index.html
     """
 
     def __init__(self, use_fake_sdl=False):
@@ -43,13 +42,13 @@ class SDLWrapper:
 
         Parameters
         ----------
-        use_fake_sdl: bool
-            if this is True (default: False), then SDLs "fake dict
-            backend" is used, which is very useful for testing since
-            it allows you to use SDL without any SDL or Redis deployed at
-            all. This can be used while developing your xapp, and also
-            for monkeypatching during unit testing (e.g., the xapp
-            framework unit tests do this).
+        use_fake_sdl: bool (optional, default False)
+            if this is True, then use SDL's in-memory backend,
+            which is very useful for testing since it allows use
+            of SDL without a running SDL or Redis instance.
+            This can be used while developing an xapp and also
+            for monkeypatching during unit testinge.g., the xapp
+            framework unit tests do this.
         """
         if use_fake_sdl:
             self._sdl = SyncStorage(fake_db_backend="dict")
@@ -58,103 +57,114 @@ class SDLWrapper:
 
     def set(self, ns, key, value, usemsgpack=True):
         """
-        sets a key
+        Stores a key-value pair,
+        optionally serializing the value to bytes using msgpack.
 
         TODO: discuss whether usemsgpack should *default* to True or
         False here. This seems like a usage statistic question (that we
         don't have enough data for yet). Are more uses for an xapp to
         write/read their own data, or will more xapps end up reading data
-        written by some other thing? I think it's too early to know
-        this. So we go with True as the very first user of this, a1, does
-        this. I'm open to changing this default to False later with
-        evidence.
+        written by some other thing? I think it's too early to know.
 
         Parameters
         ----------
         ns: string
-        the sdl namespace
+            SDL namespace
         key: string
-        the sdl key
+            SDL key
         value:
-        if usemsgpack is True, value can be anything serializable by msgpack
-        if usemsgpack is False, value must be bytes
-        usemsgpack: boolean (optional)
-        determines whether the value is serialized using msgpack
+            Object or byte array to store.  See the `usemsgpack` parameter.
+        usemsgpack: boolean (optional, default is True)
+            Determines whether the value is serialized using msgpack before storing.
+            If usemsgpack is True, the msgpack function `packb` is invoked
+            on the value to yield a byte array that is then sent to SDL.
+            Stated differently, if usemsgpack is True, the value can be anything
+            that is serializable by msgpack.
+            If usemsgpack is False, the value must be bytes.
         """
         if usemsgpack:
-            self._sdl.set(ns, {key: msgpack.packb(value, use_bin_type=True)})
-        else:
-            self._sdl.set(ns, {key: value})
+            value = msgpack.packb(value, use_bin_type=True)
+        self._sdl.set(ns, {key: value})
 
     def get(self, ns, key, usemsgpack=True):
         """
-        get a key
+        Gets the value for the specified namespace and key,
+        optionally deserializing stored bytes using msgpack.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
+            SDL namespace
         key: string
-            the sdl key
-        usemsgpack: boolean (optional)
-            if usemsgpack is True, the value is deserialized using msgpack
-            if usemsgpack is False, the value is returned as raw bytes
+            SDL key
+        usemsgpack: boolean (optional, default is True)
+            If usemsgpack is True, the byte array stored by SDL is deserialized
+            using msgpack to yield the original object that was stored.
+            If usemsgpack is False, the byte array stored by SDL is returned
+            without further processing.
 
         Returns
         -------
-        None (if not exist) or see above; depends on usemsgpack
+        Value
+            See the usemsgpack parameter for an explanation of the returned value type.
+            Answers None if the key is not found.
         """
+        result = None
         ret_dict = self._sdl.get(ns, {key})
         if key in ret_dict:
+            result = ret_dict[key]
             if usemsgpack:
-                return msgpack.unpackb(ret_dict[key], raw=False)
-            return ret_dict[key]
-
-        return None
+                result = msgpack.unpackb(result, raw=False)
+        return result
 
     def find_and_get(self, ns, prefix, usemsgpack=True):
         """
-        get all k v pairs that start with prefix
+        Gets all key-value pairs in the specified namespace
+        with keys that start with the specified prefix,
+        optionally deserializing stored bytes using msgpack.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
-        key: string
-            the sdl key
+           SDL namespace
         prefix: string
-            the prefix
-        usemsgpack: boolean (optional)
-            if usemsgpack is True, the value returned is a dict where each value has been deserialized using msgpack
-            if usemsgpack is False, the value returned is as a dict mapping keys to raw bytes
+            the key prefix
+        usemsgpack: boolean (optional, default is True)
+            If usemsgpack is True, every byte array stored by SDL is deserialized
+            using msgpack to yield the original value that was stored.
+            If usemsgpack is False, every byte array stored by SDL is returned
+            without further processing.
 
         Returns
         -------
-        {} (if no keys match) or see above; depends on usemsgpack
+        Dictionary of key-value pairs
+            Each key has the specified prefix.
+            See the usemsgpack parameter for an explanation of the returned value types.
+            Answers an empty dictionary if no keys matched the prefix.
         """
 
         # note: SDL "*" usage is inconsistent with real python regex, where it would be ".*"
         ret_dict = self._sdl.find_and_get(ns, "{0}*".format(prefix))
         if usemsgpack:
-            return {k: msgpack.unpackb(v, raw=False) for k, v in ret_dict.items()}
+            ret_dict = {k: msgpack.unpackb(v, raw=False) for k, v in ret_dict.items()}
         return ret_dict
 
     def delete(self, ns, key):
         """
-        delete a key
+        Deletes the key-value pair with the specified key in the specified namespace.
 
         Parameters
         ----------
         ns: string
-           the sdl namespace
+           SDL namespace
         key: string
-            the sdl key
+            SDL key
         """
         self._sdl.remove(ns, {key})
 
     def healthcheck(self):
         """
-        checks if the sdl connection is healthy
+        Checks if the sdl connection is healthy.
 
         Returns
         -------
index 26640d0..5753ed5 100644 (file)
--- a/setup.py
+++ b/setup.py
@@ -32,7 +32,7 @@ def _long_descr():
 
 setup(
     name="ricxappframe",
-    version="1.1.1",
+    version="1.1.2",
     packages=find_packages(exclude=["tests.*", "tests"]),
     author="Tommy Carpenter, E. Scott Daniels",
     description="Xapp and RMR framework for python",
diff --git a/tox.ini b/tox.ini
index 0aaf152..82f70f6 100644 (file)
--- a/tox.ini
+++ b/tox.ini
@@ -45,7 +45,7 @@ extend-ignore = E501,E741,E731
 [testenv:clm]
 # use pip to gather dependencies with versions for CLM analysis
 whitelist_externals = sh
-commands = sh -c 'pip freeze > clm.txt'
+commands = sh -c 'pip freeze > requirements.txt'
 
 # doc jobs
 [testenv:docs]