Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Take initial parameters from parameters yaml files and constructor arguments. #225

Merged
merged 35 commits into from
Aug 29, 2018

Conversation

nuclearsandwich
Copy link
Member

With this pull request initial parameters can be passed via yaml parameters file with the __params:= and as a list of parameters in the initial_parameters kwarg of the Node constructor.

Parameters in the constructor will override parameters from a parameters file.

If at least one reviewer could keep a close eye on my use of Py_DECREF and make sure that I have employed everywhere I should and nowhere I shouldn't have I'd be grateful.. I'm fairly confident in my usage but it's also my first time in Python's C API.

To test this PR I've been using the params.yaml in this gist https://gist.github.com/nuclearsandwich/8753121711671bfa8d9cb5f718ce09bc and the talker node from demo_nodes_py as well as the parameter services behavior just merged from #214

Connects to #202

@nuclearsandwich nuclearsandwich added the in review Waiting for review (Kanban column) label Aug 17, 2018
@nuclearsandwich nuclearsandwich self-assigned this Aug 17, 2018
@dirk-thomas dirk-thomas mentioned this pull request Aug 17, 2018
6 tasks
Copy link
Contributor

@sloretz sloretz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a few places where error checking is needed. Most cpython APIs can set an exception and return NULL.

PyErr_Format(PyExc_RuntimeError, "Failed to parse yaml params file: %s", param_files[i]);
return false;
}
allocator.deallocate(param_files[i], allocator.state);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this needs a call to allocator.deallocate(param_files, allocator.state) after the array members have been deallocated.

