Skip to content

Commit 071124a

Browse files
Yash PrasoonYash Prasoon
authored andcommitted
IHRACP-9134 SPIKE: Implement single-pass XML traversal in pyPodcastParser
1 parent 7cdd0aa commit 071124a

9 files changed

Lines changed: 1058548 additions & 8 deletions

File tree

‎.python-version‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.9.1
1+
3.9.11

‎requirements.txt‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,61 @@
11
#
2-
# This file is autogenerated by pip-compile with Python 3.7
2+
# This file is autogenerated by pip-compile with Python 3.9
33
# by the following command:
44
#
55
# pip-compile requirements.in
66
#
7+
--extra-index-url https://aws:eyJ2ZXIiOjEsImlzdSI6MTc3ODc0OTc0MCwiZW5jIjoiQTEyOEdDTSIsInRhZyI6Imp5X2lFLUdQb3A4aDVDZTVaSW1UaEEiLCJleHAiOjE3Nzg3OTI5NDAsImFsZyI6IkExMjhHQ01LVyIsIml2Ijoib0ZXa19xbHFxOGxLbkZNQSJ9.id5c6rqcfQRj9S3Ygq0CHg.laO_21sEwNuCVssS.wu2v0YutJgIZOsJmaYBO38HQty-lF-RiIqPqcBR04geFTbdqCD9ZZ326UO6na4VxaDjJdVlmeVDz8Ltc3fU37DTtaOYFooeERGJD4RfrCPQE6iPua1pLEZvrH8AwQhYNoa18eIEBGRamOyU26eLFhvSg8hBSy-OiLhNUoFX3tfJhG2OFs9idJUTc1PcdGLIa-uiJdWmHugHtxFAqBAz-TbZJtue1UQBcfsqzD-_pVCxYx0sN3c23mMF-TgH91s_Rljl2MMd8oBxnxpSFy8L5U7yIcNRgx2lcXfgHqRwZAfxjX5Tmvy8tWtIPGyfNNAj0quexS0i5HKUvT0x4vUAJUlfAIdiDmuj6_TyMszey65Fx32DZ8QDcD1IbU40tupFXSSxAJCiYgqRsQwLxYhayhJD0rUUGrM2xDoxK1eOlom7djJpmB07jbvGVua_f8UxGpkouh6N2U9QFI9Uk_QfxV6jtM5AAM2h7fwt-zTTVTfq-6Jq1ZJYXnEKm93_-HzbQm15zhlk42062HoU1Nu8KpSv2q-k7fIrG9dtpiupaDjW4kpDIgHz4JHbH1Q87wvnTfqc22il9wE1E2XrGT3SvO7O8LtduVxDefeqqqvp5sd9CL0gadGI0IKC2QpXGlEAwVlBTo8AdMCigkvJtwfUaumqPw9BnxU4i6LF0sPior1Qk18qXf19IowqJMSrrxmznkLhIWUmsHW7mEqYe4s9qmx8t1vGIkYX0joLHJ-lz9w_wAuiQ-9cBPFzKMRPxownanUeP5OSZkCR6sX77MTeyGn8OGKpif0nwyJ7z0bBjRPlmG-P9_-MsW1XyW_0PqK1MxvVfRJufN54lX8wBYs4pxlWI05_Y2DG3boxTyXn5puvm6_mexeh4BFCUGJXQro1x7P9JOCAEwd0ewBR6W7PrVfiHBbOli2Jt4LHnWTF-YKMppXG80MhH5SDyg33NyJ0nMDXxbgP91j4EJDO8lYcf3qX7KP3bU7pM9cJS1lURADhZNYQKNUjR0exK9OUlJYBv3kOeoMROZ4llSaoHMwX-f1_GvSOmurMHX23zUuUWBo5CHYCA2QhUOtebjm2pjiRbQ6epzjZkLy2FdvMJP6DQ2_lnwwvwUwaOF7cLJBN_P2caCoCYstOdLeAhNa2XB7hNfdpU-8wccCJW1v3Oq8Ak__c75hWUk8jiaO64RYlpXdtCD8QAGztzJveEQu_I3DeSe2PYeh4pQ42jiu28_iuNlayqzhtDIx3myH8gVNVfIvAvN6k1WMvYwfpM97HjJ90cjRQBeTxi_gYxqAxW3AqIOq9BnUBLHDY8lyCR1qQbHzAuAhC7xHeL7D3JGw.dMUaFQ8u1LveQyE1Ef4zCw@content-platform-219619990026.d.codeartifact.us-east-1.amazonaws.com/pypi/content-platform/simple/
8+
79
beautifulsoup4==4.9.1
810
# via -r requirements.in
911
certifi==2020.6.20
1012
# via requests
1113
chardet==3.0.4
1214
# via requests
15+
distlib==0.4.0
16+
# via virtualenv
1317
docopt==0.6.2
1418
# via -r requirements.in
19+
filelock==3.19.1
20+
# via
21+
# python-discovery
22+
# tox
23+
# virtualenv
1524
idna==2.10
1625
# via requests
17-
importlib-metadata==1.7.0
18-
# via pluggy
1926
lxml==4.5.2
2027
# via -r requirements.in
28+
packaging==26.2
29+
# via tox
30+
platformdirs==4.4.0
31+
# via
32+
# python-discovery
33+
# virtualenv
2134
pluggy==0.13.1
22-
# via -r requirements.in
35+
# via
36+
# -r requirements.in
37+
# tox
38+
py==1.11.0
39+
# via tox
40+
python-discovery==1.3.1
41+
# via virtualenv
2342
pytz==2022.7.1
2443
# via -r requirements.in
2544
requests==2.24.0
2645
# via -r requirements.in
46+
six==1.17.0
47+
# via tox
2748
soupsieve==2.0.1
2849
# via beautifulsoup4
50+
toml==0.10.2
51+
# via tox
52+
tox==3.25.0
53+
# via -r requirements.in
54+
typing-extensions==4.15.0
55+
# via virtualenv
2956
urllib3==1.25.9
3057
# via requests
58+
virtualenv==21.3.3
59+
# via tox
3160
wheel==0.24.0
3261
# via -r requirements.in
33-
zipp==3.1.0
34-
# via importlib-metadata
35-
tox==3.25.0

