Skip to content

Commit fe613a1

Browse files
Merge pull request #652 from tableau/remote_server
Add capability to deploy models remotely
2 parents 59f4056 + 3bb4d37 commit fe613a1

File tree

11 files changed

+155
-17
lines changed

11 files changed

+155
-17
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,4 @@ tabpy/tabpy_server/staging
128128
# etc
129129
setup.bat
130130
*~
131-
tabpy_log.log.1
131+
tabpy_log.log.*

CHANGELOG

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## v2.13.0
4+
5+
### Improvements
6+
7+
- Add support for deploying functions to a remote TabPy server by setting
8+
`remote_server=True` when creating the Client instance.
9+
310
## v2.12.0
411

512
### Improvements

docs/tabpy-tools.md

+10
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ The URL and port are where the Tableau-Python-Server process has been started -
4141
more info can be found in the
4242
[Starting TabPy](server-install.md#starting-tabpy) section of the documentation.
4343

44+
When connecting to a remote TabPy server, configure the following parameters:
45+
46+
- Set `remote_server` to `True` to indicate a remote connection
47+
- Set `localhost_endpoint` to the specific localhost address used by the remote server
48+
- **Note:** The protocol and port may differ from the main endpoint
49+
50+
```python
51+
client = Client('https://example.com:443/', remote_server=True, localhost_endpoint='http://localhost:9004/')
52+
```
53+
4454
## Authentication
4555

4656
When TabPy is configured with the authentication feature on, client code

tabpy/VERSION

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.12.1
1+
2.13.0

tabpy/tabpy_server/handlers/endpoint_handler.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,13 @@ def delete(self, name):
115115

116116
# delete files
117117
if endpoint_info["type"] != "alias":
118-
delete_path = get_query_object_path(
118+
query_path = get_query_object_path(
119119
self.settings["state_file_path"], name, None
120120
)
121+
staging_path = query_path.replace("/query_objects/", "/staging/endpoints/")
121122
try:
122-
yield self._delete_po_future(delete_path)
123+
yield self._delete_po_future(query_path)
124+
yield self._delete_po_future(staging_path)
123125
except Exception as e:
124126
self.error_out(400, f"Error while deleting: {e}")
125127
self.finish()

tabpy/tabpy_server/management/state.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ def add_endpoint(
215215
Name of the endpoint
216216
description : str, optional
217217
Description of this endpoint
218-
doc_string : str, optional
218+
docstring : str, optional
219219
The doc string for this endpoint, if needed.
220220
endpoint_type : str
221221
The endpoint type (model, alias)
@@ -309,7 +309,7 @@ def update_endpoint(
309309
Name of the endpoint
310310
description : str, optional
311311
Description of this endpoint
312-
doc_string : str, optional
312+
docstring : str, optional
313313
The doc string for this endpoint, if needed.
314314
endpoint_type : str, optional
315315
The endpoint type (model, alias)

tabpy/tabpy_tools/client.py

+87-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import copy
2+
import inspect
23
from re import compile
34
import time
45
import requests
@@ -49,7 +50,9 @@ def _check_endpoint_name(name):
4950

5051

5152
class Client:
52-
def __init__(self, endpoint, query_timeout=1000):
53+
def __init__(
54+
self, endpoint, query_timeout=1000, remote_server=False, localhost_endpoint=None
55+
):
5356
"""
5457
Connects to a running server.
5558
@@ -63,10 +66,19 @@ def __init__(self, endpoint, query_timeout=1000):
6366
6467
query_timeout : float, optional
6568
The timeout for query operations.
69+
70+
remote_server : bool, optional
71+
Whether client is a remote TabPy server.
72+
73+
localhost_endpoint : str, optional
74+
The localhost endpoint with potentially different protocol and
75+
port compared to the main endpoint parameter.
6676
"""
6777
_check_hostname(endpoint)
6878

6979
self._endpoint = endpoint
80+
self._remote_server = remote_server
81+
self._localhost_endpoint = localhost_endpoint
7082

7183
session = requests.session()
7284
session.verify = False
@@ -232,6 +244,12 @@ def deploy(self, name, obj, description="", schema=None, override=False, is_publ
232244
--------
233245
remove, get_endpoints
234246
"""
247+
if self._remote_server:
248+
return self._remote_deploy(
249+
name, obj,
250+
description=description, schema=schema, override=override, is_public=is_public
251+
)
252+
235253
endpoint = self.get_endpoints().get(name)
236254
version = 1
237255
if endpoint:
@@ -390,6 +408,7 @@ def _gen_endpoint(self, name, obj, description, version=1, schema=None, is_publi
390408
"methods": endpoint_object.get_methods(),
391409
"required_files": [],
392410
"required_packages": [],
411+
"docstring": endpoint_object.get_docstring(),
393412
"schema": copy.copy(schema),
394413
"is_public": is_public,
395414
}
@@ -419,6 +438,7 @@ def _wait_for_endpoint_deployment(
419438
logger.info(
420439
f"Waiting for endpoint {endpoint_name} to deploy to " f"version {version}"
421440
)
441+
time.sleep(interval)
422442
start = time.time()
423443
while True:
424444
ep_status = self.get_status()
@@ -447,6 +467,72 @@ def _wait_for_endpoint_deployment(
447467
logger.info(f"Sleeping {interval}...")
448468
time.sleep(interval)
449469

470+
def _remote_deploy(
471+
self, name, obj, description="", schema=None, override=False, is_public=False
472+
):
473+
"""
474+
Remotely deploy a Python function using the /evaluate endpoint. Takes the same inputs
475+
as deploy.
476+
"""
477+
remote_script = self._gen_remote_script()
478+
remote_script += f"{inspect.getsource(obj)}\n"
479+
480+
remote_script += (
481+
f"client.deploy("
482+
f"'{name}', {obj.__name__}, '{description}', "
483+
f"override={override}, is_public={is_public}, schema={schema}"
484+
f")"
485+
)
486+
487+
return self._evaluate_remote_script(remote_script)
488+
489+
def _gen_remote_script(self):
490+
"""
491+
Generates a remote script for TabPy client connection with credential handling.
492+
493+
Returns:
494+
str: A Python script to establish a TabPy client connection
495+
"""
496+
remote_script = [
497+
"from tabpy.tabpy_tools.client import Client",
498+
f"client = Client('{self._localhost_endpoint or self._endpoint}')"
499+
]
500+
501+
remote_script.append(
502+
f"client.set_credentials('{auth.username}', '{auth.password}')"
503+
) if (auth := self._service.service_client.network_wrapper.auth) else None
504+
505+
return "\n".join(remote_script) + "\n"
506+
507+
def _evaluate_remote_script(self, remote_script):
508+
"""
509+
Uses TabPy /evaluate endpoint to execute a remote TabPy client script.
510+
511+
Parameters
512+
----------
513+
remote_script : str
514+
The script to execute remotely.
515+
"""
516+
print(f"Remote script:\n{remote_script}\n")
517+
url = f"{self._endpoint}evaluate"
518+
headers = {"Content-Type": "application/json"}
519+
payload = {"data": {}, "script": remote_script}
520+
521+
response = requests.post(
522+
url,
523+
headers=headers,
524+
auth=self._service.service_client.network_wrapper.auth,
525+
json=payload
526+
)
527+
528+
msg = response.text.replace('null', 'Success')
529+
if "Ad-hoc scripts have been disabled" in msg:
530+
msg += "\n[Deployment to remote tabpy client not allowed.]"
531+
532+
status_message = (f"{response.status_code} - {msg}\n")
533+
print(status_message)
534+
return status_message
535+
450536
def set_credentials(self, username, password):
451537
"""
452538
Set credentials for all the TabPy client-server communication

tabpy/tabpy_tools/custom_query_object.py

+11-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import logging
2+
import platform
3+
import sys
24
from .query_object import QueryObject as _QueryObject
35

46

@@ -69,12 +71,16 @@ def query(self, *args, **kwargs):
6971
)
7072
raise
7173

72-
def get_doc_string(self):
74+
def get_docstring(self):
7375
"""Get doc string from customized query"""
74-
if self.custom_query.__doc__ is not None:
75-
return self.custom_query.__doc__
76-
else:
77-
return "-- no docstring found in query function --"
76+
default_docstring = "-- no docstring found in query function --"
77+
78+
# TODO: fix docstring parsing on Windows systems
79+
if sys.platform == 'win32':
80+
return default_docstring
81+
82+
ds = getattr(self.custom_query, '__doc__', None)
83+
return ds if ds and isinstance(ds, str) else default_docstring
7884

7985
def get_methods(self):
8086
return [self.get_query_method()]

tabpy/tabpy_tools/query_object.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class QueryObject(abc.ABC):
1414
"""
1515
Derived class needs to implement the following interface:
1616
* query() -- given input, return query result
17-
* get_doc_string() -- returns documentation for the Query Object
17+
* get_docstring() -- returns documentation for the Query Object
1818
"""
1919

2020
def __init__(self, description=""):
@@ -30,7 +30,7 @@ def query(self, input):
3030
pass
3131

3232
@abc.abstractmethod
33-
def get_doc_string(self):
33+
def get_docstring(self):
3434
"""Returns documentation for the query object
3535
3636
By default, this method returns the docstring for 'query' method

tabpy/tabpy_tools/rest_client.py

+2
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Endpoint(RESTObject):
4141
version = RESTProperty(int)
4242
description = RESTProperty(str)
4343
dependencies = RESTProperty(list)
44+
docstring = RESTProperty(str)
4445
methods = RESTProperty(list)
4546
creation_time = RESTProperty(datetime, from_epoch, to_epoch)
4647
last_modified_time = RESTProperty(datetime, from_epoch, to_epoch)
@@ -64,6 +65,7 @@ def __eq__(self, other):
6465
and self.version == other.version
6566
and self.description == other.description
6667
and self.dependencies == other.dependencies
68+
and self.docstring == other.docstring
6769
and self.methods == other.methods
6870
and self.evaluator == other.evaluator
6971
and self.schema_version == other.schema_version

tests/unit/tools_tests/test_client.py

+28-3
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,25 @@ def setUp(self):
1212

1313
def test_init(self):
1414
client = Client("http://example.com:9004")
15-
1615
self.assertEqual(client._endpoint, "http://example.com:9004")
16+
self.assertEqual(client._remote_server, False)
1717

1818
client = Client("http://example.com/", 10.0)
19-
2019
self.assertEqual(client._endpoint, "http://example.com/")
2120

2221
client = Client(endpoint="https://example.com/", query_timeout=-10.0)
23-
2422
self.assertEqual(client._endpoint, "https://example.com/")
2523
self.assertEqual(client.query_timeout, 0.0)
2624

25+
client = Client(
26+
"http://example.com:442/",
27+
remote_server=True,
28+
localhost_endpoint="http://localhost:9004/"
29+
)
30+
self.assertEqual(client._endpoint, "http://example.com:442/")
31+
self.assertEqual(client._remote_server, True)
32+
self.assertEqual(client._localhost_endpoint, "http://localhost:9004/")
33+
2734
# valid name tests
2835
with self.assertRaises(ValueError):
2936
Client("")
@@ -90,3 +97,21 @@ def test_check_invalid_endpoint_name(self):
9097
f"endpoint name {endpoint_name } can only contain: "
9198
"a-z, A-Z, 0-9, underscore, hyphens and spaces.",
9299
)
100+
101+
def test_deploy_with_remote_server(self):
102+
client = Client("http://example.com:9004/", remote_server=True)
103+
mock_evaluate_remote_script = Mock()
104+
client._evaluate_remote_script = mock_evaluate_remote_script
105+
client.deploy('name', lambda: True, 'description')
106+
mock_evaluate_remote_script.assert_called()
107+
108+
def test_gen_remote_script(self):
109+
client = Client("http://example.com:9004/", remote_server=True)
110+
script = client._gen_remote_script()
111+
self.assertTrue("from tabpy.tabpy_tools.client import Client" in script)
112+
self.assertTrue("client = Client('http://example.com:9004/')" in script)
113+
self.assertFalse("client.set_credentials" in script)
114+
115+
client.set_credentials("username", "password")
116+
script = client._gen_remote_script()
117+
self.assertTrue("client.set_credentials('username', 'password')" in script)

0 commit comments

Comments
 (0)