Skip to content

Commit fd9f0b1

Browse files
committed
Update
1 parent 64a2385 commit fd9f0b1

1 file changed

Lines changed: 209 additions & 36 deletions

File tree

app/spec_renderer.py

Lines changed: 209 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import operator
12
import re
23

4+
import requests
35
import streamlit as st
46

57
from app.utils.github_utils import (
@@ -8,6 +10,7 @@
810
fetch_pull_request_payload,
911
fetch_repo_file_text_at_ref,
1012
get_all_github_prs,
13+
get_headers,
1114
)
1215

1316
st.set_page_config(page_title="Spec renderer", page_icon="🔧")
@@ -28,6 +31,47 @@ def filter_spec_prs(prs: list[dict]) -> list[dict]:
2831
return spec_prs
2932

3033

34+
@st.cache_data(ttl=600)
35+
def fetch_merged_specs() -> list[dict]:
36+
"""Fetch list of merged spec folders from the specs directory on develop branch."""
37+
url = "https://api.github.com/repos/streamlit/streamlit/contents/specs"
38+
try:
39+
response = requests.get(url, headers=get_headers(), params={"ref": "develop"}, timeout=30)
40+
if response.status_code != 200:
41+
return []
42+
contents = response.json()
43+
except requests.RequestException:
44+
return []
45+
46+
specs = []
47+
date_pattern = re.compile(r"^(\d{4}-\d{2}-\d{2})-(.+)$")
48+
49+
for item in contents:
50+
if item.get("type") != "dir":
51+
continue
52+
name = item.get("name", "")
53+
match = date_pattern.match(name)
54+
if match:
55+
date_str = match.group(1)
56+
feature_name = match.group(2).replace("-", " ").title()
57+
specs.append(
58+
{
59+
"folder": name,
60+
"date": date_str,
61+
"title": feature_name,
62+
"path": f"specs/{name}/product-spec.md",
63+
}
64+
)
65+
66+
specs.sort(key=operator.itemgetter("date"), reverse=True)
67+
return specs
68+
69+
70+
def fetch_merged_spec_content(spec_path: str) -> tuple[str | None, str | None]:
71+
"""Fetch the content of a merged spec file from the develop branch."""
72+
return fetch_repo_file_text_at_ref("streamlit/streamlit", spec_path, "develop")
73+
74+
3175
@st.cache_data(ttl=300)
3276
def fetch_pr_files(pr_number: int) -> tuple[list[dict], str | None]:
3377
"""Fetch files changed in a PR."""
@@ -141,11 +185,13 @@ def fetch_issue_details(issue_number: int) -> tuple[dict | None, str | None]:
141185

142186
def replace_issue_references_with_previews(markdown_content: str) -> str:
143187
"""Replace issue references with styled previews."""
144-
# Pattern for standalone issue numbers like #1234
145-
standalone_pattern = r"(?<!\[)#(\d+)(?!\])"
146-
147188
# Pattern for issue links like [#12331](https://github.com/streamlit/streamlit/issues/12331)
148-
link_pattern = r"\[#(\d+)\]\(https://github\.com/streamlit/streamlit/issues/\d+\)"
189+
# Also handles optional trailing content in URL (query params, anchors)
190+
link_pattern = r"\[#(\d+)\]\(https://github\.com/streamlit/streamlit/issues/\d+[^)]*\)"
191+
192+
# Pattern for standalone issue numbers like #1234
193+
# Must not be preceded by '[' and must not be followed by ']' or '('
194+
standalone_pattern = r"(?<!\[)#(\d+)(?![\]\(])"
149195

150196
def create_issue_preview(issue_number: int) -> str:
151197
"""Create a styled issue preview using only markdown features."""
@@ -183,11 +229,11 @@ def replace_issue_link(match: re.Match) -> str:
183229
issue_number = int(match.group(1))
184230
return create_issue_preview(issue_number)
185231

186-
# Replace standalone issue references first
187-
updated_content = re.sub(standalone_pattern, replace_standalone_issue, markdown_content)
232+
# Replace issue links FIRST (before standalone), so we don't corrupt the link syntax
233+
updated_content = re.sub(link_pattern, replace_issue_link, markdown_content)
188234

189-
# Then replace issue links
190-
updated_content = re.sub(link_pattern, replace_issue_link, updated_content)
235+
# Then replace standalone issue references
236+
updated_content = re.sub(standalone_pattern, replace_standalone_issue, updated_content)
191237

192238
return updated_content
193239

