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

feat: content render and restart #236

Merged
merged 19 commits into from
Jul 24, 2024
Merged

Conversation

tdstein
Copy link
Collaborator

@tdstein tdstein commented Jul 22, 2024

Add support for content.restart() and content.refresh().

Copy link

github-actions bot commented Jul 22, 2024

☂️ Python Coverage

current status: ✅

Overall Coverage

Lines Covered Coverage Threshold Status
1226 1157 94% 0% 🟢

New Files

File Coverage Status
src/posit/connect/env.py 100% 🟢
src/posit/connect/variants.py 100% 🟢
TOTAL 100% 🟢

Modified Files

File Coverage Status
src/posit/connect/content.py 100% 🟢
TOTAL 100% 🟢

updated for commit: cb78d63 by action🐍

@@ -25,6 +25,9 @@ dependencies = ["requests>=2.31.0,<3"]
Source = "https://github.com/posit-dev/posit-sdk-py"
Issues = "https://github.com/posit-dev/posit-sdk-py/issues"

[tool.mypy]
exclude = "integration/resources/*"
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ignore the resources folder with static type checking.

@@ -34,6 +37,7 @@ version_file = "src/posit/_version.py"

[tool.ruff]
line-length = 79
exclude = ["integration/resources/*"]
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ignore the resources folder when linting

src/posit/connect/content.py Outdated Show resolved Hide resolved
src/posit/connect/content.py Outdated Show resolved Hide resolved
Comment on lines +385 to +387
@property
def _variants(self) -> Variants:
return Variants(self.config, self.session, self.guid)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is intentionally marked as private. We don't want customers to interact directly with the variants API since it isn't a v1 API.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We'll add a v1 version, but even so, you'll still have to fall back to the old APIs when you're on old (well, contemporary) Connect. This is fine for this PR, since I'm guessing you're not implementing all of the Variants features (I haven't read that far yet), but just wanted to put that out there for the future. It's been in production for years, people use it, so our notion of internal vs. public doesn't really matter.

src/posit/connect/env.py Outdated Show resolved Hide resolved
Comment on lines 196 to 215
d: Dict[str, str] = {}
if other is not None:
if isinstance(other, MutableMapping):
d.update(other)
elif isinstance(other, Iterable) and not isinstance(
other, (str, bytes)
):
try:
d.update(other)
except (TypeError, ValueError):
raise TypeError(
f"update expected a {MutableMapping} or {Iterable}, got {type(other)}"
)
else:
raise TypeError(
f"update expected a {MutableMapping} or {Iterable}, got {type(other)}"
)

if kwargs:
d.update(kwargs)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This logic mimics the dict.update behavior.

Comment on lines 44 to 87
@pytest.mark.skipif(
CONNECT_VERSION <= version.parse("2023.01.1"),
reason="Python 3.12 not available",
)
def test_restart(self):
# create content
content = self.client.content.create(name="example-flask-minimal")
# create bundle
path = Path(
"../../../resources/bundles/example-flask-minimal/bundle.tar.gz"
)
path = (Path(__file__).parent / path).resolve()
bundle = content.bundles.create(str(path))
# deploy bundle
task = bundle.deploy()
task.wait_for()
# restart content
content.restart()
# delete content
content.delete()

@pytest.mark.skipif(
CONNECT_VERSION <= version.parse("2023.01.1"),
reason="Quarto not available",
)
def test_refresh(self):
# create content
content = self.client.content.create(name="example-quarto-minimal")
# create bundle
path = Path(
"../../../resources/bundles/example-quarto-minimal/bundle.tar.gz"
)
path = (Path(__file__).parent / path).resolve()
bundle = content.bundles.create(str(path))
# deploy bundle
task = bundle.deploy()
task.wait_for()
# refresh content
task = content.refresh()
if task:
task.wait_for()

# delete content
content.delete()
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm still working on adding support for the skipped versions of Connect, but I'll make a separate change. Theres a bit involved to make it work, so it deserves its own pull request.

@tdstein tdstein marked this pull request as ready for review July 22, 2024 19:44
Comment on lines 285 to 356
def _re_whatever(self) -> Task | None:
"""Submit a re-whatever request (i.e., restart, refresh, etc).

A re-whatever is a catch-all term for restarting, refreshing, or re-whatever-ing the content requires to bounce it to a new state.

refresh:
For content that require variants. Find the default variant and render it again.

restart:
For content that require server threads. Toggle an unique environment variable and open the content, which activates a new server thread.

Returns
-------
Task | None:
A task for the content render when available, otherwise None

Raises
------
RuntimeError
Found an incorrect number of default variants.

Examples
--------
>>> _re_whatever()
"""
# Update the item to its current state.
# The 'app_mode' is not set until a bundle is created and deployed.
# During the deployment process, the 'app_mode' is read from manifest.json and written to the database.
# Until this occurs the 'app_mode' will be 'unknown'.
self.update()

