Repository: radiasoft/pykern

Issue:
RFC: pykern.quest
A Quest is "a long or arduous search for something." It is also a Pythonesque word so appropriate. The reason it is called Quest is that it is longer than a simple function call, since it must validate its inputs, e.g. an HTTP request, command line execution (pkcli), a Web Socket message, etc.

There are several classes in sirepo.quest:

* Spec is the decorator that describes the functions inputs and other prerequisites (e.g. permissions)
* Call is instantiated to make a call to a quest function
* Args is a PKDict subclass that holds the inputs
* Result is the return value or reply

Sirepo will subclass these, e.g. a sirepo.api.Base is a subclass of pykern.quest.Call, and used as follows:

```py
    @api_perm.require_user
    def api_deleteSimulation(self):
        req = self.parse_post(id=True)
        simulation_db.delete_simulation(req.type, req.id)
        return self.reply_ok()
```

Would turn into:

```py
    @sirepo.api.Spec('require_user', id='SimId')
    def api_deleteSimulation(self):
        simulation_db.delete_simulation(self.args.type, self.args.id)
        self.done(status='ok')
```

`self.result(status='ok')` would be shortened to `self.result_ok()`.

In the above example, sirepo.api.Spec always adds `type='SimType'` and applies the `@api_perm.require_user`. The value `auth_uid` is also in args. All values in args are validated, and collisions are managed safely, e.g. if a caller tries to supply auth_uid, it will be rejected, since that type of arg is special (the structures for this have to be defined clearly).

args will be parsed from whatever inputs make sense for the way the Call is made. If it is an API request and there is a URI with parameters, those values will be extracted, validated, and add to args. If there is a POST or PUT, those values will be validated and added to args. This is already being done to a degree with parse_post, but this formalizes all inputs.

The types of the inputs are found in pykern.pktype. For Sirepo sirepo.pktype will define its additional types. The type interface has yet to be defined, but this is part of the project.

Discussion:
robnagler: @mkeilman I made a significant change by adding _exception_reason() which is more robust.  Please do a thorough re-review

Patch:
diff --git a/pykern/pkio.py b/pykern/pkio.py
index 521b1d5..898d378 100644
--- a/pykern/pkio.py
+++ b/pykern/pkio.py
@@ -192,8 +192,12 @@ def read_text(filename):
     Returns:
         Str: contents of `filename`
     """
-    with open_text(filename) as f:
-        return f.read()
+    try:
+        with open_text(filename) as f:
+            return f.read()
+    except Exception as e:
+        _exception_reason(e, f"filename={filename}")
+        raise
 
 
 @contextlib.contextmanager
@@ -335,7 +339,30 @@ def write_text(path, contents):
     """
     from pykern import pkcompat
 
-    fn = py_path(path)
-    with io.open(str(fn), "wt", encoding=TEXT_ENCODING) as f:
-        f.write(pkcompat.from_bytes(contents))
-    return fn
+    p = py_path(path)
+    try:
+        with io.open(str(p), "wt", encoding=TEXT_ENCODING) as f:
+            f.write(pkcompat.from_bytes(contents))
+    except Exception as e:
+        _exception_reason(e, f"path={path}")
+        raise
+    return p
+
+
+def _exception_reason(exc, reason):
+    def _prefix_reason(string):
+        return ("; " if len(string) > 0 else "") + reason
+
+    if hasattr(exc, "reason") and isinstance(exc.reason, str):
+        exc.reason += _prefix_reason(exc.reason)
+    if hasattr(exc, "args"):
+        if exc.args is None:
+            exc.args = tuple()
+        if isinstance(exc.args, (tuple, list)):
+            if len(exc.args) == 0:
+                exc.args = (reason,)
+            elif isinstance(exc.args[0], str):
+                x = list(exc.args)
+                x[0] += _prefix_reason(x[0])
+                exc.args = tuple(x)
+    # Other cases?


Test patch:
diff --git a/tests/pkio_test.py b/tests/pkio_test.py
index 619d358..95d8a31 100644
--- a/tests/pkio_test.py
+++ b/tests/pkio_test.py
@@ -20,6 +20,26 @@ def test_atomic_write():
         pkunit.pkeq("abc", pkio.read_text("x.ABC"))
 
 
+def test__exception_reason():
+    from pykern import pkio
+    from pykern.pkcollections import PKDict
+    from pykern.pkunit import pkeq
+
+    # Run through the cases because there are so many
+    r = "xyzzy"
+    for e, a in (
+        (PKDict(), PKDict()),
+        (PKDict(reason=1), PKDict(reason=1)),
+        (PKDict(reason="xyzzy"), PKDict(reason="")),
+        (PKDict(args=("xyzzy",)), PKDict(args=None)),
+        (PKDict(args=("xyzzy",)), PKDict(args=())),
+        (PKDict(args=(1,)), PKDict(args=(1,))),
+        (PKDict(args=("hello; xyzzy",)), PKDict(args=("hello",))),
+    ):
+        pkio._exception_reason(a, r)
+        pkeq(e, a)
+
+
 def test_has_file_extension():
     from pykern.pkunit import pkeq
     from pykern import pkio
@@ -160,3 +180,8 @@ def test_write_text():
     assert expect_content == pkio.read_text(
         str(expect_res)
     ), 'When read_text, it should read "something"'
+    pkio.write_binary(expect_res, b"\xFF\xFF\0")
+    with pkunit.pkexcept(str(expect_res)):
+        pkio.read_text(expect_res)
+    with pkunit.pkexcept(str(expect_res)):
+        pkio.write_text(expect_res, b"\xFF\xFF\0")