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

Add st.navigation, st.Page and associated V2 strategies #8673

Merged
merged 16 commits into from
May 16, 2024

Conversation

kmcgrady
Copy link
Collaborator

@kmcgrady kmcgrady commented May 15, 2024

Describe your changes

This change includes st.Page and st.navigation commands bringing us closer to the finish point for MPA v2.

Testing Plan

  • Added associated unit tests
  • E2E Tests will be needed to test MPA end-to-end, but they will not be included in this PR to the feature branch.

Contribution License Agreement

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.

Comment on lines 1352 to 1353
// If the app logo is not associated with the main script, clear it.
if (appLogo && appLogo.activeScriptHash !== mainScriptHash) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

is this something we could already handle on the backend? Like, not allowing to send the logo from anywhere else but the main script? I don't have a super strong opinion here, but it feels a little bit weird on first glimpse to have the page_script stuff being tightly bundled with these different areas, such as logo, on the web app side and think that it might be cleaner to not send it in the first place. It might also help to detach these concepts from each other from the perspective of using the web app more like a lib - the less complex logic, the better 🙂

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah. I think it should be in the frontend. It's essentially the same logic as the clearPageNodes, but the logo is in a special case.

How about logo be in the AppNode as a special variable? That would definitely clean it up.

)
}

