From 3bc9436c9735635c00822b9d5ab2fe4eece54146 Mon Sep 17 00:00:00 2001 From: flotchet Date: Thu, 28 Aug 2025 15:18:22 +0200 Subject: [PATCH] add python paper muncher package --- .../python/.doctrees/environment.pickle | Bin 0 -> 19704 bytes .../python/.doctrees/examples.doctree | Bin 0 -> 16566 bytes .../.doctrees/framework_examples.doctree | Bin 0 -> 3790 bytes meta/bindings/python/.doctrees/index.doctree | Bin 0 -> 2531 bytes meta/bindings/python/.doctrees/readme.doctree | Bin 0 -> 27819 bytes meta/bindings/python/README.md | 303 ++++++++++++++++++ .../dist/paper_muncher-0.1.0-py3-none-any.whl | Bin 0 -> 28385 bytes .../python/dist/paper_muncher-0.1.0.tar.gz | Bin 0 -> 15617 bytes .../python/documentation_source/conf.py | 15 + .../python/documentation_source/examples.rst | 70 ++++ .../framework_examples.rst | 66 ++++ .../python/documentation_source/readme.rst | 8 + .../python/examples/async_cm_example.py | 20 ++ .../bindings/python/examples/async_example.py | 20 ++ .../python/examples/auto_mode_cm_example.py | 32 ++ .../python/examples/auto_mode_example.py | 28 ++ .../python/examples/django_asgi_example.py | 46 +++ .../python/examples/django_wsgi_example.py | 42 +++ .../python/examples/fastapi_example.py | 23 ++ .../bindings/python/examples/flask_example.py | 16 + .../bindings/python/examples/quart_example.py | 16 + .../python/examples/sync_cm_example.py | 19 ++ meta/bindings/python/examples/sync_example.py | 18 ++ .../bindings/python/paper_muncher/__init__.py | 4 + .../paper_muncher/asynchronous/__init__.py | 9 + .../paper_muncher/asynchronous/asyncify.py | 14 + .../paper_muncher/asynchronous/interface.py | 293 +++++++++++++++++ .../asynchronous/io_with_timeout.py | 118 +++++++ .../paper_muncher/asynchronous/popen.py | 43 +++ .../paper_muncher/asynchronous/request.py | 92 ++++++ .../paper_muncher/autochronous/__init__.py | 8 + .../paper_muncher/autochronous/interface.py | 18 ++ .../paper_muncher/autochronous/proxy.py | 23 ++ meta/bindings/python/paper_muncher/binary.py | 58 ++++ .../paper_muncher/frameworks/__init__.py | 11 + .../paper_muncher/frameworks/asgi_app.py | 20 ++ .../paper_muncher/frameworks/django_asgi.py | 38 +++ .../paper_muncher/frameworks/django_wsgi.py | 38 +++ .../paper_muncher/frameworks/fastapi.py | 32 ++ .../python/paper_muncher/frameworks/flask.py | 15 + .../python/paper_muncher/frameworks/quart.py | 15 + .../paper_muncher/frameworks/wsgi_app.py | 27 ++ .../python/paper_muncher/runners/asgi.py | 77 +++++ .../python/paper_muncher/runners/wsgi.py | 151 +++++++++ .../paper_muncher/synchronous/__init__.py | 9 + .../paper_muncher/synchronous/interface.py | 274 ++++++++++++++++ .../synchronous/io_with_timeout/__init__.py | 37 +++ .../synchronous/io_with_timeout/common.py | 29 ++ .../fallback/communications.py | 70 ++++ .../io_with_timeout/nt/communications.py | 129 ++++++++ .../io_with_timeout/posix/communications.py | 126 ++++++++ .../python/paper_muncher/synchronous/popen.py | 33 ++ .../paper_muncher/synchronous/request.py | 91 ++++++ meta/bindings/python/paper_muncher/typing.py | 15 + meta/bindings/python/papermuncher.py | 141 -------- meta/bindings/python/pyproject.toml | 31 ++ meta/bindings/python/sample.py | 12 - 57 files changed, 2690 insertions(+), 153 deletions(-) create mode 100644 meta/bindings/python/.doctrees/environment.pickle create mode 100644 meta/bindings/python/.doctrees/examples.doctree create mode 100644 meta/bindings/python/.doctrees/framework_examples.doctree create mode 100644 meta/bindings/python/.doctrees/index.doctree create mode 100644 meta/bindings/python/.doctrees/readme.doctree create mode 100644 meta/bindings/python/README.md create mode 100644 meta/bindings/python/dist/paper_muncher-0.1.0-py3-none-any.whl create mode 100644 meta/bindings/python/dist/paper_muncher-0.1.0.tar.gz create mode 100644 meta/bindings/python/documentation_source/conf.py create mode 100644 meta/bindings/python/documentation_source/examples.rst create mode 100644 meta/bindings/python/documentation_source/framework_examples.rst create mode 100644 meta/bindings/python/documentation_source/readme.rst create mode 100644 meta/bindings/python/examples/async_cm_example.py create mode 100644 meta/bindings/python/examples/async_example.py create mode 100644 meta/bindings/python/examples/auto_mode_cm_example.py create mode 100644 meta/bindings/python/examples/auto_mode_example.py create mode 100644 meta/bindings/python/examples/django_asgi_example.py create mode 100644 meta/bindings/python/examples/django_wsgi_example.py create mode 100644 meta/bindings/python/examples/fastapi_example.py create mode 100644 meta/bindings/python/examples/flask_example.py create mode 100644 meta/bindings/python/examples/quart_example.py create mode 100644 meta/bindings/python/examples/sync_cm_example.py create mode 100644 meta/bindings/python/examples/sync_example.py create mode 100644 meta/bindings/python/paper_muncher/__init__.py create mode 100644 meta/bindings/python/paper_muncher/asynchronous/__init__.py create mode 100644 meta/bindings/python/paper_muncher/asynchronous/asyncify.py create mode 100644 meta/bindings/python/paper_muncher/asynchronous/interface.py create mode 100644 meta/bindings/python/paper_muncher/asynchronous/io_with_timeout.py create mode 100644 meta/bindings/python/paper_muncher/asynchronous/popen.py create mode 100644 meta/bindings/python/paper_muncher/asynchronous/request.py create mode 100644 meta/bindings/python/paper_muncher/autochronous/__init__.py create mode 100644 meta/bindings/python/paper_muncher/autochronous/interface.py create mode 100644 meta/bindings/python/paper_muncher/autochronous/proxy.py create mode 100644 meta/bindings/python/paper_muncher/binary.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/__init__.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/asgi_app.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/django_asgi.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/django_wsgi.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/fastapi.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/flask.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/quart.py create mode 100644 meta/bindings/python/paper_muncher/frameworks/wsgi_app.py create mode 100644 meta/bindings/python/paper_muncher/runners/asgi.py create mode 100644 meta/bindings/python/paper_muncher/runners/wsgi.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/__init__.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/interface.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/io_with_timeout/__init__.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/io_with_timeout/common.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/io_with_timeout/fallback/communications.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/io_with_timeout/nt/communications.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/io_with_timeout/posix/communications.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/popen.py create mode 100644 meta/bindings/python/paper_muncher/synchronous/request.py create mode 100644 meta/bindings/python/paper_muncher/typing.py delete mode 100644 meta/bindings/python/papermuncher.py create mode 100644 meta/bindings/python/pyproject.toml delete mode 100644 meta/bindings/python/sample.py diff --git a/meta/bindings/python/.doctrees/environment.pickle b/meta/bindings/python/.doctrees/environment.pickle new file mode 100644 index 0000000000000000000000000000000000000000..0439daa39a461c437de6a3ccc363ffe1148d30f2 GIT binary patch literal 19704 zcmcIs37A|*ah9~&OWIqj%a_)cuvZp43pTbnVj@|xgjs1VTQ&xKJincJv;E%Ayf=D> zv@2W2+*;GdHXd`|nENJNAs2xhDgV`u^j39^rpMI zySlr&y1J^mdhhLgHQ#J&auQ;ii1fq)6()WVM#$ zWfA0)4Zjhj6ZIlSE}g6gxj#7*#+5Lh%_bWQxk%#4lI|mGzOs2^Q+8!(nYt6ba{m)Y z23!SA&dDC_GB zS&oUPSpafalFsKPzsM7G-IN;)odJyfMp6rsxG6U)s8{#Xxk_?8_GXCiKoZEFnjg;= z{%p{cYXVeVUrWkQr{CeAe{kpqh>s#N~u2*p$6RUY&U6L`Ku|phbd-Y}mtzCB?(Ky;n{e3u!nj&}0?Hb1X=S zB2BSNcBert@Uwu@ogjd}K&f6%dDVK3QEUOpi!|`eEWK)23uqiox#_MnN%E7WyG+^1 zyFl-gO*u9j*{`%2Ps3v72pHr4lgUJ)o`ATzVU6343U*!BY9Hxk{Mcj@JgPB4BfUI z>SY4-RR}XML?x;FA$ah7w5fYlP!P-#cxg}#&}TUSbNSd#i?WX!D*hpA4>T5LY1e`M z@&z9Ahycgbh->W9gLm}AnsT^RoHS@!8R#zu@&L5+^B`mCLhL_iK;BFmRP=PbNYRyN-r!Z~M$xj!+QHpSD8VleawGkcJfvNS?{kVZ$o_R%D z1Nxpg%o|~OZh@$$(_T0mCuu-q8Epq)31$NxWLI3&Ns2AX4JzejK^ZBp5;P!zr&h~; z4X-9?-Orh=yHSUTj5!`rsj;WfdaJ#Ob=e=zmKfBT`!iTS*>e;9GH4xyO)xP7F*Jwv z2g57}*{P(E=hyxDu#V0lfMTC^i&qO{e1dougPE3t7=iFGpc!mC>}Fal1_eh!c5oOE zaTp3zbjkjEZ~i!%+lGAv1cv;gPw zW^l5eugTDHbRQ-;>K5tUOnf*ldyPm^a(_+aH)ZDu;q9GPzc<}Nl3NRLRi^_B+>~os zS_B}CVrvf!E&MFZASP-Uut-Sk6VvtujbetT#KQEz?jC(muVXm&H%Y9ulmRQZV4kyG z1{oYyFz>J$(vj3%#B(vu7aSv8mf_R|9`TUu;d~KRD5Lr)C+NMoQ|D$;79cmk6|pN= zQu7g{`e`J@`HhNJR_oXM_+wnnOGE*a(7C_LQ_0GMqFREZByd zU|V|FUf44r*C>O)Zwllc4=cp(tMqtzuVZHsSBEh%A*2Zp5xn8?hDT3BjHD7|fa3!?%uw|_JxFj1 zofCWt#EH=c#G`r^Eajm|Km?)-&E?PVXqzDqnMf5P>~XR!U`vQRZ)DG&oGe%3Qj}GK zT6ioi#jq;kMtxG(-F^9FkXr0%Z{2_M!GmMddk$SY_A}QW+H>8(-Fx=! zyYbp1N1ltF+-jvv4=)e=I{4<`p6LTO?>T@b2M_M9g{35&MfLThkuNCzeQ{Jk8c>;a;SWqKPhKNI!&9odA>WN-BM*4KxK5Gc~}o1i<(mxxKJ`+2*XsT3-w{t zCxAi-w$N&zGs!h~TnM9P>~vK~3uL8ur#!D!Ck4+zjMQ-$m?0h5oqka{Gn{(LL@Z)u zi-acv+DqFVJV7H#Eso6dGZ+%d94u{|abRU-0D9<54-JiorpjeTtaHJAQbL>#; zf-tToUOSTy8E_ku8Q?Ph9X2Hn2lp6L3wzTP#|c#W+z)GD+ycFCF$Lu`f!1YhG9o|W z%*~j~f(~ried*N`_gu8=PWU^xG!Bk~Dma+s+9HO*BR4RZ%tw2S@X2CuX|28OWLY;4 zr3boUp40I*SoXq-2kVy=C!hxVjgT?|5x)dHREGUaTFG3n0A{LC-Er9s#Q=r_jh0hM z6{EvugMQUb(z6DeNA{8q7{+v>!fL|d1Umx?ZogK2$lyiCD@$X%2>(A zwoW#e8x5suEH?&+xoqj|MhUvo{4S(|$Lnv1D&dGMW--cS4$3dAxE72Vf}Cvtk4OG7 zU#Y2&i*_q~6=B1%!DjsO99$)t0HO<(lg(qzE1_19c9m)GU>@j<0Wy8_G~}HXRCDs*u(&MpkUq-OB(my1K1E;gt>89wg5$+ zP43MMhoB*~W-jAaDHLF!KbMQz!5(&+htmSZLtG5u;weLnMDVy=OKJ^HxK37Ykid}| z)^bw&JE~Ly-x4<@6Isa*vU1fhs_ch>6jPEZJQg%DbXBHzEZOTG>tVbj6>0J?$AqC- zsKWq*Q*g?XiDP6rEGk7JQ;^lbuH<~e5C(Wytm}~{mkyK>1{wJUEggFi=RrG^eRXU$ zplly<1I8HL4^9F_Bg!v6UI2 zvazsfS(8OSiq1yyG#*O%G|1V@Sl3Mx*h|^nrUraYHh;G^3m{S9foS7hRwZVVd5B#& zRY+-M)TD=ksUZ+MIgdRczXsanD|6`CIgWq2HZfRAvnhOALHc4i|Dz+(FZn1^fwy80!~|x)iUF=ZtHP{dgLai8DIQZ z$Q+dg9NShCLnr%hqz#C37FG$|*=^92Ru|CGHmFuBXhkd>OQQFZJ(@P!!Zsd(i`^EK zi!4vz(Nqc`3Emb)Qgt9HFa%{{V-$spcr!9^OPzAOVjotUi>}8xGsnfn;u6_`3qc4` zF)nsSpINHsx!-v%kCFQWY=z5Z; z@EYqE7x!c8QryqKuN4o_ul8KCYSR>#Fi82mA(iQ7Q|x4zaxhy*nApZp?dZlC9%hj2 zrn25O4%bs{@t}B!e_yA69~KXpX*GA6)FU?adhrGWe4}`iPDY=^_K7})52Jdkc$=y7 z_Nfk&e1|3CJ6qiLE<-u-?x|I#=6l4W&O}HmYd<$*f1e)c`}OY!#0MGvA@O0uc^?rU zHOY^OkL#rIY?Hp&l-u>fwA~GljY0IZZrPirrFD0*!Tp5Wi%;0y^GTcfluf-^yv2<6 z=ftN?@-yPICiyw>d6WEk+v*F82KEc$7fte)#4nrVubeWeFWS_viZ2=9doA$G7SXR+ z*st5vZ;0PCO}^5SlfPw$@!K}_JGRa5+SKpa)bEQwFnIn@eAOiX$d>=H_?iL!iADK! z@eKp~rr5x? zIQyS0drjB03E?BE!1UzO|5BBIdWv)ZE&hjoqg9{uTA*XbqbI#Xvo<@$6^4%qoC)&C@uCF-Y21E$F!<%Uddm~tZ~w}En_CbyAtn@nyq z5>u;r3|lC90VlUovXhhNAj$KL&P6IZ45ZtR-(~NKj#uYsUA124e zl)J>_#*t72-AV9E4SW|8ifB)@H72M=i9Sj%izX@kGzai>q|HW)E~A3WoziExrB_hF zl}_nZlzyfIcowCf?Ev;7ZA416j|#4JO7~OxfCG3QrLS`U&qvy}pF&1W@Ssf{aw|*| z*v#~LH*?snc7szQhbqA_b~8Pu)>CVByVG~Qb%{Xz%uBBU)XUhHOWrCPVqNAtw(l)l3Ocu3o} zce)v$YRx!RXOXrGB&Z;CN+U|oIe;3{7IPgLtF}Zjau!oUZ5s5^V?2tqWz*EnWXMc? z#nj8GO+g>s@ED~R9KZ>rEvCEN%-wG0q?@@189VHkxFz=@6TOr^y1$pXRbNg8uW(9V zN$FQPfLBxclml3#^nDKCHAvg>N-DVDDSa)aA8-H;RgSe#8O19%AbyP}wp{p!*eSA__E z!tK~;)o8)3~u3ZX+j?#ltX-{P7YvQftX(myNC2NwI{n&ZccT` zElYs{QLyl!;o@<%iIzoNFh$j6rE;kJEmUTLZfzODl&5-aXtDt-O9=J1F6pRsuR_Bx z(YmG?`Cyw$uVCk^3yh7kEg{L{SyO5(g){C5@;1U%^Lr$+Y zIKn&-0f_ij(1K!*)Rn;ixf$uQ4L&W3R+MWHGa&mpItpvYE__^_k=GkH&E)6BtsX^U z!mY9R)dZW`d^t5CEgcm!bx4a8E?xx`nn6wRVSE&$F-YT6})0ZbCJpr+%) z!zE2xEjw*5Hf<4PFm8)}5j*4n8mLQ)d2-eg zp~xo(_G*U?o2%L^s8#uf236MyAK|kcRX}&vn>e}$tc06Fa&4;Xp9-eG!3_cAt3yx) z*O=@U6CIBq3EOYn3RqJFiHU89jfFQZ_e=m{A$#cR2(zK3RwzZ=z^7@bLME=|RZYA@v|+>@Yt88M}s}21(exvPsWR zWEbDD=gp{w#@UQ7*EvxJbf|QM972FF0Uhy=J=>|yJ*0XNSlY_abvUFA?dw8z=RP71 z5EC0kK#i+LxvrKVTAAEkcD|}x1oJwZR?TDmOhFEI$Qj6eGy5n8Drgu9e$0e>sfLG} z@%m}1jpI3odXfWitf@L|!u;s!V>b00bPOw1V8uSH3_V@76eIe-kvuw%s?l5U0lA}j z{DREU3H)M4cdMU!)X%-@=VkmuoK3GEaEEw`)Qu|cJCu~ensIy(Q(NpRqF10n^d|an zU#~~1ctU+?6itibK}HeXho2Rx^uTEf#-m40Qxd)TbOlVxftxeLmW|nMsn}J`=L=|g zR%j;&w-?mygza`2EkB38pCm0m%_vSw%kk)R#5_}44(-=3TKw7=lT~kiMa^%WHEIrT zH&1E&+5{7}xs0CQc=Gi8+L@wwEgC8se3eoBzY~7rUKM;icF2z@QgQ|A{}er+9bVqF z9l9^vulWSreAN5tmf$C6O23wP`0<&dIO|K$xt}zjHijQtd$vg4wVm~>H`06u7an?!Py&nd`p2OF*5xIKSh}^SX z8zjrgTs~78wa9$unW8xBWbWF>##$Sd7tb1%ySFRba5<4*b*2<*5&7k3ief2|gD0S( z>eGD@r-3?GKh5XD8K7v5;*)23w6sQ{l-DOUiq|}uQIPF32n&avXWD1N+CwqblgR-p zb3(&mmN5tqUsRwenM-mAwb=C32Cf3~iwbfNrigZiDM&T@XKTp}U|gN;*I|W+qLCda z{^S6;{n$PwQ*U({t;gkhe$bsQ)MqF>e3}2SbZOyo!7GG*QwNxSGykG&*(rujczfs zWT&Uzt@=@4C*iiG9po2wip^qmkYgBdxgK8s*;WLt8C#bQPo^q8c+jjS7+nbL@T-`= zT%jD5xS;fIYW-CNTpr9XC`W+xJ5y*~Ou_l^j$B8t-kYy(&b%5967vckF2}~ggw4T; zu>)Pfn2s*SZ*&PhvhNW8d4Vw(L6>?h*o>aeBU_=>`Xl_pa_lgaeBEnXS4%sV8?-NI zNpIMwj+0Tl78qviVMOj9K^1{NYl5qFW1OQ;+27`%)NK{8)p&Fdb+AI~KKAEnquX>$ zuvXhmdypMFf^b@FWxR?vmC^Sg54+_E)Cd?nQLeXeYHhGD0j3? z=`nB)@)Vb$WN;hC$Czk;n-|v*nSrfI_U|Wy$E8nyn}^yoW1!RQ22$ePW`j$d(uS-W z+-f>R%IO%iQ@8a7?e}*5z#4-tnxLW-#QqMKz(0=`wWNwbbqx;kq2?W;zO`N2anyM6?0Cw5G4}TJY zZpo-crbXlk9`n<)vYLL{Onyro4!v|-d0P2^(t#&9+F?PZqfm(G-MD@=X1&I8b9({x zt{l*4w6VRcj^~4r6@>2G_7X;imm{`(OM5xpBel9*(wI*QM~}npK-*ZMTu{tAk#>b5 zJ6NbYeCdjlTm7{o%$}Y)>z6ceOyd%*q<|%=31#L`1*bj05HwWP=n8lr$bVvmg450O zs~+bm*08#U!=;V;3Tt4=11t_$ks~WUzuLsld>8?Fpq-aj>z6#bipsdTpN=a04ftE7 zZ=sgBHR+L+A6ShjRh2LK%-bpsc!3JN>}A!Uc{}Aisv0&h_dS*RlMnoqii2Qe|I&w4 z8!Vvh4b{*}&!_s^Urup!j2u|@N#*@*HR5if|k{n*{GVyE6&lA%wMqlVD*iudUcXUP7m*yJo6qd%7px z)!wy45CH|Pr4mRm5X40!;)TmA5)V8e-Vg#_kr09h#LwXJovOagcxK(>watRo^0ua{ zs!yFdRp*?q>eTu2=(m6V$wTU&oR8Yv^|vdQqV7>z<9)L;Rb&8Js;&`d>n20q#PBl@K~Wr zE?kU#)!y=q&qh{*b*L?x9=*Y~iHSZf-tB0{#@ZIx5FLI@PMR6td_;~V>~6@Tp6j#D zMqIPxlqE#y)?$HS(y&HcC&Ccqxb0a{ghKot^{tlvHQ~hV{H12un<1-Hn^=wut3g#A zKF?3_8U7d_;iu*GDi2z$TK58Bb0(^7tIfi-7DmTeSZy(3Rco&AxPBw5wr>d@_|=qU zi{xc$o~vxzYPCHURl*1(d;Ic9T!SQmhTu=v7nwy{lc|NQAJCAQQk+2$t*!cRV|_o{y0qp|1hM_pTHzPiNB}u zcLskekSu?Oukq7-6{9|yN>-2F4t!x@Cd0z$^Hnu&TkW1DCr?~23_p=+I>^3tIlHfx zXZPiV-7&EHl45n%a|H`6&#ZZYy;)%Q^>+^+I#dsXmZ4ULnXHUTaaP<`I|zk=MeVR~ zYHEs$mS>zZmX?;L*7P8%4 z-F1J6)j~}RQX(Nk=PK*f_W3F5b85(Q7xm+e5SB(~| z$GN3;==x%5MK#@Wu?B-S^Ou)`ShQnN!N*Ih#?n@8X$6bXsB4l$wqDr^A-3g=ye6T@ zEiYYr^`g;Wn0!kxEMqL?$ht|tBBMZg1?;+Y!!$`XP186BxtS(0Z<;Frk! z8jzTA>8c^z7K36YPKDh5SR~+x8U<@8C(^D`qkNQl?&MbZOXaQb7ct0f3}jJWyibhh zY-|_V$Y*Ug-!+rUTsee!<^xYD+huw#e0qwjinwT!YD}2GR-sz1UW7oeUA=zkb<9PC z%4pSa$vt>3EHDKAX2d+8tssovOgwltm;6T>b1h2aZd3|F|DZTpOx(P}YbKx%gSm$~ z8#fv;7T+%5UUJErA# z3f?Zt`$RoosJ|~S5l94Q$iuhp?n7nBwHQ@)aRfx=Tn_YA^9t5fqdjb5n-lIF42Mh zm6?2+qd?5%MU-OkN*yPyv7{c1s_sF{F2cR1tq+q{v%dhYJ<=Gz_a^s83CD32zJ~i0+!BNVEiVf4^B&7cY)1mnv0DfO%jU}A z#v*6|pWEbcr#QCHfGS0^bH~|`4cvmm14ie1fu9`>?FUwJ-%uIKH>8N?(bRkG?zI*# zKxoEwfIggc0-f~utwGf}cP|aaUFx|`K^fNZWtZpr+L3<8M#g``S&>lgbW%zWG9RUl zQNQz|{|!%-`dX7zq~9=xJ@!(B9;BxwMiu=Mj#eFq>5(Z9P{Q%16PmljRwxdtMcjo5>8(pE2953BHK9=ICRx*sPAldyUn~(pU zY&63J%bqUCVRvBj75#mI`^ffr$fhX0=w0NH``7!zA2>&=`f>5NxB!Sk|mUr)A3n!fZp} zjgo)?J*6SDgPGZPGn2W%_n|fAw$WGi-~!1Pn|3SpO2aw8T-yuIX`V=~*{%LQP}LSX z%)Qf~cvsOS+_9g2FcGe|GyiwDuvYWeLzziLwwk}v=J5dpw-o8(0c$=hyT!NGy4}rt z>EKf|kcUwA62NNyg(Ri!4Gk5;YW}rH!u+QxgQY`O&42XJxNFo(^$I7pNay6aQ|#rX zoPetk@SoMJ@7^!MSIs|VoIa!oR&x{w5)UYd)f^|<>OR#l_~%*3VFX{6h>PqhuY+jT z&>_0QT7UeW=I%Mn-G}aV?gpz9|0n+!|2H3N9)f-}50f(K;8oE(&_%QA?tuBJ3*?aJkIGFifBV77Su{?s-@EP|M zEQG-ZqiB5uNm#n;gA`AzBQe!!@!r}n;gu4Iy2O#&@=A`l4<;7-qW`Oj%>er)C~-N! zzOe_uu2iW2poc^3xv|M@tz1wv2SRpTUFdR;n0-G4o)W;bA6Uu#hclG_NMm3hhyO@V z&31QwgW_C?Fa}lU48}t-m^!$1Fc|haxFaL|99(x8?4kLz{(-Q*2leHBSa!TK*njms z1sdSB2)C3AJHctGgQl`qJ2J}T?X;)PhOS=Iu$itaFM%$nJXi`3!;23k5H-$+8iG5} zhCyed2il-P@O{O!`&An4Z1jQEDLFLTBTRia=Oi z=kg&rm9}9HUSry~AC_}=93qw?k}SE-hMY-DbcUath?o$#tb-iJV_8ljw#3L!@Eu%f z{hZ~+NbO56u*)%>JJi|W^BZz9$&3g>P@Z&CStb_swHMYR7r!6tuB5nC z5SsdiSqO;}tG4LKc`fMzl~F~dBTx6#^pI%&5o#^xG~Jws^y|mED|$Yb3p56-wTMGR zo=WeTO;dRwaR(Qkk06Ovo(P#8gpTPjB&#{-2FVqW!x$8$j$uL~u4CpGqUV%8=0);| zi{&-z#I3fOEkOuY6;O6e=;N{;$jqw}wYX{e^P+adVka<>hJ;oNY`Yyq%q)JXR*5al zn{MPH*@I}AjvHAuk6|DQm*c5wK+R?_mEk8&Z}(Tmk8 ziX&_~DdV*ggpDe)o2aE?04+`AwP1P#regx-YfnG>G01Z+tlK^0sU#==k(4}?)uJW4 z$-;`uL|yd(15l;JM07SOtQxe)5xjGGLY~H&Y?+yQW{EAPP5`?t7l3YswQXiuT^-~Q zQ|=Pdu3E_FGT(}!*Od5mLcQx(6Um-dBHGh7$31!*6R-6RQdT0w*PC%031yTrt@CQM z9k`9v6WFd_#~Mft!*MbdQC#b3=eBUE0-ae}8!5Ya6B*L1RbviR_!d~;j0tpd(nhOI zli|jfXW|Aa8Cu2mu_Qkmi`A=ZvvymKF#jfHNy`asPLOMcrAX<@P!3@tk*&dCo{)TY z3`}8Z33WFV%M3Zu$ap*6RZ1C{sugq&Bv)w|6Xpk=!CY32akBrJWFq9 z=;6@AqQ{S62Ac2Fu5z}x#MGE) zZ5IobbPaOQC1gp`c3QZ_65S3W$C7N-0xw&&s_7#=g<0##E{c#DD6{?hx~KSn$8FC4o< zs(dx&X_}ivvQLM;uEB>ykBiJiA{P{|r zhCb^8l)GWCiR&gj(<&F$O*!Xbz`eI>UV5B|;@?%NqgeM7_;L{9xtH^-emY3Y;&CzO z$*mO&(ka8f=SzB3$z%DUJd!Wvt~?8GXVPS1w$R2WQutZH3t_LaGRMl!GU54buJTmn zOE)X-y)^l(4Ve=%td4oMurPgR>@5>3o)ucSsdYHxN7uXM9ANYllY5R+*)_l(vD{C1 zu?2J(Ml{Eby&BaIwxtcIIMHYIiEiXagq!>^z>=?U5kJB2WBfkB?*c%`pUGdyGkJ;7 zo?+W8hBws83(GZ|Ym?lrh4^RtWM^k#O*W3mum+iFcV0JL(4~)6Rv7EYRxFi+DYl_L zh!!>!TNoNtt3h8xUU;|8RmBf6S zK>=Hv+x|cL{~Pq{szP=A*8PW4>If@b|Mx7?(gdgn<udFp_F1HGZCPyCl{Ohs|5ht*@@1I3j^x zZ<2uM7$Xd0NLMZKaH5d#qG5=u$Pia|7D)>-cuCvTPl zoXEXM#~$tC3os_=Yb}^w3-q$#g!*A0`0ob^t|z0SJyjX(CUa3%aZ&(38SR6)WsV9uWrIoE2(=|IB>#y>Kj!!?fNQs zkhmzt&Hah%5>2aX%zL1*qV&~`JiZD21{Pysxt{_b=h0TMIIJ5EgO^(@2hFmy8mpl< z38St@Z{fIy2rV+|`N9~V8!y&gUOx##YVUcOu@zD)Lt=!HL$fWwm>EqC4kkaWB|?iR zVMLUy?Y;2WY-pM>V6cK30n_500DMowKyuwJd*M{96j1Zrm#$Ung1Kg)RR#wrC(hA^2#YZzubJugtf*p$@5erQ*rG$TjHy5 zTqC_^r5CMK;cV;8mI?XyFsh9{2GDMOx%pyiXE>n)AkmxzK0226uq|W2ouZbXVh9WG zS&UV6hGMd7JJZVl5iD_EC*Ks5@` zm4wZ?CWFUO5;L3b{!vsoQKp80f}nLGR)ukbt?!H}l6dw`Ipt%19=C=>%2~I$Cai$VESxa%>hnQAzPxTV{c}m5Fw8@vq zLaj38t1nMJ38O+344?>vshsllR@kW$ei413`e!sJz**n(IZ7z? zvbsJ9XGqA5b+y?YfYs#x5Ki1Zrp6WP7`?)1Y$)%KTm~*@-;^k7BK`N`x?VEOaJ+%` zERh`X&_ot15-3LqvHP|x&`fCNkAr+e0uRU@lc;=w9B5hLIq`%m=UaLzLl1&8UY8;b z?Y9+jCYy^C^?Q#Hq!1oXUL?>$lgqf$44)^&vN7z_<^u!CsuwkW)Ct!_j*NB_1E}%U>Kay_F7tnwPa2ds3 zIPj+Mmh=DJg$ecE%4OpbL-M&VnM?K$3PqiR5Z?n$tM+Ten)>#zq{WrxW!4$i6p?u- zOU?Ki25_=6Kf4G+%PFgP{43IMBz2)0#!IR4t@wD-3vy#sg~Sw7T@lLiI*N$jaD1zPJrKS6b(?Is}y|{$6>ey_UDB# z6Xlp68!wDrY%4`ml%zGZ$Z5~50|bwh)Et?J zPsLHQq~ZyH70*zJ=K%A#E5-S>r8QhdDn7#13;cg_85{A9nBd)h7?Z7=DE7s-;lPcFk(3EDOg&gJqPu|@K>rDl(pyZ&@{eoJS+`zZSXN(am_9D#-;skECI&PrpvHlE+P1dFDQ7arUo>(baN@oMHk$eN%} zQkyxL{J52H#j}K5OE1Wl`0&_0=sE#lSY8|JiQ?}#KztY`f~x*F2&a54ft{zmaa*N9 z7%;wUR&Y1Frd9(xXrLb|NX95s!BbN4?=X`ZND;0Jljs)1xbfxrcjr#x`^EiB!mTxy z2&$O^hfsXvR)BlQE#7ym46>CNUhdrNm=I-{wMK6P*gk!6|6=E5I7tbBZcYFng@j_5 zDymlvU2(w8aDIs94Ua7^jAaC!4zj>a8V?u_h0!H)1RRo)8WTmXqZo!6ZM-43qVQ0H zVN=#rHR)LhRI@;WfL2qL3?64m?txVMTT$(J!wgwhiBBhdT^q;A-Itjng(vT%lPy1w z36qX4RZ66b@?hH?=(HmwLGk z)MB8GUV(*5R?^3xPQD1Un%4xNV8-bp19p@oON!W2XN2d%``UfKSU~LN?TnfmyIREX zG->7lph-NhP#%wRj37UM_4OOzIkRQ4V?If4&40c%k4~kDuUFj8CHH0aff=w6zEW^D z_H>CBVrhzx!b=dcB7JZ62@qE&hTMb`x6z)iiI6Ly@Sg{#NxVOD6-Af*)}W~!fO?$I z?U5)`&~|ZzmxZ8+2Ze8&4Jc;_@%pN%;UX=`JaxJufb{{1)Fn8uoeF1ZPPk^dqbI9G zUS(+8ifN=@MQ{eYhM|8q2tjb};pAxntt-MSDiy&#PqQ|MeVQ!i4Z&5}&a|7b&-J^A zK^{i!EJ5>vifO^1IXv;yt~h2B7R4!o0<}E0Jhlw>Wt!mO?4GpU#Sp_<*&ncN%e2NJ zKS;vvdz`-grbck@qj-kf>D}^V{b?alz{-MA=GpclA{D zR9AKC;jxi|mW7ZRs1<0_8v#MEpcOy(g%k>kq~_6 z-nw;f^=rCj#*SCaD6Vwf=eg(J^PN-YzCJtj#V1$Uaw_@S{S8s=w=W5T|Zq;_zf)-^&zWZ*N zw}_zalE2mB6c0BI&=WAT`tHpn_04=3$dj_-49*K93EWg{*aVd&fCC`9|Iy@%~; zKy4eTI7Sd45Wk09qrrcTR->kTDI1k_->gxZC>{xoHHvCklh%XQQR^Y=uyrcFHg9t&Q4c_AUh8Y_3<)ppyP1gQ-L9#)d)H%)bzG+k&X2}oGu7@uliptgz z-a(SXQz9UqcquT}OwGJyG@1?wXN)}A5u7j#)P9^(yKFs7Jh46sMp&o8{73NbQT%%x z|7Org>j~DlIiB2_1u4V*NtwEUrcy%;M`Kh8Vmc!vIg9zoIhXMIbXU#^E(qQo2Ek+T z7zT;4<{M2*9gvr@1JZUYP6WZw%3f$`!EFpv-*dev&GacQ=Mhsl+dOx}vI7nO8CrmHoK&7Puck!-5GtEd zvt3H6aEX`<=qjC^Z=NeqorOZxtZ5A}zBuztLBn6OTGPw7fjyv#Q&}V$XSD_Zd11Qg z+io~L!;)^;7=vEZbc@qo6gH!_ulBi? zk86AsV>E6AeSk4lE%6Ka@m>JbJdGVac`G$S$2lgCa~ptAXtb6=dV*&VO+Z+pH{v!S-lS?@7l;HcIdyi; z1Q;2iS=C5n&OymR6Is_%m9~85%yQ}3(wQ^b4a>|(8Q_+rv}^>(4D-RzNW-qILLNgC z2v0z!fn(aG6)m(ICWI$}XEI8LgAkCurr=6EDykCjb|2N;l6dx8gX7t6qLJ^RAt&Pt zdqlV-<2{uO*=yaDw$K9Og69lcCruZ|9^<<1*sv^w(I;&RC`yXa0VWM4I-b1%hF)D+ zz4QwDB0yv?tJws!jtwjW!(R_f2ezQ+2d^i(Fzrk0&$vZ+GS;>$MPd5a)z+fpCYd#< z!~8MQ*S$yVD(9QR7_fVRcfq+2ucx=>g_`Ls+v`iW3hzI4Fsj~T&}fqhr`crHzc@BcBA>fz?V zdU$0wdKf68)5oDukNI@5#7IRKA2m>DcU8=yL{+&pu~NsBbidCtbd5znr#akhvyd859*h^fwM3Sp~R=Nt*VqYO@pu zN-IhKgIuHwv*8R#{|a(u%!ca-=<>0r8h#lQ!mQqla~I6<#5Jy2?e=L$dYnx`*q#nI zt7$Z5wIHnOrtc@yAITI5CO>GVB288zpGy(wP4OM`9=(*dnBmyx?j7N7s*3&`qj#`R z!YjK_MI;P*r6IIKK1C#@PEit*RODQx(Onm@Sf#@4)sYI#9p8p`X1?M}!e6xQBt`w7 zbHNqnDe50^n7RW~6|890ohW-nsA(}(L@qo5`%h+J|Er?$!iy&z;=7rSsQ)i<3QJ9j zn5D@vBT+wyk3Y3h)?W((2cCeakG7Su?HmNF70To!`8%a@YNur|20sM`{}1c+{`F!q zqW<6Mc)CmT5q0`dxq=FTeKZCUQ@>C}VwbM}7B0=(~lT z_FZprs%wyo`e}MhlY|V{AEDnL!y}oEP}Kx|U_8~|g8kT{Y6fm&!VZ>L!o+8Cu};{q zKLXah%2=nkzTQF$yV$Vo<69WuAA%9P(z|Q<@PM5pAF1w^fqg`UyY30OonX? zT?=`~rBFr!zrB=A>q5hxVOA_)9qyF&Rg z0y-5+b}r@aI6|O9y3eMFdG5^o!{9n2^>6gn7kl6aA)g8TDQiFzPYPR2I>@XGSs=-* zMWMhoW7>4~D&}5z+~6F@D8b%bxC>fy=Ti#8&9pyW_r`6$@>W(mAA^dheuqP)w3ZHjUKNdq=T*XOQUGd6f#b@FWW@vG8oJ0h>Rw zx|jiAfJ6ms4WLDr4KE0>ny?u;hOgCp>+4Kq=UBa9XVAyW{mpP5pi@O$}2b zexmDknzS{-GaHDFH3ze1$5?!%9662|_F7vk-FHJ5u-9sxGbNqes(+g(hF=c8t|WnM z%|GOAP5o~u2ipfz{}z4@;==j6w2~xub`|c>wTM>HGZH1$-=$0m)jWZu`ajbG2b}#9 zR1bl*|Gqa=k6th^HZk4TPWaH_lyI3w(mq1bY)@NG+Od_Vei)hefa4?Q>)7z%bu7Le z=HWuB#H%F!;hhpc0^&ci3*se!wL7}wVJ;n5xO#>buGYUvgX5IwaqQPpZTNJ2#pb86 z8B?nnH$B?nBf*9CK)4>(pScWyXx09w3d^1b;PJ60|FpIWd`ec^IAm2jCT;ZO+as`H zlC4@RuuUdzm;%&Ys!jIPGdu06u?)lNe+zC3cYNo3!4G1qCLr3bXfLlmkL@FJr%1b0 zTp@b6IQPjU>0j9?{n(mOzdYbbyO>te6__4OTesNOxVV(ULjWp_vyFO9LfL(li{l@@HN@&eS zpCu)#_UoeRelZtoY01cDnnR$;@&0arai)LUS5mfx%Fh1vwERIj8z23Gj0Zs6uTqDo zYbgEhj^xzN2ud}w4x}Ss4bW!#dXsiii(Q`u<~hhDWNn(&uA4#Aa|06}vdt#GSXgjT z*J-P^uFn*Fvs-yZB&OA4KaT&Gd@M#Zi_`Pih)JKp*mq;z5?{cyTT^F)#tl*Wbgylt zd>I3)obNE5=3k)7{!dm_ViKx@`;f%`o!aw3%|tClX_~8*Q9em`2eK78%o$ z;d$EsD80t}+)sAj-d*l zz&R`tD_Lc)R5ZjYQWo^30gua536xcQf@;FNeyFQh??BylQ~jH{Fpg078)*(f!KrOf zm(|&Uy4g^Py5C95&xyJZgSZcnIfJ@Cy)Ej#L0>n6pl&81qrMY$S@4cgSGpi~fxV1` zp4iKFDcxNR;AZ73`#q641%dm1?`}_|X#72h$(=`ICNKgWbEB~oX9Xn3S(!c1I8)yM zXe^Pe3mUV+R5TU={n41Pun!uKkXIsgN}P<-cZq)|?#ttVAVj`D%^?t(Eb%^@L8pXo zq#cwyBP3mqrM2Y5Wm?tz2r_4I`KJUf*T4{?X$t^O_@f|e=UiDywVAWoyIZ-bP0X2( z6}R2fWvt;f6FYav7Tyz7PTI9wT;@llwA;doUA{oGYV4F1*u-YlHuUYR;{9IO5hm|d zFQj<tJl@Sx<3!YUjL1@u6-1s)O6`uxB39Hn04hr~>qO;|q%sAUm7xB}yp4B4 zI8zfRn0S@=_mjEsk8t_rG>5?DCxL;|#W_SyxpZr5@+K>^((Ho(E@m=BEuT>8?ym?= zaEG#1BuZx5wLyDyJr1Jvq=-3F>Y@C2l;s;ad>|7vU zsEUTMZYDtmp1jK3<|V)(ychgPtiTLI?4C`^OB48JiOLqKfb}}P>xu&*Q=}D!O_BH_ zey83Rrk#>vBOj-1B=t6p(8`oh@lYpsrjb8dq~lwhO`Xbt`=fyrRZJwg@ZvU+26;nnFg)35P}E0@lne{t#M#p_E8uOf&%QPT6|n{PyB5GI>E#F1vH;|Ws& zI1}wW>vHDaZqu!3tkRn$zpKY7gXC(*l7DHIZ!~tpo_e%UK%ax3@YA_;wAriN*#!m{ zr*UK%`-+>HExcAJfbXVz)5aN0B`_L|G$IG~#rYQ&m!DtJSC?+wxU~HIs(xkV{7Ww` z(P?M8PUolTIt-A4($_RXQBk)63$sRPFcNhi55>09q>pT00QUuOUjRQ00Za|Sx1kgV zGX!^V;A*ljG?-TrAPLO3V3V@4(B2>XV$y`9)e zMw^{P@AsaaL{u?L5lcFSJ;WNVW4#ou^W^4~g=r!mrkI;pIU?zEFd6N&_B09CL?UK> z4rrIDTF+6omrfymf~|ZKWCu!lJVtoUD5kC4bEF54D~XHzX)^@Pt}&=)1-fYD&VkDi8CutE@`gp3r;0dwP_&H5)$=`i2`gY~75G zQ~TC%1UJ7WVkq=N!)aYU5En!mvx?V~cht2p5FCkzKpp7c=Tc$goE)>^hEm*cVI&@|6S$zxRy^DfY7CA=o^YDqZ>qUfO-_x<~}(W*!cZWHj+-E@qC@1x#)xAL~12$#goa=bF!2nRtoMPX^Uwq zqW;j(fdlabFSj%IL)nP7%<&Yp0b?z&z#@ko`93a=cL&F-IR5q!Ze59w`zGx4s$Mm5 z6+ji$U~w<_Fig%}G!q=Ls~D@|c-#rLF+v8#hinY5ad;t{@DXz#Ffo<|&^lPSKOHJ? zl7+CrP2Depnt>TrJsn5WBh{37tLX)%uI6N;q!Q{k?ZCzX;8d1gwF9FJe`-qQh!72k z*-j%ub!F=Dc%o@Gn4=UzhsNt99Ptm3Cp&m%ejdSa1+$x2N}j(qj}z}HB_jYS zb)0pM?g`NyqX?gS^y5#1pA&wq(pEf$$l@Op#Y0#P8nWxAU$V`x#_9k7%#$A%m5qf) z8B$aYt=8qy_!P!uLr=vMy~+o;{A@Z(#k7{ze43 zrpo}1vv>Zg<0=hFg!?n0FDc1i(Bpl2 ze2pHHRK-zx4AbKfIu_0@TK>k_MYE`!U9|Lqvx}@H&MumJW9;JYqj+2uJvJ7`D>mOVLE_Fs z2w_D!KIqgCo(_TrQEikxCzWQF^ntd(H1$}f#4%AT%XELHd?-tHmq&b}y{se#HVW{+ z;zfk+xB{f&VOJKrlLVp5(Pk}T2wCmA2{^sO8@`> literal 0 HcmV?d00001 diff --git a/meta/bindings/python/README.md b/meta/bindings/python/README.md new file mode 100644 index 000000000..5c2d0d978 --- /dev/null +++ b/meta/bindings/python/README.md @@ -0,0 +1,303 @@ +# Paper Muncher Python Bindings + +## Usage examples + +### Functional Usage + +Paper Muncher includes both synchronous and asynchronous functional APIs. + +```python +from paper_muncher.synchronous import render + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in a synchronous context.