if self.app_mode in {
"rmd-static",
"jupyter-static",
"quarto-static",
}:
variants = self._variants.find()
variants = [variant for variant in variants if variant.is_default]
if len(variants) != 1:
raise RuntimeError(
f"Found {len(variants)} default variants. Expected 1. Without a single default variant, the content cannot be refreshed. This is indicative of a corrupted state."
)
variant = variants[0]
return variant.render()

if self.app_mode in {
"api",
"jupyter-voila",
"python-api",
"python-bokeh",
"python-dash",
"python-fastapi",
"python-shiny",
"python-streamlit",
"quarto-shiny",
"rmd-shiny",
"shiny",
"tensorflow-saved-model",
}:
random_hash = secrets.token_hex(32)
key = f"_CONNECT_RESTART_TMP_{random_hash}"
self.environment_variables.create(key, random_hash)
self.environment_variables.delete(key)
# GET via the base Connect URL to force create a new worker thread.
url = urls.append(dirname(self.config.url), f"content/{self.guid}")
self.session.get(url)
return None

warnings.warn(
f"Content '{self.guid}' with application mode '{self.app_mode}' does not require restarts"
)
return None
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@mmarchetti - I am adding you for this section in particular. This is the crux of the change, and I want to check if the business logic is reasonable. The big caveat to this approach is upgrading the app_mode checks over time to accommodate new application modes. I've been tinkering with adding information to the warning message that directs users to open a GitHub issue as a lazy way to know if this happens.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The big caveat to this approach is upgrading the app_mode checks over time to accommodate new application modes.

We ran into the same thing in connect_api. I wonder if we ought to have a more central place to retrieve this (either on the server, that enumerates what it knows about), or somewhere common in our code we could depend on. Of course, we don't need to / shouldn't solve that here, but it's not unique to this SDK!

Copy link
Collaborator

@toph-allen toph-allen left a comment

Choose a reason for hiding this comment

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

Looks great! ✨

I don't have any specific feedback that should prevent merging — just running thoughts on stuff we've been discussing.

src/posit/connect/content.py Outdated Show resolved Hide resolved
Comment on lines 177 to 185
Submit a refresh request to the server for the content. After submission, the server executes an asynchronous process to refresh the content. This is useful when content is dependent on external information, such as a dataset.

See Also
--------
restart

Notes
-----
This method is identical to `restart` and exists to provide contextual clarity. Both methods produce identical results. When working with documents, natural language prefers "refresh this content" instead of "restart this content" since documents do not require a system process. When writing software that operates on multiple types of content (e.g., applications, documents, scripts, etc.), you may use either 'refresh' or 'restart' to achive the same result.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I like this way of discussing it.

src/posit/connect/content.py Outdated Show resolved Hide resolved
src/posit/connect/content.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@nealrichardson nealrichardson left a comment

Choose a reason for hiding this comment

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

Cool! A few notes.

integration/tests/posit/connect/test_env.py Show resolved Hide resolved

Notes
-----
This method is identical to `refresh` and exists to provide contextual clarity. Both methods produce identical results. When working with applications, natural language prefers "restart this content" instead of "refresh this content" since applications require a system process. When writing software that operates on multiple types of content (e.g., applications, documents, scripts, etc.), you may use either 'restart' or 'refresh' to achieve the same result.
Copy link
Collaborator

Choose a reason for hiding this comment

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

  1. I don't know that "restart" and "refresh" should be identical. I might think you have restart(), which works on interactive content (and errors on static), and render(), which works on static (and errors on interactive), and then refresh() is the generic. Then you have
def refresh(self, **kwargs):
    if self.is_interactive():
        self.restart()
    else:
        self.render(**kwargs) # for variants/params

That way, if you want to be strict about what you're intending (restart or render), you can.

  1. If you want to proceed in the current direction, you don't need _re_whatever(), just define one and have the other call it, you don't need them both to call some third function.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

After discussing, we decided to implement restart and `render' and provide a guide error message if the content type does not support the invoked method.

Comment on lines +385 to +387
@property
def _variants(self) -> Variants:
return Variants(self.config, self.session, self.guid)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We'll add a v1 version, but even so, you'll still have to fall back to the old APIs when you're on old (well, contemporary) Connect. This is fine for this PR, since I'm guessing you're not implementing all of the Variants features (I haven't read that far yet), but just wanted to put that out there for the future. It's been in production for years, people use it, so our notion of internal vs. public doesn't really matter.

src/posit/connect/env.py Outdated Show resolved Hide resolved
@tdstein tdstein force-pushed the tdstein/189-implement-variants-api branch from dceb2b7 to d6ba382 Compare July 23, 2024 14:47
@tdstein tdstein changed the title feat: content refresh and restart feat: content render and restart Jul 24, 2024
@tdstein tdstein merged commit 2132431 into main Jul 24, 2024
31 checks passed
@tdstein tdstein deleted the tdstein/189-implement-variants-api branch July 24, 2024 19:30
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