From d4b3799634e2588600615b0937069886bb40cc10 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:12 +0800 Subject: [PATCH 1/7] Add website.html fixture Task: MIL-002#1 Closes #9 --- tests/fixtures/.gitkeep | 0 tests/fixtures/website.html | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+) delete mode 100644 tests/fixtures/.gitkeep create mode 100644 tests/fixtures/website.html diff --git a/tests/fixtures/.gitkeep b/tests/fixtures/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/website.html b/tests/fixtures/website.html new file mode 100644 index 0000000..ce4dfbb --- /dev/null +++ b/tests/fixtures/website.html @@ -0,0 +1,26 @@ + + + + + + Angela's Personal Site + + + +

Angela Yu

+

Founder of The App Brewery.

+

I am an iOS and Web Developer. I love coffee and motorcycles.

+
+

Books and Teaching

+ +
+

Other Pages

+ My Hobbies + Contact Me + + + \ No newline at end of file -- 2.54.0 From 142e7aaf2cd411806616c5bae0dd3797623c2888 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:13 +0800 Subject: [PATCH 2/7] Add soup creation, tag access, find and select helpers Task: MIL-002#2 Task: MIL-002#3 Closes #10 Closes #11 --- src/scrape_movies/domain/link.py | 15 +++ .../infrastructure/soup_basics.py | 107 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 src/scrape_movies/domain/link.py create mode 100644 src/scrape_movies/infrastructure/soup_basics.py diff --git a/src/scrape_movies/domain/link.py b/src/scrape_movies/domain/link.py new file mode 100644 index 0000000..286d670 --- /dev/null +++ b/src/scrape_movies/domain/link.py @@ -0,0 +1,15 @@ +"""@brief The Link value object: the text and the address of an anchor.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Link: + """@brief A hyperlink found on a page. + + @param text The visible text of the anchor. + @param url The value of its `href` attribute. + """ + + text: str + url: str diff --git a/src/scrape_movies/infrastructure/soup_basics.py b/src/scrape_movies/infrastructure/soup_basics.py new file mode 100644 index 0000000..e2e8495 --- /dev/null +++ b/src/scrape_movies/infrastructure/soup_basics.py @@ -0,0 +1,107 @@ +"""@brief BeautifulSoup basics from the "Parsing HTML and Making Soup" lectures.""" + +from pathlib import Path + +from bs4 import BeautifulSoup, Tag + +from scrape_movies.constants import HTML_PARSER +from scrape_movies.domain.link import Link + + +def load_html(path: Path) -> str: + """@brief Read an HTML file as text. + + @param path The file to read. + @return The content of the file. + """ + return path.read_text(encoding="utf-8") + + +def make_soup(html: str) -> BeautifulSoup: + """@brief Parse HTML text into a soup object. + + @param html The HTML document as text. + @return The parsed document. + """ + return BeautifulSoup(html, HTML_PARSER) + + +def get_title(soup: BeautifulSoup) -> str | None: + """@brief Return the text of the `title` tag. + + @param soup The parsed document. + @return The title text, or None when the page has no title. + """ + if soup.title is None or soup.title.string is None: + return None + return str(soup.title.string) + + +def prettify_html(soup: BeautifulSoup) -> str: + """@brief Return the document indented one tag per line. + + @param soup The parsed document. + @return The indented HTML. + """ + return str(soup.prettify()) + + +def find_all_anchors(soup: BeautifulSoup) -> list[Link]: + """@brief Find every anchor tag with its text and address (`find_all`). + + @param soup The parsed document. + @return One Link per anchor that has an `href` attribute. + """ + links: list[Link] = [] + for anchor in soup.find_all("a"): + url = anchor.get("href") + if isinstance(url, str): + links.append(Link(text=str(anchor.get_text()), url=url)) + return links + + +def find_text_by_id(soup: BeautifulSoup, element_id: str) -> str | None: + """@brief Return the text of the first tag with the given id (`find`). + + @param soup The parsed document. + @param element_id The value of the `id` attribute. + @return The text, or None when no tag has that id. + """ + tag = soup.find(id=element_id) + return str(tag.get_text()) if isinstance(tag, Tag) else None + + +def find_all_texts_by_class( + soup: BeautifulSoup, tag_name: str, class_name: str +) -> list[str]: + """@brief Return the text of every tag with the given class (`find_all`). + + The keyword is `class_` because `class` is a reserved word in Python. + + @param soup The parsed document. + @param tag_name The tag to look for, for example `h3`. + @param class_name The CSS class the tag must have. + @return The texts in page order. + """ + return [str(tag.get_text()) for tag in soup.find_all(tag_name, class_=class_name)] + + +def select_one_text(soup: BeautifulSoup, selector: str) -> str | None: + """@brief Return the text of the first tag matching a CSS selector. + + @param soup The parsed document. + @param selector A CSS selector such as `p a` or `#name`. + @return The text, or None when nothing matches. + """ + tag = soup.select_one(selector) + return str(tag.get_text()) if tag is not None else None + + +def select_texts(soup: BeautifulSoup, selector: str) -> list[str]: + """@brief Return the text of every tag matching a CSS selector. + + @param soup The parsed document. + @param selector A CSS selector such as `.heading`. + @return The texts in page order. + """ + return [str(tag.get_text()) for tag in soup.select(selector)] -- 2.54.0 From f05b1d75e8fcf640ec3ee286a94ce4e22ee7ce06 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:15 +0800 Subject: [PATCH 3/7] Add command-line entry, screenshot tools and soup screenshots Task: MIL-002#5 Closes #13 --- docs/images/soup-1-title.png | Bin 0 -> 4572 bytes docs/images/soup-2-prettify.png | Bin 0 -> 51467 bytes docs/images/soup-3-find-all.png | Bin 0 -> 12357 bytes docs/images/soup-4-find.png | Bin 0 -> 9394 bytes docs/images/soup-5-select.png | Bin 0 -> 9203 bytes pyproject.toml | 5 ++ src/scrape_movies/__main__.py | 7 ++ src/scrape_movies/presentation/cli.py | 36 +++++++++ src/scrape_movies/presentation/soup_steps.py | 55 ++++++++++++++ tools/make_screenshots.py | 55 ++++++++++++++ tools/render_terminal_png.py | 75 +++++++++++++++++++ 11 files changed, 233 insertions(+) create mode 100644 docs/images/soup-1-title.png create mode 100644 docs/images/soup-2-prettify.png create mode 100644 docs/images/soup-3-find-all.png create mode 100644 docs/images/soup-4-find.png create mode 100644 docs/images/soup-5-select.png create mode 100644 src/scrape_movies/__main__.py create mode 100644 src/scrape_movies/presentation/cli.py create mode 100644 src/scrape_movies/presentation/soup_steps.py create mode 100644 tools/make_screenshots.py create mode 100644 tools/render_terminal_png.py diff --git a/docs/images/soup-1-title.png b/docs/images/soup-1-title.png new file mode 100644 index 0000000000000000000000000000000000000000..c3a207da06d8798a9a545f660e6f6f21a42b08ec GIT binary patch literal 4572 zcmb_gXH-+&wmo1$R00Zu6hTmFHj03B1ZfgLdQ$?@dkMXZfFd0XJ%aSk2U0_`A-(qm zf^7-JN&ttBGpBvix31+DJwnIgdnnC z;J57sah^f%p2w)iu~b+qmdB58i7zd%@>&^?g6D^wEEK^~vALjGlAc=2nN3lLMRa_vfgrEZ=uGikWbMH&H@cxIUy(4mE(t+l*ZScG-BQ^pmC61Jf)@M& zOweVK%nqJz&g_3Ik&)*ixy#nNhbn5$^oJ?|u(V@&g)=gNZ`EBurvH|amo9l~Ppw9K zpXrVR-8OREkNMoRJ{cq@5j+~#Vt+L~`R_}5i;@+@H>t$}=jlGh;s3ctx`9B_6jMSG zzQo$c7)uv*620QsJq58dUj*fX%J2|$9P)p~{09o`nPiaH#s3BD>Qsa0rcECi1Yus^ z`Y#Cn`@8=U>@T0M_kUly9B8wn(j>wNLCd*8tR}*l^))Fz&$6l-kD@v1B1kHX% zDk~}R@$(ZJk|3|`I)`z1wi;SY$a7<|HG<(_g=L0^*5&$6Up48`4GW+98`wIbImcW) z<_Pz)=%Im;Q4EvNvzs)#gyJt+p2z&1Cb1&+dA25=eB6>!!Sol-jwX~AR~DV}lDd9i za|nHT%_-NRpiiq?SKNGkeYJEFrY<*7jImy)<{#Cz(}zVyM>hwYU@9uMA{hlEqoVpU zq}kZm$_K1}#;~znyLOG0)mU5msk9&;pYuSLJUcu4CHi|8V6eQ+c&DPcy zwxi@l{a{<0A~)UXKF-N^f6d0uE}Oe^eEcyV-*aA6YFb*cc2ReC_lFN3Mn`phm;#Tt z-iC*FfRG`wGn*}>X5RxxB0G#zHIzs988TIH>kf zVgirvJ-jcdgKurMz|}b17;}}8`4WFeCGjBwKR(WldRCo8%W>=0&i1xgGlywd2$O4p zg!g9+UKDl&W7YSuSg(9zx&>k7-Ei&r`cPPpHq|zF<;OCM<+l9C4+1?Vwj314)L#^O zblRs+G&TC()7$?RdPz_~V6@bTD^4jZD=YEAOX5Nj!no0^qND`QiyCYwleroDEVr<* z(6*Y`Z}za|csudF(ecSiTB;aZDJZU$xp|3k6Lxe|SXg*}d!Z{OC52ADX>ZhM?q^iQ z`}ai!1l>iYf>k>Lm+Hqe zJNNFD6cj9fr+w(nPL4}R*c4lEMA-4?}ysp$@xtxJ=#B+0%=L}Gk=Jg(L$hD9_!i(5rS#mQ+iCnsli_EnTZLPkd5?2qt? z&sKJd3kwT(Zr^_N=+TG7L|a9I+uAt)y3Te%0r&cef`USLc=*BQG($uy=pi8?Av$>~ zrbq7@tMc+VKRsVwTeG*c93_0p%*@Qo%ex`&p{=9ie$v53A7S?DRd0rLxo&aaN6Di} zWZ%F6K%SG6vq-xrF)gLUxqN0bhtr2dYT#?A8#zwcqV8Z=`yC6ME zPft%REv@%iEH`f4U}4eJ(sFO6|2vDX@EtJHI4wFI1A{_;r9$mgK+xpm{uVEfbOj@$G^HO!7hb=9J%yjfVhs@p z^%iSYRVshE{Nv-}6V4Aq$CuIhwkF~m8hmBe{XNmA$C(FatC!+PmI-epcVv? zlamAI>FMr{j66N9$8D`>)uPz=OoC9Tk@sy?F}$c}=maaDuK7+Y3yV_yif>(AtN9rL z+gn@p&NIQiV;p_4&*jMTG{i)hVa&p8&c3a-@Q0YOr#H$Wk)>=JX~O)rj98!s$F@J*~=&}5?g}PnC}3Y z*#^c&JmnzPN}`#C4b(;K9kYG>@5_bh2-ACEJ!o5mo7zM zu~-a74Cr@%f4}MXu!=%IA0HnRlgzZVH2uj-8RlaKhgDToakmuDQ&3FTBNcrGQX_jV zH;l$csb#k(l$Vsaxw=*cz#k;xu-MAUzbJh$rbyP220KPC|FaIS9A;1J#r<)|r0x;a zO8i5WqDuX9yFbEdTS%wAQ3_fb8Wj$>nrMbqW@cvf45{^{r2{axJehK}dFN(l$;rus z-@K9MxRTa2IxsK*hMrN4J)?m6JIz8k96r1xV1n?PixL6Dh#>d5sHo@+1uR_@85U4b zSNG=)wUj=PFnp8movX@i>Mk@tT3Z1ti0yH=K!;CEOtiMP+VhfnM5ci#l)P*h#bQ&X zxW%1+1?|(4v&7y-fJQ*0?eM}d_mG*!{LR4mJz zo0|keJ(!`!1~;Lg8ROf-$AujiJ6BMQAz@)ahFY?+WFI7f=b%&IiTcIREZmXPWL-tT ziO&aypt1bN5R_RyL%shWP3^x}vOZTC>0=BKU<*d_X)+zpLs`YtM?I-tv`Pbk|m|5v#_zXn0yTm7(Pliq9W9o zDl034iMi`*b;vMCh4fth-a^qsae{d$uw)~#w=o6;b7p3Sl9E#L#P;;p*Ed}7War~Q zF4WZ2u38281q`733xILylRC2R&wNr=R%U8ydNn*mmAdqFb#Dye>EZ%JoagRcI~yB< zr|+HV-OXFpUc4)-tAvjsV7DaG^2SEMCcY_wOC38t-uV05r4M9ht2|Qb>+6B~;qmxk z!JeTZK`yQ(;@;|1o}k31Th_bsmJ@C?d|Qp2K{B( zlL}@J(&(tPv{c-4Lz@@nTJJ|XJ>gRSXfE16H1s($^B0hmXSsvlzh~# z2dZ^-b)|`WI*z@b6np1+o9?2xaNDa?3DYyb8dv#IdR9U{E5LpTTJ=gG7b88s8KyU& z`uKk(&c7gM@{cpvm$&5o(7WFaFKwWlDyN+4d=ng;qgnXk0cgg{7cS^GdTrU2H{}KD zVX_s0(sp`I`;X!AQan7$OH#Mulz^dZ@jvpww3fbotJv23f`zlM)Ig~IM3mO?#*b>a zs%kzwMF7Z8lkdUcK`d8a6`3=BFS)a;lfdD-AWn2;6u@|RIOQZ!frVV>w8 zS6_|htlHo-ThTSv)ZFg8TMjJmVlP@;Af*H_psGqcLt)WSD-Ui^ZF{XNB9WDq73f1$ zR1}>&@ZMkw3>-M8Ni6*{BSJ(7dSh7BJC7=8r>sre*m2~hos$!3%nSe?9Ucx?JTuv2 z74)_C_L3J2lx&aZgOn;-6u4TA;t@cR_E>gcNQ`P7*M}36(n2pWl<8M+@bmNEzpuKK z(9_d{Mx&!5BgJaSlZ71&H8ro+b|4*fdzw$Y%iU-;_tbDK9!<>!=b3^2-+S=D!`azn zXWr5CE&UrO<+8E1wPYg4*yt#}9@#cAVZfQ-zc-?XZEgp|uK`1{FUcLpa5(e*?Z(Ci z0Fv})E=F96iIsI@ad971ltf%DpYnJt6>wbF>!6XL;f9#+?CiX&SLP;Z0Z%rq4IQ-F zX6gPYDbB^kB`R89Sg4#mupIbWo|~==9EZ3|VDsPA2ndi$V;{;>1(+Z(ZiV~L9kEq5 zSFT(!Yl~hVo=!ZOcQ5ctvlO4ajDfX8wvW%}}qz{|hfng(mow@pzGmyyPDK6)Vvw=-@c5Fr{>*^-9w2-<} z1VP~_U@Qfih0!;}^VHLjz?)pTrO9cE%dM1w$pREs&^oHa}MIy`E#>T7+44|Zq@)7jmPqQefSzln>csMw+5Prig;9I{K xMO(q2>z6Zh6yOfxU*!6~U03|SY@N*~K`U0Uk9vlSUf`w#QkGYHS|)1}{5P16?8X29 literal 0 HcmV?d00001 diff --git a/docs/images/soup-2-prettify.png b/docs/images/soup-2-prettify.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9a1361993c9ea3d3c2d84b23aca56874581d44 GIT binary patch literal 51467 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 UtBpF&Cl#4Ae)_mz&3VrM3%+O|)&Kwi literal 0 HcmV?d00001 diff --git a/docs/images/soup-3-find-all.png b/docs/images/soup-3-find-all.png new file mode 100644 index 0000000000000000000000000000000000000000..9751f45b7c8c11365c2fc455299ce97e205725f8 GIT binary patch literal 12357 zcmch7Wn7i}wlyjW2kB-}f^0xqB&1v_A=2F?C8a3cC`gx-NSBm!2+|-(h;)}oNlP~@ z?!BnEy|$u3ndyYB$E-3#Z+^Yk>dK zzwHLP2RBmv1vHE&;0%|Yx`HNSj#TXZS6wiK2mnAR~O(y223_eSm6;(hE38Q9++{J zsNo^dTLl9R?ZwQsL3mNh?Z119yo&9_Gm$DPxoYCneZo?b`s8e==*h(HQdeB+iQQOB zeM|Kf_@n{TltSUGt?1|T0?~!Rtlc?om(kD~SmioYvN&?XyB(HG5QkRq>%HThB zJ3)9L#>hE;>T`(DVEAF;?IRIm52jFf%Tz#Pn(PcBU#!lia7yz zc~w$_N=$d#8UNcVHwjdIce>PlM1-i`i4Wuf>`;TT~DgMYAAM zcEl+HUUQf383rRf{x9P7r%;A&E+l)_t)5>r8WCpb-`vB!_YjZ2zrFwCQ(4OIH=^(AtxpR;9LW48`0YS0b;nrleBR?M>E*_rz zd%cf5UY(}Ng7$%K1ZV>f?X91>*&Zbpk&EbzArhrphJ#vTN9wcsEov>bo-zZCRA^C; z4GsH-_xm_5plvLZ?<-DjY&^5JEX>bZ+dl4&z4dmBuY`$2oW9o^1MjU&W>ywcWP4s- zp0LwKiVlb8@mhtlk`f^i(fUNCcfIm8{5;K4Miv%}-Q_{Jn}eW*uEZur;9Wk5>^Yf|NG@QHJ+eDb?q@<)uIc3?|i=TY1F5WU< zlvNwvx=O||QSDf0!|CVi>+9$DKtjU7(Q(^Jc&5bC#%3#DyCMonpeQOvY02-xT3$B%XN#|Iwv}T#t*}KlvT0`GxAX=Y>YNOa-TQy0twcYQExAQRyftm6-Q4 z^2H}cM3g{wU4IZdFf_!+!vhg^!++OnCQMUUR8*9ewJ_(u|IO8UxImYVj;^*gJ?vU- zO^uY4l%b)am6cUrU*FGfwXXADNU)pkpblmaw->VBz2o8K6%Z7BI$qw{+&p)@Uf&u+ zh>0#PE>6j3RrKM*Dclrf0i=VM`AP`?H?t>CWTmC0B_%)FE_6Xvqvm7uX$0*sYb$>K z_)+e0dbBoPafUixPbbWej#hPXagmjkb#tpxVWA!@fAi)|dwaX4re=lp)VE@z2Rb@B z_wQfyeIzF*2Z!+R;ls7HwSt0zWcR(%+19W^{rXTdXY3{~FRxEcP4!31xfpmfw*>`% zjuaa`dsg)EqsGelQStKfhS7*@?e6}jlo%E!51-=b$bU43m*(rwH&8I&`^Y<`z+0(U(DR`(q{6Wc`{}PuHO>aqv%Qb0d zV$36#uJB%O;j@XEgD&-T@;J%}&7$}3S*~ACho79BoV+}%_K4pORu;OG^~gww5?(TMWc9uTwZsGd+>C>l<4pt_ntbzhLju>@SRT5(2 zgQFut>B%a4R#sMN#z+yjgV!2T>9-zt;X5W~ynBaVNJ&m^{P;1_buaj{A(UKEZ7ntO zrjMSswKZp=oTGySA1^PpDlsYP(D-|)_!^f%%7 z`S@DoS#PL|rYdleze8L=cJK^+|8AhJPKHW_ggUqs^8WpMF?vM>1qEYcV^L9W4K~Km zjMddu1SRDlb>`0QECpvvr2hQYHDgmM#eLA zMV4?$9yPs5#&qqvvN9EBVw=hdTF;Z-*4726grNh?MFjmA{=G>hM3Y2NLlP!SI$+R5 zTiarLB-3n&p4|xAi`wNJ+@|2ih>o+U{jxo~A^W%y^V|Lu9pAp`u|&^=0k&PedKJ(V z&H)m~$j~rqDnQ>Oy?=>ecw%VC_Yt{Y6J3iKu3!J?sK98!SxlHg>8PHb-sa{eckC)0 z0lBY;`;lFv*JU2W6CKB7>!~{3R0ReGhO!SIaPaUJe*CboIt18>m3b2r6O)`=7{h+Q z!p+mOUW4t&rjvT1p1rN@c&%$i3yDQuY89WDawT!qmbbTeGY5fpV?*~{GwfBe%+gY# zCN94C5Tr@z=s|BOP1BVZ>N`wxd9it@F4VA&eyNV}sMe5_DS@r6Enj@6VX<0)&eHVs z^R2muX_9QDwzm_WjF(5p#6UvJIOhN;Oiw@Z^z^)b{W^DY-_VenuI_0g2JP+Jw~I9L zev^K)ad<++Bqvio#Xb#ivb?eanCxG>G&5s}>qkUMc|2xW7(VrxkDq@XvSf7??ldGc zG)cggG4>572L~x8IzS4;x?xZ8d{AJZX?GGe0m0^6JJC!UH6j)f`b3mlg59UUOE~*)^(p{N^59x(*1C2J|!ikGoC+8ONu_+*whqS ziiDUL7diI45Z53_q&>(pI=#7NkT&6 zq^PSq*_9~N1Ymf&r|pJJ{CJ6o4jL>YAL2zYnP9VjJO>wWoZpyEcR{P zCF`j{Q`ke#49_4iebIi_(~Y#JprG)1_3C4FHLakXBquAdUNpN}c5#$n4No3%zaN}& zLqh`u_}#mAEiDqYc|?nlQ@L)ZK;FW_!u)<*MynxY;sW~nBIjbJDX{PVJ*&)GP+=kr z@c&2D1y$L6JyR}nXJ_Z6q@-DvIGaqhtQtv`Ws{8=6uSwW=y{JC8HOg1e8)XdHh6As zu42lgx~K?Wq_Q*=LP^S|@#0MW1kgcIODirWhF@So^zGMwlfyT~{WWxfjG^)Il)jHx znD*}xU-BTU!VBv9YlM>h43QUU{AfM{fJ$tD-)6>(tE5pYJ2R%zWl>9)KjlcK%8#>vNT?%}ro(axynJ7Xk(_3b+WBygB@L4D_0ZDYl}RZ$Q95!di{EW9R4R z4_IHmdkekF&hyeDJx(SnZ7*NG>_4Qi zuW4O>>hO#G(BI6-xqD5c)KpeQMMWNOG$%@ef{JRQ-m_ke{-KnVpxeO)T%+C6yg$JK zrT@eAjl;_X;)vs*?gb2VbPa9o{w2Fg9U3aCJRPD`1)!O4!^2Z;q>!GTXZjw8;`2BB z6vh2fF0�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 literal 0 HcmV?d00001 diff --git a/docs/images/soup-5-select.png b/docs/images/soup-5-select.png new file mode 100644 index 0000000000000000000000000000000000000000..4fa5d3d54bac03d35569730da5ace8743545328c GIT binary patch literal 9203 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