@@ -231,20 +277,33 @@ def extract_frontmatter(markdown_content: str) -> tuple[dict | None, str]:
231277
return frontmatter, content_without_frontmatter
232278

233279

280+
def is_likely_github_username(name: str) -> bool:
281+
"""Check if a string looks like a GitHub username."""
282+
if not name or len(name) > 39:
283+
return False
284+
# GitHub usernames: alphanumeric and hyphens, can't start/end with hyphen
285+
github_username_pattern = r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$"
286+
return bool(re.match(github_username_pattern, name))
287+
288+
234289
def render_frontmatter_caption(frontmatter: dict) -> None:
235290
"""Render frontmatter as caption."""
236291
if not frontmatter:
237292
return
238293

239294
info_parts = []
240295

241-
# Add author (with link if it's a GitHub mention)
296+
# Add author (with link if it's a GitHub mention or looks like a username)
242297
if "author" in frontmatter:
243298
author = frontmatter["author"].strip("\"'") # Remove surrounding quotes
244299
if author.startswith("@"):
245300
username = author[1:] # Remove @
246301
author_link = f"[@{username}](https://github.com/{username})"
247302
info_parts.append(f"By {author_link}")
303+
elif is_likely_github_username(author):
304+
# Treat as GitHub username even without @
305+
author_link = f"[@{author}](https://github.com/{author})"
306+
info_parts.append(f"By {author_link}")
248307
else:
249308
info_parts.append(f"By {author}")
250309

@@ -291,18 +350,73 @@ def extract_title_and_content(markdown_content: str) -> tuple[str | None, str]:
291350
return title, remaining_content
292351

293352

294-
def main() -> None:
295-
title_row = st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center")
296-
with title_row:
297-
st.title("🔧 Spec renderer")
298-
if st.button(":material/refresh: Refresh Data", type="tertiary"):
299-
get_all_github_prs.clear()
300-
fetch_pr_files.clear()
301-
fetch_pull_request_payload.clear()
302-
fetch_repo_file_text_at_ref.clear()
303-
fetch_issue_payload.clear()
304-
st.markdown("Read product specs from the Streamlit repo. So far only supports PRs, not merged specs.")
353+
def replace_local_images_with_github_urls_for_merged_spec(markdown_content: str, spec_file_path: str) -> str:
354+
"""Replace local image references with GitHub raw URLs for merged specs on develop branch."""
355+
# Get the directory of the spec file to resolve relative paths
356+
spec_dir = "/".join(spec_file_path.split("/")[:-1]) # Remove filename, keep directory
357+
358+
# Pattern to match markdown images with local paths
359+
image_pattern = r"!\[([^\]]*)\]\((?!https?://)([^)]+)\)"
360+
361+
def replace_image(match: re.Match) -> str:
362+
alt_text = match.group(1)
363+
image_path = match.group(2)
364+
365+
# Handle relative paths
366+
if image_path.startswith("./"):
367+
clean_path = image_path[2:]
368+
full_path = f"{spec_dir}/{clean_path}" if spec_dir else clean_path
369+
elif image_path.startswith("../"):
370+
dir_parts = spec_dir.split("/") if spec_dir else []
371+
path_parts = image_path.split("/")
372+
up_count = sum(1 for part in path_parts if part == "..")
373+
remaining_parts = [part for part in path_parts if part != ".."]
374+
final_dir_parts = dir_parts[:-up_count] if up_count <= len(dir_parts) else []
375+
full_path = "/".join(final_dir_parts + remaining_parts)
376+
else:
377+
full_path = f"{spec_dir}/{image_path}" if spec_dir else image_path
378+
379+
# Create GitHub raw URL for develop branch
380+
github_url = f"https://raw.githubusercontent.com/streamlit/streamlit/develop/{full_path}"
381+
return f"![{alt_text}]({github_url})"
305382