PyObject * value;
if (variant->bool_value) {
type_enum_value = 1;
value = variant->bool_value ? Py_True : Py_False;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to Py_INCREF(Py_True) or Py_INCREF(Py_False).

value = PyUnicode_FromString(variant->string_value);
} else if (variant->byte_array_value) {
type_enum_value = 5;
value = PyList_New(variant->byte_array_value->size);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check for NULL return here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment in a couple places below

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NULL check is now just below.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this returns a list of bytes() instances. It might be more convenient for users if this was one bytes() object with all the bytes in it. If that sounds reasonable I think PyBytes_FromStringAndSize could convert byte_array_value in one go.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be more convenient for users if this was one bytes() object with all the bytes in it.

It would be more convenient but it would violate the interface specification which states that a byte should be represented as a bytes of length one and that any of the array types should be represented by a Python list of the scalar type. http://design.ros2.org/articles/generated_interfaces_python.html

@mikaelarguedas and I talked about this and they may have a link to further discussion material.

type_enum_value = 5;
value = PyList_New(variant->byte_array_value->size);
for (size_t i = 0; i < variant->byte_array_value->size; i++) {
PyList_SetItem(value, i, PyBytes_FromFormat("%u", variant->byte_array_value->values[i]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since list is new you can use PyList_SET_ITEM here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs don't mention it but it looks like PyBytes_FromFormat can error and return NULL.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comments about PyList_SET_ITEM below

type_enum_value = 6;
value = PyList_New(variant->bool_array_value->size);
for (size_t i = 0; i < variant->bool_array_value->size; i++) {
PyList_SetItem(value, i, variant->bool_array_value->values[i] ? Py_True : Py_False);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about needing to incref True and false. PyList_SetItem and PyList_SET_ITEM steal a reference

}
}

PyObject * type = PyObject_CallObject(parameter_type_cls, Py_BuildValue("(i)", type_enum_value));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check Py_BuildValue didn't return NULL, as well as PyObject_CallObject.

}

rcl_node_t * node = (rcl_node_t *)PyCapsule_GetPointer(node_capsule, "rcl_node_t");
const rcl_node_options_t * node_options = rcl_node_get_options(node);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be moved below the if (!node) check

const rcl_node_options_t * node_options = rcl_node_get_options(node);
const rcl_allocator_t allocator = node_options->allocator;
if (!node) {
PyErr_Format(PyExc_RuntimeError, "Failed to retrieve rcl node from capsule");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyCapsule_GetPointer would have already set an exception here. Returning NULL is enough.

return PyList_New(0);
}

PyObject * parameter_type_cls = PyObject_GetAttrString(parameter_cls, "Type");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to check for NULL

@nuclearsandwich
Copy link
Member Author

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

@@ -60,7 +60,7 @@ class Node:

def __init__(
self, node_name, *, cli_args=None, namespace=None, use_global_arguments=True,
start_parameter_services=True
start_parameter_services=True, initial_parameters=[]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same list instance is used for every Node object initialized with the default initial_parameters. In this case it should be fine since no code modifies the list. I recommend using either None or tuple() as the default to be defensive against that though.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of course. 😁 I think we were even talking about this together last week.

* false when there was an error during parsing and a Python exception was raised.
*
*/
static bool _parse_param_files(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, the rest of the file has return type on its own line.

for (size_t i = 0; i < variant->byte_array_value->size; i++) {

member_value = PyBytes_FromFormat("%u", variant->byte_array_value->values[i]);
if (NULL == member_value) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list still has its reference count incremented. Need to Py_DECREF(value) before returning.

value = PyUnicode_FromString(variant->string_value);
} else if (variant->byte_array_value) {
type_enum_value = 5;
value = PyList_New(variant->byte_array_value->size);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this returns a list of bytes() instances. It might be more convenient for users if this was one bytes() object with all the bytes in it. If that sounds reasonable I think PyBytes_FromStringAndSize could convert byte_array_value in one go.

return NULL;
}
for (size_t i = 0; i < variant->integer_array_value->size; i++) {
PyList_SET_ITEM(value, i, PyLong_FromLong(variant->integer_array_value->values[i]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PyLong_FromLong can return NULL if it fails to create a new python object.

}
PyObject * parameter_type_cls = PyObject_GetAttrString(parameter_cls, "Type");
if (NULL == parameter_type_cls) {
PyErr_Format(PyExc_RuntimeError, "Error getting 'Type' attribute from Parameter class");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect PyObject_GetAttrString would have already raised AttributeError

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


if (node_options->use_global_arguments) {
if (!_parse_param_files(rcl_get_global_arguments(), allocator, params)) {
return NULL;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to free params

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought so too but rcl_parse_yaml_file will actually fini the passed in struct on error https://github.com/ros2/rcl/blob/adc0190259a4eb725480e7b32a90b902bc5279b0/rcl_yaml_param_parser/src/parser.c#L1382.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

c08b236 makes _parse_param_files fini the params struct in both error situations so that the caller does not need to.

}

if (!_parse_param_files(&(node_options->arguments), allocator, params)) {
return NULL;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same about freeing params here

}

if (!PyObject_HasAttrString(parameter_cls, "Type")) {
PyErr_Format(PyExc_RuntimeError, "Parameter class is missing 'Type' attribute");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to fini params here and a few places below

rcl_node_params_t node_params = params->params[node_index];
PyObject * parameter_list = PyList_New(node_params.num_params);
if (NULL == parameter_list) {
return NULL;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Py_DECREF(parameter_type_cls) and in another place below

if (RCL_RET_OK != ret) {
PyErr_Format(PyExc_RuntimeError, "Failed to get initial parameters: %s",
rcl_get_error_string_safe());
return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replying to https://github.com/ros2/rclpy/pull/225/files#r211049548 , since the calling code doesn't fini params if this errors then I think params needs to be fini'd here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With da7da1a the implementation no longer takes an rcl_params_t as input and instead creates and uses the data structure internally only if parameter arguments are found without error so the params object no longer exists at this point.

@nuclearsandwich
Copy link
Member Author

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

Next CI after fixing lint and some warnings. I couldn't track the MSBuild warnings so I'll get those next wave.

return NULL;
}

int node_index = -1;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MSBuild (rightly) complains that intmight lose information when assigned from a size_t below. But changing to a size_t makes the sigil value -1 invalid. Do we have a pattern we use for this sort of situation like storing a second node_index_found value and setting and checking it along with the index?

@nuclearsandwich
Copy link
Member Author

While setting up system tests I discovered that the yaml parameter parser doesn't preserve any parameters currently in the struct as I though. So I'll need to refactor this to extract parameters one file as a time and store them in a Python dict rather than a list.

@nuclearsandwich nuclearsandwich added in progress Actively being worked on (Kanban column) in review Waiting for review (Kanban column) and removed in review Waiting for review (Kanban column) in progress Actively being worked on (Kanban column) labels Aug 21, 2018
@nuclearsandwich
Copy link
Member Author

refactor this to extract parameters one file at a time and store them in a Python dict rather than a list.

This refactor is complete and the implementation is now working against the existing tests in system_tests/test_cli.

@@ -100,6 +100,13 @@ def __init__(

self.__executor_weakref = None

node_parameters = _rclpy.rclpy_get_node_parameters(Parameter, self.handle)
# use the set_parameters API so parameter events are publish.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to read the code to understand this but I'm a bit lost I think. In this case aren't the parameters potentially set twice, once as the "node parameters" and then again using the initial parameters?

And wouldn't that generate two events? Is that the intended behavior? I could see it either way: "I'd like to see the evolution of the values on startup" or "I just care about what it was initialized with not what went into determining the initial value".

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought I was matching the rclcpp behavior in sending events for parameters changed by subsequent parameter files but I must've been seeing double as ros2 topic echo /parameter_events for a cpp node only sends events for the end state of initial parameters.

@wjwwood
Copy link
Member

wjwwood commented Aug 23, 2018

Looks like this comment is still pending right?

#225 (comment)

@nuclearsandwich
Copy link
Member Author

Looks like this comment is still pending right? #225 (comment)

It was addressed indirectly. Added an explanation.

@nuclearsandwich
Copy link
Member Author

nuclearsandwich commented Aug 23, 2018

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

elif node_parameters:
self.set_parameters(node_parameters)
elif initial_parameters:
self.set_parameters(initial_parameters)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first glance, it looks like this could be simplified quite a bit by setting default values for node_parameters and initial_parameters.

(I didnt check the implementation so this is hypothetical)
But if:
initial_parameters defaulted to an empty list and node_parameters to an empty dict.
Could we get rid of the conditional logic and just have the following?

params_by_name = {p.name: p for p in node_parameters}
params_by_name.update({p.name: p for p in initial_parameters})
self.set_parameters(params_by_name.values())

I wouldnt expect a big performance hit if these are empty.

Copy link
Member

@mikaelarguedas mikaelarguedas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like a few comments were not addressed, added a few comments inline as well

@@ -3369,6 +3370,385 @@ rclpy_clock_set_ros_time_override(PyObject * Py_UNUSED(self), PyObject * args)
Py_RETURN_NONE;
}

/// Create an rclpy.parameter.Parameter from an rcl_variant_t
/**
* On failure a Python exception is raised and false is returned if:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NULL is returned on failure (which is the right thing to return 👍 we should just update the docblock).

not sure what the if: is referring to ? are we missing a sentence here? Matches the rest of the file, pretty surprising sentence though but out of scope of this PR

* \param[in] parameter_type_cls The PythonObject for the Parameter.Type class.
*
* Returns a pointer to an rclpy.parameter.Parameter with the name, type, and value from
* the variant or NULL when raising a python exception.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: python -> Python same multiple times below

PyObject * value;
PyObject * member_value;
if (variant->bool_value) {
type_enum_value = 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these type_enum_values matching the types defined in https://github.com/ros2/rcl_interfaces/blob/db27f0e8619460848d80c1442f7fec0c56ee63e5/rcl_interfaces/msg/ParameterType.msg ?
If yes, any reason for not reusing these enums?

int param_files_count = rcl_arguments_get_param_files_count(args);
rcl_ret_t ret;
bool successful = true;
if (param_files_count > 0) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could reduce nesting by just returning false if param_files_count <= 0

rcl_ret_t ret;
bool successful = true;
if (param_files_count > 0) {
ret = rcl_arguments_get_param_files(args, allocator, &param_files);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as ret is not used anywhere else we could just check the return value of rcl_arguments_get_param_files and return on failure

}
PyObject * param = PyObject_CallObject(parameter_cls, args);
Py_DECREF(args);
Py_DECREF(type);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

* Raises RuntimeError if the parameters file fails to parse
*
* \param[in] parameter_cls The rclpy.parameter.Parameter class object.
* \param[in] node_handle Capsule pointing to the node handle
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: node_handle -> node_capsule

}

rcl_node_t * node = (rcl_node_t *)PyCapsule_GetPointer(node_capsule, "rcl_node_t");
if (!node) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: if (NULL == node)

}
PyObject * parameter_type_cls = PyObject_GetAttrString(parameter_cls, "Type");
if (NULL == parameter_type_cls) {
/* PyObject_GetAttrString raises AttributeError on failure. */
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please use // for single line comments

}
Py_DECREF(parameter_type_cls);

const char * node_namespace = rcl_node_get_namespace(node);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this deallocated anywhere?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No. Since this is a const "borrowed" reference to the internal node string. It's lifetime is bound to the node it is from.

@@ -3613,7 +3994,7 @@ static PyMethodDef rclpy_methods[] = {

{
"rclpy_convert_from_py_qos_policy", rclpy_convert_from_py_qos_policy, METH_VARARGS,
"Convert a QoSPolicy python object into a rmw_qos_profile_t."
"Convert a QoSPolicy Python object into a rmw_qos_profile_t."
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mikaelarguedas had me turn all my python references to Python so I brought this one along for the ride. Has nothing else to do with this PR.

Uses rcl_yaml_param_parser to find and parse parameter yaml files and
supply initial parameters to rclpy nodes.
rclpy nodes can now take a list of initial parameters via their
constructor and will check rcl arguments for parameter files returning
any parameters specific to that node.
This also fixes an issue where early return would prevent deallocation
of later array members.
nuclearsandwich and others added 15 commits August 24, 2018 13:57
The previous implementation relied on the (later debunked) assumption
that calling rcl_parse_yaml_file repeatedly with the same rcl_params_t
would yield a struct that contains the cumulative parameters from those
files. That isn't actually the case so we need to create an intermediate
storage which can be used instead. In rclcpp a std::map is used but we
have no such datatype here. Instead we'll use a PyDict which maps node
names to a nested dict of rclpy.parameter.Parameters by parameter name
and then query that struct for parameters to return to the node.
This was reincorporated unintentionally during merge conflict
resolution.
@nuclearsandwich
Copy link
Member Author

FYI for any reviewers who have pulled locally I've just rebased and pushed in order to resolve merge conflicts.

@nuclearsandwich
Copy link
Member Author

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

return false;
}
PyObject * parameter_dict;
if (!PyDict_Contains(node_params_dict, py_node_name)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the docs say this can set an exception and return -1 , though I'm not sure under what circumstances

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bit of empirical testing and it can return -1 when the item you pass in as a dict isn't. Since we create the dict that doesn't seem like a case worth checking at runtime.

}
if (-1 == PyDict_SetItem(node_params_dict, py_node_name, parameter_dict)) {
Py_DECREF(py_node_name);
return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Py_DECREF(parameter_dict) too

parameter_dict = PyDict_GetItem(node_params_dict, py_node_name);
if (NULL == parameter_dict) {
Py_DECREF(py_node_name);
return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confusingly, PyDict_GetItem does not set an exception. In the rest of the places this function returns false a python exception is already set before returning.

Py_INCREF(parameter_dict);
}
rcl_node_params_t node_params = params->params[i];
for (size_t ii = 0; ii < node_params.num_params; ii++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, use prefix increment ++ii, though I'm sure the compiler is smart enough to not make a copy here anyways.

* \param[in] args The arguments to parse for parameter files
* \param[in] allocator Allocator to use for allocating and deallocating within the function.
* \param[out] params_by_node_name A Python dict object to place parsed parameters into.
*
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, missing documentation of parameter_cls, and parameter_type_cls

rcl_get_error_string_safe());
return false;
}
for (int i = 0; i < param_files_count; i++) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, ++i

params, allocator, parameter_cls, parameter_type_cls, params_by_node_name))
{
PyErr_Format(PyExc_RuntimeError,
"Failed to fill params dict from file: %s", param_files[i]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Except as noted about PyDict_GetItem, an exception is already set here.

PyErr_Format(PyExc_RuntimeError,
"Failed to fill params dict from file: %s", param_files[i]);
rcl_yaml_node_struct_fini(params);
return false;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think param_files needs to be deallocated before returning.

Py_DECREF(params_by_node_name);
return NULL;
}
allocator.deallocate(node_name_with_namespace, allocator.state);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, could deduplicate this line by putting it before the NULL check above.

// PyDict_GetItem is a borrowed reference. INCREF so we can return a new one.
Py_INCREF(node_params);

Py_DECREF(parameter_type_cls);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this was already decref'd

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to Py_DECREF(py_node_name_with_namespace);

Copy link
Contributor

@sloretz sloretz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with green ci

@nuclearsandwich
Copy link
Member Author

nuclearsandwich commented Aug 29, 2018

  • Linux Build Status
  • Linux-aarch64 Build Status
  • macOS Build Status
  • Windows Build Status

@nuclearsandwich
Copy link
Member Author

Thank you @sloretz and @mikaelarguedas. This PR wouldn't have been realized without the time you spent providing clear feedback and meticulous review.

@nuclearsandwich nuclearsandwich merged commit 92bb336 into master Aug 29, 2018
@nuclearsandwich nuclearsandwich removed the in review Waiting for review (Kanban column) label Aug 29, 2018
@nuclearsandwich nuclearsandwich deleted the initial-parameters branch August 29, 2018 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants