Skip to content

Commit 4ffef39

Browse files
authored
connect: use the public /v1/tasks/{id} endpoint (#609)
* connect: use the public /v1/tasks/{id} endpoint The v0 task is still returned by the v0 deploy endpoint. * poll-wait is an integer, not a float, and is used to configure the long-poll task requests against the Connect server. * task_get() calls /v1/tasks/{id}. It takes first and wait arguments, indicating the starting point for requested output and the duration for long-poll requests. * wait_for_task() does no sleeping, but asks task_get() to perform a long-poll with its wait argument. * output_task_log() sends every output line to the log callback. It previously used data abut the last-line to determine when to send lines, but that is unnecessary, as wait_for_task() requests only new lines from task_get(). * RSConnectExecutor.delete_runtime_cache() returns its delete result and task for easier testing. It no longer stores state on the object. fixes #608 * backwards-compatible fields for rsconnect-jupyter
1 parent 2c12229 commit 4ffef39

File tree

8 files changed

+120
-103
lines changed

8 files changed

+120
-103
lines changed

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## Unreleased
88

9+
### Changed
10+
11+
- The `rsconnect content build run --poll-wait` argument specifies an integral
12+
number of seconds. It previously allowed fractional seconds. (#608)
13+
14+
- Uses the public Connect server API endpoint `/v1/tasks/{id}` to poll task
15+
progress. (#608)
16+
917
### Removed
1018

1119
- Uncalled `RSConnectClient.app_publish()` function, which referenced an

rsconnect/actions_content.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def build_start(
139139
running: bool = False,
140140
retry: bool = False,
141141
all: bool = False,
142-
poll_wait: float = 2,
142+
poll_wait: int = 1,
143143
debug: bool = False,
144144
):
145145
build_store = ensure_content_build_store(connect_server)
@@ -302,7 +302,7 @@ def _monitor_build(connect_server: RSConnectServer, content_items: list[ContentI
302302
return True
303303

304304

305-
def _build_content_item(connect_server: RSConnectServer, content: ContentItemWithBuildState, poll_wait: float):
305+
def _build_content_item(connect_server: RSConnectServer, content: ContentItemWithBuildState, poll_wait: int):
306306
build_store = ensure_content_build_store(connect_server)
307307
with RSConnectClient(connect_server) as client:
308308
# Pending futures will still try to execute when ThreadPoolExecutor.shutdown() is called
@@ -333,7 +333,7 @@ def _build_content_item(connect_server: RSConnectServer, content: ContentItemWit
333333
def write_log(line: str):
334334
log.write("%s\n" % line)
335335

336-
_, _, task_status = emit_task_log(
336+
_, _, task = emit_task_log(
337337
connect_server,
338338
guid,
339339
task_id,
@@ -347,8 +347,8 @@ def write_log(line: str):
347347
if build_store.aborted():
348348
return
349349

350-
build_store.set_content_item_last_build_task_result(guid, task_status)
351-
if task_status["code"] != 0:
350+
build_store.set_content_item_last_build_task_result(guid, task)
351+
if task["code"] != 0:
352352
logger.error("Build failed: %s" % guid)
353353
build_store.set_content_item_build_status(guid, BuildStatus.ERROR)
354354
else:

rsconnect/api.py

+54-62
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
PyInfo,
7373
ServerSettings,
7474
TaskStatusV0,
75+
TaskStatusV1,
7576
UserRecord,
7677
)
7778
from .timeouts import get_task_timeout, get_task_timeout_help_message
@@ -393,12 +394,26 @@ def system_caches_runtime_delete(self, target: DeleteInputDTO) -> DeleteOutputDT
393394
response = self._server.handle_bad_response(response)
394395
return response
395396

396-
def task_get(self, task_id: str, first_status: Optional[int] = None) -> TaskStatusV0:
397+
def task_get(
398+
self,
399+
task_id: str,
400+
first: Optional[int] = None,
401+
wait: Optional[int] = None,
402+
) -> TaskStatusV1:
397403
params = None
398-
if first_status is not None:
399-
params = {"first_status": first_status}
400-
response = cast(Union[TaskStatusV0, HTTPResponse], self.get("tasks/%s" % task_id, query_params=params))
404+
if first is not None or wait is not None:
405+
params = {}
406+
if first is not None:
407+
params["first"] = first
408+
if wait is not None:
409+
params["wait"] = wait
410+
response = cast(Union[TaskStatusV1, HTTPResponse], self.get("v1/tasks/%s" % task_id, query_params=params))
401411
response = self._server.handle_bad_response(response)
412+
413+
# compatibility with rsconnect-jupyter
414+
response["status"] = response["output"]
415+
response["last_status"] = response["last"]
416+
402417
return response
403418

404419
def deploy(
@@ -467,76 +482,55 @@ def wait_for_task(
467482
log_callback: Optional[Callable[[str], None]],
468483
abort_func: Callable[[], bool] = lambda: False,
469484
timeout: int = get_task_timeout(),
470-
poll_wait: float = 0.5,
485+
poll_wait: int = 1,
471486
raise_on_error: bool = True,
472-
) -> tuple[list[str] | None, TaskStatusV0]:
487+
) -> tuple[list[str] | None, TaskStatusV1]:
473488
if log_callback is None:
474489
log_lines: list[str] | None = []
475490
log_callback = log_lines.append
476491
else:
477492
log_lines = None
478493

479-
last_status: int | None = None
494+
first: int | None = None
480495
start_time = time.time()
481-
sleep_duration = 0.5
482-
time_slept = 0.0
483496
while True:
484497
if (time.time() - start_time) > timeout:
485498
raise RSConnectException(get_task_timeout_help_message(timeout))
486499
elif abort_func():
487500
raise RSConnectException("Task aborted.")
488501

489-
# we continue the loop so that we can re-check abort_func() in case there was an interrupt (^C),
490-
# otherwise the user would have to wait a full poll_wait cycle before the program would exit.
491-
if time_slept <= poll_wait:
492-
time_slept += sleep_duration
493-
time.sleep(sleep_duration)
494-
continue
495-
else:
496-
time_slept = 0
497-
task_status = self.task_get(task_id, last_status)
498-
last_status = self.output_task_log(task_status, last_status, log_callback)
499-
if task_status["finished"]:
500-
result = task_status.get("result")
501-
if isinstance(result, dict):
502-
data = result.get("data", "")
503-
type = result.get("type", "")
504-
if data or type:
505-
log_callback("%s (%s)" % (data, type))
506-
507-
err = task_status.get("error")
508-
if err:
509-
log_callback("Error from Connect server: " + err)
510-
511-
exit_code = task_status["code"]
512-
if exit_code != 0:
513-
exit_status = "Task exited with status %d." % exit_code
514-
if raise_on_error:
515-
raise RSConnectException(exit_status)
516-
else:
517-
log_callback("Task failed. %s" % exit_status)
518-
return log_lines, task_status
502+
task = self.task_get(task_id, first=first, wait=poll_wait)
503+
self.output_task_log(task, log_callback)
504+
first = task["last"]
505+
if task["finished"]:
506+
result = task.get("result")
507+
if isinstance(result, dict):
508+
data = result.get("data", "")
509+
type = result.get("type", "")
510+
if data or type:
511+
log_callback("%s (%s)" % (data, type))
512+
513+
err = task.get("error")
514+
if err:
515+
log_callback("Error from Connect server: " + err)
516+
517+
exit_code = task["code"]
518+
if exit_code != 0:
519+
exit_status = "Task exited with status %d." % exit_code
520+
if raise_on_error:
521+
raise RSConnectException(exit_status)
522+
else:
523+
log_callback("Task failed. %s" % exit_status)
524+
return log_lines, task
519525

520526
@staticmethod
521527
def output_task_log(
522-
task_status: TaskStatusV0,
523-
last_status: int | None,
528+
task: TaskStatusV1,
524529
log_callback: Callable[[str], None],
525530
):
526-
"""Pipe any new output through the log_callback.
527-
528-
Returns an updated last_status which should be passed into
529-
the next call to output_task_log.
530-
531-
Raises RSConnectException on task failure.
532-
"""
533-
new_last_status = last_status
534-
if task_status["last_status"] != last_status:
535-
for line in task_status["status"]:
536-
log_callback(line)
537-
new_last_status = task_status["last_status"]
538-
539-
return new_last_status
531+
"""Pipe any new output through the log_callback."""
532+
for line in task["output"]:
533+
log_callback(line)
540534

541535

542536
# for backwards compatibility with rsconnect-jupyter
@@ -601,8 +595,6 @@ def __init__(
601595

602596
self.bundle: IO[bytes] | None = None
603597
self.deployed_info: RSConnectClientDeployResult | None = None
604-
self.result: DeleteOutputDTO | None = None
605-
self.task_status: TaskStatusV0 | None = None
606598

607599
self.logger: logging.Logger | None = logger
608600
self.ctx = ctx
@@ -954,7 +946,7 @@ def emit_task_log(
954946
log_callback: logging.Logger = connect_logger,
955947
abort_func: Callable[[], bool] = lambda: False,
956948
timeout: int = get_task_timeout(),
957-
poll_wait: float = 0.5,
949+
poll_wait: int = 1,
958950
raise_on_error: bool = True,
959951
):
960952
"""
@@ -1207,10 +1199,10 @@ def delete_runtime_cache(self, language: Optional[str], version: Optional[str],
12071199
self.result = result
12081200
if result["task_id"] is None:
12091201
print("Dry run finished")
1202+
return result, None
12101203
else:
1211-
(_, task_status) = self.client.wait_for_task(result["task_id"], connect_logger.info, raise_on_error=False)
1212-
self.task_status = task_status
1213-
return self
1204+
(_, task) = self.client.wait_for_task(result["task_id"], connect_logger.info, raise_on_error=False)
1205+
return result, task
12141206

12151207

12161208
class S3Client(HTTPServer):
@@ -1825,7 +1817,7 @@ def emit_task_log(
18251817
log_callback: Optional[Callable[[str], None]],
18261818
abort_func: Callable[[], bool] = lambda: False,
18271819
timeout: int = get_task_timeout(),
1828-
poll_wait: float = 0.5,
1820+
poll_wait: int = 1,
18291821
raise_on_error: bool = True,
18301822
):
18311823
"""

rsconnect/main.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -2690,9 +2690,9 @@ def get_build_logs(
26902690
@click.option("--all", is_flag=True, help="Build all content, even if it is already marked as COMPLETE.")
26912691
@click.option(
26922692
"--poll-wait",
2693-
type=click.FloatRange(min=0.5, clamp=True),
2694-
default=2,
2695-
help="Defines the number of seconds between polls when polling for build output. Defaults to 2.",
2693+
type=click.IntRange(min=1, clamp=True),
2694+
default=1,
2695+
help="Defines the number of seconds between polls when polling for build output. Defaults to 1.",
26962696
)
26972697
@click.option(
26982698
"--format",
@@ -2720,7 +2720,7 @@ def start_content_build(
27202720
running: bool,
27212721
retry: bool,
27222722
all: bool,
2723-
poll_wait: float,
2723+
poll_wait: int,
27242724
format: LogOutputFormat.All,
27252725
debug: bool,
27262726
verbose: int,

rsconnect/metadata.py

+6-8
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
from .exception import RSConnectException
3434
from .log import logger
35-
from .models import AppMode, AppModes, ContentItemV1, TaskStatusResult, TaskStatusV0
35+
from .models import AppMode, AppModes, ContentItemV1, TaskStatusResult, TaskStatusV1
3636

3737
T = TypeVar("T", bound=Mapping[str, object])
3838

@@ -544,21 +544,20 @@ def resolve(self, server: str, app_id: Optional[str], app_mode: Optional[AppMode
544544
DEFAULT_BUILD_DIR = join(os.getcwd(), "rsconnect-build")
545545

546546

547-
# A trimmed version of TaskStatusV0 which doesn't contain `status` and `last_status` fields.
548-
class TaskStatusV0Trimmed(TypedDict):
547+
# A trimmed version of TaskStatusV1 which doesn't contain `output` and `last` fields.
548+
class TaskStatusV1Trimmed(TypedDict):
549549
id: str
550550
finished: bool
551551
code: int
552552
error: str
553-
user_id: int
554553
result: TaskStatusResult | None
555554

556555

557556
class ContentItemWithBuildState(ContentItemV1, TypedDict):
558557
rsconnect_build_status: str
559558
rsconnect_last_build_time: NotRequired[str]
560559
rsconnect_last_build_log: NotRequired[str | None]
561-
rsconnect_build_task_result: NotRequired[TaskStatusV0Trimmed]
560+
rsconnect_build_task_result: NotRequired[TaskStatusV1Trimmed]
562561

563562

564563
class ContentBuildStoreData(TypedDict):
@@ -745,7 +744,7 @@ def update_content_item_last_build_log(self, guid: str, log_file: str | None, de
745744
if not defer_save:
746745
self.save()
747746

748-
def set_content_item_last_build_task_result(self, guid: str, task: TaskStatusV0, defer_save: bool = False) -> None:
747+
def set_content_item_last_build_task_result(self, guid: str, task: TaskStatusV1, defer_save: bool = False) -> None:
749748
"""
750749
Set the latest task_result for a content build
751750
"""
@@ -754,12 +753,11 @@ def set_content_item_last_build_task_result(self, guid: str, task: TaskStatusV0,
754753
# status contains the log lines for the build. We have already recorded these in the
755754
# log file on disk so we can remove them from the task result before storing it
756755
# to reduce the data stored in our state-file.
757-
task_copy: TaskStatusV0Trimmed = {
756+
task_copy: TaskStatusV1Trimmed = {
758757
"id": task["id"],
759758
"finished": task["finished"],
760759
"code": task["code"],
761760
"error": task["error"],
762-
"user_id": task["user_id"],
763761
"result": task["result"],
764762
}
765763
content["rsconnect_build_task_result"] = task_copy

rsconnect/models.py

+4
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,10 @@ class TaskStatusV1(TypedDict):
552552
last: int
553553
result: TaskStatusResult | None
554554

555+
# redundant fields for compatibility with rsconnect-python.
556+
last_status: int
557+
status: list[str]
558+
555559

556560
class BootstrapOutputDTO(TypedDict):
557561
api_key: str

0 commit comments

Comments
 (0)