From 5891b414665680748beb4bce34fe3a27c00449aa Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 24 Nov 2024 20:39:19 -0800 Subject: [PATCH 01/19] Bump ReactPy to v1.1.0 (#258) --- ...lease-docs.yml => publish-latest-docs.yml} | 2 +- CHANGELOG.md | 27 ++++++------------ README.md | 2 +- docs/src/reference/components.md | 2 +- docs/src/reference/router.md | 4 +-- docs/src/reference/settings.md | 14 ++++++++- requirements/pkg-deps.txt | 2 +- setup.py | 6 ++-- src/js/bun.lockb | Bin 101426 -> 102513 bytes src/js/package.json | 2 +- src/reactpy_django/config.py | 6 +++- src/reactpy_django/decorators.py | 8 ++---- src/reactpy_django/hooks.py | 6 ++-- src/reactpy_django/http/views.py | 2 ++ src/reactpy_django/pyscript/layout_handler.py | 2 +- src/reactpy_django/utils.py | 2 +- 16 files changed, 46 insertions(+), 41 deletions(-) rename .github/workflows/{publish-release-docs.yml => publish-latest-docs.yml} (96%) diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-latest-docs.yml similarity index 96% rename from .github/workflows/publish-release-docs.yml rename to .github/workflows/publish-latest-docs.yml index 93df3e2a..bc7409f0 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -1,4 +1,4 @@ -name: Publish Release Docs +name: Publish Latest Docs on: release: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5342e003..4719dd02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,24 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), @@ -36,7 +19,13 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Added + +- `settings.py:REACTPY_ASYNC_RENDERING` to enable asynchronous rendering of components. + +### Changed + +- Bumped the minimum ReactPy version to `1.1.0`. ## [5.0.0] - 2024-10-22 diff --git a/README.md b/README.md index d3d2a1a9..89d1fb11 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - [Customizable reconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#stability-settings) - [Customizable disconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag) - [Multiple root components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/) -- [Cross-process communication/signaling (Channel Layers)](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) +- [Cross-process communication/signaling](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) - [Django view to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#view-to-component) - [Django static file access](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-css) - [Django database access](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-query) diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 943b76c0..7c60ca68 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -160,7 +160,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. - Requires manual intervention to change HTTP methods to anything other than `GET`. - ReactPy events cannot conveniently be attached to converted view HTML. - - Has no option to automatically intercept local anchor link (such as `#!html `) click events. + - Has no option to automatically intercept click events from hyperlinks (such as `#!html `). ??? question "How do I use this for Class Based Views?" diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 28b84351..be6093c6 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -50,6 +50,6 @@ URL router that enables the ability to conditionally render other components bas | --- | --- | | `#!python VdomDict | None` | The matched component/path after it has been fully rendered. | -??? question "How is this different from `#!python reactpy_router.simple.router`?" +??? question "How is this different from `#!python reactpy_router.browser_router`?" - This component utilizes `reactpy-router` under the hood, but provides a more Django-like URL routing syntax. + The `django_router` component utilizes the same internals as `browser_router`, but provides a more Django-like URL routing syntax. diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 3f35ee4d..e65dd203 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -87,7 +87,7 @@ This is useful to continuously update `#!python last_login` timestamps and refre Multiprocessing-safe database used by ReactPy for database-backed hooks and features. -If configuring this value, it is mandatory to enable our database router like such: +If configuring this value, it is mandatory to configure Django to use the ReactPy database router: === "settings.py" @@ -123,6 +123,18 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne). --- +### `#!python REACTPY_ASYNC_RENDERING` + +**Default:** `#!python False` + +**Example Value(s):** `#!python True` + +Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is typically a relatively slow operation). + +This setting is currently experimental, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient. + +--- + ### `#!python REACTPY_DEFAULT_HOSTS` **Default:** `#!python None` diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index cec6a9e1..61182ef9 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,6 +1,6 @@ channels >=4.0.0 django >=4.2.0 -reactpy >=1.0.2, <1.1.0 +reactpy >=1.1.0, <1.2.0 reactpy-router >=1.0.0, <2.0.0 dill >=0.3.5 orjson >=3.6.0 diff --git a/setup.py b/setup.py index a3388b35..f0c2f22d 100644 --- a/setup.py +++ b/setup.py @@ -11,8 +11,6 @@ from setuptools.command.develop import develop from setuptools.command.sdist import sdist -log = getLogger(__name__) - # ----------------------------------------------------------------------------- # Basic Constants # ----------------------------------------------------------------------------- @@ -22,6 +20,7 @@ js_dir = src_dir / "js" package_dir = src_dir / name static_dir = package_dir / "static" / name +log = getLogger(__name__) # ----------------------------------------------------------------------------- @@ -60,7 +59,10 @@ "Intended Audience :: Developers", "Intended Audience :: Science/Research", "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Widget Sets", + "Topic :: Software Development :: User Interfaces", "Environment :: Web Environment", + "Typing :: Typed", ], } diff --git a/src/js/bun.lockb b/src/js/bun.lockb index 3807b5711eaf5836a8f91e41e3a15c31dc0c1370..142d0d8330711681d02299206f06f9d3b3333663 100644 GIT binary patch delta 19389 zcmeHvc~})?-~Zer2OR`N*$#_>q6jL;q8t`E0^&Mgnig7GBA}oM2)KX}$2HB&N4W#!kKm4wHM$&~ z5t5ysKUO%$6PaImMt){iW>#MD2aN?mLaTk~lF-o{2J=b5eFrMmGB76lG1ukU}j2NOPZTsby0^dU19L6wSy^ z&mD{U1+6qkW~Ud0K;O|b!D*Z{Kdt>nNYXKVjB1)Np)e<8OpzcIOvo#t9P8?@>QZj= z!3p%X4Td7*vr*|q#p5wSVb+AAoKYo0Woym2@oh9?sv$|SbVzC+g?^aZ<^u^kZMOmh z0lM3ogOdVGANQNK+BoMSQDj@(j%2xDn}rkPh|NDx^V0V)gA(inBn{jbk}}-ZUbApP z2SI2KejSn&u@BbDN9Px0LT};SAZSv>rhT9l&#BA`Gp}_<>?vOA^HHW zdW90AY5FZVE$pWuTR`4MIfbtdiqKLV+*$LSO;^MxOk0v$AZem87?__jhwnm7}Z4DdpG;)Pl18O7Me z9KlJ^+H#cA#6Ln(!*h^S;gqO(Vmu@*?ZJ9|6(sS@B(2{ONV2R`4~>5aNeTxiYZLnE z5+NwcDyHQfGA_M95O$&+8Ct#qC*+csASpKb-jKBj6OpB*kW@cX=f-~i=oa_rUS#Y& z7kX=ooq{C8-i4$j-mLLBTWCM6qC#)@=(;s_;Q6ShSeNJnT$Oo;CaoOKrf3EqP2o>A znV4{(>}qA)iDP4aJRA}-!1Ln8-D8HfYwj_~KmBy{_CeAQOP~01z_z#2FD(3i(Bknw zZhQ5^$@7Y7oEs=2hRhMOh-gwM7${=;rVyDs}GubF*5m@h_DR4uDb*jUpp zFU1zQDb2q9w8rORD-xWZs(HcIW})k%onC(OVb_4hNA8VyGX3-PUv~|VewUa%PYmqH zw?|3Lj+eSxr4e?5&<6$xd|Gph^g0+~r!l7=z_12vw13$1aF>oetf$n(BnaKLChaXw zd0_E+6BmBCIhyoVn?Rl)HRon*QVHJO{=cOK2=Fj(dbT;T1gzt5=ii^Q8)Td2&Em$jw$GTfh<0L^C zz^gsu#M2V5MIVy`k2PDxB~5v$*(#+VzTH%P6WuN194B6j!jJSq6K7r)(ZQLQdRe6z zNOi5{VW@r3nb&$*O?Ock#^XKXOdYY7`|xV$R@98|^|p#t&3LW1)$|+6d-3Y#aZ(I& zJXW*Y)gn%J;d_0o(iIeHqhczlC9*jgO&GaGN(UoH+3{&DEYeyqtXir=%hp(cauYx7YB3!K zizR*8U4Exkcd?rrk8NR~N63MP%;0*3T++$J*4J_*;}Knrn}S*I{_)c_+fZ&I2b917SMQwnePp-O6T-K%mSCX zVRMW^lScfoi$&@O)=Oia7HOegC-TI|Xy(Oh+gjN@9@DP7812nV+gZg4-h6L6t8^5X z8j7*%OVQtl#|B!(K|Z`R&?>FOW=wSmSv!mA9M}L}=NBgh`f7?o3#j&-FE4Fxl@_9~ z6B>*BFs9!RM%p*#6v)=Ny@^okC`$%CWN`Z|Fj5<-0CpaX7A%~a6y2niF%RM|JqbpG z*m34+kye3G;=#l27Sk1Ksp{s13xic{1)Zf#Fd9zHGif^*MGOx1wy+=hoetflzW!Pu zU?bEn1EUbY2p5ZKH`oALE0PJfN5iyn+$>^xYaZLlD(-5{OFLPmtEi(WAW#7o$+wLl z^i*3;gI;A|2|ON|eF|q}CZmiHTLkdEAy#Q%fFL}jbwxNgf(-x@u%%g~7HtJ#pgIsd zC}y-(7ymL8(gK5i-WKUku>N3;)V;1J?y#s1u0ym|6H`5M8D~i-*Op;0?!WXk4NEct zd1;tc+JeF-(Zmiro<;I%F9=U-9T47m?RjjtRjlm5OT(>FZ3n{)iey5^2RVu;zW_!_ zuTCUx>&QzZtWq5cDbW!DbO=X==(-?=MPQ^0I-vX32aF>39T-JI;LL0h{X%$cq*WXq z!ehHwr8hz}yBVJbM>&QH!Vql~%#aC|qA{e?b}(|O8gY}*Sr7*E_%3l$9?mG9m>I23 zfN8NI9m8M)IRkZIyTNoF11#dtVWfa5IGh$V4KyBSxU^DZ7_<-UL1WV&U{+oh-QA=q zgjFq;NATEatMnccS$8q!@Q!@IkOl={x&_#__eb*B7^`H44|Ns&EvDzd`tdriIO%nq zQM$tw*dnij4dL-IaiX;=FO9{b?E0V^JbVs}e5!gwaz@fVP~9{H4A-oXIPsMzzBkS) z{iauIfg5Ns9EtRm)`C$OvCrcg^tFZW?PisNFtoP1$ZwfogEhyX!@FQ~RmNaoLX4KF za2o7Q03*||n*>-)v%!*7*PeaQinc>H_*EYrt84(6K04;w0H%3!8usd1uwJTj`o|d; zr^6u@X)zf2Rny@zm>y+hKp2MB88n&>M%rrnodA1IW6(V){z10kN^6VfrHNMQdlbU; zxYl6X5ZhaMEecm4E~B)52pPjYMmMrcjPK6(CRxSl-Fa=2RXT`rC~U*cMv}#J2MpF@ zzlg(9*0fEGv4}4w@Y)_$@niyzO}0v{al5K#HIi;780`XDzWxg=84TV`wn!d`l3wR# zktTxasffMqJecM~@~;(lrh1*X#q>E?AEa)#CK!v{t~o9LAw#xp2J6DB+p3Moe6^A2 z+LM>|wo0S03u-~3bl49@$%ZCyw>{!bi3kR@tYAaHw6t6bruDSZ_y?6j-A3CG_Ey8Z6ZdNQ!*xP|vPuQO`}J~?G~fZ9lca$U>b$h%?RJxOZ6 z5|R|H)cJayZ_?#vNc;#}@QdQHll4%aPYH)mK!JE4vN7Z-NK)uLBn|i_Bq@3sl8(Pg zUOn71kt(j>7s;>ii$?nnl6qfQgAK+>pY{A$EgpY}t33qngO(j}ex z>6|3>^4B>@T6V!YuP@0No%C{&)Gq{*%ER>XMZk6r|6Kk4Z(aTF?^^#{gk8d^6K+fA41c!Mio}Ag zA(l^L<7S=c{oB?YhlSsT?)ErTwl{V4y0r%+Hp&U~fo@PIGh$r)R|!*|}2 z>1Aov+&!a!cg>VpGkzg6jR$2qmEZ6W_%0>-gBLgNI_sJ;{MX8|PF>t%m!|Ho89H}E zUe)mMcZOY^(7ATI?`-!o(F@b%)N@|TZ>IUK`Ze-)#JZMV*$c6*m~g+@eaPmt<>-gq zZPUqD@5MAvITj-MyFPup>$tHUhY#q~=FeVXpRL)MeM<7#{l)uZ^M-jjTGu%C-xm2t z^@q*o`jy@O=+6L;;M7S)98xlyydYRcdxy_>{j&B8W!{U8t>AV>uKl8V@9R%IayA8 z)hHRy{QG97@#s-b{0}fso;W&t|c$Y zNaMG`Ze+>KkDmd1J;#YJ$&yoB8^%N0&2pY&8#yKF?zi!?Z%(+@dcv?~`7&SC*)A%v4 z(_kUoYg`(ioae;L#>uQRKMod@@5Dp$WERd#^3wQuuuEW(JUAbIDuBKDGV97OfJKjY z;_(GCv+%hE@Dtb#uoxaQ9)2o>N5{)7j$Z@oQ{==`3uPA1D+=K!usX2ryl)ZwR1A+6 z$t;oI0UI*Gi4QN9Sr1-S4ErX+z6mnx$%jsWePGpKy}2|I_DzC)6J^$y?*TJUhJBM{ zCi9$0un+7sSSt6L4Esu8-(;CR!Hq@|dZxuMGB0mD#iW8d#qfU|*TchVzOt*aubz_B`+V0_-b? zeJ{u?o!=GE~!3$yEBG|W3 zW=r@5u;|6GZ;{LtK6er91G@oM!DAM~J`Vd9%WMU|2G-|Q*vDnIidS&h2UZ8RhWC9H z_AP;ZugYvKzXLX8DePM!v-P}c3G7o~-%^>q#)mG2ePGpKo4BOFzGbjaky#br17@y( zeamFFnddBnePE}-wsNlu*tZ<^Rmkj3ejF@l1?*ccvmLx-IqU>%&E z2KH6LzBMvC%e?uy3u*-seNt!alHSun)Pk4)(2wed}a)obLfM zZ-9O4WpBhuMM#8HQ2X7W*_t8U_l#U-)k~E!%JR+ePEZsKIOq1Vc#a$ zw^3%F@e5$lufx7gGW(p*-30rY=M27W%f0%+6?=)!oDps`<4&g0{g(K!M@|tR@k=<_HC8f zHNFST{3h(%CbR22XB+GTI}LV&d%X$!w!^+RW%e^a4i>Zn_HCEhFT7+s>;t<5_8Sl0 z0sD5sz8y0AgI@rP-Ua)1%Ir@*cPH!vy8(8G$LxZAZ^6D@GX7qDZC4upDx3IL8mrq? z@zw&y3ta4Y&D+I1!Nu*txovkQAFX zZQlZ?t|GsGrslXE51b|QnA(7HKb+B{CHh3>03c@S1CjuAYX<}ZK>$5W zX$5RV{U+dbpb}UM(Bs*az$$>#iKcnzS3TMDoYpa)?bcom=s@K*Pn z%YVk{O@JP#9tHXVSX{zD$OuTvlLNp8fF9o4fM%#02jl@EKy$zqI0;Ncy&I%E-~oID zd;**S&H?mX?*i}zaFO8~VF%HnGY|&cB;#>xZ^F3Wop#M@aefCN-O1r?__cd-b3dH5 z1X=^WKntE&=UPtp=l%fIwgRXH)p`RS00qSjz~2k#t9@#KsLTv_0$u>s`2gpDtH8Iw zH^A4x=Rg`jf8qEPAbtM@kpDlS5K=%+02CYw(rlnDFbkLoya1E|QvgcfBETQW1G0gU zzzE=Z;5lFzFciSw3+bG*=HG2}s8L^koqBk6?Wu2LS_t zzCb@<0PqCRA0TB?02xTt&q+QBkc*!Mo&m^uIv);DIf1;84vYq}fD9lL2p)xBV}TrC z43GYyECgN$j?hqtaB>iM88`q`1M`3-zyjbUU_P)9*b6KM z_5kk!+kkg~Rls`SZD2R>2JjZJ19%hI3Ty#31Dk-2z-zz;GG!f3mIHGDL*qp_Cne?r zqyQ;LTE7C+SBf|(WLQO<+P(@XfYFD_45O$makUS*k}4_ya@AUZCR_nj0&9TP09m*a zpgK~DKcBfuV7O}+&S?^3Q0ibzNM(j%G#DvGxv?LhI^tALIY5e& zXX}?6W0In~fW;L5!#E+=l9tq&Bn@hGq-1O8oHD)spoRjJsFavgN9T{WAurKf^CIi!X@Ha(415nH0trAjihmps z19Szt0AWC9AP8s&(7o+7$brBh;91}qfCe89qyePNFF;3tloem(AYHR zZD0h@iVA9hI^Z5~7pQ5%@EfzafTYk7rylxsB~NjZ^GOgMTK@z20Abg`>0T;^r<2@ zVP338DJOALISt|Kj|I>SV;J8!*XOlhUpFZuoS0cxFvWP2%J??9J{kPmu<8vdsN8SD ze9h-DwF9P({%XW;AN(~b44f0>8m~DW?6l&GxTnnh zbX{SEmL4f$rlYdWiKU47j>7&{m zMPuvE7yX=+RxYsFcu&uNV!!Q$Zv76amc>x^7;p5oY;z^xkkn=mYOp%U8%a(|F8cbu z=cMK3Gi>F>UVpABK#Q(gi(bx3r3=)0-dQ^XQ7RH--4%gCJcCUQ77Fy7Hh88nhDIdAhsI8Rm z-7!m=pW^EQuNv>*KFYJ+u2+pMIYl>#cf19kZ*y zGSdSVRkl|CMKZmO;_u1KU5(fBUThq^I(7ZdP3S>m;!0t>s2Atg=eg3w{baR9&D&eb zI8PQy&ROTlmW$`xDg)x7rjc^rv{hEeL)}@ptRQo2n_CZAS3B#kw(0-@IclH2wVa z<-1yq1zXpN_R26XX7({&{<>*9IiN$}o#nb7=v${O@M2z~Yml-q66zRli=BA=#b)PC zy)Wqvkw=|^l*Dnmnb%CInE(1Fdq zS!wIXJWc4>JXkpt0qt4^D=s0J!g#5%>4o2y40*fhcwIZJ$LL_Cpd(r)2P;>7SVWZZ zQlEL&v5Esjs=n5zf_se@7~Rgk+~dH;lQ;Alj8PP<^!LRW(}Ir*>;f3J0-&ChsoamxB9 z`lW9>mHkkq@06A;aV;|5bSy}iy7^FfoU`5lySVX=q&e`r)|VG94|rG;+Cj-}30;l1 zD%}#hCQUf<@kPA_;+EDysYHt?<9$q1i{f`HSM)Lx4)L>f&~7enI(m-D{#R|P-WMt_ z=%5S;U>=RSK9ZD5tG28Q_OI|Z%!A^j$IaJxOZCS$U+Q4XdFqgoYbnz8ZgA8;qB1_f7s`;Q3y4-F1&+#nP$|gwFIDEp8)hLd@?I z*Y%z^U&FX zfgQSmwSaWq5~kcoUwuWo+abtmxV^NfYughP)oDZeLJNH#(^5uX=lV+fH-6AE&x}N= zzZpJm5M!hKcXgt%-5E<QXI&S~g%Pq7F4b39dJXk$&|Z0CgYeS>MA{uC57p6B5C&@_rEW_BCFncJjFW%*~f; zjB6Hh((s&NjJ~KE-i-AaDsKgAE>YF}dvC+3x~JSth@ch`H+#Cc8E$A`?}1hcaY`i| zT0<+tbMpC7JEZF%T%()UKCWk=RhpS*sgMdNbysIm3S|DGHVy686$H0s|1 z^U$71a`h{n9y%ZUuYLs;2L~%(QjO8uC^r-_7KApM`N6_5vQ~?Rxz6=L zPrs-JVvK}0-e6xpX5xgB`_Hz77ZC>fL{n&0rXqF4sa9oaDDHV5)yvT1(Q-rIhU$%S ze6;ipL&7`ZF@zhEU`%%J+mmYI_NsBDOS_<5)6Kq*{rsoi@6g@k0YslnqWB1V4mu~|$BGWtdP&(o6&8Ok}RqejT51X6u{|To2`D@7G=QrXp0rF72 zbJo_odS|zyhq4U=iaUEK$GRY{+U;S)gA4G7J+vnapKe?`ctE5=A1mo58^i0jPfBT5 z7UgUF(}cKl3(bO)l~cHqKS&rYF&=-RRYpZUX5-hgzkX&Q34gNaV?I7t z{b}PHk7eMBNTfZ>eD_^`XNIo3ej`%<+F~p)V`DWIls=J<@sCX!c-Y}3mh@J-MI-pe zzg+OLPiyNFH1SnT85U}xPb+v5Qh*kZc@h$%Z4QF*?;MI!#y&No$MjihU-gN~=HAKy z^cD3dBI3Kf6W@I8e!@dAe6qAX?cL{$-!Hq2B*66l@zW6BhM$Juna9*P%!Vfc+vA{6 zy-)a1&@4XspW+RljX{hi^X30uznXR9;`qJ{Zts>DMn?H>{n*Bs%Uc zO!t>x`FY`l{6XjHNs^Z&l^=ohfUJgehg<{M7?MLaf_xd$3ONbV39@;8N%Dkrf^>uY z%_2z+A%8IBcRY&uyN?_eF)A-_v@|Slc;@iTF~!_bk`z9V`IbEkW$LHDAl;dA{*2pn=g_)9c z1%w77rHq^+Da}>qXAHd&S!0GrjLnx^5FKjgV95N4oV;NfF~u-HCo4CrSaLwPN#Pj& zkY`K4$>PGS+`^0zlB8Jy&wF|BGjdQ_5@w0iM`xCdqk#Mavp{@APDXKY=5XmC2sxfv zG`2Vkc4Zb7jLj@8k-mVj)NN)_eqko;-wZvNTs|GqBR?{RZcp>4@gW>d45Lm zs0dg+Y*a?hXw+|RrU!adMo|QeAGr#g`pIvu>%R?224`ezeoA8tvm&yK_;2-`%KG^0 zHpM`~%<^2cqtK1WC@L;M*M*s5i?T+PNCyIRA7{7FefkoTVlxqv^!uV6Mk$Yh^o4W@ zlq6VN9tKWB`xyP+LpjCj7NnLuTU+TJz6wdcB_NY1hzt!V8!w|ZbvzW3R!K#W?%U>2 zNooS_5T+aAAEK9!%*!7RyQNQq_5Ki(yz!U~rH#%#phx{rLA@fC!PC6Lh|G*(qmqpd z!nF#id{?+`>2Kh)tn(m|z~xOM^oV(Y(=yME)FW2`gD4^~ZS`gU0VEAH)fjLeI7Mnb zBpJSf>dSBviW4|nJ_8-l0CUlR)>l3_c|6FFJ&pPZNSdnIFu(?3gh|@hhgmRja^^j!Ha!8uX zTtj~_Bze>ylJeXJNdtvJQoBp>diiY_O6@Kf5qkrY9NQX<_)~*H9rOVzP(cxR29hlA zVaQlW8t5)OrR2H{Ngk%6SIUv1%-nIAg_NvCc{$@Ur6rvt3He+;2a*OZg(L@}L5LS( zzKgNtwRS@M$x>I8(!dr-QoNz)D>J_^U#fR6exi@b_Jnm!a-aWm>QP?in9;BGn6&cto6_q)eQ|Pm%LT3r z-zxpKGV$0)+0hkSmi^Ls~Fo;}<5T)!QAGfpqq?d`7~b`5M0Jf*>x9V$9o zzk6~~!Sx-BKb$b9_>k-MU*_=uCBW;w&NF*^O)S0m#f}C)I;?LUbFlKNm+z5z@}6?# zwqmisgvLChaWJoJ>?q%^$E#fJa+(9L^0dnf9e9|RT{-6_Oi><>ho)8^+A0}>I}VlJPKV|8t`m)yWFDzzXma- z0T1)Av)6c*N0Q}614)YGHH{PH){eZ&-EMi-QIg_$glnR_(vgS3xNjZ#HFVru;bGo( zd506v_O>gTh<-=Sssb;Yyrv<)hQfaug%*Cx+a?EEdA5&TS&7NlHE+R^Q&xV>$8K>$ zO1IyRra_mL{yeX4*+G8s5UATG$jHtOu%tF)eKJKsO%N!mcbuA=#v>oCKo=aNf&i zalyj1^9Zj*OK+SZY~G2=YMhaE^*CliISWQMY4fhQV25Z2)_~uFAJ2eMdTEictfe|F zz<hr6CNs1jypR9l`VmJ^-RsG#TG^EwMqxcIuo2iZ;-CW7*rp5v z!*V4v5SlGi$7_0aWM2GQYddr2p+QMJXHYF`_(@T+Z-l%cqo(3~KLVeC>c8WsADZI+W@{b=zh z%>yLqIjtPoAx{k8VG(xu;{cu=VOQ>>jwS>%7-&;sansa8?*S9C6s$AP^-5H(;EY^_ zmb*=E(~?(3+Li2gG-tvIX@Uz^gn zl_d26t4Hfa83IOim>_umn!)s_`~=n&by`L$U0c^Kba*qVHP4R1Mux&CpyI%9p|j{9 zNgALvphR3B#KWTP@_|sE9c@=!!%SCbMhAh}HBFjr0Y(c!8%X{zjAysED;~H%qZsQc z&m|Hu%jc=+%O)y_!rSUtb<*NYF}$z3!7y;STA1Vlc*fVnI8LUY?$}K2J(mwiE?@j z&rY-}TM%^JDVmxaU=&U*5K3@t9jh$`U~znVWTLz&mREJOD-Ds+OHex#qv{_bz zb=9I;^FRyxVnl-3JFT%SFuivg>kycpss&hat_VIZX9&(noORd6sgATMTfr!p zQL^ikpPFbgx^k@mtKGeo%V0w^P4c7*wvT}tqYH0EN1ol)uCPvede^5-L+;#(Uqj(u z6pk=Qu=rq>I+9=V06VYhW|vpk`L%9#tRO+9imYZ=wO4~Dcd>VB@v)rA`Z@Qjq(reu&t;myh^nz3sH!frlf3YvwRHJh3?gum1m_T z$xo&7sx-SYA2-qzU2I4`Hs$YN53Febuc%k-z8Iq?7?Qrck#-%Q+>2l9ZI}1=;$eO4 zikoVN0Dj6tR9@A`F0WDfHPqch9ab0hf~D0yjc50@%VX1cRo~@hj*|A|gTMcOvLW=b zuWHAi$$HShE~p(OWdNI*c0iVyClbRMHZAR_D_v27@JK;GwpOc4(jRSblJ$WCfXWL2 zI!Mxh#YAxYQ9k*gf*OtmsKI!>_)jJ2PcZcAO2%{h+xWKrj%CzSsi8rVT1+-1IZnJB zprafhJ_VqdO*8m(NOElsKnF?En@a>oT}k83*Ge^6rYXK+6#SW_4i^}DB*_47aFSHN z*x)2-prrtn3xE!i#FrVe!kAmB0tM9JN~2;GBpoEF!K*}Y)Rickfe?e8N99}#kT>HhXGn6HvkK88=!VS0(6ih{vUwq?;8A9NID*pWm*9>xTjU? zk{Ze=q@;3$qb&5(h~NaZ^ZY?`hO~8PeO3K9J;ypT-gYW;mgPBn=p- zRY+1RgNH#EDI|LBAidmFMp zRp1~=NW4-N1PBpEOmlGvj;9B;^pkhB-S1W5x;gQPun0VEwHsXaG%T}efYje23!LzZa`mZN|yebrD{WAF`z z+yaSzQWgHt6zzg+1o;*uP0f3d4Iqy}l0jcWQh#S4$m8qm0^{cgc=o66 zt{Ba)Leg+IAX`DYqA#3EZjjWi2mX-1-b4&Zr@jW)B#%w^rx}x)8>J*^DFz$7uB6_= zjB=9Ht_>uWM;hf1BtO$P`>%^5LZe?bDOi799JPz2whFbt=od)}&0iNsx>!i6s?FAWZT?!)>m zv--7i*~w#v=kpU;Ds$ll+37qb)5+0?M+XA=Hc*7&8+UTjs!Gm z9DZ_U{^_GPE_~$c;?v_&^$%x9#xx&PTC?Z;ieuM1bony*A8LoFJ1)tU_vVj16P@6d z{mtrc2ZlR;$ybf=;$LK|c!TYY5eJU6^1&li=E=8=Nav2DtUPn1%Dj2?$aMZXnENP| z`SOfW>3md{m0MBQj2{5=%C_=mu-l)Pj85ktft??%vKBlvC!J3iZRPWFR2IljgN5W+ zd8b^JwdS*O)A=c|8n9rVFeaVP%(e1WV^kK(uY$#ovGU$|Dr>_l^3wTtVE4cxcxrw+ zUzTU(+w)b{mfrzO$%oYiDvRP<3J@nSYoW^8^Ff8_d~<=79|DWzN)h}kwDQ~{mD%_K zFvlWTU97SMo>dJ0z)pZAa__P5uh_~<$EvIoKMLkG*2*KssVs??jDvq*=fS%0(DCqZ z9Q+%vvTpn|Sjc$zH$i1R_^b)=53B~PCr>DWe-q$eiON#>Rj~LH_%~5yDzBIb|G@5n z_2#KB!oP{|??sjM<#)hRUW9*>RMwwwnFRm9tfeY@k`F3{f0N)J*wb8@4F5{u-(;1g z^8;Xxli^>P$_DYQGWZ8}0_<7tT@L@s;9t4Q2J@p}Ughv_iprknB~#!Z*mwzRoNb;a>&(TduM#{3=-da`?AGWm|d03it)_v7__t1F@A1=MA?x7ZdX;^^XRU{SU^QSL@`MfWZ$13mpt7Upm__tAI$9cs@ z_y=|m>;zBU1phX|zfCIp8@~gVvI+ieR@tX~%VzioX5FH)&-kD%@NYBx1N(w2Rq$^M z{Hs#gSNs5&V-@_{s?*$s7QYMrRjce@ zyrLTZf!zbE;iMl@DI$oS7ra^gZ9F|J@60gN3QIH ze|zEIK9&8%4}dxDgMa%~c86!}hkyI5drs`1%J{`M_u>};4*cTl#r)y{2l-1!{?>sY z%Ni%VMcLE;;PcQxX?T-8eGYyjTiW^H`IPrRD5#CKrJw#|k8sFFdoCSGl=pN#dWt>x z!}A-rqV^uQJL*5SFfWC4>k!*%&xIqsWMimHNB*S^wa4%HS8bh6{j6)c-Qf%FcEoFA z?fC7!eh@hgP%rfSO^3Fl7i{}g;>Yi|=4rqBHKix#a3BJpK5jBz z`0LB{>CJ7l#DDv>xdku2@n0ue-1Ft#?*(X&()47GxX^Exv?ufyXd4K$0)l{MfFH0P z^&5bV0KFAl4bWS}<-iJnUNO>p0eVN^3DCQP7g06|pf|C_z-S-`$OZDqrhJ^xJC3cu zHefrj1K0^%2fhdP0{ej1fY*7G-#p9K;%ps2uUD1;O96Tnvk+JW%m(HG^MO|Ye~c0U zv;bNHfj}@22Dk(CW`*7l9t7wmGrhE215^U^LUbiSuRz>^Qu=Q_^w2#HpjUVd1H=LG zz+v!rf%kw9fFr<%z{kJ|;BUZJz$t*{mge#-a1OWtd<|R#t^nWiCier&=y~NZkODMC zXHG%}0QfIqOfB=q8Qm0;g-w9QfD1r@wE})X zGr$Y*1U!Jcx-CDNon>eC&>s()=c1eU@-6;@GLM07yvvCJPABOF7?Mr zcfbyG0uq4^qJS~ivi3NQ20{Q@iDVxw=GFjZVoQKtwzUB$BSQhw4F-Y?X`W+BwCQgP z83WjWjzApH8At-U09}D@Ko1H|Kb-Udl7XH;Zy*g&0Wu~PNCA2o=Op_A6x4x0IzSH7 z`7;2O6P^Hu0KAj8z{ng0Z<5x1BwA!UL^oU zoeZRzpmT~i1vUXF1t_vqKM9}&r$tZ>kg+tw8-Zz5@jmb#umo5DybBx#-T^o;57+>_ z4IBdA1ZDvTfj5Aez#?ESFawwkybinuyaMb8_5oXgy})vy64(Qj?Z%%?KsB%f*alPq zTY$~LdSD%}7FYwU1_Ur2Fb$rMb28#(fD9nx$mlshU89JTL8epSWt#Fr6fOqLM%01n z6)6)p8&UaEfFkuOKm#rVRsk!46##iy0Z<(oMIe2W)P~M=qY;0yuo9q7ALW%<|De%> zHdIDNR|0gt5!e7w897ALLeZ^TXP!?(`39PQvlE&hIybE)ugQb$Mmcp(9q$CpXzju| z4Ptgm`sRRCR%z6kQ$o3Mz$l}>%BX^JfI6qpQX{IMaX< z(X^Q7TIe;7_><>U`5EvjKr7({P*?fy;HHS*m#$iD_?DlP!$0m_{{z;=LE!zo}oa1J;NoB>V) z)Hn4(=+mbJg#fV!<=*|YE=FbFa@{`{2RCh+yrg_HNbe_I`A*x8Za8j z1fHk)AB>Y{fvdoGz(0W}0WzvTa0Rdfoqz-&9*6^?fhZsX2nSjNEdjb;{T8wh&<_|0 zqyyCVGr&^-8S_05LRT8HhK!(re*kEZQlLFRolgRO1W1SYPrxt09pGo+9Sg%>Tr=$A z4M%o}#lAK{VQ(?tqKu&#C3$0{ec6LrH}QBfKxRYQMaD+L?nz>`Gjq4ZMn*-yERw9u z+t>VT$NV+2rW+LrKi7yFXDFE8`ItXnHWb?7#zFLVW*)u>h52pJf9eJ1ENpwVouOkB z`Bvs9-fYNxe8008zZL)Hg?}HpKYk2Uq9dat(f9}93`5v&;w*{(p;@w1eUaagdHq-O zEEu7AebEuRqR`4l8RPhA9~j}XX#Q~8Fd3oGRYc=P%q!0PF}es$3O_TZlQ%S?U~w!i z2#Wr5^_niv4vg*VmY~(Nr}$PWVn`zv?Pvai-hIG{%i)3V579K*MMfpy>l(3*G|b=N zKXrCuNE^TS5ikmOz!9;=DV-}In_z*a$#QbP$zK=C`8` zq}lX$J3<4FwvTKl^>r5Iq+xz^^LF@(Gl@@o^)l>)Bj&_>+?d74rOx7`jx1WlG+{pS zOlQ%r3EG;!@Ly5U>aE^<(`~f1VG7Q$FS_>NjQ4@)>8*f#elu zF^1&#&ceqIdB<6N3CFVsVT2njS@qc9Ag%mt<)CiRNTObFPcHKz*6ts-^e;y!YTAj$T4vQ5YS4-Ec#!5QUVw(LgPZ<+&Vc^M`(!lU`yn*QNA%$$w!5?5 z%t73BXWsG>cj4i|%H<>vvCM;|88LGgcRg4$xtE9V@nj?X%x}zcLz@q+T0FF`%#a8) z8s+^;PlRE*xA?>pA;$Dw^F%6o_~@VeZ1WM7NpQ*h`fPNk#WM>&J4fbV=OS~^!xLU? z1alP!yjbrz^J_7utLe>>D(C(JMH&l==J#QJ+P=Hi``CMKsEL8ksM*|1bo7Q1`OQVX zH}c;6a_rNARiC8ZUVTMolMx%rZu8?f?~fKL=cn)5s`akLEyiEGg|>1Ze{sqiaX1hl zf_zw-pZTfSoZ*YB-#l~lYnj1n+AXBnEyR2uEa)#=XiszHiT)`=CNAowqEFgTkjpOO zun%ibA^ndJTQ1)U6ze;~OfxO-1&WKlFxUKuY-@{wYk!%3zm?X3=Cb*j+4Y}?o`2re zF-CJ)t4U}rBK`Dur}!b>oz~nl5_g^T zIqt6S09sx=6D-o2!3^{BGq2SzR?OS{RJ73+b2cSdltV+F9V{xNAuEE#kXXpA!Qv#f zG(Wc*(&yrnSNn%_(H)=**r&luTS4(cu<&aRc{^CdHAhr`3l<*-GB4&KzK(_U{vqOp zmaMG>(-0OS`b0s~79y^rg`fG&ob&11O9t+DF3`G;j<$q((OI1NL0Vke z#5R$uZ$v_a@)jv%e!>=5mK^%;>j9L}D5uQ17$WQem?ZNnxEI&;?Uk|P<58MMyGXi| z5Cs9auDXjk5Ps&@bzVXLY;j@1@<2lmdvVWD@eX>5Grz}k>k`{_?7M$EYiM8=%`f(* ztbecR8Haha)6;GPD`td>MlG0Etof1P&4!-YqyGL&?*|&@_lyI#J@-S4oF_ilYOtPS zA4(z-5X54z<*Z3y9yBHT?0ET^-#cF4*=dd2zNbTs_VCZh52lFHK=`T6ss~;5^`_BA zg`6BBcC`GTWSZH)NS|Hdq8+766R|Laxi!#shu@DWLo@0@V*N=j>s|hdg3+-^YsGy0 z%ug>5PPo?pRPwzfBbrzf`igU>0EM;{D=Ev3K*=#}#V6o#+V_{Z#9zzUVd+%7@e89d z;$nWG>HJONHI*+k5|OsYeQiba)@VBw_XfBbDR=vPZnw8K9tu7oF&D70y~)@yZ-&Ecb0ad7KuZj7+E%t<7nF_FT-)8l%^vQqB1n86%{(01NoL2; zSP+SxFu<-*7C1bWfx2#f97z%kCs;TX|M>OfEgBu_Zu-!3)1XTyck2}W>eEbV|6@!qtwmO z0-~As`-Vn;+R(bDJB^0;Hl+R7@GNA>)9{KdpKv~bZ?SbEIm7dL~goy1#c`(F}lc4rY3Q@dOrZ=s$& zvU#8Iu%qUFKr^e~`51RR4_%jVJF|`MgC2Q1<0YH-GNoNaWE|9t8-?1qnTyk$%d=g? zF6jB0U&-%sK9f6P`u@+f8Paam9%pD&t)aSSe&z@9(f9W^>tmU%hgiE8>Csh`+i3+$$A35Gt_{C!?^n0&p^P(tU>?&Tx zy_mc?RJ+b_0`NF%d;zAb|?sU;8Ts}v*b!7ps#dl@J^&IgAFfOuq zB%8AVycB8F#@fh& None: try: # Run the query if asyncio.iscoroutinefunction(query): - new_data = await query(**kwargs) + new_data = await query(**kwargs) # type: ignore[call-arg] else: new_data = await database_sync_to_async( query, thread_sensitive=thread_sensitive @@ -219,7 +219,7 @@ def use_mutation( ) -> Mutation[FuncParams]: """This hook is used to modify data in the background, typically to create/update/delete \ data from the Django ORM. - + Mutation functions can `return False` to prevent executing your `refetch` function. All \ other returns are ignored. Mutation functions can be sync or async. @@ -318,7 +318,7 @@ def use_user_data( save_default_data: bool = False, ) -> UserData: """Get or set user data stored within the REACTPY_DATABASE. - + Kwargs: default_data: A dictionary containing `{key: default_value}` pairs. \ For computationally intensive defaults, your `default_value` \ diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 522d3dcf..780ccc17 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -37,6 +37,8 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: await caches[REACTPY_CACHE].aset( cache_key, file_contents, timeout=604800, version=int(last_modified_time) ) + + # TODO: Convert this to a StreamingHttpResponse return HttpResponse(file_contents, content_type="text/javascript") diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index 479cb613..f5a72fa9 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -107,7 +107,7 @@ def delete_old_workspaces(): if value.startswith("user_workspace_") } - # Delete the workspace if it exists at the moment when we check + # Delete any workspaces that are not being used for uuid in python_uuids - dom_uuids: task_name = f"task_{uuid}" if task_name in globals(): diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 401cf724..b86cabdc 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -263,7 +263,7 @@ def generate_obj_name(obj: Any) -> str: """Makes a best effort to create a name for an object. Useful for JSON serialization of Python objects.""" - # First attempt: Dunder methods + # First attempt: Create a dotted path by inspecting dunder methods if hasattr(obj, "__module__"): if hasattr(obj, "__name__"): return f"{obj.__module__}.{obj.__name__}" From 2abe5ce89e2e77579f1e364ddb148b569b98cb49 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 24 Nov 2024 23:11:49 -0800 Subject: [PATCH 02/19] v5.1.0 (#259) --- CHANGELOG.md | 7 ++++++- src/reactpy_django/__init__.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4719dd02..82209551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] +- Nothing (yet)! + +## [5.1.0] - 2024-11-24 + ### Added - `settings.py:REACTPY_ASYNC_RENDERING` to enable asynchronous rendering of components. @@ -508,7 +512,8 @@ Don't forget to remove deprecated code on each major release! - Support for IDOM within the Django -[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.0.0...HEAD +[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.1.0...HEAD +[5.1.0]: https://github.com/reactive-python/reactpy-django/compare/5.0.0...5.1.0 [5.0.0]: https://github.com/reactive-python/reactpy-django/compare/4.0.0...5.0.0 [4.0.0]: https://github.com/reactive-python/reactpy-django/compare/3.8.1...4.0.0 [3.8.1]: https://github.com/reactive-python/reactpy-django/compare/3.8.0...3.8.1 diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 806428de..f3fb1545 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -13,7 +13,7 @@ ) from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_ROUTE -__version__ = "5.0.0" +__version__ = "5.1.0" __all__ = [ "REACTPY_WEBSOCKET_ROUTE", "html", From c5b5c81854c2dd0e59f325a0a708ae664c768c76 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 1 Dec 2024 01:07:41 -0800 Subject: [PATCH 03/19] Switch from Nox to Hatch (#260) --- .github/workflows/publish-develop-docs.yml | 8 +- .github/workflows/publish-latest-docs.yml | 8 +- .github/workflows/publish-py.yml | 8 +- .github/workflows/test-docs.yml | 24 +- .github/workflows/test-javascript.yml | 25 + .../{test-src.yml => test-python.yml} | 10 +- .linkspector.yml | 7 - CHANGELOG.md | 8 +- MANIFEST.in | 3 - docs/mkdocs.yml | 4 +- docs/src/about/code.md | 85 -- docs/src/about/contributing.md | 93 +++ docs/src/about/docs.md | 45 -- docs/src/dictionary.txt | 5 + docs/src/reference/settings.md | 2 +- noxfile.py | 73 -- pyproject.toml | 220 +++++- requirements.txt | 8 - requirements/build-docs.txt | 8 - requirements/build-pkg.txt | 3 - requirements/check-style.txt | 1 - requirements/check-types.txt | 3 - requirements/dev-env.txt | 3 - requirements/pkg-deps.txt | 8 - requirements/test-env.txt | 5 - requirements/test-run.txt | 1 - setup.py | 182 ----- src/build_scripts/copy_dir.py | 31 + src/js/eslint.config.js | 1 - src/js/eslint.config.mjs | 43 ++ src/reactpy_django/clean.py | 9 +- src/reactpy_django/config.py | 4 +- .../templates/reactpy/component.html | 28 +- .../templates/reactpy/pyscript_setup.html | 2 +- src/reactpy_django/templatetags/reactpy.py | 14 +- tests/test_app/apps.py | 8 - tests/test_app/middleware.py | 31 + tests/test_app/prerender/components.py | 9 +- tests/test_app/settings_multi_db.py | 45 +- tests/test_app/settings_single_db.py | 26 +- tests/test_app/tests/__init__.py | 1 - tests/test_app/tests/conftest.py | 19 + tests/test_app/tests/test_components.py | 724 ++++++++---------- tests/test_app/tests/test_database.py | 1 + tests/test_app/tests/test_regex.py | 17 +- tests/test_app/tests/utils.py | 92 +++ 46 files changed, 985 insertions(+), 970 deletions(-) create mode 100644 .github/workflows/test-javascript.yml rename .github/workflows/{test-src.yml => test-python.yml} (66%) delete mode 100644 .linkspector.yml delete mode 100644 MANIFEST.in delete mode 100644 docs/src/about/code.md create mode 100644 docs/src/about/contributing.md delete mode 100644 docs/src/about/docs.md delete mode 100644 noxfile.py delete mode 100644 requirements.txt delete mode 100644 requirements/build-docs.txt delete mode 100644 requirements/build-pkg.txt delete mode 100644 requirements/check-style.txt delete mode 100644 requirements/check-types.txt delete mode 100644 requirements/dev-env.txt delete mode 100644 requirements/pkg-deps.txt delete mode 100644 requirements/test-env.txt delete mode 100644 requirements/test-run.txt delete mode 100644 setup.py create mode 100644 src/build_scripts/copy_dir.py delete mode 100644 src/js/eslint.config.js create mode 100644 src/js/eslint.config.mjs create mode 100644 tests/test_app/middleware.py create mode 100644 tests/test_app/tests/conftest.py create mode 100644 tests/test_app/tests/utils.py diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 53c5aa16..11a7fa23 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -5,7 +5,7 @@ on: branches: - main jobs: - deploy: + publish-develop-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,12 +17,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: pip install -r requirements/build-docs.txt + - name: Install dependencies + run: pip install --upgrade pip hatch uv - name: Publish Develop Docs run: | git config user.name github-actions git config user.email github-actions@github.com - cd docs - mike deploy --push develop + hatch run docs:deploy_develop concurrency: group: publish-docs diff --git a/.github/workflows/publish-latest-docs.yml b/.github/workflows/publish-latest-docs.yml index bc7409f0..697b10da 100644 --- a/.github/workflows/publish-latest-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -5,7 +5,7 @@ on: types: [published] jobs: - deploy: + publish-latest-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,12 +17,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: pip install -r requirements/build-docs.txt + - name: Install dependencies + run: pip install --upgrade pip hatch uv - name: Publish ${{ github.event.release.name }} Docs run: | git config user.name github-actions git config user.email github-actions@github.com - cd docs - mike deploy --push --update-aliases ${{ github.event.release.name }} latest + hatch run docs:deploy_latest ${{ github.ref_name }} concurrency: group: publish-docs diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 6a86db98..f72cc55d 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -8,7 +8,7 @@ on: types: [published] jobs: - release-package: + publish-python: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,13 +20,11 @@ jobs: with: python-version: "3.x" - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/build-pkg.txt + run: pip install --upgrade pip hatch uv - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python -m build --sdist --wheel --outdir dist . + hatch build --clean twine upload dist/* diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 66a5c942..08bfadd7 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -7,8 +7,6 @@ on: pull_request: branches: - main - schedule: - - cron: "0 0 * * *" jobs: docs: @@ -23,20 +21,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - # - name: Check docs links - # uses: umbrelladocs/action-linkspector@v1 - # with: - # github_token: ${{ secrets.github_token }} - # reporter: github-pr-review - # fail_on_error: false + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + # DISABLED DUE TO DJANGO DOCS CONSTANTLY THROWING 429 ERRORS + # - name: Check documentation links + # run: hatch run docs:linkcheck - name: Check docs build - run: | - pip install -r requirements/build-docs.txt - cd docs - mkdocs build --strict + run: hatch run docs:build - name: Check docs examples - run: | - pip install -r requirements/check-types.txt - pip install -r requirements/check-style.txt - mypy --show-error-codes docs/examples/python/ - ruff check docs/examples/python/ + run: hatch run docs:check_examples diff --git a/.github/workflows/test-javascript.yml b/.github/workflows/test-javascript.yml new file mode 100644 index 00000000..d5b9db1d --- /dev/null +++ b/.github/workflows/test-javascript.yml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + javascript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Run Tests + run: hatch run javascript:check diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-python.yml similarity index 66% rename from .github/workflows/test-src.yml rename to .github/workflows/test-python.yml index 5eb2e67a..9fe700b8 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-python.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - source: + python: runs-on: ubuntu-latest strategy: matrix: @@ -26,6 +26,8 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install Python Dependencies - run: pip install -r requirements/test-run.txt - - name: Run Tests - run: nox -t test + run: pip install --upgrade pip hatch uv + - name: Run Single DB Tests + run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v + - name: Run Multi-DB Tests + run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v diff --git a/.linkspector.yml b/.linkspector.yml deleted file mode 100644 index 6c0747e7..00000000 --- a/.linkspector.yml +++ /dev/null @@ -1,7 +0,0 @@ -dirs: - - ./docs -files: - - README.md - - CHANGELOG.md -useGitIgnore: true -modifiedFilesOnly: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 82209551..ec8d38ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,13 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Fixed + +- Fixed regression in v5.1.0 where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. + +### Changed + +- Set upper limit on ReactPy version to `<2.0.0`. ## [5.1.0] - 2024-11-24 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ddcb7f8d..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include src/reactpy_django/py.typed -recursive-include src/reactpy_django/static * -recursive-include src/reactpy_django/templates *.html diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c1b5922f..100b669b 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -16,9 +16,7 @@ nav: - Management Commands: reference/management-commands.md - About: - Changelog: about/changelog.md - - Contributor Guide: - - Code: about/code.md - - Docs: about/docs.md + - Contributor Guide: about/contributing.md - Community: - GitHub Discussions: https://github.com/reactive-python/reactpy-django/discussions - Discord: https://discord.gg/uNb5P4hA9X diff --git a/docs/src/about/code.md b/docs/src/about/code.md deleted file mode 100644 index 81e49c51..00000000 --- a/docs/src/about/code.md +++ /dev/null @@ -1,85 +0,0 @@ -## Overview - -

- - You will need to set up a Python environment to develop ReactPy-Django. - -

- -!!! abstract "Note" - - Looking to contribute features that are not Django specific? - - Everything within the `reactpy-django` repository must be specific to Django integration. Check out the [ReactPy Core documentation](https://reactpy.dev/docs/about/contributor-guide.html) to contribute general features such as components, hooks, and events. - ---- - -## Creating an environment - -If you plan to make code changes to this repository, you will need to install the following dependencies first: - -- [Python 3.9+](https://www.python.org/downloads/) -- [Bun](https://bun.sh/) -- [Git](https://git-scm.com/downloads) - -Once done, you should clone this repository: - -```bash linenums="0" -git clone https://github.com/reactive-python/reactpy-django.git -cd reactpy-django -``` - -Then, by running the command below you can install the dependencies needed to run the ReactPy-Django development environment. - -```bash linenums="0" -pip install -r requirements.txt --upgrade --verbose -``` - -!!! warning "Pitfall" - - Some of our development dependencies require a C++ compiler, which is not installed by default on Windows. If you receive errors related to this during installation, follow the instructions in your console errors. - - Additionally, be aware that ReactPy-Django's JavaScript bundle is built within the following scenarios: - - 1. When `pip install` is run on the `reactpy-django` package. - 2. Every time `python manage.py ...` or `nox ...` is run - -## Running the full test suite - -!!! abstract "Note" - - This repository uses [Nox](https://nox.thea.codes/en/stable/) to run tests. For a full test of available scripts run `nox -l`. - -By running the command below you can run the full test suite: - -```bash linenums="0" -nox -t test -``` - -Or, if you want to run the tests in the background: - -```bash linenums="0" -nox -t test -- --headless -``` - -## Running Django tests - -If you want to only run our Django tests in your current environment, you can use the following command: - -```bash linenums="0" -cd tests -python manage.py test -``` - -## Running Django test web server - -If you want to manually run the Django test application, you can use the following command: - -```bash linenums="0" -cd tests -python manage.py runserver -``` - -## Creating a pull request - -{% include-markdown "../../includes/pr.md" %} diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md new file mode 100644 index 00000000..ecb0131b --- /dev/null +++ b/docs/src/about/contributing.md @@ -0,0 +1,93 @@ +## Overview + +

+ + You will need to set up a Python environment to develop ReactPy-Django. + +

+ +!!! abstract "Note" + + Looking to contribute features that are not Django specific? + + Everything within the `reactpy-django` repository must be specific to Django integration. Check out the [ReactPy Core documentation](https://reactpy.dev/docs/about/contributor-guide.html) to contribute general features such as components, hooks, and events. + +--- + +## Creating a development environment + +If you plan to make code changes to this repository, you will need to install the following dependencies first: + +- [Git](https://git-scm.com/downloads) +- [Python 3.9+](https://www.python.org/downloads/) +- [Hatch](https://hatch.pypa.io/latest/) +- [Bun](https://bun.sh/) + +Once you finish installing these dependencies, you can clone this repository: + +```bash linenums="0" +git clone https://github.com/reactive-python/reactpy-django.git +cd reactpy-django +``` + +## Executing test environment commands + +By utilizing `hatch`, the following commands are available to manage the development environment. + +### Tests + +| Command | Description | +| --- | --- | +| `hatch test` | Run Python tests using the current environment's Python version | +| `hatch test --all` | Run tests using all compatible Python versions | +| `hatch test --python 3.9` | Run tests using a specific Python version | +| `hatch test --include "django=5.1"` | Run tests using a specific Django version | +| `hatch test -k test_object_in_templatetag` | Run only a specific test | +| `hatch test --ds test_app.settings_multi_db` | Run tests with a specific Django settings file | +| `hatch run django:runserver` | Manually run the Django development server without running tests | + +??? question "What other arguments are available to me?" + + The `hatch test` command is a wrapper for `pytest`. Hatch "intercepts" a handful of arguments, which can be previewed by typing `hatch test --help`. + + Any additional arguments in the `test` command are directly passed on to pytest. See the [pytest documentation](https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags) for what additional arguments are available. + +### Linting and Formatting + +| Command | Description | +| --- | --- | +| `hatch fmt` | Run all linters and formatters | +| `hatch fmt --check` | Run all linters and formatters, but do not save fixes to the disk | +| `hatch fmt --linter` | Run only linters | +| `hatch fmt --formatter` | Run only formatters | +| `hatch run javascript:check` | Run the JavaScript linter/formatter | +| `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | + +??? tip "Configure your IDE for linting" + + This repository uses `hatch fmt` for linting and formatting, which is a [modestly customized](https://hatch.pypa.io/latest/config/internal/static-analysis/#default-settings) version of [`ruff`](https://github.com/astral-sh/ruff). + + You can install `ruff` as a plugin to your preferred code editor to create a similar environment. + +### Documentation + +| Command | Description | +| --- | --- | +| `hatch run docs:serve` | Start the [`mkdocs`](https://www.mkdocs.org/) server to view documentation locally | +| `hatch run docs:build` | Build the documentation | +| `hatch run docs:linkcheck` | Check for broken links in the documentation | +| `hatch run docs:check_examples` | Run linter on code examples in the documentation | + +### Environment Management + +| Command | Description | +| --- | --- | +| `hatch build --clean` | Build the package from source | +| `hatch env prune` | Delete all virtual environments created by `hatch` | +| `hatch python install 3.12` | Install a specific Python version to your system | + +??? tip "Check out Hatch for all available commands!" + + This documentation only covers commonly used commands. + + You can type `hatch --help` to see all available commands. diff --git a/docs/src/about/docs.md b/docs/src/about/docs.md deleted file mode 100644 index 712570ec..00000000 --- a/docs/src/about/docs.md +++ /dev/null @@ -1,45 +0,0 @@ -## Overview - -

- -You will need to set up a Python environment to create, test, and preview docs changes. - -

- ---- - -## Modifying Docs - -If you plan to make changes to this documentation, you will need to install the following dependencies first: - -- [Python 3.9+](https://www.python.org/downloads/) -- [Git](https://git-scm.com/downloads) - -Once done, you should clone this repository: - -```bash linenums="0" -git clone https://github.com/reactive-python/reactpy-django.git -cd reactpy-django -``` - -Then, by running the command below you can: - -- Install an editable version of the documentation -- Self-host a test server for the documentation - -```bash linenums="0" -pip install -r requirements.txt --upgrade -``` - -Finally, to verify that everything is working properly, you can manually run the docs preview web server. - -```bash linenums="0" -cd docs -mkdocs serve -``` - -Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to view a preview of the documentation. - -## GitHub Pull Request - -{% include-markdown "../../includes/pr.md" %} diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 14aa7a61..1b4ce080 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -43,3 +43,8 @@ unstyled WebSocket WebSockets whitespace +pytest +linter +linters +linting +formatters diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index e65dd203..23760919 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -117,7 +117,7 @@ We recommend using [`redis`](https://docs.djangoproject.com/en/stable/topics/cac Configures whether ReactPy components are rendered in a dedicated thread. -This allows the web server to process other traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). +This allows the web server to process other traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://github.com/pgjones/hypercorn) and [`uvicorn`](https://www.uvicorn.org/). This setting is incompatible with [`daphne`](https://github.com/django/daphne). diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index 8776de45..00000000 --- a/noxfile.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from glob import glob -from pathlib import Path - -from nox import Session, session - -ROOT_DIR = Path(__file__).parent - - -@session(tags=["test"]) -def test_python(session: Session) -> None: - """Run the Python-based test suite""" - install_requirements_file(session, "test-env") - session.install(".[all]") - session.chdir(ROOT_DIR / "tests") - session.env["REACTPY_DEBUG_MODE"] = "1" - - posargs = session.posargs[:] - if "--headless" in posargs: - posargs.remove("--headless") - session.env["PLAYWRIGHT_HEADLESS"] = "1" - - if "--no-debug-mode" not in posargs: - posargs.append("--debug-mode") - - session.run("playwright", "install", "chromium") - - # Run tests for each settings file (tests/test_app/settings_*.py) - settings_glob = "test_app/settings_*.py" - settings_files = glob(settings_glob) - assert settings_files, f"No Django settings files found at '{settings_glob}'!" - for settings_file in settings_files: - settings_module = ( - settings_file.strip(".py").replace("/", ".").replace("\\", ".") - ) - session.run( - "python", - "manage.py", - "test", - *posargs, - "--settings", - settings_module, - ) - - -@session(tags=["test"]) -def test_types(session: Session) -> None: - install_requirements_file(session, "check-types") - install_requirements_file(session, "pkg-deps") - session.run("mypy", "--show-error-codes", "src/reactpy_django", "tests/test_app") - - -@session(tags=["test"]) -def test_style(session: Session) -> None: - """Check that style guidelines are being followed""" - install_requirements_file(session, "check-style") - session.run("ruff", "check", ".") - - -@session(tags=["test"]) -def test_javascript(session: Session) -> None: - install_requirements_file(session, "test-env") - session.chdir(ROOT_DIR / "src" / "js") - session.run("bun", "install", external=True) - session.run("bun", "run", "check", external=True) - - -def install_requirements_file(session: Session, name: str) -> None: - session.install("--upgrade", "pip", "setuptools", "wheel") - file_path = ROOT_DIR / "requirements" / f"{name}.txt" - assert file_path.exists(), f"requirements file {file_path} does not exist" - session.install("-r", str(file_path)) diff --git a/pyproject.toml b/pyproject.toml index 99ff6917..44f920a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,215 @@ [build-system] -requires = ["setuptools>=42", "wheel"] -build-backend = "setuptools.build_meta" +build-backend = "hatchling.build" +requires = ["hatchling", "hatch-build-scripts"] -[tool.mypy] -exclude = ['migrations/.*'] -ignore_missing_imports = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true -check_untyped_defs = true +############################## +# >>> Hatch Build Config <<< # +############################## -[tool.ruff.lint.isort] -known-first-party = ["src", "tests"] +[project] +name = "reactpy_django" +description = "It's React, but in Python. Now with Django integration." +readme = "README.md" +keywords = [ + "React", + "ReactJS", + "ReactPy", + "components", + "asgi", + "django", + "http", + "server", + "reactive", + "interactive", +] +license = "MIT" +authors = [{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" }] +requires-python = ">=3.9" +classifiers = [ + "Framework :: Django", + "Framework :: Django :: 4.0", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Widget Sets", + "Topic :: Software Development :: User Interfaces", + "Environment :: Web Environment", + "Typing :: Typed", +] +dependencies = [ + "channels>=4.0.0", + "django>=4.2.0", + "reactpy>=1.1.0, <2.0.0", + "reactpy-router>=1.0.3, <2.0.0", + "dill>=0.3.5", + "orjson>=3.6.0", + "nest_asyncio>=1.5.0", + "typing_extensions", +] +dynamic = ["version"] +urls.Changelog = "https://reactive-python.github.io/reactpy-django/latest/about/changelog/" +urls.Documentation = "https://reactive-python.github.io/reactpy-django/" +urls.Source = "https://github.com/reactive-python/reactpy-django" -[tool.ruff.lint] -ignore = ["E501"] +[tool.hatch.version] +path = "src/reactpy_django/__init__.py" + +[tool.hatch.build.targets.sdist] +include = ["/src"] +artifacts = ["/src/reactpy_django/static/"] + +[tool.hatch.build.targets.wheel] +artifacts = ["/src/reactpy_django/static/"] + +[tool.hatch.metadata] +license-files = { paths = ["LICENSE.md"] } + +[tool.hatch.envs.default] +installer = "uv" + +[[tool.hatch.build.hooks.build-scripts.scripts]] +commands = [ + "bun install --cwd src/js", + "bun build src/js/src/index.tsx --outfile src/reactpy_django/static/reactpy_django/client.js --minify", + 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"', + 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"', +] +artifacts = [] + +############################# +# >>> Hatch Test Runner <<< # +############################# + +[tool.hatch.envs.hatch-test] +extra-dependencies = [ + "pytest-sugar", + "pytest-django", + "playwright", + "channels[daphne]>=4.0.0", + "twisted", + "tblib", + "servestatic", +] +matrix-name-format = "{variable}-{value}" + +# Django 4.2 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.9", "3.10", "3.11", "3.12"] +django = ["4.2"] + +# Django 5.0 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.10", "3.11", "3.12"] +django = ["5.0"] + +# Django 5.1 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.10", "3.11", "3.12", "3.13"] +django = ["5.1"] + +[tool.hatch.envs.hatch-test.overrides] +matrix.django.dependencies = [ + { if = [ + "4.2", + ], value = "django~=4.2" }, + { if = [ + "5.0", + ], value = "django~=5.0" }, + { if = [ + "5.1", + ], value = "django~=5.1" }, +] + +[tool.pytest.ini_options] +addopts = """\ + --strict-config + --strict-markers + --reuse-db + """ +django_find_project = false +DJANGO_SETTINGS_MODULE = "test_app.settings_single_db" +pythonpath = [".", "tests/"] + +################################ +# >>> Hatch Django Scripts <<< # +################################ + +[tool.hatch.envs.django] +extra-dependencies = ["channels[daphne]>=4.0.0", "twisted", "servestatic"] + +[tool.hatch.envs.django.scripts] +runserver = [ + "cd tests && python manage.py migrate --noinput", + "cd tests && python manage.py runserver", +] + +####################################### +# >>> Hatch Documentation Scripts <<< # +####################################### + +[tool.hatch.envs.docs] +template = "docs" +extra-dependencies = [ + "mkdocs", + "mkdocs-git-revision-date-localized-plugin", + "mkdocs-material==9.4.0", + "mkdocs-include-markdown-plugin", + "mkdocs-spellcheck[all]", + "mkdocs-git-authors-plugin", + "mkdocs-minify-plugin", + "mike", + "ruff", + "django-stubs", + "linkcheckmd", +] + +[tool.hatch.envs.docs.scripts] +serve = ["cd docs && mkdocs serve"] +build = ["cd docs && mkdocs build --strict"] +linkcheck = [ + "linkcheckMarkdown docs/ -v -r --method head", + "linkcheckMarkdown README.md -v -r", + "linkcheckMarkdown CHANGELOG.md -v -r", +] +deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] +deploy_develop = ["cd docs && mike deploy --push develop"] +check_examples = ["ruff check docs/examples/python"] + +############################ +# >>> Hatch JS Scripts <<< # +############################ + +[tool.hatch.envs.javascript] +detached = true + +[tool.hatch.envs.javascript.scripts] +check = ["cd src/js && bun install", "cd src/js && bun run check"] +fix = ["cd src/js && bun install", "cd src/js && bun run format"] + +######################### +# >>> Generic Tools <<< # +######################### [tool.ruff] extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", ".nox/*", "build/*"] line-length = 120 +format.preview = true +lint.extend-ignore = [ + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "ARG004", # Unused static method argument + "FBT001", # Boolean-typed positional argument in function definition + "FBT002", # Boolean default positional argument in function definition + "PLR2004", # Magic value used in comparison + "SIM115", # Use context handler for opening files + "SLF001", # Private member accessed + "E501", # Line too long + "PLC0415", # `import` should be at the top-level of a file +] +lint.preview = true +lint.isort.known-first-party = ["src", "tests"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 63e3d68e..00000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements/build-docs.txt --r requirements/build-pkg.txt --r requirements/check-style.txt --r requirements/check-types.txt --r requirements/dev-env.txt --r requirements/pkg-deps.txt --r requirements/test-env.txt --r requirements/test-run.txt diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt deleted file mode 100644 index 846a7ba3..00000000 --- a/requirements/build-docs.txt +++ /dev/null @@ -1,8 +0,0 @@ -mkdocs -mkdocs-git-revision-date-localized-plugin -mkdocs-material==9.4.0 -mkdocs-include-markdown-plugin -mkdocs-spellcheck[all] -mkdocs-git-authors-plugin -mkdocs-minify-plugin -mike diff --git a/requirements/build-pkg.txt b/requirements/build-pkg.txt deleted file mode 100644 index 82f40eaf..00000000 --- a/requirements/build-pkg.txt +++ /dev/null @@ -1,3 +0,0 @@ -twine -wheel -build diff --git a/requirements/check-style.txt b/requirements/check-style.txt deleted file mode 100644 index af3ee576..00000000 --- a/requirements/check-style.txt +++ /dev/null @@ -1 +0,0 @@ -ruff diff --git a/requirements/check-types.txt b/requirements/check-types.txt deleted file mode 100644 index c962b716..00000000 --- a/requirements/check-types.txt +++ /dev/null @@ -1,3 +0,0 @@ -mypy -django-stubs[compatible-mypy] -channels-redis diff --git a/requirements/dev-env.txt b/requirements/dev-env.txt deleted file mode 100644 index 05940702..00000000 --- a/requirements/dev-env.txt +++ /dev/null @@ -1,3 +0,0 @@ -twine -wheel --r ./test-run.txt diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt deleted file mode 100644 index 61182ef9..00000000 --- a/requirements/pkg-deps.txt +++ /dev/null @@ -1,8 +0,0 @@ -channels >=4.0.0 -django >=4.2.0 -reactpy >=1.1.0, <1.2.0 -reactpy-router >=1.0.0, <2.0.0 -dill >=0.3.5 -orjson >=3.6.0 -nest_asyncio >=1.5.0 -typing_extensions diff --git a/requirements/test-env.txt b/requirements/test-env.txt deleted file mode 100644 index fc1ba2ce..00000000 --- a/requirements/test-env.txt +++ /dev/null @@ -1,5 +0,0 @@ -playwright -twisted -channels[daphne]>=4.0.0 -tblib -whitenoise diff --git a/requirements/test-run.txt b/requirements/test-run.txt deleted file mode 100644 index 816817c6..00000000 --- a/requirements/test-run.txt +++ /dev/null @@ -1 +0,0 @@ -nox diff --git a/setup.py b/setup.py deleted file mode 100644 index f0c2f22d..00000000 --- a/setup.py +++ /dev/null @@ -1,182 +0,0 @@ -from __future__ import annotations, print_function - -import shutil -import subprocess -import sys -import traceback -from logging import getLogger -from pathlib import Path - -from setuptools import find_namespace_packages, setup -from setuptools.command.develop import develop -from setuptools.command.sdist import sdist - -# ----------------------------------------------------------------------------- -# Basic Constants -# ----------------------------------------------------------------------------- -name = "reactpy_django" -root_dir = Path(__file__).parent -src_dir = root_dir / "src" -js_dir = src_dir / "js" -package_dir = src_dir / name -static_dir = package_dir / "static" / name -log = getLogger(__name__) - - -# ----------------------------------------------------------------------------- -# Package Definition -# ----------------------------------------------------------------------------- -package = { - "name": name, - "python_requires": ">=3.9", - "packages": find_namespace_packages(src_dir), - "package_dir": {"": "src"}, - "description": "It's React, but in Python. Now with Django integration.", - "author": "Mark Bakhit", - "author_email": "archiethemonger@gmail.com", - "url": "https://github.com/reactive-python/reactpy-django", - "license": "MIT", - "platforms": "Linux, Mac OS X, Windows", - "keywords": [ - "interactive", - "reactive", - "widgets", - "DOM", - "React", - "ReactJS", - "ReactPy", - ], - "include_package_data": True, - "zip_safe": False, - "classifiers": [ - "Framework :: Django", - "Framework :: Django :: 4.0", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "Topic :: Multimedia :: Graphics", - "Topic :: Software Development :: Widget Sets", - "Topic :: Software Development :: User Interfaces", - "Environment :: Web Environment", - "Typing :: Typed", - ], -} - - -# ----------------------------------------------------------------------------- -# Library Version -# ----------------------------------------------------------------------------- -for line in (package_dir / "__init__.py").read_text().split("\n"): - if line.startswith("__version__ = "): - package["version"] = eval(line.split("=", 1)[1]) - break -else: - print(f"No version found in {package_dir}/__init__.py") - sys.exit(1) - - -# ----------------------------------------------------------------------------- -# Requirements -# ----------------------------------------------------------------------------- -requirements: list[str] = [] -with (root_dir / "requirements" / "pkg-deps.txt").open() as f: - requirements.extend(line for line in map(str.strip, f) if not line.startswith("#")) -package["install_requires"] = requirements - - -# ----------------------------------------------------------------------------- -# Library Description -# ----------------------------------------------------------------------------- -with (root_dir / "README.md").open() as f: - long_description = f.read() - -package["long_description"] = long_description -package["long_description_content_type"] = "text/markdown" - - -# ---------------------------------------------------------------------------- -# Build Javascript -# ---------------------------------------------------------------------------- -def copy_js_files(source_dir: Path, destination: Path) -> None: - if destination.exists(): - shutil.rmtree(destination) - destination.mkdir() - - for file in source_dir.iterdir(): - if file.is_file(): - shutil.copy(file, destination / file.name) - else: - copy_js_files(file, destination / file.name) - - -def build_javascript_first(build_cls: type): - class Command(build_cls): - def run(self): - - log.info("Installing Javascript...") - result = subprocess.run( - ["bun", "install"], cwd=str(js_dir), check=True - ).returncode - if result != 0: - log.error(traceback.format_exc()) - log.error("Failed to install Javascript") - raise RuntimeError("Failed to install Javascript") - - log.info("Building Javascript...") - result = subprocess.run( - [ - "bun", - "build", - "./src/index.tsx", - "--outfile", - str(static_dir / "client.js"), - "--minify", - ], - cwd=str(js_dir), - check=True, - ).returncode - if result != 0: - log.error(traceback.format_exc()) - log.error("Failed to build Javascript") - raise RuntimeError("Failed to build Javascript") - - log.info("Copying @pyscript/core distribution") - pyscript_dist = js_dir / "node_modules" / "@pyscript" / "core" / "dist" - pyscript_static_dir = static_dir / "pyscript" - copy_js_files(pyscript_dist, pyscript_static_dir) - - log.info("Copying Morphdom distribution") - morphdom_dist = js_dir / "node_modules" / "morphdom" / "dist" - morphdom_static_dir = static_dir / "morphdom" - copy_js_files(morphdom_dist, morphdom_static_dir) - - log.info("Successfully built Javascript") - super().run() - - return Command - - -package["cmdclass"] = { - "sdist": build_javascript_first(sdist), - "develop": build_javascript_first(develop), -} - -if sys.version_info < (3, 10, 6): - from distutils.command.build import build - - package["cmdclass"]["build"] = build_javascript_first(build) -else: - from setuptools.command.build_py import build_py - - package["cmdclass"]["build_py"] = build_javascript_first(build_py) - - -# ----------------------------------------------------------------------------- -# Installation -# ----------------------------------------------------------------------------- -if __name__ == "__main__": - setup(**package) diff --git a/src/build_scripts/copy_dir.py b/src/build_scripts/copy_dir.py new file mode 100644 index 00000000..1f446f83 --- /dev/null +++ b/src/build_scripts/copy_dir.py @@ -0,0 +1,31 @@ +import shutil +import sys +from pathlib import Path + + +def copy_files(source: Path, destination: Path) -> None: + if destination.exists(): + shutil.rmtree(destination) + destination.mkdir() + + for file in source.iterdir(): + if file.is_file(): + shutil.copy(file, destination / file.name) + else: + copy_files(file, destination / file.name) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python copy_dir.py ") + sys.exit(1) + + root_dir = Path(__file__).parent.parent.parent + src = Path(root_dir / sys.argv[1]) + dest = Path(root_dir / sys.argv[2]) + + if not src.exists(): + print(f"Source directory {src} does not exist") + sys.exit(1) + + copy_files(src, dest) diff --git a/src/js/eslint.config.js b/src/js/eslint.config.js deleted file mode 100644 index 27082ef3..00000000 --- a/src/js/eslint.config.js +++ /dev/null @@ -1 +0,0 @@ -export default [{}]; diff --git a/src/js/eslint.config.mjs b/src/js/eslint.config.mjs new file mode 100644 index 00000000..320e9f8b --- /dev/null +++ b/src/js/eslint.config.mjs @@ -0,0 +1,43 @@ +import react from "eslint-plugin-react"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + ...compat.extends("eslint:recommended", "plugin:react/recommended"), + { + plugins: { + react, + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + + ecmaVersion: "latest", + sourceType: "module", + }, + + settings: { + react: { + version: "18.2.0", + }, + }, + + rules: { + "react/prop-types": "off", + }, + }, +]; diff --git a/src/reactpy_django/clean.py b/src/reactpy_django/clean.py index 93df7be6..1ec327ee 100644 --- a/src/reactpy_django/clean.py +++ b/src/reactpy_django/clean.py @@ -49,7 +49,8 @@ def clean_sessions(verbosity: int = 1): """Deletes expired component sessions from the database. As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds. """ - from reactpy_django.config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE + + from reactpy_django.config import DJANGO_DEBUG, REACTPY_SESSION_MAX_AGE from reactpy_django.models import ComponentSession if verbosity >= 2: @@ -66,7 +67,7 @@ def clean_sessions(verbosity: int = 1): session_objects.delete() - if REACTPY_DEBUG_MODE or verbosity >= 2: + if DJANGO_DEBUG or verbosity >= 2: inspect_clean_duration(start_time, "component sessions", verbosity) @@ -78,7 +79,7 @@ def clean_user_data(verbosity: int = 1): However, we can't use Django to enforce this relationship since ReactPy can be configured to use any database. """ - from reactpy_django.config import REACTPY_DEBUG_MODE + from reactpy_django.config import DJANGO_DEBUG from reactpy_django.models import UserDataModel if verbosity >= 2: @@ -102,7 +103,7 @@ def clean_user_data(verbosity: int = 1): user_data_objects.delete() - if REACTPY_DEBUG_MODE or verbosity >= 2: + if DJANGO_DEBUG or verbosity >= 2: inspect_clean_duration(start_time, "user data", verbosity) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index e74299e3..090980a5 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -18,13 +18,13 @@ from reactpy_django.utils import import_dotted_path # Non-configurable values -REACTPY_DEBUG_MODE = _REACTPY_DEBUG_MODE.current REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {} REACTPY_FAILED_COMPONENTS: set[str] = set() REACTPY_REGISTERED_IFRAME_VIEWS: dict[str, Callable | View] = {} # Configurable through Django settings.py -_REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) +DJANGO_DEBUG = settings.DEBUG # Snapshot of Django's DEBUG setting +_REACTPY_DEBUG_MODE.set_current(settings.DEBUG) _REACTPY_ASYNC_RENDERING.set_current( getattr(settings, "REACTPY_ASYNC_RENDERING", _REACTPY_ASYNC_RENDERING.current) ) diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 6b4ecc16..7e3746f5 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -1,6 +1,6 @@ {% load static %} -{% if reactpy_failure and reactpy_debug_mode %} +{% if reactpy_failure and django_debug %}
{% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}"
{% endif %} @@ -10,18 +10,18 @@ {% if reactpy_prerender_html %}
{{reactpy_prerender_html|safe}}
{% endif %} {% if reactpy_offline_html %}{% endif %} {% endif %} diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html index e258cf08..547a672a 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_setup.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -1,7 +1,7 @@ {% load static %} -{% if not reactpy_debug_mode %} +{% if not django_debug %} {% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 2f34651a..1f419049 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -74,6 +74,8 @@ def component( """ + from reactpy_django.config import DJANGO_DEBUG + request: HttpRequest | None = context.get("request") perceived_host = (request.get_host() if request else "").strip("/") host = ( @@ -93,7 +95,7 @@ def component( _offline_html = "" # Validate the host - if host and reactpy_config.REACTPY_DEBUG_MODE: + if host and DJANGO_DEBUG: try: validate_host(host) except InvalidHostError as e: @@ -108,7 +110,7 @@ def component( return failure_context(dotted_path, ComponentDoesNotExistError(msg)) # Validate the component args & kwargs - if is_local and reactpy_config.REACTPY_DEBUG_MODE: + if is_local and DJANGO_DEBUG: try: validate_component_args(user_component, *args, **kwargs) except ComponentParamError as e: @@ -234,17 +236,21 @@ def pyscript_setup( config: A JSON string or Python dictionary containing PyScript \ configuration values. """ + from reactpy_django.config import DJANGO_DEBUG + return { "pyscript_config": extend_pyscript_config(extra_py, extra_js, config), "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, - "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, + "django_debug": DJANGO_DEBUG, } def failure_context(dotted_path: str, error: Exception): + from reactpy_django.config import DJANGO_DEBUG + return { "reactpy_failure": True, - "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, + "django_debug": DJANGO_DEBUG, "reactpy_dotted_path": dotted_path, "reactpy_error": type(error).__name__, } diff --git a/tests/test_app/apps.py b/tests/test_app/apps.py index 2bef8446..c5ec0d60 100644 --- a/tests/test_app/apps.py +++ b/tests/test_app/apps.py @@ -1,4 +1,3 @@ -import contextlib import sys from django.apps import AppConfig @@ -11,8 +10,6 @@ class TestAppConfig(AppConfig): name = "test_app" def ready(self): - from django.contrib.auth.models import User - register_iframe("test_app.views.view_to_iframe_sync_func") register_iframe(views.view_to_iframe_async_func) register_iframe(views.ViewToIframeSyncClass) @@ -22,8 +19,3 @@ def ready(self): if "test" in sys.argv: return - - with contextlib.suppress(Exception): - User.objects.create_superuser( - username="admin", email="admin@example.com", password="password" - ) diff --git a/tests/test_app/middleware.py b/tests/test_app/middleware.py new file mode 100644 index 00000000..0927a100 --- /dev/null +++ b/tests/test_app/middleware.py @@ -0,0 +1,31 @@ +import contextlib + +from asgiref.sync import iscoroutinefunction, markcoroutinefunction + + +class AutoCreateAdminMiddleware: + async_capable = True + sync_capable = True + + def __init__(self, get_response): + from django.contrib.auth.models import User + + # One-time configuration and initialization. + self.get_response = get_response + with contextlib.suppress(Exception): + User.objects.create_superuser( + username="admin", email="admin@example.com", password="password" + ) + + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + def __call__(self, request): + if iscoroutinefunction(self.get_response): + + async def async_call(): + return await self.get_response(request) + + return async_call() + + return self.get_response(request) diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index dd312195..7a2b29b4 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -1,14 +1,19 @@ from time import sleep -import reactpy_django from reactpy import component, html +import reactpy_django + +SLEEP_TIME = 0.25 + @component def prerender_string(): scope = reactpy_django.hooks.use_scope() - sleep(0.5) + if scope.get("type") != "http": + sleep(SLEEP_TIME) + return ( "prerender_string: Fully Rendered" if scope.get("type") == "websocket" diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py index 65e37415..fb390e28 100644 --- a/tests/test_app/settings_multi_db.py +++ b/tests/test_app/settings_multi_db.py @@ -12,9 +12,9 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # Run in production mode when using a real web server -DEBUG = all( - not sys.argv[0].endswith(webserver_name) - for webserver_name in {"hypercorn", "uvicorn", "daphne"} +DEBUG = not any( + sys.argv[0].endswith(webserver_name) + for webserver_name in ["hypercorn", "uvicorn", "daphne"] ) ALLOWED_HOSTS = ["*"] @@ -32,7 +32,8 @@ ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + "test_app.middleware.AutoCreateAdminMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -66,37 +67,26 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` - "NAME": os.path.join( - BASE_DIR, - f"test_{DB_NAME}.sqlite3" if "test" in sys.argv else f"{DB_NAME}.sqlite3", - ), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "TEST": { - "NAME": os.path.join(BASE_DIR, f"test_{DB_NAME}.sqlite3"), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "OPTIONS": {"timeout": 20}, "DEPENDENCIES": [], }, "OPTIONS": {"timeout": 20}, }, -} -if "test" in sys.argv: - DATABASES["reactpy"] = { + "reactpy": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` - "NAME": os.path.join( - BASE_DIR, - f"test_{DB_NAME}_2.sqlite3" - if "test" in sys.argv - else f"{DB_NAME}_2.sqlite3", - ), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}_2.sqlite3"), "TEST": { - "NAME": os.path.join(BASE_DIR, f"test_{DB_NAME}_2.sqlite3"), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}_2.sqlite3"), "OPTIONS": {"timeout": 20}, "DEPENDENCIES": [], }, "OPTIONS": {"timeout": 20}, - } - REACTPY_DATABASE = "reactpy" + }, +} +REACTPY_DATABASE = "reactpy" DATABASE_ROUTERS = ["reactpy_django.database.Router"] # Cache @@ -121,7 +111,6 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True -USE_L10N = True USE_TZ = True # Default primary key field type @@ -139,9 +128,7 @@ ] # Logging -LOG_LEVEL = "WARNING" -if DEBUG and ("test" not in sys.argv): - LOG_LEVEL = "DEBUG" +LOG_LEVEL = "DEBUG" LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -159,4 +146,6 @@ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # ReactPy-Django Settings -REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv +REACTPY_BACKHAUL_THREAD = any( + sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"] +) diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py index 2550c8d1..e5f8969a 100644 --- a/tests/test_app/settings_single_db.py +++ b/tests/test_app/settings_single_db.py @@ -12,9 +12,9 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # Run in production mode when using a real web server -DEBUG = all( - not sys.argv[0].endswith(webserver_name) - for webserver_name in {"hypercorn", "uvicorn", "daphne"} +DEBUG = not any( + sys.argv[0].endswith(webserver_name) + for webserver_name in ["hypercorn", "uvicorn", "daphne"] ) ALLOWED_HOSTS = ["*"] @@ -32,7 +32,8 @@ ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + "test_app.middleware.AutoCreateAdminMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -66,13 +67,9 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` - "NAME": os.path.join( - BASE_DIR, - f"test_{DB_NAME}.sqlite3" if "test" in sys.argv else f"{DB_NAME}.sqlite3", - ), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "TEST": { - "NAME": os.path.join(BASE_DIR, f"test_{DB_NAME}.sqlite3"), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "OPTIONS": {"timeout": 20}, "DEPENDENCIES": [], }, @@ -102,7 +99,6 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True -USE_L10N = True USE_TZ = True # Default primary key field type @@ -120,9 +116,7 @@ ] # Logging -LOG_LEVEL = "WARNING" -if DEBUG and ("test" not in sys.argv): - LOG_LEVEL = "DEBUG" +LOG_LEVEL = "DEBUG" LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -140,4 +134,6 @@ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # ReactPy-Django Settings -REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv +REACTPY_BACKHAUL_THREAD = any( + sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"] +) diff --git a/tests/test_app/tests/__init__.py b/tests/test_app/tests/__init__.py index fff5a11e..e69de29b 100644 --- a/tests/test_app/tests/__init__.py +++ b/tests/test_app/tests/__init__.py @@ -1 +0,0 @@ -from . import * # noqa: F401, F403 diff --git a/tests/test_app/tests/conftest.py b/tests/test_app/tests/conftest.py new file mode 100644 index 00000000..89c7fad9 --- /dev/null +++ b/tests/test_app/tests/conftest.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +os.chdir(Path(__file__).parent.parent.parent) + + +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + pass + + +@pytest.fixture(autouse=True, scope="session") +def install_playwright(): + subprocess.run(["playwright", "install", "chromium"], check=True) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index f3726a4c..c4848ccf 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,111 +1,25 @@ -import asyncio import os import socket -import sys -from functools import partial from time import sleep -from channels.testing import ChannelsLiveServerTestCase -from channels.testing.live import make_application -from django.core.exceptions import ImproperlyConfigured -from django.core.management import call_command -from django.db import connections -from django.test.utils import modify_settings -from playwright.sync_api import TimeoutError, sync_playwright +from playwright.sync_api import TimeoutError from reactpy_django.models import ComponentSession from reactpy_django.utils import strtobool -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") -CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. +from .utils import GITHUB_ACTIONS, PlaywrightTestCase +CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. -class ComponentTests(ChannelsLiveServerTestCase): - from django.db import DEFAULT_DB_ALIAS - from reactpy_django import config +class GenericComponentTests(PlaywrightTestCase): databases = {"default"} @classmethod def setUpClass(cls): - # Repurposed from ChannelsLiveServerTestCase._pre_setup - for connection in connections.all(): - if cls._is_in_memory_db(cls, connection): - raise ImproperlyConfigured( - "ChannelLiveServerTestCase can not be used with in memory databases" - ) - cls._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": cls.host} - ) - cls._live_server_modified_settings.enable() - get_application = partial( - make_application, - static_wrapper=cls.static_wrapper if cls.serve_static else None, - ) - cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process.start() - cls._server_process.ready.wait() - cls._port = cls._server_process.port.value - - # Open the second server process, used for testing custom hosts - cls._server_process2 = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process2.start() - cls._server_process2.ready.wait() - cls._port2 = cls._server_process2.port.value - - # Open the third server process, used for testing offline fallback - cls._server_process3 = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process3.start() - cls._server_process3.ready.wait() - cls._port3 = cls._server_process3.port.value - - # Open a Playwright browser window - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - cls.playwright = sync_playwright().start() - headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) - cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) - cls.page = cls.browser.new_page() - cls.page.set_default_timeout(5000) - - @classmethod - def tearDownClass(cls): - from reactpy_django import config - - # Close the Playwright browser - cls.playwright.stop() - - # Close the other server processes - cls._server_process.terminate() - cls._server_process.join() - cls._server_process2.terminate() - cls._server_process2.join() - cls._server_process3.terminate() - cls._server_process3.join() - - # Repurposed from ChannelsLiveServerTestCase._post_teardown - cls._live_server_modified_settings.disable() - for db_name in {"default", config.REACTPY_DATABASE}: - call_command( - "flush", - verbosity=0, - interactive=False, - database=db_name, - reset_sequences=False, - ) - - def _pre_setup(self): - """Handled manually in `setUpClass` to speed things up.""" - - def _post_teardown(self): - """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing - database flushing. This is needed to prevent a `SynchronousOnlyOperation` from - occuring due to a bug within `ChannelsLiveServerTestCase`.""" - - def setUp(self): - if self.page.url == "about:blank": - self.page.goto(self.live_server_url) + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}") def test_hello_world(self): self.page.wait_for_selector("#hello-world") @@ -297,149 +211,6 @@ def test_component_session_missing(self): os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) - def test_custom_host(self): - """Make sure that the component is rendered by a separate server.""" - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/port/{self._port2}/") - try: - elem = new_page.locator(".custom_host-0") - elem.wait_for() - self.assertIn( - f"Server Port: {self._port2}", - elem.text_content(), - ) - finally: - new_page.close() - - def test_custom_host_wrong_port(self): - """Make sure that other ports are not rendering components.""" - new_page = self.browser.new_page() - try: - tmp_sock = socket.socket() - tmp_sock.bind((self._server_process.host, 0)) - random_port = tmp_sock.getsockname()[1] - new_page.goto(f"{self.live_server_url}/port/{random_port}/") - with self.assertRaises(TimeoutError): - new_page.locator(".custom_host").wait_for(timeout=1000) - finally: - new_page.close() - - def test_host_roundrobin(self): - """Verify if round-robin host selection is working.""" - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8") - try: - elem0 = new_page.locator(".custom_host-0") - elem1 = new_page.locator(".custom_host-1") - elem2 = new_page.locator(".custom_host-2") - elem3 = new_page.locator(".custom_host-3") - - elem0.wait_for() - elem1.wait_for() - elem2.wait_for() - elem3.wait_for() - - current_ports = { - elem0.get_attribute("data-port"), - elem1.get_attribute("data-port"), - elem2.get_attribute("data-port"), - elem3.get_attribute("data-port"), - } - correct_ports = { - str(self._port), - str(self._port2), - } - - # There should only be two ports in the set - self.assertEqual(current_ports, correct_ports) - self.assertEqual(len(current_ports), 2) - finally: - new_page.close() - - def test_prerender(self): - """Verify if round-robin host selection is working.""" - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/prerender/") - try: - string = new_page.locator("#prerender_string") - vdom = new_page.locator("#prerender_vdom") - component = new_page.locator("#prerender_component") - use_root_id_http = new_page.locator("#use-root-id-http") - use_root_id_ws = new_page.locator("#use-root-id-ws") - use_user_http = new_page.locator("#use-user-http[data-success=True]") - use_user_ws = new_page.locator("#use-user-ws[data-success=true]") - - string.wait_for() - vdom.wait_for() - component.wait_for() - use_root_id_http.wait_for() - use_user_http.wait_for() - - # Check if the prerender occurred - self.assertEqual( - string.all_inner_texts(), ["prerender_string: Prerendered"] - ) - self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Prerendered"]) - self.assertEqual( - component.all_inner_texts(), ["prerender_component: Prerendered"] - ) - root_id_value = use_root_id_http.get_attribute("data-value") - self.assertEqual(len(root_id_value), 36) - - # Check if the full render occurred - sleep(1) - self.assertEqual( - string.all_inner_texts(), ["prerender_string: Fully Rendered"] - ) - self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Fully Rendered"]) - self.assertEqual( - component.all_inner_texts(), ["prerender_component: Fully Rendered"] - ) - use_root_id_ws.wait_for() - use_user_ws.wait_for() - self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value) - - finally: - new_page.close() - - def test_component_errors(self): - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/errors/") - try: - # ComponentDoesNotExistError - broken_component = new_page.locator("#component_does_not_exist_error") - broken_component.wait_for() - self.assertIn( - "ComponentDoesNotExistError:", broken_component.text_content() - ) - - # ComponentParamError - broken_component = new_page.locator("#component_param_error") - broken_component.wait_for() - self.assertIn("ComponentParamError:", broken_component.text_content()) - - # InvalidHostError - broken_component = new_page.locator("#invalid_host_error") - broken_component.wait_for() - self.assertIn("InvalidHostError:", broken_component.text_content()) - - # SynchronousOnlyOperation - broken_component = new_page.locator("#broken_postprocessor_query pre") - broken_component.wait_for() - self.assertIn("SynchronousOnlyOperation:", broken_component.text_content()) - - # ViewNotRegisteredError - broken_component = new_page.locator("#view_to_iframe_not_registered pre") - broken_component.wait_for() - self.assertIn("ViewNotRegisteredError:", broken_component.text_content()) - - # DecoratorParamError - broken_component = new_page.locator("#incorrect_user_passes_test_decorator") - broken_component.wait_for() - self.assertIn("DecoratorParamError:", broken_component.text_content()) - finally: - new_page.close() - def test_use_user_data(self): text_input = self.page.wait_for_selector("#use-user-data input") login_1 = self.page.wait_for_selector("#use-user-data .login-1") @@ -536,170 +307,333 @@ def test_use_user_data_with_default(self): user_data_div.text_content(), ) + +class PrerenderTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/prerender/") + + def test_prerender(self): + """Verify if round-robin host selection is working.""" + string = self.page.locator("#prerender_string") + vdom = self.page.locator("#prerender_vdom") + component = self.page.locator("#prerender_component") + use_root_id_http = self.page.locator("#use-root-id-http") + use_root_id_ws = self.page.locator("#use-root-id-ws") + use_user_http = self.page.locator("#use-user-http[data-success=True]") + use_user_ws = self.page.locator("#use-user-ws[data-success=true]") + + # Check if the prerender occurred properly + string.wait_for() + vdom.wait_for() + component.wait_for() + use_root_id_http.wait_for() + use_user_http.wait_for() + self.assertEqual(string.all_inner_texts(), ["prerender_string: Prerendered"]) + self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Prerendered"]) + self.assertEqual( + component.all_inner_texts(), ["prerender_component: Prerendered"] + ) + root_id_value = use_root_id_http.get_attribute("data-value") + self.assertEqual(len(root_id_value), 36) + + # Check if the full render occurred + sleep(2) + self.assertEqual(string.all_inner_texts(), ["prerender_string: Fully Rendered"]) + self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Fully Rendered"]) + self.assertEqual( + component.all_inner_texts(), ["prerender_component: Fully Rendered"] + ) + use_root_id_ws.wait_for() + use_user_ws.wait_for() + self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value) + + +class ErrorTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/errors/") + + def test_component_does_not_exist_error(self): + broken_component = self.page.locator("#component_does_not_exist_error") + broken_component.wait_for() + self.assertIn("ComponentDoesNotExistError:", broken_component.text_content()) + + def test_component_param_error(self): + broken_component = self.page.locator("#component_param_error") + broken_component.wait_for() + self.assertIn("ComponentParamError:", broken_component.text_content()) + + def test_invalid_host_error(self): + broken_component = self.page.locator("#invalid_host_error") + broken_component.wait_for() + self.assertIn("InvalidHostError:", broken_component.text_content()) + + def test_synchronous_only_operation_error(self): + broken_component = self.page.locator("#broken_postprocessor_query pre") + broken_component.wait_for() + self.assertIn("SynchronousOnlyOperation:", broken_component.text_content()) + + def test_view_not_registered_error(self): + broken_component = self.page.locator("#view_to_iframe_not_registered pre") + broken_component.wait_for() + self.assertIn("ViewNotRegisteredError:", broken_component.text_content()) + + def test_decorator_param_error(self): + broken_component = self.page.locator("#incorrect_user_passes_test_decorator") + broken_component.wait_for() + self.assertIn("DecoratorParamError:", broken_component.text_content()) + + +class UrlRouterTests(PlaywrightTestCase): + def test_url_router(self): - new_page = self.browser.new_page() - try: - new_page.goto(f"{self.live_server_url}/router/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/subroute/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/subroute/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("subroute/", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/unspecified/123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/unspecified/123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/unspecified//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/integer/123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/integer/123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/integer//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/path/abc/123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/path//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/slug/abc-123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/slug//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/string/abc/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/string/abc/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/string//", string.text_content()) - - new_page.goto( - f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" - ) - path = new_page.wait_for_selector("#router-path") - self.assertIn( - "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", - path.get_attribute("data-path"), - ) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/uuid//", string.text_content()) + self.page.goto(f"{self.live_server_url}/router/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/", string.text_content()) + + def test_url_router_subroute(self): + self.page.goto(f"{self.live_server_url}/router/subroute/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/subroute/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("subroute/", string.text_content()) + + def test_url_unspecified(self): + self.page.goto(f"{self.live_server_url}/router/unspecified/123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/unspecified/123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/unspecified//", string.text_content()) + + def test_url_router_integer(self): + self.page.goto(f"{self.live_server_url}/router/integer/123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/integer/123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/integer//", string.text_content()) + + def test_url_router_path(self): + self.page.goto(f"{self.live_server_url}/router/path/abc/123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/path//", string.text_content()) + + def test_url_router_slug(self): + self.page.goto(f"{self.live_server_url}/router/slug/abc-123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/slug//", string.text_content()) + + def test_url_router_string(self): + self.page.goto(f"{self.live_server_url}/router/string/abc/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/string/abc/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/string//", string.text_content()) + + def test_url_router_uuid(self): + self.page.goto( + f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" + ) + path = self.page.wait_for_selector("#router-path") + self.assertIn( + "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", + path.get_attribute("data-path"), + ) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/uuid//", string.text_content()) - new_page.goto( - f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/" - ) - path = new_page.wait_for_selector("#router-path") - self.assertIn( - "/router/any/adslkjgklasdjhfah/6789543256/", - path.get_attribute("data-path"), - ) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/any/", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/two/123/abc/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual( - "/router/two///", string.text_content() - ) + def test_url_router_any(self): + self.page.goto( + f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/" + ) + path = self.page.wait_for_selector("#router-path") + self.assertIn( + "/router/any/adslkjgklasdjhfah/6789543256/", + path.get_attribute("data-path"), + ) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/any/", string.text_content()) - finally: - new_page.close() + def test_url_router_int_and_string(self): + self.page.goto(f"{self.live_server_url}/router/two/123/abc/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/two///", string.text_content()) - def test_offline_components(self): - new_page = self.browser.new_page() - try: - server3_url = self.live_server_url.replace( - str(self._port), str(self._port3) - ) - new_page.goto(f"{server3_url}/offline/") - new_page.wait_for_selector("div:not([hidden]) > #online") - self.assertIsNotNone(new_page.query_selector("div[hidden] > #offline")) - self._server_process3.terminate() - self._server_process3.join() - new_page.wait_for_selector("div:not([hidden]) > #offline") - self.assertIsNotNone(new_page.query_selector("div[hidden] > #online")) - finally: - new_page.close() +class ChannelLayersTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/channel-layers/") def test_channel_layer_components(self): - new_page = self.browser.new_page() - try: - new_page.goto(f"{self.live_server_url}/channel-layers/") - sender = new_page.wait_for_selector("#sender") - sender.type("test", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) - receiver = new_page.wait_for_selector("#receiver[data-message='test']") - self.assertIsNotNone(receiver) - - sender = new_page.wait_for_selector("#group-sender") - sender.type("1234", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) - receiver_1 = new_page.wait_for_selector( - "#group-receiver-1[data-message='1234']" - ) - receiver_2 = new_page.wait_for_selector( - "#group-receiver-2[data-message='1234']" - ) - receiver_3 = new_page.wait_for_selector( - "#group-receiver-3[data-message='1234']" - ) - self.assertIsNotNone(receiver_1) - self.assertIsNotNone(receiver_2) - self.assertIsNotNone(receiver_3) - - finally: - new_page.close() - - def test_pyscript_components(self): - new_page = self.browser.new_page() - try: - new_page.goto(f"{self.live_server_url}/pyscript/") - new_page.wait_for_selector("#hello-world-loading") - new_page.wait_for_selector("#hello-world") - new_page.wait_for_selector("#custom-root") - new_page.wait_for_selector("#multifile-parent") - new_page.wait_for_selector("#multifile-child") - - new_page.wait_for_selector("#counter") - new_page.wait_for_selector("#counter pre[data-value='0']") - new_page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#counter pre[data-value='1']") - new_page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#counter pre[data-value='2']") - new_page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#counter pre[data-value='1']") - - new_page.wait_for_selector("#parent") - new_page.wait_for_selector("#child") - new_page.wait_for_selector("#child pre[data-value='0']") - new_page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#child pre[data-value='1']") - new_page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#child pre[data-value='2']") - new_page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#child pre[data-value='1']") - - new_page.wait_for_selector("#parent-toggle") - new_page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle") - new_page.wait_for_selector("#parent-toggle pre[data-value='0']") - new_page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle pre[data-value='1']") - new_page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle pre[data-value='2']") - new_page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle pre[data-value='1']") - - new_page.wait_for_selector("#moment[data-success=true]") - finally: - new_page.close() + sender = self.page.wait_for_selector("#sender") + sender.type("test", delay=CLICK_DELAY) + sender.press("Enter", delay=CLICK_DELAY) + receiver = self.page.wait_for_selector("#receiver[data-message='test']") + self.assertIsNotNone(receiver) + + sender = self.page.wait_for_selector("#group-sender") + sender.type("1234", delay=CLICK_DELAY) + sender.press("Enter", delay=CLICK_DELAY) + receiver_1 = self.page.wait_for_selector( + "#group-receiver-1[data-message='1234']" + ) + receiver_2 = self.page.wait_for_selector( + "#group-receiver-2[data-message='1234']" + ) + receiver_3 = self.page.wait_for_selector( + "#group-receiver-3[data-message='1234']" + ) + self.assertIsNotNone(receiver_1) + self.assertIsNotNone(receiver_2) + self.assertIsNotNone(receiver_3) + + +class PyscriptTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/pyscript/") + + def test_0_hello_world(self): + self.page.wait_for_selector("#hello-world-loading") + self.page.wait_for_selector("#hello-world") + + def test_1_custom_root(self): + self.page.wait_for_selector("#custom-root") + + def test_1_multifile(self): + self.page.wait_for_selector("#multifile-parent") + self.page.wait_for_selector("#multifile-child") + + def test_1_counter(self): + self.page.wait_for_selector("#counter") + self.page.wait_for_selector("#counter pre[data-value='0']") + self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter pre[data-value='1']") + self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter pre[data-value='2']") + self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter pre[data-value='1']") + + def test_1_server_side_parent(self): + self.page.wait_for_selector("#parent") + self.page.wait_for_selector("#child") + self.page.wait_for_selector("#child pre[data-value='0']") + self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child pre[data-value='1']") + self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child pre[data-value='2']") + self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child pre[data-value='1']") + + def test_1_server_side_parent_with_toggle(self): + self.page.wait_for_selector("#parent-toggle") + self.page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle") + self.page.wait_for_selector("#parent-toggle pre[data-value='0']") + self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle pre[data-value='1']") + self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle pre[data-value='2']") + self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle pre[data-value='1']") + + def test_1_javascript_module_execution_within_pyscript(self): + self.page.wait_for_selector("#moment[data-success=true]") + + +class DistributedComputingTests(PlaywrightTestCase): + + @classmethod + def setUpServer(cls): + super().setUpServer() + cls._server_process2 = cls.ProtocolServerProcess(cls.host, cls.get_application) + cls._server_process2.start() + cls._server_process2.ready.wait() + cls._port2 = cls._server_process2.port.value + + @classmethod + def tearDownServer(cls): + super().tearDownServer() + cls._server_process2.terminate() + cls._server_process2.join() + + def test_host_roundrobin(self): + """Verify if round-robin host selection is working.""" + self.page.goto( + f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8" + ) + elem0 = self.page.locator(".custom_host-0") + elem1 = self.page.locator(".custom_host-1") + elem2 = self.page.locator(".custom_host-2") + elem3 = self.page.locator(".custom_host-3") + + elem0.wait_for() + elem1.wait_for() + elem2.wait_for() + elem3.wait_for() + + current_ports = { + elem0.get_attribute("data-port"), + elem1.get_attribute("data-port"), + elem2.get_attribute("data-port"), + elem3.get_attribute("data-port"), + } + correct_ports = { + str(self._port), + str(self._port2), + } + + # There should only be two ports in the set + self.assertEqual(current_ports, correct_ports) + self.assertEqual(len(current_ports), 2) + + def test_custom_host(self): + """Make sure that the component is rendered by a separate server.""" + self.page.goto(f"{self.live_server_url}/port/{self._port2}/") + elem = self.page.locator(".custom_host-0") + elem.wait_for() + self.assertIn( + f"Server Port: {self._port2}", + elem.text_content(), + ) + + def test_custom_host_wrong_port(self): + """Make sure that other ports are not rendering components.""" + tmp_sock = socket.socket() + tmp_sock.bind((self._server_process.host, 0)) + random_port = tmp_sock.getsockname()[1] + self.page.goto(f"{self.live_server_url}/port/{random_port}/") + with self.assertRaises(TimeoutError): + self.page.locator(".custom_host").wait_for(timeout=1000) + + +class OfflineTests(PlaywrightTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/offline/") + + def test_offline_components(self): + self.page.wait_for_selector("div:not([hidden]) > #online") + self.assertIsNotNone(self.page.query_selector("div[hidden] > #offline")) + self._server_process.terminate() + self._server_process.join() + self.page.wait_for_selector("div:not([hidden]) > #offline") + self.assertIsNotNone(self.page.query_selector("div[hidden] > #online")) diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 6daa516f..83e34ccb 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -4,6 +4,7 @@ import dill as pickle from django.test import TransactionTestCase + from reactpy_django import clean from reactpy_django.models import ComponentSession, UserDataModel from reactpy_django.types import ComponentParams diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index bf567413..5c3ec95a 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -1,6 +1,7 @@ import re from django.test import TestCase + from reactpy_django.utils import COMMENT_REGEX, COMPONENT_REGEX @@ -21,10 +22,10 @@ def test_component_regex(self): ) self.assertRegex( r"""{% - component - "my.component" - class="my_thing" - attr="attribute" + component + "my.component" + class="my_thing" + attr="attribute" %}""", # noqa: W291 COMPONENT_REGEX, @@ -84,10 +85,10 @@ def test_comment_regex(self): COMMENT_REGEX, ) self.assertRegex( - r"""""", # noqa: W291 COMMENT_REGEX, ) @@ -138,8 +139,8 @@ def test_comment_regex(self): COMMENT_REGEX.sub( "", r"""""", # noqa: W291 ), "", diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py new file mode 100644 index 00000000..fe32d97d --- /dev/null +++ b/tests/test_app/tests/utils.py @@ -0,0 +1,92 @@ +import asyncio +import os +import sys +from functools import partial + +from channels.testing import ChannelsLiveServerTestCase +from channels.testing.live import make_application +from django.core.exceptions import ImproperlyConfigured +from django.core.management import call_command +from django.db import connections +from django.test.utils import modify_settings +from playwright.sync_api import sync_playwright + +from reactpy_django.utils import strtobool + +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") + + +class PlaywrightTestCase(ChannelsLiveServerTestCase): + + from reactpy_django import config + + databases = {"default"} + + @classmethod + def setUpClass(cls): + # Repurposed from ChannelsLiveServerTestCase._pre_setup + for connection in connections.all(): + if cls._is_in_memory_db(cls, connection): + raise ImproperlyConfigured( + "ChannelLiveServerTestCase can not be used with in memory databases" + ) + cls._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": cls.host} + ) + cls._live_server_modified_settings.enable() + cls.get_application = partial( + make_application, + static_wrapper=cls.static_wrapper if cls.serve_static else None, + ) + cls.setUpServer() + + # Open a Playwright browser window + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + cls.playwright = sync_playwright().start() + headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) + cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) + cls.page = cls.browser.new_page() + cls.page.set_default_timeout(10000) + + @classmethod + def setUpServer(cls): + cls._server_process = cls.ProtocolServerProcess(cls.host, cls.get_application) + cls._server_process.start() + cls._server_process.ready.wait() + cls._port = cls._server_process.port.value + + @classmethod + def tearDownClass(cls): + from reactpy_django import config + + # Close the Playwright browser + cls.playwright.stop() + + # Close the other server processes + cls.tearDownServer() + + # Repurposed from ChannelsLiveServerTestCase._post_teardown + cls._live_server_modified_settings.disable() + # Using set to prevent duplicates + for db_name in {"default", config.REACTPY_DATABASE}: + call_command( + "flush", + verbosity=0, + interactive=False, + database=db_name, + reset_sequences=False, + ) + + @classmethod + def tearDownServer(cls): + cls._server_process.terminate() + cls._server_process.join() + + def _pre_setup(self): + """Handled manually in `setUpClass` to speed things up.""" + + def _post_teardown(self): + """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing + database flushing. This is needed to prevent a `SynchronousOnlyOperation` from + occurring due to a bug within `ChannelsLiveServerTestCase`.""" From a85164cfaba95b3515d1ba2a8ea9c2396e1d61bf Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 2 Dec 2024 01:32:55 -0800 Subject: [PATCH 04/19] Replace `mypy` and `black` with `ruff` (#262) --- .github/workflows/test-docs.yml | 2 +- .github/workflows/test-python.yml | 15 ++ CHANGELOG.md | 3 + ...component.html => pyscript_component.html} | 0 ...ject.html => pyscript_initial_object.html} | 0 ...ring.html => pyscript_initial_string.html} | 0 ...import.html => pyscript_local_import.html} | 0 ...iles.html => pyscript_multiple_files.html} | 0 ...{pyscript-root.html => pyscript_root.html} | 0 ...yscript-setup.html => pyscript_setup.html} | 0 ...html => pyscript_setup_config_object.html} | 0 ...html => pyscript_setup_config_string.html} | 0 ....html => pyscript_setup_dependencies.html} | 0 ...ml => pyscript_setup_extra_js_object.html} | 0 ...ml => pyscript_setup_extra_js_string.html} | 0 ... => pyscript_setup_local_interpreter.html} | 0 ...r-parent.html => pyscript_ssr_parent.html} | 0 .../{pyscript-tag.html => pyscript_tag.html} | 0 .../{configure-asgi.py => configure_asgi.py} | 11 +- ...leware.py => configure_asgi_middleware.py} | 11 +- ...-app.py => configure_channels_asgi_app.py} | 0 ...py => configure_channels_installed_app.py} | 0 ...ed-apps.py => configure_installed_apps.py} | 0 .../{configure-urls.py => configure_urls.py} | 0 .../python/{django-css.py => django_css.py} | 1 + ...al-link.py => django_css_external_link.py} | 4 +- ...local-link.py => django_css_local_link.py} | 0 .../python/{django-js.py => django_js.py} | 1 + ...al-script.py => django_js_local_script.py} | 0 ...e-script.py => django_js_remote_script.py} | 0 ...essor.py => django_query_postprocessor.py} | 3 +- .../{django-router.py => django_router.py} | 3 +- docs/examples/python/example/__init__.py | 3 + docs/examples/python/example/components.py | 6 + docs/examples/python/example/views.py | 9 +- .../python/{example/urls.py => first_urls.py} | 1 + docs/examples/python/first_view.py | 5 + .../python/hello_world_app_config_cbv.py | 4 +- .../python/hello_world_app_config_fbv.py | 4 +- ...dide-js-module.py => pyodide_js_module.py} | 5 +- ...y => pyscript_component_initial_object.py} | 1 + ...y => pyscript_component_initial_string.py} | 1 + ...pyscript_component_multiple_files_root.py} | 1 + ...ent-root.py => pyscript_component_root.py} | 1 + ...hello-world.py => pyscript_hello_world.py} | 0 ...l-object.py => pyscript_initial_object.py} | 0 ...cal-import.py => pyscript_local_import.py} | 0 ...ld.py => pyscript_multiple_files_child.py} | 0 ...oot.py => pyscript_multiple_files_root.py} | 5 +- .../{pyscript-root.py => pyscript_root.py} | 0 ...ect.py => pyscript_setup_config_object.py} | 0 ...t.py => pyscript_setup_extra_js_object.py} | 0 ...ipt-ssr-child.py => pyscript_ssr_child.py} | 0 ...t-ssr-parent.py => pyscript_ssr_parent.py} | 1 + .../{pyscript-tag.py => pyscript_tag.py} | 1 + ...ter-component.py => register_component.py} | 1 + ...-kwargs.py => template_tag_args_kwargs.py} | 0 ...g-bad-view.py => template_tag_bad_view.py} | 0 .../{example/models.py => todo_item_model.py} | 0 ...-channel-layer.py => use_channel_layer.py} | 1 + ...er-group.py => use_channel_layer_group.py} | 1 + ...y => use_channel_layer_signal_receiver.py} | 1 + ....py => use_channel_layer_signal_sender.py} | 0 .../{use-connection.py => use_connection.py} | 1 + .../{use-location.py => use_location.py} | 1 + .../{use-mutation.py => use_mutation.py} | 3 +- ...-kwargs.py => use_mutation_args_kwargs.py} | 6 +- ...fetch.py => use_mutation_query_refetch.py} | 7 +- ...utation-reset.py => use_mutation_reset.py} | 3 +- ...ve.py => use_mutation_thread_sensitive.py} | 2 +- .../python/{use-origin.py => use_origin.py} | 1 + .../python/{use-query.py => use_query.py} | 7 +- .../{use-query-args.py => use_query_args.py} | 1 + ...e.py => use_query_postprocessor_change.py} | 2 +- ....py => use_query_postprocessor_disable.py} | 2 +- ...s.py => use_query_postprocessor_kwargs.py} | 3 +- ...itive.py => use_query_thread_sensitive.py} | 2 +- .../python/{use-root-id.py => use_root_id.py} | 1 + .../python/{use-scope.py => use_scope.py} | 1 + .../python/{use-user.py => use_user.py} | 1 + .../{use-user-data.py => use_user_data.py} | 1 + ...-defaults.py => use_user_data_defaults.py} | 1 + ...ser-passes-test.py => user_passes_test.py} | 1 + ...=> user_passes_test_component_fallback.py} | 1 + ...k.py => user_passes_test_vdom_fallback.py} | 1 + docs/examples/python/views.py | 7 - docs/examples/python/vtc.py | 4 +- .../python/{vtc-args.py => vtc_args.py} | 4 +- .../python/{vtc-cbv.py => vtc_cbv.py} | 4 +- ...trict-parsing.py => vtc_strict_parsing.py} | 4 +- .../{vtc-transforms.py => vtc_transforms.py} | 8 +- docs/examples/python/vti.py | 4 +- .../python/{vti-args.py => vti_args.py} | 4 +- .../python/{vti-cbv.py => vti_cbv.py} | 4 +- ...{vti-extra-props.py => vti_extra_props.py} | 8 +- docs/overrides/home.html | 12 +- .../add_interactivity.py} | 7 +- .../add_interactivity_demo.html} | 0 .../code_block.html} | 0 .../create_user_interfaces.py} | 1 + .../create_user_interfaces_demo.html} | 0 .../write_components_with_python.py} | 1 + .../write_components_with_python_demo.html} | 0 docs/src/about/contributing.md | 2 +- .../learn/add-reactpy-to-a-django-project.md | 12 +- docs/src/learn/your-first-component.md | 6 +- docs/src/reference/components.md | 56 ++--- docs/src/reference/decorators.md | 6 +- docs/src/reference/hooks.md | 62 ++--- docs/src/reference/html.md | 6 +- docs/src/reference/router.md | 2 +- docs/src/reference/settings.md | 4 +- docs/src/reference/template-tag.md | 54 ++-- docs/src/reference/utils.md | 10 +- pyproject.toml | 10 +- src/build_scripts/copy_dir.py | 6 +- src/reactpy_django/__init__.py | 6 +- src/reactpy_django/checks.py | 98 ++------ src/reactpy_django/clean.py | 28 +-- src/reactpy_django/components.py | 42 ++-- src/reactpy_django/config.py | 30 +-- src/reactpy_django/database.py | 9 +- src/reactpy_django/decorators.py | 9 +- src/reactpy_django/exceptions.py | 27 +- src/reactpy_django/hooks.py | 115 ++++----- src/reactpy_django/http/urls.py | 2 +- src/reactpy_django/http/views.py | 34 +-- .../management/commands/clean_reactpy.py | 5 +- src/reactpy_django/models.py | 12 +- .../pyscript/component_template.py | 1 + src/reactpy_django/pyscript/layout_handler.py | 55 ++-- src/reactpy_django/router/converters.py | 3 +- src/reactpy_django/router/resolvers.py | 6 +- src/reactpy_django/templatetags/reactpy.py | 38 ++- src/reactpy_django/types.py | 4 +- src/reactpy_django/utils.py | 175 ++++++------- src/reactpy_django/websocket/consumer.py | 45 ++-- src/reactpy_django/websocket/paths.py | 3 +- tests/manage.py | 8 +- tests/test_app/__init__.py | 22 +- tests/test_app/admin.py | 3 +- tests/test_app/asgi.py | 11 +- tests/test_app/channel_layers/components.py | 7 +- tests/test_app/components.py | 236 ++++++------------ tests/test_app/middleware.py | 4 +- tests/test_app/migrations/0001_initial.py | 2 +- tests/test_app/models.py | 34 ++- tests/test_app/offline/components.py | 3 +- tests/test_app/performance/components.py | 29 +-- tests/test_app/performance/urls.py | 1 - tests/test_app/prerender/components.py | 14 +- tests/test_app/pyscript/components/child.py | 8 +- tests/test_app/pyscript/components/counter.py | 8 +- .../pyscript/components/multifile_parent.py | 1 + .../pyscript/components/server_side.py | 5 +- tests/test_app/settings_multi_db.py | 14 +- tests/test_app/settings_single_db.py | 18 +- tests/test_app/templates/channel_layers.html | 6 +- .../tests/js/button-from-js-module.js | 30 +-- tests/test_app/tests/test_components.py | 220 ++++++---------- tests/test_app/tests/test_database.py | 31 ++- tests/test_app/tests/test_regex.py | 187 +++++--------- tests/test_app/tests/utils.py | 13 +- tests/test_app/views.py | 18 +- 164 files changed, 915 insertions(+), 1228 deletions(-) rename docs/examples/html/{pyscript-component.html => pyscript_component.html} (100%) rename docs/examples/html/{pyscript-initial-object.html => pyscript_initial_object.html} (100%) rename docs/examples/html/{pyscript-initial-string.html => pyscript_initial_string.html} (100%) rename docs/examples/html/{pyscript-local-import.html => pyscript_local_import.html} (100%) rename docs/examples/html/{pyscript-multiple-files.html => pyscript_multiple_files.html} (100%) rename docs/examples/html/{pyscript-root.html => pyscript_root.html} (100%) rename docs/examples/html/{pyscript-setup.html => pyscript_setup.html} (100%) rename docs/examples/html/{pyscript-setup-config-object.html => pyscript_setup_config_object.html} (100%) rename docs/examples/html/{pyscript-setup-config-string.html => pyscript_setup_config_string.html} (100%) rename docs/examples/html/{pyscript-setup-dependencies.html => pyscript_setup_dependencies.html} (100%) rename docs/examples/html/{pyscript-setup-extra-js-object.html => pyscript_setup_extra_js_object.html} (100%) rename docs/examples/html/{pyscript-setup-extra-js-string.html => pyscript_setup_extra_js_string.html} (100%) rename docs/examples/html/{pyscript-setup-local-interpreter.html => pyscript_setup_local_interpreter.html} (100%) rename docs/examples/html/{pyscript-ssr-parent.html => pyscript_ssr_parent.html} (100%) rename docs/examples/html/{pyscript-tag.html => pyscript_tag.html} (100%) rename docs/examples/python/{configure-asgi.py => configure_asgi.py} (77%) rename docs/examples/python/{configure-asgi-middleware.py => configure_asgi_middleware.py} (60%) rename docs/examples/python/{configure-channels-asgi-app.py => configure_channels_asgi_app.py} (100%) rename docs/examples/python/{configure-channels-installed-app.py => configure_channels_installed_app.py} (100%) rename docs/examples/python/{configure-installed-apps.py => configure_installed_apps.py} (100%) rename docs/examples/python/{configure-urls.py => configure_urls.py} (100%) rename docs/examples/python/{django-css.py => django_css.py} (99%) rename docs/examples/python/{django-css-external-link.py => django_css_external_link.py} (53%) rename docs/examples/python/{django-css-local-link.py => django_css_local_link.py} (100%) rename docs/examples/python/{django-js.py => django_js.py} (99%) rename docs/examples/python/{django-js-local-script.py => django_js_local_script.py} (100%) rename docs/examples/python/{django-js-remote-script.py => django_js_remote_script.py} (100%) rename docs/examples/python/{django-query-postprocessor.py => django_query_postprocessor.py} (99%) rename docs/examples/python/{django-router.py => django_router.py} (99%) create mode 100644 docs/examples/python/example/components.py rename docs/examples/python/{example/urls.py => first_urls.py} (99%) create mode 100644 docs/examples/python/first_view.py rename docs/examples/python/{pyodide-js-module.py => pyodide_js_module.py} (58%) rename docs/examples/python/{pyscript-component-initial-object.py => pyscript_component_initial_object.py} (99%) rename docs/examples/python/{pyscript-component-initial-string.py => pyscript_component_initial_string.py} (99%) rename docs/examples/python/{pyscript-component-multiple-files-root.py => pyscript_component_multiple_files_root.py} (99%) rename docs/examples/python/{pyscript-component-root.py => pyscript_component_root.py} (99%) rename docs/examples/python/{pyscript-hello-world.py => pyscript_hello_world.py} (100%) rename docs/examples/python/{pyscript-initial-object.py => pyscript_initial_object.py} (100%) rename docs/examples/python/{pyscript-local-import.py => pyscript_local_import.py} (100%) rename docs/examples/python/{pyscript-multiple-files-child.py => pyscript_multiple_files_child.py} (100%) rename docs/examples/python/{pyscript-multiple-files-root.py => pyscript_multiple_files_root.py} (60%) rename docs/examples/python/{pyscript-root.py => pyscript_root.py} (100%) rename docs/examples/python/{pyscript-setup-config-object.py => pyscript_setup_config_object.py} (100%) rename docs/examples/python/{pyscript-setup-extra-js-object.py => pyscript_setup_extra_js_object.py} (100%) rename docs/examples/python/{pyscript-ssr-child.py => pyscript_ssr_child.py} (100%) rename docs/examples/python/{pyscript-ssr-parent.py => pyscript_ssr_parent.py} (99%) rename docs/examples/python/{pyscript-tag.py => pyscript_tag.py} (99%) rename docs/examples/python/{register-component.py => register_component.py} (99%) rename docs/examples/python/{template-tag-args-kwargs.py => template_tag_args_kwargs.py} (100%) rename docs/examples/python/{template-tag-bad-view.py => template_tag_bad_view.py} (100%) rename docs/examples/python/{example/models.py => todo_item_model.py} (100%) rename docs/examples/python/{use-channel-layer.py => use_channel_layer.py} (99%) rename docs/examples/python/{use-channel-layer-group.py => use_channel_layer_group.py} (99%) rename docs/examples/python/{use-channel-layer-signal-receiver.py => use_channel_layer_signal_receiver.py} (99%) rename docs/examples/python/{use-channel-layer-signal-sender.py => use_channel_layer_signal_sender.py} (100%) rename docs/examples/python/{use-connection.py => use_connection.py} (99%) rename docs/examples/python/{use-location.py => use_location.py} (99%) rename docs/examples/python/{use-mutation.py => use_mutation.py} (99%) rename docs/examples/python/{use-mutation-args-kwargs.py => use_mutation_args_kwargs.py} (71%) rename docs/examples/python/{use-mutation-query-refetch.py => use_mutation_query_refetch.py} (90%) rename docs/examples/python/{use-mutation-reset.py => use_mutation_reset.py} (99%) rename docs/examples/python/{use-mutation-thread-sensitive.py => use_mutation_thread_sensitive.py} (98%) rename docs/examples/python/{use-origin.py => use_origin.py} (99%) rename docs/examples/python/{use-query.py => use_query.py} (82%) rename docs/examples/python/{use-query-args.py => use_query_args.py} (99%) rename docs/examples/python/{use-query-postprocessor-change.py => use_query_postprocessor_change.py} (98%) rename docs/examples/python/{use-query-postprocessor-disable.py => use_query_postprocessor_disable.py} (97%) rename docs/examples/python/{use-query-postprocessor-kwargs.py => use_query_postprocessor_kwargs.py} (99%) rename docs/examples/python/{use-query-thread-sensitive.py => use_query_thread_sensitive.py} (97%) rename docs/examples/python/{use-root-id.py => use_root_id.py} (99%) rename docs/examples/python/{use-scope.py => use_scope.py} (99%) rename docs/examples/python/{use-user.py => use_user.py} (99%) rename docs/examples/python/{use-user-data.py => use_user_data.py} (99%) rename docs/examples/python/{use-user-data-defaults.py => use_user_data_defaults.py} (99%) rename docs/examples/python/{user-passes-test.py => user_passes_test.py} (99%) rename docs/examples/python/{user-passes-test-component-fallback.py => user_passes_test_component_fallback.py} (99%) rename docs/examples/python/{user-passes-test-vdom-fallback.py => user_passes_test_vdom_fallback.py} (99%) delete mode 100644 docs/examples/python/views.py rename docs/examples/python/{vtc-args.py => vtc_args.py} (95%) rename docs/examples/python/{vtc-cbv.py => vtc_cbv.py} (90%) rename docs/examples/python/{vtc-strict-parsing.py => vtc_strict_parsing.py} (90%) rename docs/examples/python/{vtc-transforms.py => vtc_transforms.py} (75%) rename docs/examples/python/{vti-args.py => vti_args.py} (93%) rename docs/examples/python/{vti-cbv.py => vti_cbv.py} (90%) rename docs/examples/python/{vti-extra-props.py => vti_extra_props.py} (60%) rename docs/overrides/{home-code-examples/add-interactivity.py => homepage_examples/add_interactivity.py} (74%) rename docs/overrides/{home-code-examples/add-interactivity-demo.html => homepage_examples/add_interactivity_demo.html} (100%) rename docs/overrides/{home-code-examples/code-block.html => homepage_examples/code_block.html} (100%) rename docs/overrides/{home-code-examples/create-user-interfaces.py => homepage_examples/create_user_interfaces.py} (94%) rename docs/overrides/{home-code-examples/create-user-interfaces-demo.html => homepage_examples/create_user_interfaces_demo.html} (100%) rename docs/overrides/{home-code-examples/write-components-with-python.py => homepage_examples/write_components_with_python.py} (94%) rename docs/overrides/{home-code-examples/write-components-with-python-demo.html => homepage_examples/write_components_with_python_demo.html} (100%) diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 08bfadd7..0babadbc 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -29,4 +29,4 @@ jobs: - name: Check docs build run: hatch run docs:build - name: Check docs examples - run: hatch run docs:check_examples + run: hatch fmt docs --check diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 9fe700b8..8faca864 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -31,3 +31,18 @@ jobs: run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v - name: Run Multi-DB Tests run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v + + python-formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Check Python formatting + run: hatch fmt src tests --check diff --git a/CHANGELOG.md b/CHANGELOG.md index ec8d38ff..399d3668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Don't forget to remove deprecated code on each major release! ### Changed - Set upper limit on ReactPy version to `<2.0.0`. +- ReactPy web modules are now streamed in chunks. +- ReactPy web modules are now streamed using asynchronous file reading to improve performance. +- Performed refactoring to utilize `ruff` as this repository's linter. ## [5.1.0] - 2024-11-24 diff --git a/docs/examples/html/pyscript-component.html b/docs/examples/html/pyscript_component.html similarity index 100% rename from docs/examples/html/pyscript-component.html rename to docs/examples/html/pyscript_component.html diff --git a/docs/examples/html/pyscript-initial-object.html b/docs/examples/html/pyscript_initial_object.html similarity index 100% rename from docs/examples/html/pyscript-initial-object.html rename to docs/examples/html/pyscript_initial_object.html diff --git a/docs/examples/html/pyscript-initial-string.html b/docs/examples/html/pyscript_initial_string.html similarity index 100% rename from docs/examples/html/pyscript-initial-string.html rename to docs/examples/html/pyscript_initial_string.html diff --git a/docs/examples/html/pyscript-local-import.html b/docs/examples/html/pyscript_local_import.html similarity index 100% rename from docs/examples/html/pyscript-local-import.html rename to docs/examples/html/pyscript_local_import.html diff --git a/docs/examples/html/pyscript-multiple-files.html b/docs/examples/html/pyscript_multiple_files.html similarity index 100% rename from docs/examples/html/pyscript-multiple-files.html rename to docs/examples/html/pyscript_multiple_files.html diff --git a/docs/examples/html/pyscript-root.html b/docs/examples/html/pyscript_root.html similarity index 100% rename from docs/examples/html/pyscript-root.html rename to docs/examples/html/pyscript_root.html diff --git a/docs/examples/html/pyscript-setup.html b/docs/examples/html/pyscript_setup.html similarity index 100% rename from docs/examples/html/pyscript-setup.html rename to docs/examples/html/pyscript_setup.html diff --git a/docs/examples/html/pyscript-setup-config-object.html b/docs/examples/html/pyscript_setup_config_object.html similarity index 100% rename from docs/examples/html/pyscript-setup-config-object.html rename to docs/examples/html/pyscript_setup_config_object.html diff --git a/docs/examples/html/pyscript-setup-config-string.html b/docs/examples/html/pyscript_setup_config_string.html similarity index 100% rename from docs/examples/html/pyscript-setup-config-string.html rename to docs/examples/html/pyscript_setup_config_string.html diff --git a/docs/examples/html/pyscript-setup-dependencies.html b/docs/examples/html/pyscript_setup_dependencies.html similarity index 100% rename from docs/examples/html/pyscript-setup-dependencies.html rename to docs/examples/html/pyscript_setup_dependencies.html diff --git a/docs/examples/html/pyscript-setup-extra-js-object.html b/docs/examples/html/pyscript_setup_extra_js_object.html similarity index 100% rename from docs/examples/html/pyscript-setup-extra-js-object.html rename to docs/examples/html/pyscript_setup_extra_js_object.html diff --git a/docs/examples/html/pyscript-setup-extra-js-string.html b/docs/examples/html/pyscript_setup_extra_js_string.html similarity index 100% rename from docs/examples/html/pyscript-setup-extra-js-string.html rename to docs/examples/html/pyscript_setup_extra_js_string.html diff --git a/docs/examples/html/pyscript-setup-local-interpreter.html b/docs/examples/html/pyscript_setup_local_interpreter.html similarity index 100% rename from docs/examples/html/pyscript-setup-local-interpreter.html rename to docs/examples/html/pyscript_setup_local_interpreter.html diff --git a/docs/examples/html/pyscript-ssr-parent.html b/docs/examples/html/pyscript_ssr_parent.html similarity index 100% rename from docs/examples/html/pyscript-ssr-parent.html rename to docs/examples/html/pyscript_ssr_parent.html diff --git a/docs/examples/html/pyscript-tag.html b/docs/examples/html/pyscript_tag.html similarity index 100% rename from docs/examples/html/pyscript-tag.html rename to docs/examples/html/pyscript_tag.html diff --git a/docs/examples/python/configure-asgi.py b/docs/examples/python/configure_asgi.py similarity index 77% rename from docs/examples/python/configure-asgi.py rename to docs/examples/python/configure_asgi.py index 8081d747..8feb0ec2 100644 --- a/docs/examples/python/configure-asgi.py +++ b/docs/examples/python/configure_asgi.py @@ -10,11 +10,10 @@ from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 + from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]), - } -) +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]), +}) diff --git a/docs/examples/python/configure-asgi-middleware.py b/docs/examples/python/configure_asgi_middleware.py similarity index 60% rename from docs/examples/python/configure-asgi-middleware.py rename to docs/examples/python/configure_asgi_middleware.py index 6df35a39..0c5a7214 100644 --- a/docs/examples/python/configure-asgi-middleware.py +++ b/docs/examples/python/configure_asgi_middleware.py @@ -1,5 +1,6 @@ # Broken load order, only used for linting from channels.routing import ProtocolTypeRouter, URLRouter + from reactpy_django import REACTPY_WEBSOCKET_ROUTE django_asgi_app = "" @@ -8,9 +9,7 @@ # start from channels.auth import AuthMiddlewareStack # noqa: E402 -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), - } -) +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), +}) diff --git a/docs/examples/python/configure-channels-asgi-app.py b/docs/examples/python/configure_channels_asgi_app.py similarity index 100% rename from docs/examples/python/configure-channels-asgi-app.py rename to docs/examples/python/configure_channels_asgi_app.py diff --git a/docs/examples/python/configure-channels-installed-app.py b/docs/examples/python/configure_channels_installed_app.py similarity index 100% rename from docs/examples/python/configure-channels-installed-app.py rename to docs/examples/python/configure_channels_installed_app.py diff --git a/docs/examples/python/configure-installed-apps.py b/docs/examples/python/configure_installed_apps.py similarity index 100% rename from docs/examples/python/configure-installed-apps.py rename to docs/examples/python/configure_installed_apps.py diff --git a/docs/examples/python/configure-urls.py b/docs/examples/python/configure_urls.py similarity index 100% rename from docs/examples/python/configure-urls.py rename to docs/examples/python/configure_urls.py diff --git a/docs/examples/python/django-css.py b/docs/examples/python/django_css.py similarity index 99% rename from docs/examples/python/django-css.py rename to docs/examples/python/django_css.py index aeb4addb..c7f60881 100644 --- a/docs/examples/python/django-css.py +++ b/docs/examples/python/django_css.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import django_css diff --git a/docs/examples/python/django-css-external-link.py b/docs/examples/python/django_css_external_link.py similarity index 53% rename from docs/examples/python/django-css-external-link.py rename to docs/examples/python/django_css_external_link.py index ac1d0fba..28eb3fca 100644 --- a/docs/examples/python/django-css-external-link.py +++ b/docs/examples/python/django_css_external_link.py @@ -4,8 +4,6 @@ @component def my_component(): return html.div( - html.link( - {"rel": "stylesheet", "href": "https://example.com/external-styles.css"} - ), + html.link({"rel": "stylesheet", "href": "https://example.com/external-styles.css"}), html.button("My Button!"), ) diff --git a/docs/examples/python/django-css-local-link.py b/docs/examples/python/django_css_local_link.py similarity index 100% rename from docs/examples/python/django-css-local-link.py rename to docs/examples/python/django_css_local_link.py diff --git a/docs/examples/python/django-js.py b/docs/examples/python/django_js.py similarity index 99% rename from docs/examples/python/django-js.py rename to docs/examples/python/django_js.py index b4af014c..37868184 100644 --- a/docs/examples/python/django-js.py +++ b/docs/examples/python/django_js.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import django_js diff --git a/docs/examples/python/django-js-local-script.py b/docs/examples/python/django_js_local_script.py similarity index 100% rename from docs/examples/python/django-js-local-script.py rename to docs/examples/python/django_js_local_script.py diff --git a/docs/examples/python/django-js-remote-script.py b/docs/examples/python/django_js_remote_script.py similarity index 100% rename from docs/examples/python/django-js-remote-script.py rename to docs/examples/python/django_js_remote_script.py diff --git a/docs/examples/python/django-query-postprocessor.py b/docs/examples/python/django_query_postprocessor.py similarity index 99% rename from docs/examples/python/django-query-postprocessor.py rename to docs/examples/python/django_query_postprocessor.py index da33c362..7bdc870c 100644 --- a/docs/examples/python/django-query-postprocessor.py +++ b/docs/examples/python/django_query_postprocessor.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component + +from example.models import TodoItem from reactpy_django.hooks import use_query from reactpy_django.utils import django_query_postprocessor diff --git a/docs/examples/python/django-router.py b/docs/examples/python/django_router.py similarity index 99% rename from docs/examples/python/django-router.py rename to docs/examples/python/django_router.py index 5c845967..e37ae0a8 100644 --- a/docs/examples/python/django-router.py +++ b/docs/examples/python/django_router.py @@ -1,7 +1,8 @@ from reactpy import component, html -from reactpy_django.router import django_router from reactpy_router import route +from reactpy_django.router import django_router + @component def my_component(): diff --git a/docs/examples/python/example/__init__.py b/docs/examples/python/example/__init__.py index e69de29b..c32d6329 100644 --- a/docs/examples/python/example/__init__.py +++ b/docs/examples/python/example/__init__.py @@ -0,0 +1,3 @@ +"""This module exists only to satisfy type checkers. + +Do not use the files in this module as examples within the docs.""" diff --git a/docs/examples/python/example/components.py b/docs/examples/python/example/components.py new file mode 100644 index 00000000..ec301524 --- /dev/null +++ b/docs/examples/python/example/components.py @@ -0,0 +1,6 @@ +"""This module exists only to satisfy type checkers. + +Do not use the files in this module as examples within the docs.""" + + +def child_component(): ... diff --git a/docs/examples/python/example/views.py b/docs/examples/python/example/views.py index 23e21130..49bfeb8e 100644 --- a/docs/examples/python/example/views.py +++ b/docs/examples/python/example/views.py @@ -1,5 +1,8 @@ -from django.shortcuts import render +"""This module exists only to satisfy type checkers. +Do not use the files in this module as examples within the docs.""" -def index(request): - return render(request, "my_template.html") +from python.hello_world_cbv import HelloWorld +from python.hello_world_fbv import hello_world + +__all__ = ["HelloWorld", "hello_world"] diff --git a/docs/examples/python/example/urls.py b/docs/examples/python/first_urls.py similarity index 99% rename from docs/examples/python/example/urls.py rename to docs/examples/python/first_urls.py index 74f72806..a0f1d72f 100644 --- a/docs/examples/python/example/urls.py +++ b/docs/examples/python/first_urls.py @@ -1,4 +1,5 @@ from django.urls import path + from example import views urlpatterns = [ diff --git a/docs/examples/python/first_view.py b/docs/examples/python/first_view.py new file mode 100644 index 00000000..23e21130 --- /dev/null +++ b/docs/examples/python/first_view.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def index(request): + return render(request, "my_template.html") diff --git a/docs/examples/python/hello_world_app_config_cbv.py b/docs/examples/python/hello_world_app_config_cbv.py index ec448117..c0852da8 100644 --- a/docs/examples/python/hello_world_app_config_cbv.py +++ b/docs/examples/python/hello_world_app_config_cbv.py @@ -1,7 +1,7 @@ from django.apps import AppConfig -from reactpy_django.utils import register_iframe -from . import views +from example import views +from reactpy_django.utils import register_iframe class ExampleAppConfig(AppConfig): diff --git a/docs/examples/python/hello_world_app_config_fbv.py b/docs/examples/python/hello_world_app_config_fbv.py index c23c6919..47a71cde 100644 --- a/docs/examples/python/hello_world_app_config_fbv.py +++ b/docs/examples/python/hello_world_app_config_fbv.py @@ -1,7 +1,7 @@ from django.apps import AppConfig -from reactpy_django.utils import register_iframe -from . import views +from example import views +from reactpy_django.utils import register_iframe class ExampleAppConfig(AppConfig): diff --git a/docs/examples/python/pyodide-js-module.py b/docs/examples/python/pyodide_js_module.py similarity index 58% rename from docs/examples/python/pyodide-js-module.py rename to docs/examples/python/pyodide_js_module.py index a96ef65b..864936dc 100644 --- a/docs/examples/python/pyodide-js-module.py +++ b/docs/examples/python/pyodide_js_module.py @@ -4,8 +4,7 @@ @component def root(): - - def onClick(event): + def on_click(event): js.document.title = "New window title" - return html.button({"onClick": onClick}, "Click Me!") + return html.button({"onClick": on_click}, "Click Me!") diff --git a/docs/examples/python/pyscript-component-initial-object.py b/docs/examples/python/pyscript_component_initial_object.py similarity index 99% rename from docs/examples/python/pyscript-component-initial-object.py rename to docs/examples/python/pyscript_component_initial_object.py index 222a568b..d84328a4 100644 --- a/docs/examples/python/pyscript-component-initial-object.py +++ b/docs/examples/python/pyscript_component_initial_object.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-component-initial-string.py b/docs/examples/python/pyscript_component_initial_string.py similarity index 99% rename from docs/examples/python/pyscript-component-initial-string.py rename to docs/examples/python/pyscript_component_initial_string.py index 664b9f9b..bb8f9d17 100644 --- a/docs/examples/python/pyscript-component-initial-string.py +++ b/docs/examples/python/pyscript_component_initial_string.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-component-multiple-files-root.py b/docs/examples/python/pyscript_component_multiple_files_root.py similarity index 99% rename from docs/examples/python/pyscript-component-multiple-files-root.py rename to docs/examples/python/pyscript_component_multiple_files_root.py index 776b26b2..fd826137 100644 --- a/docs/examples/python/pyscript-component-multiple-files-root.py +++ b/docs/examples/python/pyscript_component_multiple_files_root.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-component-root.py b/docs/examples/python/pyscript_component_root.py similarity index 99% rename from docs/examples/python/pyscript-component-root.py rename to docs/examples/python/pyscript_component_root.py index 9880b740..3d795247 100644 --- a/docs/examples/python/pyscript-component-root.py +++ b/docs/examples/python/pyscript_component_root.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-hello-world.py b/docs/examples/python/pyscript_hello_world.py similarity index 100% rename from docs/examples/python/pyscript-hello-world.py rename to docs/examples/python/pyscript_hello_world.py diff --git a/docs/examples/python/pyscript-initial-object.py b/docs/examples/python/pyscript_initial_object.py similarity index 100% rename from docs/examples/python/pyscript-initial-object.py rename to docs/examples/python/pyscript_initial_object.py diff --git a/docs/examples/python/pyscript-local-import.py b/docs/examples/python/pyscript_local_import.py similarity index 100% rename from docs/examples/python/pyscript-local-import.py rename to docs/examples/python/pyscript_local_import.py diff --git a/docs/examples/python/pyscript-multiple-files-child.py b/docs/examples/python/pyscript_multiple_files_child.py similarity index 100% rename from docs/examples/python/pyscript-multiple-files-child.py rename to docs/examples/python/pyscript_multiple_files_child.py diff --git a/docs/examples/python/pyscript-multiple-files-root.py b/docs/examples/python/pyscript_multiple_files_root.py similarity index 60% rename from docs/examples/python/pyscript-multiple-files-root.py rename to docs/examples/python/pyscript_multiple_files_root.py index dc17e7ad..9ae8e549 100644 --- a/docs/examples/python/pyscript-multiple-files-root.py +++ b/docs/examples/python/pyscript_multiple_files_root.py @@ -1,9 +1,6 @@ -from typing import TYPE_CHECKING - from reactpy import component, html -if TYPE_CHECKING: - from .child import child_component +from example.components import child_component @component diff --git a/docs/examples/python/pyscript-root.py b/docs/examples/python/pyscript_root.py similarity index 100% rename from docs/examples/python/pyscript-root.py rename to docs/examples/python/pyscript_root.py diff --git a/docs/examples/python/pyscript-setup-config-object.py b/docs/examples/python/pyscript_setup_config_object.py similarity index 100% rename from docs/examples/python/pyscript-setup-config-object.py rename to docs/examples/python/pyscript_setup_config_object.py diff --git a/docs/examples/python/pyscript-setup-extra-js-object.py b/docs/examples/python/pyscript_setup_extra_js_object.py similarity index 100% rename from docs/examples/python/pyscript-setup-extra-js-object.py rename to docs/examples/python/pyscript_setup_extra_js_object.py diff --git a/docs/examples/python/pyscript-ssr-child.py b/docs/examples/python/pyscript_ssr_child.py similarity index 100% rename from docs/examples/python/pyscript-ssr-child.py rename to docs/examples/python/pyscript_ssr_child.py diff --git a/docs/examples/python/pyscript-ssr-parent.py b/docs/examples/python/pyscript_ssr_parent.py similarity index 99% rename from docs/examples/python/pyscript-ssr-parent.py rename to docs/examples/python/pyscript_ssr_parent.py index b51aa110..524cdc52 100644 --- a/docs/examples/python/pyscript-ssr-parent.py +++ b/docs/examples/python/pyscript_ssr_parent.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-tag.py b/docs/examples/python/pyscript_tag.py similarity index 99% rename from docs/examples/python/pyscript-tag.py rename to docs/examples/python/pyscript_tag.py index 6455e9da..a038b267 100644 --- a/docs/examples/python/pyscript-tag.py +++ b/docs/examples/python/pyscript_tag.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.html import pyscript example_source_code = """ diff --git a/docs/examples/python/register-component.py b/docs/examples/python/register_component.py similarity index 99% rename from docs/examples/python/register-component.py rename to docs/examples/python/register_component.py index cbdbf789..6d7d3831 100644 --- a/docs/examples/python/register-component.py +++ b/docs/examples/python/register_component.py @@ -1,4 +1,5 @@ from django.apps import AppConfig + from reactpy_django.utils import register_component diff --git a/docs/examples/python/template-tag-args-kwargs.py b/docs/examples/python/template_tag_args_kwargs.py similarity index 100% rename from docs/examples/python/template-tag-args-kwargs.py rename to docs/examples/python/template_tag_args_kwargs.py diff --git a/docs/examples/python/template-tag-bad-view.py b/docs/examples/python/template_tag_bad_view.py similarity index 100% rename from docs/examples/python/template-tag-bad-view.py rename to docs/examples/python/template_tag_bad_view.py diff --git a/docs/examples/python/example/models.py b/docs/examples/python/todo_item_model.py similarity index 100% rename from docs/examples/python/example/models.py rename to docs/examples/python/todo_item_model.py diff --git a/docs/examples/python/use-channel-layer.py b/docs/examples/python/use_channel_layer.py similarity index 99% rename from docs/examples/python/use-channel-layer.py rename to docs/examples/python/use_channel_layer.py index 83a66f19..f504c978 100644 --- a/docs/examples/python/use-channel-layer.py +++ b/docs/examples/python/use_channel_layer.py @@ -1,4 +1,5 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer diff --git a/docs/examples/python/use-channel-layer-group.py b/docs/examples/python/use_channel_layer_group.py similarity index 99% rename from docs/examples/python/use-channel-layer-group.py rename to docs/examples/python/use_channel_layer_group.py index bcbabee6..4e6aaa83 100644 --- a/docs/examples/python/use-channel-layer-group.py +++ b/docs/examples/python/use_channel_layer_group.py @@ -1,4 +1,5 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer diff --git a/docs/examples/python/use-channel-layer-signal-receiver.py b/docs/examples/python/use_channel_layer_signal_receiver.py similarity index 99% rename from docs/examples/python/use-channel-layer-signal-receiver.py rename to docs/examples/python/use_channel_layer_signal_receiver.py index 57a92321..bd8c47f9 100644 --- a/docs/examples/python/use-channel-layer-signal-receiver.py +++ b/docs/examples/python/use_channel_layer_signal_receiver.py @@ -1,4 +1,5 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer diff --git a/docs/examples/python/use-channel-layer-signal-sender.py b/docs/examples/python/use_channel_layer_signal_sender.py similarity index 100% rename from docs/examples/python/use-channel-layer-signal-sender.py rename to docs/examples/python/use_channel_layer_signal_sender.py diff --git a/docs/examples/python/use-connection.py b/docs/examples/python/use_connection.py similarity index 99% rename from docs/examples/python/use-connection.py rename to docs/examples/python/use_connection.py index 1ea0fdb6..a15cd39b 100644 --- a/docs/examples/python/use-connection.py +++ b/docs/examples/python/use_connection.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_connection diff --git a/docs/examples/python/use-location.py b/docs/examples/python/use_location.py similarity index 99% rename from docs/examples/python/use-location.py rename to docs/examples/python/use_location.py index d7afcbac..454da7f6 100644 --- a/docs/examples/python/use-location.py +++ b/docs/examples/python/use_location.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_location diff --git a/docs/examples/python/use-mutation.py b/docs/examples/python/use_mutation.py similarity index 99% rename from docs/examples/python/use-mutation.py rename to docs/examples/python/use_mutation.py index 1bc69312..dcfabb3e 100644 --- a/docs/examples/python/use-mutation.py +++ b/docs/examples/python/use_mutation.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_mutation diff --git a/docs/examples/python/use-mutation-args-kwargs.py b/docs/examples/python/use_mutation_args_kwargs.py similarity index 71% rename from docs/examples/python/use-mutation-args-kwargs.py rename to docs/examples/python/use_mutation_args_kwargs.py index f9889777..9a4b1e0a 100644 --- a/docs/examples/python/use-mutation-args-kwargs.py +++ b/docs/examples/python/use_mutation_args_kwargs.py @@ -1,9 +1,9 @@ from reactpy import component + from reactpy_django.hooks import use_mutation -def example_mutation(value: int, other_value: bool = False): - ... +def example_mutation(value: int, other_value: bool = False): ... @component @@ -11,5 +11,3 @@ def my_component(): mutation = use_mutation(example_mutation) mutation(123, other_value=True) - - ... diff --git a/docs/examples/python/use-mutation-query-refetch.py b/docs/examples/python/use_mutation_query_refetch.py similarity index 90% rename from docs/examples/python/use-mutation-query-refetch.py rename to docs/examples/python/use_mutation_query_refetch.py index 227ab1a7..40d4100a 100644 --- a/docs/examples/python/use-mutation-query-refetch.py +++ b/docs/examples/python/use_mutation_query_refetch.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_mutation, use_query @@ -26,9 +27,7 @@ def submit_event(event): elif item_query.error or not item_query.data: rendered_items = html.h2("Error when loading!") else: - rendered_items = html.ul( - html.li(item.text, key=item.pk) for item in item_query.data - ) + rendered_items = html.ul(html.li(item.text, key=item.pk) for item in item_query.data) # Handle all possible mutation states if item_mutation.loading: diff --git a/docs/examples/python/use-mutation-reset.py b/docs/examples/python/use_mutation_reset.py similarity index 99% rename from docs/examples/python/use-mutation-reset.py rename to docs/examples/python/use_mutation_reset.py index 8eb1e042..0b68d8b9 100644 --- a/docs/examples/python/use-mutation-reset.py +++ b/docs/examples/python/use_mutation_reset.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_mutation diff --git a/docs/examples/python/use-mutation-thread-sensitive.py b/docs/examples/python/use_mutation_thread_sensitive.py similarity index 98% rename from docs/examples/python/use-mutation-thread-sensitive.py rename to docs/examples/python/use_mutation_thread_sensitive.py index 85046dc0..762b0819 100644 --- a/docs/examples/python/use-mutation-thread-sensitive.py +++ b/docs/examples/python/use_mutation_thread_sensitive.py @@ -1,10 +1,10 @@ from reactpy import component, html + from reactpy_django.hooks import use_mutation def execute_thread_safe_mutation(text): """This is an example mutation function that does some thread-safe operation.""" - pass @component diff --git a/docs/examples/python/use-origin.py b/docs/examples/python/use_origin.py similarity index 99% rename from docs/examples/python/use-origin.py rename to docs/examples/python/use_origin.py index e8763bbf..f0713db9 100644 --- a/docs/examples/python/use-origin.py +++ b/docs/examples/python/use_origin.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_origin diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use_query.py similarity index 82% rename from docs/examples/python/use-query.py rename to docs/examples/python/use_query.py index 5688765b..9cadbd25 100644 --- a/docs/examples/python/use-query.py +++ b/docs/examples/python/use_query.py @@ -1,6 +1,7 @@ from channels.db import database_sync_to_async -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_query @@ -17,8 +18,6 @@ def todo_list(): elif item_query.error or not item_query.data: rendered_items = html.h2("Error when loading!") else: - rendered_items = html.ul( - [html.li(item.text, key=item.pk) for item in item_query.data] - ) + rendered_items = html.ul([html.li(item.text, key=item.pk) for item in item_query.data]) return html.div("Rendered items: ", rendered_items) diff --git a/docs/examples/python/use-query-args.py b/docs/examples/python/use_query_args.py similarity index 99% rename from docs/examples/python/use-query-args.py rename to docs/examples/python/use_query_args.py index 8deb549a..a37f7277 100644 --- a/docs/examples/python/use-query-args.py +++ b/docs/examples/python/use_query_args.py @@ -1,4 +1,5 @@ from reactpy import component + from reactpy_django.hooks import use_query diff --git a/docs/examples/python/use-query-postprocessor-change.py b/docs/examples/python/use_query_postprocessor_change.py similarity index 98% rename from docs/examples/python/use-query-postprocessor-change.py rename to docs/examples/python/use_query_postprocessor_change.py index 5685956d..2faba050 100644 --- a/docs/examples/python/use-query-postprocessor-change.py +++ b/docs/examples/python/use_query_postprocessor_change.py @@ -1,4 +1,5 @@ from reactpy import component + from reactpy_django.hooks import use_query @@ -11,7 +12,6 @@ def my_postprocessor(data, example_kwarg=True): def execute_io_intensive_operation(): """This is an example query function that does something IO intensive.""" - pass @component diff --git a/docs/examples/python/use-query-postprocessor-disable.py b/docs/examples/python/use_query_postprocessor_disable.py similarity index 97% rename from docs/examples/python/use-query-postprocessor-disable.py rename to docs/examples/python/use_query_postprocessor_disable.py index e9541924..a22f7a96 100644 --- a/docs/examples/python/use-query-postprocessor-disable.py +++ b/docs/examples/python/use_query_postprocessor_disable.py @@ -1,10 +1,10 @@ from reactpy import component + from reactpy_django.hooks import use_query def execute_io_intensive_operation(): """This is an example query function that does something IO intensive.""" - pass @component diff --git a/docs/examples/python/use-query-postprocessor-kwargs.py b/docs/examples/python/use_query_postprocessor_kwargs.py similarity index 99% rename from docs/examples/python/use-query-postprocessor-kwargs.py rename to docs/examples/python/use_query_postprocessor_kwargs.py index 4ed108af..18ba2999 100644 --- a/docs/examples/python/use-query-postprocessor-kwargs.py +++ b/docs/examples/python/use_query_postprocessor_kwargs.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component + +from example.models import TodoItem from reactpy_django.hooks import use_query diff --git a/docs/examples/python/use-query-thread-sensitive.py b/docs/examples/python/use_query_thread_sensitive.py similarity index 97% rename from docs/examples/python/use-query-thread-sensitive.py rename to docs/examples/python/use_query_thread_sensitive.py index d657be6b..9b929e3a 100644 --- a/docs/examples/python/use-query-thread-sensitive.py +++ b/docs/examples/python/use_query_thread_sensitive.py @@ -1,10 +1,10 @@ from reactpy import component + from reactpy_django.hooks import use_query def execute_thread_safe_operation(): """This is an example query function that does some thread-safe operation.""" - pass @component diff --git a/docs/examples/python/use-root-id.py b/docs/examples/python/use_root_id.py similarity index 99% rename from docs/examples/python/use-root-id.py rename to docs/examples/python/use_root_id.py index f2088cc4..246a8da1 100644 --- a/docs/examples/python/use-root-id.py +++ b/docs/examples/python/use_root_id.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_root_id diff --git a/docs/examples/python/use-scope.py b/docs/examples/python/use_scope.py similarity index 99% rename from docs/examples/python/use-scope.py rename to docs/examples/python/use_scope.py index 2e6f5961..2bd8f483 100644 --- a/docs/examples/python/use-scope.py +++ b/docs/examples/python/use_scope.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_scope diff --git a/docs/examples/python/use-user.py b/docs/examples/python/use_user.py similarity index 99% rename from docs/examples/python/use-user.py rename to docs/examples/python/use_user.py index 641bbeee..597e9f67 100644 --- a/docs/examples/python/use-user.py +++ b/docs/examples/python/use_user.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_user diff --git a/docs/examples/python/use-user-data.py b/docs/examples/python/use_user_data.py similarity index 99% rename from docs/examples/python/use-user-data.py rename to docs/examples/python/use_user_data.py index bc0ffaff..2c998db0 100644 --- a/docs/examples/python/use-user-data.py +++ b/docs/examples/python/use_user_data.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_user_data diff --git a/docs/examples/python/use-user-data-defaults.py b/docs/examples/python/use_user_data_defaults.py similarity index 99% rename from docs/examples/python/use-user-data-defaults.py rename to docs/examples/python/use_user_data_defaults.py index 7a1380bc..2c066ad7 100644 --- a/docs/examples/python/use-user-data-defaults.py +++ b/docs/examples/python/use_user_data_defaults.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_user_data diff --git a/docs/examples/python/user-passes-test.py b/docs/examples/python/user_passes_test.py similarity index 99% rename from docs/examples/python/user-passes-test.py rename to docs/examples/python/user_passes_test.py index 201ad831..37160c1b 100644 --- a/docs/examples/python/user-passes-test.py +++ b/docs/examples/python/user_passes_test.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.decorators import user_passes_test diff --git a/docs/examples/python/user-passes-test-component-fallback.py b/docs/examples/python/user_passes_test_component_fallback.py similarity index 99% rename from docs/examples/python/user-passes-test-component-fallback.py rename to docs/examples/python/user_passes_test_component_fallback.py index 9fb71ea7..b18330d1 100644 --- a/docs/examples/python/user-passes-test-component-fallback.py +++ b/docs/examples/python/user_passes_test_component_fallback.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.decorators import user_passes_test diff --git a/docs/examples/python/user-passes-test-vdom-fallback.py b/docs/examples/python/user_passes_test_vdom_fallback.py similarity index 99% rename from docs/examples/python/user-passes-test-vdom-fallback.py rename to docs/examples/python/user_passes_test_vdom_fallback.py index 5d5c54f4..9dd1ad65 100644 --- a/docs/examples/python/user-passes-test-vdom-fallback.py +++ b/docs/examples/python/user_passes_test_vdom_fallback.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.decorators import user_passes_test diff --git a/docs/examples/python/views.py b/docs/examples/python/views.py deleted file mode 100644 index 60ebc945..00000000 --- a/docs/examples/python/views.py +++ /dev/null @@ -1,7 +0,0 @@ -from .hello_world_cbv import HelloWorld -from .hello_world_fbv import hello_world - -__all__ = [ - "HelloWorld", - "hello_world", -] diff --git a/docs/examples/python/vtc.py b/docs/examples/python/vtc.py index 194d35cc..84c7aeb2 100644 --- a/docs/examples/python/vtc.py +++ b/docs/examples/python/vtc.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.hello_world) diff --git a/docs/examples/python/vtc-args.py b/docs/examples/python/vtc_args.py similarity index 95% rename from docs/examples/python/vtc-args.py rename to docs/examples/python/vtc_args.py index edc0fbb2..9ce081b5 100644 --- a/docs/examples/python/vtc-args.py +++ b/docs/examples/python/vtc_args.py @@ -1,8 +1,8 @@ from django.http import HttpRequest from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.hello_world) diff --git a/docs/examples/python/vtc-cbv.py b/docs/examples/python/vtc_cbv.py similarity index 90% rename from docs/examples/python/vtc-cbv.py rename to docs/examples/python/vtc_cbv.py index 47509b75..38e40efe 100644 --- a/docs/examples/python/vtc-cbv.py +++ b/docs/examples/python/vtc_cbv.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.HelloWorld.as_view()) diff --git a/docs/examples/python/vtc-strict-parsing.py b/docs/examples/python/vtc_strict_parsing.py similarity index 90% rename from docs/examples/python/vtc-strict-parsing.py rename to docs/examples/python/vtc_strict_parsing.py index 194d35cc..84c7aeb2 100644 --- a/docs/examples/python/vtc-strict-parsing.py +++ b/docs/examples/python/vtc_strict_parsing.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.hello_world) diff --git a/docs/examples/python/vtc-transforms.py b/docs/examples/python/vtc_transforms.py similarity index 75% rename from docs/examples/python/vtc-transforms.py rename to docs/examples/python/vtc_transforms.py index adbf9ea1..b8402481 100644 --- a/docs/examples/python/vtc-transforms.py +++ b/docs/examples/python/vtc_transforms.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component def example_transform(vdom): @@ -10,9 +10,7 @@ def example_transform(vdom): vdom["children"][0] = "Farewell World!" -hello_world_component = view_to_component( - views.hello_world, transforms=[example_transform] -) +hello_world_component = view_to_component(views.hello_world, transforms=[example_transform]) @component diff --git a/docs/examples/python/vti.py b/docs/examples/python/vti.py index c8ff6796..207e5bc5 100644 --- a/docs/examples/python/vti.py +++ b/docs/examples/python/vti.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe hello_world_iframe = view_to_iframe(views.hello_world) diff --git a/docs/examples/python/vti-args.py b/docs/examples/python/vti_args.py similarity index 93% rename from docs/examples/python/vti-args.py rename to docs/examples/python/vti_args.py index f5013ecd..a26c3d3a 100644 --- a/docs/examples/python/vti-args.py +++ b/docs/examples/python/vti_args.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe hello_world_iframe = view_to_iframe( views.hello_world, diff --git a/docs/examples/python/vti-cbv.py b/docs/examples/python/vti_cbv.py similarity index 90% rename from docs/examples/python/vti-cbv.py rename to docs/examples/python/vti_cbv.py index 4e1f1b44..63f182ae 100644 --- a/docs/examples/python/vti-cbv.py +++ b/docs/examples/python/vti_cbv.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe hello_world_iframe = view_to_iframe(views.HelloWorld.as_view()) diff --git a/docs/examples/python/vti-extra-props.py b/docs/examples/python/vti_extra_props.py similarity index 60% rename from docs/examples/python/vti-extra-props.py rename to docs/examples/python/vti_extra_props.py index 655ad541..09846a1c 100644 --- a/docs/examples/python/vti-extra-props.py +++ b/docs/examples/python/vti_extra_props.py @@ -1,11 +1,9 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe -hello_world_iframe = view_to_iframe( - views.hello_world, extra_props={"title": "Hello World!"} -) +hello_world_iframe = view_to_iframe(views.hello_world, extra_props={"title": "Hello World!"}) @component diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 67e31441..93d5ca29 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -73,9 +73,9 @@

Create user interfaces from components

{% with image="create-user-interfaces.png", class="pop-left" %} - {% include "home-code-examples/code-block.html" %} + {% include "homepage_examples/code_block.html" %} {% endwith %} - {% include "home-code-examples/create-user-interfaces-demo.html" %} + {% include "homepage_examples/create_user_interfaces_demo.html" %}

Whether you work on your own or with thousands of other developers, using React feels the same. It is @@ -94,9 +94,9 @@

Write components with pure Python code

{% with image="write-components-with-python.png", class="pop-left" %} - {% include "home-code-examples/code-block.html" %} + {% include "homepage_examples/code_block.html" %} {% endwith %} - {% include "home-code-examples/write-components-with-python-demo.html" %} + {% include "homepage_examples/write_components_with_python_demo.html" %}
@@ -110,9 +110,9 @@

Add interactivity wherever you need it

{% with image="add-interactivity.png" %} - {% include "home-code-examples/code-block.html" %} + {% include "homepage_examples/code_block.html" %} {% endwith %} - {% include "home-code-examples/add-interactivity-demo.html" %} + {% include "homepage_examples/add_interactivity_demo.html" %}

You don't have to build your whole page in ReactPy. Add React to your existing HTML page, and render diff --git a/docs/overrides/home-code-examples/add-interactivity.py b/docs/overrides/homepage_examples/add_interactivity.py similarity index 74% rename from docs/overrides/home-code-examples/add-interactivity.py rename to docs/overrides/homepage_examples/add_interactivity.py index f29ba3c8..9a7bf76f 100644 --- a/docs/overrides/home-code-examples/add-interactivity.py +++ b/docs/overrides/homepage_examples/add_interactivity.py @@ -1,8 +1,9 @@ -# pylint: disable=assignment-from-no-return, unnecessary-lambda +# ruff: noqa: INP001 from reactpy import component, html, use_state -def filter_videos(*_, **__): ... +def filter_videos(*_, **__): + return [] def search_input(*_, **__): ... @@ -18,7 +19,7 @@ def searchable_video_list(videos): return html._( search_input( - {"onChange": lambda new_text: set_search_text(new_text)}, + {"onChange": lambda event: set_search_text(event["target"]["value"])}, value=search_text, ), video_list( diff --git a/docs/overrides/home-code-examples/add-interactivity-demo.html b/docs/overrides/homepage_examples/add_interactivity_demo.html similarity index 100% rename from docs/overrides/home-code-examples/add-interactivity-demo.html rename to docs/overrides/homepage_examples/add_interactivity_demo.html diff --git a/docs/overrides/home-code-examples/code-block.html b/docs/overrides/homepage_examples/code_block.html similarity index 100% rename from docs/overrides/home-code-examples/code-block.html rename to docs/overrides/homepage_examples/code_block.html diff --git a/docs/overrides/home-code-examples/create-user-interfaces.py b/docs/overrides/homepage_examples/create_user_interfaces.py similarity index 94% rename from docs/overrides/home-code-examples/create-user-interfaces.py rename to docs/overrides/homepage_examples/create_user_interfaces.py index 873b9d88..7878aa6b 100644 --- a/docs/overrides/home-code-examples/create-user-interfaces.py +++ b/docs/overrides/homepage_examples/create_user_interfaces.py @@ -1,3 +1,4 @@ +# ruff: noqa: INP001 from reactpy import component, html diff --git a/docs/overrides/home-code-examples/create-user-interfaces-demo.html b/docs/overrides/homepage_examples/create_user_interfaces_demo.html similarity index 100% rename from docs/overrides/home-code-examples/create-user-interfaces-demo.html rename to docs/overrides/homepage_examples/create_user_interfaces_demo.html diff --git a/docs/overrides/home-code-examples/write-components-with-python.py b/docs/overrides/homepage_examples/write_components_with_python.py similarity index 94% rename from docs/overrides/home-code-examples/write-components-with-python.py rename to docs/overrides/homepage_examples/write_components_with_python.py index 47e28b68..5993046c 100644 --- a/docs/overrides/home-code-examples/write-components-with-python.py +++ b/docs/overrides/homepage_examples/write_components_with_python.py @@ -1,3 +1,4 @@ +# ruff: noqa: INP001 from reactpy import component, html diff --git a/docs/overrides/home-code-examples/write-components-with-python-demo.html b/docs/overrides/homepage_examples/write_components_with_python_demo.html similarity index 100% rename from docs/overrides/home-code-examples/write-components-with-python-demo.html rename to docs/overrides/homepage_examples/write_components_with_python_demo.html diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md index ecb0131b..59f4f989 100644 --- a/docs/src/about/contributing.md +++ b/docs/src/about/contributing.md @@ -76,7 +76,7 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch run docs:serve` | Start the [`mkdocs`](https://www.mkdocs.org/) server to view documentation locally | | `hatch run docs:build` | Build the documentation | | `hatch run docs:linkcheck` | Check for broken links in the documentation | -| `hatch run docs:check_examples` | Run linter on code examples in the documentation | +| `hatch fmt docs --check` | Run linter on code examples in the documentation | ### Environment Management diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md index 0bf919e2..407fe61d 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -29,7 +29,7 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject === "settings.py" ```python - {% include "../../examples/python/configure-installed-apps.py" %} + {% include "../../examples/python/configure_installed_apps.py" %} ``` ??? warning "Enable ASGI and Django Channels (Required)" @@ -42,13 +42,13 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject 2. Add `#!python "daphne"` to `#!python INSTALLED_APPS`. ```python linenums="0" - {% include "../../examples/python/configure-channels-installed-app.py" %} + {% include "../../examples/python/configure_channels_installed_app.py" %} ``` 3. Set your `#!python ASGI_APPLICATION` variable. ```python linenums="0" - {% include "../../examples/python/configure-channels-asgi-app.py" %} + {% include "../../examples/python/configure_channels_asgi_app.py" %} ``` ??? info "Configure ReactPy settings (Optional)" @@ -64,7 +64,7 @@ Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https: === "urls.py" ```python - {% include "../../examples/python/configure-urls.py" %} + {% include "../../examples/python/configure_urls.py" %} ``` ## Step 4: Configure `asgi.py` @@ -74,7 +74,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` === "asgi.py" ```python - {% include "../../examples/python/configure-asgi.py" %} + {% include "../../examples/python/configure_asgi.py" %} ``` ??? info "Add `#!python AuthMiddlewareStack` (Optional)" @@ -88,7 +88,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` In these situations will need to ensure you are using `#!python AuthMiddlewareStack`. ```python linenums="0" - {% include "../../examples/python/configure-asgi-middleware.py" start="# start" %} + {% include "../../examples/python/configure_asgi_middleware.py" start="# start" %} ``` ??? question "Where is my `asgi.py`?" diff --git a/docs/src/learn/your-first-component.md b/docs/src/learn/your-first-component.md index 85af4109..11e29798 100644 --- a/docs/src/learn/your-first-component.md +++ b/docs/src/learn/your-first-component.md @@ -8,7 +8,7 @@ Components are one of the core concepts of ReactPy. They are the foundation upon !!! abstract "Note" - If you have reached this point, you should have already [installed ReactPy-Django](../learn/add-reactpy-to-a-django-project.md) through the previous steps. + If you have reached this point, you should have already [installed ReactPy-Django](./add-reactpy-to-a-django-project.md) through the previous steps. --- @@ -87,7 +87,7 @@ Within your **Django app**'s `views.py` file, you will need to [create a view fu === "views.py" ```python - {% include "../../examples/python/example/views.py" %} + {% include "../../examples/python/first_view.py" %} ``` We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/stable/intro/tutorial01/#write-your-first-view) and define what URL it should be accessible at. @@ -95,7 +95,7 @@ We will add this new view into your [`urls.py`](https://docs.djangoproject.com/e === "urls.py" ```python - {% include "../../examples/python/example/urls.py" %} + {% include "../../examples/python/first_urls.py" %} ``` ??? question "Which urls.py do I add my views to?" diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 7c60ca68..4186af42 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -12,26 +12,26 @@ We supply some pre-designed that components can be used to help simplify develop This allows you to embedded any number of client-side PyScript components within traditional ReactPy components. -{% include-markdown "../reference/template-tag.md" start="" end="" %} +{% include-markdown "./template-tag.md" start="" end="" %} -{% include-markdown "../reference/template-tag.md" start="" end="" %} +{% include-markdown "./template-tag.md" start="" end="" %} === "components.py" ```python - {% include "../../examples/python/pyscript-ssr-parent.py" %} + {% include "../../examples/python/pyscript_ssr_parent.py" %} ``` === "root.py" ```python - {% include "../../examples/python/pyscript-ssr-child.py" %} + {% include "../../examples/python/pyscript_ssr_child.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-ssr-parent.html" %} + {% include "../../examples/html/pyscript_ssr_parent.html" %} ``` ??? example "See Interface" @@ -53,31 +53,31 @@ This allows you to embedded any number of client-side PyScript components within === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup.html" %} + {% include "../../examples/html/pyscript_setup.html" %} ``` -{% include-markdown "../reference/template-tag.md" start="" end="" %} +{% include-markdown "./template-tag.md" start="" end="" %} -{% include-markdown "../reference/template-tag.md" start="" end="" trailing-newlines=false preserve-includer-indent=false %} +{% include-markdown "./template-tag.md" start="" end="" trailing-newlines=false preserve-includer-indent=false %} === "components.py" ```python - {% include "../../examples/python/pyscript-component-multiple-files-root.py" %} + {% include "../../examples/python/pyscript_component_multiple_files_root.py" %} ``` === "root.py" ```python - {% include "../../examples/python/pyscript-multiple-files-root.py" %} + {% include "../../examples/python/pyscript_multiple_files_root.py" %} ``` === "child.py" ```python - {% include "../../examples/python/pyscript-multiple-files-child.py" %} + {% include "../../examples/python/pyscript_multiple_files_child.py" %} ``` ??? question "How do I display something while the component is loading?" @@ -89,7 +89,7 @@ This allows you to embedded any number of client-side PyScript components within === "components.py" ```python - {% include "../../examples/python/pyscript-component-initial-object.py" %} + {% include "../../examples/python/pyscript_component_initial_object.py" %} ``` However, you can also use a string containing raw HTML. @@ -97,7 +97,7 @@ This allows you to embedded any number of client-side PyScript components within === "components.py" ```python - {% include "../../examples/python/pyscript-component-initial-string.py" %} + {% include "../../examples/python/pyscript_component_initial_string.py" %} ``` ??? question "Can I use a different name for my root component?" @@ -107,13 +107,13 @@ This allows you to embedded any number of client-side PyScript components within === "components.py" ```python - {% include "../../examples/python/pyscript-component-root.py" %} + {% include "../../examples/python/pyscript_component_root.py" %} ``` === "main.py" ```python - {% include "../../examples/python/pyscript-root.py" %} + {% include "../../examples/python/pyscript_root.py" %} ``` --- @@ -171,7 +171,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-cbv.py" %} + {% include "../../examples/python/vtc_cbv.py" %} ``` === "views.py" @@ -187,7 +187,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-args.py" %} + {% include "../../examples/python/vtc_args.py" %} ``` === "views.py" @@ -215,7 +215,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-strict-parsing.py" %} + {% include "../../examples/python/vtc_strict_parsing.py" %} ``` === "views.py" @@ -237,7 +237,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-transforms.py" %} + {% include "../../examples/python/vtc_transforms.py" %} ``` === "views.py" @@ -308,7 +308,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vti-cbv.py" %} + {% include "../../examples/python/vti_cbv.py" %} ``` === "views.py" @@ -332,7 +332,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vti-args.py" %} + {% include "../../examples/python/vti_args.py" %} ``` === "views.py" @@ -364,7 +364,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vti-extra-props.py" %} + {% include "../../examples/python/vti_extra_props.py" %} ``` === "views.py" @@ -388,7 +388,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. === "components.py" ```python - {% include "../../examples/python/django-css.py" %} + {% include "../../examples/python/django_css.py" %} ``` ??? example "See Interface" @@ -413,7 +413,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. Here's an example on what you should avoid doing for Django static files: ```python - {% include "../../examples/python/django-css-local-link.py" %} + {% include "../../examples/python/django_css_local_link.py" %} ``` ??? question "How do I load external CSS?" @@ -423,7 +423,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. For external CSS, you should use `#!python html.link`. ```python - {% include "../../examples/python/django-css-external-link.py" %} + {% include "../../examples/python/django_css_external_link.py" %} ``` ??? question "Why not load my CSS in `#!html `?" @@ -450,7 +450,7 @@ Be mindful of load order! If your JavaScript relies on the component existing on === "components.py" ```python - {% include "../../examples/python/django-js.py" %} + {% include "../../examples/python/django_js.py" %} ``` ??? example "See Interface" @@ -475,7 +475,7 @@ Be mindful of load order! If your JavaScript relies on the component existing on Here's an example on what you should avoid doing for Django static files: ```python - {% include "../../examples/python/django-js-local-script.py" %} + {% include "../../examples/python/django_js_local_script.py" %} ``` ??? question "How do I load external JS?" @@ -485,7 +485,7 @@ Be mindful of load order! If your JavaScript relies on the component existing on For external JavaScript, you should use `#!python html.script`. ```python - {% include "../../examples/python/django-js-remote-script.py" %} + {% include "../../examples/python/django_js_remote_script.py" %} ``` ??? question "Why not load my JS in `#!html `?" diff --git a/docs/src/reference/decorators.md b/docs/src/reference/decorators.md index bc84c75e..1763cf25 100644 --- a/docs/src/reference/decorators.md +++ b/docs/src/reference/decorators.md @@ -17,7 +17,7 @@ This only works with ReactPy components, and is inspired by Django's [`user_pass === "components.py" ```python - {% include "../../examples/python/user-passes-test.py" %} + {% include "../../examples/python/user_passes_test.py" %} ``` ??? example "See Interface" @@ -42,7 +42,7 @@ This only works with ReactPy components, and is inspired by Django's [`user_pass === "components.py" ```python - {% include "../../examples/python/user-passes-test-component-fallback.py" %} + {% include "../../examples/python/user_passes_test_component_fallback.py" %} ``` ??? question "How do I render a simple `#!python reactpy.html` snippet if the test fails?" @@ -52,5 +52,5 @@ This only works with ReactPy components, and is inspired by Django's [`user_pass === "components.py" ```python - {% include "../../examples/python/user-passes-test-vdom-fallback.py" %} + {% include "../../examples/python/user_passes_test_vdom_fallback.py" %} ``` diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 3c07639f..65bf1727 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -22,20 +22,20 @@ Prefabricated hooks can be used within your `components.py` to help simplify dev Execute functions in the background and return the result, typically to [read](https://www.sumologic.com/glossary/crud/) data from the Django ORM. -The [default postprocessor](../reference/utils.md#django-query-postprocessor) expects your query function to `#!python return` a Django `#!python Model` or `#!python QuerySet`. This needs to be changed or disabled to execute other types of queries. +The [default postprocessor](./utils.md#django-query-postprocessor) expects your query function to `#!python return` a Django `#!python Model` or `#!python QuerySet`. This needs [to be changed](./settings.md#reactpy_default_query_postprocessor) or disabled to execute other types of queries. Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query.py" %} + {% include "../../examples/python/use_query.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? example "See Interface" @@ -63,7 +63,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-args.py" %} + {% include "../../examples/python/use_query_args.py" %} ``` ??? question "How can I customize this hook's behavior?" @@ -83,7 +83,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-thread-sensitive.py" %} + {% include "../../examples/python/use_query_thread_sensitive.py" %} ``` --- @@ -102,7 +102,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-postprocessor-disable.py" %} + {% include "../../examples/python/use_query_postprocessor_disable.py" %} ``` If you wish to create a custom `#!python postprocessor`, you will need to create a function where the first must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs` (see below). This `#!python postprocessor` function must return the modified `#!python data`. @@ -110,7 +110,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-postprocessor-change.py" %} + {% include "../../examples/python/use_query_postprocessor_change.py" %} ``` --- @@ -126,7 +126,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-postprocessor-kwargs.py" %} + {% include "../../examples/python/use_query_postprocessor_kwargs.py" %} ``` _Note: In Django's ORM design, the field name to access foreign keys is [postfixed with `_set`](https://docs.djangoproject.com/en/stable/topics/db/examples/many_to_one/) by default._ @@ -144,20 +144,20 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-reset.py" %} + {% include "../../examples/python/use_mutation_reset.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? question "Why does the example query function return `#!python TodoItem.objects.all()`?" This design decision was based on [Apollo's `#!javascript useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `#!python SynchronousOnlyOperation` exceptions. - With the `#!python Model` or `#!python QuerySet` your function returns, this hook uses the [default postprocessor](../reference/utils.md#django-query-postprocessor) to ensure that all [deferred](https://docs.djangoproject.com/en/stable/ref/models/instances/#django.db.models.Model.get_deferred_fields) or [lazy](https://docs.djangoproject.com/en/stable/topics/db/queries/#querysets-are-lazy) fields are executed. + With the `#!python Model` or `#!python QuerySet` your function returns, this hook uses the [default postprocessor](./utils.md#django-query-postprocessor) to ensure that all [deferred](https://docs.djangoproject.com/en/stable/ref/models/instances/#django.db.models.Model.get_deferred_fields) or [lazy](https://docs.djangoproject.com/en/stable/topics/db/queries/#querysets-are-lazy) fields are executed. --- @@ -172,13 +172,13 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation.py" %} + {% include "../../examples/python/use_mutation.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? example "See Interface" @@ -204,7 +204,7 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-args-kwargs.py" %} + {% include "../../examples/python/use_mutation_args_kwargs.py" %} ``` ??? question "How can I customize this hook's behavior?" @@ -224,7 +224,7 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-thread-sensitive.py" %} + {% include "../../examples/python/use_mutation_thread_sensitive.py" %} ``` ??? question "Can I make ORM calls without hooks?" @@ -240,13 +240,13 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-reset.py" %} + {% include "../../examples/python/use_mutation_reset.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? question "Can `#!python use_mutation` trigger a refetch of `#!python use_query`?" @@ -260,13 +260,13 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-query-refetch.py" %} + {% include "../../examples/python/use_mutation_query_refetch.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` --- @@ -282,7 +282,7 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. === "components.py" ```python - {% include "../../examples/python/use-user-data.py" %} + {% include "../../examples/python/use_user_data.py" %} ``` ??? example "See Interface" @@ -309,7 +309,7 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. === "components.py" ```python - {% include "../../examples/python/use-user-data-defaults.py" %} + {% include "../../examples/python/use_user_data_defaults.py" %} ``` --- @@ -329,7 +329,7 @@ This is often used to create chat systems, synchronize data between components, === "components.py" ```python - {% include "../../examples/python/use-channel-layer.py" %} + {% include "../../examples/python/use_channel_layer.py" %} ``` ??? example "See Interface" @@ -391,7 +391,7 @@ This is often used to create chat systems, synchronize data between components, === "components.py" ```python - {% include "../../examples/python/use-channel-layer-group.py" %} + {% include "../../examples/python/use_channel_layer_group.py" %} ``` ??? question "How do I signal a re-render from something that isn't a component?" @@ -405,13 +405,13 @@ This is often used to create chat systems, synchronize data between components, === "signals.py" ```python - {% include "../../examples/python/use-channel-layer-signal-sender.py" %} + {% include "../../examples/python/use_channel_layer_signal_sender.py" %} ``` === "components.py" ```python - {% include "../../examples/python/use-channel-layer-signal-receiver.py" %} + {% include "../../examples/python/use_channel_layer_signal_receiver.py" %} ``` --- @@ -427,7 +427,7 @@ Returns the active connection, which is either a Django [WebSocket](https://chan === "components.py" ```python - {% include "../../examples/python/use-connection.py" %} + {% include "../../examples/python/use_connection.py" %} ``` ??? example "See Interface" @@ -451,7 +451,7 @@ Shortcut that returns the WebSocket or HTTP connection's [scope](https://channel === "components.py" ```python - {% include "../../examples/python/use-scope.py" %} + {% include "../../examples/python/use_scope.py" %} ``` ??? example "See Interface" @@ -475,7 +475,7 @@ Shortcut that returns the browser's current `#!python Location`. === "components.py" ```python - {% include "../../examples/python/use-location.py" %} + {% include "../../examples/python/use_location.py" %} ``` ??? example "See Interface" @@ -501,7 +501,7 @@ You can expect this hook to provide strings such as `http://example.com`. === "components.py" ```python - {% include "../../examples/python/use-origin.py" %} + {% include "../../examples/python/use_origin.py" %} ``` ??? example "See Interface" @@ -529,7 +529,7 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use === "components.py" ```python - {% include "../../examples/python/use-root-id.py" %} + {% include "../../examples/python/use_root_id.py" %} ``` ??? example "See Interface" @@ -553,7 +553,7 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`. === "components.py" ```python - {% include "../../examples/python/use-user.py" %} + {% include "../../examples/python/use_user.py" %} ``` ??? example "See Interface" diff --git a/docs/src/reference/html.md b/docs/src/reference/html.md index baef6ebf..c9bb0108 100644 --- a/docs/src/reference/html.md +++ b/docs/src/reference/html.md @@ -19,13 +19,13 @@ The `pyscript` tag functions identically to HTML tags contained within `#!python === "components.py" ```python - {% include "../../examples/python/pyscript-tag.py" %} + {% include "../../examples/python/pyscript_tag.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-tag.html" %} + {% include "../../examples/html/pyscript_tag.html" %} ``` -{% include-markdown "../reference/components.md" start="" end="" %} +{% include-markdown "./components.md" start="" end="" %} diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index be6093c6..757981f6 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -33,7 +33,7 @@ URL router that enables the ability to conditionally render other components bas === "components.py" ```python - {% include "../../examples/python/django-router.py" %} + {% include "../../examples/python/django_router.py" %} ``` ??? example "See Interface" diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 23760919..6b1c78c4 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -145,7 +145,7 @@ The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. This is typically useful for self-hosted applications. -You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) to manually override this default. +You can use the `#!python host` argument in your [template tag](./template-tag.md#component) to manually override this default. --- @@ -164,7 +164,7 @@ During pre-rendering, there are some key differences in behavior: 3. The component will be non-interactive until a WebSocket connection is formed. 4. The component is re-rendered once a WebSocket connection is formed. -You can use the `#!python prerender` argument in your [template tag](../reference/template-tag.md#component) to manually override this default. +You can use the `#!python prerender` argument in your [template tag](./template-tag.md#component) to manually override this default. --- diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 091b2ac8..f969eb00 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -56,10 +56,10 @@ Each component loaded via this template tag will receive a dedicated WebSocket c === "views.py" ```python - {% include "../../examples/python/template-tag-bad-view.py" %} + {% include "../../examples/python/template_tag_bad_view.py" %} ``` - _Note: If you decide to not follow this warning, you will need to use the [`register_component`](../reference/utils.md#register-component) function to manually register your components._ + _Note: If you decide to not follow this warning, you will need to use the [`register_component`](./utils.md#register-component) function to manually register your components._ @@ -102,12 +102,12 @@ Each component loaded via this template tag will receive a dedicated WebSocket c === "components.py" ```python - {% include "../../examples/python/template-tag-args-kwargs.py" %} + {% include "../../examples/python/template_tag_args_kwargs.py" %} ``` ??? question "Can I render components on a different server (distributed computing)?" - Yes! This is most commonly done through [`settings.py:REACTPY_HOSTS`](../reference/settings.md#reactpy_default_hosts). However, you can use the `#!python host` keyword to render components on a specific ASGI server. + Yes! This is most commonly done through [`settings.py:REACTPY_HOSTS`](./settings.md#reactpy_default_hosts). However, you can use the `#!python host` keyword to render components on a specific ASGI server. === "my_template.html" @@ -127,7 +127,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c ??? question "How do I pre-render components for SEO compatibility?" - This is most commonly done through [`settings.py:REACTPY_PRERENDER`](../reference/settings.md#reactpy_prerender). However, you can use the `#!python prerender` keyword to pre-render a specific component. + This is most commonly done through [`settings.py:REACTPY_PRERENDER`](./settings.md#reactpy_prerender). However, you can use the `#!python prerender` keyword to pre-render a specific component. === "my_template.html" @@ -175,13 +175,13 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-component.html" %} + {% include "../../examples/html/pyscript_component.html" %} ``` === "hello_world.py" ```python - {% include "../../examples/python/pyscript-hello-world.py" %} + {% include "../../examples/python/pyscript_hello_world.py" %} ``` ??? example "See Interface" @@ -211,7 +211,7 @@ The entire file path provided is loaded directly into the browser, and must have === "root.py" ```python - {% include "../../examples/python/pyodide-js-module.py" %} + {% include "../../examples/python/pyodide_js_module.py" %} ``` **PyScript FFI** @@ -225,13 +225,13 @@ The entire file path provided is loaded directly into the browser, and must have === "root.py" ```python - {% include "../../examples/python/pyscript-local-import.py" %} + {% include "../../examples/python/pyscript_local_import.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-local-import.html" %} + {% include "../../examples/html/pyscript_local_import.html" %} ``` @@ -253,19 +253,19 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-multiple-files.html" %} + {% include "../../examples/html/pyscript_multiple_files.html" %} ``` === "root.py" ```python - {% include "../../examples/python/pyscript-multiple-files-root.py" %} + {% include "../../examples/python/pyscript_multiple_files_root.py" %} ``` === "child.py" ```python - {% include "../../examples/python/pyscript-multiple-files-child.py" %} + {% include "../../examples/python/pyscript_multiple_files_child.py" %} ``` ??? question "How do I display something while the component is loading?" @@ -277,7 +277,7 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-initial-string.html" %} + {% include "../../examples/html/pyscript_initial_string.html" %} ``` However, you can also insert a `#!python reactpy.html` snippet or a non-interactive `#!python @component` via template context. @@ -285,13 +285,13 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-initial-object.html" %} + {% include "../../examples/html/pyscript_initial_object.html" %} ``` === "views.py" ```python - {% include "../../examples/python/pyscript-initial-object.py" %} + {% include "../../examples/python/pyscript_initial_object.py" %} ``` ??? question "Can I use a different name for my root component?" @@ -301,13 +301,13 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-root.html" %} + {% include "../../examples/html/pyscript_root.html" %} ``` === "main.py" ```python - {% include "../../examples/python/pyscript-root.py" %} + {% include "../../examples/python/pyscript_root.py" %} ``` ## PyScript Setup @@ -319,7 +319,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup.html" %} + {% include "../../examples/html/pyscript_setup.html" %} ``` ??? example "See Interface" @@ -341,7 +341,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-dependencies.html" %} + {% include "../../examples/html/pyscript_setup_dependencies.html" %} ``` ??? question "How do I install additional Javascript dependencies?" @@ -351,13 +351,13 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-extra-js-object.html" %} + {% include "../../examples/html/pyscript_setup_extra_js_object.html" %} ``` === "views.py" ```python - {% include "../../examples/python/pyscript-setup-extra-js-object.py" %} + {% include "../../examples/python/pyscript_setup_extra_js_object.py" %} ``` The value for `#!python extra_js` is most commonly a Python dictionary, but JSON strings are also supported. @@ -365,7 +365,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-extra-js-string.html" %} + {% include "../../examples/html/pyscript_setup_extra_js_string.html" %} ``` ??? question "How do I modify the `pyscript` default configuration?" @@ -375,7 +375,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-config-string.html" %} + {% include "../../examples/html/pyscript_setup_config_string.html" %} ``` While this value is most commonly a JSON string, Python dictionary objects are also supported. @@ -383,13 +383,13 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-config-object.html" %} + {% include "../../examples/html/pyscript_setup_config_object.html" %} ``` === "views.py" ```python - {% include "../../examples/python/pyscript-setup-config-object.py" %} + {% include "../../examples/python/pyscript_setup_config_object.py" %} ``` ??? question "Can I use a local interpreter for PyScript?" @@ -403,5 +403,5 @@ You can optionally use this tag to configure the current PyScript environment. F 3. Configure your `#!jinja {% pyscript_setup %}` template tag to use `pyodide` as an interpreter. ```jinja linenums="0" - {% include "../../examples/html/pyscript-setup-local-interpreter.html" %} + {% include "../../examples/html/pyscript_setup_local_interpreter.html" %} ``` diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md index 917ba959..c5887d04 100644 --- a/docs/src/reference/utils.md +++ b/docs/src/reference/utils.md @@ -16,7 +16,7 @@ Utility functions provide various miscellaneous functionality for advanced use c This function is used register a Django view as a ReactPy `#!python iframe`. -It is mandatory to use this function alongside [`view_to_iframe`](../reference/components.md#view-to-iframe). +It is mandatory to use this function alongside [`view_to_iframe`](./components.md#view-to-iframe). === "apps.py" @@ -51,7 +51,7 @@ Typically, this function is automatically called on all components contained wit === "apps.py" ```python - {% include "../../examples/python/register-component.py" %} + {% include "../../examples/python/register_component.py" %} ``` ??? example "See Interface" @@ -76,7 +76,7 @@ Typically, this function is automatically called on all components contained wit For security reasons, ReactPy requires all root components to be registered. However, all components contained within Django templates are automatically registered. - This function is commonly needed when you have configured your [`host`](../reference/template-tag.md#component) to a dedicated Django rendering application that doesn't have templates. + This function is commonly needed when you have configured your [`host`](./template-tag.md#component) to a dedicated Django rendering application that doesn't have templates. --- @@ -89,13 +89,13 @@ Since ReactPy is rendered within an `#!python asyncio` loop, this postprocessor === "components.py" ```python - {% include "../../examples/python/django-query-postprocessor.py" %} + {% include "../../examples/python/django_query_postprocessor.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? example "See Interface" diff --git a/pyproject.toml b/pyproject.toml index 44f920a6..dbb94c21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,7 +178,6 @@ linkcheck = [ ] deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] deploy_develop = ["cd docs && mike deploy --push develop"] -check_examples = ["ruff check docs/examples/python"] ############################ # >>> Hatch JS Scripts <<< # @@ -210,6 +209,13 @@ lint.extend-ignore = [ "SLF001", # Private member accessed "E501", # Line too long "PLC0415", # `import` should be at the top-level of a file + "BLE001", # Do not catch blind exception: `Exception` + "PLW0603", # Using global statement is discouraged + "PLR6301", # Method could be a function, class method, or static method + "S403", # `dill` module is possibly insecure + "S301", # `dill` deserialization is possibly insecure unless using trusted data + "RUF029", # Function is declared async but doesn't contain await expression ] lint.preview = true -lint.isort.known-first-party = ["src", "tests"] +lint.isort.known-first-party = ["reactpy_django", "test_app", "example"] +lint.isort.known-third-party = ["js"] diff --git a/src/build_scripts/copy_dir.py b/src/build_scripts/copy_dir.py index 1f446f83..0a2cafab 100644 --- a/src/build_scripts/copy_dir.py +++ b/src/build_scripts/copy_dir.py @@ -1,3 +1,5 @@ +# ruff: noqa: INP001 +import logging import shutil import sys from pathlib import Path @@ -17,7 +19,7 @@ def copy_files(source: Path, destination: Path) -> None: if __name__ == "__main__": if len(sys.argv) != 3: - print("Usage: python copy_dir.py ") + logging.error("Script used incorrectly!\nUsage: python copy_dir.py ") sys.exit(1) root_dir = Path(__file__).parent.parent.parent @@ -25,7 +27,7 @@ def copy_files(source: Path, destination: Path) -> None: dest = Path(root_dir / sys.argv[2]) if not src.exists(): - print(f"Source directory {src} does not exist") + logging.error("Source directory %s does not exist", src) sys.exit(1) copy_files(src, dest) diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index f3fb1545..b34398d8 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -16,13 +16,13 @@ __version__ = "5.1.0" __all__ = [ "REACTPY_WEBSOCKET_ROUTE", - "html", - "hooks", "components", "decorators", + "hooks", + "html", + "router", "types", "utils", - "router", ] # Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops. diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 740df974..888cc47d 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -17,19 +17,16 @@ def reactpy_warnings(app_configs, **kwargs): from reactpy_django.config import REACTPY_FAILED_COMPONENTS warnings = [] - INSTALLED_APPS: list[str] = getattr(settings, "INSTALLED_APPS", []) + installed_apps: list[str] = getattr(settings, "INSTALLED_APPS", []) # Check if REACTPY_DATABASE is not an in-memory database. if ( - getattr(settings, "DATABASES", {}) - .get(getattr(settings, "REACTPY_DATABASE", "default"), {}) - .get("NAME", None) + getattr(settings, "DATABASES", {}).get(getattr(settings, "REACTPY_DATABASE", "default"), {}).get("NAME", None) == ":memory:" ): warnings.append( Warning( - "Using ReactPy with an in-memory database can cause unexpected " - "behaviors.", + "Using ReactPy with an in-memory database can cause unexpected behaviors.", hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a " "multiprocessing and thread safe database.", id="reactpy_django.W001", @@ -52,14 +49,12 @@ def reactpy_warnings(app_configs, **kwargs): ) # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne - if ( - sys.argv[0].endswith("daphne") - or ("runserver" in sys.argv and "daphne" in INSTALLED_APPS) - ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False): + if (sys.argv[0].endswith("daphne") or ("runserver" in sys.argv and "daphne" in installed_apps)) and getattr( + settings, "REACTPY_BACKHAUL_THREAD", False + ): warnings.append( Warning( - "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " - "and you running with Daphne.", + "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled and you running with Daphne.", hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different web server.", id="reactpy_django.W003", ) @@ -79,10 +74,8 @@ def reactpy_warnings(app_configs, **kwargs): if REACTPY_FAILED_COMPONENTS: warnings.append( Warning( - "ReactPy failed to register the following components:\n\t+ " - + "\n\t+ ".join(REACTPY_FAILED_COMPONENTS), - hint="Check if these paths are valid, or if an exception is being " - "raised during import.", + "ReactPy failed to register the following components:\n\t+ " + "\n\t+ ".join(REACTPY_FAILED_COMPONENTS), + hint="Check if these paths are valid, or if an exception is being raised during import.", id="reactpy_django.W005", ) ) @@ -106,10 +99,8 @@ def reactpy_warnings(app_configs, **kwargs): # Check if REACTPY_URL_PREFIX is being used properly in our HTTP URLs with contextlib.suppress(NoReverseMatch): - full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip( - "/" - ) - reactpy_http_prefix = f'{full_path[: full_path.find("web_module/")].strip("/")}' + full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip("/") + reactpy_http_prefix = f"{full_path[: full_path.find('web_module/')].strip('/')}" if reactpy_http_prefix != config.REACTPY_URL_PREFIX: warnings.append( Warning( @@ -138,9 +129,7 @@ def reactpy_warnings(app_configs, **kwargs): ) # Check if `daphne` is not in installed apps when using `runserver` - if "runserver" in sys.argv and "daphne" not in getattr( - settings, "INSTALLED_APPS", [] - ): + if "runserver" in sys.argv and "daphne" not in getattr(settings, "INSTALLED_APPS", []): warnings.append( Warning( "You have not configured the `runserver` command to use ASGI. " @@ -153,10 +142,7 @@ def reactpy_warnings(app_configs, **kwargs): # DELETED W013: Check if deprecated value REACTPY_RECONNECT_MAX exists # Check if REACTPY_RECONNECT_INTERVAL is set to a large value - if ( - isinstance(config.REACTPY_RECONNECT_INTERVAL, int) - and config.REACTPY_RECONNECT_INTERVAL > 30000 - ): + if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL > 30000: warnings.append( Warning( "REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? " @@ -167,10 +153,7 @@ def reactpy_warnings(app_configs, **kwargs): ) # Check if REACTPY_RECONNECT_MAX_RETRIES is set to a large value - if ( - isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) - and config.REACTPY_RECONNECT_MAX_RETRIES > 5000 - ): + if isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES > 5000: warnings.append( Warning( "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value " @@ -204,18 +187,12 @@ def reactpy_warnings(app_configs, **kwargs): and config.REACTPY_RECONNECT_MAX_INTERVAL > 0 and config.REACTPY_RECONNECT_MAX_RETRIES > 0 and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 1 - and ( - config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER - ** config.REACTPY_RECONNECT_MAX_RETRIES - ) + and (config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER**config.REACTPY_RECONNECT_MAX_RETRIES) * config.REACTPY_RECONNECT_INTERVAL < config.REACTPY_RECONNECT_MAX_INTERVAL ): max_value = math.floor( - ( - config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER - ** config.REACTPY_RECONNECT_MAX_RETRIES - ) + (config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER**config.REACTPY_RECONNECT_MAX_RETRIES) * config.REACTPY_RECONNECT_INTERVAL ) warnings.append( @@ -229,13 +206,10 @@ def reactpy_warnings(app_configs, **kwargs): # Check if 'reactpy_django' is in the correct position in INSTALLED_APPS position_to_beat = 0 - for app in INSTALLED_APPS: + for app in installed_apps: if app.startswith("django.contrib."): - position_to_beat = INSTALLED_APPS.index(app) - if ( - "reactpy_django" in INSTALLED_APPS - and INSTALLED_APPS.index("reactpy_django") < position_to_beat - ): + position_to_beat = installed_apps.index(app) + if "reactpy_django" in installed_apps and installed_apps.index("reactpy_django") < position_to_beat: warnings.append( Warning( "The position of 'reactpy_django' in INSTALLED_APPS is suspicious.", @@ -276,17 +250,13 @@ def reactpy_errors(app_configs, **kwargs): ) # DATABASE_ROUTERS is properly configured when REACTPY_DATABASE is defined - if getattr( - settings, "REACTPY_DATABASE", None - ) and "reactpy_django.database.Router" not in getattr( + if getattr(settings, "REACTPY_DATABASE", None) and "reactpy_django.database.Router" not in getattr( settings, "DATABASE_ROUTERS", [] ): errors.append( Error( - "ReactPy database has been changed but the database router is " - "not configured.", - hint="Set settings.py:DATABASE_ROUTERS to " - "['reactpy_django.database.Router', ...]", + "ReactPy database has been changed but the database router is not configured.", + hint="Set settings.py:DATABASE_ROUTERS to ['reactpy_django.database.Router', ...]", id="reactpy_django.E002", ) ) @@ -336,9 +306,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check if REACTPY_DEFAULT_QUERY_POSTPROCESSOR is a valid data type - if not isinstance( - getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None)) - ): + if not isinstance(getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None))): errors.append( Error( "Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.", @@ -397,10 +365,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check if REACTPY_RECONNECT_INTERVAL is a positive integer - if ( - isinstance(config.REACTPY_RECONNECT_INTERVAL, int) - and config.REACTPY_RECONNECT_INTERVAL < 0 - ): + if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL < 0: errors.append( Error( "Invalid value for REACTPY_RECONNECT_INTERVAL.", @@ -420,10 +385,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check if REACTPY_RECONNECT_MAX_INTERVAL is a positive integer - if ( - isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) - and config.REACTPY_RECONNECT_MAX_INTERVAL < 0 - ): + if isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) and config.REACTPY_RECONNECT_MAX_INTERVAL < 0: errors.append( Error( "Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.", @@ -457,10 +419,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check if REACTPY_RECONNECT_MAX_RETRIES is a positive integer - if ( - isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) - and config.REACTPY_RECONNECT_MAX_RETRIES < 0 - ): + if isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES < 0: errors.append( Error( "Invalid value for REACTPY_RECONNECT_MAX_RETRIES.", @@ -523,10 +482,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check if REACTPY_CLEAN_INTERVAL is a positive integer - if ( - isinstance(config.REACTPY_CLEAN_INTERVAL, int) - and config.REACTPY_CLEAN_INTERVAL < 0 - ): + if isinstance(config.REACTPY_CLEAN_INTERVAL, int) and config.REACTPY_CLEAN_INTERVAL < 0: errors.append( Error( "Invalid value for REACTPY_CLEAN_INTERVAL.", diff --git a/src/reactpy_django/clean.py b/src/reactpy_django/clean.py index 1ec327ee..0a7e9017 100644 --- a/src/reactpy_django/clean.py +++ b/src/reactpy_django/clean.py @@ -12,9 +12,7 @@ if TYPE_CHECKING: from reactpy_django.models import Config -CLEAN_NEEDED_BY: datetime = datetime( - year=1, month=1, day=1, tzinfo=timezone.now().tzinfo -) +CLEAN_NEEDED_BY: datetime = datetime(year=1, month=1, day=1, tzinfo=timezone.now().tzinfo) def clean( @@ -36,8 +34,8 @@ def clean( user_data = REACTPY_CLEAN_USER_DATA if args: - sessions = any(value in args for value in {"sessions", "all"}) - user_data = any(value in args for value in {"user_data", "all"}) + sessions = any(value in args for value in ("sessions", "all")) + user_data = any(value in args for value in ("user_data", "all")) if sessions: clean_sessions(verbosity) @@ -54,16 +52,14 @@ def clean_sessions(verbosity: int = 1): from reactpy_django.models import ComponentSession if verbosity >= 2: - print("Cleaning ReactPy component sessions...") + _logger.info("Cleaning ReactPy component sessions...") start_time = timezone.now() expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_MAX_AGE) - session_objects = ComponentSession.objects.filter( - last_accessed__lte=expiration_date - ) + session_objects = ComponentSession.objects.filter(last_accessed__lte=expiration_date) if verbosity >= 2: - print(f"Deleting {session_objects.count()} expired component sessions...") + _logger.info("Deleting %d expired component sessions...", session_objects.count()) session_objects.delete() @@ -83,7 +79,7 @@ def clean_user_data(verbosity: int = 1): from reactpy_django.models import UserDataModel if verbosity >= 2: - print("Cleaning ReactPy user data...") + _logger.info("Cleaning ReactPy user data...") start_time = timezone.now() user_model = get_user_model() @@ -92,14 +88,12 @@ def clean_user_data(verbosity: int = 1): # Django doesn't support using QuerySets as an argument with cross-database relations. if user_model.objects.db != UserDataModel.objects.db: - all_user_pks = list(all_user_pks) # type: ignore + all_user_pks = list(all_user_pks) user_data_objects = UserDataModel.objects.exclude(user_pk__in=all_user_pks) if verbosity >= 2: - print( - f"Deleting {user_data_objects.count()} user data objects not associated with an existing user..." - ) + _logger.info("Deleting %d user data objects not associated with an existing user...", user_data_objects.count()) user_data_objects.delete() @@ -129,9 +123,7 @@ def inspect_clean_duration(start_time: datetime, task_name: str, verbosity: int) clean_duration = timezone.now() - start_time if verbosity >= 3: - print( - f"Cleaned ReactPy {task_name} in {clean_duration.total_seconds()} seconds." - ) + _logger.info("Cleaned ReactPy %s in %s seconds.", task_name, clean_duration.total_seconds()) if clean_duration.total_seconds() > 1: _logger.warning( diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 98981730..d9ed0e6a 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -2,7 +2,7 @@ import json import os -from typing import Any, Callable, Sequence, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode from uuid import uuid4 @@ -10,7 +10,6 @@ from django.core.cache import caches from django.http import HttpRequest from django.urls import reverse -from django.views import View from reactpy import component, hooks, html, utils from reactpy.types import ComponentType, Key, VdomDict @@ -24,6 +23,11 @@ vdom_or_component_to_string, ) +if TYPE_CHECKING: + from collections.abc import Sequence + + from django.views import View + def view_to_component( view: Callable | View | str, @@ -62,9 +66,7 @@ def constructor( return constructor -def view_to_iframe( - view: Callable | View | str, extra_props: dict[str, Any] | None = None -): +def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None): """ Args: view: The view function or class to convert, or the dotted path to the view. @@ -81,9 +83,7 @@ def constructor( key: Key | None = None, **kwargs, ): - return _view_to_iframe( - view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key - ) + return _view_to_iframe(view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key) return constructor @@ -147,9 +147,7 @@ def _view_to_component( kwargs: dict | None, ): """The actual component. Used to prevent pollution of acceptable kwargs keys.""" - converted_view, set_converted_view = hooks.use_state( - cast(Union[VdomDict, None], None) - ) + converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None)) _args: Sequence = args or () _kwargs: dict = kwargs or {} if request: @@ -157,13 +155,13 @@ def _view_to_component( else: _request = HttpRequest() _request.method = "GET" - resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore[assignment] + resolved_view: Callable = import_module(view) if isinstance(view, str) else view # Render the view render within a hook @hooks.use_effect( dependencies=[ - json.dumps(vars(_request), default=lambda x: generate_obj_name(x)), - json.dumps([_args, _kwargs], default=lambda x: generate_obj_name(x)), + json.dumps(vars(_request), default=generate_obj_name), + json.dumps([_args, _kwargs], default=generate_obj_name), ] ) async def async_render(): @@ -199,10 +197,11 @@ def _view_to_iframe( registered_view = REACTPY_REGISTERED_IFRAME_VIEWS.get(dotted_path) if not registered_view: - raise ViewNotRegisteredError( + msg = ( f"'{dotted_path}' has not been registered as an iframe! " "Are you sure you called `register_iframe` within a Django `AppConfig.ready` method?" ) + raise ViewNotRegisteredError(msg) query = kwargs.copy() if args: @@ -237,23 +236,18 @@ def _cached_static_contents(static_path: str) -> str: # Try to find the file within Django's static files abs_path = find(static_path) if not abs_path: - raise FileNotFoundError( - f"Could not find static file {static_path} within Django's static files." - ) + msg = f"Could not find static file {static_path} within Django's static files." + raise FileNotFoundError(msg) # Fetch the file from cache, if available last_modified_time = os.stat(abs_path).st_mtime cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get( - cache_key, version=int(last_modified_time) - ) + file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) if file_contents is None: with open(abs_path, encoding="utf-8") as static_file: file_contents = static_file.read() caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set( - cache_key, file_contents, timeout=None, version=int(last_modified_time) - ) + caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) return file_contents diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 090980a5..3f46c48b 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -1,22 +1,25 @@ from __future__ import annotations from itertools import cycle -from typing import Callable +from typing import TYPE_CHECKING, Callable from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS -from django.views import View from reactpy.config import REACTPY_ASYNC_RENDERING as _REACTPY_ASYNC_RENDERING from reactpy.config import REACTPY_DEBUG_MODE as _REACTPY_DEBUG_MODE -from reactpy.core.types import ComponentConstructor -from reactpy_django.types import ( - AsyncPostprocessor, - SyncPostprocessor, -) from reactpy_django.utils import import_dotted_path +if TYPE_CHECKING: + from django.views import View + from reactpy.core.types import ComponentConstructor + + from reactpy_django.types import ( + AsyncPostprocessor, + SyncPostprocessor, + ) + # Non-configurable values REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {} REACTPY_FAILED_COMPONENTS: set[str] = set() @@ -25,9 +28,7 @@ # Configurable through Django settings.py DJANGO_DEBUG = settings.DEBUG # Snapshot of Django's DEBUG setting _REACTPY_DEBUG_MODE.set_current(settings.DEBUG) -_REACTPY_ASYNC_RENDERING.set_current( - getattr(settings, "REACTPY_ASYNC_RENDERING", _REACTPY_ASYNC_RENDERING.current) -) +_REACTPY_ASYNC_RENDERING.set_current(getattr(settings, "REACTPY_ASYNC_RENDERING", _REACTPY_ASYNC_RENDERING.current)) REACTPY_URL_PREFIX: str = getattr( settings, "REACTPY_URL_PREFIX", @@ -59,10 +60,7 @@ else: REACTPY_DEFAULT_QUERY_POSTPROCESSOR = import_dotted_path( "reactpy_django.utils.django_query_postprocessor" - if ( - _default_query_postprocessor == "UNSET" - or not isinstance(_default_query_postprocessor, str) - ) + if (_default_query_postprocessor == "UNSET" or not isinstance(_default_query_postprocessor, str)) else _default_query_postprocessor ) REACTPY_AUTH_BACKEND: str | None = getattr( @@ -81,9 +79,7 @@ None, ) REACTPY_DEFAULT_HOSTS: cycle[str] | None = ( - cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)]) - if _default_hosts - else None + cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)]) if _default_hosts else None ) REACTPY_RECONNECT_INTERVAL: int = getattr( settings, diff --git a/src/reactpy_django/database.py b/src/reactpy_django/database.py index 0d0b2065..2acd673e 100644 --- a/src/reactpy_django/database.py +++ b/src/reactpy_django/database.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from reactpy_django.config import REACTPY_DATABASE @@ -7,17 +9,19 @@ class Router: auth and contenttypes applications. """ - route_app_labels = {"reactpy_django"} + route_app_labels: ClassVar[set[str]] = {"reactpy_django"} def db_for_read(self, model, **hints): """Attempts to read go to REACTPY_DATABASE.""" if model._meta.app_label in self.route_app_labels: return REACTPY_DATABASE + return None def db_for_write(self, model, **hints): """Attempts to write go to REACTPY_DATABASE.""" if model._meta.app_label in self.route_app_labels: return REACTPY_DATABASE + return None def allow_relation(self, obj1, obj2, **hints): """Returning `None` only allow relations within the same database. @@ -27,5 +31,4 @@ def allow_relation(self, obj1, obj2, **hints): def allow_migrate(self, db, app_label, model_name=None, **hints): """Make sure ReactPy models only appear in REACTPY_DATABASE.""" - if app_label in self.route_app_labels: - return db == REACTPY_DATABASE + return db == REACTPY_DATABASE if app_label in self.route_app_labels else None diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index d5278f7d..804e10bb 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -4,13 +4,13 @@ from typing import TYPE_CHECKING, Any, Callable from reactpy import component -from reactpy.core.types import ComponentConstructor from reactpy_django.exceptions import DecoratorParamError from reactpy_django.hooks import use_user if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser + from reactpy.core.types import ComponentConstructor def user_passes_test( @@ -31,9 +31,7 @@ def user_passes_test( def decorator(user_component): @wraps(user_component) def _wrapper(*args, **kwargs): - return _user_passes_test( - user_component, fallback, test_func, *args, **kwargs - ) + return _user_passes_test(user_component, fallback, test_func, *args, **kwargs) return _wrapper @@ -49,10 +47,11 @@ def _user_passes_test(component_constructor, fallback, test_func, *args, **kwarg # Ensure that the component is a ReactPy component. user_component = component_constructor(*args, **kwargs) if not getattr(user_component, "render", None): - raise DecoratorParamError( + msg = ( "`user_passes_test` is not decorating a ReactPy component. " "Did you forget `@user_passes_test` must be ABOVE the `@component` decorator?" ) + raise DecoratorParamError(msg) # Render the component. return user_component diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 412d647f..c0d4b32d 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -1,34 +1,25 @@ -class ComponentParamError(TypeError): - ... +class ComponentParamError(TypeError): ... -class ComponentDoesNotExistError(AttributeError): - ... +class ComponentDoesNotExistError(AttributeError): ... -class OfflineComponentMissing(ComponentDoesNotExistError): - ... +class OfflineComponentMissingError(ComponentDoesNotExistError): ... -class InvalidHostError(ValueError): - ... +class InvalidHostError(ValueError): ... -class ComponentCarrierError(Exception): - ... +class ComponentCarrierError(Exception): ... -class UserNotFoundError(Exception): - ... +class UserNotFoundError(Exception): ... -class ViewNotRegisteredError(AttributeError): - ... +class ViewNotRegisteredError(AttributeError): ... -class ViewDoesNotExistError(AttributeError): - ... +class ViewDoesNotExistError(AttributeError): ... -class DecoratorParamError(TypeError): - ... +class DecoratorParamError(TypeError): ... diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index c226e1ca..9f76902f 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -2,13 +2,11 @@ import asyncio import logging +from collections import defaultdict from typing import ( TYPE_CHECKING, Any, - Awaitable, Callable, - DefaultDict, - Sequence, Union, cast, ) @@ -22,7 +20,6 @@ from reactpy import use_connection as _use_connection from reactpy import use_location as _use_location from reactpy import use_scope as _use_scope -from reactpy.backend.types import Location from reactpy_django.exceptions import UserNotFoundError from reactpy_django.types import ( @@ -40,14 +37,15 @@ from reactpy_django.utils import django_query_postprocessor, generate_obj_name, get_pk if TYPE_CHECKING: + from collections.abc import Awaitable, Sequence + from channels_redis.core import RedisChannelLayer from django.contrib.auth.models import AbstractUser + from reactpy.backend.types import Location _logger = logging.getLogger(__name__) -_REFETCH_CALLBACKS: DefaultDict[Callable[..., Any], set[Callable[[], None]]] = ( - DefaultDict(set) -) +_REFETCH_CALLBACKS: defaultdict[Callable[..., Any], set[Callable[[], None]]] = defaultdict(set) def use_location() -> Location: @@ -62,21 +60,11 @@ def use_origin() -> str | None: try: if scope["type"] == "websocket": return next( - ( - header[1].decode("utf-8") - for header in scope["headers"] - if header[0] == b"origin" - ), + (header[1].decode("utf-8") for header in scope["headers"] if header[0] == b"origin"), None, ) if scope["type"] == "http": - host = next( - ( - header[1].decode("utf-8") - for header in scope["headers"] - if header[0] == b"host" - ) - ) + host = next(header[1].decode("utf-8") for header in scope["headers"] if header[0] == b"host") return f"{scope['scheme']}://{host}" if host else None except Exception: _logger.info("Failed to get origin") @@ -90,7 +78,8 @@ def use_scope() -> dict[str, Any]: if isinstance(scope, dict): return scope - raise TypeError(f"Expected scope to be a dict, got {type(scope)}") + msg = f"Expected scope to be a dict, got {type(scope)}" + raise TypeError(msg) def use_connection() -> ConnectionType: @@ -103,9 +92,7 @@ def use_query( kwargs: dict[str, Any] | None = None, *, thread_sensitive: bool = True, - postprocessor: ( - AsyncPostprocessor | SyncPostprocessor | None - ) = django_query_postprocessor, + postprocessor: (AsyncPostprocessor | SyncPostprocessor | None) = django_query_postprocessor, postprocessor_kwargs: dict[str, Any] | None = None, ) -> Query[Inferred]: """This hook is used to execute functions in the background and return the result, \ @@ -139,31 +126,31 @@ def use_query( loading, set_loading = use_state(True) error, set_error = use_state(cast(Union[Exception, None], None)) query_ref = use_ref(query) + async_task_refs = use_ref(set()) kwargs = kwargs or {} postprocessor_kwargs = postprocessor_kwargs or {} if query_ref.current is not query: - raise ValueError(f"Query function changed from {query_ref.current} to {query}.") + msg = f"Query function changed from {query_ref.current} to {query}." + raise ValueError(msg) async def execute_query() -> None: """The main running function for `use_query`""" try: # Run the query if asyncio.iscoroutinefunction(query): - new_data = await query(**kwargs) # type: ignore[call-arg] + new_data = await query(**kwargs) else: - new_data = await database_sync_to_async( - query, thread_sensitive=thread_sensitive - )(**kwargs) + new_data = await database_sync_to_async(query, thread_sensitive=thread_sensitive)(**kwargs) # Run the postprocessor if postprocessor: if asyncio.iscoroutinefunction(postprocessor): new_data = await postprocessor(new_data, **postprocessor_kwargs) else: - new_data = await database_sync_to_async( - postprocessor, thread_sensitive=thread_sensitive - )(new_data, **postprocessor_kwargs) + new_data = await database_sync_to_async(postprocessor, thread_sensitive=thread_sensitive)( + new_data, **postprocessor_kwargs + ) # Log any errors and set the error state except Exception as e: @@ -181,14 +168,18 @@ async def execute_query() -> None: @use_effect(dependencies=None) def schedule_query() -> None: - """Schedule the query to be run when needed""" + """Schedule the query to be run""" # Make sure we don't re-execute the query unless we're told to if not should_execute: return set_should_execute(False) # Execute the query in the background - asyncio.create_task(execute_query()) + task = asyncio.create_task(execute_query()) + + # Add the task to a set to prevent it from being garbage collected + async_task_refs.current.add(task) + task.add_done_callback(async_task_refs.current.remove) @use_callback def refetch() -> None: @@ -210,9 +201,7 @@ def register_refetch_callback() -> Callable[[], None]: def use_mutation( - mutation: ( - Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]] - ), + mutation: (Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]]), *, thread_sensitive: bool = True, refetch: Callable[..., Any] | Sequence[Callable[..., Any]] | None = None, @@ -245,6 +234,7 @@ def use_mutation( loading, set_loading = use_state(False) error, set_error = use_state(cast(Union[Exception, None], None)) + async_task_refs = use_ref(set()) # The main "running" function for `use_mutation` async def execute_mutation(exec_args, exec_kwargs) -> None: @@ -253,17 +243,15 @@ async def execute_mutation(exec_args, exec_kwargs) -> None: if asyncio.iscoroutinefunction(mutation): should_refetch = await mutation(*exec_args, **exec_kwargs) else: - should_refetch = await database_sync_to_async( - mutation, thread_sensitive=thread_sensitive - )(*exec_args, **exec_kwargs) + should_refetch = await database_sync_to_async(mutation, thread_sensitive=thread_sensitive)( + *exec_args, **exec_kwargs + ) # Log any errors and set the error state except Exception as e: set_loading(False) set_error(e) - _logger.exception( - "Failed to execute mutation: %s", generate_obj_name(mutation) - ) + _logger.exception("Failed to execute mutation: %s", generate_obj_name(mutation)) # Mutation was successful else: @@ -279,18 +267,18 @@ async def execute_mutation(exec_args, exec_kwargs) -> None: # Schedule the mutation to be run when needed @use_callback - def schedule_mutation( - *exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs - ) -> None: + def schedule_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs) -> None: # Set the loading state. # It's okay to re-execute the mutation if we're told to. The user # can use the `loading` state to prevent this. set_loading(True) # Execute the mutation in the background - asyncio.ensure_future( - execute_mutation(exec_args=exec_args, exec_kwargs=exec_kwargs) - ) + task = asyncio.ensure_future(execute_mutation(exec_args=exec_args, exec_kwargs=exec_kwargs)) + + # Add the task to a set to prevent it from being garbage collected + async_task_refs.current.add(task) + task.add_done_callback(async_task_refs.current.remove) # Used when the user has told us to reset this mutation @use_callback @@ -307,14 +295,13 @@ def use_user() -> AbstractUser: connection = use_connection() user = connection.scope.get("user") or getattr(connection.carrier, "user", None) if user is None: - raise UserNotFoundError("No user is available in the current environment.") + msg = "No user is available in the current environment." + raise UserNotFoundError(msg) return user def use_user_data( - default_data: ( - None | dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any] - ) = None, + default_data: (None | dict[str, Callable[[], Any] | Callable[[], Awaitable[Any]] | Any]) = None, save_default_data: bool = False, ) -> UserData: """Get or set user data stored within the REACTPY_DATABASE. @@ -332,9 +319,11 @@ def use_user_data( async def _set_user_data(data: dict): if not isinstance(data, dict): - raise TypeError(f"Expected dict while setting user data, got {type(data)}") + msg = f"Expected dict while setting user data, got {type(data)}" + raise TypeError(msg) if user.is_anonymous: - raise ValueError("AnonymousUser cannot have user data.") + msg = "AnonymousUser cannot have user data." + raise ValueError(msg) pk = get_pk(user) model, _ = await UserDataModel.objects.aget_or_create(user_pk=pk) @@ -386,13 +375,15 @@ def use_channel_layer( channel_name = use_memo(lambda: str(name or uuid4())) if not name and not group_name: - raise ValueError("You must define a `name` or `group_name` for the channel.") + msg = "You must define a `name` or `group_name` for the channel." + raise ValueError(msg) if not channel_layer: - raise ValueError( + msg = ( f"Channel layer '{layer}' is not available. Are you sure you" " configured settings.py:CHANNEL_LAYERS properly?" ) + raise ValueError(msg) # Add/remove a group's channel during component mount/dismount respectively. @use_effect(dependencies=[]) @@ -401,9 +392,8 @@ async def group_manager(): await channel_layer.group_add(group_name, channel_name) if group_name and group_discard: - return lambda: asyncio.run( - channel_layer.group_discard(group_name, channel_name) - ) + return lambda: asyncio.run(channel_layer.group_discard(group_name, channel_name)) + return None # Listen for messages on the channel using the provided `receiver` function. @use_effect @@ -433,9 +423,7 @@ def use_root_id() -> str: return scope["reactpy"]["id"] -async def _get_user_data( - user: AbstractUser, default_data: None | dict, save_default_data: bool -) -> dict | None: +async def _get_user_data(user: AbstractUser, default_data: None | dict, save_default_data: bool) -> dict | None: """The mutation function for `use_user_data`""" from reactpy_django.models import UserDataModel @@ -447,7 +435,8 @@ async def _get_user_data( data = orjson.loads(model.data) if model.data else {} if not isinstance(data, dict): - raise TypeError(f"Expected dict while loading user data, got {type(data)}") + msg = f"Expected dict while loading user data, got {type(data)}" + raise TypeError(msg) # Set default values, if needed if default_data: diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py index def755e4..11f3ec31 100644 --- a/src/reactpy_django/http/urls.py +++ b/src/reactpy_django/http/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from . import views +from reactpy_django.http import views app_name = "reactpy" diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 780ccc17..25315479 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -1,45 +1,25 @@ -import asyncio import os from urllib.parse import parse_qs -from django.core.cache import caches from django.core.exceptions import SuspiciousOperation -from django.http import HttpRequest, HttpResponse, HttpResponseNotFound +from django.http import FileResponse, HttpRequest, HttpResponse, HttpResponseNotFound from reactpy.config import REACTPY_WEB_MODULES_DIR -from reactpy_django.utils import create_cache_key, render_view +from reactpy_django.utils import FileAsyncIterator, render_view -async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: - """Gets JavaScript required for ReactPy modules at runtime. These modules are - returned from cache if available.""" - from reactpy_django.config import REACTPY_CACHE +def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: + """Gets JavaScript required for ReactPy modules at runtime.""" web_modules_dir = REACTPY_WEB_MODULES_DIR.current path = os.path.abspath(web_modules_dir.joinpath(file)) # Prevent attempts to walk outside of the web modules dir if str(web_modules_dir) != os.path.commonpath((path, web_modules_dir)): - raise SuspiciousOperation( - "Attempt to access a directory outside of REACTPY_WEB_MODULES_DIR." - ) + msg = "Attempt to access a directory outside of REACTPY_WEB_MODULES_DIR." + raise SuspiciousOperation(msg) - # Fetch the file from cache, if available - last_modified_time = os.stat(path).st_mtime - cache_key = create_cache_key("web_modules", path) - file_contents = await caches[REACTPY_CACHE].aget( - cache_key, version=int(last_modified_time) - ) - if file_contents is None: - with open(path, "r", encoding="utf-8") as fp: - file_contents = await asyncio.to_thread(fp.read) - await caches[REACTPY_CACHE].adelete(cache_key) - await caches[REACTPY_CACHE].aset( - cache_key, file_contents, timeout=604800, version=int(last_modified_time) - ) - - # TODO: Convert this to a StreamingHttpResponse - return HttpResponse(file_contents, content_type="text/javascript") + return FileResponse(FileAsyncIterator(path), content_type="text/javascript") async def view_to_iframe(request: HttpRequest, dotted_path: str) -> HttpResponse: diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py index 0c5dc308..1b1fe9a2 100644 --- a/src/reactpy_django/management/commands/clean_reactpy.py +++ b/src/reactpy_django/management/commands/clean_reactpy.py @@ -1,7 +1,10 @@ +from logging import getLogger from typing import Literal from django.core.management.base import BaseCommand +_logger = getLogger(__name__) + class Command(BaseCommand): help = "Manually clean ReactPy data. When using this command without args, it will perform all cleaning operations." @@ -22,7 +25,7 @@ def handle(self, **options): clean(*cleaning_args, immediate=True, verbosity=verbosity) if verbosity >= 1: - print("ReactPy data has been cleaned!") + _logger.info("ReactPy data has been cleaned!") def add_arguments(self, parser): parser.add_argument( diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 5256fba6..15f07595 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -9,15 +9,15 @@ class ComponentSession(models.Model): """A model for storing component sessions.""" - uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore - params = models.BinaryField(editable=False) # type: ignore - last_accessed = models.DateTimeField(auto_now=True) # type: ignore + uuid = models.UUIDField(primary_key=True, editable=False, unique=True) + params = models.BinaryField(editable=False) + last_accessed = models.DateTimeField(auto_now=True) class Config(models.Model): """A singleton model for storing ReactPy configuration.""" - cleaned_at = models.DateTimeField(auto_now_add=True) # type: ignore + cleaned_at = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs): """Singleton save method.""" @@ -36,8 +36,8 @@ class UserDataModel(models.Model): # We can't store User as a ForeignKey/OneToOneField because it may not be in the same database # and Django does not allow cross-database relations. Also, since we can't know the type of the UserModel PK, # we store it as a string to normalize. - user_pk = models.CharField(max_length=255, unique=True) # type: ignore - data = models.BinaryField(null=True, blank=True) # type: ignore + user_pk = models.CharField(max_length=255, unique=True) + data = models.BinaryField(null=True, blank=True) @receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data") diff --git a/src/reactpy_django/pyscript/component_template.py b/src/reactpy_django/pyscript/component_template.py index 59442571..0dfb27b7 100644 --- a/src/reactpy_django/pyscript/component_template.py +++ b/src/reactpy_django/pyscript/component_template.py @@ -1,3 +1,4 @@ +# ruff: noqa: TCH004, N802, N816, RUF006 from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index f5a72fa9..77aa8c81 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -1,5 +1,11 @@ -# mypy: disable-error-code=attr-defined import asyncio +import logging + +import js +from jsonpointer import set_pointer +from pyodide.ffi.wrappers import add_event_listener +from pyscript.js_modules import morphdom +from reactpy.core.layout import Layout class ReactPyLayoutHandler: @@ -12,11 +18,11 @@ class ReactPyLayoutHandler: def __init__(self, uuid): self.uuid = uuid + self.running_tasks = set() @staticmethod def update_model(update, root_model): """Apply an update ReactPy's internal DOM model.""" - from jsonpointer import set_pointer if update["path"]: set_pointer(root_model, update["path"], update["model"]) @@ -25,9 +31,6 @@ def update_model(update, root_model): def render_html(self, layout, model): """Submit ReactPy's internal DOM model into the HTML DOM.""" - from pyscript.js_modules import morphdom - - import js # Create a new container to render the layout into container = js.document.getElementById(f"pyscript-{self.uuid}") @@ -42,8 +45,6 @@ def render_html(self, layout, model): def build_element_tree(self, layout, parent, model): """Recursively build an element tree, starting from the root component.""" - import js - if isinstance(model, str): parent.appendChild(js.document.createTextNode(model)) elif isinstance(model, dict): @@ -63,30 +64,26 @@ def build_element_tree(self, layout, parent, model): element.className = value else: element.setAttribute(key, value) - for event_name, event_handler_model in model.get( - "eventHandlers", {} - ).items(): - self.create_event_handler( - layout, element, event_name, event_handler_model - ) + for event_name, event_handler_model in model.get("eventHandlers", {}).items(): + self.create_event_handler(layout, element, event_name, event_handler_model) for child in children: self.build_element_tree(layout, element, child) parent.appendChild(element) else: - raise ValueError(f"Unknown model type: {type(model)}") + msg = f"Unknown model type: {type(model)}" + raise TypeError(msg) - @staticmethod - def create_event_handler(layout, element, event_name, event_handler_model): + def create_event_handler(self, layout, element, event_name, event_handler_model): """Create an event handler for an element. This function is used as an adapter between ReactPy and browser events.""" - from pyodide.ffi.wrappers import add_event_listener - target = event_handler_model["target"] def event_handler(*args): - asyncio.create_task( - layout.deliver({"type": "layout-event", "target": target, "data": args}) - ) + task = asyncio.create_task(layout.deliver({"type": "layout-event", "target": target, "data": args})) + + # Add the task to a set to prevent it from being garbage collected + self.running_tasks.add(task) + task.add_done_callback(self.running_tasks.remove) event_name = event_name.lstrip("on_").lower().replace("_", "") add_event_listener(element, event_name, event_handler) @@ -97,15 +94,9 @@ def delete_old_workspaces(): it is no longer in use (removed from the page). To do this, we compare what UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global interpreter.""" - import js - dom_workspaces = js.document.querySelectorAll(".pyscript") dom_uuids = {element.dataset.uuid for element in dom_workspaces} - python_uuids = { - value.split("_")[-1] - for value in globals() - if value.startswith("user_workspace_") - } + python_uuids = {value.split("_")[-1] for value in globals() if value.startswith("user_workspace_")} # Delete any workspaces that are not being used for uuid in python_uuids - dom_uuids: @@ -115,20 +106,16 @@ def delete_old_workspaces(): task.cancel() del globals()[task_name] else: - print(f"Warning: Could not auto delete PyScript task {task_name}") + logging.error("Could not auto delete PyScript task %s", task_name) workspace_name = f"user_workspace_{uuid}" if workspace_name in globals(): del globals()[workspace_name] else: - print( - f"Warning: Could not auto delete PyScript workspace {workspace_name}" - ) + logging.error("Could not auto delete PyScript workspace %s", workspace_name) async def run(self, workspace_function): """Run the layout handler. This function is main executor for all user generated code.""" - from reactpy.core.layout import Layout - self.delete_old_workspaces() root_model: dict = {} diff --git a/src/reactpy_django/router/converters.py b/src/reactpy_django/router/converters.py index 483dbcbb..0b54efbc 100644 --- a/src/reactpy_django/router/converters.py +++ b/src/reactpy_django/router/converters.py @@ -2,7 +2,6 @@ from reactpy_router.types import ConversionInfo CONVERTERS: dict[str, ConversionInfo] = { - name: {"regex": converter.regex, "func": converter.to_python} - for name, converter in get_converters().items() + name: {"regex": converter.regex, "func": converter.to_python} for name, converter in get_converters().items() } CONVERTERS["any"] = {"regex": r".*", "func": str} diff --git a/src/reactpy_django/router/resolvers.py b/src/reactpy_django/router/resolvers.py index 4568786c..30bb3f46 100644 --- a/src/reactpy_django/router/resolvers.py +++ b/src/reactpy_django/router/resolvers.py @@ -1,10 +1,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from reactpy_router.resolvers import StarletteResolver -from reactpy_router.types import ConversionInfo, Route from reactpy_django.router.converters import CONVERTERS +if TYPE_CHECKING: + from reactpy_router.types import ConversionInfo, Route + class DjangoResolver(StarletteResolver): """A simple route resolver that uses regex to match paths""" diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 1f419049..70b7fa5e 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,12 +1,11 @@ from __future__ import annotations from logging import getLogger +from typing import TYPE_CHECKING from uuid import uuid4 from django import template -from django.http import HttpRequest from django.urls import NoReverseMatch, reverse -from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict from reactpy_django import config as reactpy_config from reactpy_django.exceptions import ( @@ -14,7 +13,7 @@ ComponentDoesNotExistError, ComponentParamError, InvalidHostError, - OfflineComponentMissing, + OfflineComponentMissingError, ) from reactpy_django.utils import ( PYSCRIPT_LAYOUT_HANDLER, @@ -28,6 +27,10 @@ vdom_or_component_to_string, ) +if TYPE_CHECKING: + from django.http import HttpRequest + from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict + try: RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") except NoReverseMatch: @@ -78,14 +81,9 @@ def component( request: HttpRequest | None = context.get("request") perceived_host = (request.get_host() if request else "").strip("/") - host = ( - host - or ( - next(reactpy_config.REACTPY_DEFAULT_HOSTS) - if reactpy_config.REACTPY_DEFAULT_HOSTS - else "" - ) - ).strip("/") + host = (host or (next(reactpy_config.REACTPY_DEFAULT_HOSTS) if reactpy_config.REACTPY_DEFAULT_HOSTS else "")).strip( + "/" + ) is_local = not host or host.startswith(perceived_host) uuid = str(uuid4()) class_ = kwargs.pop("class", "") @@ -114,7 +112,10 @@ def component( try: validate_component_args(user_component, *args, **kwargs) except ComponentParamError as e: - _logger.error(str(e)) + _logger.exception( + "The parameters you provided for component '%s' was incorrect.", + dotted_path, + ) return failure_context(dotted_path, e) # Store args & kwargs in the database (fetched by our websocket later) @@ -123,7 +124,7 @@ def component( save_component_params(args, kwargs, uuid) except Exception as e: _logger.exception( - "An unknown error has occurred while saving component params for '%s'.", + "An unknown error has occurred while saving component parameters for '%s'.", dotted_path, ) return failure_context(dotted_path, e) @@ -145,9 +146,7 @@ def component( ) _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) - _prerender_html = prerender_component( - user_component, args, kwargs, uuid, request - ) + _prerender_html = prerender_component(user_component, args, kwargs, uuid, request) # Fetch the offline component's HTML, if requested if offline: @@ -155,7 +154,7 @@ def component( if not offline_component: msg = f"Cannot render offline component '{offline}'. It is not registered as a component." _logger.error(msg) - return failure_context(dotted_path, OfflineComponentMissing(msg)) + return failure_context(dotted_path, OfflineComponentMissingError(msg)) if not request: msg = ( "Cannot render an offline component without a HTTP request. Are you missing the " @@ -201,9 +200,8 @@ def pyscript_component( root: The name of the root component function. """ if not file_paths: - raise ValueError( - "At least one file path must be provided to the 'pyscript_component' tag." - ) + msg = "At least one file path must be provided to the 'pyscript_component' tag." + raise ValueError(msg) uuid = uuid4().hex request: HttpRequest | None = context.get("request") diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 91ffc319..75aa1d64 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -6,10 +6,8 @@ Any, Callable, Generic, - MutableMapping, NamedTuple, Protocol, - Sequence, TypeVar, Union, ) @@ -19,6 +17,8 @@ from typing_extensions import ParamSpec if TYPE_CHECKING: + from collections.abc import MutableMapping, Sequence + from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index b86cabdc..6d8b150d 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -8,11 +8,12 @@ import re import textwrap from asyncio import iscoroutinefunction +from concurrent.futures import ThreadPoolExecutor from copy import deepcopy from fnmatch import fnmatch from importlib import import_module from pathlib import Path -from typing import Any, Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Callable from uuid import UUID, uuid4 import dill @@ -28,12 +29,10 @@ from django.template import engines from django.templatetags.static import static from django.utils.encoding import smart_str -from django.views import View from reactpy import vdom_to_html from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection, Location from reactpy.core.layout import Layout -from reactpy.types import ComponentConstructor from reactpy_django.exceptions import ( ComponentDoesNotExistError, @@ -42,12 +41,16 @@ ViewDoesNotExistError, ) +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from django.views import View + from reactpy.types import ComponentConstructor + _logger = logging.getLogger(__name__) _TAG_PATTERN = r"(?Pcomponent)" _PATH_PATTERN = r"""(?P"[^"'\s]+"|'[^"'\s]+')""" -_OFFLINE_KWARG_PATTERN = ( - rf"""(\s*offline\s*=\s*{_PATH_PATTERN.replace(r"", r"")})""" -) +_OFFLINE_KWARG_PATTERN = rf"""(\s*offline\s*=\s*{_PATH_PATTERN.replace(r"", r"")})""" _GENERIC_KWARG_PATTERN = r"""(\s*.*?)""" COMMENT_REGEX = re.compile(r"") COMPONENT_REGEX = re.compile( @@ -58,13 +61,10 @@ + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + r"\s*%}" ) -PYSCRIPT_COMPONENT_TEMPLATE = ( - Path(__file__).parent / "pyscript" / "component_template.py" -).read_text(encoding="utf-8") -PYSCRIPT_LAYOUT_HANDLER = ( - Path(__file__).parent / "pyscript" / "layout_handler.py" -).read_text(encoding="utf-8") +PYSCRIPT_COMPONENT_TEMPLATE = (Path(__file__).parent / "pyscript" / "component_template.py").read_text(encoding="utf-8") +PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "pyscript" / "layout_handler.py").read_text(encoding="utf-8") PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {} +FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator") async def render_view( @@ -76,7 +76,7 @@ async def render_view( """Ingests a Django view (class or function) and returns an HTTP response object.""" # Convert class-based view to function-based view if getattr(view, "as_view", None): - view = view.as_view() # type: ignore[union-attr] + view = view.as_view() # Async function view if iscoroutinefunction(view): @@ -105,16 +105,13 @@ def register_component(component: ComponentConstructor | str): REACTPY_REGISTERED_COMPONENTS, ) - dotted_path = ( - component if isinstance(component, str) else generate_obj_name(component) - ) + dotted_path = component if isinstance(component, str) else generate_obj_name(component) try: REACTPY_REGISTERED_COMPONENTS[dotted_path] = import_dotted_path(dotted_path) except AttributeError as e: REACTPY_FAILED_COMPONENTS.add(dotted_path) - raise ComponentDoesNotExistError( - f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." - ) from e + msg = f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." + raise ComponentDoesNotExistError(msg) from e def register_iframe(view: Callable | View | str): @@ -131,9 +128,8 @@ def register_iframe(view: Callable | View | str): try: REACTPY_REGISTERED_IFRAME_VIEWS[dotted_path] = import_dotted_path(dotted_path) except AttributeError as e: - raise ViewDoesNotExistError( - f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." - ) from e + msg = f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." + raise ViewDoesNotExistError(msg) from e def import_dotted_path(dotted_path: str) -> Callable: @@ -143,9 +139,8 @@ def import_dotted_path(dotted_path: str) -> Callable: try: module = import_module(module_name) except ImportError as error: - raise RuntimeError( - f"Failed to import {module_name!r} while loading {component_name!r}" - ) from error + msg = f"Failed to import {module_name!r} while loading {component_name!r}" + raise RuntimeError(msg) from error return getattr(module, component_name) @@ -171,9 +166,7 @@ def get_loaders(self): template_source_loaders = [] for e in engines.all(): if hasattr(e, "engine"): - template_source_loaders.extend( - e.engine.get_template_loaders(e.engine.loaders) - ) + template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) loaders = [] for loader in template_source_loaders: if hasattr(loader, "loaders"): @@ -203,8 +196,7 @@ def get_templates(self, paths: set[str]) -> set[str]: templates.update( os.path.join(root, name) for name in files - if not name.startswith(".") - and any(fnmatch(name, f"*{glob}") for glob in extensions) + if not name.startswith(".") and any(fnmatch(name, f"*{glob}") for glob in extensions) ) return templates @@ -213,21 +205,16 @@ def get_components(self, templates: set[str]) -> set[str]: """Obtains a set of all ReactPy components by parsing HTML templates.""" components: set[str] = set() for template in templates: - with contextlib.suppress(Exception): - with open(template, "r", encoding="utf-8") as template_file: - clean_template = COMMENT_REGEX.sub("", template_file.read()) - regex_iterable = COMPONENT_REGEX.finditer(clean_template) - new_components: list[str] = [] - for match in regex_iterable: - new_components.append( - match.group("path").replace('"', "").replace("'", "") - ) - offline_path = match.group("offline_path") - if offline_path: - new_components.append( - offline_path.replace('"', "").replace("'", "") - ) - components.update(new_components) + with contextlib.suppress(Exception), open(template, encoding="utf-8") as template_file: + clean_template = COMMENT_REGEX.sub("", template_file.read()) + regex_iterable = COMPONENT_REGEX.finditer(clean_template) + new_components: list[str] = [] + for match in regex_iterable: + new_components.append(match.group("path").replace('"', "").replace("'", "")) + offline_path = match.group("offline_path") + if offline_path: + new_components.append(offline_path.replace('"', "").replace("'", "")) + components.update(new_components) if not components: _logger.warning( "\033[93m" @@ -312,7 +299,7 @@ def django_query_postprocessor( # Force the query to execute getattr(data, field.name, None) - if many_to_one and type(field) == ManyToOneRel: # noqa: E721 + if many_to_one and type(field) == ManyToOneRel: prefetch_fields.append(field.related_name or f"{field.name}_set") elif many_to_many and isinstance(field, ManyToManyField): @@ -329,13 +316,14 @@ def django_query_postprocessor( # Unrecognized type else: - raise TypeError( + msg = ( f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n" "One of the following may have occurred:\n" " - You are using a non-Django ORM.\n" " - You are attempting to use `use_query` to fetch non-ORM data.\n\n" "If these situations apply, you may want to disable the postprocessor." ) + raise TypeError(msg) return data @@ -352,9 +340,8 @@ def validate_component_args(func, *args, **kwargs): signature.bind(*args, **kwargs) except TypeError as e: name = generate_obj_name(func) - raise ComponentParamError( - f"Invalid args for '{name}'. {str(e).capitalize()}." - ) from e + msg = f"Invalid args for '{name}'. {str(e).capitalize()}." + raise ComponentParamError(msg) from e def create_cache_key(*args): @@ -362,7 +349,8 @@ def create_cache_key(*args): all *args separated by `:`.""" if not args: - raise ValueError("At least one argument is required to create a cache key.") + msg = "At least one argument is required to create a cache key." + raise ValueError(msg) return f"reactpy_django:{':'.join(str(arg) for arg in args)}" @@ -388,7 +376,7 @@ def get_pk(model): return getattr(model, model._meta.pk.name) -def strtobool(val): +def strtobool(val: str) -> bool: """Convert a string representation of truth to true (1) or false (0). True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values @@ -396,12 +384,12 @@ def strtobool(val): 'val' is anything else. """ val = val.lower() - if val in ("y", "yes", "t", "true", "on", "1"): - return 1 - elif val in ("n", "no", "f", "false", "off", "0"): - return 0 - else: - raise ValueError(f"invalid truth value {val}") + if val in {"y", "yes", "t", "true", "on", "1"}: + return True + if val in {"n", "no", "f", "false", "off", "0"}: + return False + msg = f"invalid truth value {val}" + raise ValueError(msg) def prerender_component( @@ -421,9 +409,7 @@ def prerender_component( user_component(*args, **kwargs), value=Connection( scope=scope, - location=Location( - pathname=request.path, search=f"?{search}" if search else "" - ), + location=Location(pathname=request.path, search=f"?{search}" if search else ""), carrier=request, ), ) @@ -439,7 +425,7 @@ def vdom_or_component_to_string( """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be automatically returned.""" if isinstance(vdom_or_component, dict): - return vdom_to_html(vdom_or_component) # type: ignore + return vdom_to_html(vdom_or_component) if hasattr(vdom_or_component, "render"): if not request: @@ -452,10 +438,8 @@ def vdom_or_component_to_string( if isinstance(vdom_or_component, str): return vdom_or_component - raise ValueError( - f"Invalid type for vdom_or_component: {type(vdom_or_component)}. " - "Expected a VdomDict, component, or string." - ) + msg = f"Invalid type for vdom_or_component: {type(vdom_or_component)}. Expected a VdomDict, component, or string." + raise ValueError(msg) def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): @@ -474,9 +458,7 @@ def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): # Try to get user code from cache cache_key = create_cache_key("pyscript", file_path) last_modified_time = os.stat(file_path).st_mtime - file_contents: str = caches[REACTPY_CACHE].get( - cache_key, version=int(last_modified_time) - ) + file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) if file_contents: all_file_contents.append(file_contents) @@ -484,9 +466,7 @@ def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): else: file_contents = Path(file_path).read_text(encoding="utf-8").strip() all_file_contents.append(file_contents) - caches[REACTPY_CACHE].set( - cache_key, file_contents, version=int(last_modified_time) - ) + caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) # Prepare the PyScript code block user_code = "\n".join(all_file_contents) # Combine all user code @@ -497,26 +477,18 @@ def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): return executor.replace(" def root(): ...", user_code) -def extend_pyscript_config( - extra_py: Sequence, extra_js: dict | str, config: dict | str -) -> str: +def extend_pyscript_config(extra_py: Sequence, extra_js: dict | str, config: dict | str) -> str: """Extends ReactPy's default PyScript config with user provided values.""" # Lazily set up the initial config in to wait for Django's static file system if not PYSCRIPT_DEFAULT_CONFIG: - PYSCRIPT_DEFAULT_CONFIG.update( - { - "packages": [ - f"reactpy=={reactpy.__version__}", - f"jsonpointer=={jsonpointer.__version__}", - "ssl", - ], - "js_modules": { - "main": { - static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom" - } - }, - } - ) + PYSCRIPT_DEFAULT_CONFIG.update({ + "packages": [ + f"reactpy=={reactpy.__version__}", + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + ], + "js_modules": {"main": {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}}, + }) # Extend the Python dependency list pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) @@ -553,8 +525,27 @@ def validate_host(host: str) -> None: """Validates the host string to ensure it does not contain a protocol.""" if "://" in host: protocol = host.split("://")[0] - msg = ( - f"Invalid host provided to component. Contains a protocol '{protocol}://'." - ) + msg = f"Invalid host provided to component. Contains a protocol '{protocol}://'." _logger.error(msg) raise InvalidHostError(msg) + + +class FileAsyncIterator: + """Async iterator that yields chunks of data from the provided async file.""" + + def __init__(self, file_path: str): + self.file_path = file_path + + async def __aiter__(self): + file_opened = False + try: + file_handle = FILE_ASYNC_ITERATOR_THREAD.submit(open, self.file_path, "rb").result() + file_opened = True + while True: + chunk = FILE_ASYNC_ITERATOR_THREAD.submit(file_handle.read, 8192).result() + if not chunk: + break + yield chunk + finally: + if file_opened: + file_handle.close() diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 345f399e..d877679b 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -6,13 +6,12 @@ import contextlib import logging import traceback -from concurrent.futures import Future from datetime import timedelta from threading import Thread -from typing import TYPE_CHECKING, Any, MutableMapping, Sequence +from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs -import dill as pickle +import dill import orjson from channels.auth import login from channels.db import database_sync_to_async @@ -24,12 +23,15 @@ from reactpy.core.serve import serve_layout from reactpy_django.clean import clean -from reactpy_django.types import ComponentParams if TYPE_CHECKING: + from collections.abc import MutableMapping, Sequence + from concurrent.futures import Future + from django.contrib.auth.models import AbstractUser from reactpy_django import models + from reactpy_django.types import ComponentParams _logger = logging.getLogger(__name__) BACKHAUL_LOOP = asyncio.new_event_loop() @@ -41,9 +43,7 @@ def start_backhaul_loop(): BACKHAUL_LOOP.run_forever() -BACKHAUL_THREAD = Thread( - target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul" -) +BACKHAUL_THREAD = Thread(target=start_backhaul_loop, daemon=True, name="ReactPyBackhaul") class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): @@ -57,7 +57,7 @@ def __init__(self, *args, **kwargs): self.threaded: bool self.recv_queue: asyncio.Queue self.dotted_path: str - self.component_session: "models.ComponentSession" | None = None + self.component_session: models.ComponentSession | None = None async def connect(self) -> None: """The browser has connected.""" @@ -77,29 +77,23 @@ async def connect(self) -> None: except Exception: await asyncio.to_thread( _logger.error, - "ReactPy websocket authentication has failed!\n" - f"{traceback.format_exc()}", + f"ReactPy websocket authentication has failed!\n{traceback.format_exc()}", ) try: await database_sync_to_async(self.scope["session"].save)() except Exception: await asyncio.to_thread( _logger.error, - "ReactPy has failed to save scope['session']!\n" - f"{traceback.format_exc()}", + f"ReactPy has failed to save scope['session']!\n{traceback.format_exc()}", ) # Start the component dispatcher self.threaded = REACTPY_BACKHAUL_THREAD if self.threaded: if not BACKHAUL_THREAD.is_alive(): - await asyncio.to_thread( - _logger.debug, "Starting ReactPy backhaul thread." - ) + await asyncio.to_thread(_logger.debug, "Starting ReactPy backhaul thread.") BACKHAUL_THREAD.start() - self.dispatcher = asyncio.run_coroutine_threadsafe( - self.run_dispatcher(), BACKHAUL_LOOP - ) + self.dispatcher = asyncio.run_coroutine_threadsafe(self.run_dispatcher(), BACKHAUL_LOOP) else: self.dispatcher = asyncio.create_task(self.run_dispatcher()) @@ -116,8 +110,7 @@ async def disconnect(self, code: int) -> None: except Exception: await asyncio.to_thread( _logger.error, - "ReactPy has failed to save component session!\n" - f"{traceback.format_exc()}", + f"ReactPy has failed to save component session!\n{traceback.format_exc()}", ) # Queue a cleanup, if needed @@ -127,7 +120,7 @@ async def disconnect(self, code: int) -> None: except Exception: await asyncio.to_thread( _logger.error, - "ReactPy cleaning failed!\n" f"{traceback.format_exc()}", + f"ReactPy cleaning failed!\n{traceback.format_exc()}", ) await super().disconnect(code) @@ -135,9 +128,7 @@ async def disconnect(self, code: int) -> None: async def receive_json(self, content: Any, **_) -> None: """Receive a message from the browser. Typically, messages are event signals.""" if self.threaded: - asyncio.run_coroutine_threadsafe( - self.recv_queue.put(content), BACKHAUL_LOOP - ) + asyncio.run_coroutine_threadsafe(self.recv_queue.put(content), BACKHAUL_LOOP) else: await self.recv_queue.put(content) @@ -192,14 +183,12 @@ async def run_dispatcher(self): uuid=uuid, last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE), ) - params: ComponentParams = pickle.loads(self.component_session.params) + params: ComponentParams = dill.loads(self.component_session.params) component_session_args = params.args component_session_kwargs = params.kwargs # Generate the initial component instance - root_component = root_component_constructor( - *component_session_args, **component_session_kwargs - ) + root_component = root_component_constructor(*component_session_args, **component_session_kwargs) except models.ComponentSession.DoesNotExist: await asyncio.to_thread( _logger.warning, diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index 435f9b71..258c58f2 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -1,8 +1,7 @@ from django.urls import path from reactpy_django.config import REACTPY_URL_PREFIX - -from .consumer import ReactpyAsyncWebsocketConsumer +from reactpy_django.websocket.consumer import ReactpyAsyncWebsocketConsumer REACTPY_WEBSOCKET_ROUTE = path( f"{REACTPY_URL_PREFIX}////", diff --git a/tests/manage.py b/tests/manage.py index 700db7bd..0f1d3262 100644 --- a/tests/manage.py +++ b/tests/manage.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python +# ruff: noqa: INP001 """Django's command-line utility for administrative tasks.""" + import os import sys @@ -11,11 +12,12 @@ def main(): try: from django.core.management import execute_from_command_line except ImportError as exc: - raise ImportError( + msg = ( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" - ) from exc + ) + raise ImportError(msg) from exc execute_from_command_line(sys.argv) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 27d5e41d..06cebfe6 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -4,13 +4,7 @@ # Make sure the JS is always re-built before running the tests js_dir = Path(__file__).parent.parent.parent / "src" / "js" -static_dir = ( - Path(__file__).parent.parent.parent - / "src" - / "reactpy_django" - / "static" - / "reactpy_django" -) +static_dir = Path(__file__).parent.parent.parent / "src" / "reactpy_django" / "static" / "reactpy_django" assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0 assert ( subprocess.run( @@ -38,22 +32,12 @@ def copy_js_files(source_dir: Path, destination: Path) -> None: # Copy PyScript copy_js_files( js_dir / "node_modules" / "@pyscript" / "core" / "dist", - Path(__file__).parent.parent.parent - / "src" - / "reactpy_django" - / "static" - / "reactpy_django" - / "pyscript", + Path(__file__).parent.parent.parent / "src" / "reactpy_django" / "static" / "reactpy_django" / "pyscript", ) # Copy MorphDOM copy_js_files( js_dir / "node_modules" / "morphdom" / "dist", - Path(__file__).parent.parent.parent - / "src" - / "reactpy_django" - / "static" - / "reactpy_django" - / "morphdom", + Path(__file__).parent.parent.parent / "src" / "reactpy_django" / "static" / "reactpy_django" / "morphdom", ) diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py index baf3ebdb..d462bd34 100644 --- a/tests/test_app/admin.py +++ b/tests/test_app/admin.py @@ -1,6 +1,7 @@ +# ruff: noqa: RUF012 from django.contrib import admin -from reactpy_django.models import ComponentSession, Config, UserDataModel +from reactpy_django.models import ComponentSession, Config, UserDataModel from test_app.models import ( AsyncForiegnChild, AsyncRelationalChild, diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index c49de99d..99397bba 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -16,11 +16,10 @@ from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 + from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 -application = ProtocolTypeRouter( - { - "http": http_asgi_app, - "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), - } -) +application = ProtocolTypeRouter({ + "http": http_asgi_app, + "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), +}) diff --git a/tests/test_app/channel_layers/components.py b/tests/test_app/channel_layers/components.py index 4f40a248..e6b427c3 100644 --- a/tests/test_app/channel_layers/components.py +++ b/tests/test_app/channel_layers/components.py @@ -1,4 +1,5 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer @@ -34,7 +35,7 @@ async def submit_event(event): @component -def group_receiver(id: int): +def group_receiver(id_number: int): state, set_state = hooks.use_state("None") async def receiver(message): @@ -43,8 +44,8 @@ async def receiver(message): use_channel_layer(receiver=receiver, group_name="group-messenger") return html.div( - {"id": f"group-receiver-{id}", "data-message": state}, - f"Group Message Receiver #{id}: {state}", + {"id": f"group-receiver-{id_number}", "data-message": state}, + f"Group Message Receiver #{id_number}: {state}", ) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 4ae0544e..ad13ac30 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -37,12 +37,10 @@ def button(): html.div( "button:", html.button( - {"id": "counter-inc", "on_click": lambda event: set_count(count + 1)}, + {"id": "counter-inc", "on_click": lambda _: set_count(count + 1)}, "Click me!", ), - html.p( - {"id": "counter-num", "data-count": count}, f"Current count is: {count}" - ), + html.p({"id": "counter-num", "data-count": count}, f"Current count is: {count}"), ) ) @@ -61,12 +59,8 @@ def parameterized_component(x, y): @component def object_in_templatetag(my_object: TestObject): success = bool(my_object and my_object.value) - co_name = inspect.currentframe().f_code.co_name # type: ignore - return html._( - html.div( - {"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object) - ) - ) + co_name = inspect.currentframe().f_code.co_name + return html._(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object))) SimpleButtonModule = web.module_from_file( @@ -80,9 +74,7 @@ def object_in_templatetag(my_object: TestObject): @component def button_from_js_module(): - return html._( - "button_from_js_module:", SimpleButton({"id": "button-from-js-module"}) - ) + return html._("button_from_js_module:", SimpleButton({"id": "button-from-js-module"})) @component @@ -95,9 +87,7 @@ def use_connection(): and getattr(ws.carrier, "disconnect", None) and getattr(ws.carrier, "dotted_path", None) ) - return html.div( - {"id": "use-connection", "data-success": success}, f"use_connection: {ws}" - ) + return html.div({"id": "use-connection", "data-success": success}, f"use_connection: {ws}") @component @@ -111,18 +101,14 @@ def use_scope(): def use_location(): location = reactpy_django.hooks.use_location() success = bool(location) - return html.div( - {"id": "use-location", "data-success": success}, f"use_location: {location}" - ) + return html.div({"id": "use-location", "data-success": success}, f"use_location: {location}") @component def use_origin(): origin = reactpy_django.hooks.use_origin() success = bool(origin) - return html.div( - {"id": "use-origin", "data-success": success}, f"use_origin: {origin}" - ) + return html.div({"id": "use-origin", "data-success": success}, f"use_origin: {origin}") @component @@ -158,16 +144,14 @@ def authorized_user(): @reactpy_django.decorators.user_passes_test( lambda user: user.is_active, - fallback=html.div( - {"id": "unauthorized-user-fallback"}, "unauthorized_user: Success" - ), + fallback=html.div({"id": "unauthorized-user-fallback"}, "unauthorized_user: Success"), ) @component def unauthorized_user(): return html.div({"id": "unauthorized-user"}, "unauthorized_user: Fail") -@reactpy_django.decorators.user_passes_test(lambda user: True) +@reactpy_django.decorators.user_passes_test(lambda _: True) def incorrect_user_passes_test_decorator(): return html.div("incorrect_decorator_test: Fail") @@ -190,9 +174,7 @@ def get_relational_parent_query(): def get_foriegn_child_query(): child = ForiegnChild.objects.first() if not child: - child = ForiegnChild.objects.create( - parent=get_relational_parent_query(), text="Foriegn Child" - ) + child = ForiegnChild.objects.create(parent=get_relational_parent_query(), text="Foriegn Child") child.save() return child @@ -215,7 +197,7 @@ def relational_query(): "id": "relational-query", "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk), }, - html.p(inspect.currentframe().f_code.co_name), # type: ignore + html.p(inspect.currentframe().f_code.co_name), html.div(f"Relational Parent Many To Many: {mtm}"), html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), @@ -249,9 +231,7 @@ async def async_get_foriegn_child_query(): child = await AsyncForiegnChild.objects.afirst() if not child: parent = await async_get_or_create_relational_parent() - child = await AsyncForiegnChild.objects.acreate( - parent=parent, text="Foriegn Child" - ) + child = await AsyncForiegnChild.objects.acreate(parent=parent, text="Foriegn Child") await child.asave() return child @@ -259,9 +239,7 @@ async def async_get_foriegn_child_query(): @component def async_relational_query(): foriegn_child = reactpy_django.hooks.use_query(async_get_foriegn_child_query) - relational_parent = reactpy_django.hooks.use_query( - async_get_relational_parent_query - ) + relational_parent = reactpy_django.hooks.use_query(async_get_relational_parent_query) if not relational_parent.data or not foriegn_child.data: return @@ -276,7 +254,7 @@ def async_relational_query(): "id": "async-relational-query", "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk), }, - html.p(inspect.currentframe().f_code.co_name), # type: ignore + html.p(inspect.currentframe().f_code.co_name), html.div(f"Relational Parent Many To Many: {mtm}"), html.div(f"Relational Parent One To One: {oto}"), html.div(f"Relational Parent Many to One: {mto}"), @@ -294,10 +272,10 @@ def add_todo_mutation(text: str): if existing.done: existing.done = False existing.save() - else: - return False - else: - TodoItem(text=text, done=False).save() + return None + return False + TodoItem(text=text, done=False).save() + return None def toggle_todo_mutation(item: TodoItem): @@ -306,23 +284,19 @@ def toggle_todo_mutation(item: TodoItem): def _render_todo_items(items, toggle_item): - return html.ul( - [ - html.li( - {"id": f"todo-item-{item.text}", "key": item.text}, - item.text, - html.input( - { - "id": f"todo-item-{item.text}-checkbox", - "type": "checkbox", - "checked": item.done, - "on_change": lambda event, i=item: toggle_item(i), - } - ), - ) - for item in items - ] - ) + return html.ul([ + html.li( + {"id": f"todo-item-{item.text}", "key": item.text}, + item.text, + html.input({ + "id": f"todo-item-{item.text}-checkbox", + "type": "checkbox", + "checked": item.done, + "on_change": lambda _, i=item: toggle_item(i), + }), + ) + for item in items + ]) @component @@ -330,9 +304,7 @@ def todo_list(): input_value, set_input_value = hooks.use_state("") items = reactpy_django.hooks.use_query(get_todo_query) toggle_item = reactpy_django.hooks.use_mutation(toggle_todo_mutation) - add_item = reactpy_django.hooks.use_mutation( - add_todo_mutation, refetch=get_todo_query - ) + add_item = reactpy_django.hooks.use_mutation(add_todo_mutation, refetch=get_todo_query) def on_submit(event): if event["key"] == "Enter": @@ -359,21 +331,19 @@ def on_change(event): elif add_item.error: mutation_status = html.h2(f"Error when adding - {add_item.error}") else: - mutation_status = "" # type: ignore + mutation_status = "" return html.div( {"id": "todo-list"}, - html.p(inspect.currentframe().f_code.co_name), # type: ignore + html.p(inspect.currentframe().f_code.co_name), html.label("Add an item:"), - html.input( - { - "type": "text", - "id": "todo-input", - "value": input_value, - "on_key_press": on_submit, - "on_change": on_change, - } - ), + html.input({ + "type": "text", + "id": "todo-input", + "value": input_value, + "on_key_press": on_submit, + "on_change": on_change, + }), mutation_status, rendered_items, ) @@ -389,10 +359,10 @@ async def async_add_todo_mutation(text: str): if existing.done: existing.done = False await existing.asave() - else: - return False - else: - await AsyncTodoItem(text=text, done=False).asave() + return None + return False + await AsyncTodoItem(text=text, done=False).asave() + return None async def async_toggle_todo_mutation(item: AsyncTodoItem): @@ -405,9 +375,7 @@ def async_todo_list(): input_value, set_input_value = hooks.use_state("") items = reactpy_django.hooks.use_query(async_get_todo_query) toggle_item = reactpy_django.hooks.use_mutation(async_toggle_todo_mutation) - add_item = reactpy_django.hooks.use_mutation( - async_add_todo_mutation, refetch=async_get_todo_query - ) + add_item = reactpy_django.hooks.use_mutation(async_add_todo_mutation, refetch=async_get_todo_query) async def on_submit(event): if event["key"] == "Enter": @@ -434,21 +402,19 @@ async def on_change(event): elif add_item.error: mutation_status = html.h2(f"Error when adding - {add_item.error}") else: - mutation_status = "" # type: ignore + mutation_status = "" return html.div( {"id": "async-todo-list"}, - html.p(inspect.currentframe().f_code.co_name), # type: ignore + html.p(inspect.currentframe().f_code.co_name), html.label("Add an item:"), - html.input( - { - "type": "text", - "id": "async-todo-input", - "value": input_value, - "on_key_press": on_submit, - "on_change": on_change, - } - ), + html.input({ + "type": "text", + "id": "async-todo-input", + "value": input_value, + "on_key_press": on_submit, + "on_change": on_change, + }), mutation_status, rendered_items, ) @@ -456,22 +422,14 @@ async def on_change(event): view_to_component_sync_func = view_to_component(views.view_to_component_sync_func) view_to_component_async_func = view_to_component(views.view_to_component_async_func) -view_to_component_sync_class = view_to_component( - views.ViewToComponentSyncClass.as_view() -) -view_to_component_async_class = view_to_component( - views.ViewToComponentAsyncClass.as_view() -) -view_to_component_template_view_class = view_to_component( - views.ViewToComponentTemplateViewClass.as_view() -) +view_to_component_sync_class = view_to_component(views.ViewToComponentSyncClass.as_view()) +view_to_component_async_class = view_to_component(views.ViewToComponentAsyncClass.as_view()) +view_to_component_template_view_class = view_to_component(views.ViewToComponentTemplateViewClass.as_view()) _view_to_iframe_sync_func = view_to_iframe(views.view_to_iframe_sync_func) _view_to_iframe_async_func = view_to_iframe(views.view_to_iframe_async_func) _view_to_iframe_sync_class = view_to_iframe(views.ViewToIframeSyncClass.as_view()) _view_to_iframe_async_class = view_to_iframe(views.ViewToIframeAsyncClass.as_view()) -_view_to_iframe_template_view_class = view_to_iframe( - views.ViewToIframeTemplateViewClass.as_view() -) +_view_to_iframe_template_view_class = view_to_iframe(views.ViewToIframeTemplateViewClass.as_view()) _view_to_iframe_args = view_to_iframe(views.view_to_iframe_args) _view_to_iframe_not_registered = view_to_iframe("view_does_not_exist") view_to_component_script = view_to_component(views.view_to_component_script) @@ -483,7 +441,7 @@ async def on_change(event): @component def view_to_iframe_sync_func(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_sync_func(key="test"), ) @@ -491,7 +449,7 @@ def view_to_iframe_sync_func(): @component def view_to_iframe_async_func(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_async_func(), ) @@ -499,7 +457,7 @@ def view_to_iframe_async_func(): @component def view_to_iframe_sync_class(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_sync_class(), ) @@ -507,7 +465,7 @@ def view_to_iframe_sync_class(): @component def view_to_iframe_async_class(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_async_class(), ) @@ -515,7 +473,7 @@ def view_to_iframe_async_class(): @component def view_to_iframe_template_view_class(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_template_view_class(), ) @@ -523,7 +481,7 @@ def view_to_iframe_template_view_class(): @component def view_to_iframe_args(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_args("Arg1", "Arg2", kwarg1="Kwarg1", kwarg2="Kwarg2"), ) @@ -531,7 +489,7 @@ def view_to_iframe_args(): @component def view_to_iframe_not_registered(): return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + {"id": inspect.currentframe().f_code.co_name}, _view_to_iframe_not_registered(), ) @@ -543,12 +501,12 @@ def view_to_component_request(): def on_click(_): post_request = HttpRequest() post_request.method = "POST" - set_request(post_request) # type: ignore + set_request(post_request) return html._( html.button( { - "id": f"{inspect.currentframe().f_code.co_name}_btn", # type: ignore + "id": f"{inspect.currentframe().f_code.co_name}_btn", "on_click": on_click, }, "Click me", @@ -567,7 +525,7 @@ def on_click(_): return html._( html.button( { - "id": f"{inspect.currentframe().f_code.co_name}_btn", # type: ignore + "id": f"{inspect.currentframe().f_code.co_name}_btn", "on_click": on_click, }, "Click me", @@ -586,7 +544,7 @@ def on_click(_): return html._( html.button( { - "id": f"{inspect.currentframe().f_code.co_name}_btn", # type: ignore + "id": f"{inspect.currentframe().f_code.co_name}_btn", "on_click": on_click, }, "Click me", @@ -602,7 +560,7 @@ def custom_host(number=0): return html.div( { - "class_name": f"{inspect.currentframe().f_code.co_name}-{number}", # type: ignore + "class_name": f"{inspect.currentframe().f_code.co_name}-{number}", "data-port": port, }, f"Server Port: {port}", @@ -611,9 +569,7 @@ def custom_host(number=0): @component def broken_postprocessor_query(): - relational_parent = reactpy_django.hooks.use_query( - get_relational_parent_query, postprocessor=None - ) + relational_parent = reactpy_django.hooks.use_query(get_relational_parent_query, postprocessor=None) if not relational_parent.data: return @@ -661,10 +617,7 @@ async def clear_data(event): async def on_submit(event): if event["key"] == "Enter": - user_data_mutation( - (user_data_query.data or {}) - | {event["target"]["value"]: event["target"]["value"]} - ) + user_data_mutation((user_data_query.data or {}) | {event["target"]["value"]: event["target"]["value"]}) return html.div( { @@ -673,9 +626,7 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": ( - "AnonymousUser" if current_user.is_anonymous else current_user.username - ), + "data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username), }, html.div("use_user_data"), html.button({"className": "login-1", "on_click": login_user1}, "Login 1"), @@ -684,17 +635,9 @@ async def on_submit(event): html.button({"className": "clear", "on_click": clear_data}, "Clear Data"), html.div(f"User: {current_user}"), html.div(f"Data: {user_data_query.data}"), - html.div( - f"Data State: (loading={user_data_query.loading}, error={user_data_query.error})" - ), - html.div( - f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})" - ), - html.div( - html.input( - {"on_key_press": on_submit, "placeholder": "Type here to add data"} - ) - ), + html.div(f"Data State: (loading={user_data_query.loading}, error={user_data_query.error})"), + html.div(f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})"), + html.div(html.input({"on_key_press": on_submit, "placeholder": "Type here to add data"})), ) @@ -730,10 +673,7 @@ async def clear_data(event): async def on_submit(event): if event["key"] == "Enter": - user_data_mutation( - (user_data_query.data or {}) - | {event["target"]["value"]: event["target"]["value"]} - ) + user_data_mutation((user_data_query.data or {}) | {event["target"]["value"]: event["target"]["value"]}) return html.div( { @@ -741,24 +681,14 @@ async def on_submit(event): "data-fetch-error": bool(user_data_query.error), "data-mutation-error": bool(user_data_mutation.error), "data-loading": user_data_query.loading or user_data_mutation.loading, - "data-username": ( - "AnonymousUser" if current_user.is_anonymous else current_user.username - ), + "data-username": ("AnonymousUser" if current_user.is_anonymous else current_user.username), }, html.div("use_user_data_with_default"), html.button({"className": "login-3", "on_click": login_user3}, "Login 3"), html.button({"className": "clear", "on_click": clear_data}, "Clear Data"), html.div(f"User: {current_user}"), html.div(f"Data: {user_data_query.data}"), - html.div( - f"Data State: (loading={user_data_query.loading}, error={user_data_query.error})" - ), - html.div( - f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})" - ), - html.div( - html.input( - {"on_key_press": on_submit, "placeholder": "Type here to add data"} - ) - ), + html.div(f"Data State: (loading={user_data_query.loading}, error={user_data_query.error})"), + html.div(f"Mutation State: (loading={user_data_mutation.loading}, error={user_data_mutation.error})"), + html.div(html.input({"on_key_press": on_submit, "placeholder": "Type here to add data"})), ) diff --git a/tests/test_app/middleware.py b/tests/test_app/middleware.py index 0927a100..ff40c7c6 100644 --- a/tests/test_app/middleware.py +++ b/tests/test_app/middleware.py @@ -13,9 +13,7 @@ def __init__(self, get_response): # One-time configuration and initialization. self.get_response = get_response with contextlib.suppress(Exception): - User.objects.create_superuser( - username="admin", email="admin@example.com", password="password" - ) + User.objects.create_superuser(username="admin", email="admin@example.com", password="password") if iscoroutinefunction(self.get_response): markcoroutinefunction(self) diff --git a/tests/test_app/migrations/0001_initial.py b/tests/test_app/migrations/0001_initial.py index 69498ba5..aa7816b6 100644 --- a/tests/test_app/migrations/0001_initial.py +++ b/tests/test_app/migrations/0001_initial.py @@ -7,7 +7,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [] # type: ignore + dependencies = [] operations = [ migrations.CreateModel( diff --git a/tests/test_app/models.py b/tests/test_app/models.py index 8d421042..8b873dd6 100644 --- a/tests/test_app/models.py +++ b/tests/test_app/models.py @@ -2,35 +2,33 @@ class TodoItem(models.Model): - done = models.BooleanField() # type: ignore - text = models.CharField(max_length=1000, unique=True) # type: ignore + done = models.BooleanField() + text = models.CharField(max_length=1000, unique=True) class AsyncTodoItem(models.Model): - done = models.BooleanField() # type: ignore - text = models.CharField(max_length=1000, unique=True) # type: ignore + done = models.BooleanField() + text = models.CharField(max_length=1000, unique=True) class RelationalChild(models.Model): - text = models.CharField(max_length=1000) # type: ignore + text = models.CharField(max_length=1000) class AsyncRelationalChild(models.Model): - text = models.CharField(max_length=1000) # type: ignore + text = models.CharField(max_length=1000) class RelationalParent(models.Model): - done = models.BooleanField(default=True) # type: ignore - many_to_many = models.ManyToManyField(RelationalChild, related_name="many_to_many") # type: ignore - one_to_one = models.OneToOneField( # type: ignore - RelationalChild, related_name="one_to_one", on_delete=models.SET_NULL, null=True - ) + done = models.BooleanField(default=True) + many_to_many = models.ManyToManyField(RelationalChild, related_name="many_to_many") + one_to_one = models.OneToOneField(RelationalChild, related_name="one_to_one", on_delete=models.SET_NULL, null=True) class AsyncRelationalParent(models.Model): - done = models.BooleanField(default=True) # type: ignore - many_to_many = models.ManyToManyField(AsyncRelationalChild, related_name="many_to_many") # type: ignore - one_to_one = models.OneToOneField( # type: ignore + done = models.BooleanField(default=True) + many_to_many = models.ManyToManyField(AsyncRelationalChild, related_name="many_to_many") + one_to_one = models.OneToOneField( AsyncRelationalChild, related_name="one_to_one", on_delete=models.SET_NULL, @@ -39,10 +37,10 @@ class AsyncRelationalParent(models.Model): class ForiegnChild(models.Model): - text = models.CharField(max_length=1000) # type: ignore - parent = models.ForeignKey(RelationalParent, related_name="many_to_one", on_delete=models.CASCADE) # type: ignore + text = models.CharField(max_length=1000) + parent = models.ForeignKey(RelationalParent, related_name="many_to_one", on_delete=models.CASCADE) class AsyncForiegnChild(models.Model): - text = models.CharField(max_length=1000) # type: ignore - parent = models.ForeignKey(AsyncRelationalParent, related_name="many_to_one", on_delete=models.CASCADE) # type: ignore + text = models.CharField(max_length=1000) + parent = models.ForeignKey(AsyncRelationalParent, related_name="many_to_one", on_delete=models.CASCADE) diff --git a/tests/test_app/offline/components.py b/tests/test_app/offline/components.py index daa7238d..381faa7a 100644 --- a/tests/test_app/offline/components.py +++ b/tests/test_app/offline/components.py @@ -5,8 +5,7 @@ def online(): return html.div( {"id": "online"}, - "This is the ONLINE component. " - "Shut down your webserver and check if the offline component appears.", + "This is the ONLINE component. Shut down your webserver and check if the offline component appears.", ) diff --git a/tests/test_app/performance/components.py b/tests/test_app/performance/components.py index 7dba23bc..54dd2280 100644 --- a/tests/test_app/performance/components.py +++ b/tests/test_app/performance/components.py @@ -1,13 +1,12 @@ -from datetime import datetime - +from django.utils import timezone from reactpy import component, hooks, html @component def renders_per_second(): - start_time, _set_start_time = hooks.use_state(datetime.now()) + start_time, _set_start_time = hooks.use_state(timezone.now()) count, set_count = hooks.use_state(0) - seconds_elapsed = (datetime.now() - start_time).total_seconds() + seconds_elapsed = (timezone.now() - start_time).total_seconds() @hooks.use_effect def run_tests(): @@ -46,9 +45,9 @@ def net_io_time_to_load(): @component def mixed_time_to_load(): - start_time, _set_start_time = hooks.use_state(datetime.now()) + start_time, _set_start_time = hooks.use_state(timezone.now()) count, set_count = hooks.use_state(0) - seconds_elapsed = (datetime.now() - start_time).total_seconds() + seconds_elapsed = (timezone.now() - start_time).total_seconds() @hooks.use_effect def run_tests(): @@ -69,8 +68,8 @@ def run_tests(): @component def event_renders_per_second(): count, set_count = hooks.use_state(0) - start_time, _set_start_time = hooks.use_state(datetime.now()) - seconds_elapsed = (datetime.now() - start_time).total_seconds() + start_time, _set_start_time = hooks.use_state(timezone.now()) + seconds_elapsed = (timezone.now() - start_time).total_seconds() erps = count / (seconds_elapsed or 0.01) async def event_handler(event): @@ -83,14 +82,12 @@ async def event_handler(event): {"class_name": "erps", "data-erps": erps}, f"Event Renders Per Second: {erps}", ), - html.input( - { - "type": "text", - "default_value": "0", - "data-count": str(count), - "on_click": event_handler, - } - ), + html.input({ + "type": "text", + "default_value": "0", + "data-count": str(count), + "on_click": event_handler, + }), ) diff --git a/tests/test_app/performance/urls.py b/tests/test_app/performance/urls.py index 6908222c..74a46bf9 100644 --- a/tests/test_app/performance/urls.py +++ b/tests/test_app/performance/urls.py @@ -9,7 +9,6 @@ time_to_load, ) - urlpatterns = [ path("rps/", renders_per_second), path("rps/", renders_per_second), diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index 7a2b29b4..bc8f900b 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -14,11 +14,7 @@ def prerender_string(): if scope.get("type") != "http": sleep(SLEEP_TIME) - return ( - "prerender_string: Fully Rendered" - if scope.get("type") == "websocket" - else "prerender_string: Prerendered" - ) + return "prerender_string: Fully Rendered" if scope.get("type") == "websocket" else "prerender_string: Prerendered" @component @@ -52,13 +48,9 @@ def use_user(): success = bool(user) if scope.get("type") == "http": - return html.div( - {"id": "use-user-http", "data-success": success}, f"use_user: {user} (HTTP)" - ) + return html.div({"id": "use-user-http", "data-success": success}, f"use_user: {user} (HTTP)") - return html.div( - {"id": "use-user-ws", "data-success": success}, f"use_user: {user} (WebSocket)" - ) + return html.div({"id": "use-user-ws", "data-success": success}, f"use_user: {user} (WebSocket)") @component diff --git a/tests/test_app/pyscript/components/child.py b/tests/test_app/pyscript/components/child.py index 1f4a7824..0ef5b3fb 100644 --- a/tests/test_app/pyscript/components/child.py +++ b/tests/test_app/pyscript/components/child.py @@ -10,16 +10,14 @@ def root(): html.div( {"className": "grid"}, html.button( - {"className": "plus", "on_click": lambda event: set_value(value + 1)}, + {"className": "plus", "on_click": lambda _: set_value(value + 1)}, "+", ), html.button( - {"className": "minus", "on_click": lambda event: set_value(value - 1)}, + {"className": "minus", "on_click": lambda _: set_value(value - 1)}, "-", ), ), "Current value", - html.pre( - {"style": {"font-style": "bold"}, "data-value": str(value)}, str(value) - ), + html.pre({"style": {"font-style": "bold"}, "data-value": str(value)}, str(value)), ) diff --git a/tests/test_app/pyscript/components/counter.py b/tests/test_app/pyscript/components/counter.py index 31df55a1..b8041057 100644 --- a/tests/test_app/pyscript/components/counter.py +++ b/tests/test_app/pyscript/components/counter.py @@ -9,16 +9,14 @@ def root(): html.div( {"className": "grid"}, html.button( - {"className": "plus", "on_click": lambda event: set_value(value + 1)}, + {"className": "plus", "on_click": lambda _: set_value(value + 1)}, "+", ), html.button( - {"className": "minus", "on_click": lambda event: set_value(value - 1)}, + {"className": "minus", "on_click": lambda _: set_value(value - 1)}, "-", ), ), "Current value", - html.pre( - {"style": {"font-style": "bold"}, "data-value": str(value)}, str(value) - ), + html.pre({"style": {"font-style": "bold"}, "data-value": str(value)}, str(value)), ) diff --git a/tests/test_app/pyscript/components/multifile_parent.py b/tests/test_app/pyscript/components/multifile_parent.py index 48a1b1d8..c54d7719 100644 --- a/tests/test_app/pyscript/components/multifile_parent.py +++ b/tests/test_app/pyscript/components/multifile_parent.py @@ -1,3 +1,4 @@ +# ruff: noqa: TCH004 from typing import TYPE_CHECKING from reactpy import component, html diff --git a/tests/test_app/pyscript/components/server_side.py b/tests/test_app/pyscript/components/server_side.py index fe31d527..682411d5 100644 --- a/tests/test_app/pyscript/components/server_side.py +++ b/tests/test_app/pyscript/components/server_side.py @@ -1,4 +1,5 @@ from reactpy import component, html, use_state + from reactpy_django.components import pyscript_component @@ -18,7 +19,7 @@ def parent_toggle(): return html.div( {"id": "parent-toggle"}, html.button( - {"onClick": lambda x: set_state(not state)}, + {"onClick": lambda _: set_state(not state)}, "Click to show/hide", ), ) @@ -26,7 +27,7 @@ def parent_toggle(): return html.div( {"id": "parent-toggle"}, html.button( - {"onClick": lambda x: set_state(not state)}, + {"onClick": lambda _: set_state(not state)}, "Click to show/hide", ), pyscript_component("./test_app/pyscript/components/child.py"), diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py index fb390e28..76e20789 100644 --- a/tests/test_app/settings_multi_db.py +++ b/tests/test_app/settings_multi_db.py @@ -12,14 +12,12 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # Run in production mode when using a real web server -DEBUG = not any( - sys.argv[0].endswith(webserver_name) - for webserver_name in ["hypercorn", "uvicorn", "daphne"] -) +DEBUG = not any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn", "daphne"]) ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ + "servestatic.runserver_nostatic", "daphne", # Overrides `runserver` command with an ASGI server "django.contrib.admin", "django.contrib.auth", @@ -146,6 +144,8 @@ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # ReactPy-Django Settings -REACTPY_BACKHAUL_THREAD = any( - sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"] -) +REACTPY_BACKHAUL_THREAD = any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"]) + +# ServeStatic Settings +SERVESTATIC_USE_FINDERS = True +SERVESTATIC_AUTOREFRESH = True diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py index e5f8969a..21112086 100644 --- a/tests/test_app/settings_single_db.py +++ b/tests/test_app/settings_single_db.py @@ -12,14 +12,12 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # Run in production mode when using a real web server -DEBUG = not any( - sys.argv[0].endswith(webserver_name) - for webserver_name in ["hypercorn", "uvicorn", "daphne"] -) +DEBUG = not any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn", "daphne"]) ALLOWED_HOSTS = ["*"] # Application definition INSTALLED_APPS = [ + "servestatic.runserver_nostatic", "daphne", # Overrides `runserver` command with an ASGI server "django.contrib.admin", "django.contrib.auth", @@ -87,9 +85,7 @@ # Password validation AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" - }, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, @@ -134,6 +130,8 @@ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # ReactPy-Django Settings -REACTPY_BACKHAUL_THREAD = any( - sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"] -) +REACTPY_BACKHAUL_THREAD = any(sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"]) + +# ServeStatic Settings +SERVESTATIC_USE_FINDERS = True +SERVESTATIC_AUTOREFRESH = True diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html index af3db04b..26361861 100644 --- a/tests/test_app/templates/channel_layers.html +++ b/tests/test_app/templates/channel_layers.html @@ -17,11 +17,11 @@

ReactPy Channel Layers Test Page


{% component "test_app.channel_layers.components.sender" %}
- {% component "test_app.channel_layers.components.group_receiver" id=1 %} + {% component "test_app.channel_layers.components.group_receiver" id_number=1 %}
- {% component "test_app.channel_layers.components.group_receiver" id=2 %} + {% component "test_app.channel_layers.components.group_receiver" id_number=2 %}
- {% component "test_app.channel_layers.components.group_receiver" id=3 %} + {% component "test_app.channel_layers.components.group_receiver" id_number=3 %}
{% component "test_app.channel_layers.components.group_sender" %}
diff --git a/tests/test_app/tests/js/button-from-js-module.js b/tests/test_app/tests/js/button-from-js-module.js index e68b9638..2b49f505 100644 --- a/tests/test_app/tests/js/button-from-js-module.js +++ b/tests/test_app/tests/js/button-from-js-module.js @@ -4,22 +4,22 @@ import htm from "https://unpkg.com/htm?module"; const html = htm.bind(h); export function bind(node, config) { - return { - create: (type, props, children) => h(type, props, ...children), - render: (element) => render(element, node), - unmount: () => render(null, node), - }; + return { + create: (type, props, children) => h(type, props, ...children), + render: (element) => render(element, node), + unmount: () => render(null, node), + }; } export function SimpleButton(props) { - return h( - "button", - { - id: props.id, - onClick(event) { - props.onClick({ data: props.eventResponseData }); - }, - }, - "simple button" - ); + return h( + "button", + { + id: props.id, + onClick(event) { + props.onClick({ data: props.eventResponseData }); + }, + }, + "simple button", + ); } diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index c4848ccf..81a8eabf 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,7 +1,9 @@ +# ruff: noqa: RUF012, N802 import os import socket from time import sleep +import pytest from playwright.sync_api import TimeoutError from reactpy_django.models import ComponentSession @@ -13,7 +15,6 @@ class GenericComponentTests(PlaywrightTestCase): - databases = {"default"} @classmethod @@ -51,32 +52,24 @@ def test_use_origin(self): self.page.locator("#use-origin[data-success=true]").wait_for() def test_static_css(self): - self.assertEqual( + assert ( self.page.wait_for_selector("#django-css button").evaluate( "e => window.getComputedStyle(e).getPropertyValue('color')" - ), - "rgb(0, 0, 255)", + ) + == "rgb(0, 0, 255)" ) def test_static_js(self): self.page.locator("#django-js[data-success=true]").wait_for() def test_unauthorized_user(self): - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - "#unauthorized-user", - timeout=1, - ) + with pytest.raises(TimeoutError): + self.page.wait_for_selector("#unauthorized-user", timeout=1) self.page.wait_for_selector("#unauthorized-user-fallback") def test_authorized_user(self): - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - "#authorized-user-fallback", - timeout=1, - ) + with pytest.raises(TimeoutError): + self.page.wait_for_selector("#authorized-user-fallback", timeout=1) self.page.wait_for_selector("#authorized-user") def test_relational_query(self): @@ -94,15 +87,9 @@ def test_use_query_and_mutation(self): todo_input.type(f"sample-{i}", delay=CLICK_DELAY) todo_input.press("Enter", delay=CLICK_DELAY) self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}") - self.page.wait_for_selector( - f"#todo-list #todo-item-sample-{i}-checkbox" - ).click() - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - f"#todo-list #todo-item-sample-{i}", - timeout=1, - ) + self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click() + with pytest.raises(TimeoutError): + self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1) def test_async_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#async-todo-input") @@ -113,15 +100,9 @@ def test_async_use_query_and_mutation(self): todo_input.type(f"sample-{i}", delay=CLICK_DELAY) todo_input.press("Enter", delay=CLICK_DELAY) self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}") - self.page.wait_for_selector( - f"#async-todo-list #todo-item-sample-{i}-checkbox" - ).click() - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - f"#async-todo-list #todo-item-sample-{i}", - timeout=1, - ) + self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click() + with pytest.raises(TimeoutError): + self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1) def test_view_to_component_sync_func(self): self.page.locator("#view_to_component_sync_func[data-success=true]").wait_for() @@ -136,9 +117,7 @@ def test_view_to_component_async_class(self): self.page.locator("#ViewToComponentAsyncClass[data-success=true]").wait_for() def test_view_to_component_template_view_class(self): - self.page.locator( - "#ViewToComponentTemplateViewClass[data-success=true]" - ).wait_for() + self.page.locator("#ViewToComponentTemplateViewClass[data-success=true]").wait_for() def _click_btn_and_check_success(self, name): self.page.locator(f"#{name}:not([data-success=true])").wait_for() @@ -197,7 +176,7 @@ def test_component_session_exists(self): query = ComponentSession.objects.filter(uuid=session_id) query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") - self.assertTrue(query_exists) + assert query_exists def test_component_session_missing(self): """No session should exist for components that don't have args/kwargs.""" @@ -209,7 +188,7 @@ def test_component_session_missing(self): query = ComponentSession.objects.filter(uuid=session_id) query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") - self.assertFalse(query_exists) + assert not query_exists def test_use_user_data(self): text_input = self.page.wait_for_selector("#use-user-data input") @@ -222,27 +201,27 @@ def test_use_user_data(self): user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]" ) - self.assertIn("Data: None", user_data_div.text_content()) + assert "Data: None" in user_data_div.text_content() # Test first user's data login_1.click() user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) - self.assertIn(r"Data: {}", user_data_div.text_content()) + assert "Data: {}" in user_data_div.text_content() text_input.type("test", delay=CLICK_DELAY) text_input.press("Enter", delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) - self.assertIn("Data: {'test': 'test'}", user_data_div.text_content()) + assert "Data: {'test': 'test'}" in user_data_div.text_content() # Test second user's data login_2.click() user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) - self.assertIn(r"Data: {}", user_data_div.text_content()) + assert "Data: {}" in user_data_div.text_content() text_input.press("Control+A", delay=CLICK_DELAY) text_input.press("Backspace", delay=CLICK_DELAY) text_input.type("test 2", delay=CLICK_DELAY) @@ -250,21 +229,21 @@ def test_use_user_data(self): user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) - self.assertIn("Data: {'test 2': 'test 2'}", user_data_div.text_content()) + assert "Data: {'test 2': 'test 2'}" in user_data_div.text_content() # Attempt to clear data clear.click() user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) - self.assertIn(r"Data: {}", user_data_div.text_content()) + assert "Data: {}" in user_data_div.text_content() # Attempt to logout logout.click() user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]" ) - self.assertIn(r"Data: None", user_data_div.text_content()) + assert "Data: None" in user_data_div.text_content() def test_use_user_data_with_default(self): text_input = self.page.wait_for_selector("#use-user-data-with-default input") @@ -275,25 +254,22 @@ def test_use_user_data_with_default(self): user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]" ) - self.assertIn("Data: None", user_data_div.text_content()) + assert "Data: None" in user_data_div.text_content() # Test first user's data login_3.click() user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) - self.assertIn( - "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}", - user_data_div.text_content(), - ) + assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content() text_input.type("test", delay=CLICK_DELAY) text_input.press("Enter", delay=CLICK_DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) - self.assertIn( - "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3', 'test': 'test'}", - user_data_div.text_content(), + assert ( + "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3', 'test': 'test'}" + in user_data_div.text_content() ) # Attempt to clear data @@ -302,14 +278,10 @@ def test_use_user_data_with_default(self): user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) - self.assertIn( - "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}", - user_data_div.text_content(), - ) + assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content() class PrerenderTests(PlaywrightTestCase): - @classmethod def setUpClass(cls): super().setUpClass() @@ -331,28 +303,23 @@ def test_prerender(self): component.wait_for() use_root_id_http.wait_for() use_user_http.wait_for() - self.assertEqual(string.all_inner_texts(), ["prerender_string: Prerendered"]) - self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Prerendered"]) - self.assertEqual( - component.all_inner_texts(), ["prerender_component: Prerendered"] - ) + assert string.all_inner_texts() == ["prerender_string: Prerendered"] + assert vdom.all_inner_texts() == ["prerender_vdom: Prerendered"] + assert component.all_inner_texts() == ["prerender_component: Prerendered"] root_id_value = use_root_id_http.get_attribute("data-value") - self.assertEqual(len(root_id_value), 36) + assert len(root_id_value) == 36 # Check if the full render occurred sleep(2) - self.assertEqual(string.all_inner_texts(), ["prerender_string: Fully Rendered"]) - self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Fully Rendered"]) - self.assertEqual( - component.all_inner_texts(), ["prerender_component: Fully Rendered"] - ) + assert string.all_inner_texts() == ["prerender_string: Fully Rendered"] + assert vdom.all_inner_texts() == ["prerender_vdom: Fully Rendered"] + assert component.all_inner_texts() == ["prerender_component: Fully Rendered"] use_root_id_ws.wait_for() use_user_ws.wait_for() - self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value) + assert use_root_id_ws.get_attribute("data-value") == root_id_value class ErrorTests(PlaywrightTestCase): - @classmethod def setUpClass(cls): super().setUpClass() @@ -361,119 +328,107 @@ def setUpClass(cls): def test_component_does_not_exist_error(self): broken_component = self.page.locator("#component_does_not_exist_error") broken_component.wait_for() - self.assertIn("ComponentDoesNotExistError:", broken_component.text_content()) + assert "ComponentDoesNotExistError:" in broken_component.text_content() def test_component_param_error(self): broken_component = self.page.locator("#component_param_error") broken_component.wait_for() - self.assertIn("ComponentParamError:", broken_component.text_content()) + assert "ComponentParamError:" in broken_component.text_content() def test_invalid_host_error(self): broken_component = self.page.locator("#invalid_host_error") broken_component.wait_for() - self.assertIn("InvalidHostError:", broken_component.text_content()) + assert "InvalidHostError:" in broken_component.text_content() def test_synchronous_only_operation_error(self): broken_component = self.page.locator("#broken_postprocessor_query pre") broken_component.wait_for() - self.assertIn("SynchronousOnlyOperation:", broken_component.text_content()) + assert "SynchronousOnlyOperation:" in broken_component.text_content() def test_view_not_registered_error(self): broken_component = self.page.locator("#view_to_iframe_not_registered pre") broken_component.wait_for() - self.assertIn("ViewNotRegisteredError:", broken_component.text_content()) + assert "ViewNotRegisteredError:" in broken_component.text_content() def test_decorator_param_error(self): broken_component = self.page.locator("#incorrect_user_passes_test_decorator") broken_component.wait_for() - self.assertIn("DecoratorParamError:", broken_component.text_content()) + assert "DecoratorParamError:" in broken_component.text_content() class UrlRouterTests(PlaywrightTestCase): - def test_url_router(self): self.page.goto(f"{self.live_server_url}/router/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/", path.get_attribute("data-path")) + assert "/router/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/", string.text_content()) + assert string.text_content() == "/router/" def test_url_router_subroute(self): self.page.goto(f"{self.live_server_url}/router/subroute/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/subroute/", path.get_attribute("data-path")) + assert "/router/subroute/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("subroute/", string.text_content()) + assert string.text_content() == "subroute/" def test_url_unspecified(self): self.page.goto(f"{self.live_server_url}/router/unspecified/123/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/unspecified/123/", path.get_attribute("data-path")) + assert "/router/unspecified/123/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/unspecified//", string.text_content()) + assert string.text_content() == "/router/unspecified//" def test_url_router_integer(self): self.page.goto(f"{self.live_server_url}/router/integer/123/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/integer/123/", path.get_attribute("data-path")) + assert "/router/integer/123/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/integer//", string.text_content()) + assert string.text_content() == "/router/integer//" def test_url_router_path(self): self.page.goto(f"{self.live_server_url}/router/path/abc/123/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) + assert "/router/path/abc/123/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/path//", string.text_content()) + assert string.text_content() == "/router/path//" def test_url_router_slug(self): self.page.goto(f"{self.live_server_url}/router/slug/abc-123/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) + assert "/router/slug/abc-123/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/slug//", string.text_content()) + assert string.text_content() == "/router/slug//" def test_url_router_string(self): self.page.goto(f"{self.live_server_url}/router/string/abc/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/string/abc/", path.get_attribute("data-path")) + assert "/router/string/abc/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/string//", string.text_content()) + assert string.text_content() == "/router/string//" def test_url_router_uuid(self): - self.page.goto( - f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" - ) + self.page.goto(f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/") path = self.page.wait_for_selector("#router-path") - self.assertIn( - "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", - path.get_attribute("data-path"), - ) + assert "/router/uuid/123e4567-e89b-12d3-a456-426614174000/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/uuid//", string.text_content()) + assert string.text_content() == "/router/uuid//" def test_url_router_any(self): - self.page.goto( - f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/" - ) + self.page.goto(f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/") path = self.page.wait_for_selector("#router-path") - self.assertIn( - "/router/any/adslkjgklasdjhfah/6789543256/", - path.get_attribute("data-path"), - ) + assert "/router/any/adslkjgklasdjhfah/6789543256/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/any/", string.text_content()) + assert string.text_content() == "/router/any/" def test_url_router_int_and_string(self): self.page.goto(f"{self.live_server_url}/router/two/123/abc/") path = self.page.wait_for_selector("#router-path") - self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) + assert "/router/two/123/abc/" in path.get_attribute("data-path") string = self.page.query_selector("#router-string") - self.assertEqual("/router/two///", string.text_content()) + assert string.text_content() == "/router/two///" class ChannelLayersTests(PlaywrightTestCase): - @classmethod def setUpClass(cls): super().setUpClass() @@ -484,27 +439,20 @@ def test_channel_layer_components(self): sender.type("test", delay=CLICK_DELAY) sender.press("Enter", delay=CLICK_DELAY) receiver = self.page.wait_for_selector("#receiver[data-message='test']") - self.assertIsNotNone(receiver) + assert receiver is not None sender = self.page.wait_for_selector("#group-sender") sender.type("1234", delay=CLICK_DELAY) sender.press("Enter", delay=CLICK_DELAY) - receiver_1 = self.page.wait_for_selector( - "#group-receiver-1[data-message='1234']" - ) - receiver_2 = self.page.wait_for_selector( - "#group-receiver-2[data-message='1234']" - ) - receiver_3 = self.page.wait_for_selector( - "#group-receiver-3[data-message='1234']" - ) - self.assertIsNotNone(receiver_1) - self.assertIsNotNone(receiver_2) - self.assertIsNotNone(receiver_3) + receiver_1 = self.page.wait_for_selector("#group-receiver-1[data-message='1234']") + receiver_2 = self.page.wait_for_selector("#group-receiver-2[data-message='1234']") + receiver_3 = self.page.wait_for_selector("#group-receiver-3[data-message='1234']") + assert receiver_1 is not None + assert receiver_2 is not None + assert receiver_3 is not None class PyscriptTests(PlaywrightTestCase): - @classmethod def setUpClass(cls): super().setUpClass() @@ -559,7 +507,6 @@ def test_1_javascript_module_execution_within_pyscript(self): class DistributedComputingTests(PlaywrightTestCase): - @classmethod def setUpServer(cls): super().setUpServer() @@ -576,9 +523,7 @@ def tearDownServer(cls): def test_host_roundrobin(self): """Verify if round-robin host selection is working.""" - self.page.goto( - f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8" - ) + self.page.goto(f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8") elem0 = self.page.locator(".custom_host-0") elem1 = self.page.locator(".custom_host-1") elem2 = self.page.locator(".custom_host-2") @@ -601,18 +546,15 @@ def test_host_roundrobin(self): } # There should only be two ports in the set - self.assertEqual(current_ports, correct_ports) - self.assertEqual(len(current_ports), 2) + assert current_ports == correct_ports + assert len(current_ports) == 2 def test_custom_host(self): """Make sure that the component is rendered by a separate server.""" self.page.goto(f"{self.live_server_url}/port/{self._port2}/") elem = self.page.locator(".custom_host-0") elem.wait_for() - self.assertIn( - f"Server Port: {self._port2}", - elem.text_content(), - ) + assert f"Server Port: {self._port2}" in elem.text_content() def test_custom_host_wrong_port(self): """Make sure that other ports are not rendering components.""" @@ -620,7 +562,7 @@ def test_custom_host_wrong_port(self): tmp_sock.bind((self._server_process.host, 0)) random_port = tmp_sock.getsockname()[1] self.page.goto(f"{self.live_server_url}/port/{random_port}/") - with self.assertRaises(TimeoutError): + with pytest.raises(TimeoutError): self.page.locator(".custom_host").wait_for(timeout=1000) @@ -632,8 +574,8 @@ def setUpClass(cls): def test_offline_components(self): self.page.wait_for_selector("div:not([hidden]) > #online") - self.assertIsNotNone(self.page.query_selector("div[hidden] > #offline")) + assert self.page.query_selector("div[hidden] > #offline") is not None self._server_process.terminate() self._server_process.join() self.page.wait_for_selector("div:not([hidden]) > #offline") - self.assertIsNotNone(self.page.query_selector("div[hidden] > #online")) + assert self.page.query_selector("div[hidden] > #online") is not None diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 83e34ccb..5d613ad5 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -1,8 +1,9 @@ +# ruff: noqa: RUF012 from time import sleep from typing import Any from uuid import uuid4 -import dill as pickle +import dill from django.test import TransactionTestCase from reactpy_django import clean @@ -31,40 +32,36 @@ def test_component_params(self): clean.clean(immediate=True) # Make sure the ComponentParams table is empty - self.assertEqual(ComponentSession.objects.count(), 0) + assert ComponentSession.objects.count() == 0 params_1 = self._save_params_to_db(1) # Check if a component params are in the database - self.assertEqual(ComponentSession.objects.count(), 1) - self.assertEqual( - pickle.loads(ComponentSession.objects.first().params), params_1 # type: ignore - ) + assert ComponentSession.objects.count() == 1 + assert dill.loads(ComponentSession.objects.first().params) == params_1 # Force `params_1` to expire sleep(config.REACTPY_CLEAN_INTERVAL) # Create a new, non-expired component params params_2 = self._save_params_to_db(2) - self.assertEqual(ComponentSession.objects.count(), 2) + assert ComponentSession.objects.count() == 2 # Try to delete the `params_1` via cleaning (it should be expired) # Note: We don't use `immediate` here in order to test timestamping logic clean.clean() # Make sure `params_1` has expired, but `params_2` is still there - self.assertEqual(ComponentSession.objects.count(), 1) - self.assertEqual( - pickle.loads(ComponentSession.objects.first().params), params_2 # type: ignore - ) + assert ComponentSession.objects.count() == 1 + assert dill.loads(ComponentSession.objects.first().params) == params_2 finally: config.REACTPY_CLEAN_INTERVAL = initial_clean_interval config.REACTPY_SESSION_MAX_AGE = initial_session_max_age config.REACTPY_CLEAN_USER_DATA = initial_clean_user_data def _save_params_to_db(self, value: Any) -> ComponentParams: - db = list(self.databases)[0] + db = next(iter(self.databases)) param_data = ComponentParams((value,), {"test_value": value}) - model = ComponentSession(str(uuid4()), params=pickle.dumps(param_data)) + model = ComponentSession(str(uuid4()), params=dill.dumps(param_data)) model.clean_fields() model.clean() model.save(using=db) @@ -100,13 +97,13 @@ def test_user_data_cleanup(self): user_data.save() # Make sure the orphaned user data object is deleted - self.assertEqual(UserDataModel.objects.count(), initial_count + 1) + assert UserDataModel.objects.count() == initial_count + 1 clean.clean_user_data() - self.assertEqual(UserDataModel.objects.count(), initial_count) + assert UserDataModel.objects.count() == initial_count # Check if deleting a user deletes the associated UserData user.delete() - self.assertEqual(UserDataModel.objects.count(), initial_count - 1) + assert UserDataModel.objects.count() == initial_count - 1 # Make sure one user data object remains - self.assertEqual(UserDataModel.objects.count(), 1) + assert UserDataModel.objects.count() == 1 diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index 5c3ec95a..6328fee5 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -8,163 +8,100 @@ class RegexTests(TestCase): def test_component_regex(self): # Real component matches - self.assertRegex(r'{%component "my.component"%}', COMPONENT_REGEX) - self.assertRegex(r'{%component "my.component"%}', COMPONENT_REGEX) - self.assertRegex(r"{%component 'my.component'%}", COMPONENT_REGEX) - self.assertRegex(r'{% component "my.component" %}', COMPONENT_REGEX) - self.assertRegex(r"{% component 'my.component' %}", COMPONENT_REGEX) - self.assertRegex( - r'{% component "my.component" class="my_thing" %}', COMPONENT_REGEX - ) - self.assertRegex( - r'{% component "my.component" class="my_thing" attr="attribute" %}', - COMPONENT_REGEX, - ) - self.assertRegex( - r"""{% - component - "my.component" - class="my_thing" - attr="attribute" - - %}""", # noqa: W291 - COMPONENT_REGEX, - ) - self.assertRegex(r'{% component "my.component" my_object %}', COMPONENT_REGEX) - self.assertRegex( - r'{% component "my.component" class="example-cls" x=123 y=456 %}', - COMPONENT_REGEX, - ) - self.assertRegex( - r'{% component "my.component" class = "example-cls" %}', + assert re.search(COMPONENT_REGEX, '{%component "my.component"%}') + assert re.search(COMPONENT_REGEX, '{%component "my.component"%}') + assert re.search(COMPONENT_REGEX, "{%component 'my.component'%}") + assert re.search(COMPONENT_REGEX, '{% component "my.component" %}') + assert re.search(COMPONENT_REGEX, "{% component 'my.component' %}") + assert re.search(COMPONENT_REGEX, '{% component "my.component" class="my_thing" %}') + assert re.search(COMPONENT_REGEX, '{% component "my.component" class="my_thing" attr="attribute" %}') + assert re.search( COMPONENT_REGEX, + '{%\n component\n "my.component"\n class="my_thing"\n attr="attribute"\n\n %}', ) + assert re.search(COMPONENT_REGEX, '{% component "my.component" my_object %}') + assert re.search(COMPONENT_REGEX, '{% component "my.component" class="example-cls" x=123 y=456 %}') + assert re.search(COMPONENT_REGEX, '{% component "my.component" class = "example-cls" %}') # Fake component matches - self.assertNotRegex(r'{% not_a_real_thing "my.component" %}', COMPONENT_REGEX) - self.assertNotRegex(r"{% component my.component %}", COMPONENT_REGEX) - self.assertNotRegex(r"""{% component 'my.component" %}""", COMPONENT_REGEX) - self.assertNotRegex(r'{ component "my.component" }', COMPONENT_REGEX) - self.assertNotRegex(r'{{ component "my.component" }}', COMPONENT_REGEX) - self.assertNotRegex(r"component", COMPONENT_REGEX) - self.assertNotRegex(r"{%%}", COMPONENT_REGEX) - self.assertNotRegex(r" ", COMPONENT_REGEX) - self.assertNotRegex(r"", COMPONENT_REGEX) - self.assertNotRegex(r'{% component " my.component " %}', COMPONENT_REGEX) - self.assertNotRegex( - r"""{% component "my.component - " %}""", - COMPONENT_REGEX, - ) - self.assertNotRegex(r'{{ component """ }}', COMPONENT_REGEX) - self.assertNotRegex(r'{{ component "" }}', COMPONENT_REGEX) + assert not re.search(COMPONENT_REGEX, '{% not_a_real_thing "my.component" %}') + assert not re.search(COMPONENT_REGEX, "{% component my.component %}") + assert not re.search(COMPONENT_REGEX, "{% component 'my.component\" %}") + assert not re.search(COMPONENT_REGEX, '{ component "my.component" }') + assert not re.search(COMPONENT_REGEX, '{{ component "my.component" }}') + assert not re.search(COMPONENT_REGEX, "component") + assert not re.search(COMPONENT_REGEX, "{%%}") + assert not re.search(COMPONENT_REGEX, " ") + assert not re.search(COMPONENT_REGEX, "") + assert not re.search(COMPONENT_REGEX, '{% component " my.component " %}') + assert not re.search(COMPONENT_REGEX, '{% component "my.component\n " %}') + assert not re.search(COMPONENT_REGEX, '{{ component """ }}') + assert not re.search(COMPONENT_REGEX, '{{ component "" }}') # Make sure back-to-back components are not merged into one match double_component_match = COMPONENT_REGEX.search( r'{% component "my.component" %} {% component "my.component" %}' ) - self.assertTrue(double_component_match[0] == r'{% component "my.component" %}') # type: ignore + assert double_component_match[0] == '{% component "my.component" %}' def test_comment_regex(self): # Real comment matches - self.assertRegex(r"", COMMENT_REGEX) - self.assertRegex( - r"""""", - COMMENT_REGEX, - ) - self.assertRegex( - r"""""", - COMMENT_REGEX, - ) - self.assertRegex( - r"""""", - COMMENT_REGEX, - ) - self.assertRegex( - r"""""", # noqa: W291 - COMMENT_REGEX, + assert re.search(COMMENT_REGEX, "") + assert re.search(COMMENT_REGEX, "") + assert re.search(COMMENT_REGEX, "") + assert re.search(COMMENT_REGEX, "") + assert re.search( + COMMENT_REGEX, "" ) # Fake comment matches - self.assertNotRegex(r"", COMMENT_REGEX) - self.assertNotRegex(r"", COMMENT_REGEX) - self.assertNotRegex(r'{% component "my.component" %}', COMMENT_REGEX) + assert not re.search(COMMENT_REGEX, "") + assert not re.search(COMMENT_REGEX, "") + assert not re.search(COMMENT_REGEX, '{% component "my.component" %}') # Components surrounded by comments - self.assertEqual( - COMMENT_REGEX.sub( - "", r'{% component "my.component" %} ' - ).strip(), - '{% component "my.component" %}', + assert ( + COMMENT_REGEX.sub("", '{% component "my.component" %} ').strip() + == '{% component "my.component" %}' ) - self.assertEqual( - COMMENT_REGEX.sub( - "", r' {% component "my.component" %}' - ).strip(), - '{% component "my.component" %}', + assert ( + COMMENT_REGEX.sub("", ' {% component "my.component" %}').strip() + == '{% component "my.component" %}' ) - self.assertEqual( - COMMENT_REGEX.sub( - "", r' {% component "my.component" %} ' - ).strip(), - '{% component "my.component" %}', + assert ( + COMMENT_REGEX.sub("", ' {% component "my.component" %} ').strip() + == '{% component "my.component" %}' ) - self.assertEqual( + assert ( COMMENT_REGEX.sub( "", - r""" {% component "my.component" %} - - """, - ).strip(), - '{% component "my.component" %}', + ' {% component "my.component" %}\n \n ', + ).strip() + == '{% component "my.component" %}' ) # Components surrounded by comments - self.assertEqual( - COMMENT_REGEX.sub("", r''), - "", - ) - self.assertEqual( + assert COMMENT_REGEX.sub("", '') == "" + assert ( COMMENT_REGEX.sub( "", - r"""""", # noqa: W291 - ), - "", + '', + ) + == "" ) def test_offline_component_regex(self): regex = re.compile(COMPONENT_REGEX) # Check if "offline_path" group is present and equals to "my_offline_path" - search = regex.search( - r'{% component "my.component" offline="my_offline_path" %}' - ) - self.assertTrue(search["offline_path"] == '"my_offline_path"') # type: ignore + search = regex.search(r'{% component "my.component" offline="my_offline_path" %}') + assert search["offline_path"] == '"my_offline_path"' - search = regex.search( - r'{% component "my.component" arg_1="1" offline="my_offline_path" arg_2="2" %}' - ) - self.assertTrue(search["offline_path"] == '"my_offline_path"') # type: ignore + search = regex.search(r'{% component "my.component" arg_1="1" offline="my_offline_path" arg_2="2" %}') + assert search["offline_path"] == '"my_offline_path"' - search = regex.search( - r'{% component "my.component" offline="my_offline_path" arg_2="2" %}' - ) + search = regex.search(r'{% component "my.component" offline="my_offline_path" arg_2="2" %}') - self.assertTrue(search["offline_path"] == '"my_offline_path"') # type: ignore - search = regex.search( - r'{% component "my.component" arg_1="1" offline="my_offline_path" %}' - ) - self.assertTrue(search["offline_path"] == '"my_offline_path"') # type: ignore + assert search["offline_path"] == '"my_offline_path"' + search = regex.search(r'{% component "my.component" arg_1="1" offline="my_offline_path" %}') + assert search["offline_path"] == '"my_offline_path"' diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index fe32d97d..64f0f60d 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -1,3 +1,4 @@ +# ruff: noqa: N802, RUF012 import asyncio import os import sys @@ -17,7 +18,6 @@ class PlaywrightTestCase(ChannelsLiveServerTestCase): - from reactpy_django import config databases = {"default"} @@ -27,12 +27,9 @@ def setUpClass(cls): # Repurposed from ChannelsLiveServerTestCase._pre_setup for connection in connections.all(): if cls._is_in_memory_db(cls, connection): - raise ImproperlyConfigured( - "ChannelLiveServerTestCase can not be used with in memory databases" - ) - cls._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": cls.host} - ) + msg = "ChannelLiveServerTestCase can not be used with in memory databases" + raise ImproperlyConfigured(msg) + cls._live_server_modified_settings = modify_settings(ALLOWED_HOSTS={"append": cls.host}) cls._live_server_modified_settings.enable() cls.get_application = partial( make_application, @@ -69,7 +66,7 @@ def tearDownClass(cls): # Repurposed from ChannelsLiveServerTestCase._post_teardown cls._live_server_modified_settings.disable() # Using set to prevent duplicates - for db_name in {"default", config.REACTPY_DATABASE}: + for db_name in {"default", config.REACTPY_DATABASE}: # noqa: PLC0208 call_command( "flush", verbosity=0, diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 0c75b357..695d0f43 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -22,19 +22,15 @@ def host_port_template(request: HttpRequest, port: int): return render(request, "host_port.html", {"new_host": host}) -def host_port_roundrobin_template( - request: HttpRequest, port1: int, port2: int, count: int = 1 -): +def host_port_roundrobin_template(request: HttpRequest, port1: int, port2: int, count: int = 1): from reactpy_django import config # Override ReactPy config to use round-robin hosts original = config.REACTPY_DEFAULT_HOSTS - config.REACTPY_DEFAULT_HOSTS = cycle( - [ - f"{request.get_host().split(':')[0]}:{port1}", - f"{request.get_host().split(':')[0]}:{port2}", - ] - ) + config.REACTPY_DEFAULT_HOSTS = cycle([ + f"{request.get_host().split(':')[0]}:{port1}", + f"{request.get_host().split(':')[0]}:{port2}", + ]) html = render( request, "host_port_roundrobin.html", @@ -129,9 +125,7 @@ def get_context_data(self, **kwargs): def view_to_iframe_args(request, arg1, arg2, kwarg1=None, kwarg2=None): - success = ( - arg1 == "Arg1" and arg2 == "Arg2" and kwarg1 == "Kwarg1" and kwarg2 == "Kwarg2" - ) + success = arg1 == "Arg1" and arg2 == "Arg2" and kwarg1 == "Kwarg1" and kwarg2 == "Kwarg2" return render( request, From 714191f3181a8dc730d9ecf7018d3b7b49e24d07 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 2 Dec 2024 02:02:53 -0800 Subject: [PATCH 05/19] v5.1.1 (#263) --- CHANGELOG.md | 7 ++++++- docs/src/assets/img/add-interactivity.png | Bin 20821 -> 22900 bytes src/reactpy_django/__init__.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 399d3668..f8c848e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] +- Nothing (yet)! + +## [5.1.1] - 2024-12-02 + ### Fixed - Fixed regression in v5.1.0 where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. @@ -521,7 +525,8 @@ Don't forget to remove deprecated code on each major release! - Support for IDOM within the Django -[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.1.0...HEAD +[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.1.1...HEAD +[5.1.1]: https://github.com/reactive-python/reactpy-django/compare/5.1.0...5.1.1 [5.1.0]: https://github.com/reactive-python/reactpy-django/compare/5.0.0...5.1.0 [5.0.0]: https://github.com/reactive-python/reactpy-django/compare/4.0.0...5.0.0 [4.0.0]: https://github.com/reactive-python/reactpy-django/compare/3.8.1...4.0.0 diff --git a/docs/src/assets/img/add-interactivity.png b/docs/src/assets/img/add-interactivity.png index 009b52ac123e2854259c3b2b3e478a721ed6b110..c32431905252537a5387e25c4ec58a3f90f87f6c 100644 GIT binary patch literal 22900 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 LmSPFiBJ_U%+FQ}0 literal 20821 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 Date: Mon, 2 Dec 2024 02:18:37 -0800 Subject: [PATCH 06/19] Attempt using hatch publish command (#264) --- .github/workflows/publish-py.yml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index f72cc55d..f3ade2f4 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -21,10 +21,11 @@ jobs: python-version: "3.x" - name: Install dependencies run: pip install --upgrade pip hatch uv - - name: Build and publish + - name: Build Package + run: hatch build --clean + - name: Publish to PyPI env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - hatch build --clean - twine upload dist/* + HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} + HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} + HATCH_INDEX_REPO: reactpy-django + run: hatch publish --yes From d663d12f0de59653d9c8b9c9c252fc565712c3ee Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 2 Dec 2024 02:25:48 -0800 Subject: [PATCH 07/19] Remove HATCH_INDEX_REPO from CI (#265) --- .github/workflows/publish-py.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index f3ade2f4..ae1ace7f 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -27,5 +27,4 @@ jobs: env: HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} - HATCH_INDEX_REPO: reactpy-django run: hatch publish --yes From c8b4ec08ab7c701a443e8fff8e734a5e76700857 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Mon, 2 Dec 2024 04:11:58 -0800 Subject: [PATCH 08/19] Synchronize CI with other reactive-python repos (#266) --- .github/workflows/publish-develop-docs.yml | 5 +++-- .github/workflows/publish-latest-docs.yml | 5 +++-- .github/workflows/{publish-py.yml => publish-python.yml} | 3 --- .github/workflows/test-python.yml | 2 +- docs/mkdocs.yml | 2 +- pyproject.toml | 2 +- 6 files changed, 9 insertions(+), 10 deletions(-) rename .github/workflows/{publish-py.yml => publish-python.yml} (76%) diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 11a7fa23..00172d4f 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -19,10 +19,11 @@ jobs: python-version: 3.x - name: Install dependencies run: pip install --upgrade pip hatch uv - - name: Publish Develop Docs + - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - hatch run docs:deploy_develop + - name: Publish Develop Docs + run: hatch run docs:deploy_develop concurrency: group: publish-docs diff --git a/.github/workflows/publish-latest-docs.yml b/.github/workflows/publish-latest-docs.yml index 697b10da..41ced54d 100644 --- a/.github/workflows/publish-latest-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -19,10 +19,11 @@ jobs: python-version: 3.x - name: Install dependencies run: pip install --upgrade pip hatch uv - - name: Publish ${{ github.event.release.name }} Docs + - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - hatch run docs:deploy_latest ${{ github.ref_name }} + - name: Publish ${{ github.event.release.name }} Docs + run: hatch run docs:deploy_latest ${{ github.ref_name }} concurrency: group: publish-docs diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-python.yml similarity index 76% rename from .github/workflows/publish-py.yml rename to .github/workflows/publish-python.yml index ae1ace7f..93fbc969 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-python.yml @@ -1,6 +1,3 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: Publish Python on: diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index 8faca864..cdc0b89e 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - python: + python-source: runs-on: ubuntu-latest strategy: matrix: diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 100b669b..bde5ac08 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -124,6 +124,6 @@ site_description: It's React, but in Python. Now with Django integration. copyright: '©
Reactive Python and affiliates. ' repo_url: https://github.com/reactive-python/reactpy-django site_url: https://reactive-python.github.io/reactpy-django -repo_name: ReactPy Django (GitHub) +repo_name: ReactPy Django edit_uri: edit/main/docs/src docs_dir: src diff --git a/pyproject.toml b/pyproject.toml index dbb94c21..6366a2f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -195,7 +195,7 @@ fix = ["cd src/js && bun install", "cd src/js && bun run format"] ######################### [tool.ruff] -extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", ".nox/*", "build/*"] +extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", "build/*"] line-length = 120 format.preview = true lint.extend-ignore = [ From 5596d8d050096bbf0135b3cf6237bb27dd1d7d29 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 10 Dec 2024 20:58:49 -0800 Subject: [PATCH 09/19] Django form component (#267) --- CHANGELOG.md | 6 +- README.md | 1 + docs/examples/html/django_form_bootstrap.html | 11 + docs/examples/python/django_form.py | 10 + docs/examples/python/django_form_bootstrap.py | 9 + docs/examples/python/django_form_class.py | 5 + .../examples/python/django_form_on_success.py | 21 + docs/examples/python/example/forms.py | 4 + docs/src/dictionary.txt | 1 + docs/src/reference/components.md | 106 +++- docs/src/reference/hooks.md | 4 +- docs/src/reference/settings.md | 16 +- pyproject.toml | 8 +- src/js/src/index.tsx | 63 +++ src/js/src/types.ts | 5 + src/reactpy_django/components.py | 69 ++- src/reactpy_django/config.py | 5 + src/reactpy_django/forms/__init__.py | 0 src/reactpy_django/forms/components.py | 141 +++++ src/reactpy_django/forms/transforms.py | 486 ++++++++++++++++++ src/reactpy_django/forms/utils.py | 31 ++ src/reactpy_django/hooks.py | 4 +- src/reactpy_django/types.py | 19 + src/reactpy_django/utils.py | 25 +- tests/test_app/components.py | 2 +- tests/test_app/forms/__init__.py | 0 tests/test_app/forms/components.py | 94 ++++ tests/test_app/forms/forms.py | 50 ++ tests/test_app/forms/urls.py | 11 + tests/test_app/forms/views.py | 21 + tests/test_app/middleware.py | 13 +- tests/test_app/settings_multi_db.py | 1 + tests/test_app/settings_single_db.py | 1 + tests/test_app/templates/admin/login.html | 6 + .../test_app/templates/async_event_form.html | 26 + tests/test_app/templates/bootstrap_form.html | 28 + .../templates/bootstrap_form_template.html | 6 + tests/test_app/templates/form.html | 26 + tests/test_app/templates/model_form.html | 26 + tests/test_app/templates/sync_event_form.html | 26 + tests/test_app/tests/test_components.py | 299 ++++++++++- tests/test_app/tests/utils.py | 6 + tests/test_app/urls.py | 1 + 43 files changed, 1664 insertions(+), 29 deletions(-) create mode 100644 docs/examples/html/django_form_bootstrap.html create mode 100644 docs/examples/python/django_form.py create mode 100644 docs/examples/python/django_form_bootstrap.py create mode 100644 docs/examples/python/django_form_class.py create mode 100644 docs/examples/python/django_form_on_success.py create mode 100644 docs/examples/python/example/forms.py create mode 100644 src/reactpy_django/forms/__init__.py create mode 100644 src/reactpy_django/forms/components.py create mode 100644 src/reactpy_django/forms/transforms.py create mode 100644 src/reactpy_django/forms/utils.py create mode 100644 tests/test_app/forms/__init__.py create mode 100644 tests/test_app/forms/components.py create mode 100644 tests/test_app/forms/forms.py create mode 100644 tests/test_app/forms/urls.py create mode 100644 tests/test_app/forms/views.py create mode 100644 tests/test_app/templates/admin/login.html create mode 100644 tests/test_app/templates/async_event_form.html create mode 100644 tests/test_app/templates/bootstrap_form.html create mode 100644 tests/test_app/templates/bootstrap_form_template.html create mode 100644 tests/test_app/templates/form.html create mode 100644 tests/test_app/templates/model_form.html create mode 100644 tests/test_app/templates/sync_event_form.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f8c848e4..f819750b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,15 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Added + +- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component! ## [5.1.1] - 2024-12-02 ### Fixed -- Fixed regression in v5.1.0 where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. +- Fixed regression from the previous release where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. ### Changed diff --git a/README.md b/README.md index 89d1fb11..f60e7a2d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - [Multiple root components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/) - [Cross-process communication/signaling](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) - [Django view to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#view-to-component) +- [Django form to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-form) - [Django static file access](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-css) - [Django database access](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-query) diff --git a/docs/examples/html/django_form_bootstrap.html b/docs/examples/html/django_form_bootstrap.html new file mode 100644 index 00000000..6aba84ca --- /dev/null +++ b/docs/examples/html/django_form_bootstrap.html @@ -0,0 +1,11 @@ +{% load django_bootstrap5 %} + + +{% bootstrap_css %} +{% bootstrap_javascript %} + + +{% bootstrap_form form %} +{% bootstrap_button button_type="submit" content="OK" %} +{% bootstrap_button button_type="reset" content="Reset" %} diff --git a/docs/examples/python/django_form.py b/docs/examples/python/django_form.py new file mode 100644 index 00000000..51960db1 --- /dev/null +++ b/docs/examples/python/django_form.py @@ -0,0 +1,10 @@ +from reactpy import component, html + +from example.forms import MyForm +from reactpy_django.components import django_form + + +@component +def basic_form(): + children = [html.input({"type": "submit"})] + return django_form(MyForm, bottom_children=children) diff --git a/docs/examples/python/django_form_bootstrap.py b/docs/examples/python/django_form_bootstrap.py new file mode 100644 index 00000000..449e1cc4 --- /dev/null +++ b/docs/examples/python/django_form_bootstrap.py @@ -0,0 +1,9 @@ +from reactpy import component + +from example.forms import MyForm +from reactpy_django.components import django_form + + +@component +def basic_form(): + return django_form(MyForm, form_template="bootstrap_form.html") diff --git a/docs/examples/python/django_form_class.py b/docs/examples/python/django_form_class.py new file mode 100644 index 00000000..e556295e --- /dev/null +++ b/docs/examples/python/django_form_class.py @@ -0,0 +1,5 @@ +from django import forms + + +class MyForm(forms.Form): + username = forms.CharField(label="Username") diff --git a/docs/examples/python/django_form_on_success.py b/docs/examples/python/django_form_on_success.py new file mode 100644 index 00000000..d8b6927c --- /dev/null +++ b/docs/examples/python/django_form_on_success.py @@ -0,0 +1,21 @@ +from reactpy import component, hooks, html +from reactpy_router import navigate + +from example.forms import MyForm +from reactpy_django.components import django_form +from reactpy_django.types import FormEventData + + +@component +def basic_form(): + submitted, set_submitted = hooks.use_state(False) + + def on_submit(event: FormEventData): + """This function will be called when the form is successfully submitted.""" + set_submitted(True) + + if submitted: + return navigate("/homepage") + + children = [html.input({"type": "submit"})] + return django_form(MyForm, on_success=on_submit, bottom_children=children) diff --git a/docs/examples/python/example/forms.py b/docs/examples/python/example/forms.py new file mode 100644 index 00000000..8d3eefc0 --- /dev/null +++ b/docs/examples/python/example/forms.py @@ -0,0 +1,4 @@ +from django import forms + + +class MyForm(forms.Form): ... diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 1b4ce080..d2ff722d 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -48,3 +48,4 @@ linter linters linting formatters +bootstrap_form diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 4186af42..26feda67 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -156,7 +156,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ??? info "Existing limitations" - There are currently several limitations of using `#!python view_to_component` that may be resolved in a future version. + There are currently several limitations of using `#!python view_to_component` that will be [resolved in a future version](https://github.com/reactive-python/reactpy-django/issues/269). - Requires manual intervention to change HTTP methods to anything other than `GET`. - ReactPy events cannot conveniently be attached to converted view HTML. @@ -292,12 +292,12 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ??? info "Existing limitations" - There are currently several limitations of using `#!python view_to_iframe` that may be resolved in a future version. + There are currently several limitations of using `#!python view_to_iframe` which may be [resolved in a future version](https://github.com/reactive-python/reactpy-django/issues/268). - No built-in method of signalling events back to the parent component. - - All provided `#!python *args` and `#!python *kwargs` must be serializable values, since they are encoded into the URL. + - All provided `#!python args` and `#!python kwargs` must be serializable values, since they are encoded into the URL. - The `#!python iframe` will always load **after** the parent component. - - CSS styling for `#!python iframe` elements tends to be awkward/difficult. + - CSS styling for `#!python iframe` elements tends to be awkward. ??? question "How do I use this for Class Based Views?" @@ -381,6 +381,104 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. --- +## Django Form + +Automatically convert a Django form into a ReactPy component. + +Compatible with both [standard Django forms](https://docs.djangoproject.com/en/stable/topics/forms/#building-a-form) and [ModelForms](https://docs.djangoproject.com/en/stable/topics/forms/modelforms/). + +=== "components.py" + + ```python + {% include "../../examples/python/django_form.py" %} + ``` + +=== "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python form` | `#!python type[Form | ModelForm]` | The form to convert. | N/A | + | `#!python on_success` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when the form is successfully submitted. | `#!python None` | + | `#!python on_error` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when the form submission fails. | `#!python None` | + | `#!python on_receive_data` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called before newly submitted form data is rendered. | `#!python None` | + | `#!python on_change` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when a form field is modified by the user. | `#!python None` | + | `#!python auto_save` | `#!python bool` | If `#!python True`, the form will automatically call `#!python save` on successful submission of a `#!python ModelForm`. This has no effect on regular `#!python Form` instances. | `#!python True` | + | `#!python extra_props` | `#!python dict[str, Any] | None` | Additional properties to add to the `#!html
` element. | `#!python None` | + | `#!python extra_transforms` | `#!python Sequence[Callable[[VdomDict], Any]] | None` | A list of functions that transforms the newly generated VDOM. The functions will be repeatedly called on each VDOM node. | `#!python None` | + | `#!python form_template` | `#!python str | None` | The template to use for the form. If `#!python None`, Django's default template is used. | `#!python None` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run event callback functions in thread sensitive mode. This mode only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python top_children` | `#!python Sequence[Any]` | Additional elements to add to the top of the form. | `#!python tuple` | + | `#!python bottom_children` | `#!python Sequence[Any]` | Additional elements to add to the bottom of the form. | `#!python tuple` | + | `#!python key` | `#!python Key | None` | A key to uniquely identify this component which is unique amongst a component's immediate siblings. | `#!python None` | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python Component` | A ReactPy component. | + +??? info "Existing limitations" + + The following fields are currently incompatible with `#!python django_form`: `#!python FileField`, `#!python ImageField`, `#!python SplitDateTimeField`, and `#!python MultiValueField`. + + Compatibility for these fields will be [added in a future version](https://github.com/reactive-python/reactpy-django/issues/270). + +??? question "How do I style these forms with Bootstrap?" + + You can style these forms by using a form styling library. In the example below, it is assumed that you have already installed [`django-bootstrap5`](https://pypi.org/project/django-bootstrap5/). + + After installing a form styling library, you can then provide ReactPy a custom `#!python form_template` parameter. This parameter allows you to specify a custom HTML template to use to render this the form. + + Note that you can also set a global default for `form_template` by using [`settings.py:REACTPY_DEFAULT_FORM_TEMPLATE`](./settings.md#reactpy_default_form_template). + + === "components.py" + + ```python + {% include "../../examples/python/django_form_bootstrap.py" %} + ``` + + === "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + + === "bootstrap_form.html" + + ```jinja + {% include "../../examples/html/django_form_bootstrap.html" %} + ``` + +??? question "How do I handle form success/errors?" + + You can react to form state by providing a callback function to any of the following parameters: `#!python on_success`, `#!python on_error`, `#!python on_receive_data`, and `#!python on_change`. + + These functions will be called when the form is submitted. + + In the example below, we will use the `#!python on_success` parameter to change the URL upon successful submission. + + === "components.py" + + ```python + {% include "../../examples/python/django_form_on_success.py" %} + ``` + + === "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + +--- + ## Django CSS Allows you to defer loading a CSS stylesheet until a component begins rendering. This stylesheet must be stored within [Django's static files](https://docs.djangoproject.com/en/stable/howto/static-files/). diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 65bf1727..5826a7b0 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -46,7 +46,7 @@ Query functions can be sync or async. | --- | --- | --- | --- | | `#!python query` | `#!python Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred]` | A function that executes a query and returns some data. | N/A | | `#!python kwargs` | `#!python dict[str, Any] | None` | Keyword arguments to passed into the `#!python query` function. | `#!python None` | - | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This mode only applies to sync query functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This setting only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | | `#!python postprocessor` | `#!python AsyncPostprocessor | SyncPostprocessor | None` | A callable that processes the query `#!python data` before it is returned. The first argument of postprocessor function must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs`. This postprocessor function must return the modified `#!python data`. | `#!python None` | | `#!python postprocessor_kwargs` | `#!python dict[str, Any] | None` | Keyworded arguments passed into the `#!python postprocessor` function. | `#!python None` | @@ -188,7 +188,7 @@ Mutation functions can be sync or async. | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python mutation` | `#!python Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | - | `#!python thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This mode only applies to sync mutation functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This setting only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function you provide to your `#!python use_query` hook) or a sequence of query functions that need a `#!python refetch` if the mutation succeeds. This is useful for refreshing data after a mutation has been performed. | `#!python None` | **Returns** diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 6b1c78c4..94c9d8b6 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -34,7 +34,7 @@ The prefix used for all ReactPy WebSocket and HTTP URLs. **Example Value(s):** `#!python "example_project.postprocessor"`, `#!python None` -Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. +Dotted path to the default postprocessor function used by the [`use_query`](./hooks.md#use-query) hook. Postprocessor functions can be async or sync. Here is an example of a sync postprocessor function: @@ -48,6 +48,18 @@ Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to disable --- +### `#!python REACTPY_DEFAULT_FORM_TEMPLATE` + +**Default:** `#!python None` + +**Example Value(s):** `#!python "my_templates/bootstrap_form.html"` + +File path to the default form template used by the [`django_form`](./components.md#django-form) component. + +This file path must be valid to Django's [template finder](https://docs.djangoproject.com/en/stable/topics/templates/#support-for-template-engines). + +--- + ### `#!python REACTPY_AUTH_BACKEND` **Default:** `#!python "django.contrib.auth.backends.ModelBackend"` @@ -131,7 +143,7 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne). Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is typically a relatively slow operation). -This setting is currently experimental, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient. +This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place even though a single render of the parent component would have been sufficient. --- diff --git a/pyproject.toml b/pyproject.toml index 6366a2f5..c25929bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ extra-dependencies = [ "twisted", "tblib", "servestatic", + "django-bootstrap5", ] matrix-name-format = "{variable}-{value}" @@ -140,7 +141,12 @@ pythonpath = [".", "tests/"] ################################ [tool.hatch.envs.django] -extra-dependencies = ["channels[daphne]>=4.0.0", "twisted", "servestatic"] +extra-dependencies = [ + "channels[daphne]>=4.0.0", + "twisted", + "servestatic", + "django-bootstrap5", +] [tool.hatch.envs.django.scripts] runserver = [ diff --git a/src/js/src/index.tsx b/src/js/src/index.tsx index 51a387f3..742ca79f 100644 --- a/src/js/src/index.tsx +++ b/src/js/src/index.tsx @@ -2,6 +2,21 @@ import { ReactPyDjangoClient } from "./client"; import React from "react"; import ReactDOM from "react-dom"; import { Layout } from "@reactpy/client/src/components"; +import { DjangoFormProps } from "./types"; + +/** + * Interface used to bind a ReactPy node to React. + */ +export function bind(node) { + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} export function mountComponent( mountElement: HTMLElement, @@ -79,3 +94,51 @@ export function mountComponent( // Start rendering the component ReactDOM.render(, client.mountElement); } + +export function DjangoForm({ + onSubmitCallback, + formId, +}: DjangoFormProps): null { + React.useEffect(() => { + const form = document.getElementById(formId) as HTMLFormElement; + + // Submission event function + const onSubmitEvent = (event) => { + event.preventDefault(); + const formData = new FormData(form); + + // Convert the FormData object to a plain object by iterating through it + // If duplicate keys are present, convert the value into an array of values + const entries = formData.entries(); + const formDataArray = Array.from(entries); + const formDataObject = formDataArray.reduce((acc, [key, value]) => { + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value); + } else { + acc[key] = [acc[key], value]; + } + } else { + acc[key] = value; + } + return acc; + }, {}); + + onSubmitCallback(formDataObject); + }; + + // Bind the event listener + if (form) { + form.addEventListener("submit", onSubmitEvent); + } + + // Unbind the event listener when the component dismounts + return () => { + if (form) { + form.removeEventListener("submit", onSubmitEvent); + } + }; + }, []); + + return null; +} diff --git a/src/js/src/types.ts b/src/js/src/types.ts index eea8a866..79b06375 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -18,3 +18,8 @@ export type ReactPyDjangoClientProps = { prerenderElement: HTMLElement | null; offlineElement: HTMLElement | null; }; + +export interface DjangoFormProps { + onSubmitCallback: (data: Object) => void; + formId: string; +} diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index d9ed0e6a..7e821c1c 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,3 +1,5 @@ +"""This file contains Django related components. Most of these components utilize wrappers to fix type hints.""" + from __future__ import annotations import json @@ -14,6 +16,7 @@ from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError +from reactpy_django.forms.components import _django_form from reactpy_django.html import pyscript from reactpy_django.utils import ( generate_obj_name, @@ -26,8 +29,11 @@ if TYPE_CHECKING: from collections.abc import Sequence + from django.forms import Form, ModelForm from django.views import View + from reactpy_django.types import AsyncFormEvent, SyncFormEvent + def view_to_component( view: Callable | View | str, @@ -114,6 +120,64 @@ def django_js(static_path: str, key: Key | None = None): return _django_js(static_path=static_path, key=key) +def django_form( + form: type[Form | ModelForm], + *, + on_success: AsyncFormEvent | SyncFormEvent | None = None, + on_error: AsyncFormEvent | SyncFormEvent | None = None, + on_receive_data: AsyncFormEvent | SyncFormEvent | None = None, + on_change: AsyncFormEvent | SyncFormEvent | None = None, + auto_save: bool = True, + extra_props: dict[str, Any] | None = None, + extra_transforms: Sequence[Callable[[VdomDict], Any]] | None = None, + form_template: str | None = None, + thread_sensitive: bool = True, + top_children: Sequence[Any] = (), + bottom_children: Sequence[Any] = (), + key: Key | None = None, +): + """Converts a Django form to a ReactPy component. + + Args: + form: The form to convert. + + Keyword Args: + on_success: A callback function that is called when the form is successfully submitted. + on_error: A callback function that is called when the form submission fails. + on_receive_data: A callback function that is called before newly submitted form data is rendered. + on_change: A callback function that is called when a form field is modified by the user. + auto_save: If `True`, the form will automatically call `save` on successful submission of \ + a `ModelForm`. This has no effect on regular `Form` instances. + extra_props: Additional properties to add to the `html.form` element. + extra_transforms: A list of functions that transforms the newly generated VDOM. \ + The functions will be repeatedly called on each VDOM node. + form_template: The template to use for the form. If `None`, Django's default template is used. + thread_sensitive: Whether to run event callback functions in thread sensitive mode. \ + This mode only applies to sync functions, and is turned on by default due to Django \ + ORM limitations. + top_children: Additional elements to add to the top of the form. + bottom_children: Additional elements to add to the bottom of the form. + key: A key to uniquely identify this component which is unique amongst a component's \ + immediate siblings. + """ + + return _django_form( + form=form, + on_success=on_success, + on_error=on_error, + on_receive_data=on_receive_data, + on_change=on_change, + auto_save=auto_save, + extra_props=extra_props or {}, + extra_transforms=extra_transforms or [], + form_template=form_template, + thread_sensitive=thread_sensitive, + top_children=top_children, + bottom_children=bottom_children, + key=key, + ) + + def pyscript_component( *file_paths: str, initial: str | VdomDict | ComponentType = "", @@ -238,6 +302,8 @@ def _cached_static_contents(static_path: str) -> str: if not abs_path: msg = f"Could not find static file {static_path} within Django's static files." raise FileNotFoundError(msg) + if isinstance(abs_path, (list, tuple)): + abs_path = abs_path[0] # Fetch the file from cache, if available last_modified_time = os.stat(abs_path).st_mtime @@ -259,7 +325,8 @@ def _pyscript_component( root: str = "root", ): rendered, set_rendered = hooks.use_state(False) - uuid = uuid4().hex.replace("-", "") + uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) + uuid = uuid_ref.current initial = vdom_or_component_to_string(initial, uuid=uuid) executor = render_pyscript_template(file_paths, uuid, root) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 3f46c48b..f4434c4f 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -126,3 +126,8 @@ "REACTPY_CLEAN_USER_DATA", True, ) +REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr( + settings, + "REACTPY_DEFAULT_FORM_TEMPLATE", + None, +) diff --git a/src/reactpy_django/forms/__init__.py b/src/reactpy_django/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py new file mode 100644 index 00000000..d19c0bbb --- /dev/null +++ b/src/reactpy_django/forms/components.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Union, cast +from uuid import uuid4 + +from channels.db import database_sync_to_async +from django.forms import Form, ModelForm +from reactpy import component, hooks, html, utils +from reactpy.core.events import event +from reactpy.web import export, module_from_file + +from reactpy_django.forms.transforms import ( + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + infer_key_from_attributes, + intercept_anchor_links, + set_value_prop_on_select_element, + transform_value_prop_on_input_element, +) +from reactpy_django.forms.utils import convert_boolean_fields, convert_multiple_choice_fields +from reactpy_django.types import AsyncFormEvent, FormEventData, SyncFormEvent +from reactpy_django.utils import ensure_async + +if TYPE_CHECKING: + from collections.abc import Sequence + + from reactpy.core.types import VdomDict + +DjangoForm = export( + module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "client.js"), + ("DjangoForm"), +) + + +@component +def _django_form( + form: type[Form | ModelForm], + on_success: AsyncFormEvent | SyncFormEvent | None, + on_error: AsyncFormEvent | SyncFormEvent | None, + on_receive_data: AsyncFormEvent | SyncFormEvent | None, + on_change: AsyncFormEvent | SyncFormEvent | None, + auto_save: bool, + extra_props: dict, + extra_transforms: Sequence[Callable[[VdomDict], Any]], + form_template: str | None, + thread_sensitive: bool, + top_children: Sequence, + bottom_children: Sequence, +): + from reactpy_django import config + + uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) + top_children_count = hooks.use_ref(len(top_children)) + bottom_children_count = hooks.use_ref(len(bottom_children)) + submitted_data, set_submitted_data = hooks.use_state({} or None) + rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None)) + uuid = uuid_ref.current + + # Validate the provided arguments + if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current: + msg = "Dynamically changing the number of top or bottom children is not allowed." + raise ValueError(msg) + if not isinstance(form, (type(Form), type(ModelForm))): + msg = ( + "The provided form must be an uninitialized Django Form. " + "Do NOT initialize your form by calling it (ex. `MyForm()`)." + ) + raise TypeError(msg) + + # Initialize the form with the provided data + initialized_form = form(data=submitted_data) + form_event = FormEventData( + form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data + ) + + # Validate and render the form + @hooks.use_effect(dependencies=[str(submitted_data)]) + async def render_form(): + """Forms must be rendered in an async loop to allow database fields to execute.""" + if submitted_data: + await database_sync_to_async(initialized_form.full_clean)() + success = not initialized_form.errors.as_data() + if success and on_success: + await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) + if not success and on_error: + await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event) + if success and auto_save and isinstance(initialized_form, ModelForm): + await ensure_async(initialized_form.save)() + set_submitted_data(None) + + set_rendered_form( + await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE) + ) + + async def on_submit_callback(new_data: dict[str, Any]): + """Callback function provided directly to the client side listener. This is responsible for transmitting + the submitted form data to the server for processing.""" + convert_multiple_choice_fields(new_data, initialized_form) + convert_boolean_fields(new_data, initialized_form) + + if on_receive_data: + new_form_event = FormEventData( + form=initialized_form, submitted_data=new_data, set_submitted_data=set_submitted_data + ) + await ensure_async(on_receive_data, thread_sensitive=thread_sensitive)(new_form_event) + + if submitted_data != new_data: + set_submitted_data(new_data) + + async def _on_change(_event): + """Event that exist solely to allow the user to detect form changes.""" + if on_change: + await ensure_async(on_change, thread_sensitive=thread_sensitive)(form_event) + + if not rendered_form: + return None + + return html.form( + extra_props + | { + "id": f"reactpy-{uuid}", + # Intercept the form submission to prevent the browser from navigating + "onSubmit": event(lambda _: None, prevent_default=True), + "onChange": _on_change, + }, + DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}), + *top_children, + utils.html_to_vdom( + rendered_form, + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + set_value_prop_on_select_element, + transform_value_prop_on_input_element, + intercept_anchor_links, + infer_key_from_attributes, + *extra_transforms, + strict=False, + ), + *bottom_children, + ) diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py new file mode 100644 index 00000000..2d527209 --- /dev/null +++ b/src/reactpy_django/forms/transforms.py @@ -0,0 +1,486 @@ +# TODO: Almost everything in this module should be moved to `reactpy.utils._mutate_vdom()`. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from reactpy.core.events import EventHandler, to_event_handler_function + +if TYPE_CHECKING: + from reactpy.core.types import VdomDict + + +def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict: + """Transformation that standardizes the prop names to be used in the component.""" + # On each node, replace the 'attributes' key names with the standardized names. + if "attributes" in vdom_tree: + vdom_tree["attributes"] = {_normalize_prop_name(k): v for k, v in vdom_tree["attributes"].items()} + + return vdom_tree + + +def convert_textarea_children_to_prop(vdom_tree: VdomDict) -> VdomDict: + """Transformation that converts the text content of a