+""" + + +def main(): + pdf_bytes = render(html, mode="print") + with open("output.pdf", "wb") as f: + f.write(pdf_bytes) +``` + +**N.B.** The synchronous API is based on a per-OS integration for IO timeouts. + +1. For POSIX systems, it relies on selectors. +2. For Windows with Python 3.12+, it puts the file in non-blocking mode. +3. For Windows with Python < 3.12, it falls back to a potentially blocking read without timeout. + +```python +from paper_muncher.asynchronous import render + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an asynchronous context.

+""" + + +async def main(): + pdf_bytes = await render(html, mode="print") + with open("output_async.pdf", "wb") as f: + f.write(pdf_bytes) +``` + +In addition to that it also includes a context based approach to automatically +handle synchronous and asynchronous code execution. + +```python +from paper_muncher import render + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an auto context.

+""" + +async def main_async(): + pdf_bytes = await render(html, mode="print") + with open("output_async.pdf", "wb") as f: + f.write(pdf_bytes) + + print("PDF generated and saved as output_async.pdf") + + +def main_sync(): + pdf_bytes = render(html, mode="print") + with open("output_sync.pdf", "wb") as f: + f.write(pdf_bytes) + + print("PDF generated and saved as output_sync.pdf") +``` + +### Context Manager Usage + +Paper Muncher includes both synchronous and asynchronous context manager APIs. + +```python +from paper_muncher.synchronous import rendered + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in a synchronous context.

+""" + + +def main(): + with rendered(html, mode="print") as (pdf_io_stream, std_err): + pdf = pdf_io_stream.read() + + with open("output_sync.pdf", "wb") as f: +``` + +**N.B.** The synchronous API is based on a per-OS integration for IO timeouts. + +1. For POSIX systems, it relies on selectors. +2. For Windows with Python 3.12+, it puts the file in non-blocking mode. +3. For Windows with Python < 3.12, it falls back to a potentially blocking read without timeout. + +```python +from paper_muncher.asynchronous import rendered + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an asynchronous context.

+""" + + +async def main(): + async with rendered(html, mode="print") as (pdf_stream_reader, std_err): + pdf = await pdf_stream_reader.read() + + with open("output_async.pdf", "wb") as f: + f.write(pdf) +``` + +In addition to that it also includes a context based approach to automatically +handle synchronous and asynchronous code execution. + +```python +from paper_muncher import rendered + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an auto context.

+""" + +def main_sync(): + with rendered(html, mode="print") as (pdf_io_stream, std_err): + pdf = pdf_io_stream.read() + + with open("output_sync.pdf", "wb") as f: + f.write(pdf) + + print("PDF generated and saved as output_sync.pdf") + + +async def main_async(): + async with rendered(html, mode="print") as (pdf_stream_reader, std_err): + pdf = await pdf_stream_reader.read() + + with open("output_async.pdf", "wb") as f: + f.write(pdf) + + print("PDF generated and saved as output_async.pdf") +``` + +Paper Muncher comes with pre-made integration with some +of the most popular frameworks as well! + +* Flask +* Quart +* Fast API +* Django + +Your favorite framework is not in the list? +No worries! Some general implementation are also +present! + +* agnostic WSGI integration +* agnostic ASGI integration + +### Flask + +```python +from paper_muncher.frameworks.flask import register_paper_muncher +from flask import Flask, Response + +app = Flask(__name__) +register_paper_muncher(app) + + +@app.route("/") +def index(): + html_content = "

