Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit c675569

Browse files
committedFeb 26, 2023
(torchx/components) Fix entrypoint loading to deal with deferred loading of modules to enable component registration to work properly
1 parent c74bb9c commit c675569

File tree

11 files changed

+544
-197
lines changed

11 files changed

+544
-197
lines changed
 

‎docs/source/advanced.rst

+98-14
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ resource can then be used in the following manner:
156156

157157
Registering Custom Components
158158
-------------------------------
159-
It is possible to author and register a custom set of components with the
159+
You can author and register a custom set of components with the
160160
``torchx`` CLI as builtins to the CLI. This makes it possible to customize
161161
a set of components most relevant to your team or organization and support
162162
it as a CLI ``builtin``. This way users will see your custom components
@@ -166,7 +166,63 @@ when they run
166166
167167
$ torchx builtins
168168
169-
Custom components can be registered via the following modification of the ``entry_points``:
169+
Custom components can be registered via ``[torchx.components]`` entrypoints.
170+
If ``my_project.bar`` had the following directory structure:
171+
172+
::
173+
174+
$PROJECT_ROOT/my_project/bar/
175+
|- baz.py
176+
177+
And ``baz.py`` had a single component (function) called ``trainer``:
178+
179+
::
180+
181+
# baz.py
182+
import torchx.specs as specs
183+
184+
def trainer(...) -> specs.AppDef: ...
185+
186+
187+
And the entrypoints were added as:
188+
189+
.. testcode::
190+
191+
# setup.py
192+
...
193+
entry_points={
194+
"torchx.components": [
195+
"foo = my_project.bar",
196+
],
197+
}
198+
199+
TorchX will search the module ``my_project.bar`` for all defined components and group the found
200+
components under the ``foo.*`` prefix. In this case, the component ``my_project.bar.baz.trainer``
201+
would be registered with the name ``foo.baz.trainer``.
202+
203+
.. note::
204+
Only python packages (those directories with an ``__init__.py`` file)
205+
are searched for and TorchX makes no attempt to recurse into namespace packages
206+
(directories without a ``__init__.py`` file).
207+
However you may register a top level namespace package.
208+
209+
``torchx`` CLI will display registered components via:
210+
211+
.. code-block:: shell-session
212+
213+
$ torchx builtins
214+
Found 1 builtin components:
215+
1. foo.baz.trainer
216+
217+
The custom component can then be used as:
218+
219+
.. code-block:: shell-session
220+
221+
$ torchx run foo.baz.trainer -- --name "test app"
222+
223+
224+
When you register your own components, TorchX will not include its own builtins. To add TorchX's
225+
builtin components you must specify another entry as:
170226

171227

172228
.. testcode::
@@ -176,32 +232,60 @@ Custom components can be registered via the following modification of the ``entr
176232
entry_points={
177233
"torchx.components": [
178234
"foo = my_project.bar",
235+
"torchx = torchx.components",
179236
],
180237
}
181238

182-
The line above registers a group ``foo`` that is associated with the module ``my_project.bar``.
183-
TorchX will recursively traverse lowest level dir associated with the ``my_project.bar`` and will find
184-
all defined components.
239+
This will add back the TorchX builtins but with a ``torchx.*`` component name prefix (e.g. ``torchx.dist.ddp``
240+
versus the default ``dist.ddp``).
241+
242+
If there are two registry entries pointing to the same component, for instance
185243

186-
.. note:: If there are two registry entries, e.g. ``foo = my_project.bar`` and ``test = my_project``
187-
there will be two sets of overlapping components with different aliases.
244+
.. testcode::
188245

246+
# setup.py
247+
...
248+
entry_points={
249+
"torchx.components": [
250+
"foo = my_project.bar",
251+
"test = my_project",
252+
],
253+
}
189254

190-
After registration, torchx cli will display registered components via:
255+
256+
There will be two sets of overlapping components for those components in ``my_project.bar`` with different
257+
prefix aliases: ``foo.*`` and ``test.bar.*``. Concretely,
191258

192259
.. code-block:: shell-session
193260
194261
$ torchx builtins
262+
Found 2 builtin components:
263+
1. foo.baz.trainer
264+
2. test.bar.baz.trainer
195265
196-
If ``my_project.bar`` had the following directory structure:
266+
To omit groupings and make the component names shorter, use underscore (e.g ``_`` or ``_0``, ``_1``, etc).
267+
For example:
197268

198-
::
269+
.. testcode::
199270

