From 2774b0526957b214836db0659607ddb9d4054d83 Mon Sep 17 00:00:00 2001 From: James Stronz Date: Wed, 15 Jun 2022 16:48:52 -0500 Subject: [PATCH 1/3] Issue #307 Added the decorator rpyc.exposed to improve pythonic-style --- docs/docs/services.rst | 25 +++++++++++++++++++++ rpyc/__init__.py | 2 +- rpyc/core/__init__.py | 2 +- rpyc/utils/__init__.py | 42 ++++++++++++++++++++++++++++++++++++ tests/test_custom_service.py | 35 ++++++++++++++++++++++++++++-- 5 files changed, 102 insertions(+), 4 deletions(-) diff --git a/docs/docs/services.rst b/docs/docs/services.rst index a3040c94..29bf5d45 100644 --- a/docs/docs/services.rst +++ b/docs/docs/services.rst @@ -44,6 +44,31 @@ that's exposed by the other party. For security concerns, access is only granted ``exposed_`` members. For instance, the ``foo`` method above is inaccessible (attempting to call it will result in an ``AttributeError``). +Rather than having each method name start with ``exposed_``, you may prefer to use a +decorator. Let's revisit the calculator service, but this time we'll use decorators. :: + + import rpyc + + @rpyc.service + class CalculatorService(rpyc.Service): + @rpyc.exposed + def add(self, a, b): + return a + b + @rpyc.exposed + def sub(self, a, b): + return a - b + @rpyc.exposed + def mul(self, a, b): + return a * b + @rpyc.exposed + def div(self, a, b): + return a / b + def foo(self): + print "foo" + +When implementing services, ``@rpyc.service`` and ``@rpyc.exposed`` can replace the ``exposed_`` naming +convention. + Implementing Services --------------------- As previously explained, all ``exposed_`` members of your service class will be available to diff --git a/rpyc/__init__.py b/rpyc/__init__.py index cdf34cf2..9dca1d67 100644 --- a/rpyc/__init__.py +++ b/rpyc/__init__.py @@ -49,7 +49,7 @@ connect_stdpipes, connect, ssl_connect, list_services, discover, connect_by_service, connect_subproc, connect_thread, ssh_connect) from rpyc.utils.helpers import async_, timed, buffiter, BgServingThread, restricted -from rpyc.utils import classic +from rpyc.utils import classic, exposed, service from rpyc.version import version as __version__ from rpyc.lib import setup_logger, spawn diff --git a/rpyc/core/__init__.py b/rpyc/core/__init__.py index 549787ff..18d12cee 100644 --- a/rpyc/core/__init__.py +++ b/rpyc/core/__init__.py @@ -1,7 +1,7 @@ # flake8: noqa: F401 from rpyc.core.stream import SocketStream, TunneledSocketStream, PipeStream from rpyc.core.channel import Channel -from rpyc.core.protocol import Connection +from rpyc.core.protocol import Connection, DEFAULT_CONFIG from rpyc.core.netref import BaseNetref from rpyc.core.async_ import AsyncResult, AsyncResultTimeout from rpyc.core.service import Service, VoidService, SlaveService, MasterService, ClassicService diff --git a/rpyc/utils/__init__.py b/rpyc/utils/__init__.py index 6a9f9ef9..5c295173 100644 --- a/rpyc/utils/__init__.py +++ b/rpyc/utils/__init__.py @@ -1,3 +1,45 @@ """ Utilities (not part of the core protocol) """ +import functools +import inspect +from rpyc.core import DEFAULT_CONFIG + + +try: + # python3 depricated basestring, so if this causes exception catch and alias to str + basestring +except Exception: + basestring = str + + +def service(cls): + """find and rename exposed decorated attributes""" + for attr_name, attr_obj in inspect.getmembers(cls): # rebind exposed decorated attributes + exposed_prefix = getattr(attr_obj, '__exposed__', False) + if exposed_prefix and not inspect.iscode(attr_obj): # exclude the implementation + renamed = exposed_prefix + attr_name + if inspect.isclass(attr_obj): # recurse exposed objects such as a class + attr_obj = service(attr_obj) + setattr(cls, attr_name, attr_obj) + setattr(cls, renamed, attr_obj) + return cls + + +def exposed(arg): + """decorator that adds the exposed prefix information to functions which `service` uses to rebind attrs""" + exposed_prefix = DEFAULT_CONFIG['exposed_prefix'] + if isinstance(arg, basestring): + # When the arg is a string (i.e. `@rpyc.exposed("customPrefix_")`) the prefix + # is partially evaluated into the wrapper. The function returned is "frozen" and used as a decorator. + return functools.partial(_wrapper, arg) + elif hasattr(arg, '__call__'): + # When the arg is callable (i.e. `@rpyc.exposed`) then use default prefix and invoke + return _wrapper(exposed_prefix, arg) + else: + raise TypeError('rpyc.exposed expects a callable object or a string') + + +def _wrapper(exposed_prefix, exposed_obj): + exposed_obj.__exposed__ = exposed_prefix + return exposed_obj diff --git a/tests/test_custom_service.py b/tests/test_custom_service.py index f9061c53..9f8f129b 100644 --- a/tests/test_custom_service.py +++ b/tests/test_custom_service.py @@ -19,6 +19,7 @@ class MyClass(object): MyClass = MyMeta(MyClass.__name__, MyClass.__bases__, dict(MyClass.__dict__)) +@rpyc.service class MyService(rpyc.Service): on_connect_called = False on_disconnect_called = False @@ -50,6 +51,24 @@ def exposed_getmeta(self): def exposed_instance(self, inst, cls): return isinstance(inst, cls) + @rpyc.exposed + class MyClass(object): + def __init__(self, a, b): + self.a = a + self.b = b + + @rpyc.exposed + def foo(self): + return self.a + self.b + + @rpyc.exposed + def get_decorated(self): + return "decorated" + + @rpyc.exposed('prefix_') + def get_decorated_prefix(self): + return "decorated_prefix" + def before_closed(root): root.on_about_to_close() @@ -61,9 +80,13 @@ class TestCustomService(unittest.TestCase): def setUp(self): self.service = MyService() client_config = {"before_closed": before_closed, "close_catchall": False} - self.conn = rpyc.connect_thread( remote_service=self.service, config=client_config) - + prefixed_client_config = {'exposed_prefix': 'prefix_'} + self.conn = rpyc.connect_thread(remote_service=self.service, config=client_config) + self.prefixed_conn = rpyc.connect_thread(remote_service=self.service, + config=prefixed_client_config, + remote_config=prefixed_client_config) self.conn.root # this will block until the service is initialized, + self.prefixed_conn.root # this will block until the service is initialized, # so we can be sure on_connect_called is True by that time self.assertTrue(self.service.on_connect_called) @@ -92,6 +115,14 @@ def test_attributes(self): self.conn.root.exposed_getlist # this is not an exposed attribute: self.assertRaises(AttributeError, lambda: self.conn.root.foobar()) + # methods exposed using decorator + self.conn.root.get_decorated + self.conn.root.exposed_get_decorated + self.prefixed_conn.root.get_decorated_prefix + self.prefixed_conn.root.prefix_get_decorated_prefix + self.assertFalse(hasattr(self.conn.root, 'get_decorated_prefix')) + smc = self.conn.root.MyClass('a', 'b') + self.assertEquals(smc.foo(), 'ab') def test_safeattrs(self): x = self.conn.root.getlist() From 9aa073b4a935537a178ad1b8e26846a10902a3b0 Mon Sep 17 00:00:00 2001 From: James Stronz Date: Wed, 15 Jun 2022 17:13:52 -0500 Subject: [PATCH 2/3] Removed basestring compatibility for Python 2.7 since it is no longer supported --- rpyc/utils/__init__.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/rpyc/utils/__init__.py b/rpyc/utils/__init__.py index 5c295173..5c77e9d9 100644 --- a/rpyc/utils/__init__.py +++ b/rpyc/utils/__init__.py @@ -6,13 +6,6 @@ from rpyc.core import DEFAULT_CONFIG -try: - # python3 depricated basestring, so if this causes exception catch and alias to str - basestring -except Exception: - basestring = str - - def service(cls): """find and rename exposed decorated attributes""" for attr_name, attr_obj in inspect.getmembers(cls): # rebind exposed decorated attributes @@ -29,7 +22,7 @@ def service(cls): def exposed(arg): """decorator that adds the exposed prefix information to functions which `service` uses to rebind attrs""" exposed_prefix = DEFAULT_CONFIG['exposed_prefix'] - if isinstance(arg, basestring): + if isinstance(arg, str): # When the arg is a string (i.e. `@rpyc.exposed("customPrefix_")`) the prefix # is partially evaluated into the wrapper. The function returned is "frozen" and used as a decorator. return functools.partial(_wrapper, arg) From 09143fa0510fa12ee591535e19ed8446f4144a59 Mon Sep 17 00:00:00 2001 From: James Stronz Date: Wed, 15 Jun 2022 17:28:58 -0500 Subject: [PATCH 3/3] Fixed assumptions in tests/test_gdb.py regarding PYTHONPATH and other environment variables --- tests/test_gdb.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_gdb.py b/tests/test_gdb.py index fc021a27..4f70ffcd 100644 --- a/tests/test_gdb.py +++ b/tests/test_gdb.py @@ -1,8 +1,10 @@ import pathlib import rpyc import subprocess +import sys import tempfile import unittest +import os from rpyc.utils.server import ThreadedServer from shutil import which @@ -13,9 +15,12 @@ class ParentGDB(rpyc.Service): def on_connect(self, conn): tests_path = pathlib.Path(__file__).resolve().parent gdb_cmd = ['gdb', '-q', '-x', pathlib.Path(tests_path, 'gdb_service.py')] - self._proc = subprocess.Popen(gdb_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + env = os.environ.copy() + env['PYTHONPATH'] = ':'.join(sys.path) + self._proc = subprocess.Popen(gdb_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) stdout = self._proc.stdout.readline() self._gdb_svc_port = int(stdout.strip().decode()) + print(self._gdb_svc_port) self.gdb_svc_conn = rpyc.connect(host='localhost', port=self._gdb_svc_port) def on_disconnect(self, conn): @@ -50,10 +55,15 @@ def tearDown(self): pass def test_gdb(self): + print(0) parent_gdb_conn = rpyc.connect(host='localhost', port=18878) + print(1) gdb = parent_gdb_conn.root.get_gdb() + print(2) gdb.execute('file {}'.format(self.a_out)) + print(3) disasm = gdb.execute('disassemble main', to_string=True) + print(4) self.assertIn('End of assembler dump', disasm) parent_gdb_conn.close()