Hello, Paper Muncher with Flask!

" + pdf_bytes = app.run_paper_muncher(html_content, mode="print") + return Response(pdf_bytes, mimetype="application/pdf") +``` + +### Quart + +```python +from paper_muncher.frameworks.quart import register_paper_muncher +from quart import Quart, Response + +app = Quart(__name__) +register_paper_muncher(app) + + +@app.route("/") +async def index(): + html_content = "

Hello, Paper Muncher with Quart!

" + pdf_bytes = await app.run_paper_muncher(html_content, mode="print") + return Response(pdf_bytes, mimetype="application/pdf") +``` + +### FastAPI + +```python +from fastapi import FastAPI, Response +from paper_muncher.frameworks.fastapi import register_paper_muncher + +app = FastAPI() +register_paper_muncher(app) + + +@app.get("/") +async def index(): + html_content = "

Hello, Paper Muncher with FastAPI!

" + pdf_bytes = await app.run_paper_muncher(html_content) + return Response(content=pdf_bytes, media_type="application/pdf") +``` + +### Django + +WSGI. + +```python +import os +from wsgiref.simple_server import make_server + +from django.conf import settings +from django.core.wsgi import get_wsgi_application +from django.http import HttpResponse +from django.urls import path +from django.core.management import execute_from_command_line + +from paper_muncher.frameworks.django_wsgi import register_paper_muncher + + +BASE_DIR = os.path.dirname(__file__) +settings.configure( + DEBUG=True, + ROOT_URLCONF=__name__, + SECRET_KEY="dummy", + ALLOWED_HOSTS=["*"], + MIDDLEWARE=[], +) + + +def index(request): + html = "

Hello from Django WSGI!

" + pdf = application.run_paper_muncher(html) + return HttpResponse(pdf, content_type="application/pdf") + + +urlpatterns = [ + path("", index), +] + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "__main__") + +django_wsgi_app = get_wsgi_application() +application = register_paper_muncher(django_wsgi_app) +``` + +ASGI. + +```python +import os +import asyncio + +from django.conf import settings +from django.core.asgi import get_asgi_application +from django.http import HttpResponse +from django.urls import path +from django.core.management import execute_from_command_line + +from asgiref.sync import async_to_sync +from hypercorn.config import Config +from hypercorn.asyncio import serve + +from paper_muncher.frameworks.django_asgi import register_paper_muncher # Your patch + + +BASE_DIR = os.path.dirname(__file__) +settings.configure( + DEBUG=True, + ROOT_URLCONF=__name__, + SECRET_KEY="dummy", + ALLOWED_HOSTS=["*"], + MIDDLEWARE=[], +) + + +def index(request): + html = "

Hello from Django!

" + pdf = async_to_sync(application.run_paper_muncher)(html) + return HttpResponse(pdf, content_type="application/pdf") + + +urlpatterns = [ + path("", index), +] + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "__main__") +django_asgi_app = get_asgi_application() +application = register_paper_muncher(django_asgi_app) +``` diff --git a/meta/bindings/python/dist/paper_muncher-0.1.0-py3-none-any.whl b/meta/bindings/python/dist/paper_muncher-0.1.0-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..5f6287cff646af3f0359b203fe55ddbe5cd129a5 GIT binary patch literal 28385 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 literal 0 HcmV?d00001 diff --git a/meta/bindings/python/dist/paper_muncher-0.1.0.tar.gz b/meta/bindings/python/dist/paper_muncher-0.1.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..33b57812a2469cbd0759b2a1cbe536be5eebe0fd GIT binary patch literal 15617 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, Paper Muncher! +

This is a simple example of using Paper Muncher in an asynchronous context.

+""" + + +async def main(): + async with rendered(html, mode="print") as (pdf_stream_reader, std_err): + pdf = await pdf_stream_reader.read() + + with open("output_async.pdf", "wb") as f: + f.write(pdf) + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) diff --git a/meta/bindings/python/examples/async_example.py b/meta/bindings/python/examples/async_example.py new file mode 100644 index 000000000..9c41a2900 --- /dev/null +++ b/meta/bindings/python/examples/async_example.py @@ -0,0 +1,20 @@ +from paper_muncher.asynchronous import render + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an asynchronous context.

+""" + + +async def main(): + pdf_bytes = await render(html, mode="print") + with open("output_async.pdf", "wb") as f: + f.write(pdf_bytes) + + print("PDF generated and saved as output_async.pdf") + + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) diff --git a/meta/bindings/python/examples/auto_mode_cm_example.py b/meta/bindings/python/examples/auto_mode_cm_example.py new file mode 100644 index 000000000..db8cd2f00 --- /dev/null +++ b/meta/bindings/python/examples/auto_mode_cm_example.py @@ -0,0 +1,32 @@ +from paper_muncher import rendered + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an auto context.

+""" + +def main_sync(): + with rendered(html, mode="print") as (pdf_io_stream, std_err): + pdf = pdf_io_stream.read() + + with open("output_sync.pdf", "wb") as f: + f.write(pdf) + + print("PDF generated and saved as output_sync.pdf") + + +async def main_async(): + async with rendered(html, mode="print") as (pdf_stream_reader, std_err): + pdf = await pdf_stream_reader.read() + + with open("output_async.pdf", "wb") as f: + f.write(pdf) + + print("PDF generated and saved as output_async.pdf") + + +if __name__ == "__main__": + main_sync() + import asyncio + asyncio.run(main_async()) diff --git a/meta/bindings/python/examples/auto_mode_example.py b/meta/bindings/python/examples/auto_mode_example.py new file mode 100644 index 000000000..3cc9d225c --- /dev/null +++ b/meta/bindings/python/examples/auto_mode_example.py @@ -0,0 +1,28 @@ +from paper_muncher import render + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in an auto context.

+""" + +async def main_async(): + pdf_bytes = await render(html, mode="print") + with open("output_async.pdf", "wb") as f: + f.write(pdf_bytes) + + print("PDF generated and saved as output_async.pdf") + + +def main_sync(): + pdf_bytes = render(html, mode="print") + with open("output_sync.pdf", "wb") as f: + f.write(pdf_bytes) + + print("PDF generated and saved as output_sync.pdf") + + +if __name__ == "__main__": + main_sync() + import asyncio + asyncio.run(main_async()) diff --git a/meta/bindings/python/examples/django_asgi_example.py b/meta/bindings/python/examples/django_asgi_example.py new file mode 100644 index 000000000..57bb36594 --- /dev/null +++ b/meta/bindings/python/examples/django_asgi_example.py @@ -0,0 +1,46 @@ +import os +import asyncio + +from django.conf import settings +from django.core.asgi import get_asgi_application +from django.http import HttpResponse +from django.urls import path +from django.core.management import execute_from_command_line + +from asgiref.sync import async_to_sync +from hypercorn.config import Config +from hypercorn.asyncio import serve + +from paper_muncher.frameworks.django_asgi import register_paper_muncher # Your patch + + +BASE_DIR = os.path.dirname(__file__) +settings.configure( + DEBUG=True, + ROOT_URLCONF=__name__, + SECRET_KEY="dummy", + ALLOWED_HOSTS=["*"], + MIDDLEWARE=[], +) + + +def index(request): + html = "

Hello from Django!

" + pdf = async_to_sync(application.run_paper_muncher)(html) + return HttpResponse(pdf, content_type="application/pdf") + + +urlpatterns = [ + path("", index), +] + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "__main__") +django_asgi_app = get_asgi_application() +application = register_paper_muncher(django_asgi_app) + + +if __name__ == "__main__": + config = Config() + config.bind = ["127.0.0.1:5000"] + config.use_reloader = True + asyncio.run(serve(application, config)) diff --git a/meta/bindings/python/examples/django_wsgi_example.py b/meta/bindings/python/examples/django_wsgi_example.py new file mode 100644 index 000000000..8958f76a3 --- /dev/null +++ b/meta/bindings/python/examples/django_wsgi_example.py @@ -0,0 +1,42 @@ +import os +from wsgiref.simple_server import make_server + +from django.conf import settings +from django.core.wsgi import get_wsgi_application +from django.http import HttpResponse +from django.urls import path +from django.core.management import execute_from_command_line + +from paper_muncher.frameworks.django_wsgi import register_paper_muncher + + +BASE_DIR = os.path.dirname(__file__) +settings.configure( + DEBUG=True, + ROOT_URLCONF=__name__, + SECRET_KEY="dummy", + ALLOWED_HOSTS=["*"], + MIDDLEWARE=[], +) + + +def index(request): + html = "

Hello from Django WSGI!

" + pdf = application.run_paper_muncher(html) + return HttpResponse(pdf, content_type="application/pdf") + + +urlpatterns = [ + path("", index), +] + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "__main__") + +django_wsgi_app = get_wsgi_application() +application = register_paper_muncher(django_wsgi_app) + + +if __name__ == "__main__": + with make_server("127.0.0.1", 5000, application) as httpd: + print("Serving on http://127.0.0.1:5000") + httpd.serve_forever() diff --git a/meta/bindings/python/examples/fastapi_example.py b/meta/bindings/python/examples/fastapi_example.py new file mode 100644 index 000000000..a179809dc --- /dev/null +++ b/meta/bindings/python/examples/fastapi_example.py @@ -0,0 +1,23 @@ +from fastapi import FastAPI, Response +from paper_muncher.frameworks.fastapi import register_paper_muncher + +app = FastAPI() +register_paper_muncher(app) + + +@app.get("/") +async def index(): + html_content = "

Hello, Paper Muncher with FastAPI!

" + pdf_bytes = await app.run_paper_muncher(html_content) + return Response(content=pdf_bytes, media_type="application/pdf") + + +if __name__ == "__main__": + import asyncio + from hypercorn.asyncio import serve + from hypercorn.config import Config + + config = Config() + config.bind = ["127.0.0.1:5000"] + config.use_reloader = True + asyncio.run(serve(app, config)) diff --git a/meta/bindings/python/examples/flask_example.py b/meta/bindings/python/examples/flask_example.py new file mode 100644 index 000000000..53d94bca7 --- /dev/null +++ b/meta/bindings/python/examples/flask_example.py @@ -0,0 +1,16 @@ +from paper_muncher.frameworks.flask import register_paper_muncher +from flask import Flask, Response + +app = Flask(__name__) +register_paper_muncher(app) + + +@app.route("/") +def index(): + html_content = "

Hello, Paper Muncher with Flask!

" + pdf_bytes = app.run_paper_muncher(html_content, mode="print") + return Response(pdf_bytes, mimetype="application/pdf") + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/meta/bindings/python/examples/quart_example.py b/meta/bindings/python/examples/quart_example.py new file mode 100644 index 000000000..be9ff4f04 --- /dev/null +++ b/meta/bindings/python/examples/quart_example.py @@ -0,0 +1,16 @@ +from paper_muncher.frameworks.quart import register_paper_muncher +from quart import Quart, Response + +app = Quart(__name__) +register_paper_muncher(app) + + +@app.route("/") +async def index(): + html_content = "

Hello, Paper Muncher with Quart!

" + pdf_bytes = await app.run_paper_muncher(html_content, mode="print") + return Response(pdf_bytes, mimetype="application/pdf") + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/meta/bindings/python/examples/sync_cm_example.py b/meta/bindings/python/examples/sync_cm_example.py new file mode 100644 index 000000000..677304201 --- /dev/null +++ b/meta/bindings/python/examples/sync_cm_example.py @@ -0,0 +1,19 @@ +from paper_muncher.synchronous import rendered + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in a synchronous context.

+""" + + +def main(): + with rendered(html, mode="print") as (pdf_io_stream, std_err): + pdf = pdf_io_stream.read() + + with open("output_sync.pdf", "wb") as f: + f.write(pdf) + + +if __name__ == "__main__": + main() diff --git a/meta/bindings/python/examples/sync_example.py b/meta/bindings/python/examples/sync_example.py new file mode 100644 index 000000000..0a49e4fb0 --- /dev/null +++ b/meta/bindings/python/examples/sync_example.py @@ -0,0 +1,18 @@ +from paper_muncher.synchronous import render + + +html = """ +

Hello, Paper Muncher!

+

This is a simple example of using Paper Muncher in a synchronous context.