200-
$PROJECT_ROOT/my_project/bar/
201-
|- baz.py
271+
# setup.py
272+
...
273+
entry_points={
274+
"torchx.components": [
275+
"_0 = my_project.bar",
276+
"_1 = torchx.components",
277+
],
278+
}
202279

203-
And ``baz.py`` defines a component (function) called ``trainer``. Then the component can be run as a job in the following manner:
280+
This has the effect of exposing the trainer component as ``baz.trainer`` (as opposed to ``foo.baz.trainer``)
281+
and adds back the builtin components as in the vanilla installation of torchx, without the ``torchx.*`` prefix.
204282

205283
.. code-block:: shell-session
206284
207-
$ torchx run foo.baz.trainer -- --name "test app"
285+
$ torchx builtins
286+
Found 11 builtin components:
287+
1. baz.trainer
288+
2. dist.ddp
289+
3. utils.python
290+
4. ... <more builtins from torchx.components.* ...>
291+

‎torchx/specs/finder.py

+215-102
Large diffs are not rendered by default.
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
import torchx
7+
from torchx import specs
8+
9+
10+
def comp_a() -> specs.AppDef:
11+
return specs.AppDef(
12+
name="foo.comp_a",
13+
roles=[
14+
specs.Role(
15+
name="foo.comp_a",
16+
image=torchx.IMAGE,
17+
entrypoint="echo",
18+
args=["hello world"],
19+
)
20+
],
21+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.

‎torchx/specs/test/components/a/b/c.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
import torchx
7+
from torchx import specs
8+
9+
10+
def d() -> specs.AppDef:
11+
return specs.AppDef(
12+
name="foo.b.c.d",
13+
roles=[
14+
specs.Role(
15+
name="foo.b.c.d",
16+
image=torchx.IMAGE,
17+
entrypoint="echo",
18+
args=["hello world"],
19+
)
20+
],
21+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under the BSD-style license found in the
6+
# LICENSE file in the root directory of this source tree.

‎torchx/specs/test/components/c/d.py

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
import torchx
7+
from torchx import specs
8+
9+
10+
def e() -> specs.AppDef:
11+
return specs.AppDef(
12+
name="bar.e",
13+
roles=[
14+
specs.Role(
15+
name="bar.e",
16+
image=torchx.IMAGE,
17+
entrypoint="echo",
18+
args=["hello world"],
19+
)
20+
],
21+
)

‎torchx/specs/test/finder_test.py

+110-72
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77

88
import os
99
import shutil
10-
import sys
1110
import tempfile
1211
import unittest
1312
from pathlib import Path
14-
from unittest.mock import patch
13+
from unittest.mock import MagicMock, patch
1514

1615
import torchx.specs.finder as finder
16+
17+
from importlib_metadata import EntryPoints
1718
from torchx.runner import get_runner
1819
from torchx.runtime.tracking import FsspecResultTracker
1920
from torchx.specs.api import AppDef, AppState, Role
@@ -26,8 +27,11 @@
2627
get_components,
2728
ModuleComponentsFinder,
2829
)
30+
from torchx.util.test.entrypoints_test import EntryPoint_from_text
2931
from torchx.util.types import none_throws
3032

33+
_METADATA_EPS: str = "torchx.util.entrypoints.metadata.entry_points"
34+
3135

3236
def _test_component(name: str, role_name: str = "worker") -> AppDef:
3337
"""
@@ -58,92 +62,126 @@ def invalid_component(name, role_name: str = "worker") -> AppDef:
5862
)
5963

6064

61-
class DirComponentsFinderTest(unittest.TestCase):
62-
def test_get_components(self) -> None:
63-
components = _load_components()
64-
self.assertTrue(len(components) > 1)
65-
component = components["utils.echo"]
66-
self.assertEqual("utils.echo", component.name)
67-
self.assertEqual(
68-
"Echos a message to stdout (calls echo)", component.description
65+
class FinderTest(unittest.TestCase):
66+
_ENTRY_POINTS: EntryPoints = EntryPoints(
67+
EntryPoint_from_text(
68+
"""
69+
[torchx.components]
70+
_ = torchx.specs.test.finder_test
71+
"""
6972
)
70-
self.assertEqual("echo", component.fn_name)
71-
self.assertIsNotNone(component.fn)
73+
)
74+
75+
def tearDown(self) -> None:
76+
# clear the globals since find_component() has side-effects
77+
# and we load a bunch of mocks for components in the tests below
78+
finder._components = None
79+
80+
def test_module_relname(self) -> None:
81+
import torchx.specs.test.components as c
82+
import torchx.specs.test.components.a as ca
83+
84+
self.assertEqual("", finder.module_relname(c, relative_to=c))
85+
self.assertEqual("a", finder.module_relname(ca, relative_to=c))
86+
with self.assertRaises(ValueError):
87+
finder.module_relname(c, relative_to=ca)
7288

7389
def test_get_component_by_name(self) -> None:
7490
component = none_throws(get_component("utils.echo"))
7591
self.assertEqual("utils.echo", component.name)
7692
self.assertEqual("echo", component.fn_name)
7793
self.assertIsNotNone(component.fn)
7894

79-
def test_get_invalid_component_by_name(self) -> None:
80-
test_torchx_group = {"foobar": sys.modules[__name__]}
81-
finder._components = None
82-
with patch("torchx.specs.finder.entrypoints") as entrypoints_mock:
83-
entrypoints_mock.load_group.return_value = test_torchx_group
84-
with self.assertRaises(ComponentValidationException):
85-
get_component("foobar.finder_test.invalid_component")
95+
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
96+
def test_get_invalid_component_by_name(self, _: MagicMock) -> None:
97+
with self.assertRaises(ComponentValidationException):
98+
get_component("invalid_component")
8699

87-
def test_get_unknown_component_by_name(self) -> None:
88-
test_torchx_group = {"foobar": sys.modules[__name__]}
89-
finder._components = None
90-
with patch("torchx.specs.finder.entrypoints") as entrypoints_mock:
91-
entrypoints_mock.load_group.return_value = test_torchx_group
92-
with self.assertRaises(ComponentNotFoundException):
93-
get_component("foobar.finder_test.unknown_component")
94-
95-
def test_get_invalid_component(self) -> None:
96-
test_torchx_group = {"foobar": sys.modules[__name__]}
97-
with patch("torchx.specs.finder.entrypoints") as entrypoints_mock:
98-
entrypoints_mock.load_group.return_value = test_torchx_group
99-
components = _load_components()
100-
foobar_component = components["foobar.finder_test.invalid_component"]
100+
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
101+
def test_get_unknown_component_by_name(self, _: MagicMock) -> None:
102+
with self.assertRaises(ComponentNotFoundException):
103+
get_component("unknown_component")
104+
105+
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
106+
def test_get_invalid_component(self, _: MagicMock) -> None:
107+
components = _load_components()
108+
foobar_component = components["invalid_component"]
101109
self.assertEqual(1, len(foobar_component.validation_errors))
102110

103-
def test_get_entrypoints_components(self) -> None:
104-
test_torchx_group = {"foobar": sys.modules[__name__]}
105-
with patch("torchx.specs.finder.entrypoints") as entrypoints_mock:
106-
entrypoints_mock.load_group.return_value = test_torchx_group
107-
components = _load_components()
108-
foobar_component = components["foobar.finder_test._test_component"]
111+
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
112+
def test_get_entrypoints_components(self, _: MagicMock) -> None:
113+
components = _load_components()
114+
foobar_component = components["_test_component"]
109115
self.assertEqual(_test_component, foobar_component.fn)
110116
self.assertEqual("_test_component", foobar_component.fn_name)
111-
self.assertEqual("foobar.finder_test._test_component", foobar_component.name)
117+
self.assertEqual("_test_component", foobar_component.name)
112118
self.assertEqual("Test component", foobar_component.description)
113119

114-
def test_get_base_module_name(self) -> None:
115-
finder = ModuleComponentsFinder(sys.modules[__name__], "")
116-
expected_name = "torchx.specs.test"
117-
actual_name = finder._get_base_module_name(sys.modules[__name__])
118-
self.assertEqual(expected_name, actual_name)
119-
120-
def test_get_base_module_name_for_init_module(self) -> None:
121-
finder = ModuleComponentsFinder("", "")
122-
expected_name = "torchx.specs"
123-
actual_name = finder._get_base_module_name(sys.modules["torchx.specs"])
124-
self.assertEqual(expected_name, actual_name)
125-
126-
def test_get_component_name(self) -> None:
127-
finder = ModuleComponentsFinder("", group="foobar")
128-
actual_name = finder._get_component_name(
129-
"test.main_module", "test.main_module.sub_module.bar", "get_component"
130-
)
131-
expected_name = "foobar.sub_module.bar.get_component"
132-
self.assertEqual(expected_name, actual_name)
133-
134-
def test_strip_init(self) -> None:
135-
finder = ModuleComponentsFinder("", "")
136-
self.assertEqual("foobar", finder._strip_init("foobar.__init__"))
137-
self.assertEqual("", finder._strip_init("__init__"))
138-
self.assertEqual("foobar", finder._strip_init("foobar"))
139-
140-
def test_get_module_name(self) -> None:
141-
finder = ModuleComponentsFinder("", "")
142-
actual_name = finder._get_module_name(
143-
"/test/path/main_module/foobar.py", "/test/path", "main"
120+
@patch(
121+
_METADATA_EPS,
122+
return_value=EntryPoints(
123+
EntryPoint_from_text(
124+
"""
125+
[torchx.components]
126+
foo = torchx.specs.test.components.a
127+
bar = torchx.specs.test.components.c.d
128+
"""
129+
)
130+
),
131+
)
132+
def test_load_custom_components(self, _: MagicMock) -> None:
133+
components = _load_components()
134+
135+
# the name of the appdefs returned by each component
136+
# is the expected component name
137+
for actual_name, comp in components.items():
138+
expected_name = comp.fn().name
139+
self.assertEqual(expected_name, actual_name)
140+
141+
self.assertEqual(3, len(components))
142+
143+
@patch(
144+
_METADATA_EPS,
145+
return_value=EntryPoints(
146+
EntryPoint_from_text(
147+
"""
148+
[torchx.components]
149+
_0 = torchx.specs.test.components.a
150+
_1 = torchx.specs.test.components.c.d
151+
"""
152+
)
153+
),
154+
)
155+
def test_load_custom_components_nogroup(self, _: MagicMock) -> None:
156+
components = _load_components()
157+
158+
# test component names are hardcoded expecting
159+
# test.components.* to be grouped under foo.*
160+
# and components.a_namepace.* to be grouped under bar.*
161+
# since we are testing _* (no group prefix) remove the first prefix
162+
for actual_name, comp in components.items():
163+
expected_name = comp.fn().name.split(".", maxsplit=1)[1]
164+
self.assertEqual(expected_name, actual_name)
165+
166+
def test_load_builtins(self) -> None:
167+
components = _load_components()
168+
169+
# if nothing registered in entrypoints, then builtins should be loaded
170+
expected = {
171+
c.name for c in ModuleComponentsFinder("torchx.components", group="").find()
172+
}
173+
self.assertEqual(components.keys(), expected)
174+
175+
def test_load_builtin_echo(self) -> None:
176+
components = _load_components()
177+
self.assertTrue(len(components) > 1)
178+
component = components["utils.echo"]
179+
self.assertEqual("utils.echo", component.name)
180+
self.assertEqual(
181+
"Echos a message to stdout (calls echo)", component.description
144182
)
145-
expected_name = "main.main_module.foobar"
146-
self.assertEqual(expected_name, actual_name)
183+
self.assertEqual("echo", component.fn_name)
184+
self.assertIsNotNone(component.fn)
147185

148186

149187
def current_file_path() -> str:

‎torchx/util/entrypoints.py

+22-4
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ def load(group: str, name: str, default=None):
3939

4040
def _defer_load_ep(ep: EntryPoint) -> object:
4141
def run(*args: object, **kwargs: object) -> object:
42-
return ep.load()(*args, **kwargs)
42+
if ep.attr is None: # this is a module
43+
return ep.load()
44+
else:
45+
return ep.load()(*args, **kwargs)
4346

4447
return run
4548

@@ -51,7 +54,10 @@ def load_group(
5154
):
5255
"""
5356
Loads all the entry points specified by ``group`` and returns
54-
the entry points as a map of ``name (str) -> entrypoint.load()``.
57+
the entry points as a map of ``name (str) -> deferred_load_fn``.
58+
where the ``deferred_load_fn`` (as the name implies) defers the
59+
loading of the entrypoint (e.g. ``entrypoint.load()``) until the
60+
caller explicitly executes the funtion.
5561
5662
For the following ``entry_point.txt``:
5763
@@ -61,9 +67,21 @@ def load_group(
6167
bar = this.is:a_fn
6268
baz = this.is:b_fn
6369
64-
1. ``load_group("foo")`` -> ``{"bar", this.is.a_fn, "baz": this.is.b_fn}``
70+
1. ``load_group("foo")["bar"]("baz")`` -> equivalent to calling ``this.is.a_fn("baz")``
6571
1. ``load_group("food")`` -> ``None``
66-
1. ``load_group("food", default={"hello": this.is.c_fn})`` -> ``{"hello": this.is.c_fn}``
72+
1. ``load_group("food", default={"hello": this.is.c_fn})["hello"]("world")`` -> equivalent to calling ``this.is.c_fn("world")``
73+
74+
75+
If the entrypoint is a module (versus a function as shown above), then calling the ``deferred_load_fn``
76+
simply loads the module and ignores any ``*args`` or ``**kwargs`` passed. For example:
77+
78+
::
79+
80+
[foo]
81+
bar = this.is.a.module
82+
83+
1. ``load_group("foo")["bar"]()`` -> loads ``this.is.a.module`` and returns a ``module`` type
84+
1. ``load_group("foo")["bar"]("baz", hello="world")`` -> same as above (ignores ``*args`` and ``**kwargs``)
6785
6886
"""
6987

‎torchx/util/test/entrypoints_test.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import unittest
88
from configparser import ConfigParser
9+
from types import ModuleType
910
from typing import List
1011
from unittest.mock import MagicMock, patch
1112

@@ -54,6 +55,11 @@ def barbaz() -> str:
5455
baz = torchx.util.test.entrypoints_test:missing_attr
5556
"""
5657

58+
_EP_GRP_MOD_TXT: str = """
59+
[ep.grp.mod.test]
60+
baz = torchx.util.test.entrypoints_test
61+
"""
62+
5763
_EP_GRP_IGN_MOD_TXT: str = """
5864
[ep.grp.missing.mod.test]
5965
baz = torchx.util.test.entrypoints_test.missing_module
@@ -62,6 +68,7 @@ def barbaz() -> str:
6268
EntryPoint_from_text(_EP_TXT)
6369
+ EntryPoint_from_text(_EP_GRP_TXT)
6470
+ EntryPoint_from_text(_EP_GRP_IGN_ATTR_TXT)
71+
+ EntryPoint_from_text(_EP_GRP_MOD_TXT)
6572
+ EntryPoint_from_text(_EP_GRP_IGN_MOD_TXT)
6673
)
6774

@@ -70,21 +77,21 @@ def barbaz() -> str:
7077

7178
class EntryPointsTest(unittest.TestCase):
7279
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
73-
def test_load(self, mock_md_eps: MagicMock) -> None:
80+
def test_load(self, _: MagicMock) -> None:
7481
print(type(load("entrypoints.test", "foo")))
7582
self.assertEqual("foobar", load("entrypoints.test", "foo")())
7683

7784
with self.assertRaisesRegex(KeyError, "baz"):
7885
load("entrypoints.test", "baz")()
7986

8087
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
81-
def test_load_with_default(self, mock_md_eps: MagicMock) -> None:
88+
def test_load_with_default(self, _: MagicMock) -> None:
8289
self.assertEqual("barbaz", load("entrypoints.test", "missing", barbaz)())
8390
self.assertEqual("barbaz", load("entrypoints.missing", "foo", barbaz)())
8491
self.assertEqual("barbaz", load("entrypoints.missing", "missing", barbaz)())
8592

8693
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
87-
def test_load_group(self, mock_md_eps: MagicMock) -> None:
94+
def test_load_group(self, _: MagicMock) -> None:
8895
eps = load_group("ep.grp.test")
8996
self.assertEqual(2, len(eps), eps)
9097
self.assertEqual("foobar", eps["foo"]())
@@ -93,8 +100,16 @@ def test_load_group(self, mock_md_eps: MagicMock) -> None:
93100
eps = load_group("ep.grp.test.missing")
94101
self.assertIsNone(eps)
95102

103+
eps = load_group("ep.grp.mod.test")
104+
module = eps["baz"]()
105+
self.assertEqual(ModuleType, type(module))
106+
self.assertEqual("torchx.util.test.entrypoints_test", module.__name__)
107+
108+
# module's deferred load function should ignore *args and **kwargs
109+
self.assertEqual(module, eps["baz"]("ignored", should="ignore"))
110+
96111
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
97-
def test_load_group_with_default(self, mock_md_eps: MagicMock) -> None:
112+
def test_load_group_with_default(self, _: MagicMock) -> None:
98113
eps = load_group("ep.grp.test", {"foo": barbaz, "bar": foobar})
99114
self.assertEqual(2, len(eps))
100115
self.assertEqual("foobar", eps["foo"]())
@@ -106,7 +121,7 @@ def test_load_group_with_default(self, mock_md_eps: MagicMock) -> None:
106121
self.assertEqual("foobar", eps["bar"]())
107122

108123
@patch(_METADATA_EPS, return_value=_ENTRY_POINTS)
109-
def test_load_group_not_missing(self, mock_md_eps: MagicMock) -> None:
124+
def test_load_group_missing(self, _: MagicMock) -> None:
110125
with self.assertRaises(AttributeError):
111126
load_group("ep.grp.missing.attr.test")["baz"]()
112127

0 commit comments

Comments
 (0)
Please sign in to comment.