‎scripts/profile_parsing.py‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
"""Profile pyPodcastParser feed parsing.
3+
4+
Supports the IHRACP-8704 / PPP-002 spike: confirm whether the secondary
5+
``find_all("item")`` fallback in ``Podcast.__init__`` actually executes on a
6+
given feed, and measure where parsing time goes.
7+
8+
Usage:
9+
python scripts/profile_parsing.py <feed.rss> [<feed2.rss> ...]
10+
python scripts/profile_parsing.py # defaults to tests/test_feeds/*
11+
12+
For each feed it reports:
13+
* whether the find_all("item") fallback fired (the thing PPP-002 removes)
14+
* item count
15+
* median / min / max wall time over N runs
16+
* a cProfile breakdown of the hottest calls
17+
18+
Run this against `master` first to capture the "before" numbers, then against
19+
the single-pass branch for "after". Same machine, same feed, same N.
20+
"""
21+
22+
import cProfile
23+
import io
24+
import pstats
25+
import statistics
26+
import sys
27+
import time
28+
from pathlib import Path
29+
30+
REPO_ROOT = Path(__file__).resolve().parent.parent
31+
sys.path.insert(0, str(REPO_ROOT))
32+
33+
import bs4 # noqa: E402
34+
from pypodcastparser.Podcast import Podcast # noqa: E402
35+
from pypodcastparser.Error import InvalidPodcastFeed # noqa: E402
36+
37+
import os # noqa: E402
38+
39+
RUNS = int(os.environ.get("PROFILE_RUNS", "50")) # set PROFILE_RUNS=5 for big feeds
40+
41+
42+
def detect_fallback(feed_content):
43+
"""Parse once with find_all instrumented; report if the fallback fired.
44+
45+
The secondary pass in Podcast.__init__ is the only place that calls
46+
BeautifulSoup.find_all during construction, so counting calls tells us
47+
directly whether PPP-002's target code path is exercised by this feed.
48+
"""
49+
original_find_all = bs4.BeautifulSoup.find_all
50+
calls = []
51+
52+
def counting_find_all(self, *args, **kwargs):
53+
calls.append((args, kwargs))
54+
return original_find_all(self, *args, **kwargs)
55+
56+
bs4.BeautifulSoup.find_all = counting_find_all
57+
try:
58+
podcast = Podcast(feed_content)
59+
finally:
60+
bs4.BeautifulSoup.find_all = original_find_all
61+
62+
fallback_fired = any(args and args[0] == "item" for args, _ in calls)
63+
return fallback_fired, len(podcast.items)
64+
65+
66+
def time_runs(feed_content, runs=RUNS):
67+
"""Return wall-clock timings (seconds) for `runs` full Podcast() builds."""
68+
timings = []
69+
for _ in range(runs):
70+
start = time.perf_counter()
71+
Podcast(feed_content)
72+
timings.append(time.perf_counter() - start)
73+
return timings
74+
75+
76+
def profile_once(feed_content, top=15):
77+
"""Return a cProfile breakdown string for a single Podcast() build."""
78+
profiler = cProfile.Profile()
79+
profiler.enable()
80+
Podcast(feed_content)
81+
profiler.disable()
82+
83+
buf = io.StringIO()
84+
stats = pstats.Stats(profiler, stream=buf).sort_stats("cumulative")
85+
stats.print_stats(top)
86+
return buf.getvalue()
87+
88+
89+
def report(feed_path):
90+
feed_content = Path(feed_path).read_bytes()
91+
size_kb = len(feed_content) / 1024
92+
93+
print(f"\n{'=' * 70}")
94+
print(f"FEED: {feed_path} ({size_kb:.1f} KB)")
95+
print(f"{'=' * 70}")
96+
97+
try:
98+
fallback_fired, item_count = detect_fallback(feed_content)
99+
timings = time_runs(feed_content)
100+
except InvalidPodcastFeed as e:
101+
# Intentionally malformed fixtures (e.g. invalid_show_dates.rss) raise
102+
# this from the parser itself. Skip; they're not benchmark candidates.
103+
print(f" [skipped] feed raises InvalidPodcastFeed: {e}")
104+
return
105+
106+
print(f" items : {item_count}")
107+
flag = "YES -- PPP-002 target path is exercised" if fallback_fired else "no"
108+
print(f" find_all('item') fallback fired : {flag}")
109+
print(f" runs : {len(timings)}")
110+
print(
111+
f" median : {statistics.median(timings) * 1000:.3f} ms"
112+
)
113+
print(
114+
f" min / max : "
115+
f"{min(timings) * 1000:.3f} / {max(timings) * 1000:.3f} ms"
116+
)
117+
print(
118+
f" mean : {statistics.mean(timings) * 1000:.3f} ms"
119+
)
120+
print()
121+
print(" cProfile (cumulative, top 15):")
122+
for line in profile_once(feed_content).splitlines():
123+
print(f" {line}")
124+
125+
126+
def main():
127+
args = sys.argv[1:]
128+
if args:
129+
feeds = [Path(a) for a in args]
130+
else:
131+
feeds = sorted((REPO_ROOT / "tests" / "Ian_test_feeds").glob("*.rss"))
132+
print(
133+
f"No feed given; profiling {len(feeds)} test feeds in tests/Ian_test_feeds/"
134+
)
135+
136+
for feed in feeds:
137+
if not feed.exists():
138+
print(f"\n!! skipping missing feed: {feed}")
139+
continue
140+
report(feed)
141+
142+
143+
if __name__ == "__main__":
144+
main()

0 commit comments

Comments
 (0)