+""" + + +def main(): + pdf_bytes = render(html, mode="print") + with open("output.pdf", "wb") as f: + f.write(pdf_bytes) + print("PDF generated and saved as output.pdf") + + +if __name__ == "__main__": + main() diff --git a/meta/bindings/python/paper_muncher/__init__.py b/meta/bindings/python/paper_muncher/__init__.py new file mode 100644 index 000000000..073a7355e --- /dev/null +++ b/meta/bindings/python/paper_muncher/__init__.py @@ -0,0 +1,4 @@ +from .autochronous import render, rendered +from .asynchronous import render as async_render, rendered as async_rendered +from .synchronous import render as sync_render, rendered as sync_rendered +from .binary import can_use_paper_muncher diff --git a/meta/bindings/python/paper_muncher/asynchronous/__init__.py b/meta/bindings/python/paper_muncher/asynchronous/__init__.py new file mode 100644 index 000000000..b6c28117f --- /dev/null +++ b/meta/bindings/python/paper_muncher/asynchronous/__init__.py @@ -0,0 +1,9 @@ +"""The :mod:`paper_muncher.asynchronous` module +provides the core functionality for rendering documents +using the Paper Muncher engine. +It includes the main rendering functions and utilities +for managing the rendering process. +""" + +from .interface import rendered, render +from ..binary import can_use_paper_muncher diff --git a/meta/bindings/python/paper_muncher/asynchronous/asyncify.py b/meta/bindings/python/paper_muncher/asynchronous/asyncify.py new file mode 100644 index 000000000..6cfdf31f9 --- /dev/null +++ b/meta/bindings/python/paper_muncher/asynchronous/asyncify.py @@ -0,0 +1,14 @@ +from ..typing import AsyncRunner, Runner + +def asyncify_runner(runner: Runner) -> AsyncRunner: + """Convert a synchronous runner function to an asynchronous one. + + :param Runner runner: A synchronous function that takes a path as input + and returns bytes. + :return: An asynchronous version of the input runner function. + :rtype: AsyncRunner + """ + async def async_runner(path: str) -> bytes: + generator = runner(path) + return generator + return async_runner diff --git a/meta/bindings/python/paper_muncher/asynchronous/interface.py b/meta/bindings/python/paper_muncher/asynchronous/interface.py new file mode 100644 index 000000000..10f34a5d1 --- /dev/null +++ b/meta/bindings/python/paper_muncher/asynchronous/interface.py @@ -0,0 +1,293 @@ +""" +The :mod:`.paper_muncher.synchronous.interface` module provides +utilities for interacting with Paper Muncher, a subprocess used to render +HTML content into or Image format. +""" + + +import logging +from asyncio import wait_for, TimeoutError as AsyncTimeoutError +from asyncio.subprocess import PIPE as APIPE +from datetime import datetime, timezone +from contextlib import asynccontextmanager +from collections.abc import Generator +from email.utils import format_datetime +from io import BytesIO +from inspect import isawaitable +from itertools import count +from typing import BinaryIO, Optional, Union + +from .asyncify import asyncify_runner +from .request import ( + consume_paper_muncher_request, + read_paper_muncher_request, +) +from .io_with_timeout import ( + read_all_with_timeout, + write_with_timeout, +) +from .popen import Popen +from ..binary import get_paper_muncher_binary, can_use_paper_muncher + +from ..typing import AsyncRunner, Runner + +_logger = logging.getLogger(__name__) + +AUTHORIZED_MODE = {'print', 'render'} +DEFAULT_READ_TIMEOUT = 60 # seconds +DEFAULT_READLINE_TIMEOUT = 60 * 15 # seconds (15 minutes is for the put request) +DEFAULT_WRITE_TIMEOUT = 30 # seconds +DEFAULT_CHUNK_SIZE = 4096 # bytes +DEFAULT_WAIT_TIMEOUT = 5 # seconds +NOT_RENDERABLE_OPTIONS = { + 'read_timeout', + 'readline_timeout', + 'write_timeout', + 'chunk_size', + 'wait_timeout', +} +SERVER_SOFTWARE = b'Paper Muncher (Fully Asynchronous Engine)' + + +@asynccontextmanager +async def rendered( + content: BytesIO, + mode: str = "print", + runner: Optional[ + Union[ + AsyncRunner, + Runner, + ] + ] = None, + **options, +) -> Generator[tuple[BinaryIO], None, None]: + """Async context manager to render HTML content using Paper Muncher. + + :param content: The HTML content to render, as a BytesIO object. + :param mode: The rendering mode, either 'print' or 'render'. + :param runner: Optional AsyncRunner function to handle asset requests. + :param options: Additional options to pass to Paper Muncher. + :return: A generator yielding the stdout and stderr streams of the + Paper Muncher process. + :raises RuntimeError: If Paper Muncher is not available or crashes. + :raises ValueError: If an invalid mode is specified. + """ + + if not can_use_paper_muncher(): + raise RuntimeError( + "Paper Muncher is not available in the current session. " + "Ensure it is installed and available in the system PATH." + ) + + if not mode in AUTHORIZED_MODE: + raise ValueError( + f"Invalid mode '{mode}', must be one of {AUTHORIZED_MODE}" + ) + + readline_timeout = options.get( + 'readline_timeout', + DEFAULT_READLINE_TIMEOUT, + ) + write_timeout = options.get('write_timeout', DEFAULT_WRITE_TIMEOUT) + wait_timeout = options.get('wait_timeout', DEFAULT_WAIT_TIMEOUT) + + extra_args = [] + for option, value in options.items(): + if option in NOT_RENDERABLE_OPTIONS: + continue + extra_args.extend([ + f'--{option}', str(value), + ]) + + if not (binary := get_paper_muncher_binary()): + raise RuntimeError( + "Paper Muncher binary not found or not usable. " + "Ensure it is installed and available in the system PATH." + ) + + if runner is not None and not isawaitable(runner): + runner = asyncify_runner(runner) + + async with Popen( + [binary, mode, "pipe:", '-o', "pipe:"] + extra_args, + stdin=APIPE, + stdout=APIPE, + stderr=APIPE, + ) as process: + # Phase 1: send HTML content headers and body + try: + await consume_paper_muncher_request( + process.stdout, + timeout=readline_timeout, + ) + except EOFError as early_eof: + raise RuntimeError( + "Paper Muncher terminated prematurely (phase 1)" + ) from early_eof + + if process.returncode is not None: + raise RuntimeError( + "Paper Muncher crashed before receiving content") + + now = datetime.now(timezone.utc) + response_headers = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: %(length)d\r\n" + b"Content-Type: text/html\r\n" + b"Date: %(date)s\r\n" + b"Server: %(server)s\r\n" + b"\r\n" + ) % { + b'length': len(content.encode()), + b'date': format_datetime(now, usegmt=True).encode(), + b'server': SERVER_SOFTWARE, + } + + await write_with_timeout( + process.stdin, + response_headers, + timeout=write_timeout, + ) + await write_with_timeout( + process.stdin, + content.encode(), + timeout=write_timeout, + ) + + if process.returncode is not None: + raise RuntimeError( + "Paper Muncher crashed while sending HTML content") + + # Phase 2: serve asset requests until the rendered content is ready + for request_no in count(start=1): + try: + path = await read_paper_muncher_request( + process.stdout, + timeout=readline_timeout, + ) + except (EOFError, TimeoutError): + process.kill() + await process.wait() + raise + + if path is None: + break + + for chunk in await runner(path): + await write_with_timeout( + process.stdin, + chunk, + timeout=write_timeout + ) + + if process.returncode is not None: + raise RuntimeError( + "Paper Muncher crashed while serving asset" + f" {request_no}: {path}" + ) + + # Phase 3: send final OK and close the process + now = datetime.now(timezone.utc) + final_response = ( + b"HTTP/1.1 200 OK\r\n" + b"Date: %(date)s\r\n" + b"Server: %(server)s\r\n" + b"\r\n" + ) % { + b'date': format_datetime(now, usegmt=True).encode(), + b'server': SERVER_SOFTWARE, + } + + await write_with_timeout( + process.stdin, + final_response, + timeout=write_timeout, + ) + try: + process.stdin.write_eof() + except (NotImplementedError, AttributeError): + process.stdin.close() + await process.stdin.wait_closed() + + if process.returncode is not None: + raise RuntimeError( + "Paper Muncher crashed before returning the rendered content" + ) + + try: + yield process.stdout, process.stderr + finally: + try: + await wait_for( + process.wait(), + timeout=wait_timeout, + ) + except AsyncTimeoutError: + process.kill() + await process.wait() + _logger.warning( + "Paper Muncher did not terminate in time," + "forcefully killed it" + ) + + if process.returncode != 0: + _logger.warning( + "Paper Muncher exited with code %d", + process.returncode, + ) + + +async def render( + content: BytesIO, + mode: str = "print", + runner: Optional[ + Union[ + AsyncRunner, + Runner, + ] + ] = None, + **options, +) -> bytes: + """Render HTML content using Paper Muncher and return the rendered output. + + :param content: The HTML content to render, as a BytesIO object. + :param mode: The rendering mode, either 'print' or 'render'. + :param runner: Optional AsyncRunner function to handle asset requests. + :param options: Additional options to pass to Paper Muncher. + :return: The rendered content as bytes. + :raises RuntimeError: If Paper Muncher is not available or crashes. + :raises ValueError: If an invalid mode is specified. + """ + + async with rendered( + content, + mode=mode, + runner=runner, + **options, + ) as (content_stream, error_stream): + read_timeout = options.get('read_timeout', DEFAULT_READ_TIMEOUT) + chunk_size = options.get('chunk_size', DEFAULT_CHUNK_SIZE) + rendered_content = await read_all_with_timeout( + content_stream, + chunk_size=chunk_size, + timeout=read_timeout, + ) + stderr_output = await read_all_with_timeout( + error_stream, + chunk_size=chunk_size, + timeout=read_timeout, + ) + + if stderr_output: + _logger.warning( + "Paper Muncher error output: %s", + stderr_output.decode('utf-8', errors='replace'), + ) + + if mode == "print": + if not rendered_content.startswith(b'%PDF-'): + raise RuntimeError( + "Paper Muncher did not return valid PDF content" + ) + + return rendered_content diff --git a/meta/bindings/python/paper_muncher/asynchronous/io_with_timeout.py b/meta/bindings/python/paper_muncher/asynchronous/io_with_timeout.py new file mode 100644 index 000000000..7aac030d1 --- /dev/null +++ b/meta/bindings/python/paper_muncher/asynchronous/io_with_timeout.py @@ -0,0 +1,118 @@ +from asyncio import wait_for, TimeoutError as AsyncTimeoutError + +# typing imports +from asyncio import StreamReader, StreamWriter + + +async def readline_with_timeout( + reader: StreamReader, + timeout: int, +) -> bytes: + """Read a full line ending with '\\n' from an asyncio StreamReader within a + timeout. + + :param asyncio.StreamReader reader: StreamReader to read from + (must be in binary mode). + :param int timeout: Max seconds to wait for line data. + :return: A line of bytes ending in '\\n'. + :rtype: bytes + :raises TimeoutError: If timeout is reached before a line is read. + :raises EOFError: If EOF is reached before a line is read. + """ + line_buffer = bytearray() + + while True: + try: + next_byte = await wait_for(reader.read(1), timeout=timeout) + except AsyncTimeoutError as ate: + raise TimeoutError("Timeout reached while reading line") from ate + + if not next_byte: + raise EOFError("EOF reached while reading line") + + line_buffer += next_byte + if next_byte == b'\n': + break + + return bytes(line_buffer) + + +async def read_all_with_timeout( + reader: StreamReader, + timeout: int, + chunk_size: int, +) -> bytes: + """Read all data from an asyncio StreamReader until EOF, with a timeout per + chunk. + + :param asyncio.StreamReader reader: StreamReader to read from. + :param int timeout: Timeout in seconds for the entire read operation. + :param int chunk_size: Number of bytes to read per chunk. + :return: All bytes read until EOF. + :rtype: bytes + :raises TimeoutError: If no data is read within the timeout period. + """ + data = bytearray() + while True: + try: + chunk = await wait_for(reader.read(chunk_size), timeout=timeout) + except AsyncTimeoutError as ate: + raise TimeoutError("Timeout reached while reading data") from ate + + if not chunk: + break + data.extend(chunk) + + return bytes(data) + + +async def write_with_timeout( + writer: StreamWriter, + data: bytes, + timeout: int, +) -> None: + """Write data to an asyncio StreamWriter. + + :param asyncio.StreamWriter writer: StreamWriter to write to. + :param bytes data: Data to write. + :param int timeout: Timeout in seconds for the drain operation. + :return: None + :rtype: None + :raises TimeoutError: If the drain operation exceeds the timeout. + """ + writer.write(data) # always non-blocking + try: + await wait_for(writer.drain(), timeout) + except AsyncTimeoutError as ate: + writer.close() + raise TimeoutError("Timeout reached while writing data") from ate + + +if __name__ == "__main__": + import asyncio + + async def main(): + proc = await asyncio.create_subprocess_exec( + "cat", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + ) + + try: + await write_with_timeout(proc.stdin, b"Hello, World!\n", timeout=5) + proc.stdin.close() + + output = await readline_with_timeout(proc.stdout, timeout=5) + print(f"Output: {output.decode().strip()}") + + await proc.wait() + finally: + if proc.returncode is None: + proc.terminate() + try: + await wait_for(proc.wait(), timeout=5) + except AsyncTimeoutError: + proc.kill() + await proc.wait() + + asyncio.run(main()) diff --git a/meta/bindings/python/paper_muncher/asynchronous/popen.py b/meta/bindings/python/paper_muncher/asynchronous/popen.py new file mode 100644 index 000000000..4d15fc531 --- /dev/null +++ b/meta/bindings/python/paper_muncher/asynchronous/popen.py @@ -0,0 +1,43 @@ +from asyncio import wait_for, TimeoutError as AsyncTimeoutError +from asyncio.subprocess import create_subprocess_exec +from contextlib import asynccontextmanager + + +@asynccontextmanager +async def Popen(*args, **kwargs): + """Async context manager for asyncio subprocess that sets non-blocking I/O + for stdin, stdout, and stderr. + This is necessary for Windows to avoid deadlocks when reading + from subprocess streams. + + :param args: Positional arguments for subprocess.Popen. + :param kwargs: Keyword arguments for subprocess.Popen. + :return: A context manager that yields the subprocess.Popen object. + """ + if isinstance(args[0], list): + args = args[0] + proc = await create_subprocess_exec(*args, **kwargs) + try: + yield proc + finally: + if proc.returncode is None: + proc.terminate() + try: + await wait_for(proc.wait(), timeout=5) + except AsyncTimeoutError: + proc.kill() + await proc.wait() + + +if __name__ == "__main__": + import asyncio + + async def main(): + async with Popen("cat", stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE) as proc: + proc.stdin.write(b"Hello, World!\n") + await proc.stdin.drain() + proc.stdin.close() + output = await proc.stdout.read() + print(f"Output: {output.decode().strip()}") + + asyncio.run(main()) diff --git a/meta/bindings/python/paper_muncher/asynchronous/request.py b/meta/bindings/python/paper_muncher/asynchronous/request.py new file mode 100644 index 000000000..a44243733 --- /dev/null +++ b/meta/bindings/python/paper_muncher/asynchronous/request.py @@ -0,0 +1,92 @@ +"""The :mod:`paper_muncher.synchronous.request` +module provides utilities for consuming and reading +Paper Muncher requests. +It includes functions to read the request line, +and to consume the request headers. +It also handles timeouts for reading lines from the request. +""" + + +import logging +import time + +from asyncio import StreamReader +from typing import Optional + +from .io_with_timeout import readline_with_timeout + +_logger = logging.getLogger(__name__) + + +def remaining_time(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Timeout exceeded") + return remaining + + +async def consume_paper_muncher_request( + stdout: StreamReader, + timeout: int +) -> None: + """Read and discard all header lines from a Paper Muncher request. + + :param BinaryIO stdout: File-like stdout stream from Paper Muncher. + :param int timeout: Timeout in seconds for each line read. + :return: None + :rtype: None + """ + deadline = time.monotonic() + timeout + while line := await readline_with_timeout( + stdout, + timeout=remaining_time(deadline) + ): + _logger.debug("Paper Muncher request line: %s", line.rstrip()) + if line == b"\r\n": + return + if not line: + raise EOFError("EOF reached while reading request headers") + + +async def read_paper_muncher_request( + stdout: StreamReader, + timeout: int, +) -> Optional[str]: + """Read the HTTP-like request line from Paper Muncher and return the path. + + :param BinaryIO stdout: File-like stdout stream from Paper Muncher. + :param int timeout: Timeout in seconds for each line read. + :return: The requested asset path, or ``None`` if the method is PUT. + :rtype: str or None + :raises EOFError: If no request line is found. + :raises ValueError: If the request format is invalid or the method is + unsupported. + """ + deadline = time.monotonic() + timeout + first_line_bytes = await readline_with_timeout( + stdout, + timeout=remaining_time(deadline) + ) + + if not first_line_bytes: + raise EOFError("EOF reached while reading first line from subprocess") + + first_line = first_line_bytes.decode('utf-8').rstrip('\r\n') + + _logger.debug("First Paper Muncher request line: %s", first_line) + + parts = first_line.split(' ') + if len(parts) != 3: + raise ValueError( + f"Invalid HTTP request line from Paper Muncher: {first_line}") + + method, path, _ = parts + if method == 'PUT': + path = None + elif method != 'GET': + raise ValueError( + f"Unexpected HTTP method: {method} in line: {first_line}") + + await consume_paper_muncher_request(stdout, timeout=remaining_time(deadline)) + + return path diff --git a/meta/bindings/python/paper_muncher/autochronous/__init__.py b/meta/bindings/python/paper_muncher/autochronous/__init__.py new file mode 100644 index 000000000..240665e0e --- /dev/null +++ b/meta/bindings/python/paper_muncher/autochronous/__init__.py @@ -0,0 +1,8 @@ +"""The :mod:`paper_muncher.autochronous` module +provides rendering capabilities that automatically +choose between synchronous and asynchronous execution +based on the context. +""" + +from ..binary import can_use_paper_muncher +from .interface import render, rendered diff --git a/meta/bindings/python/paper_muncher/autochronous/interface.py b/meta/bindings/python/paper_muncher/autochronous/interface.py new file mode 100644 index 000000000..310b7eb10 --- /dev/null +++ b/meta/bindings/python/paper_muncher/autochronous/interface.py @@ -0,0 +1,18 @@ +import asyncio + +from .proxy import RenderedProxy +from ..asynchronous import render as async_render +from ..synchronous import render as sync_render + +def render(*args, **kwargs): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None and loop.is_running(): + return async_render(*args, **kwargs) + return sync_render(*args, **kwargs) + +def rendered(*args, **kwargs): + return RenderedProxy(*args, **kwargs) diff --git a/meta/bindings/python/paper_muncher/autochronous/proxy.py b/meta/bindings/python/paper_muncher/autochronous/proxy.py new file mode 100644 index 000000000..7f6dd53ea --- /dev/null +++ b/meta/bindings/python/paper_muncher/autochronous/proxy.py @@ -0,0 +1,23 @@ +from ..asynchronous import rendered as async_rendered +from ..synchronous import rendered as sync_rendered + + + +class RenderedProxy: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + def __enter__(self): + self._sync_cm = sync_rendered(*self.args, **self.kwargs) + return self._sync_cm.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + return self._sync_cm.__exit__(exc_type, exc_val, exc_tb) + + async def __aenter__(self): + self._async_cm = async_rendered(*self.args, **self.kwargs) + return await self._async_cm.__aenter__() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return await self._async_cm.__aexit__(exc_type, exc_val, exc_tb) diff --git a/meta/bindings/python/paper_muncher/binary.py b/meta/bindings/python/paper_muncher/binary.py new file mode 100644 index 000000000..a9e65d4f2 --- /dev/null +++ b/meta/bindings/python/paper_muncher/binary.py @@ -0,0 +1,58 @@ +"""The :mod:`paper_muncher.utils.binary` module +provides utilities to locate and validate the Paper Muncher binary. +""" + + +import logging +import os +import subprocess +from shutil import which + +from typing import Optional + + +_logger = logging.getLogger(__name__) + +FALLBACK_BINARY = '/opt/paper-muncher/bin/paper-muncher' + + +def find_in_path(name): + path = os.environ.get('PATH', os.defpath).split(os.pathsep) + return which(name, path=os.pathsep.join(path)) + + +def get_paper_muncher_binary() -> Optional[str]: + """Find and validate the Paper Muncher binary + + :return: Path to the Paper Muncher binary if found and usable, + None otherwise. + :rtype: str or None + """ + try: + binary = find_in_path('paper-muncher') + except OSError: + _logger.debug("Cannot locate in path paper-muncher", exc_info=True) + binary = FALLBACK_BINARY + + try: + subprocess.run( + [binary, '--version'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + ) + except subprocess.CalledProcessError: + _logger.debug("Cannot use paper-muncher", exc_info=True) + return None + + return binary + + +def can_use_paper_muncher() -> bool: + """Check if Paper Muncher binary is available and usable. + + :return: True if Paper Muncher is in debug session and available, + False otherwise. + :rtype: bool + """ + return bool(get_paper_muncher_binary()) diff --git a/meta/bindings/python/paper_muncher/frameworks/__init__.py b/meta/bindings/python/paper_muncher/frameworks/__init__.py new file mode 100644 index 000000000..1841cbfc6 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/__init__.py @@ -0,0 +1,11 @@ +"""The :mod:`.paper_muncher.frameworks` module provides +integration with popular web frameworks. +Currently supported frameworks are: +- Flask +- Quart +- FastAPI +- Django + +- generic WSGI applications +- generic ASGI applications +""" diff --git a/meta/bindings/python/paper_muncher/frameworks/asgi_app.py b/meta/bindings/python/paper_muncher/frameworks/asgi_app.py new file mode 100644 index 000000000..eaa566158 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/asgi_app.py @@ -0,0 +1,20 @@ +"""The :mod:`paper_muncher.frameworks.asgi_app` module +provides integration with generic ASGI applications. +""" + +from contextvars import ContextVar +from ..runners.asgi import asgi_runner_factory +from ..asynchronous import render + + +_current_scope: ContextVar[dict] = ContextVar("current_scope") + +def register_paper_muncher(asgi_application): + async def run_paper_muncher(content, mode="print", **options): + scope = _current_scope.get() + runner = asgi_runner_factory(asgi_application, scope) + return await render(content, mode=mode, runner=runner, **options) + + asgi_application.run_paper_muncher = run_paper_muncher + + return _current_scope diff --git a/meta/bindings/python/paper_muncher/frameworks/django_asgi.py b/meta/bindings/python/paper_muncher/frameworks/django_asgi.py new file mode 100644 index 000000000..79146cd13 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/django_asgi.py @@ -0,0 +1,38 @@ +"""The :mod:`paper_muncher.frameworks.django_asgi` module +provides integration with Django ASGI applications. +""" + +from contextvars import ContextVar +from ..runners.asgi import asgi_runner_factory +from ..asynchronous import render + + +_current_scope: ContextVar[dict] = ContextVar("current_scope") + + +def register_paper_muncher(django_asgi_app): + """ + Registers the `run_paper_muncher` method on a Django ASGI app object. + Adds middleware to capture the scope. + """ + class PaperMuncherScopeMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + token = _current_scope.set(scope) + try: + await self.app(scope, receive, send) + finally: + _current_scope.reset(token) + + async def run_paper_muncher(content, mode="print", **options): + scope = _current_scope.get() + runner = asgi_runner_factory(django_asgi_app, scope) + return await render(content, mode=mode, runner=runner, **options) + + django_asgi_app.run_paper_muncher = run_paper_muncher + middleware = PaperMuncherScopeMiddleware(django_asgi_app) + middleware.run_paper_muncher = run_paper_muncher + + return middleware diff --git a/meta/bindings/python/paper_muncher/frameworks/django_wsgi.py b/meta/bindings/python/paper_muncher/frameworks/django_wsgi.py new file mode 100644 index 000000000..804c959a0 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/django_wsgi.py @@ -0,0 +1,38 @@ +"""The :mod:`paper_muncher.frameworks.django_wsgi` module +provides integration with Django WSGI applications. +""" + +from contextvars import ContextVar +from ..runners.wsgi import wsgi_runner_factory +from ..synchronous import render + + +_current_environ: ContextVar[dict] = ContextVar("current_environ") + +def register_paper_muncher(django_wsgi_app): + """ + Registers the `run_paper_muncher` method on a Django WSGI app object. + Adds middleware to capture the WSGI environ. + """ + class PaperMuncherEnvironMiddleware: + def __init__(self, app): + self.app = app + + def __call__(self, environ, start_response): + token = _current_environ.set(environ) + try: + return self.app(environ, start_response) + finally: + _current_environ.reset(token) + + def run_paper_muncher(content, mode="print", **options): + environ = _current_environ.get() + runner = wsgi_runner_factory(django_wsgi_app, environ) + return render(content, mode=mode, runner=runner, **options) + + django_wsgi_app.run_paper_muncher = run_paper_muncher + + middleware = PaperMuncherEnvironMiddleware(django_wsgi_app) + middleware.run_paper_muncher = run_paper_muncher + + return middleware diff --git a/meta/bindings/python/paper_muncher/frameworks/fastapi.py b/meta/bindings/python/paper_muncher/frameworks/fastapi.py new file mode 100644 index 000000000..fdad014a6 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/fastapi.py @@ -0,0 +1,32 @@ +"""The :mod:`paper_muncher.frameworks.fastapi` module +provides integration with FastAPI applications. +""" + +from contextvars import ContextVar +from ..runners.asgi import asgi_runner_factory +from ..asynchronous import render + + +_current_scope: ContextVar[dict] = ContextVar("current_scope") + +def register_paper_muncher(fastapi_app): + """ + Registers the `run_paper_muncher` method on a FastAPI application. + + Automatically adds middleware to capture the ASGI scope on each request. + """ + + async def run_paper_muncher(content, mode="print", **options): + scope = _current_scope.get() + runner = asgi_runner_factory(fastapi_app, scope) + return await render(content, mode=mode, runner=runner, **options) + + fastapi_app.run_paper_muncher = run_paper_muncher + + @fastapi_app.middleware("http") + async def capture_scope_middleware(request, call_next): + token = _current_scope.set(request.scope) + try: + return await call_next(request) + finally: + _current_scope.reset(token) diff --git a/meta/bindings/python/paper_muncher/frameworks/flask.py b/meta/bindings/python/paper_muncher/frameworks/flask.py new file mode 100644 index 000000000..61a5e73a8 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/flask.py @@ -0,0 +1,15 @@ +"""The :mod:`paper_muncher.frameworks.flask` module +provides integration with Flask applications. +""" + +from ..runners.wsgi import wsgi_runner_factory +from ..synchronous import render +from flask import request + + +def register_paper_muncher(flask_application): + def run_paper_muncher(content, mode="print", **options): + runner = wsgi_runner_factory(flask_application, request.environ) + return render(content, mode=mode, runner=runner, **options) + + flask_application.run_paper_muncher = run_paper_muncher diff --git a/meta/bindings/python/paper_muncher/frameworks/quart.py b/meta/bindings/python/paper_muncher/frameworks/quart.py new file mode 100644 index 000000000..3426c071d --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/quart.py @@ -0,0 +1,15 @@ +"""The :mod:`paper_muncher.frameworks.quart` module +provides integration with Quart applications. +""" + +from quart import request +from ..runners.asgi import asgi_runner_factory +from ..asynchronous import render + + +def register_paper_muncher(quart_application): + async def run_paper_muncher(content, mode="print", **options): + runner = asgi_runner_factory(quart_application, request.scope) + return await render(content, mode=mode, runner=runner, **options) + + quart_application.run_paper_muncher = run_paper_muncher diff --git a/meta/bindings/python/paper_muncher/frameworks/wsgi_app.py b/meta/bindings/python/paper_muncher/frameworks/wsgi_app.py new file mode 100644 index 000000000..a1bfe0a26 --- /dev/null +++ b/meta/bindings/python/paper_muncher/frameworks/wsgi_app.py @@ -0,0 +1,27 @@ +"""The :mod:`paper_muncher.frameworks.wsgi_app` module +provides integration with generic WSGI applications. +""" + +from contextvars import ContextVar +from ..runners.wsgi import wsgi_runner_factory +from ..synchronous import render + + +_current_environ: ContextVar[dict] = ContextVar("current_environ") + +def patch(application): + """ + Monkey-patches a WSGI application to add `run_paper_muncher()` that + can render content using the current WSGI environ from the request. + + Requires a WSGI middleware to set the environ per request. + """ + + def run_paper_muncher(content, mode="print", **options): + environ = _current_environ.get() + runner = wsgi_runner_factory(application, environ) + return render(content, mode=mode, runner=runner, **options) + + application.run_paper_muncher = run_paper_muncher + + return _current_environ diff --git a/meta/bindings/python/paper_muncher/runners/asgi.py b/meta/bindings/python/paper_muncher/runners/asgi.py new file mode 100644 index 000000000..d279f9e11 --- /dev/null +++ b/meta/bindings/python/paper_muncher/runners/asgi.py @@ -0,0 +1,77 @@ +import logging +from datetime import datetime, timezone +from email.utils import format_datetime +from typing import AsyncGenerator + +import httpx + +_logger = logging.getLogger(__name__) +SERVER_SOFTWARE = 'Paper Muncher (ASGI Request SIMULATION)' + + +async def generate_http_response( + request_path: str, + application, + scope: dict, +) -> AsyncGenerator[bytes, None]: + """Simulate an internal HTTP GET request to an ASGI app and yield + the full HTTP response (headers + body) as bytes. + + :param request_path: Path to query within the ASGI app. + :param application: The ASGI application to query. + :param scope: The ASGI scope from the current request. + :yield: Chunks of the full HTTP response. + """ + headers = { + "host": scope.get("headers", {}).get(b"host", b"localhost").decode("latin1"), + "user-agent": SERVER_SOFTWARE, + } + + client_addr = scope.get("client", ("127.0.0.1", 0))[0] + if client_addr: + headers["x-forwarded-for"] = client_addr + + host = headers.get(b"host", b"localhost").decode() + + async with httpx.AsyncClient( + app=application, + base_url=host, + ) as client: + response = await client.get(request_path, headers=headers) + + now = datetime.now(timezone.utc) + response_header = ( + f"HTTP/1.1 {response.status_code} {response.reason_phrase}\r\n" + f"Date: {format_datetime(now, usegmt=True)}\r\n" + f"Server: {SERVER_SOFTWARE}\r\n" + f"Content-Length: {len(response.content)}\r\n" + f"Content-Type: {response.headers.get('Content-Type', 'application/octet-stream')}\r\n" + "\r\n" + ).encode() + + yield response_header + yield response.content + + +def asgi_runner_factory(application, scope: dict): + """Create a runner coroutine that can generate HTTP responses + from an ASGI application using the current request scope. + + :param application: The ASGI app. + :param scope: The current ASGI request scope. + :return: Async function taking a request path and yielding bytes. + """ + _logger.debug( + "Creating ASGI runner for application %r with scope %r", + application, + { + "client": scope.get("client"), + "headers": dict(scope.get("headers", [])), + } + ) + + async def runner(request_path: str) -> AsyncGenerator[bytes, None]: + async for chunk in generate_http_response(request_path, application, scope): + yield chunk + + return runner diff --git a/meta/bindings/python/paper_muncher/runners/wsgi.py b/meta/bindings/python/paper_muncher/runners/wsgi.py new file mode 100644 index 000000000..4fe4407bf --- /dev/null +++ b/meta/bindings/python/paper_muncher/runners/wsgi.py @@ -0,0 +1,151 @@ +"""The :mod:`paper_muncher.runners.wsgi` module +provides utilities to simulate HTTP requests to a WSGI application. +It includes functions to generate WSGI environments and simulate +HTTP responses from a WSGI app. +""" +import logging +import os +from collections.abc import Generator +from datetime import datetime, timezone +from email.utils import format_datetime +from typing import Optional +try: + from wsgiref.types import WSGIEnvironment, WSGIApplication +except ImportError: + from typing import Any, Callable, Iterable, Tuple + WSGIStartResponse = Callable[ + [str, list[Tuple[str, str]], Optional[Exception]], + None + ] + WSGIEnvironment = dict[str, Any] + WSGIApplication = Callable[ + [WSGIEnvironment, WSGIStartResponse], + Iterable[bytes] + ] + +from werkzeug.test import create_environ, run_wsgi_app + +_logger = logging.getLogger(__name__) +SERVER_SOFTWARE = 'Paper Muncher (WSGI Request SIMULATION)' + + +def generate_environ( + path: str, + current_environ: WSGIEnvironment, +) -> WSGIEnvironment: + """Generate a WSGI environment for the given path. + This is used to simulate an HTTP request to a WSGI application. + :param str path: The HTTP request path. + :return: The WSGI environment dictionary. (See PEP 3333) + :rtype: WSGIEnvironment + """ + url, _, query_string = path.partition('?') + environ = create_environ( + method='GET', + path=url, + query_string=query_string, + headers={ + 'Host': current_environ['HTTP_HOST'], + 'User-Agent': SERVER_SOFTWARE, + 'http_cookie': current_environ['HTTP_COOKIE'], + 'remote_addr': current_environ['REMOTE_ADDR'], + } + ) + return environ + + +def generate_http_response( + request_path: str, + application: WSGIApplication, + environ: WSGIEnvironment, +) -> Generator[bytes, None, None]: + """Simulate an internal HTTP GET request to an WSGI app and yield + the HTTP response headers and body as bytes. + The use of it is mainly permitting to call a wsgi application from an + inline external application, such as a subprocess requesting resources. + + Note: This function doesn't preserves the thread-local data. + + usage example: + .. code-block:: python + + from paper_muncher.runners.wsgi import generate_http_response + + for chunk in generate_http_response('/my/request/path'): + print(chunk.decode()) + + :param str request_path: Path to query within the wsgi app. + :param WSGIApplication application: The WSGI application to query. + :param WSGIEnvironment environ: The current WSGI environment. + :yields: Chunks of the full HTTP response to the simulated request. + :rtype: Generator[bytes, None, None] + """ + + response_iterable, http_status, http_response_headers = run_wsgi_app( + application, generate_environ( + path=request_path, + current_environ=environ + ) + ) + + if "X-Sendfile" in http_response_headers: + with open(http_response_headers["X-Sendfile"], 'rb') as file: + now = datetime.now(timezone.utc) + http_response_status_line_and_headers = ( + f"HTTP/1.1 {http_status}\r\n" + f"Date: {format_datetime(now, usegmt=True)}\r\n" + f"Server: {SERVER_SOFTWARE}\r\n" + f"Content-Length: {os.path.getsize(http_response_headers['X-Sendfile'])}\r\n" + f"Content-Type: {http_response_headers['Content-Type']}\r\n" + "\r\n" + ).encode() + + yield http_response_status_line_and_headers + yield from file + + else: + now = datetime.now(timezone.utc) + http_response_status_line_and_headers = ( + f"HTTP/1.1 {http_status}\r\n" + f"Date: {format_datetime(now, usegmt=True)}\r\n" + f"Server: {SERVER_SOFTWARE}\r\n" + f"Content-Length: {http_response_headers['Content-Length']}\r\n" + f"Content-Type: {http_response_headers['Content-Type']}\r\n" + "\r\n" + ).encode() + + yield http_response_status_line_and_headers + yield from response_iterable + + +def wsgi_runner_factory( + application: WSGIApplication, + environ: WSGIEnvironment, +): + """Create a runner function that can be used to generate HTTP responses + from a WSGI application. + + :param WSGIApplication application: The WSGI application to query. + :param WSGIEnvironment environ: The current WSGI environment. + (See PEP 3333) This environment only needs to provide the + necessary keys to build a new environment for each request. + (Host, http_cookie, remote_addr) + :return: A function that takes a request path and yields the HTTP response. + :rtype: Callable[[str], Generator[bytes, None, None]] + """ + _logger.debug( + "Creating WSGI runner for application %r with environ %r", + application, + {k: environ[k] for k in ( + 'HTTP_HOST', + 'REMOTE_ADDR', + ) if k in environ} + ) + + def runner(request_path: str) -> Generator[bytes, None, None]: + return generate_http_response( + request_path, + application, + environ, + ) + return runner diff --git a/meta/bindings/python/paper_muncher/synchronous/__init__.py b/meta/bindings/python/paper_muncher/synchronous/__init__.py new file mode 100644 index 000000000..9821b3a97 --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/__init__.py @@ -0,0 +1,9 @@ +"""The :mod:`paper_muncher.synchronous` module +provides the core functionality for rendering documents +using the Paper Muncher engine. +It includes the main rendering functions and utilities +for managing the rendering process. +""" + +from .interface import rendered, render +from ..binary import can_use_paper_muncher diff --git a/meta/bindings/python/paper_muncher/synchronous/interface.py b/meta/bindings/python/paper_muncher/synchronous/interface.py new file mode 100644 index 000000000..2920c17a0 --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/interface.py @@ -0,0 +1,274 @@ +""" +The :mod:`.paper_muncher.synchronous.interface` module provides +utilities for interacting with Paper Muncher, a subprocess used to render +HTML content into or Image format. +""" + + +import logging +import subprocess + +from datetime import datetime, timezone +from contextlib import contextmanager +from collections.abc import Generator +from email.utils import format_datetime +from io import BytesIO +from itertools import count +from typing import BinaryIO, Optional + +from .request import ( + consume_paper_muncher_request, + read_paper_muncher_request, +) +from .io_with_timeout import ( + read_all_with_timeout, + write_with_timeout, +) +from .popen import Popen +from ..binary import get_paper_muncher_binary, can_use_paper_muncher + +from ..typing import Runner + +_logger = logging.getLogger(__name__) + +AUTHORIZED_MODE = {'print', 'render'} +DEFAULT_READ_TIMEOUT = 60 # seconds +DEFAULT_READLINE_TIMEOUT = 60 * 15 # seconds (15 minutes is for the put request) +DEFAULT_WRITE_TIMEOUT = 30 # seconds +DEFAULT_CHUNK_SIZE = 4096 # bytes +DEFAULT_WAIT_TIMEOUT = 5 # seconds +NOT_RENDERABLE_OPTIONS = { + 'read_timeout', + 'readline_timeout', + 'write_timeout', + 'chunk_size', + 'wait_timeout', +} +SERVER_SOFTWARE = b'Paper Muncher (Fully Synchronous Engine)' + + +@contextmanager +def rendered( + content: BytesIO, + mode: str = "print", + runner: Optional[Runner] = None, + **options, +) -> Generator[tuple[BinaryIO], None, None]: + """Context manager to render HTML content using Paper Muncher. + + :param content: The HTML content to render, as a BytesIO object. + :param mode: The rendering mode, either 'print' or 'render'. + :param runner: Optional runner function to handle asset requests. + :param options: Additional options to pass to Paper Muncher. + :return: A generator yielding the stdout and stderr streams of the + Paper Muncher process. + :raises RuntimeError: If Paper Muncher is not available or crashes. + :raises ValueError: If an invalid mode is specified. + """ + + if not can_use_paper_muncher(): + raise RuntimeError( + "Paper Muncher is not available in the current session. " + "Ensure it is installed and available in the system PATH." + ) + + if not mode in AUTHORIZED_MODE: + raise ValueError( + f"Invalid mode '{mode}', must be one of {AUTHORIZED_MODE}" + ) + + readline_timeout = options.get( + 'readline_timeout', + DEFAULT_READLINE_TIMEOUT, + ) + write_timeout = options.get('write_timeout', DEFAULT_WRITE_TIMEOUT) + wait_timeout = options.get('wait_timeout', DEFAULT_WAIT_TIMEOUT) + + extra_args = [] + for option, value in options.items(): + if option in NOT_RENDERABLE_OPTIONS: + continue + extra_args.extend([ + f'--{option}', str(value), + ]) + + if not (binary := get_paper_muncher_binary()): + raise RuntimeError( + "Paper Muncher binary not found or not usable. " + "Ensure it is installed and available in the system PATH." + ) + + with Popen( + [binary, mode, "pipe:", '-o', "pipe:"] + extra_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as process: + # Phase 1: send HTML content headers and body + try: + consume_paper_muncher_request( + process.stdout, + timeout=readline_timeout, + ) + except EOFError as early_eof: + raise RuntimeError( + "Paper Muncher terminated prematurely (phase 1)" + ) from early_eof + + if process.poll() is not None: + raise RuntimeError( + "Paper Muncher crashed before receiving content") + + now = datetime.now(timezone.utc) + response_headers = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: %(length)d\r\n" + b"Content-Type: text/html\r\n" + b"Date: %(date)s\r\n" + b"Server: %(server)s\r\n" + b"\r\n" + ) % { + b'length': len(content.encode()), + b'date': format_datetime(now, usegmt=True).encode(), + b'server': SERVER_SOFTWARE, + } + + write_with_timeout( + process.stdin, + response_headers, + timeout=write_timeout, + ) + write_with_timeout( + process.stdin, + content.encode(), + timeout=write_timeout, + ) + process.stdin.flush() + + if process.poll() is not None: + raise RuntimeError( + "Paper Muncher crashed while sending HTML content") + + # Phase 2: serve asset requests until the rendered content is ready + for request_no in count(start=1): + try: + path = read_paper_muncher_request( + process.stdout, + timeout=readline_timeout, + ) + except (EOFError, TimeoutError): + process.kill() + process.wait() + raise + + if path is None: + break + + for chunk in runner(path): + write_with_timeout( + process.stdin, + chunk, + timeout=write_timeout + ) + process.stdin.flush() + + if process.poll() is not None: + raise RuntimeError( + "Paper Muncher crashed while serving asset" + f" {request_no}: {path}" + ) + + # Phase 3: send final OK and close the process + now = datetime.now(timezone.utc) + final_response = ( + b"HTTP/1.1 200 OK\r\n" + b"Date: %(date)s\r\n" + b"Server: %(server)s\r\n" + b"\r\n" + ) % { + b'date': format_datetime(now, usegmt=True).encode(), + b'server': SERVER_SOFTWARE, + } + + write_with_timeout( + process.stdin, + final_response, + timeout=write_timeout, + ) + process.stdin.flush() + process.stdin.close() + + if process.poll() is not None: + raise RuntimeError( + "Paper Muncher crashed before returning the rendered content" + ) + + try: + yield process.stdout, process.stderr + finally: + try: + process.wait(timeout=wait_timeout) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + _logger.warning( + "Paper Muncher did not terminate in time," + "forcefully killed it" + ) + + if process.returncode != 0: + _logger.warning( + "Paper Muncher exited with code %d", + process.returncode, + ) + + +def render( + content: BytesIO, + mode: str = "print", + runner: Optional[Runner] = None, + **options, +) -> bytes: + """Render HTML content using Paper Muncher and return the rendered output. + + :param content: The HTML content to render, as a BytesIO object. + :param mode: The rendering mode, either 'print' or 'render'. + :param runner: Optional runner function to handle asset requests. + :param options: Additional options to pass to Paper Muncher. + :return: The rendered content as bytes. + :raises RuntimeError: If Paper Muncher is not available or crashes. + :raises ValueError: If an invalid mode is specified. + """ + + with rendered( + content, + mode=mode, + runner=runner, + **options, + ) as (content_stream, error_stream): + read_timeout = options.get('read_timeout', DEFAULT_READ_TIMEOUT) + chunk_size = options.get('chunk_size', DEFAULT_CHUNK_SIZE) + rendered_content = read_all_with_timeout( + content_stream, + chunk_size=chunk_size, + timeout=read_timeout, + ) + stderr_output = read_all_with_timeout( + error_stream, + chunk_size=chunk_size, + timeout=read_timeout, + ) + + if stderr_output: + _logger.warning( + "Paper Muncher error output: %s", + stderr_output.decode('utf-8', errors='replace'), + ) + + if mode == "print": + if not rendered_content.startswith(b'%PDF-'): + raise RuntimeError( + "Paper Muncher did not return valid PDF content" + ) + + return rendered_content diff --git a/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/__init__.py b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/__init__.py new file mode 100644 index 000000000..4e78a2e0e --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/__init__.py @@ -0,0 +1,37 @@ +"""The :mod:`synchronous.io_with_timeout` +module provides cross-platform utilities for I/O operations with timeouts. +It includes functions for reading and writing data with a specified timeout, +and handles platform-specific differences in I/O behavior when possible. +""" + +import os +import logging +import sys + +_logger = logging.getLogger(__name__) + + +if os.name == 'posix': + _logger.info("Using POSIX communications module") + from .posix.communications import ( + read_all_with_timeout, + readline_with_timeout, + write_with_timeout, + ) +elif os.name == 'nt' and sys.version_info >= (3, 12): + _logger.info("Using NT communications module") + from .nt.communications import ( + read_all_with_timeout, + readline_with_timeout, + write_with_timeout, + ) +else: + _logger.warning( + "Using basic communications module without proper" + " anti stalled process handling" + ) + from .fallback.communications import ( + read_all_with_timeout, + readline_with_timeout, + write_with_timeout, + ) diff --git a/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/common.py b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/common.py new file mode 100644 index 000000000..1c8d9bd60 --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/common.py @@ -0,0 +1,29 @@ +"""The :mod:`synchronous.io_with_timeout.common` +module provides utilities for managing timeouts in I/O operations. +It includes a function to calculate the remaining time until a deadline, +and raises a `TimeoutError` if the deadline has already passed. +""" + +import inspect +import time + + +def remaining_time(deadline: float) -> float: + """Calculate the remaining time until a deadline. + + :param float deadline: The deadline timestamp. + :return: Remaining time in seconds. + :rtype: float + :raises TimeoutError: If the deadline has already passed. + """ + remaining = deadline - time.monotonic() + if remaining <= 0: + caller_frame = inspect.currentframe().f_back + raise TimeoutError( + "Timeout exceeded in function %(function)s at line %(line)d" + " in file %(file)s" % { + 'function': caller_frame.f_code.co_name, + 'line': caller_frame.f_lineno, + 'file': caller_frame.f_code.co_filename, + }) + return remaining diff --git a/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/fallback/communications.py b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/fallback/communications.py new file mode 100644 index 000000000..97ce4246b --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/fallback/communications.py @@ -0,0 +1,70 @@ +"""The :mod:`synchronous.io_with_timeout.fallback.communications` +module provides fallback implementations for reading and writing data +with timeouts in a file-like object. +This means it does not support timeouts and may lead to stalled processes. +""" + +import logging + +from typing import BinaryIO + +_logger = logging.getLogger(__name__) + + +def readline_with_timeout( + file_object: BinaryIO, + timeout: int, +) -> bytes: + """Read a full line ending with '\\n' from a file-like object. + + :param BinaryIO file_object: File-like object to read from + (must be in binary mode). + :param int timeout: UNUSED timeout parameter. + (Fallback implementation does not support timeouts) + :return: A line of bytes ending in '\\n'. + :rtype: bytes + """ + _logger.warning( + "Using fallback readline_with_timeout. " + "This may lead to stalled processes." + ) + return file_object.readline() + + +def read_all_with_timeout( + file_object: BinaryIO, + timeout: int, +) -> bytes: + """Read all data from a file-like object until EOF. + + :param BinaryIO file_object: File-like object to read from. + :param int timeout: UNUSED timeout parameter. + (Fallback implementation does not support timeouts) + :return: All bytes read from the file-like object. + :rtype: bytes + """ + _logger.warning( + "Using fallback readlines_with_timeout. " + "This may lead to stalled processes." + ) + return file_object.read() + + +def write_with_timeout( + file_object: BinaryIO, + data: bytes, + timeout: int, +) -> None: + """Write data to a file-like object. + + :param BinaryIO file_object: File-like object to write to. + :param bytes data: Data to write. + :param int timeout: UNUSED timeout parameter. + (Fallback implementation does not support timeouts) + """ + _logger.warning( + "Using fallback write_with_timeout. " + "This may lead to stalled processes." + ) + file_object.write(data) + file_object.flush() diff --git a/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/nt/communications.py b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/nt/communications.py new file mode 100644 index 000000000..414353cbf --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/nt/communications.py @@ -0,0 +1,129 @@ +"""The :mod:`synchronous.io_with_timeout.nt.communications` +module provides cross-platform utilities for reading and writing data +with timeouts on Windows systems. It includes functions for reading lines +and chunks of data, as well as writing data to file-like objects +with a specified timeout. +:note: This module is specifically designed for Windows and requires +Python 3.12 or later. +""" + +import sys +if sys.version_info < (3, 12): + raise ImportError( + "This module requires Python 3.12 or later" + ) + +import logging +import os +import time + +from typing import BinaryIO + +from ..common import remaining_time + +_logger = logging.getLogger(__name__) + + +def readline_with_timeout( + file_object: BinaryIO, + timeout: int, +) -> bytes: + """Read a full line ending with '\\n' from a file-like object within a + timeout. + + :param BinaryIO file_object: File-like object to read from + (must be in binary mode). + :param int timeout: Max seconds to wait for line data. + :return: A line of bytes ending in '\\n'. + :rtype: bytes + :raises TimeoutError: If timeout is reached before a line is read. + :raises EOFError: If EOF is reached before a line is read. + """ + fd = file_object.fileno() + deadline = time.monotonic() + timeout + line_buffer = bytearray() + + if os.get_blocking(fd): + os.set_blocking(fd, False) + + while remaining_time(deadline): + next_byte = os.read(fd, 1) + if next_byte is None: + time.sleep(0.01) + elif not next_byte: + raise EOFError("EOF reached while reading line") + else: + line_buffer += next_byte + if next_byte == b'\n': + break + + return bytes(line_buffer) + + +def read_all_with_timeout( + file_object: BinaryIO, + timeout: int, + chunk_size: int, +) -> bytes: + """Read all data from a file-like object until EOF, with a timeout per + chunk. + + :param BinaryIO file_object: File-like object to read from. + :param int timeout: Timeout in seconds for the entire read operation. + :param int chunk_size: Number of bytes to read per chunk. + :return: All bytes read until EOF. + :rtype: bytes + :raises TimeoutError: If no data is read within the timeout period. + """ + fd = file_object.fileno() + data = bytearray() + deadline = time.monotonic() + timeout + + if os.get_blocking(fd): + os.set_blocking(fd, False) + + while remaining_time(deadline): + chunk = os.read(fd, chunk_size) + if chunk is None: + time.sleep(0.01) + elif not chunk: + break + else: + data.extend(chunk) + + _logger.debug( + "Elapsed time reading: %.3f seconds", + time.monotonic() - (deadline - timeout) + ) + return bytes(data) + + +def write_with_timeout( + file_object: BinaryIO, + data: bytes, + timeout: int, +) -> None: + """Write all data to a file-like object within a timeout, using selectors. + + :param BinaryIO file_object: File-like object to write to. + :param bytes data: Bytes to write. + :param int timeout: Max seconds to wait for write readiness. + :raises TimeoutError: If writing cannot complete within timeout. + """ + fd = file_object.fileno() + total_written = 0 + deadline = time.monotonic() + timeout + + if os.get_blocking(fd): + os.set_blocking(fd, False) + + while remaining_time(deadline): + written = os.write(fd, data[total_written:]) + if written is None: + time.sleep(0.01) + elif written == 0: + raise RuntimeError("Write operation returned zero bytes") + else: + total_written += written + if total_written >= len(data): + break diff --git a/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/posix/communications.py b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/posix/communications.py new file mode 100644 index 000000000..8271b4ba4 --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/io_with_timeout/posix/communications.py @@ -0,0 +1,126 @@ +"""The :mod:`synchronous.io_with_timeout.posix.communications` +module provides POSIX-specific implementations for reading and writing data +with timeouts in a file-like object. +This module uses the `selectors` module to handle I/O operations +in a non-blocking manner, allowing for timeouts on read and write operations. +""" + +import logging +import os +import selectors +import time + +from typing import BinaryIO + +from ..common import remaining_time + +_logger = logging.getLogger(__name__) + + +def readline_with_timeout( + file_object: BinaryIO, + timeout: int, +) -> bytes: + """Read a full line ending with '\\n' from a file-like object within a + timeout. + + :param BinaryIO file_object: File-like object to read from + (must be in binary mode). + :param int timeout: Max seconds to wait for line data. + :return: A line of bytes ending in '\\n'. + :rtype: bytes + :raises TimeoutError: If timeout is reached before a line is read. + :raises EOFError: If EOF is reached before a line is read. + """ + fd = file_object.fileno() + deadline = time.monotonic() + timeout + line_buffer = bytearray() + + with selectors.DefaultSelector() as selector: + selector.register(fd, selectors.EVENT_READ) + + while selector.select(timeout=remaining_time(deadline)): + next_byte = os.read(fd, 1) + if not next_byte: + raise EOFError("EOF reached while reading line") + + line_buffer += next_byte + if next_byte == b'\n': + break + + _logger.debug( + "Elapsed time reading line: %.3f seconds", + time.monotonic() - (deadline - timeout) + ) + return bytes(line_buffer) + + +def read_all_with_timeout( + file_object: BinaryIO, + timeout: int, + chunk_size: int, +) -> bytes: + """Read all data from a file-like object until EOF, with a timeout per + chunk. + + :param BinaryIO file_object: File-like object to read from. + :param int timeout: Timeout in seconds for the entire read operation. + :param int chunk_size: Number of bytes to read per chunk. + :return: All bytes read until EOF. + :rtype: bytes + :raises TimeoutError: If no data is read within the timeout period. + """ + fd = file_object.fileno() + data = bytearray() + deadline = time.monotonic() + timeout + + with selectors.DefaultSelector() as selector: + selector.register(fd, selectors.EVENT_READ) + while selector.select(timeout=remaining_time(deadline)): + chunk = os.read(fd, chunk_size) + if not chunk: + break + data.extend(chunk) + + _logger.debug( + "Elapsed time reading: %.3f seconds", + time.monotonic() - (deadline - timeout) + ) + return bytes(data) + + +def write_with_timeout( + file_object: BinaryIO, + data: bytes, + timeout: int, +) -> None: + """Write all data to a file-like object within a timeout, using selectors. + + :param BinaryIO file_object: File-like object to write to. + :param bytes data: Bytes to write. + :param int timeout: Max seconds to wait for write readiness. + :raises TimeoutError: If writing cannot complete within timeout. + """ + fd = file_object.fileno() + total_written = 0 + deadline = time.monotonic() + timeout + + with selectors.DefaultSelector() as selector: + selector.register(fd, selectors.EVENT_WRITE) + + while total_written < len(data): + events = selector.select(timeout=remaining_time(deadline)) + if not events: + raise TimeoutError( + "Timeout exceeded while writing to subprocess" + ) + + written = os.write(fd, data[total_written:]) + if written == 0: + raise RuntimeError("Write returned zero bytes") + total_written += written + + _logger.debug( + "Elapsed time writing: %.3f seconds", + time.monotonic() - (deadline - timeout) + ) diff --git a/meta/bindings/python/paper_muncher/synchronous/popen.py b/meta/bindings/python/paper_muncher/synchronous/popen.py new file mode 100644 index 000000000..232691760 --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/popen.py @@ -0,0 +1,33 @@ +"""The :mod:`paper_muncher.synchronous.popen` module +provides a cross-platform context manager for +subprocess.Popen that ensures non-blocking I/O for Windows. +This is necessary to avoid deadlocks when reading from subprocess streams. +""" + + +import os +import subprocess +import sys + + +if os.name == 'nt' and sys.version_info >= (3, 12): + from contextlib import contextmanager + + @contextmanager + def Popen(*args, **kwargs): + """Context manager for subprocess.Popen that sets non-blocking I/O + for stdin, stdout, and stderr. + This is necessary for Windows to avoid deadlocks when reading + from subprocess streams. + + :param args: Positional arguments for subprocess.Popen. + :param kwargs: Keyword arguments for subprocess.Popen. + :return: A context manager that yields the subprocess.Popen object. + """ + with subprocess.Popen(*args, **kwargs, bufsize=0) as proc: + os.set_blocking(proc.stdout, False) + os.set_blocking(proc.stderr, False) + os.set_blocking(proc.stdin, False) + yield proc +else: + Popen = subprocess.Popen diff --git a/meta/bindings/python/paper_muncher/synchronous/request.py b/meta/bindings/python/paper_muncher/synchronous/request.py new file mode 100644 index 000000000..6bf7be09d --- /dev/null +++ b/meta/bindings/python/paper_muncher/synchronous/request.py @@ -0,0 +1,91 @@ +"""The :mod:`paper_muncher.synchronous.request` +module provides utilities for consuming and reading +Paper Muncher requests. +It includes functions to read the request line, +and to consume the request headers. +It also handles timeouts for reading lines from the request. +""" + + +import logging +import time + +from typing import BinaryIO, Optional + +from .io_with_timeout import readline_with_timeout + +_logger = logging.getLogger(__name__) + + +def remaining_time(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Timeout exceeded") + return remaining + + +def consume_paper_muncher_request( + stdout: BinaryIO, + timeout: int +) -> None: + """Read and discard all header lines from a Paper Muncher request. + + :param BinaryIO stdout: File-like stdout stream from Paper Muncher. + :param int timeout: Timeout in seconds for each line read. + :return: None + :rtype: None + """ + deadline = time.monotonic() + timeout + while line := readline_with_timeout( + stdout, + timeout=remaining_time(deadline) + ): + _logger.debug("Paper Muncher request line: %s", line.rstrip()) + if line == b"\r\n": + return + if not line: + raise EOFError("EOF reached while reading request headers") + + +def read_paper_muncher_request( + stdout: BinaryIO, + timeout: int, +) -> Optional[str]: + """Read the HTTP-like request line from Paper Muncher and return the path. + + :param BinaryIO stdout: File-like stdout stream from Paper Muncher. + :param int timeout: Timeout in seconds for each line read. + :return: The requested asset path, or ``None`` if the method is PUT. + :rtype: str or None + :raises EOFError: If no request line is found. + :raises ValueError: If the request format is invalid or the method is + unsupported. + """ + deadline = time.monotonic() + timeout + first_line_bytes = readline_with_timeout( + stdout, + timeout=remaining_time(deadline) + ) + + if not first_line_bytes: + raise EOFError("EOF reached while reading first line from subprocess") + + first_line = first_line_bytes.decode('utf-8').rstrip('\r\n') + + _logger.debug("First Paper Muncher request line: %s", first_line) + + parts = first_line.split(' ') + if len(parts) != 3: + raise ValueError( + f"Invalid HTTP request line from Paper Muncher: {first_line}") + + method, path, _ = parts + if method == 'PUT': + path = None + elif method != 'GET': + raise ValueError( + f"Unexpected HTTP method: {method} in line: {first_line}") + + consume_paper_muncher_request(stdout, timeout=remaining_time(deadline)) + + return path diff --git a/meta/bindings/python/paper_muncher/typing.py b/meta/bindings/python/paper_muncher/typing.py new file mode 100644 index 000000000..07d2ceedc --- /dev/null +++ b/meta/bindings/python/paper_muncher/typing.py @@ -0,0 +1,15 @@ +"""The :mod:`paper_muncher.typing` module provides +type definitions used in Paper Muncher. +""" + +from typing import Callable + + +class Runner(Callable): + def __call__(self, path: str) -> bytes: + pass + + +class AsyncRunner(Callable): + async def __call__(self, path: str) -> bytes: + pass diff --git a/meta/bindings/python/papermuncher.py b/meta/bindings/python/papermuncher.py deleted file mode 100644 index 07b688f6f..000000000 --- a/meta/bindings/python/papermuncher.py +++ /dev/null @@ -1,141 +0,0 @@ -import dataclasses as dc -from email.message import Message -from pathlib import Path -from email.parser import BytesParser -import subprocess -import tempfile -from typing import IO -import magic - - -class Loader: - def handleRequest( - self, url: str, headers: dict[str, str] - ) -> tuple[int, dict[str, str], bytes]: - return ( - 404, - { - "mime": "text/html", - }, - b"404 Not Found", - ) - - -@dc.dataclass -class StaticDir(Loader): - _path: Path - - def __init__(self, path: Path): - self._path = path - - def handleRequest( - self, url: str, headers: dict[str, str] - ) -> tuple[int, dict[str, str], bytes]: - path = self._path / url - if not path.exists(): - return ( - 404, - { - "mime": "text/html", - }, - b"404 Not Found", - ) - with open(path, "rb") as f: - return ( - 200, - { - "mime": magic.Magic(mime=True).from_file(path), - }, - f.read(), - ) - - -def _run( - args: list[str], - loader=Loader(), -) -> bytes: - def _readRequest(fd: IO) -> Message[str, str] | None: - # Read the request header from the file descriptor - parser = BytesParser() - return parser.parse(fd) - - def _sendResponse(fd: IO, status: int, headers: dict[str, str], body: bytes): - fd.write(f"HTTP/2 {status}\r\n".encode()) - for key, value in headers.items(): - fd.write(f"{key}: {value}\r\n".encode()) - fd.write(b"\r\n") - fd.write(body) - - with subprocess.Popen( - args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) as proc: - stdout = proc.stdout - if stdout is None: - raise ValueError("stdout is None") - - stderr = proc.stderr - if stderr is None: - raise ValueError("stderr is None") - - stdin = proc.stdin - if stdin is None: - raise ValueError("stdin is None") - - while True: - request = _readRequest(stdout) - if request is None: - raise ValueError("request is None") - - if request.preamble is None: - raise ValueError("request.preamble is None") - - preamble = request.preamble.split(" ") - if preamble[0] == b"GET": - _sendResponse(stdin, *loader.handleRequest(preamble[1], dict(request))) - elif preamble[0] == b"POST": - payload = request.get_payload() - if not isinstance(payload, bytes): - raise ValueError("payload is not bytes") - proc.terminate() - return payload - else: - raise ValueError("Invalid request") - - -def find() -> Path: - return Path(__file__).parent / "bin" - - -def print( - document: bytes | str | Path, - mime: str = "text/html", - loader: Loader = StaticDir(Path.cwd()), - bin: Path = find(), - **kwargs: str, -) -> bytes: - extraArgs = [] - for key, value in kwargs.items(): - extraArgs.append(f"--{key}") - extraArgs.append(str(value)) - - if isinstance(document, Path): - return _run( - [str(bin), "print", "-i", str(document), "-o", "out.pdf"] + extraArgs, - loader, - ) - else: - with tempfile.NamedTemporaryFile(delete=False) as f: - if isinstance(document, str): - document = document.encode() - f.write(document) - return _run( - [str(bin), "print", "-i", f.name, "-o", "out.pdf"] + extraArgs, - loader, - ) - return b"" - - -__all__ = ["Loader", "StaticDir", "print"] diff --git a/meta/bindings/python/pyproject.toml b/meta/bindings/python/pyproject.toml new file mode 100644 index 000000000..4e7b7ead4 --- /dev/null +++ b/meta/bindings/python/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "paper_muncher" +version = "0.1.0" +description = "Python bindings for Paper Muncher" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "LGPL-3.0-or-later" } +authors = [ + { name = "Odoo", email = "info@odoo.com" } +] + +[tool.hatch.build] +exclude = [ + "/.doctrees", + "/documentation_source", + "/examples", +] + +[tool.hatch.build.targets.wheel] +packages = ["paper_muncher"] + +[tool.hatch.build.targets.sdist] +exclude = [ + "/.doctrees", + "/documentation_source", + "/examples", +] diff --git a/meta/bindings/python/sample.py b/meta/bindings/python/sample.py deleted file mode 100644 index e1a59c492..000000000 --- a/meta/bindings/python/sample.py +++ /dev/null @@ -1,12 +0,0 @@ -import papermuncher - -with open("out.pdf", "wb") as f: - document = """ -

Hello, world!

- """ - f.write( - papermuncher.print( - document, - paper="a4", - ) - )