383+
return re.sub(image_pattern, replace_image, markdown_content)
384+
385+
386+
def render_spec_content(
387+
markdown_content: str,
388+
spec_file_path: str,
389+
pr_number: int | None = None,
390+
) -> None:
391+
"""Render spec content with all transformations applied."""
392+
# Extract frontmatter
393+
frontmatter, content_without_frontmatter = extract_frontmatter(markdown_content)
394+
395+
# Extract title and remaining content
396+
title, remaining_content = extract_title_and_content(content_without_frontmatter)
397+
398+
# Render title if found
399+
if title:
400+
st.markdown(f"# {title}")
401+
402+
# Render frontmatter as caption below title
403+
if frontmatter:
404+
render_frontmatter_caption(frontmatter)
405+
406+
# Replace local image references with GitHub URLs
407+
if pr_number is not None:
408+
processed_content = replace_local_images_with_github_urls(remaining_content, pr_number, spec_file_path)
409+
else:
410+
processed_content = replace_local_images_with_github_urls_for_merged_spec(remaining_content, spec_file_path)
411+
412+
# Replace issue references with styled previews
413+
processed_content = replace_issue_references_with_previews(processed_content)
414+
415+
st.markdown(processed_content)
416+
417+
418+
def render_open_spec_prs() -> None:
419+
"""Render the open spec PRs tab."""
306420
# Get query parameters
307421
query_params = st.query_params
308422
pr_param = query_params.get("pr", None)
@@ -393,29 +507,88 @@ def main() -> None:
393507
return
394508

395509
if markdown_content:
396-
# Extract frontmatter
397-
frontmatter, content_without_frontmatter = extract_frontmatter(markdown_content)
510+
render_spec_content(markdown_content, spec_file, pr_number)
511+
else:
512+
st.error("Failed to fetch markdown content.")
513+
398514

399-
# Extract title and remaining content
400-
title, remaining_content = extract_title_and_content(content_without_frontmatter)
515+
def render_merged_specs() -> None:
516+
"""Render the merged specs tab."""
517+
query_params = st.query_params
518+
spec_param = query_params.get("spec", None)
401519

402-
# Render title if found
403-
if title:
404-
st.markdown(f"# {title}")
520+
merged_specs = fetch_merged_specs()
521+
522+
if not merged_specs:
523+
st.info("No merged specs found in the repository.")
524+
return
405525

406-
# Render frontmatter as caption below title
407-
if frontmatter:
408-
render_frontmatter_caption(frontmatter)
526+
# Create selectbox options
527+
spec_options = {}
528+
for spec in merged_specs:
529+
label = f"{spec['date']} - {spec['title']}"
530+
spec_options[label] = spec
409531

410-
# Replace local image references with GitHub URLs
411-
processed_content = replace_local_images_with_github_urls(remaining_content, pr_number, spec_file)
532+
# Determine default index based on query parameter
533+
default_index = None
534+
if spec_param:
535+
for i, spec in enumerate(merged_specs):
536+
if spec["folder"] == spec_param:
537+
default_index = i
538+
break
412539

413-
# Replace issue references with styled previews
414-
processed_content = replace_issue_references_with_previews(processed_content)
540+
selected_option = st.selectbox(
541+
"Select a merged spec:",
542+
options=list(spec_options.keys()),
543+
index=default_index,
544+
key="merged_spec_selector",
545+
)
415546

416-
st.markdown(processed_content)
547+
if selected_option:
548+
selected_spec = spec_options[selected_option]
549+
550+
# Update query parameter
551+
if (
552+
"last_selected_spec" not in st.session_state
553+
or st.session_state.last_selected_spec != selected_spec["folder"]
554+
):
555+
st.query_params["spec"] = selected_spec["folder"]
556+
st.session_state.last_selected_spec = selected_spec["folder"]
557+
558+
# Fetch and render spec content
559+
with st.spinner("Fetching spec content..."):
560+
markdown_content, error = fetch_merged_spec_content(selected_spec["path"])
561+
562+
if error:
563+
st.error(error)
564+
return
565+
566+
if markdown_content:
567+
render_spec_content(markdown_content, selected_spec["path"])
417568
else:
418-
st.error("Failed to fetch markdown content.")
569+
st.warning(f"Could not find `product-spec.md` in `{selected_spec['folder']}`.")
570+
571+
572+
def main() -> None:
573+
title_row = st.container(horizontal=True, horizontal_alignment="distribute", vertical_alignment="center")
574+
with title_row:
575+
st.title("🔧 Spec renderer")
576+
if st.button(":material/refresh: Refresh Data", type="tertiary"):
577+
get_all_github_prs.clear()
578+
fetch_pr_files.clear()
579+
fetch_pull_request_payload.clear()
580+
fetch_repo_file_text_at_ref.clear()
581+
fetch_issue_payload.clear()
582+
fetch_merged_specs.clear()
583+
st.markdown("Read product specs from the Streamlit repo.")
584+
585+
tab_open, tab_merged = st.tabs(["Open PRs", "Merged Specs"])
586+
587+
with tab_open:
588+
render_open_spec_prs()
589+
590+
with tab_merged:
591+
render_merged_specs()
419592

420593

421594
main()

0 commit comments

Comments
 (0)