p$WDU}^cGYuuqpDY_g8m(@5a8ex}JqUgK$ z+>vkD(D3u1V_j2;Po4gm@@V(AxVI2HyIou8Ey7|+P^Zua!42$+XN6}BD3>*L5|TuK zF8qF;J9`3MmQ{!mY-2T=6peK9H>`^m+i5Ew{V$i6mL9y)q=|-cv#{8kdQboqCPxtlW$%latz^u`bZ+QA(JY{9YFl(h0_29}d4bGRE*!b%wdamQaY{L836Jb31fOAJVWoURRL}bnq}K&0f3l6x7f4Ny ziFt_-7i9nAQ=6h;av*w#?`(L#_%xs0H#{~it-n!nI#^t^{ zsWZWC#x6X6mn+zL^uKvhAMNra%TrS$;YmQ^&J^|2sRmw40VMD2+3z3(coqs{2I8{a zu+eR8W?((5vit${2i^ndO4X(Etd=0W^EHF>l)fG3R$;<3VX%*cg+0vIE_YBj+6o;1 znm}EAmP7Z>S?mo4ho!e<{`cQ|{;@tmeOZzRnF2rWa4+v!Eb;(hqc8O@f8p?Nf28vN f=Uaw3+vk`1486kXN2B~;b!f5=6{Sig3}5~q#f0~r literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 352ca05..7e5cd5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "ruff>=0.6", "mypy>=1.11", "types-requests>=2.32", + "pillow>=10.4", ] [tool.setuptools.packages.find] @@ -46,3 +47,7 @@ ignore = ["D203", "D213"] strict = true python_version = "3.13" mypy_path = "src" + +[[tool.mypy.overrides]] +module = ["bs4.*", "requests.*", "PIL.*", "pytest.*"] +ignore_missing_imports = true diff --git a/src/scrape_movies/__main__.py b/src/scrape_movies/__main__.py new file mode 100644 index 0000000..3c37d48 --- /dev/null +++ b/src/scrape_movies/__main__.py @@ -0,0 +1,7 @@ +"""@brief Allow `python -m scrape_movies`.""" + +import sys + +from scrape_movies.presentation.cli import main + +sys.exit(main()) diff --git a/src/scrape_movies/presentation/cli.py b/src/scrape_movies/presentation/cli.py new file mode 100644 index 0000000..5ed8ed1 --- /dev/null +++ b/src/scrape_movies/presentation/cli.py @@ -0,0 +1,36 @@ +"""@brief Command-line entry point that runs the lecture steps.""" + +import argparse +from collections.abc import Sequence + +from scrape_movies.presentation import soup_steps + + +def build_parser() -> argparse.ArgumentParser: + """@brief Build the argument parser. + + @return The parser with one sub-command per lecture. + """ + parser = argparse.ArgumentParser( + prog="python -m scrape_movies", + description="Day 45 of 100 Days of Code: web scraping exercises.", + ) + commands = parser.add_subparsers(dest="command") + soup = commands.add_parser("soup", help="parse the sample page website.html") + soup.add_argument("step", choices=list(soup_steps.STEPS)) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """@brief Run the command given on the command line. + + @param argv The arguments, or None to read `sys.argv`. + @return The process exit code. + """ + parser = build_parser() + args = parser.parse_args(argv) + if args.command == "soup": + print(soup_steps.render_step(args.step)) + return 0 + parser.print_help() + return 0 diff --git a/src/scrape_movies/presentation/soup_steps.py b/src/scrape_movies/presentation/soup_steps.py new file mode 100644 index 0000000..0368319 --- /dev/null +++ b/src/scrape_movies/presentation/soup_steps.py @@ -0,0 +1,55 @@ +"""@brief Text output of each "Parsing HTML and Making Soup" lecture step.""" + +from collections.abc import Callable + +from bs4 import BeautifulSoup + +from scrape_movies.constants import WEBSITE_HTML_PATH +from scrape_movies.infrastructure import soup_basics + + +def _title(soup: BeautifulSoup) -> str: + return f"soup.title.string -> {soup_basics.get_title(soup)}" + + +def _prettify(soup: BeautifulSoup) -> str: + return soup_basics.prettify_html(soup).rstrip() + + +def _find_all(soup: BeautifulSoup) -> str: + return "\n".join( + f"{link.text} -> {link.url}" for link in soup_basics.find_all_anchors(soup) + ) + + +def _find(soup: BeautifulSoup) -> str: + by_id = soup_basics.find_text_by_id(soup, "name") + by_class = soup_basics.find_all_texts_by_class(soup, "h3", "heading") + return f'find(id="name") -> {by_id}\nfind_all("h3", class_="heading") -> {by_class}' + + +def _select(soup: BeautifulSoup) -> str: + first = soup_basics.select_one_text(soup, "p a") + every = soup_basics.select_texts(soup, ".heading") + return f'select_one("p a") -> {first}\nselect(".heading") -> {every}' + + +## The lecture steps by command-line name, in lecture order. +STEPS: dict[str, Callable[[BeautifulSoup], str]] = { + "title": _title, + "prettify": _prettify, + "find-all": _find_all, + "find": _find, + "select": _select, +} + + +def render_step(step: str) -> str: + """@brief Run one lecture step against the sample page. + + @param step A key of STEPS. + @return The text the step prints. + @throws KeyError When the step name is unknown. + """ + soup = soup_basics.make_soup(soup_basics.load_html(WEBSITE_HTML_PATH)) + return STEPS[step](soup) diff --git a/tools/make_screenshots.py b/tools/make_screenshots.py new file mode 100644 index 0000000..990a025 --- /dev/null +++ b/tools/make_screenshots.py @@ -0,0 +1,55 @@ +"""@brief Run the lecture steps and save one terminal screenshot per step. + +Usage: python tools/make_screenshots.py [name-prefix] +The images are written to docs/images/ and used by the README and the wiki. +""" + +import subprocess +import sys +from pathlib import Path + +from render_terminal_png import render + +ROOT = Path(__file__).resolve().parents[1] +IMAGES = ROOT / "docs" / "images" + +## File name (without .png) and the arguments given to `python -m scrape_movies`. +STEPS: dict[str, list[str]] = { + "soup-1-title": ["soup", "title"], + "soup-2-prettify": ["soup", "prettify"], + "soup-3-find-all": ["soup", "find-all"], + "soup-4-find": ["soup", "find"], + "soup-5-select": ["soup", "select"], +} + + +def capture(name: str, args: list[str]) -> Path: + """@brief Run one step and write its screenshot. + + @param name The image name without extension. + @param args The arguments for `python -m scrape_movies`. + @return The path of the PNG. + """ + result = subprocess.run( + [sys.executable, "-m", "scrape_movies", *args], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + target = IMAGES / f"{name}.png" + render("python -m scrape_movies " + " ".join(args), result.stdout, target) + return target + + +def main() -> None: + """@brief Capture every step whose name starts with the given prefix.""" + prefix = sys.argv[1] if len(sys.argv) > 1 else "" + for name, args in STEPS.items(): + if name.startswith(prefix): + print(capture(name, args)) + + +if __name__ == "__main__": + main() diff --git a/tools/render_terminal_png.py b/tools/render_terminal_png.py new file mode 100644 index 0000000..b11fe6a --- /dev/null +++ b/tools/render_terminal_png.py @@ -0,0 +1,75 @@ +"""@brief Render a command and its output as a terminal-style PNG screenshot. + +The screenshots of the README and the wiki are made with this script, so no +desktop is captured. Needs Pillow (`pip install -e ".[dev]"`). +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +BACKGROUND = (30, 30, 30) +TITLE_BAR = (50, 50, 50) +TEXT = (212, 212, 212) +PROMPT = (96, 200, 96) +FONT_SIZE = 16 +PADDING = 16 +BAR_HEIGHT = 30 +MAX_COLUMNS = 110 +MAX_LINES = 40 +FONT_FILES = ( + "consola.ttf", + "DejaVuSansMono.ttf", + "LiberationMono-Regular.ttf", + "Menlo.ttc", +) + + +def _load_font() -> ImageFont.ImageFont | ImageFont.FreeTypeFont: + for name in FONT_FILES: + try: + return ImageFont.truetype(name, FONT_SIZE) + except OSError: + continue + return ImageFont.load_default() + + +def _fit(lines: list[str]) -> list[str]: + fitted = [ + line if len(line) <= MAX_COLUMNS else line[: MAX_COLUMNS - 3] + "..." + for line in lines + ] + if len(fitted) > MAX_LINES: + fitted = fitted[:MAX_LINES] + ["..."] + return fitted + + +def render(command: str, output: str, target: Path) -> None: + """@brief Draw the command and its output and save the PNG. + + @param command The command line shown after the prompt. + @param output The text the command printed. + @param target The PNG file to write. + """ + font = _load_font() + lines = _fit(output.rstrip().splitlines()) + prompt = "$ " + columns = max([len(prompt + command), *(len(line) for line in lines)]) + char_width = font.getlength("M") + line_height = FONT_SIZE + 6 + width = int(columns * char_width) + 2 * PADDING + height = BAR_HEIGHT + 2 * PADDING + line_height * (len(lines) + 1) + image = Image.new("RGB", (max(width, 480), height), BACKGROUND) + draw = ImageDraw.Draw(image) + draw.rectangle((0, 0, image.width, BAR_HEIGHT), fill=TITLE_BAR) + for index, color in enumerate(((255, 95, 86), (255, 189, 46), (39, 201, 63))): + x = PADDING + index * 22 + draw.ellipse((x, 9, x + 12, 21), fill=color) + y = BAR_HEIGHT + PADDING + draw.text((PADDING, y), prompt, font=font, fill=PROMPT) + draw.text((PADDING + font.getlength(prompt), y), command, font=font, fill=TEXT) + for line in lines: + y += line_height + draw.text((PADDING, y), line, font=font, fill=TEXT) + target.parent.mkdir(parents=True, exist_ok=True) + image.save(target) -- 2.54.0 From dd5a4b369a1ed9c51396c23d0ae976dc432a7b4c Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:16 +0800 Subject: [PATCH 4/7] Add tests for the parsing steps Task: MIL-002#4 Closes #12 --- tests/unit/test_cli.py | 24 +++++++++++++ tests/unit/test_soup_basics.py | 65 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/unit/test_cli.py create mode 100644 tests/unit/test_soup_basics.py diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py new file mode 100644 index 0000000..dd83807 --- /dev/null +++ b/tests/unit/test_cli.py @@ -0,0 +1,24 @@ +import pytest + +from scrape_movies.presentation import cli, soup_steps + + +def test_main_when_soup_step_is_given_prints_output( + capsys: pytest.CaptureFixture[str], +) -> None: + for step in soup_steps.STEPS: + assert cli.main(["soup", step]) == 0 + assert capsys.readouterr().out.strip(), step + + +def test_main_when_no_command_prints_help( + capsys: pytest.CaptureFixture[str], +) -> None: + assert cli.main([]) == 0 + assert "usage:" in capsys.readouterr().out + + +def test_render_step_when_title_returns_page_title() -> None: + assert soup_steps.render_step("title") == ( + "soup.title.string -> Angela's Personal Site" + ) diff --git a/tests/unit/test_soup_basics.py b/tests/unit/test_soup_basics.py new file mode 100644 index 0000000..3a3125e --- /dev/null +++ b/tests/unit/test_soup_basics.py @@ -0,0 +1,65 @@ +from bs4 import BeautifulSoup + +from scrape_movies.constants import WEBSITE_HTML_PATH +from scrape_movies.domain.link import Link +from scrape_movies.infrastructure import soup_basics + + +def _soup() -> BeautifulSoup: + return soup_basics.make_soup(soup_basics.load_html(WEBSITE_HTML_PATH)) + + +def test_get_title_when_page_has_title_returns_text() -> None: + assert soup_basics.get_title(_soup()) == "Angela's Personal Site" + + +def test_get_title_when_page_has_no_title_returns_none() -> None: + assert soup_basics.get_title(soup_basics.make_soup("

x

")) is None + + +def test_prettify_html_when_called_puts_tags_on_own_lines() -> None: + pretty = soup_basics.prettify_html(soup_basics.make_soup("

x

")) + assert pretty.splitlines()[:3] == ["

", " ", " x"] + + +def test_find_all_anchors_when_page_has_links_returns_text_and_url() -> None: + links = soup_basics.find_all_anchors(_soup()) + assert links[0] == Link("The App Brewery", "https://www.appbrewery.co/") + assert [link.text for link in links] == [ + "The App Brewery", + "My Hobbies", + "Contact Me", + ] + + +def test_find_all_anchors_when_anchor_has_no_href_skips_it() -> None: + soup = soup_basics.make_soup("no linkx") + assert soup_basics.find_all_anchors(soup) == [Link("x", "/x")] + + +def test_find_text_by_id_when_id_exists_returns_text() -> None: + assert soup_basics.find_text_by_id(_soup(), "name") == "Angela Yu" + + +def test_find_text_by_id_when_id_is_missing_returns_none() -> None: + assert soup_basics.find_text_by_id(_soup(), "nobody") is None + + +def test_find_all_texts_by_class_when_class_matches_returns_texts() -> None: + texts = soup_basics.find_all_texts_by_class(_soup(), "h3", "heading") + assert texts == ["Books and Teaching", "Other Pages"] + + +def test_select_one_text_when_nested_selector_returns_first_match() -> None: + assert soup_basics.select_one_text(_soup(), "p a") == "The App Brewery" + + +def test_select_one_text_when_nothing_matches_returns_none() -> None: + assert soup_basics.select_one_text(_soup(), "table") is None + + +def test_select_texts_when_class_selector_returns_all_matches() -> None: + assert soup_basics.select_texts(_soup(), ".heading") == [ + "Books and Teaching", + "Other Pages", + ] -- 2.54.0 From e672e5fb3684e4eae7f01eb965097c0476478ddd Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:18 +0800 Subject: [PATCH 5/7] Document the parsing steps in README Task: MIL-002#6 Closes #14 --- README.md | 258 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 150 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index 45cbac4..74211cb 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,150 @@ -# ๐Ÿš€ scrape-movies - -Day 45 of Udemy's "100 Days of Code: The Complete Python Pro Bootcamp": web scraping with Requests and BeautifulSoup. It parses a local HTML page, scrapes the Hacker News front page and writes Empire's 100 greatest movies to `movies.txt`. It is for fellow coursists and anyone looking for a small, tested scraping example. - -## ๐Ÿ“š Table of Contents - -- [Overview](#-overview) -- [Requirements](#-requirements) -- [Setup](#-setup) -- [Run](#-run) -- [Tests](#-tests) -- [License](#-license) -- [Links](#-links) -- [Appendix: Project documents](#-appendix-project-documents) - -## ๐Ÿงญ Overview - -| Part | What it is for | -| --- | --- | -| `src/scrape_movies/` | The code in clean-architecture layers (`domain`, `application`, `infrastructure`, `presentation`) plus `constants.py`; the lecture steps `soup_basics`, `hacker_news` and the movies scraper follow milestone by milestone | -| `tests/` | pytest tests that run offline against saved pages in `tests/fixtures/` | -| `docs/` | Project documents (business case, plan, milestones, reviews) and the wiki in English, Danish and Bahasa Malaysia | -| `Doxyfile` | Generates the API pages from the Doxygen comments in the source | - -The only runtime dependencies are `requests` and `beautifulsoup4`. - -## ๐Ÿ“‹ Requirements - -- Python 3.13 or newer (the `venv` module comes with it). -- `requests` and `beautifulsoup4` (installed by the setup below). -- Optional: [Doxygen](https://www.doxygen.nl/) to build the API pages. - -## ๐Ÿ› ๏ธ Setup - -Create a local virtual environment named `.venv`, update `pip`, then install the project with its test tools. - -Windows (PowerShell): - -```powershell -python -m venv .venv -.venv\Scripts\Activate.ps1 -python -m pip install --upgrade pip -python -m pip install -e ".[dev]" -``` - -Linux and macOS: - -```bash -python3 -m venv .venv -source .venv/bin/activate -python -m pip install --upgrade pip -python -m pip install -e ".[dev]" -``` - -Leave out `[dev]` to install only the runtime dependencies. Personal tokens belong in a git-ignored `.env`; the project never reads it. - -## โ–ถ๏ธ Run - -The lecture steps are added milestone by milestone; each gets a section with its command, its output and a screenshot here. - -```bash -python -m scrape_movies # available from the movies milestone -``` - -## ๐Ÿงช Tests - -```bash -python -m pytest -``` - -The tests use saved pages and never touch the network. Lint and types: `ruff check .`, `ruff format --check .` and `mypy src tests`. API pages: `doxygen Doxyfile`. - -## ๐Ÿ“„ License - -See [LICENSE](./LICENSE). - -## ๐Ÿ”— Links - -- [Repository][repository] -- [Documentation][documentation] -- [Issue tracker][issues] - -## ๐Ÿ“Ž Appendix: Project documents - -| Document | What it tells you | -| --- | --- | -| [AGENTS.md][agents] | The rules AI agents follow in this repository | -| [Business Case][BC-001] | Why the project exists, its scope and success criteria | -| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers | -| [Project Plan][PP-001] | The gateways and their schedule | -| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks | -| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain | -| [Framework README][framework-readme] | The framework this project uses | -| [Framework changelog][framework-changelog] | What changed in each framework release | - ---- - -[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies -[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/wiki -[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/issues -[agents]: ./AGENTS.md -[BC-001]: ./docs/business-case.md -[SA-001]: ./docs/stakeholder-analysis.md -[PP-001]: ./docs/project-plan.md -[milestones]: ./docs/milestones/ -[registry]: ./docs/artifact-registry.md -[framework-readme]: ./framework/README.md -[framework-changelog]: ./framework/CHANGELOG.md +# ๐Ÿš€ scrape-movies + +Day 45 of Udemy's "100 Days of Code: The Complete Python Pro Bootcamp": web scraping with Requests and BeautifulSoup. It parses a local HTML page, scrapes the Hacker News front page and writes Empire's 100 greatest movies to `movies.txt`. It is for fellow coursists and anyone looking for a small, tested scraping example. + +## ๐Ÿ“š Table of Contents + +- [Overview](#-overview) +- [Requirements](#-requirements) +- [Setup](#-setup) +- [Run](#-run) +- [Tests](#-tests) +- [License](#-license) +- [Links](#-links) +- [Appendix: Project documents](#-appendix-project-documents) + +## ๐Ÿงญ Overview + +| Part | What it is for | +| --- | --- | +| `src/scrape_movies/` | The code in clean-architecture layers (`domain`, `application`, `infrastructure`, `presentation`) plus `constants.py`; the lecture steps `soup_basics`, `hacker_news` and the movies scraper follow milestone by milestone | +| `tests/` | pytest tests that run offline against saved pages in `tests/fixtures/` | +| `docs/` | Project documents (business case, plan, milestones, reviews) and the wiki in English, Danish and Bahasa Malaysia | +| `Doxyfile` | Generates the API pages from the Doxygen comments in the source | + +The only runtime dependencies are `requests` and `beautifulsoup4`. + +## ๐Ÿ“‹ Requirements + +- Python 3.13 or newer (the `venv` module comes with it). +- `requests` and `beautifulsoup4` (installed by the setup below). +- Optional: [Doxygen](https://www.doxygen.nl/) to build the API pages. + +## ๐Ÿ› ๏ธ Setup + +Create a local virtual environment named `.venv`, update `pip`, then install the project with its test tools. + +Windows (PowerShell): + +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` + +Linux and macOS: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -e ".[dev]" +``` + +Leave out `[dev]` to install only the runtime dependencies. Personal tokens belong in a git-ignored `.env`; the project never reads it. + +## โ–ถ๏ธ Run + +Every lecture step is a command of the form `python -m scrape_movies `; without arguments the help is printed. Each step below shows the command, what it teaches and a screenshot of its output. + +### Lecture: Parsing HTML and making soup + +The sample page is `tests/fixtures/website.html`; the code is `src/scrape_movies/infrastructure/soup_basics.py`. + +**1. Create a soup and read the title** (`BeautifulSoup(html, "html.parser")`, `soup.title.string`) + +```bash +python -m scrape_movies soup title +``` + +![Step 1: soup title](docs/images/soup-1-title.png) + +**2. Prettify** (`soup.prettify()` indents one tag per line) + +```bash +python -m scrape_movies soup prettify +``` + +![Step 2: prettify](docs/images/soup-2-prettify.png) + +**3. Find all tags** (`find_all("a")`, then `get_text()` and `get("href")` in a loop) + +```bash +python -m scrape_movies soup find-all +``` + +![Step 3: find_all](docs/images/soup-3-find-all.png) + +**4. Find by id and by class** (`find(id=...)`, `find_all("h3", class_=...)`; `class_` because `class` is a Python keyword) + +```bash +python -m scrape_movies soup find +``` + +![Step 4: find by attribute](docs/images/soup-4-find.png) + +**5. CSS selectors** (`select_one("p a")` and `select(".heading")`) + +```bash +python -m scrape_movies soup select +``` + +![Step 5: select](docs/images/soup-5-select.png) + +The screenshots are made by `python tools/make_screenshots.py`, which renders the command output as an image; no desktop is captured. + +## ๐Ÿงช Tests + +```bash +python -m pytest +``` + +The tests use saved pages and never touch the network. Lint and types: `ruff check .`, `ruff format --check .` and `mypy src tests`. API pages: `doxygen Doxyfile`. + +## ๐Ÿ“„ License + +See [LICENSE](./LICENSE). + +## ๐Ÿ”— Links + +- [Repository][repository] +- [Documentation][documentation] +- [Issue tracker][issues] + +## ๐Ÿ“Ž Appendix: Project documents + +| Document | What it tells you | +| --- | --- | +| [AGENTS.md][agents] | The rules AI agents follow in this repository | +| [Business Case][BC-001] | Why the project exists, its scope and success criteria | +| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers | +| [Project Plan][PP-001] | The gateways and their schedule | +| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks | +| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain | +| [Framework README][framework-readme] | The framework this project uses | +| [Framework changelog][framework-changelog] | What changed in each framework release | + +--- + +[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies +[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/wiki +[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/issues +[agents]: ./AGENTS.md +[BC-001]: ./docs/business-case.md +[SA-001]: ./docs/stakeholder-analysis.md +[PP-001]: ./docs/project-plan.md +[milestones]: ./docs/milestones/ +[registry]: ./docs/artifact-registry.md +[framework-readme]: ./framework/README.md +[framework-changelog]: ./framework/CHANGELOG.md -- 2.54.0 From fda38c906e3d470482ae1cc4a8c032991f7efe26 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:43 +0800 Subject: [PATCH 6/7] Add code review record for MIL-002 --- docs/artifact-registry.md | 2 +- docs/sqa/reviews/rc-012-mil-002-python.md | 61 +++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 docs/sqa/reviews/rc-012-mil-002-python.md diff --git a/docs/artifact-registry.md b/docs/artifact-registry.md index 3fe8fa4..7cdb474 100644 --- a/docs/artifact-registry.md +++ b/docs/artifact-registry.md @@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g. | SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 | | PP | Project Plan | docs/project-plan.md | 002 | | MIL | Milestone / Gateway | docs/milestones/*.md | 006 | -| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 012 | +| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 013 | ## Languages diff --git a/docs/sqa/reviews/rc-012-mil-002-python.md b/docs/sqa/reviews/rc-012-mil-002-python.md new file mode 100644 index 0000000..66f82f3 --- /dev/null +++ b/docs/sqa/reviews/rc-012-mil-002-python.md @@ -0,0 +1,61 @@ +# Review of MIL-002 Python code + +## Metadata +| Key | Value | +| --- | --- | +| ID | RC-012 | +| CrossReference | [MIL-002], [QC-PY-001] | + +## Version History +| Date | Status | Author | Reviewer | Change | Commit | +| --- | --- | --- | --- | --- | --- | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in, autonomous mode) | Initial version | pending | + +--- + +## Artifact Under Review + +- Instance reviewed: [MIL-002] +- Checklist used: [QC-PY-001] +- Scope: full review. Code reviewed: soup_basics, soup_steps, cli, Link, tools/, tests/unit. +- Language and domain: n/a (technical type) +- Language reviewer: none +- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`) + +## Checklist Results + +| # | Criterion | Status | Evidence/Notes | +| --- | --- | --- | --- | +| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | modules and functions are snake_case, classes PascalCase, constants UPPER_SNAKE. | +| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | names state purpose; the lecture names (`find_all`, `select_one`) are kept in the helper names. | +| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` are clean with no `noqa`. | +| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | every signature is annotated; `mypy --strict` passes, also in a venv without the third-party packages. | +| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | N-A | no exception is raised or caught. | +| 6 | No mutable default arguments and no shadowed builtins | Pass | no mutable defaults, no shadowed builtins. | +| 7 | Files, locks and connections are managed with context managers | N-A | no file or connection is opened except through `Path.read_text`. | +| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | every public module, class and function has a Doxygen docstring saying what it does. | +| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | N-A | no logging; the command-line output is the product, written with `print` in the presentation layer. | +| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | no Design Class Diagram; all tasks are plain technical tasks (Needs Use Case: No). | +| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | tests are named for behaviour, offline and independent of order. | +| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` runs over `src`, `tests` and `tools`. | +| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Fail | dependencies are declared with lower bounds, not pinned (see RC-009 action item); none is unused. | +| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | each constant and format is defined once; the step texts are built in one place. | +| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports nothing from other layers; `infrastructure` imports `domain` and `constants`; `presentation` composes them. | +| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | no class hierarchy; the dataclass `Link` has one reason to change. | +| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | code is in `domain`, `infrastructure` and `presentation`; tests are in `tests/unit`. | +| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | all names are English lowercase with `_`, no space. | + +## Overall Verdict + +Go โ€” no Mandatory criterion fails and none is Pending. Stand-in review in autonomous mode, not independent; S01 challenges it in the run summary. + +## Action Items + +| Action | Owner | Due | +| --- | --- | --- | +| Pin dependencies or accept lower bounds (open from RC-009, optional criterion 13) | S01 | 2026-11-14 | + +--- + +[MIL-002]: ../../milestones/mil-002-parse-html.md +[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md -- 2.54.0 From 41a471b9e8d2342126e60c6354ce1cb519b09bd3 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Wed, 28 Oct 2026 14:14:44 +0800 Subject: [PATCH 7/7] Resolve pending commit links --- docs/sqa/reviews/rc-012-mil-002-python.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sqa/reviews/rc-012-mil-002-python.md b/docs/sqa/reviews/rc-012-mil-002-python.md index 66f82f3..be72a35 100644 --- a/docs/sqa/reviews/rc-012-mil-002-python.md +++ b/docs/sqa/reviews/rc-012-mil-002-python.md @@ -9,7 +9,7 @@ ## Version History | Date | Status | Author | Reviewer | Change | Commit | | --- | --- | --- | --- | --- | --- | -| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in, autonomous mode) | Initial version | pending | +| 2026-10-28 | Proposed | Jens Tirsvad Nielsen | S01 (AI as stand-in, autonomous mode) | Initial version | [fda38c9] | --- @@ -59,3 +59,4 @@ Go โ€” no Mandatory criterion fails and none is Pending. Stand-in review in auto [MIL-002]: ../../milestones/mil-002-parse-html.md [QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md +[fda38c9]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/045-scrape-movies/commit/fda38c906e3d470482ae1cc4a8c032991f7efe26 -- 2.54.0