clearPageElements(
Copy link
Collaborator

@raethlein raethlein May 15, 2024

Choose a reason for hiding this comment

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

a more telling name might be retainMainScriptElements or filterMainScriptElements if I understood the logic correctly (and based on this comment)


@gather_metrics("navigation")
def navigation(
pages: list[StreamlitPage] | dict[str, list[StreamlitPage]],
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: should the key-type be SectionHeader instead of str?

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 would be fine, but do we need to worry for the docs that come from the types. I'll follow up.

if isinstance(self._page, Path):
h = calc_md5(str(self._page))
else:
h = calc_md5(self.title)
Copy link
Collaborator

Choose a reason for hiding this comment

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

if we calc the hash based on the title, we should perhaps add a check in navigation for title uniqueness and raise an error if multiple pages have the same title (maybe I have overlooked it though or this scenario is not possible)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That's a good point. It's hard cause the path and the title can be different, but also provide the same thing. We would need to detect uniqueness of the hashes. I'll talk to @sfc-gh-jcarroll

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Addressed this.

@kmcgrady kmcgrady marked this pull request as ready for review May 15, 2024 19:03
@kmcgrady kmcgrady requested a review from a team as a code owner May 15, 2024 19:03
Comment on lines 100 to 103
if len(page_list) == 0:
raise StreamlitAPIException(
"`st.navigation` must be called with at least one `st.Page`."
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: the idiomatic way of writing this is just to write if page_list since the empty list is Falsy

"`st.navigation` must be called with at least one `st.Page`."
)

defaults = []
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: This feels like a weird way to write this. I feel like it'd be cleaner to initialize default = None, then set it if we encounter a page with the default attribute set to True or error out if default is already set

Comment on lines 164 to 168
managed_page = ctx.pages_manager.get_page_script(
fallback_page_hash=default_page._script_hash
)

found_page = default_page
Copy link
Collaborator

Choose a reason for hiding this comment

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

managed_page and found_page seem like weird names to me / I feel like had a hard time figuring out what's going on here due to them

Would something like:

  • managed_page -> found_page
  • found_page -> page_to_return

work?

I also feel like it'd be cleaner to write something like

    found_page = ctx.pages_manager.get_page_script(
        fallback_page_hash=default_page._script_hash
    )
    page_to_return = None
    if found_page:
        found_page_script_hash = found_page["page_script_hash"]
        matching_pages = [
            p for p in page_list if p._script_hash == found_page_script_hash
        ]
        if len(matching_pages) > 0:
            page_to_return = matching_pages[0]

    if not page_to_return:
        send_page_not_found(ctx)
        page_to_return = default_page

    # ...

Also related is my comment above about whether the return value of ctx.pages_manager.get_page_script should ever be falsy at this point.

# Inform our page manager about the set of pages we have
ctx.pages_manager.set_pages(pagehash_to_pageinfo)
managed_page = ctx.pages_manager.get_page_script(
fallback_page_hash=default_page._script_hash
Copy link
Collaborator

Choose a reason for hiding this comment

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

(Writing this without double-checking the implementation of get_page_script because I think the comment is valid regardless of exactly what the method's exact behavior is)

Is it possible for ctx.pages_manager.get_page_script to return None? If so, it feels weird to me that we're passing it fallback_page_hash=default_page._script_hash, which I would guess should always allow it to return a value since we're both giving it a fallback and are requesting a page that we know at this point must exist.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yea, perhaps better naming is needed here, but it falls under the weirdness we used for the v1, and is just as complex as v2. We basically search for pages based on:

  1. page_script_hash - if it exists check using this, and otherwise, rely on the default hash, so it should technically always return a page. The edge case is when st.navigation is called with different set of pages. We set the page_script_hash as the page in the first call, but the subsequent calls is with a new set of hashes. We just rely on the new default page identified.
  2. We check by page name - this can return None - This is the direct navigation setup where we rely on the url path to identify the page and will present a Page Not Found
  3. If neither is specified, we get the fallback, it's essentially the default page.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Definitely can write more comments around the call. Perhaps detect_page_script or find_page_script.

Comment on lines +110 to +111
if isinstance(page, Path):
page = (main_path / page).resolve()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this line handle the case where the path is an absolute one? Just looking at this, I would guess that the path constructed here will be incorrect when given an absolute path. If this is the case, we should probably reword the docstring to say the path must be relative rather than it can be.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah. Yes. The spec says it should be relative. I'll note that in the docstring.


def test_run_with_active_hash(self):
"""Ensure the active script is set correctly"""
current_hash = self.pages_manager.main_script_hash
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: I feel like main_script_hash would be a better name here since the hash isn't always current throughout the test (it's not in the run_with_active_hash contextmanager, for example)

export class V1Strategy {
function getTitle(pageName: string): string {
return `${pageName} · Streamlit`
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: newline between function end and new class declaration

@@ -38,7 +42,10 @@ export type PageUrlUpdateCallback = (
) => void
export type PageNotFoundCallback = (pageName?: string) => void

export class V1Strategy {
function getTitle(pageName: string): string {
return `${pageName} · Streamlit`
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: should we just have this return Streamlit when pageName is falsy? The separator is a bit weird otherwise

}
}

export class StrategyV2 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should a common interface be defined here so that we can ensure StrategyV1 and StrategyV2 both comply to it? It seems like it'd be easy in the future to have them desync in ways that slip by code review + CI without one.

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 was thinking that, but the types were StrategyV1 | StrategyV2, so theoretically this must comply with the interface for both, but let me know if you think strongly.

this.hideSidebarNav = position === "hidden"

const currentPage = appPages.find(
p => p.pageScriptHash === navigationMsg.pageScriptHash
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: should we extract pageScriptHash from navigationMsg in the destructor already used above to be used here instead of accessing it from the object?

Comment on lines 259 to 265
try:
yield
except Exception:
self.set_active_script_hash(original_page_hash)
raise
else:
self.set_active_script_hash(original_page_hash)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Worth double checking, but I think writing

try:
    yield
finally:
    self.set_active_script_hash(original_page_hash)

should be equivalent (I think finally can sometimes essentially catch an exception, but only if a control flow statement is used within the finally block)

@kmcgrady kmcgrady merged commit be13fe4 into feature/mpa-v2 May 16, 2024
31 checks passed
@kmcgrady kmcgrady deleted the feature/st-navigation branch May 16, 2024 16:47
kmcgrady added a commit that referenced this pull request May 20, 2024
## Describe your changes

This change includes `st.Page` and `st.navigation` commands bringing us
closer to the finish point for MPA v2.

## Testing Plan

- Added associated unit tests
- E2E Tests will be needed to test MPA end-to-end, but they will not be
included in this PR to the feature branch.

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this
project are made under the Apache 2.0 license.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants