././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6658857 guidata-3.13.4/0000755000175100017510000000000015114075015012665 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/LICENSE0000644000175100017510000000277615114075001013701 0ustar00runnerrunnerBSD 3-Clause License Copyright (c) 2023, CEA-Codra, Pierre Raybaut. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/MANIFEST.in0000644000175100017510000000006415114075001014416 0ustar00runnerrunnergraft doc include *.desktop include requirements.txt././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6658857 guidata-3.13.4/PKG-INFO0000644000175100017510000001626015114075015013767 0ustar00runnerrunnerMetadata-Version: 2.4 Name: guidata Version: 3.13.4 Summary: Automatic GUI generation for easy dataset editing and display Author-email: Codra License: BSD 3-Clause License Copyright (c) 2023, CEA-Codra, Pierre Raybaut. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Project-URL: Homepage, https://github.com/PlotPyStack/guidata/ Project-URL: Documentation, https://guidata.readthedocs.io/en/latest/ Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Education Classifier: Intended Audience :: Science/Research Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows :: Windows 7 Classifier: Operating System :: Microsoft :: Windows :: Windows 8 Classifier: Operating System :: Microsoft :: Windows :: Windows 10 Classifier: Operating System :: Microsoft :: Windows :: Windows 11 Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Scientific/Engineering Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Software Development :: User Interfaces Classifier: Topic :: Software Development :: Widget Sets Classifier: Topic :: Utilities Requires-Python: <4,>=3.9 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: h5py>=3.6 Requires-Dist: NumPy>=1.22 Requires-Dist: QtPy>=1.9 Requires-Dist: requests Requires-Dist: tomli; python_version < "3.11" Provides-Extra: qt Requires-Dist: PyQt5>5.15.5; extra == "qt" Provides-Extra: dev Requires-Dist: build; extra == "dev" Requires-Dist: babel; extra == "dev" Requires-Dist: Coverage; extra == "dev" Requires-Dist: pylint; extra == "dev" Requires-Dist: ruff; extra == "dev" Requires-Dist: pre-commit; extra == "dev" Provides-Extra: doc Requires-Dist: PyQt5; extra == "doc" Requires-Dist: pillow; extra == "doc" Requires-Dist: pandas; extra == "doc" Requires-Dist: sphinx; extra == "doc" Requires-Dist: myst_parser; extra == "doc" Requires-Dist: sphinx-copybutton; extra == "doc" Requires-Dist: sphinx_qt_documentation; extra == "doc" Requires-Dist: python-docs-theme; extra == "doc" Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-xvfb; extra == "test" Dynamic: license-file # guidata: Automatic GUI generation for easy dataset editing and display with Python [![pypi version](https://img.shields.io/pypi/v/guidata.svg)](https://pypi.org/project/guidata/) [![PyPI status](https://img.shields.io/pypi/status/guidata.svg)](https://github.com/PlotPyStack/guidata/) [![PyPI pyversions](https://img.shields.io/pypi/pyversions/guidata.svg)](https://pypi.python.org/pypi/guidata/) [![download count](https://img.shields.io/conda/dn/conda-forge/guidata.svg)](https://www.anaconda.com/download/) ℹ️ Created in 2009 by [Pierre Raybaut](https://github.com/PierreRaybaut) and maintained by the [PlotPyStack](https://github.com/PlotPyStack) organization. ## Overview The `guidata` package is a Python library generating Qt graphical user interfaces. It is part of the [PlotPyStack](https://github.com/PlotPyStack) project, aiming at providing a unified framework for creating scientific GUIs with Python and Qt. Simple example of `guidata` datasets embedded in an application window: ![Example](https://raw.githubusercontent.com/PlotPyStack/guidata/master/doc/images/screenshots/editgroupbox.png) See [documentation](https://guidata.readthedocs.io/en/latest/) for more details on the library. Copyrights and licensing: * Copyright © 2023 [CEA](https://www.cea.fr), [Codra](https://codra.net/), [Pierre Raybaut](https://github.com/PierreRaybaut). * Licensed under the terms of the BSD 3-Clause (see [LICENSE](https://github.com/PlotPyStack/guidata/blob/master/LICENSE)). ## Features Based on the Qt library, `guidata` is a Python library generating graphical user interfaces for easy dataset editing and display. It also provides helpers and application development tools for Qt (PyQt5, PySide2, PyQt6, PySide6). Generate GUIs to edit and display all kind of objects regrouped in datasets: * Integers, floats, strings * Lists (single/multiple choices) * Dictionaries * `ndarrays` (NumPy's N-dimensional arrays) * Etc. Save and load datasets to/from HDF5, JSON or INI files. Application development tools: * Data model (internal data structure, serialization, etc.) * Configuration management * Internationalization (`gettext`) * Deployment tools * HDF5, JSON and INI I/O helpers * Qt helpers * Ready-to-use Qt widgets: Python console, source code editor, array editor, etc. ## Dependencies and installation ### Supported Qt versions and bindings The whole PlotPyStack set of libraries relies on the [Qt](https://doc.qt.io/) GUI toolkit, thanks to [QtPy](https://pypi.org/project/QtPy/), an abstraction layer which allows to use the same API to interact with different Python-to-Qt bindings (PyQt5, PyQt6, PySide2, PySide6). Compatibility table: | guidata version | PyQt5 | PyQt6 | PySide2 | PySide6 | |----------------|-------|-------|---------|---------| | 3.0-3.5 | ✅ | ⚠️ | ❌ | ⚠️ | | Latest | ✅ | ✅ | ❌ | ✅ | ### Other dependencies and installation See [Installation](https://guidata.readthedocs.io/en/latest/installation.html) section in the documentation for more details. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/README.md0000644000175100017510000000612115114075001014137 0ustar00runnerrunner# guidata: Automatic GUI generation for easy dataset editing and display with Python [![pypi version](https://img.shields.io/pypi/v/guidata.svg)](https://pypi.org/project/guidata/) [![PyPI status](https://img.shields.io/pypi/status/guidata.svg)](https://github.com/PlotPyStack/guidata/) [![PyPI pyversions](https://img.shields.io/pypi/pyversions/guidata.svg)](https://pypi.python.org/pypi/guidata/) [![download count](https://img.shields.io/conda/dn/conda-forge/guidata.svg)](https://www.anaconda.com/download/) ℹ️ Created in 2009 by [Pierre Raybaut](https://github.com/PierreRaybaut) and maintained by the [PlotPyStack](https://github.com/PlotPyStack) organization. ## Overview The `guidata` package is a Python library generating Qt graphical user interfaces. It is part of the [PlotPyStack](https://github.com/PlotPyStack) project, aiming at providing a unified framework for creating scientific GUIs with Python and Qt. Simple example of `guidata` datasets embedded in an application window: ![Example](https://raw.githubusercontent.com/PlotPyStack/guidata/master/doc/images/screenshots/editgroupbox.png) See [documentation](https://guidata.readthedocs.io/en/latest/) for more details on the library. Copyrights and licensing: * Copyright © 2023 [CEA](https://www.cea.fr), [Codra](https://codra.net/), [Pierre Raybaut](https://github.com/PierreRaybaut). * Licensed under the terms of the BSD 3-Clause (see [LICENSE](https://github.com/PlotPyStack/guidata/blob/master/LICENSE)). ## Features Based on the Qt library, `guidata` is a Python library generating graphical user interfaces for easy dataset editing and display. It also provides helpers and application development tools for Qt (PyQt5, PySide2, PyQt6, PySide6). Generate GUIs to edit and display all kind of objects regrouped in datasets: * Integers, floats, strings * Lists (single/multiple choices) * Dictionaries * `ndarrays` (NumPy's N-dimensional arrays) * Etc. Save and load datasets to/from HDF5, JSON or INI files. Application development tools: * Data model (internal data structure, serialization, etc.) * Configuration management * Internationalization (`gettext`) * Deployment tools * HDF5, JSON and INI I/O helpers * Qt helpers * Ready-to-use Qt widgets: Python console, source code editor, array editor, etc. ## Dependencies and installation ### Supported Qt versions and bindings The whole PlotPyStack set of libraries relies on the [Qt](https://doc.qt.io/) GUI toolkit, thanks to [QtPy](https://pypi.org/project/QtPy/), an abstraction layer which allows to use the same API to interact with different Python-to-Qt bindings (PyQt5, PyQt6, PySide2, PySide6). Compatibility table: | guidata version | PyQt5 | PyQt6 | PySide2 | PySide6 | |----------------|-------|-------|---------|---------| | 3.0-3.5 | ✅ | ⚠️ | ❌ | ⚠️ | | Latest | ✅ | ✅ | ❌ | ✅ | ### Other dependencies and installation See [Installation](https://guidata.readthedocs.io/en/latest/installation.html) section in the documentation for more details. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6198857 guidata-3.13.4/doc/0000755000175100017510000000000015114075015013432 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6198857 guidata-3.13.4/doc/_static/0000755000175100017510000000000015114075015015060 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/_static/favicon.ico0000644000175100017510000027613715114075001017214 0ustar00runnerrunner (f h  /~  F00 %V( N N tԽ2! Y?ڻ!ʻ߶M򼼼.Lûi,޻G𻻻Ūf溺6 wһŀ1廻.E񻻻h7 ϻ4廻ʽ2\JM#ͻ\R Tﻻ_c8IqԻߺw&226'ĹB,^껻-˻_cgltkw{>{>z=z=y=y{>z=z=y=y{>z=z=y=y{>z=z=y=y{>z=z=y=y{>z=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=yz=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y{>{>z=z=y=y<}EOZeqü~}||O~}|~}Ϊ ڻ~+3ras ͹a :軻!û񻻻ak򻻻𼼼cM̻DʻNwܻļf B⻻ʺ~!"o󻻻f DyỻԺk6K{任ݺr? )Km׽껻󻻻޺ɺ}gE#""-3+""????????(  O=кa^2EUv:u;idz hv:s7o5l3IJnv:s7o5k2΃HFC}@z=v:s7o5k2ﻻ`p}@z=v:s7o5k2~ű_n}@z=v:s7o5yH{GC}@z=v:s7}K~ɾ?ø=^a1.??(0 NN5U๹,Q λ_껻am軻&ֻS~FW~p;fxxxxxxx)r$hТiڟ9B(Jx'QM!|5 d x哢7V!"DvPO W@򑺺N;kfl޼4H\ "4}R/34Mu;L.q0܈w53P  R{ yMMIfby>DDi3A"znݺ-N;c93_Jp" @ poN*Hhjjap(D=fQ4vڙPPZNoNBzn)7ׯ_ig&CApk|Cb 73@D?nujhllTTT_t٢D049KhBM05@$Ih @'iC ks(A "%A22&avdB'1錆Th{_%%%@xo:L5y$| #aht/u6-Wf m 3:RTZ`ZG2c0a0c`0c0Afʖ-[~z-HuqK?"8x FLS3U0}D@bЃDI/$| 'H$ӎhjj*3 VDvi( H%~Fk 3mc)Cb?Co‡.'K_wW@,n0i_&P2ʃC>3A3%Ӷ=MS &M3Gw>f'pT<z'/2¥C,(FZ) ]&`w-0ݦib !a%8pcV9.5*a1śY }>NQn˘}h)E{o)\KDA'.Z8*&PQƨ &<',pJѢ֟'C% ѧ#yx<>.'9BU!ƜYC_S|Y'=h xW]Hqg:~0˛bˈ0eY &d}`v Tm&R^휅H8[Mx[[[{$ʋ3=kzUjkn %ګq;T"u@3 ^Q@ \ګqLއtI)YWW\ gp뭷+;>+'<)fWJٟ(8mI#Yp{w/BC0Nu3_뫓*Esp'q*ng {) G;|$R-+L"H$݆m'=>":,l!S(OF^ G:آ|U L".gQ[` "PX@ ]XSd0B:#Bj )sX"D"G2hPKK]> ,P\pXS0?ˇEWrLRJyM]]&;&---_pK^`\ K ǟpۘϕXS0P:3Ղ.)ںWEGn %eAst;~SJQp`ʃrݺ 6Y(xskY}JmgM%v 5=prv]]"?騕aÖ<۴ib"z9,~/-Բ+gQ\v6xTqa߱b`uV4_ KiW4"ʂ4PL@޸/(bNwi2o߹ t\zCW}Ea5Rlً]ޖBlڴiN---psnNhK\Z@bBx+XXyvRqק,~d7\ŜYEG^1a\0o7ٓ WBONu) @&@{ š^T}_1YR}g]"שVTb;n=ar/J+fK=&=`x}W̵ lp@2 KMl1`0xy :\0}vG:N}[DD~M.j =I_x7Id}$З$$ҌIHqZ!sCy:6AgH ^#4A/0J}A'QY¨HTJT0TX1U$StכNDPCCY L*hnn^*Sz*QvYn`'D/d?Kh##% rߧ^xYs`5>z $A2F/QU:oaĂ oFC錎wA"凍|&~l_N.X9#O n¾vBl|˶ RB!B&VXZebn9 -ЛݫaJB3}Ul+WcϘ2si#\0o'vZnY^L_:k[˫+D0} ^ϟ#Xr} M;X>5tؒx>}@KK˯Ϯ[ԇٚS–[v$VEvέ67oLm' 'ض%l☮K'Z|tBn4Y…pM` /,wrsg}{[J&{*JL$Qꓣ;zCo@g6h4zxLX&T5AX lpݽ:բᅑ¯p=}?~"w^?a)/x D5p8FMMM^0.b:MDm@_Y m:+`O{ n8=z|<6*nq'M!,H"Mc~㑗ĩah~'z=,qI^c揍}\B| 6lZ/pӯtv*Nj^}?㡿j5R?˞jll9 c&to\2y!|~KMGvs~͋ƞ6=F Cm5ވ^1Ηl TW mGN{K>{Z{xD`QAL];0J̎#-u}W>.7rG|*= Few5Z{QT(_a_Æn脹cy 4FH(G6w5Ϋ8de1V65[3ʓnNYCkP؁SlJ+!rֻ2li 0!T1q3yЮZ]-`sǽki*mʍ7uJ˯PbzYPCiн,~!+N*CBy=$8"l?_>⺉bn$hVICqթ#ohNgϒ+̮:Hn@(t[oy/[o.j4ud?UU\1y4m>4|vYZ gꪫVYnjeȽOC@ BP33݄Wݍx˲MUڎǗY5RioùpFQC&7e$֝'%&JhB”"m"#b A(/qGkX;DJՌߒu?AE{vS<,yއ^6;sH߃E;t!0 XeձMs띫L|=c $ 75zOဓWH1Y~ro%]6/@JyURw ك .ϭ3W<-A,pSb"Cεw BTdnskhy-Jת8wPP"Pc,`p<-^-KUvonĆw)T0zM ;/"'wyqۙ*I@0 0Vh뤽~ 2>Wkp{.(,"xءtbD~ WY5 fm׹r o;>&R C:3} &/hU+~{c֣= ӜR?%^oPnHW:3m0an6NG]qS9O @!ngr!D@Y5Slc% êvL+Ǻ+cǝn_׀[ ,TQy~ǑƼfB\N #o ד2o04[浇 ݝ=` c (9UlC%0)vδsCwo"mt͖H[ ׮niKH83L1Eu_14a,O;v_8A;)vlKV7t9P%)Bؐ ԪdlnC{@~a} 81` g=p9ڗ?x@2kx~nܕ5<{p3?7/3t &@ }&jXPX#!t:dLÐ-{mE[`ۑ)pfWW1MTa_(rEmsE9֓_0f"!,]l$޷UGvU/Dw Dt6§li ܵEO_ɯ"<2t>;Ŵcv |!ڪ !s;_"l@J̎7 |37,utn@8sFq,Ud8kƋbyKkX0,Q $㽄WN&v";j(*L#mvD|s]ӕ 4дcoNȬ O)79&xUKoEq#b e}e"M^[;}ܽBQhGҦAˋK[H 'T(n:37Qrðާأ"'_@O3O "[KR7j.DiV=/@N-[1MsH.t]f:W* @o"iuuuہـR{{JK{;+0ӌ!* "U;xG#`{,;v4xK/taϑ >(@Q$rDw$ Oqʚ~";R$|{eDO i>(SiƉ(dn f5M{ {)oWZ>cG5`QV(  9`V6/{(o 3իGn #X`يF @__}>oπHo//~Qvڄێ4amit.AEї\d3x8_﷥A(ބHpa7Zjի[nY=jgOT;v<Ʃ˙_۲ɱ>Q<;}W~32cϏܩ%. ;XlG555&d|x7^Yr ~i;Nj\8H=ǗZ^3vOփ EBrb pȿ.]o?& /ρ@Y;N&c2^;`9BzR6͈2uVSSu/1uB[3hT3;"J<gggmg`͚5}>_]gǞ$Sj!%mqd 9߲ gm?k CIIuB[Jm:رG P\Rk㋐HY̱ڇ'ݳ UN?v]nY^T(܈P{߶ت )ұcn4׎ ;٣ +fm2, H _[~I;/544]ض+Vv!˖Ц<̎ ?wäG\uUw !^|I/T*:9hɞ  (@k-7aJ.v0!G @MMM2N_`󑧊lcT:`+m}*Š\]`8Ύxui8zR/;vn˲XPvsDp8N*9h423@Ns $gvdp bJ;GkOB"7\1loBs] DV"rN&c/x Lv8):M}׌iڻY VaqmssBgE :3D%xų{CѷȼD|3(㩗nf:J&fn7]9_Sew7Hphsޞ fGG~H2*W}|POo |fuaF"gNND#t~ xeROCE2|n8b >'I'LrbmccVQQq3_IkX<X4k|׳m;lW(C^=.NmuW lK<&Fϣw|ccO}ym&x Cyl+# @8r }HV3t (fx+P<٥rRʏ S19~<*3I' X4ZЁb@m%/ 1:M p|?޺srZMf?#8br+ M$ϑyF|L13\tAx^+CeIof\h:Be=FNB̙XX-Q;s`Wg}43 %%|QAm6!ĵ֭۝M,heAfր%Ofa\8'd2 6 U---wDi_1tƼJyQ%jf}TPӰza0+w-h4`>:]\'Zd~}̮0rg0 A[tb"i`X,i"888B&fWHT2в$ߋ{.q ˫|xT G4ekߘ"NjM6EJy?ےM!+L*@#P]Ya `,jΫŸKumq҉zjM~e2KLT3,70,K?vjw=8w˫X![d2e7`h`(:}y`ҀDE2ƾϿd]R@eP /Ν^tyEqC>)!i_@Y`H ʃd&? 9Qw[ R5< .s , Xұ8}νM]0nJX6o\H$FF !>%&J}pugxd͡xt*a^ye:*LݔxQquv;)HaӦMRʻࢁCv Q( dP7L `oF;`Qr@e@Ge@CePìʀR+g-ߞNQ=Xo%h{L<&^~5% lqgkܯ$8h4R@#JAPf54ϯ_䊂9Sx CchC1t-[ ]qThC#h34!_ 3o'OFV}53FG x/x,TN3`7.BFDN(N_^ڮh0Bss5M8•0)dF 477Y ׀53 @ lnb{$yi_H =7эPUxY[[igL sLӼoP?i1W}ꫯ314775M 3NG)Q]] )$LEfj0t GZ*.`lܸq}D19O>MnSauL bW  `ՠbR0DtiimBG 4imm a)hPvuj3 %ijjq3Z4h4cr )E Mlڴi13/H/3?FDuoP`3---CstxoTj"G#8cD(=ohhH9P wP,} ײe3?JD7or-H&v lڴi>9K"zX޻%y ='﮹NRL%b?703bh(n580Qgf@i_S?Dh43 k(pX,v=`ӾLIDI),8Dkkk(NODNr/ڝN;%ڪۉN2/1>#ܡ|Мe/:"(p oB@~0~3;jFPWW33rЍT&kJ\DmmDD[r"(pGUtG ٰa͛y>9niTr cF>GDWE"?:}ފ"D~BD˰[fsVO_EnQ@QSSN"aSd*r X,i"OHȥMN"w((p{90J0\vڄ PD"_K)e{0"`a,4;"ȣN~ ,f~[m6}\J.QAT`Du]ͦg !hll8} {Q0èDO0?0m4}eEEja* &Pnu`|i0L&g >)hmm4Mf&GLӼLM*|T@PSSs2JM&i֨ 6="Vm'"ݎ<Hٴij4"Me#HsRL%ELkkke: 2nsSC51555'[Ztx|礘*Pb;觰6hd2 6 8}>ɡ"a"z#3o`2r|C H@  0sC,S  X,@DwMcw&D"9}3@1!6mZl/-=!::UL%3uV_1*#4򚚚Nb|(&ŦM" )HCCCsPjTLum ×0L0_QblܸMpdaѨ@1e֯_Bgg盉Lf"AssB}WFE K<if>h4ZϊQ_w8FO9bUPB$iF'yyyyyyyyz=yz=yz=yz=yz=yz=yz=yz=yz=yz=yz=yz=yz=yDirectory", TEMPDIR) a = gds.FloatItem("Parameter #1", default=2.3) b = gds.IntItem("Parameter #2", min=0, max=10, default=5) c = gds.StringItem("Parameter #3", default="default value") type = gds.ChoiceItem("Processing algorithm", ("type 1", "type 2", "type 3")) fname = gds.FileOpenItem("Open file", ("csv", "eta"), FILE_CSV.name) fnames = gds.FilesOpenItem("Open files", "csv", FILE_CSV.name) fname_s = gds.FileSaveItem("Save file", "eta", FILE_ETA.name) string = gds.StringItem("String") text = gds.TextItem("Text") float_slider = gds.FloatItem( "Float (with slider)", default=0.5, min=0, max=1, step=0.01, slider=True ) integer = gds.IntItem("Integer", default=5, min=3, max=16, slider=True).set_pos( col=1 ) dtime = gds.DateTimeItem("Date/time", default=datetime.datetime(2010, 10, 10)) date = gds.DateItem("Date", default=datetime.date(2010, 10, 10)).set_pos(col=1) bool1 = gds.BoolItem("Boolean option without label") bool2 = gds.BoolItem("Boolean option with label", "Label") _bg = gds.BeginGroup("A sub group") color = gds.ColorItem("Color", default="red") choice = gds.ChoiceItem( "Single choice 1", [ ("16", "first choice"), ("32", "second choice"), ("64", "third choice"), (128, "fourth choice"), ], ) mchoice2 = gds.ImageChoiceItem( "Single choice 2", [ ("rect", "first choice", "gif.png"), ("ell", "second choice", "txt.png"), ("qcq", "third choice", "file.png"), ], ) _eg = gds.EndGroup("A sub group") floatarray = gds.FloatArrayItem("Float array", format=" %.2e ").set_pos(col=1) mchoice3 = gds.MultipleChoiceItem( "MC type 1", [str(i) for i in range(12)] ).horizontal(4) mchoice1 = ( gds.MultipleChoiceItem( "MC type 2", ["first choice", "second choice", "third choice"] ) .vertical(1) .set_pos(col=1) ) dictionary = gds.DictItem( "Dictionary", help="This is a dictionary", ) def doc_test(self, a: int, b: float, c: str) -> str: """Test method for autodoc. Args: a: first parameter. b: second parameter. c: third parameter. Returns: Concatenation of c and (a + b). """ return c + str(a + b) class AutodocExampleParam2(AutodocExampleParam1): pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/autodoc/index.rst0000644000175100017510000000542215114075001016727 0ustar00runnerrunner:tocdepth: 3 Sphinx autodoc extension ======================== Extension --------- The :mod:`guidata` library provides a Sphinx extension to automatically document data set classes (:py:class:`guidata.dataset.datatypes.DataSet`). This extension is based on the Sphinx autodoc extension. Three directives are provided: * :code:`.. autodataset_create:: [module.dataset].create` to document the :code:`create()` classmethod of a :code:`DataSet` using its :code:`DataItem`. * :code:`.. datasetnote:: [module.dataset] [n]` to display a note on how to instanciate a dataset. Optional parameter :code:`n` gives the number of items to show. * :code:`.. autodataset:: [module.dataset]` used to document a dataset class. It is derived from the :code:`.. autoclass::` directive and therefore has the same options. By default, it will document a dataset without its constructor signature but will document its attributes and the :code:`create()` class method using the :code:`autodataset_create` directive. Several additional options are available to more finely tune the documentation (see examples below). Example dataset --------------- .. literalinclude:: autodoc_example.py Generated documentation ----------------------- Basic usage ~~~~~~~~~~~ In most cases, the :code:`.. autodataset::` directive should be sufficient to document a dataset. However, it might be useful to display examples on how to instanciate the given dataset. This can be done using the :code:`:shownote:` option (or the :code:`.. datasetnote::` directive). .. code-block:: rst .. autodataset:: autodoc_example.AutodocExampleParam1 .. autodataset:: autodoc_example.AutodocExampleParam1 :shownote: The second example line would result in the following documentation: .. autodataset:: autodoc_example.AutodocExampleParam1 :shownote: Advanced usage ~~~~~~~~~~~~~~ The :code:`.. autodataset::` directive behavior can be modified using all :code:`.. autoclass::` options, as well as the the following ones: * :code:`:showsig:` to show the constructor signature * :code:`:hideattr:` to hide the dataset attributes * :code:`:shownote: [n]` to add a note on how to instanciate the dataset with the first :code:`n` items. If :code:`n` is not provided, all items will be shown. * :code:`:hidecreate:` to hide the :code:`create()` method documentation which is shown by default. The following reST example shows how these options can be used. .. code-block:: rst .. autodataset:: autodoc_example.AutodocExampleParam2 :showsig: :hideattr: :hidecreate: :shownote: 5 :members: .. autodataset:: autodoc_example.AutodocExampleParam2 :showsig: :hideattr: :hidecreate: :shownote: 5 :members:././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/basic_example.py0000644000175100017510000000173215114075001016576 0ustar00runnerrunner# -*- coding: utf-8 -*- import guidata import guidata.dataset as gds # Note: the following line is not required if a QApplication has already been created _app = guidata.qapplication() class Processing( gds.DataSet, title="Processing Parameters", comment="This comment concerns this class of parameters, and may be overriden " "when calling the constructor by passing a comment argument. That is the same for " "the title.", ): """Example""" a = gds.FloatItem("Parameter #1", default=2.3) b = gds.IntItem("Parameter #2", min=0, max=10, default=5) type = gds.ChoiceItem("Processing algorithm", ("type 1", "type 2", "type 3")) param = Processing() # Default title and comment are used if not provided here param.edit() print(param) # Showing param contents param.b = 4 # Modifying item value param.view() # Alternative way for creating a DataSet instance: param = Processing.create(a=7.323, b=4) print(param) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/conf.py0000644000175100017510000000313415114075001014725 0ustar00runnerrunner# -*- coding: utf-8 -*- import os import sys sys.path.insert(0, os.path.abspath("..")) sys.path.insert(0, os.path.abspath("autodoc")) import guidata # noqa: E402 creator = "Pierre Raybaut" project = "guidata" copyright = "2009 CEA, " + creator version = ".".join(guidata.__version__.split(".")[:2]) release = guidata.__version__ extensions = [ "sphinx.ext.autodoc", "myst_parser", "sphinx.ext.intersphinx", "sphinx.ext.doctest", "sphinx_copybutton", "sphinx.ext.napoleon", "sphinx_qt_documentation", "guidata.dataset.autodoc", ] if "htmlhelp" in sys.argv: extensions += ["sphinx.ext.imgmath"] else: extensions += ["sphinx.ext.mathjax"] templates_path = ["_templates"] source_suffix = ".rst" master_doc = "index" exclude_trees = [] pygments_style = "sphinx" modindex_common_prefix = ["guidata."] autodoc_member_order = "bysource" intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable/", None), "h5py": ("https://docs.h5py.org/en/stable/", None), } # nitpicky = True # Uncomment to warn about all broken links if "htmlhelp" in sys.argv: html_theme = "classic" else: html_theme = "python_docs_theme" html_title = "%s %s Manual" % (project, version) html_short_title = "%s Manual" % project html_logo = "images/guidata-vertical.png" html_favicon = "_static/favicon.ico" html_static_path = ["_static"] html_use_modindex = True htmlhelp_basename = "guidata" latex_documents = [ ("index", "guidata.tex", "guidata Manual", creator, "manual"), ] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6218855 guidata-3.13.4/doc/dev/0000755000175100017510000000000015114075015014210 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/dev/contribute.rst0000644000175100017510000000713615114075001017122 0ustar00runnerrunnerHow to contribute ----------------- Coding guidelines ^^^^^^^^^^^^^^^^^ In general, we try to follow the standard Python coding guidelines, which cover all the important coding aspects (docstrings, comments, naming conventions, import statements, ...) as described here: * `Style Guide for Python Code `_ The easiest way to check that your code is following those guidelines is to run `pylint` (a note greater than 9/10 seems to be a reasonable goal). Moreover, the following guidelines should be followed: * Write docstrings for all classes, methods and functions. The docstrings should follow the `Google style `_. * Add typing annotations for all functions and methods. The annotations should use the future syntax ``from __future__ import annotations`` (see `PEP 563 `_) and the ``if TYPE_CHECKING`` pattern to avoid circular imports (see `PEP 484 `_). .. note:: To ensure that types are properly referenced by ``sphinx`` in the documentation, you may need to import the individual types for Qt (e.g. ``from qtpy.QtCore import QRectF``) instead of importing the whole module (e.g. ``from qtpy import QtCore as QC``): this is a limitation of ``sphinx-qt-documentation`` extension. * Try to keep the code as simple as possible. If you have to write a complex piece of code, try to split it into several functions or classes. * Add as many comments as possible. The code should be self-explanatory, but it is always useful to add some comments to explain the general idea of the code, or to explain some tricky parts. * Do not use ``from module import *`` statements, even in the ``__init__`` module of a package. * Avoid using mixins (multiple inheritance) when possible. It is often possible to use composition instead of inheritance. * Avoid using ``__getattr__`` and ``__setattr__`` methods. They are often used to implement lazy initialization, but this can be done in a more explicit way. Submitting patches ^^^^^^^^^^^^^^^^^^ Check-list ~~~~~~~~~~ Before submitting a patch, please check the following points: * The code follows the coding guidelines described above. * Build the documentation and check that it is correctly generated. *No warning should be displayed.* * Run pylint on the code and check that there is no error: .. code-block:: bash pylint --disable=fixme,C,R,W guidata * Run the tests and check that they all pass: .. code-block:: bash pytest Pull request ~~~~~~~~~~~~ If you want to contribute to the project, you can submit a patch. The recommended way to do this is to fork the project on GitHub, create a branch for your modifications and then send a pull request. The pull request will be reviewed and merged if it is accepted. Setting up development environment ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If you want to contribute to the project, you will probably want to set up a development environment. The easiest way to do this is to use `virtualenv `_ and `pip `_. Visual Studio Code `.env` file: * This file is used to set environment variables for the application. It is used to set the ``PYTHONPATH`` environment variable to the root of the project. This is required to be able to import the project modules from within Visual Studio Code. To create this file, copy the ``.env.template`` file to ``.env`` (and eventually add your own paths). ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/dev/howto.rst0000644000175100017510000000154115114075001016076 0ustar00runnerrunnerHow to build, test and deploy ----------------------------- Build instructions ^^^^^^^^^^^^^^^^^^ To build the package, you need to run the following command:: python -m build It should generate a source package (``.tar.gz`` file) and a Wheel package (``.whl`` file) in the `dist` directory. Running unittests ^^^^^^^^^^^^^^^^^ To run the unittests, you need: * Python * pytest * coverage (optional) Then run the following command:: pytest To run test with coverage support, use the following command:: pytest -v --cov --cov-report=html guidata Code formatting ^^^^^^^^^^^^^^^ The code is formatted with `ruff `_. If you are using `Visual Studio Code `_, the formatting is done automatically when you save a file, thanks to the project settings in the `.vscode` directory. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/dev/index.rst0000644000175100017510000000017515114075001016047 0ustar00runnerrunnerDevelopment =========== .. toctree:: :maxdepth: 2 :caption: Contents: contribute howto v2_to_v3 platform ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/dev/platform.rst0000644000175100017510000000702015114075001016560 0ustar00runnerrunnerReference test platforms ------------------------ About requirements ^^^^^^^^^^^^^^^^^^ The ``requirements.txt`` mentioned in the following sections is a text file which contains the list of all the Python packages required for building up the projet environment. It is used by the ``pip`` command to install all the dependencies. The ``requirements.txt`` file is generated automatically by the ``guidata-genreqs`` tool. It is based on the ``pyproject.toml`` file which is the reference file for the project dependencies. .. warning:: Please note that the generation is not systematic and the ``requirements.txt`` file may not be up-to-date. To update the ``requirements.txt`` file, use the Visual Studio task ``Update requirements.txt`` or execute the following command: .. code-block:: bash python -m guidata.utils.genreqs txt Microsoft Windows 10 ^^^^^^^^^^^^^^^^^^^^ First, install the latest version of Python 3.10 from the WinPython project. .. note:: At the time of writing, the latest version is 3.10.11.1 which can be download from `here `_. Then install all the requirements using the following command from the WinPython command prompt: .. code-block:: bash pip install -r requirements.txt That's it, you can now run the tests using the following command: .. code-block:: bash pytest CentOS Stream 8.8 ^^^^^^^^^^^^^^^^^ .. note:: The following instructions have been tested on CentOS Stream which is the reference platform for the project. However, they should work on any other Linux distribution relying on the ``yum`` package manager. As for the other distributions, you may need to adapt the instructions to your specific environment (e.g. use ``apt-get`` instead of ``yum``). First, install the prerequisites: .. code-block:: bash sudo yum install groupinstall "Development Tools" -y sudo yum install openssl-devel.i686 libffi-devel.i686 bzip2-devel.i686 sqlite-devel -y Check that ``gcc`` is installed and available in the ``PATH`` environment variable: .. code-block:: bash gcc --version Install OpenSSL 1.1.1: .. code-block:: bash wget https://www.openssl.org/source/openssl-1.1.1v.tar.gz tar -xvf openssl-1.1.1v.tar.gz cd openssl-1.1.1v ./config --prefix=/usr --openssldir=/etc/ssl --libdir=lib no-shared zlib-dynamic make sudo make install openssl version which openssl cd .. Install Python 3.10.13 (the latest 3.10 version at the time of writing): .. code-block:: bash wget https://www.python.org/ftp/python/3.10.13/Python-3.10.13.tgz tar -xvf Python-3.10.13.tgz cd Python-3.10.13 ./configure --enable-optimizations --with-openssl=/usr --enable-loadable-sqlite-extensions sudo make altinstall cd .. Eventually add the ``/usr/local/bin`` directory to the ``PATH`` environment variable if Python has warned you about it: .. code-block:: bash sudo echo 'pathmunge /usr/local/bin' > /etc/profile.d/py310.sh chmod +x /etc/profile.d/py310.sh . /etc/profile # or logout and login again (reload the environment variables) echo $PATH # check that /usr/local/bin is in the PATH Create a virtual environment and install the requirements: .. code-block:: bash python3.10 -m venv guidata-venv source guidata-venv/bin/activate pip install --upgrade pip pip install -r requirements.txt That's it, you can now run the tests using the following command: .. code-block:: bash pytest././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/dev/v2_to_v3.csv0000644000175100017510000000432015114075001016360 0ustar00runnerrunnerVersion 2,Version 3 ``.userconfigio.BaseIOHandler``,``.io.BaseIOHandler`` ``.userconfigio.WriterMixin``,``.io.WriterMixin`` ``.userconfigio.UserConfigIOHandler``,``.io.INIHandler`` ``.userconfigio.UserConfigWriter``,``.io.INIWriter`` ``.userconfigio.UserConfigReader``,``.io.INIReader`` ``.jsonio.JSONHandler``,``.io.JSONHandler`` ``.jsonio.JSONReader``,``.io.JSONReader`` ``.jsonio.JSONWriter``,``.io.JSONWriter`` ``.hdf5io.HDF5Handler``,``.io.HDF5Handler`` ``.hdf5io.HDF5Reader``,``.io.HDF5Reader`` ``.hdf5io.HDF5Writer``,``.io.HDF5Writer`` ``.gettext_helpers``,``.utils.gettext_helpers`` ``.disthelpers``,*removed* ``.encoding``,``.utils.encoding`` ``.encoding.transcode``,*removed* ``.encoding.getfilesystemencoding``,*removed* ``.qthelpers.text_to_qcolor``,*removed* ``.utils.update_dataset``,``.dataset.update_dataset`` ``.utils.restore_dataset``,``.dataset.restore_dataset`` ``.utils.to_string``,``.utils.misc.to_string.misc`` ``.utils.decode_fs_string``,``.utils.misc.decode_fs_string.misc`` ``.utils.assert_interfaces_valid``,``.utils.misc.assert_interfaces_valid.misc`` ``.utils.get_module_path``,``.utils.misc.get_module_path.misc`` ``.utils.is_program_installed``,``.utils.misc.is_program_installed.misc`` ``.utils.run_program``,``.utils.misc.run_program.misc`` ``.utils.run_shell_command``,``.utils.misc.run_shell_command.misc`` ``.utils.getcwd_or_home``,``.utils.misc.getcwd_or_home.misc`` ``.utils.remove_backslashes``,``.utils.misc.remove_backslashes.misc`` ``.qtwidgets.RotatedLabel``,``.widgets.rotatedlabel.RotatedLabel`` ``.qtwidgets.DockableWidgetMixin``,``.widgets.dockable.DockableWidgetMixin`` ``.qtwidgets.DockableWidget``,``.widgets.dockable.DockableWidget`` ``.utils.min_equals_max``,*removed* ``.utils.pairs``,*removed* ``.utils.add_extension``,*removed* ``.utils.bind``,*removed* ``.utils.trace``,*removed* ``.utils.utf8_to_unicode``,*removed* ``.utils.unicode_to_stdout``,*removed* ``.utils.localtime_to_isodate``,*removed* ``.utils.isodate_to_localtime``,*removed* ``.utils.FormatTime``,*removed* ``.utils.Timer``,*removed* ``.utils.tic``,*removed* ``.utils.toc``,*removed* ``.utils.is_module_available``,*removed* ``.utils.get_package_data``,*removed* ``.utils.get_subpackages``,*removed* ``.utils.cythonize_all``,*removed* ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/dev/v2_to_v3.rst0000644000175100017510000000220215114075001016372 0ustar00runnerrunnerMigrating from version 2 to version 3 ===================================== Version 3 is a new major version of the library which brings many new features, fixes bugs and improves the API. However, it is not fully backward compatible with the previous version. The main changes are: * New automated test suite * New documentation * `guidata.guitest`: * Added support for subpackages * New comment directive (``# guitest: show``) to add test module to test suite or to show test module in test launcher (this replaces the old ``SHOW = True`` line) * `guidata.dataset.datatypes.DataSet`: new `create` class method for concise dataset creation This section describes the steps to migrate your code from :mod:`guidata` version 2 to version 3. The following table gives the equivalence between version 2 and version 3 imports. For most of them, the change in the module path is the only difference (only the import statement have to be updated in your client code). For others, the third column of this table gives more details about the changes that may be required in your code. .. csv-table:: Compatibility table :file: v2_to_v3.csv ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/examples.rst0000644000175100017510000000510415114075001015775 0ustar00runnerrunner.. _examples: Data set examples ================= Basic example ------------- Source code : .. literalinclude:: basic_example.py .. image:: images/basic_example.png Other examples -------------- A lot of examples are available in the :mod:`guidata` test module :: from guidata import tests tests.run() The two lines above execute the `guidata test launcher` : .. image:: images/screenshots/__init__.png All :mod:`guidata` items demo ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_all_items.py :start-after: guitest: .. image:: images/screenshots/all_items.png All (GUI-related) :mod:`guidata` features demo ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_all_features.py :start-after: guitest: .. image:: images/screenshots/all_features.png Embedding guidata objects in GUI layouts ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_editgroupbox.py :start-after: guitest: .. image:: images/screenshots/editgroupbox.png Data item groups and group selection ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_bool_selector.py :start-after: guitest: .. image:: images/screenshots/bool_selector.png Activable data sets ^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_activable_dataset.py :start-after: guitest: .. image:: images/screenshots/activable_dataset.png Data set groups ^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_datasetgroup.py :start-after: guitest: .. image:: images/screenshots/datasetgroup.png Utilities ^^^^^^^^^ Update/restore a dataset from/to a dictionary ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. literalinclude:: ../guidata/tests/unit/test_updaterestoredataset.py :start-after: guitest: Create a dataset class from a function signature ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. literalinclude:: ../guidata/tests/unit/test_dataset_from_func.py :start-after: guitest: Create a dataset class from a function dictionary ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. literalinclude:: ../guidata/tests/unit/test_dataset_from_dict.py :start-after: guitest: Data set HDF5 serialization/deserialization ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_loadsave_hdf5.py :start-after: guitest: Data set JSON serialization/deserialization ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. literalinclude:: ../guidata/tests/dataset/test_loadsave_json.py :start-after: guitest:././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6218855 guidata-3.13.4/doc/images/0000755000175100017510000000000015114075015014677 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/basic_example.png0000644000175100017510000001121015114075001020167 0ustar00runnerrunnerPNG  IHDRksRGBgAMA a pHYsodIDATx^[lW.C`0!eavx26q"qy$Zhʱ6WɊ4;y F, H86 ,XۓaM7Il0bSuNnq__Tqr۸:Y)F(,\P-Q&{.fy""C4 "0HP "ML0::Ç| DnJ$GFFp |["1`Sda g"+ xz<7˱bJ̝;W=R$u(ܾ}}}}X4l~8oDˆQ 0؈1DdppW=,c*//OڿSzzzx1N+̚BŊUE ~vϮA`y,Ě1";*bMUgCB2EJ[orϞ=sq466GKww7lC`m1NVC5/_ {핚emt@[qh}]8٪ vb{D_M^LkR%tR1u(H3f@^^6l%KH{gcⅮOXhzdl]t1=;psX 6 X'7(CJQԷ( xq:I颮'NPKiݺub\'bg}f0E,>B'8N9QH̷c[j144d|JR_L ɡ oFQ*ݜ(ws&q Da(@Di Da(x'"bOt "0HP " C4 "0HP " C4 "mifѿ_WsD;PH32X-Q/SHP " Cryy Da(fʡPTv>mPQр~j4ȍWz׎8~EU5[Leez٦SX_a +vIׅWֈݿغI5 "wQ-_ b.ʛףaV/y!.c4V<ڪxCuW἞82Wh΋%NNܰ.ՠXWHExڍNJQhnkWc#v_ ýXo.plG Kl8m=KgB4wA5i=Bڛ*=^zOѸk`Ϳ#{"^ay;мOĎz/m;'p<1ܧ5ቴr*$~;G#lC0#(L=QYwm5zd͝Q?X0s6/Otx^=D녿 w ?U8 '¹v|UC휂=W;u]/5G׳k6C5fX5_ǟiLA$(}Q 6c."e)YGw\3.Eݚ|5 LMv_{QVcC@0ګD\XUY|~) "eš8\VFD8>yVͶzm!CECz.71NB'z]H%m-h{)4OIʐ))uSD39b/!31Hs icpń"0HP " C4 "Cy^Ssa( oZLO98| " C4 "0HP "͔C!#+D@-&DJĻ4SS-9S*D]Pփ0~S(٧Ѥ8cT~Hm)6ylEaD'P QЌmآfB ,ęW!JϜ6Xp0AY")dT(ۆa&(0Kd9tenR0V*X'YMPH Qfx]2{j)qYWˑ(:j49~J2SdOIQ Da( oFx;6" Da(@Di DՇ49"/PH32֭[(uwwO98| " C4 "0HP ["Z$zDO`ʡ`;x;wǠZL|oߋe=ݒBԚh*R5hNѤ)1._ƛ豩m`URG?lأJU&m]lUR3:jjAFkUvNm?{6/*Z5\_$2S99y"AMzXsvEٛGcSpPm9r]n@ni u֢D-Sݿ~8&*PW+0ɢ)5}e?]@.PIj ŪfB3|Ƶm 1iեBũքc*6~NA}X*Z^ +fE+GĎZRΡ!܀Ԧ64>ʕbNj#C 5u5"2DhllS6`̟?hooׂA6\'VSuG-BCV`lk^ف)KWr$k8wG\ڌ(`.eow"kWsʅh5KŮEyv7]fBvW%R9k/.7m6^j5CABm7coE 6 @rU4H݄5톕 T/)SM'{0;)8 dSqoGWa: E>;#0vfhƹUrچi.:T3ŽUVEB6ǂ (YX@ rUj*l^]_טI9áXy{ؿ1BۏLl_#;z$hUNp9'`hN4&Tn%澄m%%/d1Lp++"Sj%G10:I" ށ eާ$e1֭:O"H>%Q_ypZOI:h]˖?`g6q" @v)QSH3؈(&>@Di Da(@D^&qS͑_,Ws={$4a(L.P[=[%4Bah<+4<42bs Da(@Dt^+xEaJN{.)oٖZT?wpM22 -llonc(,2 ^v>ni0kddB:ތݚr>C:O2n8-ө׵(7*zWkB= $_X\?\o12*P\Y тCy6jeZ=Fb=uUXK9F#)P`[z%XfDZMwԢZ.0 ʀ]"% |n {ւv#TQ kH >B(GU-zK['/i5 r+2FYm;F-ۆ^[dʙr{FF >Bb`uaS8=B.*O;\Hcɍ {PYC>$=;۫鈾t ?~ ₡@DixNaK9K)09ޣ1v ")@Di Da(@Di Da(@Di Dd?W&S.IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/guidata-banner.png0000644000175100017510000004640315114075001020270 0ustar00runnerrunnerPNG  IHDRY pHYsLLtEXtSoftwarewww.inkscape.org< IDATxy|T?Β@ "(jEm],H]Zժ͓~+mmZ[o]J*  !!{233ɝ;w&Β_0s}3w{sČ3dD"X2ƍg\c.F^f.k(ۊ-D"HːR4MJ,pZePu~ŋ[-D"Hc+tڼyIfYh`3r=U\\|$I$9RoܸD%"IgFD璒t $H${ 0ny$wf~믿>n$Dt+t/mf;H}>߃/>na$DIZB袋V.$^%T/ZhwH$I܁@k28Ÿ Bۥ@ hL =|4t #H$STWW+R^ ^FWCGۥfaxbk1kq 4MP|~_s'o>7z<~8x `݈QrYr4fj 5jUdR&lx]BYN Lǚļ`MCGO:3zD^%"z.|T"H$R7nQEQ`N;7+pFAѹG-kVU3f>?x^L%k5):tY֖:Ϗ'5cw\hnQ8: =)W]\uRUUtD"$*p-fe cG0: n\w&,\! =\e![Xȣ&Y^ ^CHDGwZsؚ-y)s3zM"H)􆆆~OJҡ `ƄƘQf%i~)\:;ދ/I@OpS!N%7 \^y/'cD"Dy 5M{I.SvEQY8bm&+֯jeg+i^{7f-Ǹ6tʕnYja>{G_c8xr,Z:rD|T"!CSiG2LH8XmӦMD8QInOO2pB/O" *6"%mTwI c `LO)));$DbIB kDD QLp4@angt)0lhER&+w C݆´a6rJ^! En~&o@&A3cLN 䴠+Oq&!fi}}}AIIt$H$k⎈e>:ppa*_" YamaZnPa}}~ GԧtYٛA^X {e W(ļ)yarѾ['|M~X/D"".nP HgeΙ]eVtlR7>YƂ-.;?-_Tw:ڍ2Edp2u`Mpdwas&}Rx̛ڎmۇ~\___P'D"u]WWw5H@3&0}EF]=F VkkȢe2qE͖r>?H-Oy=b܏U_ю[m~%k ݃,or2:z'3qqDf_RR:H$I͛cM?ӍlS;tBcZ;g׫oIv(8=+RpT3?E3A$,H$#VvCCXM@{Np3=чmiYK =st*{>*gegtobJx0c6l(JD2Ѕxq)Y<;ݣeƱ&ƫ .ɠ[cS{] i8cn1y+9nBUH$Ⴍ%\rE Ă6Ǝ=h2cpdШO)}}x .%YZ__B>D2n:ט1c~GDN; ,̌=|(i݇;6c}Y`gE_2o,udQ]]} 3'yTU}"-I$C |N335fL#hZb^BxW[GSSN:F/y'1ʻ²gM݉w]_Q[ׯ_ŋ]" Q115_ɴ4Dnj^ӧxMR&”kᴷc:::믇X/{n*tnN/ ;c!"0!0s>wґBm۶,fvd+ aL]nQndmvލ@ 2aϞ=7YYJQuҘC8trNu9G~ $CUUn$FvB'VAn ս Mry>Q=z`* VwE̺K&D"",t"2[$K>)#6e*0 8z ':p Y¸l̟3g+5F7iylѸ꧿@ ]KJ"t@矗Ղqy'peH$BB߸q\ःS2R`(i*m*"\[xoot6D,\[:_TY{mkAcm=bD߀`a=Nm.3Y"J$p&TU N. =a)@#Mr;6?t ߸y-eM:}_PžrH+Τeg{@oaID^'0* 9MNzWD"}ϟwrIP\&eNC''QQ|'u}K[7*C~-aY4f\Otvk뙱:`8NلdH$DB߸q C] h^p{̰>8Ќ[j6-[|~qڄ})g/0UD"أar;pBnW?o_ hMP/m?kӉMK.Xj!u؋/%H$ABg2'AVt[G@]|ui@ê_<դ2$l zw;0Ə>k*MP:D"ƌ2\JiX7({cC\U<_L2MǶxϯŌ4oo#WGZPosfx{ iWXŕH$3S4.R?lʬRo~"Tgqa\| 𛻮CVf1v'2}1 i,J$H }Inu5BzoJZ0ӛލy|B|ڳ,͜?}NLÞM9-?K2vIcLh}0ZAsN=GajCC# ꐕ+W|8z> @VyǷ̼AUUge$` ltTU=-i0\ӡg >:80mwhpfV KDsy6{Y>Bq svff拷v[k*t( kuuPZb@nX7=~Xv6uesc._S2=Z̵^׫;ؼeX3쫤ju|~n`) `@b_pF?f%*GQGDTVV#8^QUq 7Ey")}ݣ:;;ۈrӾ)S}JDFNp/a㒙ɸXxLUՖ(ץ$wb:}/p)>sB ?˖-lVs/37T!:@{b]$bT>bT ?=PsNloGD'E(|DӴJdY fwTUeqL =n`5]+t"3(ʟDR&=cVVWWb_e:/ބ{\ɬJB_#_EQj***rQOOO!L|?k}կBYi&,0p|>]B?\˗//Y^0*u3X彉cBk̸y&ݤ ?:~Ξ{MMNutͰG;z%}EL !!o5M{E%^B|"״%qI[%#*OfރBfkBsMǒ5M;upP\ikB{V^wn!<BT]6p !K/vm˼\OU x7=nfVyD4@3B/"z----BO#|gSD|.fBCTB%+=ΰhmWUAk@@Áіt&͵ׄq@ =+Ӄo3(Wwf)T8fs)IP]]ef~(+ID2_c!FD_d`X 嚚k+**^0B]:ف@`=ӣVUֺb"t+Е f}uu5HBgxw[NrWC)0@w?bŊ/[̺*U8]A0 @m;Dkdz\2fޢj`.DDUUU\'f.!N7ȸ~ֵL%!ޛ˛ ֚)k(=NwD;dy~~_r4ܺLoo11~0ֺ%8mIBB| oaܢ([~?B,+UiZCMMUz5@Wf$WVV:PU|\Ӵ\l8OBN "zv?˗ ^?od_z|1')~b!D-K[tҁNnD[c DZfπUqqM\ܚ9J鶰Ao{Q3nK.deޱ(}#S/gL5ֈͿ-qö56X:2P@]2"G֬Y'OB I~KjժBQfeIeeeµ+**'K_b&f~aDUg<>TVV_zuCKKb:^NIpY~Or߻{cggR*xW拾2GCgx)txy'{?W- >o/=p_>k6C*UUQU5iۉwZ ]iLۉ$ BD",5"?TU5aen3d3P|͝~nfIMMi܏Y:\[[m)(& Pn-cW榰=տBzwzlR-g~=}G.] ^6ygKq7EIDիWgu@iPkxNNN<1$SUUVEQYYTlٲJ<13)զCJx zЭo Oz+[\f)@Δ q[ax W:(DcL M eښx&venؙR-QB vgDyUUέX%)F18tz%KTj ȼlHa^c  ">rJ`mGv@ !z`HB aÕۏ{Bb l"ghMS ؋zv,\oNQU_TTU_%ja J%̪k"UmGҨRi_ 3{v-PrȕjT (htu`kg!1`XLKoBuX[l)eɒ%>薺 `L=.F'Gu~5d(yrq/klr{-h,\.Mf&_&މ>aaNݙiZ\+&CfGiz7I>paѶvpIޗIpVSB1g(SQQS%B{kA'U4nilI>v>7;3)YFZ7~ .x,6!1~гzYt{-ך< JMy5.w;HD0-Ͽo8IٱEWY4sڏdhLjΛaUUK/v[ٷr5Z4;])'1|Vz8-ܜٵлzPn IDAT_aM8)m7{·8:U^ډۉ-[v0^VX19whMx- ^N*nP^S==ohlej vk搐2q98sNΦǒ"u~;n\|q /0#,8.b"G8{3qJ6ܐ#OGV3c.EXnkΝ_Zi t <1w@gIfg! k׮>|&Kyz]0CBiOdddvh036ֽ6g ”ה.у{6-| ƌǞz CҙͶ]|NFRgƩQ۾ǚ~:N̳:O~bo+dz":Y!Ĺ;w|#Qј\!qWB\rN6zB9s|׷fR}Mckai1}bݰqx6z5klz+og;1G"&ia6;Qx8^yfL(2̶\EKnz,I-qtnMȠB3nJLVf^B c{B=͇/ x"o%(z^.@6 0--T%QQׅ|BT絵gF纲nX`'=ڷ¬HsE#pF =" [.PN"-=iv;nr!`(3 f0B(UUUk;QNybϟN^uֹv5 y{yY^xztvvڪ@c aO^;΂L8 9(3s=~iu[= HfV2A"By3@C!V;G9Ns@pID8VPnV781QP 4VXq>ݲOcΜ9'ZZZ6tvv~ =vMpNu_U1*7?9r˗'y$t|zH<=gnzcN߅cr}%3C'o ݌+fch8tǎӇ lnҙ(4j"&G(Jv\Acz! T},):rr4܍gzb4'^|r>jQk.]j5)XF(zޛ:;;7}P]q,c̉I=vqL4&%H23/k!lwCuov5+JDi#,3Q"Jvpaw7K,<1 '\$`i uJViHU9WKCFBĬ,de sp =d?pEqqqRrE+bd=Ȱ6m_t%s 1}`jzA'al<A{yk$Qvgȑ ~go:MQi`ʔ+Ӧ |>OGG KXYYY;.NV+nrbQ*j:OKKpI8a)UUU6vH0adUOQe]^FF#?ؾ X+1 Dty:I&%% $ctj˘WDUEQyc֡aWc{KKa=il`̙81K> 0i |m|#OMӮK8ᓖƚYLrE(tft:dI&{o&"82n̐1%\vT~0ԩw =+#ra֬Y袋} 3f̰,xӰAx{? ۃ^8&p> ?{%܌^{ mmѳk9nj;^kIڟiCEmb!İ cj>'0+4MKDx̢.E W1+2% '{333 {idEQC|0i A![vuu7sIް#qP\ h,¾ W\qxPUUB OL~`IM5=4I Ps !HfT%_2Ix |gJzvoV`ɵCfݩ$̌1oƸYS}~_N BQpe-jTRRq+DFuǽhnBflHPUu<wAʕ+':n>0=mʕE ,aY{Z]&Lu=8':gd֭khSiBJ}@< nX|Z1 jpTwlp̼v-ePU15{=13H`EQ"|5Y$ADtt(V HK!w?";_[R7aMm}0KoGGǀoqB܅H| ݗ===72UJ~ٵχ5` 7rHgo(^8Z̓f?}/3Dp\U}%C!@ Ѐ$*ʗ65S]]h+VLSs*** Ϧ ]~y$P]]]*X4hl51NZ1s3~#<+X ^:2/^|H- ]>h w RJfD PDl`Ȍh$#˗`š5BJгy%5333ʢȌCdvǗ.]jU.] \mÇTB%,v9#33~z >wYS{Eo.)n NTS--/9}rz]S=>`,gLW)$vp`#a燳a;D钒])4999mjlMM͙FMӞ@F5"#hiiE*25TjxPU#UUUwu*Vm qS 宻ʯzF.4 Rc5ʲehj=`,_qZj:lٲe(N?N&#\L,rc'ˏN;ySs_־s䵋>YRR|d[o]Q4TjժqB0H֛o4kUUU?_n]ҳwOWXoUU2qEee~oq趪׬YmQBAXpe~|nAA(E"ZK_ICs'SSlV "8֤S]]JCs "O%śru۶mY|,L`4.TG;&j[HfbCbkmd0 ]c96g͔=ta̰ucL3˪ޓս{_=x3H/uTii' !M6?<5SLWjmm3/Ed_ONUՄ&_SU57wuW~wwjD.PU5o3s2(UTTD9aY---p7|EU7]Def/D= ?^rrrr~@.2UUr;ٹwnv'1p !yL "ZRYY@[$"M r9RW>;("M~pOUv;.tH(qāc^ܛ*½CszW׉ ǢMbc(zXc26~As}n{EUVa*:ЫhV 2KZ4Z8 ]!BovBVs(hZBc5g ~CCw7֬qGhhZ6@ $.7BBQПc]n&;cyO39*?YF =5A=dPUƫYiړNGcA?s sb;JDTVVlLSUvxR2i"DG8;v3zؠ'nkסaΌKczV.n l%@CvucdjX6cTȡkK0;ާkޕOGa2'*芺? `!3[DT@d)R 5A/ryEd*BUUMU{Q6?BG$晕yW(A|srr.@BK W@KFrUNV83ﵸ&Hn ~p#&Ayr\U^ZxBl G&YYYIK^^^͛4mI&`D`$6! >LiO]amk,3{ l YP?&%3;q0Ά>cCυG'@'OBDהFDVn:!%tk&Dfr{{.]zf$ rŪͨ`N;\ {hpffnF( Q%4PJDv7]qffޮ ˕ۂAr^JAׄkG_ fff>wr cYYYYЍ~I͛/4m#~aLL(;:Z϶TnpB\6-rGu)V$RALkM&bh}gr@ X =řu4>Z6R{q#Cք]%@xFUՖvc\}{нР~UUU[88BL"2j.,Lp]{m7*x(Ibz^~?f`;)LEEſIWaÆs\.׳Hr 8m"cxňE+ta-ڬ)UV3@B z׾)Xd<9O@? oiˍJ-4MۂgffkmEIr4M r=((5M{OUՓ)`","̣h43Q3ّ&>@hzw V? h3B}q??rj6t^/b7weбl544L#xNBWSQ^x– VsDյ&K25t2xj,pč xIDAT@@K(H#}.NF"B—RU5V(D"IʞM {sH عG)/CPq9/4n8'"qƦ6kc㵦0y(pId4e}cć%_Ke`6sʐ^H$'iAqVzW?+ff 8tB rP466 S׹9.,-GU<N}G=㇏őiGGwRrݢ4"=#;wZ zD"ܤT… }QWW / F: >tcA70~c4zw֪ÔɍᢷrgO3+j0ƃmf˞9߰x- GFHs>N'5}岲&ӑBD$D"I+ս`߰7itH .(1~uaw 4bwAR794Fb6l3bk֎LhEcK.Nudǻ,*DhWWꦤv<,D"ISPZZzCC~aWI  8±&@a^cF1*+`H1kP}@t7~o^sᇹcQ֊:<4eDK[rOGyHӴo?F S D+iD"ˀ*t(..nM6](o9l>N4 \p)\?0&׏1>( Lv++íj#F˺Vcx @hDKGZ;ґWzo-ZhnYJDvID2Lpl͛_3_I&'[8E3ҐGnv9>de֨)<-^AG hrˋnOk]`Dt[95MZA"H쐲}Nd\*_ ex=dz5xǭA"\zO`f=>}WF?mdɠEAA}`FI &8=x㪪OLdx6 ihhx -FAHqtxJ0Ù3D"$`PzPWWw?AgYm&\yv*rIsէCD2HYx)--m?n^V`KJJJ2RK$ kQihh|%Hb m4mEYY'I _d?VU5$cиܭ(..n?xAlPSRR!݂D֮]a s;"H$ǠV!/^|ׯ_|wKXCQMII?-HK357ggg?y$cPܣm۶яN<f\O?BK#9,UU4$H!CRK|EQ~[RR" ssw}dɒ%rD"I C]0&| t3tx C[l٤n$:B C~W*K$1l2f^t˒" "ZWZZz* fZUխ0{tSYYyH$#aЃЦMn$!9a/3hzꬖ/X]PPd%((LU[=D" gxg333ק[81s=(SW^y,&3D3WU|6BΌrQ+yH$ia+uuu/[~PODO+//?n:QbN*ҮiZ;tEQdk620ZܤD"f(tظq<0<.L@  =U0n-˖-0 K$ĈRe˖QLE9/DziXS?>:C?EQxiƓH$ЃP}}\/P3@:8@>b@9 }M|7H`D"$ʈVPWWf~hNfx<--1UUUI$AˈWiӦI< SJKK~%D"E* o=| $D22kIbӦM?${aPZZ%H$TIS\1H\D"Hd6lpz ;3-++;Z$D2kIfѢE}>߹D4ihhkK$DSmڴ6"Z {555]wR-D"HBO\VVS"1b̘1wZ(D" OBO1%%%4mw.xӦM9bI$d!]@F80bI$d8 aÆY.w*CaǟbL@d,Zހؼ/`ic A0 jPa󄧽o9r_bWk9w8,seظ(cCl x$i_UFRMdZb@$Ӳ,w֒/O<'azDQ4֮1KI3ICcS\g[IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/guidata-vertical.png0000644000175100017510000001501515114075001020627 0ustar00runnerrunnerPNG  IHDR}kZ pHYs[e tEXtSoftwarewww.inkscape.org<IDATxytՕٲ-˖`HbasF0 $ Y $L c[j'0$d&C&B M'0۲M-uw՝?ZUݒ[2wT]U޻U_ݷw+%" ĂVh ~b$ ' I?)BCCCmDd0NcJGSgCDQӲ])׶흶mZDIDF"?TЛ1yfhvigEm4=_ c%" ^`lUyF) Om+?JKK=ZLbݡ<6>m<@l4ewYBl[:"[l[E[MMM{_ ŖeՉwɂl]26eCJm}|wKVbǺ;!G;<(pR=[eM555OA3 ׯ?Ӷǀ{Z GT۔Y(T bbVXtz9"‘b.cop/?z'YfR꿀T=:?J(-k%1T!P,_%[|e$MG)MD^zWΝ;wwnֱय]OphGWLXе2Rd'QDtFl,[2'b)Rn^AI_nݵ"@ғ=BcTm#:C<,nnMLƥ4Zеo7MgnB`4"]'OP5Ri=\'hq0~.=SdRBGA|Wi |qC8-ơCVƽ$#"7[Xڵk<R~R5,揯<(+O/ka6MMM4550vnN {bF}}9555/C}F^-}RRQ1,d#~pp, qOkO^Q[O1w]#NkV]^I}~V^3G`^Skigtݓ\'hU1'*LFs>1U,Et?×_kbVv_S'?)F&3~nFr( >wv_?/!_^^jR_}D~u J< +eUh]&ַ|Q6ݩ' vO9Zu5z@N]%rKRMe{cc${%)'zh<55'_:V(Õ5>qj.`sE_2JyV#JIG9YuKBp~ϓvhM梤lŻ{s=B_9"oI_dPXVY>Oseͬ~98@36*jNehjwiK/tKa*@EQQW`clBݰQ7/[t.[><@GGqcʙ4.1E~Z& u]+_y:묳^|lxw/!>>q;sbVgBYY%%AEEEgtF`ԸÚ=RͅZ~*TgŖN1_&=?RqL:38#c|^` +:qu"7J.Lu]ߧn5 6_ &9ˤ1/%D_+rZ6VmݝSپB \zBNkR "ȹ'fLPZxcK@F/ȹ3_e۾ l'4y@šKIgg5MKrPlai3t BJAeΜDaZ}Nu}T~)>>]Glv&U$L'/:359z&I>~,c m-=oJBVaAIXfMR)xN`*WH_%mfzr,f>z~<}vTB$q<^@ ֮];x HXPV'MF'Ӝr M' I\{G1[vO` n? ֭-"# SZT-?9=YuO֜aQb͞$_RͲkjjjt 6|̲= GٌPc,/ARS~숥`9Z֞6ʩAӴ/͛7 bqơ`A ~`Q#,0$BJki94UA{K#eؒ!"_zUNIzk֬A)@a0^0|Ő(-Py,WXpDqC{#>7O}SoeФOuncޫѝBG4l9;~o%B`Ś5kVJ\X`UvJs$Xqܐźu"M೤XY)dGqܑ'۶}MzQGRm?/䒍GqܒE}}hMꀛ͒:PhGmE}}gu]%{񒒒g >,/yM)ws XbXGG瀿JƫS|>se}x.$Qkk_͙3'GS{u?ZP|H;w֫HX0m?`'*>r,XxyTi@&41i-[~3@R \)uڵkOo"kҕR˲N?|ÍA@Vnq\."ZԻ=l>GD[g4L 40M@ &yqF0Y+\z͛6mS7]=SJ=^UU5 }.W\94 VZyyn-C sψα1cE&%?vڕU:V*ioogYV9D5z T 5rÚY3Ԅ/(IWga.V(|SD.hFVCi˖-;ٲJ>wJ l>|SN9: ill0o8˝&^TJ=}1O94 lO}a ^58߬K k%Kd512kKTm*t |ND~kpFJZ^Df /g7 qH&$FrەRe"2mMM42tJ=8> 4J"r&p|4+**>-DP Tt ^qPJu uvv |ᅰRNy ppm7E+EV+Ed5|0?$ʬ^z͛V텥K^ a$ }۷oOPsH&i8?p x/Ay-Z)_fogʢEFؚ9?4a׸ہ62TCDz7(6H@)u-@'z "@ADیm4MRR0gNybcLW-gp2Wg S)լz/uuuYMI :Nrw LB0h7Msp KSuC)L8G|5` &LGG_%R#FZi>MNDd>9PDn4-`vc{-koo ɈLG[8o"R<Ė/_>JD&E^wVy%˭/꾘16*ԐiZ.?0ӗA ,ʉޡPs8U:`:`ys{tԫnozhͰuYb=3^ Xgܜwwe=."/8eh+eY6MS6Ϸ' 6W2p8#cD2Ε.ojHa[˖-[6mg5XӴsqF^6 Ùpi:˲9@ |6C I4My)茾wR@,!43t\rmzYSTdNm4wdPc?YreYivw"dN˲̔իGpMy5?ӽdQl.]:'S˗/0X:@mm:߁o/_g`020;anMӆsNIq+df'4WJ-"A(',YhbP(F xmgRJU_+))) }٢*0 086mE`܏ӥ (z)һQ)u--~o+"  .\.#@mQi8$}ID~ l?z p}]]\7q8%l`p mϋqޯxH6 ~6F""rMJ}"r"R/"]dD$NC*9ZӴ]sRGBgAMA a pHYs(J9dIDATx^ tWb/ka!hhΞM"d<٧ּwh6($`emR! }dž*b8d7UM&o! K!{C#i$qfH;3&JlIrI*]>^,`!p AЕWld8 9ߺ+gg(W  @?=y%E\.;qgm ڬbtt5K:푳  : +M!M` ŗ;koBa6wwPɦW%Z5)+j<'"M%n;y^:[,6}1t.BP*uZIUcnګ[xۡ!1oΜ91J:mgݖD6 'Wjv7Hh\S2^1d_5ֵRVP6Wdk6NiY"%/O]#ci3}}t/ҏ R[F^eA׮Ӭ>W*%$0V{jA4=mPF qE %@;n썗+zx7t̛M}\ X:2;]~c5t[EvkT>?+,ЯB&V#$0xV wE\aE@!=F8if%mcD^R7rʓIXkwnT6opyKO @ߊtdu\\ `HY_^&X:fEmrieQ'x%`4ֈ]$wlca]~uô}b+b#)7fG5c)kخ%0?3:UR}U `HYIonJB^)+j`8h^y衇 @ۺ WWWWL tČi߷jվb&[܈! yn3 @ai&B'W`mV1`^HY8~+Ez1G}bWns .,h q-Pr~ߪy%[O'}6j(̛9b#cT:QիW_}@ %tEb^?| m$!O;hkY;NP9s_[H.}yTJ4!9W,}KHr%t@IX^nK]\"fB'5&t{?AY-|~i2aBl:j#Rّ0eWaRD]\!RPz\p鿲 +4PHC}u$oQ p };M /`\ osW%5K.~|"Fi^IÓڰAiɪAMFrT]*6[=h_)6`8)|?s1$'l~Oea \T WÇ3-  Jڼ@>Rl%p ѼWWb +zhxNE(2/Ks9&{kzRwۈj(:ȢFuX2=JSs2{E+>`JVIWm. msH(>v7BK$q&ӥl6bwˍ+Qv~o}6 +dG v;4ge|Pڥi(e%{0c=>ѽA,}*f䢆( Agzp=3c.l%6/{f:?Ջ ҽgԐzxv$$ȳ\_- wPfF3=&f] yЏ,K3:I-` 7ίt+Tp-=WLo}#>1+O>k473q-PM[=ba?~QB6TJ,3S?JmqKS:]4x5 7_G8㫧"LSWk%8} e,^4W:JFβϼч%nIqP6!J,;F% /vD}sֶ8`o1޳ar6ekX]{f;I G1n_TϣX4,X59rQΈF EΊYACUXZ8YPd2+o.A7+qu^m 5ueA}0-c Ww⑾`u=xň6E}A1P`+EͲ$7J3 'Rm!ui7P[/;FoeR(g% B(lP|{Ut'2V=$kzBE?(g4&bA-;;w3URdRTꙬm Iq+8F::"u&VjxW_Nс3*1P`+E`NE,'Wb-j˃C]T1َ]_gz5.QoR{TֽZk:T?zKsdO4Q`Z1}됨%r +a+79,vַhX \fa!HNg{5Yc 䝳 %ԝ$ts~. Wwt4WԮ-?@zZ*Va׷-2z?X`8p}[3\J  ƯX t{50D H@`m1)`wWWWWp~PᴂC=.^(=#r#R`z?X`+ .3f``yr-R`z?X`R1T0R`z?X`W_X C^C^C^0κQ1 PW*ٰ]D%AQ\n"{ XE-?Sw}IL%{ κdp6gl˪}|7]KV/rl86{TӬ~xٳG&ch^i%6kt\dkX*}+  Z@⻄gj}RYjkX]pTV8EO .wr-`0^/ y" tV{cBVgO[IXoWnKdg^6Ẅƕ>wUb,adNowy=&Ji&RJ4K˚MxoppT9$&y?b ̋ŕ Fc-,-.Z;Fi++YSK_xH%҄XV=9+f9)&ĞjWo ag^6Ẇuiz| eԭjAߙ|c3EAZ^K4T+y k-\2K ]Rq5 9iB6.X~xjn[ecg^.W kw:5 Yv/32ݣM/dB[B}:+QJݻEZ /HRǯθNX]U'ֶQ`iؕQ#i6>QֶL3b`g^&Wt6 m"5$¿6o1<7&W`}bImVZY[]W|>QS36 죦?w7}fUyW+E>m[q(qJJ#7y^vQϼd LhVzBRceuxKËhV\'/q\ MiȲ!U# cռ%fTtD6S:s.\sv0"@Qm Le1X~JUIN qyα]6tu:0!]Ts ^DO!okRb [c^7R`z?Xq+JY~+Fw .z  _\: x!ڶoV۪c`yE37YU/`-'fd0Vl܂ `@iB^(;h\fWW=>Y^^! *oCd7~EG^ٻ}|}o羖˷CuO &:JМE),ca!H+Pr?v럓"wI1>\ WCYhEf@X(+ _yE_޾#t@GHeGBV/'[0 M^9y-.s<%EKo=@YoQ欤ÄO."q1O: uʃ6fX3E yh)WԳ~x`IW %?Uh✡YGu t>bGҜ /:"ډ$DԠ̊AcX:zɨ(aklCQC^Y <5lnVظ6Ʊ!ֵE[eV~/dXd1{XIImS@r E$݄4r&޳a)[3c0'+7لt颅|+~8Y6XYi^ڬZqFPʋؽqZꕺ-.` ))hnPp}*)eHhir&"uƾJb{-rut>MM5`['8JH<$!qbR8Sۤ[9ܓ\Q4#V*4z39kX{pk"UV(r+%e7dD < ifK."(_+ŌhklHr Ņa +4sy xpZ;[6{& Utb $;۹?!<96<JY;IZu dZe$:hnN(ut5)1ELʂnKwTcOU$+=*$@^)Jz?WIǓcFt$Q }1yo[L%{ !@9C^)Jz?+Dif[@^]@ N -y8,YmgtJN+jO.aZhZ&V=!5[Mˊ#@@^)v~oƟ'%4MxjXk&(ii  I[H}FsXfxBy5M+ @T`zXET(0 4}~-cGJ}ݡCĔVZ%AEI+NҜ 1(\e<*YR^iؔ↹I7…A^)мCyWʎޏ90Šz:p+FnO,ho5(Ov8 Lޏ908 v,hxVa߉7*)zZ.]ˏњ;HؾBZ?1(q%] y%k?S++|qFGG tZ3Aݬ.+(H^x[ukvY: Ww?<7miإm^3Á%m\n`ߵi"6*E}A1撻w{xdo&}_WpW'Zh+FPrL}1)HWaM* EKFו +q ޮBn5h~r^WtziQĢaG1>"{3_OCJtLJđHr4qy@^imK]!DmѴ쾤;X5qDGIRnnZtg]EKZrÞTE 1xRy$}Xf͒XWt ,6ExhQ;*> +bhM;y Ƒ0F&-O}} Sw#+**c89ԱV;=vFI[+6y%MVmԁ\4fV[V/CfM7D\Sw%ؤe{X&aJ6Y[JJo3+B_ra<+ xb>Shdٶ3[J;[f)GHPݹV4αjey]uSlha[HlHa9yW+@)( LUm,z`L +yZO P oYZIVcְ=W+5՜zfurdó)3P=l،g+GoF.(RQgiG3M5"/MN"l ky6Q6EY\Yƒe~/'1rbnSw&m]NQZ7_˵Z2hA%u$o\׷+׷~mWSZ7fW{ōPˑޏ90?BdP^)u쮒6(pD-P+M)MWy<敏ZgkHV7>Bb cJS6wPwKg l,1*ҸZPJxQT!E Y%y`~4 +7-Myg |愳y;2^)ᙄ% zvIʸ8iXRH]x+0T,:H|)Tp+0vRFG/t>p\O?>G9M.튩rAowKbҾ깯e9T,}{_~?܇?Y;[LF%t!>-ju G0=0?B4E'O]mc[?>ML#kRJ<|̩}1[Tr EadRABycbĢc{ [^S/+r^aaW< !oa7 TKpZ?lal[iq1K;͗q&jG5 L XǏS%GS=*Iм"Pyuڼ`\0uC4M RiG HN6Wx , KN$0z?XƢyŨ/pBM"%_5T!l?&.Ӓ)K͞3W5 2U,NJZ|,hؖj7>(z܇?'Q K.RG楙Lpt,c,HфYh5WY0̛I{CĚd+l i^!WC<\ |Ba?տ:E|2{9F@7oS+k`!:<47%4d#w;m߾u"%x "uT7&B)n%: n`Fn][o|?Pp;Lo,CҰGk6n=0Ăx[~Pda/r[|pX1o@APmFcakA|~ہY1:?X7Zro6^9Fnq|f=SܻƕgIC拲1U (et8s`,c3|r ?ݺ޺7,;돹Z~v%x[?IO"֑N"5 Sм >}'䕉ܯcoqoxv~a}_;7&}acOo_>קޑpWX䜜7h0$ W`dvq1Urs@ѝukd-] Fw/2^]HNzƩ @Ō ׷-Gz?2,eI38@ +z?@Jz1JiәW:Ơ^?{]1-LD+=>Tg|P."fyvI6n/wꉵCXZ#ve ߀tG @Y{wfے< ƭrI#`F@yAwֶ@\ g Z 7͚PΊ4|>QErȿijY YRva(GMJ j! ;uYLX t׷U~hQАWʅҼeHb w_CC}gNYZH j{Z^#OaȂi"t@:?(;8k= Y{ [^S/+`<sxһ?! z+`4)# sxһ?! z$(g^&0;0;0;0;0;0;0;0;䕲kw:cb&7,eXĥ)N+R(_+%iJFYG<%m᣹8ui5um8a)+KԐWJ?ՋҜ9w{ZS&Xoe>+!zգ.N\ #D^9KFGK)(O+Nm)KHd($&RQƷtL'YPM3 K G1tϴXfxB#W۬ Ji yjx`hk< nfa#%Xl{v:HOf"M k5cdCO?tU6ey$Ţaw1˚`r [KD Vb2jKE^+:k`D&tVӲ|`F(O3IhX~QmQHVFB]12k2O$ g NшnХ" B 䕒fC?'r5'IA-[\X &SXa7#$4 F!΄(n~rs%;'Hf̐Nf|`j+$dhhhΜ9bƬ11U<>'éX }4MfZAqdϼL4KfHDKn<46ҙR2m'mv{r, 7q *x@B^)u $.Q$nV%t>sExCl-F{29ܼ}SL'l|! WwieD{v:.óMwC9ʀ29>Ul Kn][o|?Ey$%_'qx\`%!364I8\^>gnmfA-#bjA-; 0Q46uObS|طbOXаRăO?: .t'6\& klޮvmit& +{`䃻<婗˲s~ߪU΋גfC?'r5'IA-[\X jvܦ'_$4&G*Y 6ƯW秹0 P:G>ywČ?o][w7}wˁѣ᛿^NewbCp Azq\OG}_/n5W>'?ysS#w޸j= a4>2sit ADf Hs'<=׏8p?crxOmI/L}_; WR E IJ8:+W%Wufŕi:poLr<}l䤰"ŕӧ.Gex`O\A^( yாٿ~ȍ=B|px;+5ⰸAk8C7>rcJl\--++@[tӻ>]$eXA> D̛"~>6D1JwQn!S\?uG;>z/Zw/!-.*D[C^()=Fمs*/' +fhV]Rh,*09ffxӥb -ԵR9rq1Ur^y1WX`HJ~?yyyyyyy.zqb (2Sb   X 喝`Bs#1HAIENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6248856 guidata-3.13.4/doc/images/screenshots/0000755000175100017510000000000015114075015017237 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/__init__.png0000644000175100017510000024545315114075001021514 0ustar00runnerrunnerPNG  IHDRnsRGBgAMA a pHYsodIDATx^\}߉~GDd)"Mnٝ`ϔ^ ƪ0`r7M8LIP͓)AIUz?CwQEw?{Y|#%B!B!Ӳso~⇕_fˆ+&xh]8~pAs6!B! iXk #jw?s?gSQ?fؖ-J_}?^{p?=9k78ʻC/&8??ԓK 7n=c@(#>GssHGZwE!BHB/hGT>[F6izLNZo~:{?k7ßG?1~s ˢڔz ` 2%TĞFV+h-PB!BoYW1?/m:qED}?eI`~;D!B!d(d{)`D>=Soc4?1nxq 9'Zq*QdG:*i)==zGzO÷7Q/ɖ{7?˿W;~'Ƈ?a[J 7Pp:FK@thOay XVFfD\IX_t)GqFOs6+L&1fyhn[!B! !DuGqïg~gGn!W.?}[B;$\4'p46{)w1TD¶L̦7y}E1#B! Bȶ'N=W=x$_7E|GֈɶᨑUD9;ƜY4^*k=Aedž$IJ絊k>2{fHWAŊ%B!~A="b !B!^u81!B!PB!B(b !B! B!BXB!B!E,!B!"B!B`k !B!XB!B!E,!B!"B!B@K!B!d`%B!20PB!B(b !B! B!BwV~ /]{3O{B!mXuN!BEٟBHpQܻw~XB!XBHpN<-}nSp~lV'B!="_wqW (푐=4nxP=B!XBHQZҞ%sT{[DzgOꥴ~NY5>9׈;yqI;5;[皲ܴjr!Bvgb  3ko]ũyu"cN1|զgoifW<ES 㷦qr}Z1{~B!<`%d0H"6+mRUĮÜ5pN۳l4غs9SQ O5]Eט^W.!B#>É ;9)xyYh=W1 %B!{W^ /b?p` ٛHk8|8بP\B<bvNQiAs[2.8Ӯtn]Cw?scB!,S>yy/|SvcB^Y;lQu ]HYIzZEՊ݅"N"*qM;|O>RnzՔo|iL~_aNma\>xK!Bz.zbE|MNoI_~א^'mG·n֠'noMuo`d2vυF\.l[ri6ml"\/rϩ3 }vا<_YcWX}ħ} ۠bCg3m=V_]vIrhp_˼hԟ9#+l1%B({Ŏ>x*čS8[_DO,~xT&ؼi̧y~KE|wnxX4q7LT՝/ ;[Bi)|si|_)?>Yy~z۾[>(c>m,3Gn9zusAq?=B!/ٹWrXdo>:?NoqD*֗>#"+⊁WU'~~΅9Kj~yOX~])*5~wԄa~ ڤ@BbBKsB!JW"? P;_ʯ:iO`>捿_9rB2_wC~kY&slK#b—>#b-oKg0~ >O[ *KRMVW憈c--V԰OsyZ}=0,1!B!‰?B5WQ 4_) ի8U]kEN3F{_u=n_~X -ɹ'̻O}hA5dҊ-Ø'ωuS_mcM՗ԫ]aRcڗ&M [wS ֍S_ҟ9([ߺ-"VpّѬ$B!8t^,t@;lc"IK(>G1ra'B!sH mJL B!dٹՉ*B!җPB!B(b !ŏ\ǭoWXj64=B! E,!d`78;ĹW_X?M5M9z.!B{PB՗~|wҫ½Q龦1=G<B!doAKkݯ~ /(; n`ht_jͫeB!Aۯ9pM!{;*B){0'lje_0o~]'>+MWއ'μH-e&:?3#sW~_p!GBȞ@Á_/|M矍ٚ"y4/| )- O$+ߍ ؤ^/GlB&[54~HOTI"җL} 摼Z== \_Ie<\Y=>wIG^~2g~e!_H/?ҙ!Irگr!E,!W}!h?-)C2~89'j'>943d5Ϣ#5M`wWJkigvaM{xQP~Փٲlm_<}!"GCQ`!}+/uOHzxҧ25G8 =#3_vd!A_KByO\v^'a;L-sލσߏ?P&b c~d/|gĆ2~H~-n^F UMh67HC;xi!*Ԯsi+[Thg[tPfm^B(b9r*-~cAƯ()KԲ;=wy |</Z"r䟯u],mX]Qbʕ:> e_}!|ROZS˫!?Ƽ~|m5ԶAqBuXc?`^ᣡN->m̧όjok߼z՛ >N߾ Li #֐anex99F~1"B:m#F Q*mq ~3)bm$c[(1ae$mKb\x}|JbfEɣ0</M Ȃ36=fV%L2<%=?TǢ闌VhqdjFY²[yyKZ_GCɿxӦtejل:{ %&B:;[*"M\PS*5b; r?7,BtCy#00Ü\ز`g7="ԦELIEH "ֺ3qYbsD%kb;plcM LH_6Bkb2fl_5"T}GN޾އ]CH|7~mߵ] ehYZM!BDly#@jHftEc||#u֟| Ƈ6E}kn #m3hk N IJ keUmEu3~I}=[e,/k}~uk}5EH^-C2 !B`ӕ5Wx]G&0=@" ΑzZKsr^.;srz^ѱiLOhh9'YAɄָBlg1 OJ-'ELm) ,_C6URFn~ب2 "ؼ4~!n}GOܕ^{{6ɣy -B! >݅GEsC-B}Vx&4"Z]&}QӢ"F +anTϵ[df )O]s]Zb9뫧>/_P}Av*ROpD0QXW_,֤cz#E06C6:&ljغ )E1[J_'JֆN/ yZ/1L _ä֥&WP[Ly.;N# '_p#*y B!7v0]Ub 2~'~Q\n&:k^zOl3V_Oۿ҇Py_^U~l^"N K!s؝'v/jizXa۾ʞ QF}}P1z_1]E0bא^عeFVQT D.hJ"@}Db/ʝrwq7s~-sk8w֞ޞpO/s8{ /C`ۛ!7/g/c꺹>m[ޮ a7ڰ_{ڷu{YLc]cX'pB ߣI@= vg/1D>텝;],$"Dd]1 m'#N8>_L~=xg[\^]%O2* wǛu.5ua,3skV~"p{foLymk˭nK 264aI[Dž1gO𳥭7i,:M'OvHH.O *"''_+QF8V7?;[!Ϛe:A8k4'6ѥ[BY|ˬ뗺L߃ݴ3dN߶8tgmknk̿;;;p|w'~'[L#molKН-Kz}np /cZBMH-DK.eX5 55_ 3rGE{N- /!'kLJyco_X}HL#Z S2엝&~v݄kHUVs_;7:`rH-M¨Mxv]eKFjA-ܨ}k '9-Ofb38_1RE̕9G>'i oI:j?VnC|ǧqf]3NJrfbse8&YObܚi~J_Vn_U8{t*۾"OxnI}ڰggP<.7vӎS18ln8-}kN 'p,MO}貿ayyEq0%?;/Rːme4Zzށ@9 n0oH3?Ԯ%9xM95ݙ-}8'//|Ҿ}|߶R7tgƖk̷OZr;wmOO{:{:mo: /|!"&L*TV1%'5 [ H#j$Cm s?YH&lS;(Ǣi`df#6ꃔ05Dv^/p[ dGtEfPTJU8Όe~ulcԥuK푩$KXv[^R>V מQ α'dlkߪ+[P(EH'0_SpfbԆ:at&f{W_i ;8Bb:XSRIak0"'#.ʾ6O3OɓPtC̯_/n;z}WMXnsQj-k8!!_fsrZE\㒆1_3徾jhss$7^[x?'.\ְP9&.LԚ-[Oι!s[ԗK+vצLǓ߼Eڼ?dgE~#8r8Պytrz w3u;QpBEV/wsM۹ egCݭW]&'acHf&PH1cs!E<$CfeKicVsΨ.c)b*('k7}w.!}zi[Lgqk8և޺z[+l[cڢ۩Ky}۠!QRvْԥ:"9e6첓PڄjLcן݌QNs&Zַ:[Һϭ:lB;KAvm9ߟfԕ$oKw4}9?<1>0s7ؖn !dAK!-s\8gLnozy\jBE߄ݻWq'pa]uHM.k(ܐø|bL'oQehv v{s*]*|Vol]r:<{׆1}CڸXDžkE[c]pSdg ܾnE`8_v3FHx|QH˺!RjmH_0?m9V%ig{ϠxyΥum^ sЯ??=-?ĺsϭXK~i~-ZYe\btŦv K8]?[E,!yPnFq[;e2"r9iBN>sF>Q; Μvn9ګ&-7bR8 zn;9V23E:"EG5[Q'E~Htiq\Bpd(q,gd`DH8MEڏٍ-rvgаo.mZ ycz_t;ןڹ1ϏAKsڟ"ч5 $#_ k1։sW-2.ǖ !d?BK!7`Jks֧1v玣1՟8k<2im h9plx'n?; - r\-vϸ.NA_wlgsƶyCH|N\^:sk8ך)~`?= !{XBi;wpC|GR]G0 ksGϻh~~ ټf!;8#3g҂Wش8q*֍W3#7B>hn@O3OɓN `km]mAGS0;|k9瞓^ٝ12&x@Zk>k>?;)S8gKc+md^ڟƋzs:Yt2w(OXtǵ[-wS=ڒLPBHܶ^Njr+$2rC~xǏ0.{ yFPKӼǏ?^fqCO[!B*qcE*;lٵ[̳ L}1계&SzV 94>+xj]Se ;XZQCKWLSd>oZ)\7|ߖVڿ|g-ax2c/`.mڟ<eOk8A;rD,:E%;>/s9@p8]G>25ݻw+v?.㙧6eP!W"B]\SqnWx'Bw[yb{Zx~FW%>ά'9HȾĖJ!g?0)144lk2r8eBȞ Oy i myM#`B[8 !m9)6/2d-32JMtU6 y%eǏBHoz95.uKOOn?Ǎ_C:sLl`wvI^عmV+,$gBHr^AzCm 6y cg$G"$eqAņcghKӸEd(wEҝsu}֗RZFul5ue1/wUM!B "V&L*TV1%55 [ H#jՃ$bȘ-3e4 ,$Ǣi`df#`3ƙ$)8}AvPEȏ`fUD_-!ÉT0X*cQ(eB##S3RX'1JgB!BDl|lcViD&Dx(猇,CS|Q Ky)ss"tNW7PY5nW$L4ZՅ N6aftg1LL'Ä%Sa㢖vPK'[";B!dҕ-o() ΢qcxN>Ypfb E5t&lcLq ~8τt5)ʾD_Kt i t ҆AgyMΥSλ}u3{at%be3.g LO%HDs$9岳IJp>9'Eƹ#G1"2+(VV1UZ~$_`}rUmi+ IA:~ By mka`䨭xfue1 7w#ܹ1Smy;pަ^5ݺm?92v6O0O]do?_.82E},jFs7 ̨GlB; ,Ö4b88_ sz$3V Iy,ɹnQS~/ ;Vl'?smRfб䐲GUizx_3}K 0uSvwΧzB hBZl#]yϯvXzO߸^ы8pMuvzuKD྇Tm SSX.#bFC]O\BgĂV 'Ǫ;*'+.i}O=/NY2 ?Ǡ~qi8&3oB96:ObGp6׬Ϝ67{j6`VVGꄬM fN5N]BU޹{c>Lgqk8cghAs'.i-w.ISH_gYWfZ1z=~T~ gYor[Ux~22v{[Lhex%;,ʲyG РkKظkmziovBAuy}>ᎴgkȖ9^ "^cAt/-gyl_3? >mϗkS% J@E#"B4-jPWqN8n';]ߎ!9ǣH#=.kvIZm;5)2ΆMu+7R?LiSc8żs8 ;3pcH}>v>FsqiOz-r3xNnj8l  cZ'3~Ar1WWԞ 2:]._ 7}5Ԇ:#Ȗ6}'w1?`AیmYJ6TY,dPj zj6nMjF 'sv5 /^"ܿwr7dNaasEбN ,3@Zw"΋0~s Ȗ۷yv3r%-yIseӱ5v 3. ;+8ͧO c5cPBk4(^XG둗~ع5X޹.7ƺς 5{׃pzqkKfm<;|X@}"`}D&#~kO*Y䝟!} ~ @;ߕraĒGÐWBٮbuy0 }k}cnay8÷.lëa^^u_avOo'oH~s" AEmi>ςǽgt^?W~ט;w; p;;K]Q!kߖ6txݒݻw+v?.㙧6Or{_},7sxNF㫑!W;0=É !BȠpbB!B!{XB!B!E,!B!"B!B@K!B!d`-˕B!B!xv|(9.":a!1L,k |3Zf9& [uJJ`DzsEc?\j[̾i@`c:[ K݌&B!dNĊ@L0QRY4DJH,hn%L wWD W/YH(8&Ǣi`df#(ꃔ05Dv+@dg-AQv&PHG@rlɎVF "\!tS/A18)Bu$c[(Vƨi ҇U8 m%)y,61Q࡜3TjłMk|Q Ky ss"LN7|!rDI 0i̵ +bu6&†Thl7z"HL8uL!(RQ$‚հ>mcr\L,eƓy$*//!o B!BH+t%bT5(b`jU_3.l G,Fmb Lgb-z\ s9zAơIJפ/X^#I7,!B!JW"x^=^6yrvTD(8G`y,~lOyQDǦ1=4YňˬdBJuk\gk}^L8hv3M'Y%ؖ`O"톪[qѷA,QuDxl84/(v'E(y7Fmhԉcpb#yVUfC5huVDhZTPt҈|%̍vK,Z$鳒Krͫ[dy}ԇ: /N[!b8ڒA7ZAcNg1ڲ-݌_}A"Z\AFG9ů?naGm6[}I*((*01| j>Ǜ\A- y:N# 'B!l@m3J &5ö-}E1bibgj^s߱{B!s{P6\Fm"B2ʹgGFUwaPD웟9{B!w OlUvNW%KEŒ h,!B!XB"- 98],!B!"B!B@K!B!d`%B!20PB!BtmB}UU96)<)BvWn%iАR6)9QDgچMj r~?D`owVѬ|\7zƆ̟YG>C!dh%d)b !}?}WL YV\-Teطc(#+.`a*r`{SEx*P8mKSoLʜ?秴%rޔ\S^z 1%gU۟GzrYB%5ؽ>6V_N WeVA2G6A|e,cL'lnRRdDP`wB?я$VJ%J+ h7Ch#+%#+"!%V/a*BvWzlPBgo1mJ rB)JN#wqOC_=t, $SXXlȄ\{`b멚Ϲ޲/ML!e0/WxLy4TfGqcO|av5_3G &0Zw듲R_Wwl&4o+WB1VTV`x)Q}yucu>䜦ckRy^}ez%%Dpe1kO𳥭7i{DΰjD1c~Bu,E,!dO0?)kDܰ&(m[Bb 7mB>G=ͦF$-X<)qʖib NKZLvX]H2gg Y`XϢ( zcId+,vn3V` 39'9l"3>^52&JUL K1,ՎY;堌Z5dcb[I*<Ӑ-sEzhYbAm%b{t[MB|"Nݒ2n3*LOv&Phɫ/M3S! նomC2_􇒺m v׈3=Ȥ;N>B!=On% >r$%3-ݢRt[qhEHy"^ :jXX^ II@U,Y.!F @d)3x1k)[[s|"J+L`IKDC@ķV:&Jy4#cT469m$Ց:Fht^fc bj9.s\gM1reŨٜRSvqL]bh%b6#z›[ oO! +bʆ$0['U aszQ&ٻ͛7emD&n04[XQ Y&nUǢܐWDzEMOP`5,G5\GWб0+nXd#r_} 1?Ƌ  Ą:Ī+9j8)gie٦ysT_eex4>K>:ub3gqQ3Z2c2mK~΃f8F:ebk_F;x?;]gi^;!ivΦқmC .㙧6 ꉍ1B bjOÝFvΝn6qXijFv}9bu^q9{d Ou Jgi] )c>Fǒ+XBޢ{QOُؔ`'z߲a3 +LrZ|>| -3@O[tkz!;QQ [_ly(Mt]zs*D,冋bXQ X}Z,!]"_o pۗ9rGo&Y}]Kp> W;(} o/:̳g#p0X*cؙ1FLԯfiVm|!ڨ BqB!k7tam]? .oOcUX_v%u o𫯕WPUJ\Dk,3m|7}&vNWu؅W@8xLA5ЃCȀ?bu!WJĺb6E[^r1Ł:Ӿ󪂮Q9%1Ye,/ItB!2t%b^oГ}_ ӺwԹ4Xj[jV,A_X_+(0jK;zO罀(cB!B‰#SXGEq̡Nl>+izF.ŠM3;M19G Hfp%}-ӼzOdҊ-D0QJ_hlR1 3Q'Rr z<'8mX RH!GqKa1L _äڣ)'WP{dNI"N# }'B! &@[ f4LW7j )aޥLZxmJGbB!=Gd %w&`Ò7 N@d~06Q^{W1ە籶i0tU!uul1B! El3^JTY,+9$'055'oܚH[B!%N [rpeWr ,"x9d?x}ElþOYtzB!AK67E{hX7fcB!$XBH܋c'ȧ~VU/SbAtzB!BKk6ʳ%?NbB!%)e]gXB&67!B~"ҟ`-‹gsB!O(b !ɡxbyw{KMB!k(b !B! B!BXB!B!E,!B!"B!B@[!;;!B!d^[G|(8l`vtnl !M89KF Bg=9)ԧfY ;=c#{M׮i{%?9)<緑2P:]cB!Wt-b)ć04Gj&Zr)Mwxjz餏"CN׎=He )ݷǤZ{N-c6ag-jg)=VSX7H~+AŮd9ȉMlnXp_f/m5xMU5)sg.y7ߐc7RHWeLYd^߷1e9:B! .݉X&JT*ƒHM׭ wN 0yu[@gnF Q*mqvM3%4'İ %L$|n{0\!J)# ǢȊce & c蔧勡?6<_dj2X'1:36vEC7EQ[eV#E2w_"{TY5}fNs?UB@Ū9yO=-s s2TJZkUe*{3!Bҝ-M`̺F"c"B9^(d9Oh88Jxʚ~AQD;[<҈(MWDتG5\s"\y~Ȝs>"p2߲EyP{rK!+[PKEH3҄|LglE\:(Ũ tBZ!LzZů0;g/u`t52H hhq^BoTl Qu:ꇆ _לZVBU{j(ەrާ !B‰#SX>q̡j>eؔ=v-bŖ ŔeJXws L{Y aOW%bif}}ϑ#G̿u+~;x3:vo sB!?^T~#6%<{VK;/`][uËsHi-猀M#Ž!B!} El3^J4'層sWɭt gu&0q}56za;V!k(b !&GGl(ȿ:o7yn o{RUqK; ~JyxTҳYQb0E /"GEx\B:nUE&֥^5 HvETD7zVU^ήCbXړa7W\6?D~ AJ#"0R‚vc2~V`Q/w&޺&i[*{ f0evvBPBr 1/۳/"rk}ӃSXgL=" Voc^b)#LrQDx 7^ޑ fpN>h%^E"Z1|_lli_(YQ46Ϙv`!0}~Xv@׊ n tPfm ;#"k%gd3"[!Z!ߡ%%NrޗԣΊqEDZs7%G=6wE'ͦ!6z/P.nNNK^ă1LRƜ~$ăQNoږ)E["w#OldŴioz<~ƣh~ '缦E4JJZZKmhs-`ń¶[fXuxf=%Q'Ƭ̯79m\+B=rBELTo}7!>ǨH9A[y嬣RQҚm8N`MZї3 lxyC}0b_{ydFt0_5[)O,NյElgy1P@rdъ6CAez~X5:^ākԼC+ua1~&x%Ͻ< oB! ݽ{bx2y8`SPGl?u!= >=48)?K!d'GlJ0艕?2&|La&Ŭ!sQ#_nvB!)` !l;N/؝§x9zaN]n!V+,47-B &t-b)ć04Gj&Zr)Mwx쌓+摎x^ϐCq$eqAņcghA8x2RWfJ1i[\m{N-cs;5YΫ~rCJU묷U)ιJ]-sf<ج1/uW &"BDt *&/_t흾n_ oV{~/YH(y }4 ̔L tFX}K9Dv+Ndg-~8"mi,sP+3J)# ǢړWά`)^)@nQK KE<-_yK(V8'7k ggL+ܖ %,vObB !B!]ҝ-M` E^B/AYDbX*ȋ["uqJe"I׋0i̵B S Nh̉1U'T)xZҥщiWFKÄ&b"$(j1'h( ES7ϖƓyD)//!(K!B%]F2ͨ}8GZUsqm6Ũ td!Lz[ů0;;y}!(s9jE &YRIa !B1;v눠H$@ًX)P.;srz^ѱiLOؗ}{f%[+Yńfg=!.M'Y%ؖ-4Ֆ4v:J-+7F`=VBmmڡ&LP2'vCxSr)D#Xqܱ[]5zӭ`(ciJ!B 'Lah 6^>jzF.<M؋NQ=nE+ F\[YOuT_J+4P_8gFn^M%LQ4-2.U 2%9TY@}s 0Ok(℈ZyJ; N"ºC8Pid=!d)??yve خI!^J!BH(Cwޭ@.^g>ؔ=Jyh3--h(*bV]; XY؞p{bJDL+u+%o~S8*b1voQ1\gq\U`!BnE>g?bSQ]izVCkۮ$787r#A#gl,}NL!B0Bryy=C]B!o%'e|cYu?^9 !B"ҟ:1!Bּ^,B!XBHr(8”ٞTB!XB!B!E,!B!"B!B@K!B!d`%B!20PB!B(b !B! r@;6X{ cy^B!yćRُfG2r8ۢ][i5\yv7GtI텝jزcKe^c %k"m !B~k[O!>48R6ђKiSpsN(#uϩ!3䤾xPs<{ idbY\kP|3ZfuR_ NyǢ Z\9jk %}u6 e _QWˡy)m;aSSzr zk-/!B٫t'bEtpv !B;[*"]k%26!C9g<]DbX*ȋؘ[1#u뼔7Dnp"z!9?9 ";{AqӄHtv>rjás§ FD)s,I`aAոKh}DLDɘx2 %-@e@lË\,!B+[PEH%0}4"FQŨ uBZ!Lil '6tNmHLg^1+cy)$ݰ@'ƎgϖpX'(b !B5]XyxunlS $Qw4'2\v6I '(cӘX6`(FXf%*ek}^Liv3M'Y%ؖ>v6%MOss6gffڨ Mj2\<<k'ZL!B-݅G|C-B}x&l"Z]0'}RӢ"F +anTϵ[df )O\s]\<뫧>/_P}Av*H)[>nOwqvoY(gh{v5̖ ~D0 u4:'EN,V&aRsԢ4yB!B2t݊xs8pM٣1bKۊ۪*l )au{ܣ Y ~A"ҿ<ƞ|O1< =vbT]a%uz"]uRqsۊ1d{m26%G?_x0CuM`wHWj/`6 8"d~wBXBH_s!\D~Z.r;dž _S Gև;%=5BRMC%lv=gBJEck#_?pC'oE~×Sб 4HX&3!d%-5lQzD +7yGr[dLuP96nUHDf _3!s3(UOE$@"1aXf Xh pEA1'u䨞l6!rt5~o 2ޔ ufB/5xG,,mh8φG̳OR }|z<;FyCF+)Hb0hDK({/oyMKʵ'I96,&QDΨJDq4PvT˧dE̼[yjk|]Ĭi߱7m>At3F}SX}To`U,-wJ żGJȠCKs,X0W]Ug{#)2rd=SG\FQ?6ϡ!"GMqS)dS=_" ^K9#Z?4>̓Gqsg m616_AR7_6\rlj851;] ]:0Vz59$",u'uI96wM>LP֩j 6&"KOjk[O#JgK40AbZNzg._C AKokxBZ:9I-D.pqQUO(fbNC%0Of7oHc!Ik/J2j?27>9vuD 7 VĬ=#j 7qnjXX(Ѩl](y8%|#GI}qIز:$W Qz G x6, XPq7CKl?#Z\AF$n欧^۞AhaR+M_8<AswǨKt*}931ZW\7BZgݻKpo]d}}ϑ#N{_̇9x'hY .X}ދV;)L2(Js|YX`EH(`_CY||$W: "^T~#6%rR ! EFJ\"5 фN2 vn4?Wil 'm@og{,@!+b5L%Z 7p6MbjË;enq1^3x' X<G8 6?8BzN]|&6T˯{O})$5vvWF!7upBb^ `*ogq vwa{O}$j/oB/V+tB!BӵE8f8R6ђKt%Tm?']G:ꞓH|[=K5dX}HL#j|>| -3~TֱlL<)9-ZT[ٗa /O)mOZiHUʓP eB!BvDt%}*XpU҈Zय@_А1yu=ψ]rRnrLN) =鈔ws&l`__s;fV% jI|+K;efGVLVF "hw2bIX:3@V!/vJKE'!ql^Y_|wBP((;wONĖ&0fMP/X*5b;wE-:ݵ/v"|H Ü\+H /Ν&_bbm2E0O']$u h1vgpK㷥PbP;u׾}(w}R^AoBAEtNΉhRC F:V":{ 6lRl^㟏dPǿ6 ٴ^^z vpo<}H^+[PEH'0}4ᢹ`CquMxm;s/Ν~>agLLg^q)cy)$ݰ}fy\Ǽٮw/N*[#WB1?CI[r-7?M>&[V=%-JߐTV0eCgWW*<^Q;:z:ډ:LL<qmG+k!e͗46a`5xfuee1 &[ILMMɱqcw N2r{Q,N7$bYAF\shؓ,FVG)϶:|]D m/el1pXߙ~~`dN{%QlE 'LaQ34 1Zx'$Z|a-Ng"pU;R׸؟PsS/fks~6D"܄ZװVۛA93^P׆ҾCq|=kCã+u;uOxT5yܶ%lP_ry~~I]=aO  :~j~ hC=8[+k{n2=O)S!YYwdEd˔ ;H?1+$b`KLl՜o~oy6|ʛov+˿+(ۿ_*xIw\.ebr=褐cpwn@8$_53IIZnu_s:Oa[vb}^[j绸9J:eݼF[7猙#G%NC+m?~m9R7klͧlLK?rq.SyRmGXU.&b5=Sy>/ڃʋI9GI2|/gC}O8UK3_׾:464Էe~\G[Ǩ~ mSyAͽOlwۺE"SX-M⎣VCikjWO2txidgڵZno0vXέI>$hQ:_d.w-QTʈJ!Sfa7V.,,!jZ7 .DWkm y^QgfѨzfܾdz_y"GfDpgIJ[󿳌7%E~_j8""s9̧ *̮C삨-]]hMC\${ .$WYAC+tAŬ?̻6C,7pU">prԄ L8pj6Ae󰖟9j 1V l(b&šwbuo1MYFuZ-p4q[d<հ(Z&Rp 5Raw9w!*,\gZ6/u2E2rH7Y(8BrSZɂrѥ\*=%̗ '3x^K9#Z?4><埣?;_xhx%ڗꏽJ>:|_=␁t݆m~_i= E,!?9tGY}lϖpcOnntEL [dNh(ږp^DNoSjey^ EtaK|q&bȦ5yR"rglq+v9'0El \=ˆ jBC-`de,l26~~ iCGW捪PIk/J2j?~I/cWdRkD W='7Y{D_X(k1_ή6}Oh}Kp1aQef#dc~ /]3OÁl !X__{s4^7?asB~ 60;:6rT!5|+5<{E~<ږB!cv78_;;O}$ol/crײ@r "l!b~0T1D<[%KK!Z4H-DOռ\N(#uϩ3䤾xx<{ idbY\kP|3ZfԟAP-LS B5:B(.{ld lC(l6!mOM“Mw7yikؔY{ }u{?4B!s"H&Lԓi,4XJ H#j$Cm s?YH&,lLEL-IGP)aj&l __tcKvdԵ2R\8~A}Pfb\A wM>%1ngaQ W5< f ;g ILNN"?(NRqRFaʌL _²[HyKy?g{r SB!{" ؄H3XRs(=/a@ܲ+ӽ-oQ$DTLyE4\F`}vc ML8QS>8~!mƓuBy0a Dkv9j*#*VWy6_lѾ&b"jM#ym!enIGDP''`)&B!Dly#@jXitEc||#5yH`V5"FQŨ tBv!L*Zï0;w 6mb:Xa21Jp(W+#ڻ2 m'B!tKW"x<4yrvqTD(8Gny,y&#]H %yzbI/mBW|:-L!B޽[\t<}){<"f^l4L׬BTO,Sj7A``ڞCjhå Jھnȑ#_:OsAG!BnE>g?bSLa4VI,m XeO]CsF~7B!Bvft)aWɥbi$juDa9ٺẑBP ,,FU}e, Uo>w֭@حGk6B!M%*q<|\籶i)}}CŽf#*!Xn^!b3džGYAeu S=ԄBi E,!dDY@M܋x#$d [T!RB$[f9ntug%#6,>W|e##BH]N<8ND6(~ ?;t8z ~Q=SWs‚S)čOkn\ !\-o!s۶"gEu?g_s'ҳ;ϖcO>'lW04ykaԌ3 dJռAQ'Ұ*?Q}64:ibgtLd X;㘃'd8Jsk-]@2X/D8gEkxMC2bdԶg,HhajshޟlSOZc,ytu3 LgbRӳ#BCwޭ@.^g>BH?nȑ#_:OsAGv KV Nѕ'YcV[]BĻM XB!摚q m 2U C%B%6ք.0AiB!;El9B"I`pBYWĖ fG|Y(D706ƹ !B!]1JmħfY ;/32j<(澦TD>_H!B_n8ŧxt7vt ݖ-{B!Z+&5ԲMR6Ux<'};u3䤾xx{̋3>$m}|h> >v)g9TK+S_QמRrN5: [[ko}jqWIm>cc{!˘{MHy~U[2ϯ%NnB!<݉X&JXiV#.Q„;+H"ɫ[~̍hR!J%-ȱh) = %L$ |nK'8dGVL]+#5!۟e&ƥmrS㦁A}N ;lqz,du䱤ԞdLOqA2eAe,ݶ?6ʕytlZJ_" St*,7yDųU6 f9JPl'z7_RA^DܲUU Xr"*" s~s 'ĴCn3pB;F ZydcԔ9/cs7&4F0Qkm#m'Qyy y?3M}( lJcˏP:]^d[7 BHt%b 54:"1>>%B`V5"FQŨ tBv!L*Zï0;w 6mb:XѰ^5Z|CVD%~&v}DITO[S~5ʽiikTDykܖ=U;=ɾv5/6Zb`qNѱ><8u{x4-~m3=-ӔiGvVKBHӕ5;'x]G &0=@" ΑmҜ&)䜜EtlXj=kbLةn mϋ 6yNI?;GTݎI[lq\gu=Gs h K40\2J@8n\Wj/#dU9W#,fv򖴍9wo3V*!s Ͽ NyqnٞGLb?RY!Q)%B!.82E}$,Pǜ& ԛqfC5hu}14b*8_ sz$3VIy,ɹnS/ ;Vli?Yn%d)o0Lɹa π jCF06C6;fo>따9Ѽ_Z\nlzbI_M`Z|u>_ &kª02]ƒ6 [/lbSpxgm+g欀"v7r=rexyp\D lODm}v:L|k}^AXHmh1_S;}qBTal Sei[ 8~&ֆ2vYKms^EmiŶ&P=oڵܒ xBz&sQ!c"[ȍ|ŨjBZ; 3tچmn|L1==E\+F\oXvG! ]=t);PaݶHX587dn1BCsGFaܶMEzg]-tkYsX3Z5>Xɐ"o (ՅL(P&r>RɟŨo҆&pz#Z!.DH aR賞rFY+][=ϼϏ6DLnޭcg0%yǵP@rd Nl<>Bq޽[\t<}p)CBu>G1y8|~꜃=7!uuç\mAV~9!Dzx)K!zb> _B!}E,!Bר#BXB!L ]/D |e !"B!B@K!B!d`%B!20PBHSFn |o;[^Y~9}i<}[xk^PU韗}OMީj꾓w6i6l_Pm[6e;y_oR/=^"d{%*q劈y<_~59&zlX۴HKzS)%mvZJnfGozͦ՛I5IhJ(.aYwuĜ,vatA\ꔁ]D1ReS?9)<}/$_XaSz,1BgARk^`"fӻ#ST݇++؝7 bK$PBvC889d?{8O<СCokrWLx==Q^C.s-yy\q̥044e7}Uw jqm-4ٶ&nԎݘV;{R,BK5[_+ ?,H_u6xmCeQ 5N(;sB=Jn^VB+2}B}riԡKFܙ'Nxa},t_Aט_%6ϳ&`s X?{~z"?'*Sxr6߰7"F/3O'g'[XR1*=N,RlVl7Crk1{S7|xuԹЛB,0qZl8i,mxģ,Pn_TjGW9[҈=GGs h tR40\2JҝiC'8 oSn^AjZU[A0WQ3s޷by徂%@|u26Ď:?'<Ʊe/ct層eg5&dfvԞלfNO9>c<_D/ ~{@ۆy3?-M1kCR$rV!r;DRVQDX" GʜD]nA7UsԣXxЦPwSЊ0OL`̞ܬ=ȋ+.$7u^r{zi /1L|ε0!з62|Ed,aY PkCDoF͞dggq40!Ɖam_ (3AC㹷d-;fG_ȶx-41NcK$cG ӳ%?N4:&mHв|꒖É{ONϣ"OjY1.7wnzbx(uM+90XQq8aedÝГ1`:gtl;pуnj^+]0(K.zbESm /2d>i{(kgdjՄ$lJ>t}C67w?v ]c cJ>Ól6uhYCuy\F)/‰xwxo+u&~t07o&0HbS֯2r/O"}O=6I[!gڧub#+(/n6BKŜckKvv)3#Wਧ̦}fO2=..ݍ}kK"T>4_kcd k{.Lq<3_cM hCo=N_|K'Oxc{ag=3xg([ֲW`-El!r+Di 덍?{h8ڨaTn}%4>WCPe䐺.7/fHn^no r,F2Y>/']H9S $GGf MI`8ثVqbVVXvg1jʞ72 FkSu6Xު-r:Rc":[c o9v5LK}ߟAe {KjjmМ1%0ˠyotq#9\'ZG|f&;p:R~{XІw3vvAsB1e7vg$Zn] S_ZĪc|7;^m˕7|l U %^﷥KHH; uoKEiv'6;mybL0QRij䂦fW׳%:v61Td!| DbX*ȋ[&uh l '!aӘ  s3 gl9-RQbZǡV=lNhKL]-RhA"iꮩOD)4di;Q"ғ!tyGYPx^\B JJl{g7;kYdJĚ 쇮n덏cxE)/U`|kQ խhu%vWK`:V/>124o{[F_?4ľ]폳UUf΄zB5*QbBڈ)7 k٫nnXazF>ލf20ڍpay\j0'.G To.f*$yZT[z@D&@ Ab/y=}?9R=k-h%2C @c`{rdP -|o-2 ckĴlR N%b-d2V)3, d2c d-Զt?$$>sବc=kWȄ]Tk\R/O1G-O^Uu9.uPjl}H '뀓=nL8jn^ lԸ DRX/ !B!܉ltAG-`+Aul P2t0_1J]ĝ9"5#|ӹZڣFm֑k\,ety D+/$uy紿j]JȵIQ]llvS˨z[At=i~:6WGGuXD5:M!B= =x _›y N,t֨K.5DN?#9#/lbPN;e޼{o>}|Oثu=#B!iE9?iC9%*(鮟UtKl'.@MF~U M#G:Z͸+VC O bú=k1ڼEÆzRu6 aB J{t(wFn`>#BCKc80;d_S˜ʵbYXGxk| @V8ũY~9~~uBUHDsz!JR0nÅ2 bgˇ@6dݾo9O|K=];8&BȅBKcq?p{MA+ُ?=vՌȬ"gjD[r(ЙL9\|XA!;x1q=q[:ylykp2]8rk;!BXB%@]y.BXprb碫&:X*;:1߭au5pG+8n'cYg!Cn rE!E,!b|W{縍kQl,ֺx!w]$OTsӍT 2h9"L-t@Oe$ÆPr u _%m]oC:^ô2v [\8zcB!E,!dg;;x&W'uqosaD%ŒBȝXWvH֖ aJ6y&QB*yR8&͉.3맯b׬/k^Nbuyb*o<>vB49ľqEV]|'䞼Sn)cjw5'Ɔ}R"Bܰ#{׿7qMB'{={vr^}U7xϊ[_>>'ܺu˞2#y SB!$>s/҆CK,!2{J!"BN޽B! E,!2DQĄBCl.o%lՐ|zL~gȾ>8#d2jUB!˅[b-: h ߟ/QFH1T"8.QyK68KB!\`@y4zV۞ Y͡UF;3KR^c 6P$Ha珘qy߇kJ{22 AOmÇ-lRtg63{Zڞ?+/*! }:s Ө`No5q_g橵5ot~|yWN9+Kٽ~tI8?=♨E{=d*uZ5 /B!2flmYŶ^! jr#b.H9:oCKCusPŧq ׄYN$n d}XslUq|]"VlK&QEE8VɌ+/*]\>maElE ܒ{-SK5Y7u'"CⰕFS#tQJ0x¾gFDPMlՑDa~ic^hËk_AauL0!'F./;,aKUB!r&XĊXWJVΈ̪"zg ޡH* KP"6EHibZ,Vl>{ c9jd6Ķ}:anE^Sb+rooESИX~i0KjIZrBuFDj]D+t"JEPNN Ym&VqS3AέN(+"b߁3R@&B!v K)kݛ**`KX]ꘅZNR(Suu6*VWWPǦ׆_~bzȬ}_k!G3l<.^6K!BΕӉ]$Bg߃VͳG-ԶqӼoca3#LHg Jujk6ugռ2+z!LƑ<N^Նض}ScNjZ#ƖKwOaX&b?h;jήM $2uu}tZ),|z.O;Oϟq0K;q~3!B)ZhQē xjjF@Zu^eKy *Z]|5>Q$\<$;.[.b#- y= !B)b/N!(`%w,o[@.vtDK!r\~KܐAeVC-QS,!B!"B!Bȥ"B!Bȥ"B!Bȥ"B 486DzQ#GI !qsX>m-Bf彐G^=U婟#Fz 郴gs{6Lav^7ߔ<12>ߕ>þ>?ֆ>tsxS>OD&"rt'-\.Ї8Bb>9+,bӴxnqiPAm;^YݯxdG *LX `BH#?˸~CsdNOrC2iΖÏe|~ts.z{i{{rmZs׺EH}=9/ d a#il\\ ŵBf E,!“OygCqoB?Ǣ &"d֊hg]om_/Oj؀+۸6t~d * ^BJ+ܭHh֙|}ZRI/iH>+G2j9ɑOɸOɸ~=7/MǞyؗ]*!gC&sYǕ0lgARGU>UL%\ !'Et]9l\Xy-a&<zi:9Ӧߜiښ[uW2B& Aq1ٗg.$^Rt۷bGzҾ*( mz s^Y $g$GmQE E'ȷgywb%qtӞ! ?3m+eXD:m,.s߂!molw j{G`,z"~,#ׯqd\KѾ1e ? WciaKy\yq(̽YŲs;rEpQAq?>\A3dn/MpJYk=bڃQiZҥZ8' E,!dyaKٙ7wjUlu-ŵT$X ]dQLż666GSZIl4]`|A?q`Lu1e M+#Vl#X_j+qT/W!pbv'L@jI hIo)uwITRS4ZV=A_׏Բ.%[:Iso+2^>kd녌tG҆H>6|FpS0!c;?q׈P)75X /j/ l2>%]ϷC0H\y~X%KCc$)y2s,@ߟZ +iM߃3ZS@K[ (ѣGw?|'] ".jk+m>NȮifߝ5.]4Rk؄̆!_b%k5I^-[["%>0=rl G<̙Cj2Z6>S\tE`e52_g Ȭel\1=qwxNV4 ԒDOXa{3>1(awe X \3vPKZBn YDƵ﬉*Xe,InxyoM3Gcs&|l,=3yXzŖ7Ju3ה&Zkҳki5jp Y59s(b !sΗ,XcOuu6*VWW}3m@%KD!#"lijҝ8kԇn`ucVp\h4F:#\.*{,Wg)oZ(>u-G[C߽mǠ<(.Mj6]>L~F {&]'} ǒ޿-T#wO2l,v :mgڊID0 o2.ԭ\)깽zD;XZm=_X/dɈl>M[ش+o|c;t'6Ր iK<ugՏw2j憆|*u}2×+ bM硱u_U&[iZs8SnS[dJݦ{] y;fNgjmO@ %fo|M+2?pN.rnʸv\Cwem=lt$7x7:v|k_\y&OBo W*p)I]GҔ].+ Iy dlc_ޅpu&2+B:;.n?܈K{X1ssuWYXIl.rڼ_Ohk\ќ@adM- pjIW=KHqv4b8Zأ(_A&ϫa43%҇Rr;/v8:MUܰ#rPWgZ;5Ϻi}O 9&^9}=p_lj?_Ѕ4j(r,|Ȟo/~MܼyKy 2{Xڮ5ZWy\r# Bq̢ q+n}KWs-{vQfaKy>,֊UR%ME$p`~>s 3<.}ė_ -="ˬ>9އOEq\{U JFy !WQF~S{4ɹ(TG\[ LU`l2'dj3&3j,9v]_®UxyG9@SçHG]86Jh{hqE3DӴ/A>Z's.}du؉qN1>;H?Hw6Ӆ8Aewf*2 l]E. އ}M3"= <%f"zg `ؽ Id6`_@L r}0M]Bcbx)`&h"Ƨ'7OXR{f*\WJU̪t$B!\i+2h_} .øtD.g6D'u]mvOZ 9a !B91vξ.@Y83F¸gV7Gt>y͸ e' xy5Qmh“ojx92'2lROEWGW_ L|5ttBȵC{`r-9%ZD&E#f>%^ALt߸ '1{% rtyve`0ӯKԾ`ʂ>iY*sETC\Yjl|kcחLm9=Sϳڏj..v˺9&^rU}bO~Mf5AR=l/ԾK̸C}jٳe'=A;9poppϞO|{dmVK79 prgOue 7~T4?{T]r}ҝ3~~{\ȽE#rݯg 6*gYI:2v?پ3|~cwʱ$mﬕ%OwqӤ|>WΠ `}| 7>$Q@]?*=q%2O}Q}UɩgC`wgpV}q<ٍȩKZ}]F$R~]W`Od4$zo/Xhtr$?Qs]Svrsߏcwζ[keZi;AJ6>o}e؍hĎϘGVޫ<ر>mS@Rc$]Jwa6OO[^Awb y]s&m]oG\~Kܐ; {jƁ<COD {+{:NbXp}ḁ=E봀YO]`mxH}?ޫ\XBSvkt/dސ}^wm"cv|@ƧMMT:#ig I>#m)m+b0e"7>#Q Rͬc߷S;޷nT_(W~cVzswc3!n=O'w5xZϴø}'GgƸcm⮮/ojl/~=c{@m,m;>_qn`|kI1(s,MmWR 8=ۘ 'Xvu!WXBs[}|Z\≞.u;g@K_ qX Sɭ V XlNyu:OD ŧ"ߖ-@u}۶iiSY_$ MdRKsW3iN+!AKX]ߢBDmr1޷T߶oMy%qgqvEzs{MvPB.Ovv/rx ݚ{x!a:JFĎ8nOo<~(t.#??̉;6.v?k/]|e{M{tZ]/K9%%mYIھ$m|۶myK7}?Rr,cwҹ '#v|NwG]%j,őOJbW>O˘NgMNrO'e6˥ ڝ2\}b !=.>pC= ^d誺k*Z)pރc\>{&T M9+O,!%polW*EČI*h% eW\{(b !}Yں1(`&Rԟ!@K!'&'G,½1=!@K!B!@K!B!@K!=V 9dwBNgcL!aI!` zxnghͫGgs{6)Ƨnuc.}V=1)c%ɸ`롑O[3{ #(O8tʓ\@2t'-\.Ix K;^DgIz!bf:,RstzξP8G%4ޘ˨&8jVP)d i}7|) `BH#<  6Jl)p]ۧXm؝r,oU_3iziۯjs=/ dL6-jB9.{nqU@ćw\S~2<}F= n9 gC+9>sW6\`7۾9+4b"Zm{&_/!%L*{Lw }ZRIW4~#D\cߓY)mdH$3c'Y_ς-%ml$"|u(٤''ŵbJI[xd*uZ5_Br"n\=jj-2<LBE~b^gqq*֞qs{?D6~ χNii#)Dω<֫1wHߥR5r nhM>`!8lh,p5`T_˨&dmղ{h]ӱwoV*;XiOܐZO6ޕ23Ro9-rM{Gڠ%݀Uu7d~F 22v?5v#73f{i1;޷>~ç<ȇk)U^/g%jl1}QFptGt_y`ns. *Ѿxb\P׻~yxǙ"sh#a.4ZgGG8rK@q>T|ё,pצ桟VVZd:FSjӕaȭZGu1e qNyoKm pj8}/W!}Iy!HyNѸjYd[>;Q}K?%Z#O w7t>bQXBSK":*훨tG҆H>6|FpS0N@؝}(?ľo v,ŽoUkewL51F sߑկ.8ߋhN$2cKl U}}P YPaW 6rn %/趟.-yXGSWOzS,Q1y>Go<(l 0׻V$rP}~|a-)d "a>{0  &V8cww/ ekz;O!{$eֵ.m'Gg~. NdV?bGq*ߋo4Qyņ|,_XaK%,=BhfĎd|aS\8>+wRg=o=-wciFhoh|Z[?W( M'N<(BȌ =@ <̜R֤?v>@j$rq.@m L8K=<>aOپ!U]Po$V(rΕ+DX]]Rrw,z985$롇8<>y g)V:fEI=[\z}=U,YjN$ǐŧ"ߖ-\_۶H'pNnVL0{Si)#cOe>{CGŎ%;;Rߔ#nc)}X_IUmz^<.F#c7|(zeļWS~ܶׄ2cN'b'q#n >f܃՘  e//Q[ҧ}_uQeVWG’!ݖ"*+ep9R#?j.paepJ}ںh"SiP^*ymyFO뎣:.Xۮw>:>Su1 nw?wGG'B%1'=gǨcA|o_x7o:g t1bKgK1K"b Xr'ty)C9*r{={vr^}U7xϊ[_>>'ܺu˞] w[ '.ӢÛKp.H!dYT_~6$K:'֒(^ʴS3F]uN"Jf2cKq,%KK!א-bS/%L.3>Ho5T&]ʡm~kY\ SBV\~Kf<=G߿҄B!\(b !B!\(b !ߐc]c9=s !\(b !tX31{uJ=?V6O;p6whf{^;L1μr^#=fǹcoNg26ed]B@(b !J{22 =<8 ׸ryO6rb9:B=gH|H,cgӟ}C v2tefB?N-_$ݻ!QcA5K}y86ujl1q}Y^ZYBCK@( `nvKkcϗ *"zz )dD^䭶=9#}ZRIVڕ>{R.^mI ÆόDRGU>U,!dΡ%\ !'0w5֗.Y/r?$t-'.il<&qbJ9y=W Έ} BzNc VJ( SEcAi#[F-oJW^: mRY*-K7Ggo5c;R΄QOår6gǬC<l1C~Ϧܳc7vLq7|ou̽l1ĥZBXBȜǯ߱č.^pP^SϒqUt:5Ϧ"nq ոz-!ILLGZ<\ŶZ*K@uoLZ6Gsk<ЯȺ&֗nCmXB"mȱ=~+ yӟ^uBkگG`Æk eh Xm(i7)x?47cmi ig7oOgQ^>H7)}5S3nEu Rulc_PYrkؾ$m׎3kmJ93}2PB.xqVVVr:?E.A"lʣqyL8aoy3*'q?=CĊ'Kr*akk_ģMeOS^|ȬeFty]Q(Q(@&*8>m_ݐ/ٶN.#t~Ϧgq,jV8(йh X_{$mŶiGvGKݔ>ďL9{D!E,!d>=!%\,ݮos{au=EXA6x@{Gg)uaޑ:~,mضeg4y;Φkm=B#cϱZHZk7Lj11;I3M.襮GXeB}6)=+&9"rxx}|XÖQݟ:RY=ak䫾U?H$]N='yf˗G<-| o3Zvp:${o>}|Oثu=#為E."Fdjk*Zܦ]Bp 1vќ\{BȕϢ˟!\%֓ bsyk*X_zsG4n2Z[1BMu9j6 fq+rعLK$\;aֱ ȏ-QFHTL,rr̊dVj<10B.77nr,֭pKN{59!sEoQk*{ZklDS1myۆGdP9 sc[BBvN/Ԡs^z+4|9/QwV= d]pü&]rxDZ̊6|غ(Nq&o<#M[r_\s.}c3|xj8t)D,yc\y? &B!\>flmYŶ^! !:rHB 6rn tW6\nŵ -W Wim0.MK%icPF}cUy/GG.6#qmwSk=mam[["$+7wJ+os̓7?OцuZ>3h{Z 9a !BԜN&":Ce݆h%#W戕sG3R:\Gn/i8+X ,J|]9Y:zc*B#h8$1mp1d坰vLS_تUwZU%q̵Ya]h,>:`wr'V$\]<9B!r]9%ZD&£5RyOP"?E[&B!}ė_ Ή$ h/`]e?(` uQn[D ۼ7Ey ,]s{N!B-[*/aKgyd nszܰxsbw=6%B!ט/b5'JoAlhV(`/+|X=2pLd,E,!B"rtE+7ɵ.v>ǽAB!CK@<<>ճ}=@=ǡE!Bȵ"r$к Go]G#A7bB!"PB!B4PB!B4PB!B4PB!B4PB!B4PB!B4PB!B4PB..'-Qc 6JnD!B\.5hxr銈uP(y&x<G!B+,b=4i˓ Cl.oa<^iY>\,&C$my]{xxu{£%B\%BګmÇaRtg63 {ڷ>MPϨkjs)7H8>A]fgQ m7Dsl\s{؈VY# B!ǩ--T;v]K@5dATpttzX:rH9:oMCusPXħ ׄ@q-H/qU •zc`eZZih~tLq;"֊m:j>AȢ*۞ShcЯVj~!EyVulBnں̤Ϣŵ=ʻx8>d| /&y%3ܦ%BXzD\UZElbޡHV3H5`I*!/D⇼Ϟ/zXA6%V+vJebO 0|~ IRc'+B_(4}Y34&ֵ}m.3%2]L'kB\g>gybgǽ !Buv K)k*O`X]`oӹ07O0FeVTCT1O}6.3t>m__SUo>JU>] !Bȵt"6qIj,[m5$_[(l WiHpM̃sବc=clG$&|+br]cXKe^ q~`]Nfg_6KBOL]fg5QmGgؖZ\jrB!B)-"ZzgVx` 䭵J$8ʠe˒6%3՜/DpdM+Yo1[Z{ȕCc 3_R.aMs(sulYO[Ѯs3M׀RZ^;Nv6?S}Ei%~r; .3tqm3%_D4ZnOD_B!B /_<|o_x7o޴!sWF`"QSUCJ.!TobɝJ xyW}>+n}KWs-{F!B>s/҆sIZ4sB!Bi"VR„B!B/b/T&YXB%Ek!?'3 (!BN E,!3FD[>Z:@>ޡ RzStrF!%\ ]xO[.tm$n׏{_?y"9K.dƷB3+l>x(QMqԬRwI S꺪ZxrB!XB+: zĢ\ V.:,B91 .vg%#n7<^s #bA&mQnftH܅R:\S>{CPjVc=4htfQ+XϪoުfn. A={h8LUPM[hm?}ha 7Mb.ӣY5!BN E,!Q>.'t;;oቹޗXyo l3 †H"c[6 ']Ʒi,8E%vМ׸t-!k+@ Z5΋Z t`.m5W5bBK]GIr0FHݹ[E?J`%BXYqm> 0n ]ÚsnsuoiU۠0X)+BKIDAT !2- /_<|o_x7o޴!y޳g'W_5/}^Mϭ[۳*!R%3FE&x+^J6u)崃bK-9[B!SpgQ}N|Oڐxh%2=wqvrtXzSB!"k1B!Bȼq"C#~i΄Cl.o]P׀v1A!sG"B,9KaVxE@>Xs,6rUlF&"ϱ"1~!B!s":y@Y͡UNXf!fL\~!B!T"lQFm v&. A:6Bf%ݻpHŎ3.@C;|\s.}řh?<ͽ؎BW\襋k{ܽ(/hӾhtQ3B! l,nGCg*j"H֊md]ob5.ḥv?N| :rRIG't ׵vj@a#fXLgD]ʋ3?\du@UnIrlcpZPUUg!٪}AL7<&&jnlеwLih#N!BHN%bw@CkY9T+VE&V"B,qqޡH~d":(6"q$Dlm}c*bkeka>Nmv%cr<;2.2 Vs-mx"Fsk{Sutdf]lpǵ}4)d5O=M Qkvrt]NΌUxqBx8:&B!\[N'bwlPF֛8c YP?3ɟT׸1A -&L6܁7VÈ''mHt9F(zeh[T004mH݉u B!ȹXb-:% yH7% Uh<2m YQE EƇ5$ I[rYG)UސgD] 3OA| 1u6Lg}?/{~5RNו{unj=>Or`u?gxx7Io{2vx!v"B服%)z8lɻcfr#s沏bYWXGMde8nݏSQ n5_eՑLZ=*퀒n kBa8FͰ ψĕg"D&K6:@\RMM\=ha\_kCT\l}نLGnIRymO 0j?!08Q@UþXi>jVr,ǣNxo]8a1!BNJ&&!ײr>)"z[,VLdEXC1*4DfuPD`I2i$im=L^ElMj#D *eL\f]C˴ĵ!DvEoМE=橧dS"jM#2X͵P7![B!BEr:Kda$'Zr(<5FSŲu ]S't7ަ㻦X K {Qut9:n|z r4B!2N&"٪fQ$7q$r+gʓ?RqEգ@y-ԶDx0s:ֳzg\LB #$卪g ֊X\1oU԰ƖϘ<'"?Ga"|Z^ ,=5쾻ߧ2d?B!B.3Br KzsVQvaD:=,u *: ',#:*YBֲ^ۣF=$~AS<2 GxG *e)uSEf# !yd>݉ !׃X?.P(O}yOL.h?9Ec6t1fy[N8rE<ǞD <07GxGRRptB!j@K8x!V..w/;IX/߷pOq ڵʎu t6uwiT>kg &/-5ikԬ':ht]|Z6ZɷU]6u'?c\QJIhyzq}uo0dDv"u#Sp<~p@0Wk]UD[<׫HQZZ'%q[]N##/qCάQQ.O^IꆬQy&XM%Oi*:fϗ*`}>|[+q3cbiնrzѱUĚKfgƷ`ꪽiӮb_2gDħwM*~\۠]x"B./_O 9cT(a=g*SF߇wގiC}wKڎׇM_[D!̈QϘscԱX/7oB<{ٳꫯ|V>a֭[[ "\мQxe&P%f#50ru4u3!rYT_~6$XBWC1+ s5,B(b !'"B!\)b!B!KE+prFB!B ȧ[+#}ڽ[Ti_B=򃭌-fzB!r9Ka֩vݧo$ Uh<2A:,R:J*DQCDycS `F4jC\]%.ψk_ܘ.Oұq6 qc0S 2xלKL(]%xOlx/@R =5Fpe:N/wd,za|yhך8ꟲ݇3Bys6X $e*GUAF&QI`[j)k`˪#')zT$nu(lP alu+/>8q"끜kgG9[|~F7oԻr} B./I}ơhOV*lX#%.; ê>YEUþbp$k!i M^ElM)b2Ay'G%"$D"JEēkqY>kpl׾L6c"7Φe]xk$ϽKYLᣇHXexxwEJܘ`1}ItC{i尗꺚Zh-lLcM]o?F.q 0,|&Et4Ƨrן1xK_e DD2ܾ{khø%BΗYb%,>:"n?: O͵Tl\1/Is .un6'RꄖLRe`>o6g"v_BMxF9s'!Zy= dڸHDԽ]\"m'v$O[sa=?ސg\Ij8Z6kq7D VEV?[n}UC c|6I_0fg wкŊmkG<8bCd{;mB 89-_fVSWZZtbD%؅qBA .Ni*: 'xXxGjYbkYQUA*狐>Qye\yQy! ue9ْK r;;hm1]#׾(Qa4q}ӆqWO%}m09E}eWt:lWv"ѫN{W~okW}صxwDޣKq 1Vovב:p$&:XseJH}oO+?E]?|}Qi,6ݸ{{6B!W/_X/7y" 'Z H;lWyaL[?J.7!{o>}|O+wmC0.Ǐ«nv 6QY7ȋHi'~͟Sǃkh NR/M)Iϵ|/ 3juP~AFZF7폖B/͛6hoS9TdW-kKﯙ!Y?u5uۖS/v\B!,9>''mH<)bLw[j_V>V "\} mc{2xxn]7+bJ:#{}d/V?Kn8S]GyCyC|'(K^ "ZA&N"hCqtyI%bV(ԧJX* 6ٟuԼrIi>~}~{; 'B|Q4\ʗroR,+,\NKt\iK!r "2 P(b !B4=!B!"B!Bȥ"B!Bȥ"B!Bȥ"B9S<$,Q^iٿEKuemGBi=cԑkհOK!L E,!XLᣇH,]4㑼澼hѾB^Ouv'«r^tiцξ'tEyI iҒF&F^ÖzqGZ*\ %W̒qqcQtx/n Y܏<^(7-H76OV*Z,Z2Q6mK!H(b !sC]0wp=w'!+öKIBCk{?e)ˑcmly†Ȭ"cQBJK#)d:4WE@aȻeSG=ES/b2NN67gɚ6k[hm̏ ןRGα>)4!B#{׿7qMr%`cGM,_'A]疁3YyJ˨h cd|Glz/}^Mϭ[9Ƥsm7uy]-zXE+U,Dc[^M~fBYT_~*SYb[Yw` JM:Qye\yQyƑq Gg~HGo%5?oU,r7'˅x@Cs3 "!B&a>؉l j)F[PG퐮J젖qr!|ΥuE* jUTu݃K!˥bgf2"s]yp8쳈w;xre=vk'~C[z*(Ouةt:TU亩+딑IWs'B/s(b"po][ʜz%zT'X͝B̡͠ҳB6ѬP\vX,&C$my/bEs}~z4)InhOҨ AN_OIkdZo㷏~B!"2txwa5w\O3%캝Giܪ fWD`FosĵfuVY^X-t"46ĥjw(R3Ռ_v":SEJmB!,%\8] vw\^y#tjZ_=c2w]mWS:^> VנҍBWQ.'t;;1wޕXI!5\X8r\†ȳ"c\6JHUVL *nɚ6x]uJ2+Yt}b+@VvHf^JIqy=g.h7B!#{׿7qMB'Ngnb>a֭[캠obɝ"H'V c[@*WG3L!\2N,ω/I{-B95E絝K+aBdPB!B4PBܓAeŅ!B>B!B. B!B. ^B!5Iv?`W;RBHs%O#] H/q1bsy>g%D{灑2$\b=[1lT{Jimr(כQ.!r9KaVǵWD'Ob32yeyqyiu]l_ҕeF8]xO[.tmBq]< G9bWOfz+vdG *LoLAo ŵ2-Bp"w^ߵdr291),c{U J&DB~} ];^\@Pc6D*c?n6=m \2꒿ǽ}Z)֣'3:rb !ӉX>nSƅwuj.zW y]T@@\^CRV>m *Z(:6>!L/ڙuRU رyFQ9E`5WeՑLZ=*זtUH%ۅU1FyF%i{<-:~Ca3QLcգ} ,"p|Ǻ7x^w=~Ǝ\џaqq7c]24џ7y~d":{-з/dU$ZD坔~Q[{."an]n`+_\]RK;#ϸq2 c?bLʸϰi>ctDaϰ(1} @JLZ!r9MEUC͞VCI5ֲЯƙOTk,-ԶD x0{O8+XϪye͸uV7*F[IUhT 2yk1y/ot'c<3&.vLDԽ-<Ǐfۨy,=5.áRcU}p}1+]tjе|Z3~N*ϰ?c<.6L& 5\5aИ&O3q6&._ׇ` "m_^* oŶy0J"奆jh\'`=;gu}>{G8tڏa(÷τ_?OS"k(O ʩ>|K~ބ1{< py`y\~mCǖSѱMHB!wCG:B׿7qMrNk2P? +#tq^1m='.h9ȥ&aU崃ZvGٳ ׾ {5=nݲgׅIϜϷJk*Zܦ.&[O:L!̘>s/^s65V<"7,p\::*J&W~%Qb &k?QB!=PV=kh'KpK 29Mt^ !B!CA+} U@hWP;bў NC!rݘOwbBș0,X)` !Be"+N \)` !BU"+JTg6N/t֐ߛ3 o-!BE,!WaJKݖl!O#]>l vla)r) i8KB!\9(b u>l(]x. DyBګ!{.YAm3)u\ ŵ2-B5"kZ`"bEB 2a3WĞ7U,[` $]E$ZM!k˗/y,_[޴WȬ緾}|O+wmn$~݆1ew1T!B.>ȞM>''U<'ϹX]x)b{R;s"zM,ui$ %B)jYs%D6D.q'e-d*I>#8e+e.6 U Y]|jo/wO qTX cB!s)b !SrmE찥 +Pf?ցj@B!d8O9BD\l9+aBQ(b !B!\(b !dɠrt] !B&"B!Bȥ"B!Bȥ"B97Ewȵ>׭Uv܅w-Ul4< X#]J7 y4܏O#߰q4i K׿$"I2Gܟm^姖ąumxM\LHCu^I^60 2Ot.vJekbujKB!"v!d<]E;w}c]u\)-:>:‘[kTu ml]ݳ꩕FS-|jEi"$Bn d]=Ws#-kC⋷LEv$eImJ1nlS2pENEkb5?jnVOEtv UkzBqI6êmw8u}dB!QPĞ1_sl!$Eď:\ -)du! !ڐivŏOy%8 PH9[["$Tq$2g{ Bo։CJVzb]{Ұ̺Ƶ׎pX2X͵P宷/9I{F!B{[g~7l !dſh,$z./Tp־ 9ET-z)%ݒkxvoi}zB!;!_?џɿ;s/@W[7773<~\O _o~̼bW+#*p'v:>"p6[sƇ};_:ɡ ?.ѿ~w7OM7 0?c?W~0Dc /G?k/Up%w2 oM:~:yi㘛s9t~~i?07RWۈz}k>O/3FW #`=x/HQvD v#`M?XB M, ?{3Y1 9z]?O1fq|~o㭯XS?| ?Wz~kϐoJߺ7ڀ"ugoK]?̸t_yb=}՜?KoEZ ~T}D_y#~!;p7QKVB!zS, Q'"v__?7oD'O '&kuOz/O*+SƂS?3ru~ǝ q8 T~ ư<1෌J^DR~DT3?-헿?s?I$OLO3?!ZB. T-D & XB!$`XU(b'6B/ww)!%~NO Xg%)9sͿ8.OO;m:B!Be%g%`?_h~/ vWNO!c?{.RWƯʯW ?¬ u<~?yF37tJ՟QTlX{48~!B!J%{6(`B{B~[ݏ>{$~gUYpȊگ*$ FSS%0o.̟yERHʛZ>Ns"j銤kÏ~"7"LWO'3;y}䁟} }/i2{r;xa%BebXUΔ~ ?t?um(Tr -vq_0@>*reW^Ș-h-ժR(]m!+L;j'`9kc[lrm9l~"`u5+$)u\ ŵ2-B%,O!Ym2:rb !k U~JL (H/,`At*F\/,ѐs?>|ƥH%,G^j>DS~ ~z0Pl r ħm4^zV*XWaKK!K E,!\ ZuV}t#|<5gg+Z0զ$"0@cS]FROTuuk UaHU>ȑB!׌/DpayE-X-jGԲZ7ËEus n.zCI88/`.n܅a4|sA1`z~]%swKZsaG1nB! q ;MIv:%ӠNB!"B!Bȕsb !B!\(b !B!\(b !B!\(b !B!\(b !B!\(b !B!\(b !B!\(b !B!\(b !B!\?R\#IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/activable_dataset.png0000644000175100017510000001110015114075001023370 0ustar00runnerrunnerPNG  IHDR`sRGBgAMA a pHYsodIDATx^}LW/ZѵvW^Ysox{)E#-1Hb!aStMLdJb&-amSi.A(b+:ޮ8SK-Ts+ f}aǙc D4&sE" vLD4BdA!2TPq}z1l=钿۷hmmũSԤƖs?_5,r&$|XxΏ?é??l#Qv=~,:-3]XC4[c]PowrڿI[W^y͛1ۢOgOH6O$r7{ay6YUqC^%7>m?H/TQ__/R&V%!23y&>pRfkwq/ 3y.{^2@C~=cUHok=~NWc + }e_߶ >IIUQpYt\coBug][o?X*?, "zEkӮ+W]_9o{5<< =W]w\jyhhq?{uU}`G]Nbgܖeջ܋|yz~]ym^.V>W~<8~xq$J/{ァb苸f̜&9 2#F-bmL8zNaсG?0T,YDe4}pwQ6aQ`g&[uWf<v"Sn*x3WՒ^ކo̝mej8B_m@NrzO0so \czK_na` MĉcKn"^vԇ쥽Z܍f^ō!yvWgYPQO/*c); QyOFGGR aagHNNƺu?ʵ898wy1leD_Wﷳ*`zfůo`FW^>Rk' ͋:O%J3E;gSrjN?AG_a\mзcC{4biڴiXb_z -C. ޅ'c~2D◿̟?_1FL Dg~3.%&쪿͞]G5ۻzW<~̬j.Q!|lvoߎ|W %ԋ|mm-8bڵkGO$M_Ȝ)Qyރ]L˗/ǚ5k000>=L&IY2,?^C8 88 !BdA!2`P 0(DԿy" - BdA!2`P 0(D" @|ĉzh|6lsf*(ˉS K/" Bd rAۇ}Ӌ1وFxk&jbzv! O"XV&khEkc^6?4ކFc/G!ۀ:QX[>i4+l=-E\qǔ[Qt+@ms- ľ(CHh@g utʿdq,6%hRݻO>D\tŕ'\'}&+젴Aff`9~' BƊ뺱-Tþ"Dy[^˃緢04yY^[24AA&Κ^FQM+^,FۣAubx2yDYM>!lAh `k;9ƻ9s栦ar&(,XgGXWx?[XW}V'cDӢ=ۋf#YH׳#֠Mr_ŝ[~*s1X-jQDl6;`bwd'okPɲoO*DRPfK+8,vPrζ'qkK뼧EQS`7tXGӢ)SMf@7\ٛ7&.he$n%%!oګddpY{C-·] :&|xS~a \UzF,+֋otLHu"@M(+k?;QS5^".TuZ~yxHa%cqw?ўm{㏰нͽk˩=5y+YvU?S~o Q'J4PoǨ)ԭZm.Q2ٳ֏zJ،V&x!H0%~z(J" ~R~(Xz`P 0(D" 5oU=G4K .[/dvPXz`P 0(BRR#BdA!2ih@(dI%p U~듪DchP(I/?5n>%X]*+v_^O4"Gt, ۨFs(<_iڴ7yd!'Ok5VfE19: VъcG|74J\ /2v}5\n}?C"$Y"&vpdbYzPR>K/22>JFfe939jy %]E1L (VcjD0GB]!q4T pWAKDl6K!EO((`P 0(D" ʭ.|w/飮[zؽ[NXxakZ܁X[VW)XPŁgpu^5RF[[` ^O4E_zh!zV ELJDB&GI YW Fke E(  CBiy-X)l)]ёݒN[HJ,P$Q0(D-J8 -"S hbBdA!2`P 0%$G4ExHaP 0(D" X5ཇCrqB:^9GvPF7bGc8vP)RH^[B/)2yW6.yZ80>J"=V";n2('&GuدKP@g~"SwHVERb"A!2/nQHD6(D> BdA!2`P y{9 epR=7'N9 eÆ z2Řeڵz;w.~2cI> L4(`P 0(D=F6©c"8KJ(aUۭz9Q迷wJAQ!)h+FKm/,24彨@]^Eh>hq$ZT<(_ Լ[4(D]g vגȐ'Y'^:r`K\ umѭpXr:}Aq7iW mx;!C"8OfAzwZEhvN"24AN4{L]NozYco>xl@qx/0rgGo ʛ~k[*O5 KBf{QP [;(hFAdSQ6$Z'6#yQOkPDD|C9y"[BTH`k!q)^oeƻ-ɏk)t;zٯluΌh|CK[bS)*AbT—^VwAˈI*ZBcGb*G *mӢcp8_c~ENPddCDQĠ`P S (WpɛA!U`P 0(D" BdA!2`P 0(DqOnIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/all_features.png0000644000175100017510000010073415114075001022413 0ustar00runnerrunnerPNG  IHDR활sRGBgAMA a pHYsodqIDATx^ ta' ي7% xHQ&}2!%τx ڈϒB: Nb % 5s 2";rBNpp:¢VFZȒHVnT? t7"n=׷oUbeH7 v?l?2  (@UW^yE?O/iýzꩧIWI7ް:]+WW]ܬzߡM69anwP~gFJoe__򵗝|M?}z0=רz[jTi]xQ͛ooz^Z~o?ti~z=zޢ_Gۿ<䓺뮻TWWvw_f7}.\C+qfR+(/,,wlҦuoo_Lo6ݹ鏦|ݭjlEc{soP[}V}R}tю+}9k={OTvle9e/eSfƙiJ) /]w\{ Ow?.<}]zM׮]s1n~Yw~Q?SI" E˧-ܤ\qvԋj)rt !]ʺlsV3/@P}}vڥ[|w뿞a fŲ|}?+zkow-o~]|d{EzC"j;~45 Qk qHDn`Ta!+(|75k4ٝ:s 7yeA9vsa 5nebnF۽:ζ\`[o/ʕ?ste]}nNIӯ wW_Gnѽv`]ӕܦw6إnDZdjtq~ƱxRg>dC; M'kqJ:8ܸu-Y۲c;%0 Δ7kUսCKE-_89~%-7uemzf-oKWB^{xV65jY'.Dtq ˋtѫ5馛nRkkz7[y7;ta:zo~^\[M[ am;uLnl_h tG}wXԔ3TU7n67(Ve4?'_D2|po[2]WְŖ +,?333n| YF˅-^s;D׶Y\ N֌kۻ-9^[TҖNk-Dƥ.7ՊqA879e[.z39U&ϟC+qfR16}1P(OUf֙]RKK\W^yŖoU5ʞ_~Y/nV[TU({̅~dԒee A@Pe #E2  (@2  (@2  (@1uvie&z 2?oXW-<U \e/Gю-֌=Q P@d=Vw#r[Vm+M uXs?&;HK*;\N Z(}<ўLMD4qiLM\pT=+35x2uKg&ꕦ/k^i_grs;yR&yzrl:,(S:ꏝP?҄]D(Җaj~ct|^w!1Oס^!(y̴]y/;{"c6N'=o?de|Te'oiKI I356|ed-a{Zǧ&t&E$!ukV$e3}щ1>_la@GMhodQ'$s~eoDGCt՞q)2Lak*>lM*9J{Rnm7gdm;w6>f>(aPJ"&L͚.JNfMEaN`uDqMص?6R- JA~H7J4ZLLgj52hv58ԔԜϭ:;;3Uin+ SۧfAug؝9 kWU [s`eHA۶֫eu9_t&@\x>ojaj8"Y^2ލ%;pdGA9j<[i'58zoy7w(`f,Ӛ~۵VZq4֯D[@{nȓ/va`Pjհ]WҼqll(|aB$0i{O\s5DԸT{ kCVgFG~Bǽyyd:xĎ}N:^?ΑZC5:̫U|wX뾱җikNo4f\~)˲)Pc53Mz o #u˭p{7 ݕ{l}e-9tj$|I}|쏔ky1_OӟG>H^?&6vNl]^r#h;"gw#x nN>vkq,7}3bc̴]Җs6^t.[k!5籒kԫqM[w7?$b;D(G3뱡1g_%0PcJO@zgw"舘PAvBqSNNRlρݷfl<]cBw;⁷#[Dd6A|5G^hQZ"Ez lTk,wJpq[GWC«z06-Y)ޮ40M-2H>W Xb.9!%>&Hu[C[F|xxl:>ԯe*h?o=A;mVSEݯM{MijJj6x\lK/>OpϲA$rܠ鲭+2|o@ʱF,,gl⟫ӡАZZ\I/Jɶ\9{@6(7 hм Wcsl9_۹ ߗvti+b8S ;b{ 6оaeaVyesqbݝ׬/r +u]P0O<~,ݷYyz4~1_VN pd ͽ^So{C$c#;&e|кkLGN՝IG2xw;wm{ExFw Xys+iy pzՏ8ѝpĭ%jW xړiʾ˜ 5bYOX6's}Pd.*Uy 2wh~e:ֲ='Ϡy9K:0i^y R^|EܛZaਆ=Yc dBrOTG Je 5@2  (@2=c|_A{C@:|QA@j~]vMǺokufNWu՗4o~[wSuuu0r[LPh477~cnV[4z+̋/y >E1&AP>/I;e;sowf}BSv;o\;u7%d+0c^\|Mnruz_\unrW&}W7$^n_@6w~L_t,J) A|mkmݼYMwgu+KSCx'.|V:Rz2s.~HC'_$ A| ʩ9! Wt=cLX핼N7|ߝ~H;_X:sAiw럴¼>T~R#6 2 ^U/uzE7Nnwßz韛9NwN7ue_ N=^Fb_Xw͸?_1&'X8@Feuħw}^}D݀^-+*e >GYZN|w ۮo|=FsjM32E!(Oi~p> 7ƛA|r9C}|m&]Xw7x1_wienq*uX {1s=vjw]xPo7n͖*uۡ穧׾vR/5B=ӟobaƽn^w>[ouW>Wڡ_UΛbP&?իWmI~EؠLe1A &tfB22@X6@  (@2 p5}3A<<xЭA Zfe7^3%TS]E ܹ>%vܮ]ȝrݖ9[0/VpPW k4hZ[5io,NB4j#EՃNv';yOxNSۢZX1o]&TpP05M >Nhޗ-x ^ZT4(%]qjLj[ާd;ӎhzauD6ʽSwy^%fke3xt r٭ n;yqiJ]ꝝ%56MFS6&0/(vQ&޼\A;ړ㨽EÀmGѝ6L4-wb5׃{-Gtl!;m ә p-k҉ݏm2/.-1VTb5?a;tCCCg>cS|2G $`ULPNMn) @%6(i mLP@Pe A]/j>dO几\LP>g 4e A@PG>E;X P Kc꬯W:ǖcCC9:չ]3P IunBvL PD5ZhNԲfPÊhzRc"v(p.iHCj15caS֌Wl]x&zj8nk|M׮Dj=_K\>SMl[xeMas[P^H;vi﹨S P ]1A6p Vkb1MMLsĉ>r 3 )4aeQg5n6Glsz$^{LmpZ-g'oʜKp\ԥDnR? P[ f5) R/Kf;]N^dPbز3aOu)Q>!m}ӟC)rnW#M*&('viXeTfeĴQ@vQ&(hz (@2 5ö8p几\LP>t@xL A@P9%;Y_D֌-jI5ʭÚ3݄C-j^ttwv^ޥN_MslzN)zEOT/ivoo,VM2YS,Nu;m ->lH Kc#oma`:^44tĉNh i–OPTШL*+Me?zCy憣 HC2uMVk| dQ{:gt|G 6ƒL[lmd`5=RtQK&k\=#٠2_WoTKN4k_&)/-*jT:}fEN(ޭx ܝ?!-(H|q q~(Crˤkr;qҔM8*4Kg4m֖:AB]:I6kl6յQkv75ǛOmv+p<֨(sM-FF)maf ɴ0v9KHN73ncgvl(HDܷo+|بbزϋxW3iqaJfgF-V.+,m)QܴaՏ46H\׫ &ٔ+u{|vC۱r{kÇu!;t<8`SpPF!(dWLP.] e mki 츘(^@2  (E pq{ C!۾☼ThP  (@2\G>E-Tȴ|-fٞ|9ݧ:{Ku+}<|e{<^o!7mi[$2J~>=*#)s ۾"(V΋,,ky!-Zux*u]iul-^G\Z> @v^H.GX&(z-^EmF;\kOk-+roK)_ mHǥ];VuJ)s)y2I,wQBlWISǎ3O^b92K @:j7 j۹ߑ/NPJew|X#y3SXݣSO/lQ:+t**D*ӕsSkzN7Ni[96ةm 7ſݖ5m}ŝk^e')輞޿Y-l{Q\_P3ޖឫ\?Pb0e A@PZո_ghe APpP_u-ٱXSg瘊(r,SlnXÚ3N7=`ǢS>;mB^D-s3 ki̸%ivvH-Hշ" Z^^ֱ+((;e^96׭~ӬCai3 Ǔꞛ@C&k^#tQ۴[aT(y]6tt8kdQ'$s5o^lfqZ}Maq4eP¦6.;1d6lyR[D;vBDmN)?{PWLcg@rCBC:2cS(#M&m-8v&,?<4Pjm=|N\gR]vi%:4o|尨=am;uV3<ؘbزG:|:dG}P[[=#@sssp[lqҁlI~JFF;z}NI|ظ5i1Ѹ_gmmri"(@2-Q-ǯZAPpN²"r-.oyNsV7KmZ)*AV!5궯8v<܉n 3ݢF۽ۜ"嵗@ ($`zлGncB[-SGj܄ҩeS֍wjL;#% PʵmakyMgj}E`G8V~p y , P\ZDNE.jg*5'mL -wy5\j۹FU[:j7<JrM60] v;m&','IwytTX,lQ>C!۾☼t;je A@P@ ö/UbPCq)CAP@qCAP@ՉD"/5$W K 2K 2Z9BAPe APŖm?Çm29pA@ A@Pe A@PTGP^Sg瘖 bQ%4Yz^lL&;Y kƎ. !k|Ay&f6ab%:(ҕ-(/+4 Iyi3 |:L2<#ڝScAL[XؔO)ʂ +Xw«( n/#$KZδ08='r@p̺s?pU|[yygm_o\i \~YsjOgԖy O3ᰴY#lvZ3>*L{Y3-i4p4S,0e!e*o1>}u;OĹ ?箟 \[;׫9|ߨ#A ;ɜ{9=W1wp?+S)*(\IAl鈚V}!UȼAJhs̟v:YmH?z ٦ .2yr#Ӻsqz4Օ!\*)K Z A/:q*uiʝ.uKQ.ϧ}bU \ۮynXvTFcdC]㚚xNLΪwpd_90nC4S.8mޮdmѥ<.lhT Gl*OYV̔[}XPld߹v9wNmlױoXўEl.4XšpEG5pGcχ5+pDYd|zW()O&̲[۔*PPܛrN6zu|&| ڪnj6Qb uRmkA7%@թ6eƽՋL/-*LwQn25e0F(2  (@2  (@~3/؁;l㕠\~aԦ|3k\ʨt=+eGPހLPC@m"(ru (ҙ|=ء8|Y2mezOؾ`WQ _fn=Ѩ.^qy}%}o'N]W:~iyF%AӋ*G 2^^TjnzQH5fjϮ9i)j뤞wI~tZ„|v(ߵCҟ]jz0+5g~7[oS/:o|-?H[ұdH6^u~R4}[X^=pak渮zş[nk9&CT,i^;\b-|.w&Ng'PrZ M-O0}GN|Sq''{!I([Ӌz^M5k_::=[78̻4֩A"$r2IwT嚰<ҬӴ8p'p˽fsmkw*8:jM]v+&<)L9At;S GSlLM'.N {kK3oj|kliEERcۓ?栐އv9=_-sƽ&֦n-0NgÅ[>d 3`2Wle89,cΒLjv%>3Н71dK/_to 륗Ot]׽q{ܙo}"m1$U4튇F-&f gO9042ôd dab^8snjMݘcH{̹I78ڵN-Nйm[V/OvtO}Nۦ&5ծ2eX~/GO&mz-NEyDvw:o^?~n[7?W ˬ؊HShRB ~~K۽Tu?om&_ھvE:(;Adbz}`(ٶ>|"͸x[utNM(5&n38mN2_̗YiN;.ylmuKcH}n0E[;v7T/M!]5w׬"pF3Kg49k.nPsn_d'3|C;5YjoZzӷmޠl(y6:mzuz_mKݯ歛ϽWOI+"2FQc&k89[Zuv8ȴ*>@mruí)M^㵧uCpﺢx-{u1-0?O+~_U=ul?Wnz5wܦw "V0۶Ols򱻷t[{{hs7.[-jd}(HQ#7&^v??޽sPhòb5Ww 2jAy}C5G z{WtwXoZj^p~-wo|[^;_>Ffp1*3^Q1iR>[oUs/֋oiua]qLH<ӿw'ſq'd>v7ݴm߼jFycFArue50ovkIP ?ߥ. ɯWS2AeGPeT\jeA A@P)@a9^k@e A|UökQ#. SJPr$|!;rz(Q*2PbW^@2 AyIc"̄U_ߩ%; 1uv9Gs9YTedn59NKc M(%_fGDTqZ}ZCp]ܮ$^:' kUF5*h:^P :U#MbnBS;Ԍ I Kg4^ߤ4!8Xw6NN-kyyY |w:a^SXD勰EFW8ovUAR zlA̤sK=wD,o ZaoDMj6ex^Xfgu;3nmope68%+4ɔܠݭ.c4?先착:y|v5vVK5YNI6èW5k(5Q8 ȰB4 L+67V;W9R[Mk*EقlHl|,e9IkЫI֤lb:)oQkkR7fi1TlTG=.Xr:z5;yƖ544;Uƭv.;QamP pj^#C M_ sQOzn^;Y[U[k|xt"J,'ґy}I:xUkx-)_fmysN)ոzs{O9f*O^͙t.lyGzgӚ_i)(9j¼F|j͝5=RǪ{>/C LxMYقr(=HBPfمu=eZ 5&k+%]l ft|&$8fE3r*Fj^SkMقai%d8^M ?F'|qqo]~׺i'23.f4~M\pء]{뤽hqm۪Fw`T+Lq_[]+^sF;lK(Fr_Sek*RقiY"96 訯>gsV'۴q5oB}2C!<ءdBkxPT2j_1k. G5Y/D5|O@F k P!6tPvNXlZiwjD ^#5<(6tZ`?bl6X=^(m+x%(P@a9^iz (@2 U[O@p@s ʇCj2(x  (@ KcӒ,jmޙnVXe ʝNHE|Bi)ﺛQ'Ṙbsú P5/Wj[{?+oK*; TƦ7Б;cjKmӄS,iHCjIv(^3a_wz2\ns ;m-ζ$gnz4g3;&1N[f:o]ʭm;"PO2a24S,pT= 8:a9*Y'8vHvEڝb'Hwٲ^ f5o3,0!5LniVjgOPĔony<&B/7`-4 : ۻ:>a tFJΓ[4ǃreZ[7G8v$c4K){PVÀvO;lMl Ai'|5B_ [&m.3 z(Pv4 JCMoCBC./loW2[ۛа]JΓU,cDm q~S>3xonskۖ+/&$(mw'zsÊě 4$ h7!x]oӥ\"unf-2:" 8ݒa~Bjχ /[̌{rJTŖm^}Y~v:tTx@CU]c_\9^רF.ePfR~;!w% syj=(%2QHV- 'c;bj^ߔTh\Le@mr))uek4&KGu$|@ ]흼ԪṀ-(b=f%UJP U{PtfN{4nZ~6=L{]ʧ꛷Ǽi7hh7?YcܟԜeeW0/;oߌXwsti]qqr? KCsB*{am({a0^sQeM/ҢN{p4lVf2wNgl7Yg\.|Ӗfބllv/3vtYbwǬɱ 2_5+jf~3>hٹc2~relR_75{am({a& ^j &N6nQykTw&Yu_TY[;X.+bqseGbnLfTB^sR]AyfJϜ$}ebRG=$ǧ{s˵NZ\^hdDjp)9]cRlmͻb?Zn8lR_7 3*{a& (K8=򖡐&"m4S'uۙ ;4J>T@-Junì7UwImy36 G'#Oegޣyn r!(@z9/˺ַ5æ܌Wr"(@D"ڴi/5o~u7Ͱ)[4::jb&z زR[Sg瘖 2TӬ駟433ݻw_5æS;]f3 D5<SlnXёl9/@!(@׾nc=!---W^qa 3ɓ'@K*a@2뮒@P o[{cǎә=ܣ'x_iF!j\=]Z\[;61T3<ޭ]X3Zα_gN>/=.Ӗy<חZԊ#(@x7ꩧܿr$_xE:XLvl ]NY,'4aqм.AGN~#gwzEڝ2nWn3xXi熣Ii(3ϼpl[4Pn6G?rwy&HznA74*:k{;"Nx tF/sKZ_Wް[SZe#hYns[ n:S+>k;2TN0uM{=qס[{ۥ)7ɫOt9;A*]|E׮]ӯs.p _37әWeiLa snjkT۰]ґ|ҴMNtfR]yl۶Mwy>kyy  6fL*N0PpEG6In`o D|4鼋Vm1m#eͤvJ:b˶?/>nv;ȦL{_tpw}M3$o޼Y'ܚfԦ|J8 PM9^ PFżg1I63w06ɦŪkQ=f*|L&@5)x%(@B{T±jRJe A@P\̇cپm?3Q|J88QM9^ Uö8pd=ceL*c-oTbWr3AСCv6=eT-3Q|J88QM9^i͛We0'<|#>5ævP!^#G_&o}kMꫯ9T9AyiLcZX#joբܺf4Y.P=V}}F"mڴI_ݚ7ͺݿfؔr-sT|/@y{qNh^W @ TIq8JKc M(%Eʵ=&B_}kU<T]]-Me?OmfklAyiS=D,&4%2PpˤԄB󗥆MONSJ3:2$ [eH6ٞ}֐׾5}CٙN̹Ukdb{Btbn *8ǟy{C55eso|΄^xqcP(mDh^3>Ǚ\NeT*_fm)mindd+GwK9>Ղ)OAk*^s-Z sB W>Kg4e䱾e AVg\.j)ǟm{2˱rmG?zJШ/57yo"|/RȡlA94;3|__N ^j4& bL.g* &s^mۭ÷6mk6`_mc:.3pءtKSnpM*9.Z|EقaiO ;æc6yqҙIv%rG~j۹F>n MʋS'h\E:5n[on:ԛkk.Pvu{Q[x bL]]q|rOu{4˫k.}+_ѵklI03Lg|` tM`pkTʰx.rڿF?PeVm[}t=2ovΛܰ=Ӗ.6O#?;tl!;m ӹKvOmS)^'6U;ړx=՛ӣyM-|[.qєAGS.Z)k8`Y3l#[o`hH-vQN}rO]ҙY/(m۶;?y ܌7ә9yv?h2E\ר Tłߑ3xgu!ÇСCv6=:pZs͛m_qw;h5|k!^'-kf_4kn%+=]q?s })inaj7;'> p v.Ԛ|J8 PM9^w11{* FQ K̈́䞨/ ~ۧokMOHAP62j" L;a߽`rZk}L|P'Np (&h\L占6ʥVSmxD=f*F5)x%(Ꙡ((TL%[ߨ&e(#3Q|J88QM9^i  (@2  (@x<|#>5æv=Q!W^ʕ+?vmz477|+ڼy>Ons{Tmö/>F1+5P!"6mڤ/~nMfx_3lo941-;j 4x588:[ʔSr #к`ab?ۥ k_"f'Oڡ"5 hzz@ vpͭQfW}X'1tagȓXkA*m}Cs=z'P%ivvH-ΉtKτsڿcx{6eމY)[!5KW/ϿDrڲKe T$2TswGv(;oV;'4'fmi^|Y:IL`|ʉ Vв<WNL.Ѩ Γ>\qәOmhT(JC[Ru`p#t.\{;TpmjĽZxm7M"eB7d-7wSڵ˽OklI03LgϪZ*kRglX:3rvukHGiZ./9ޮd ZV<@ᶨyeo<3~hsD7[)Y*A*mtwʔf:3}F ׂy9O4Q4.[Y"i[B5:q+92]@zF!56%:6BQm'> /@ꫯ _Bn7$*܂_=f*|\m.[udM;;a. uzrث 6M&z4d(O[[Ü~kC3Mt49^ PFżg1I63w06ɦE֚dԄ|J8bJ9^ PFgP3plq|sF@Pe A@Pe A/@LP2PzeA(L1+M/e A@Pe 2l_esLPn{_'(ePJ A@Pe`ZSg瘖`V;і4Y.BLX+sH7ovoڲR{KЇ' pe@WDun@%Υ~ &tؒuQl"b75;+?X@-g%X e[&=S,f /K Pbd;gtdH޷!ӱOґB+լtՏJږOؾ@U 14;1^M;N8'Z'Dק즌Ԥ`Fa2ggr/iM[ mxk'AV4p7y\:bRfKCcHV?oit+WtncNʰsB'?۬q3Ӎ7/軺S|rޯ~_ʙt)N,?Gfl}y4X}fOL#{Ciff&%,~SfƙiT&2PE:"뚊pa%Z%liEExn:'+%-Ѥwu|*Y|!]dZWmojw>\]\lw٭u7`O~C_o~w5n4+ݏNkeEl-LޑLHAFb؄z-󚵽9apD?J؆B}j,C4vj yzj]LHA"dY[`F]ꝝԙL՞ (ap-myKg&5ە AgnS3X>iV_HN7 Lޠe̓xa T2PE=2`U}jRk l4e A@PUg};} eT5njɥKLM*A972[UPĻemQ2b ͧ_=ߠ| b3;%ʍ\A~L?/un)xg@Y+իWumY;޻ k\Ar;~ P^ +(x|۽sxG^WӶ tS>D]>ՙ.mzY8\JAyiL؂S9%;Luiӻ̲ՌkW_}UW)L[޶W{/Ԩp{b)Z^ֲNYfCv,(nP6QnUotDhZ&b1l7)'w{ k饗^WWgͨy&Ph?eҮ҉x<=ֶNkQS'1%Ǝcf=a;VS]Gh{&]Z>9Z4^9t,EmF;f ^4[<O?3Gv \K:3) EaKH같nHCjqkXg3 wjqSVP&ItNʵ+eՙi3}Q$-j^ߴAm [`8yF$uv^?iS:A#V3>'F;A)mnؓݠ}R;(5ie`P#qk6otF!564p Ú4=p{5>e̔{5`5<UQ[C9Lv"Z5hkOcsݚ_k*6uk{m+ڵv6Ni d.=oqqe#mnuy+0.=vYWlǹsQ-R9 Իt2S3(5C]iRhp`>b{ ѥxsqvjV_2({v벝RrBs|4m[l`DmNlq;!*@}i< 6k,ie'?3ze+WCB- S33O[n[3o k NΣif#jZdqή;gL} i&|_ܹS#405@HBG|5&)TTu7(Ěe5 hzEPꐓ4bO 'H Qtx_p3Lm&HB+GikհvhV fNSV_'h?ho8۽4إmmڝhk2w8kzбH=ոՄvowfb>nr⹹h+zQ愾AӔXvuߨ^|.a|ߡȜ xu*ǽ#L /WMΣ3ҙI:Zӕq iMXN6p'R8KGMWh{N];%;]%VŖmAyqvh{4k fS;/jg&4nYGYa[ڡ`~._5#=ȥK @l~Dc+X?^ҏ:57 I&q/U^?}Y?~ek+1(W"2 ~Y[(ܺQ*APp U7PIhzizFUlâ2`5@Pe A@Pe A@Pe Qթ-;电j;3y].,gW+ƫv;gڠ㘎%VmhPSbز3<[ڡ._lmYHݪڵA3Ճ8 ͮ]Z6ڽpVDܭޥfzҗ M ̗]/Jc˖-/K.;C){P |tN;K{O-X-s``M"ܚZxuztro)TOvįu- ©^ș1}V+;#js.2mܪmNȶ({\'Oi тrZls.AԔʹ=\~]Nw 7\w/u8\Tjŝ;u12zOFr\!8klxBMv5rrj!rQ;iv N~pqwv/6viw[vwF en3:`Bur)ML m\w0Mr֋P 6\r8}>H(@j:(8ô6y   (@2  (@2  (bز3<[ڡ._-[ءۗ> x8`pñtfLҥK;Pa*&(r@:=< 08X@ 4XP>:չ]3ZSOiǎ:s- 8qPE;>xhGuš\j*()Z"\KLH޻wU~[`!m8X^>m=np&$^al0&$v6E9 pj{j(({kG|k.^P#!yddDr`1ǁԤhC[mЂl 6'/*pLh 9nƭv.;`T&{ tBE҆dB=B f*Q)}^'lܨ샊zBsvxdS d|(I &lڬF-j9 7ٓbX΅D; hoBDwr FlHj#nP^m!5ATM BrAΊčpdR[[D gഡ5viwE]Ƴ8#t',_<5,Ȫ{\'ũ:m+'*EdfB2#~n8!6maqdDX@jFyDZSxۣ6J^х*c]a5C&A׎c }kcz}ssPcM/52Fոlvq1CgivAMӆfV1?a](~pñ[ k2e A@Pe A@ͩ//Aj X/A kT xXjTX@ P]X X@:~PHH2  ʧ>E;yTu}m9jSO=;v̙3$.5 u1P\2~cv/lP>gȮr qal@OJlE.L G~&ݻ-[~:w|Ál*A$e}a'xkW6,oz>m}ݩάxLLm6VNÝ/UL)x(շQvb194ptXÚsʧ.+2A35oqʇʝy܅[bTVeS~fR^i}]ּҶo|gPHv&B*ckh iv~ҳ;uusE[l n}|KW*(dyY] q_\F7|>QE;i5 Ky4jzߢ:5hRh{4ovן*/*D+Ԑb:69*|i_Ly7($~t;'`^O;}YR&0i5ŧǐ|lPcsnpw/NĹmʧdz[LI~]-jQ4;zp"sÊx m͉ZlY8m_V{1_VfAC-vZ4}TR[|ȶ*-B'7tqgU]SXNqU/<*:dy(r9OuryGueFF%QL< (g0㻍L}4DQ G{eJa z>"jA3Z;"nߠigY8N>ADFE1m6ʕ6ʨ4')AP@Pe A@PG`I3o}{1ۇL~ۗ?2XW sB@׸yE/Ayu ʴQe A/C 6'!m޼9}K6oㇾi7 ݆e|?ǯ\WgMe ͽz+ϖm^i<h\hQ 6og>1iK4C׿I6!ޯ냏UKNVM' .7e{W^Ww(oa_'uSٝZg VV`1lb- Ǿ5?'G8]:uߝo66>МFH }3~᤿ls?SŖm?+q"i؋g=cPQe m(#3(Nm `]s'(#;2P"Qe A@Pe A@Pe AXAǿ%IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/all_items.png0000644000175100017510000011406015114075001021713 0ustar00runnerrunnerPNG  IHDRycsRGBgAMA a pHYsodIDATx^ x}wͰ%ے2E]26+3CBP:=IGu ɵZKݪzBb(+ `dDLPh@!o#ZZ"EΙ3.|?zfߜ3[Jbvk&W~DA@DA@DЂ/[o%o韛XAQ_r-}X.]9rҕRS#rʭ]>뮻. ŢB%t@ɤtm 7_AxTwQd];WSko]/wnSb=y*Ii\uz坹޺Cjkk򃷚?%6g?$Ͳv9'>!555{6s١4=O9 򦧧cNfNy27h;WRtMN殸YĪO^}O;.ݺ3vZ߭B۶mW_}U_ ک̙3oizr rG4?z]|G?'.]˗/y4#/^+s慍coBIxp怬,r̤阼7|\, ?(eԧ>U5ys)oΩ{r M{wGnW^qQ{*dm:)ƤJNٹH3^ *ikVx۩-Rf\7߹IC!Էֵ娍w{sM ,q~)qWkM3i0#:w\tϗ.(D/}IU3oWO  /ȕWȵ[?#L>>"ګWal[sYIVYgS)ަCʁMrHͼjiz\fVE^ GdL|Ldt~KC~72'6o/(tuu1Byjke%} rŅǿQi)y;o?|]<+ޛnX%vM%sX/Nڰ<竗Tp5/m7L5kz͇trڙ=4b@m6VB O2*\'3ߖkzSV/Y)iHRn%yGW5)wOB#z}qbZ:fpm2tWR Oj=TT޳>+wy^yn+|Bkojo(xr慏s3P/'';6ݱ{ᮻMnKӱCbB.&=>;~F댌 kua]$RH~)VA PiS:dQ@/Jŋs?ؘT=L&Kvn[;?m's*Nr>}mfvcyIkMϭny8=3,8 F_cPux;4)۵o/Jb|@fAinnŋX,.\ |v ʂk\ <Q Յ  "  "  "|@DPAyDAyDAyDAyDAyDAyT ov@dd,.q^:ʱ(Z 6.~GZ <7vmON-՘;7T*%vt%+Zh:An9%ڹ®?'KKQVުaStDɄWة%ĤWR]b$K}㩏o3dW ԐĦiEWg2 e}&Dܧ&(MW !2(z]L.(f ~w;PpU筕1TZ=jҬeTNe䤡kSK>>K L3N!^ƓViQ")3*|v*&;%<=^]=;>|Y+uQ,Ug(44~gdu$g 'W|6jqk!5{B%C6|yӴ|m_~kHS:ȷTPYk} 0yӅVH]oe옌$euFH)Xo%~՗TVtens J9&hR73u5JZմ!J_ndl' L_BL]X9&yiv/KβaϛbN, 9Ϥ75MS@+{f\wO]ua#=8lL tdv{u󜝒WvgZ7][#ON4zi/ y T _p/l:bi=SL w'6gjR;mӵ}ug ZZ?]6>|XUpT*5gGIH0,? yé# 5yDMފ1+<Q O5XH?ϧ|x 2IBρCո6E!C3,/:/R*w/Te$S: /S(.w[. ^-S`pϼ|2O7*C]d- rseفIĆJ|)LM{-a~ uKZϮ˷ n?_b!0X~?ᵜ~jldP,! 3&'Di} -%kA ~PL l5_]3#ݬ;Gݽr]%MX 3泼WVZ+k|je>5OnS;v7?Nxk,)*RVKoC@ۿ\Mcߴf2m?|++?-idJ?5_秜;^v[ӛqǦ4^N^&/žvYw|-=m4:=y5>SҶ((L{˭qf_N:~o6ok,Rh>ydw<i[#^y̡1yqz:Uwn<_rn(Vٳ=&N{n=? 9ǣ~_puLE yr-Aotpj~&u&u`Ny4ϺZÞ+9q$%;faUFj045P>fӜlnѧy {?9>SV| #(,>䪀Jߩ*Q|?_W&{?1[@;4*]π@ߓ/|ςzaKk?u6.f]s |Yw(< y]U.Tg}T+&&ʛ' *u,9T} sBˠsXL_]2^i?_ZŐstvV´Ss ~G]F̅Ei+oGV7:;&#ITzvJbvKƒcTZ.tw s}δ ]os@B>^ 5e {&Ϗݭ*{guBNZf"Bk@i^š$u4G<ǧdu/0+1By^]AA]ikYUƙ3gBys}w!Tjl<~Kj_Mouߔ_ {75ݍ?^TTܜ?U_ڏ߯|}w'W^]| bjjjl%e#>k?eFPN&\ /JfufZuuH%ֺyݞ|_|ZV\a:r 9pyJ 6 vdJ'Jwt}NVWNN;~ ^ֽȁu*z=cÙ-+xYf}6orMwk)Lz ŧ  x%D*%)MIzu2:+u\(]cQY:d19*q-pJ[uW̌ٮ\˴{S}ZS;eηrNCyL'Ϳc&y@T_-5Nzd֭⺦/.OK@nLY2Г46=cֲ֡k*tYfdH!dhass1$[!|&xtkZ8ЭuueSJ;oN-Q&ҭIٟ^O&/|ӱ볳xUrt\N-c.tZQ^о@o]}i!me|w|u;Ws.kɣy][ӻ?>؈9W.s8ЙI9;ӟMPC 4˙t^xXr:v kKl|JACiS_Zu<%ҵ2ܣcu`)2dǫNzI-=MH3їN7ZZnW ic#2صKz{9:$wQҠ/P öxSvXSRŧ!)qs[6{!Q=^:}zRbOڴmÛ.>޴`S2f&K·Un$q\=Q6DdɭyK'^/lGFт5l%M| ˷}ZID*@0Utu& s-G&6͗ϴ|]c+}mIoV5j]kkYZc]id15[=N|7Mj5GcZTZϮ xs/:R+ʼxvF<Ʃm\'dX.,7U!iYXR]4Z/ǐ ]2mK6ͯZ<YOfQeVzN{?OyMn- 0ͅy-{o=>)ǹA?<=sEq!-?Ayhכutq l㽡c݌l8iyvޖ_Z,Ó1O @g2IReNr ťvL,rݗdn;T sTIhzݿ#Xy0]1tͫ@^@6_4}T`}p)* 8tPX`I~v;43<#k֬CnxGnYs/O~VV㲝9sF6l`«̋Kļpx<Ժ?}y;XF Ys,e^.boRS׫LmZϹ9՟VM^D,&Bj^i75I\s+fRkAa!A^E@u Xat`DZ8"  "  "  <e(c=fR۽{훏5CQto>;ʞ={m3y0|;oߺC;嫝Nw9+ ^yOOYq{nw:ݯ󗝞 K.9 CXGP^zI?gD|֚.>gm@fPfufZuuH%ֺyݞ|_|ZV\ey.<(%6._7KOIA@[:]@C1w&o"%>I_antlw53W`VZr73#֩_HxgǮ9Pfټ7U"ؘw|Χ0yʘge͟,ǥ۳AeQ%V-/:Mon+Ԋt=w}s:]fpf3rNM;~!97HLG1߮,g=znyt휖&+(zʛwyylc76-vg;=](p|.@>\$:=o%[:dcn5Z]ƧOJA[7%q@mJv2cL{--}N:NqI{5G Q 0B .śҵǚVWCRv~ʑmB.-z .}udĞiÛ Eoj0Xe* 78~ToXvRXIv 2Sۿ>ttlvBoy|,|;l&۠7_f֛?=wj%sXhytfi%'uo;Ex48 tILЂ-s/ԍy:&O;>aNyv >H x7':=o o.]ؙ9[mMt͜mG[duLSn@Q6ShϣkNܫo* @苄n μ fUi{!1*D[֙"nn:R޴5slaaQW)'=Oeg=zkmJY^x4e O3_s@gOL'dˮLDw]=. w dѠ3W@%QOL*kϸ4kq;?Z ^ƇDn5Zeĥ~qcdKܱW} E2yMrܩBZ.]N?}g{jy[_j:rTy/y)?*uUSY]}Rd[3/H9R}"P98e5{usnB] 6.ِ}1:mExޝtդ D;}\YHhҿVݥ?.y[55e>g]yí0ۜA0TfLn,mF JǭARrNsvj{utSRVW4^i>}x~M7_q̍C8>?z\n>\9,<2rO}flx1ϳk.|. x7t{v U7YS,zgt{}õ6gamG>(t^-;ϑ2m/Vͩ .DWA^]Lt K)\ܲs~Yyn{ os|!-?Ayhכutqjl㽡ch8iyvޖ_Z,Ó1On!uЎw&u BD>/65-9\-x{"-uM^M*Rc\{D8{LgJ}B<]zݿ#Xy rW5m hBR Thr?K~eϞ={n;43<#k֬C{&%P~7Pk9sF6l`+Z/x\ nYʼ\ʧrWELW%jyO_vzr+oMb5yR_y5roPw\Uf'oy r <*>O [ղ~Nty& <PJ+RkJ@g"  "  "  "(_fT~ e}!Tٳl<ԇ!o!/_t_Yyʻȯ}RRmȺs~mߟTA@\tI.I`~O=< (K}93o%ťt15<; mm2LFjtݪ[IaX'ءË*ERVS^/ĒTK>.vT\j9n l.ksY'-ˣPt|c.U>%ymKwm$ޙ&$w^keznNTLh9m?Զji;3WU˻-QN^ۿ9N)=^u?[cw?*x~o.guW`"o>h ɋJcv2Z|XrNcyL'Ϲc߶ZʯGug[6{Jx{'PCg'圚/wCr(]4HLG-î,g=znY)dm$O7]AlV^?< sK˽i9+f8ygEoHψu F\$:=o%[:dcn5Z]ƧOJA[7%q@mJv2cL{--}N:NqDLݾ(5A 5 |!SRʶxSXSR`iHV9NnU黬#ﶜؓ6mu{xPK f1k~T|X^qU߳I:ATOfjܚy[eӖrȈwZо}G|?r"ΕT]Msz=&[ܴyhoo|y9~ς+}mKo3sk8Z*Ȓc$C :.~ l@ lq sxm'0qugg;}># >LEպx7':=o o.]?sR7ښ897(TێV3Fi\ui SmJ #l^.]3wJP}VO@;4μ fTӶ7[֙[֪ݺG]fr6m>,i.:~lbGA!Rnm=M~kv?̗w?rsLL|7sWÆ 2 xm͓fGtmduf懮se:$[vdRGzJ㶀R9T{ C}?͝w~}vZ t-=~糚X 8gPt:jivЗ[t*]FL{`|]e&9d$u! 0{ @}'sXV+i[@MɪpQUMAu \USY&ۅ2 G}\a/O> un]BY͘Цc7xf.w$9>K<0y\Uq[?c.<>Ol+p> 8g^TeLގ>j8I/CȠ[sx%,Ty:؛&٥/ǻλ0?`43WtDtRwѢKVLo:tNSlklR5yLf~#(FHM;NMvOn juμƫ1ͥϯɻ {4s9/yj.>Kنy;v|yNflx1ϳj.99*y+[U.ԋ)?)ҶZdEt_vz* .]$$mbA@ ^z%>^ycq5]|L H[ۀ:S1s`;0cג{怬[w@*֥˅XRӢZ%T(BA^bsnT ]kd5&ΤM$5'+4;-555&9v Y^rf: CT]<,>79JB&}=OM'k6tgm/\Txt+!MS*`vF:_+{W}G^YܜUEM[di#];"# ixN搁^}뫗2ֳvۙh9o;ԩ*NJ1sǤ)maAM'/*vDNm>$mISa;SJF@{r鲄VBs Vo5}mڽ61|/.1&qw8FΟEۜqN=gE6Lc#"Ӈdm:d͝wl~X(ٝ%3wns]ֻnL3w b 7(WO'Y<ӧҠwjQ>[wɖrč5l苦:9?ۖ^wGvBsYLuCz} ii =:NqIc5G"`a%*śwǚ2/> Iyؽs*G;sU%*]֑ӓ{ҦulPzVwAK·Un$q\=Q׉tm:ATOfjܚtiv9}d$s װyP4v1ps/?qk'eseWzSeNWa9ܲydgo|yYonL?ݖ6za[ѵ֛< lLVi99_GZј6- W ̟*lχ%`Ok> dAòZn>ӹ@qŋޜVto j"pwɠFҷUͫam;]c7>%E5#`mОG[E}0s453/ȤPQi{!1*oYgJת]HxּwڛJ9y=,۷؅<_nsP:E«ץ崧o_u-c|nw8{W9B =yק=l o]Qz֗ {fmc@m~Tvz?ɇdlIn+e:ҿ_YtM &9Yk1v3򝇹~*WBsگ!1y5pv6i!}I57쐍nS[Ci-B9Ҩf掽U(L9j 2碧/VN6}[,Zw`89Z=\B7"_ט9C&ۅ2)̇B-8n':ostTWWbӅS$gaR7:&UP~0RscXZ=M'@clxseL^Vצ4jߒ fO ˸ӫƎG$ٱyq^b -%+GUoM vXFNK*N_-m=}E~˦a}t[3`9S`56̘ܼY&w/3-δt5Si~>\y"?_@XK]WJTjcɾ}P)tQR&u|c]H7&N7yk s4~,9På<*!Ϡ aϞ={n;43<#k֬C{&%P~7Pk9sF6l`+f]wھ5y3cNJ5 kP:uδ:='--}2Jo]P#=^uC]2_scoNHlȎPiP~85nY vdEwwF'd(Z ͵Vky&kpD¼NɸykL \2>%!^Ø]ƇEA^G3r~}kWvڞ7VIM7AԤR9_=R;.)cɾ}X#wCL^yVSdhxP5A^]| R5Aʧ:[3y^>GA4DA@DA@DoV!B+T"߮^kP>yDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyTtW[[;kS0; mmR’( /JIjOZZdBnN@5(_sKS7&qoMGO'Ԛ@*S)ekR2ܣb[O$Cv11*uu{0SHM @y'ț J[׬k䬞V+jZ wTYTZ3zN/vR]}`1'ȫxBY$)2X>g}(29%;/TgEx#<]I9*ui IRsU}!ٳGvm)3yyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDAyDA5TjJ;nϞ=y*Q*WEQ~<AyDAyT ov@d׬ Jm|m+\ťM*IY~VmK&e^cCRegڤyj[t[tuQ& ,q,Mv96wH6\?}JvA,[\gGKz|}K#Sr5/^ZQeJS2j92 m<+c/[L4KݶB˕l])z 5rt˜ŔuҐ|X<=*[@,W zj{)W^}%B+ke2?ȣYUF-ײcRF<]ZUnt6['! iVr2FdC6RL6uiI\'ků7kͰkiV03(zm-@Ŗ[e 2 2露˧+̬+Y<[ `L* $JD_R:mDU;jƙn(&:='--}2ƏT$csZW/)9ǟXȈILIֽFNby]w q*z17s֓-{RjS.~zfOO=tR|esMԨnBBUS߲KU%߲+<P >o~]X~ˮy`1<$vDiQEsIdZJ{WRt%٩h:cN ijMt:AkkOoizYhz]zE{ܜ̙4ŷ emj[vS҂ʠrYQ , oٕkqBEx1aeqT'Sgd$(6JGRg$^tUM.#+Bo.ㅂ 3/ژ4` [k{D_;ybʬPդAiRMiUtxWSx)fp"sZ/]3(K4DFFD3miRAh< kI̷Iif߆ݘ>s! wl-9tfȺňȑMroqAePktyʬ"ˬ({U<$MIihϹHzeW,,_T,Su9M^-T#-dc:xLE^:DtQ,vZ7,as>d܇癕_1γ. &9fI;_MC\ܴS]VhM*7WAeVebʬ({UW,ߓ=yX}=A5}/>, ],W(ʎ[g%;Kŋj Τ\y~S+Fy*̓kT/uheXULJwx&Մ2 x&:D͵DAyDAyDA|J;p)QFK2 +׏Q@^{5W+`_EI]Ɇ  `-A" >BTg7; mm2k3dݺ2ceV˗/gurNBJ ͞(uҝU1]63rDdkᒗjzYwM!Ů4Oe.,(_+G}"_6 筥Ї>d:w>H$>Iv,pvJxuvȪ^X rkǻ7sNIܝ]4M'u9 Z^4IS|4O '̲BG4۾M[RPVJ)rxywba͵:~.rvs&N $Tf5Mws`Lrۻ׹[5wqwjtM"oWS27/k*e3T߾UNugzpeoCr' 1H_4Oe͝:S<"_|+կ~Վ4=OUHZ+lù! -Ÿɫսbx\G]u{OZhY7Oɮj xFd]Zs{i6ƓxPK;{D~SO?Ir޾E_!a#UMxF򅄙?hZXnf? ty^ץyB/^x ;E3 Iʠt5PFhӚQn~=@+e{kZKw*FyfSsdglAn\e(_Ŋ)??[T t(u<,}tu2lftG 26& ΀0Vb@8&t@@|a'<5o,K( (Ys3yn!фv"Z5E!h{7ibZr͞U0 ͗~l-Q|~t]]lPE"7ϽyfymG+s}^kyvC{n!NĹ_՝eM]̴XNSOrVK}k,_,S50μӧPS#Mlv^o71. !B7opbb4Oe.ݲ*XVJ)r ^y3^͝IR K2n }١ﮑ[H$>IX Q+Rg+b1h=;%z'X5G+ƻɀg8x[;5w$,x[2Г4qm{fײS`<4VR0~h:vmIA麙7)yucXZOUTM/:h 7 d옌$euFHn~NQA؈ F{ q[txWwmSdsz +NvO^n6LdFSȈHc6Po[<˧B+P6ULȐyKUDLu4ޕ=tŁ^ˬg*VDM^un4E/?TPEdc&t;We.+!/=tޗ>@Y͓="Pbe կ:^@YDA@DA@DA@DA@UhtX0*3gء֬YCTVۀ/L,: ZOӖ3oAb2ŋ(k铉TJR(u2:+uv"cT@Yف6OQ #)yg|V-w~T);JV%]4p|jiK<}@OBҬƵݛ]Nmdp[KmXzzκD[YQHwC1I*@[R@y`VY]\ U o K7,'(=MaG Meg|"󼛦 yC{גo)9kfP`{]Z<]CQW/1>n#ʬB[FdC6λ%|[7Oٗ%2YuG$;;%ٷ# Pv[} Ta$f`n wr/)ݞX0]3X<7_C13O OY&;mZ ­<׬K.|˔cY回5hʾ/-O6JGKtp T\eɫ>D7== 23yQfU5-1 tO@U]xbnM<lAAyDAyDAyDAyDA%3ؾڰaˏ52 Jjz{/g>Kn/ɟ7xæx`2ԹӣDu*țV'{O~rNdIّ@\4:߼.x|4<B\o%3N~lGx]",M(˹{V ov@dH3dݺ2cDu] /gm=™O95Z/!oRsE 쮹FE2Ac(U垱̮mxP3\d_V ]US֙&\ |vx,Qɱ,\uU}9;}[Ew{nէ?oI?W*SX7;&"C)ݐ,ph}ƮSnJoBLanM%sA7/F-7oLkr۷ vJ?(_RX3=s-aWybQtsgN_꺚[.]g7כ~F/$*uG/vXkN'}'sW7 ;IV9zFjj;FnprڎL<g]D$̩lRa^wCRV'>榷ʑm;=>.M.`e}p~6бA[gZe͢`pӌog|]l 2)etRx|`*XsԐ=kzje`@^#}O^%\q7_-}I>ڏCj;Mȏ~ ;ћyk:En<*LvY91<.fgy]kڢ7Qx9ۦ׻#s2]qKrMߐo.gtWs_)?^䮟F>rq*]0:kֵ-ԮmI]]^F˪+|K[}˗K!ߕ?#?sM3&8\ڼy;t}sscq5lbj2՞i~-4}KNey8Ì?"##<P4O7'>v*]2-c2N_0SC盾Ud_'yћON.T些nB^;7Ȫ^%|Srw̲Ew=j鐍vxבnvn%3߳u/go͙@oU,+lup8n$;q͉ {CBWMrh_&7ZݙS5I-3y ]@enB 9?* UQ˹7/m& Z+o!O+j?&oZ>k7k||֫&d\^_G>3f\zeWyBej4k~氵B:fY]g#(꙼T*UԚ_y[}!`۳g޽3Ț5kUl_ius9sF6l`򫆲-6xO7xӵu_~뼼?[f*NW]+?տ ~ЌWF}ߔCχgd7.SMHww|c.I@@*=y7,&_oߕok1݋+Z ?Sx(+}>޽K~~S~7{7~GN~)9z^O{M`?k3-6j5yjGZw}W}crΗ+to!.ejM74"S_)"dFPP,鵵mՐ+h|k=U /<,)It7$"u2:+uvUhSL ᢜ{QQ>qAWݻi["=o|P]2dtݍJO!95g/)q' ύwEDLkcR.Z5AV?zZ.Lu`YIO9cO\s̐!6XqTR}b{V)͵ۥ]מZ' 7ktWsCjjOֹUgLv4חY_vM^NZy_X7|SX݄1zR.iWŽVCZ3, ,O`X5(Ct5vn+5%uP]IO]z!=9Ahb}|j u|D9yO˵vmM;'GePϣiyQYzTxNSuSi 4'}ҺW/3-_O'O?D.oGyDƲ=ݯiz(ʼekuՙGp ie*O X*ms{%`nvFtO!S 4B bK+6T ޺w~BMmSvo )}M.oh{_\{uj4L}ԝ|N@zx-/o\X&N9tcDOZ<*f;6}3*\k<9;%7 &UѳȪa{Ԛɨ lRrk](7#u 4-F=|Zii43=X2TɣK,;/>4fx<7eSUx1\;z_N'g1_ J?2][ݘ)Toh)SLJ/Os`eIRs?W^yEnV;Taαv]R+C3Ț5kr9slذ[Բ-@)6{XJ).Jgw2-n yVRʇʿxQ[[@U]gV\ 2hܕR>BR>\ AyDAyDA][% r ])A^g̀gDA)͵D OOA;en2ݧ>)iinn{tzb13/U|RWWgkjj+0uɥKLw9x&]ۗ;۶r3y9|r_AߕH^f`yy6 vh ] xν(S(Е}w>(_RoqTӶ`YxM[ ھuɐ)t7*>XSLftƝ0+h|(>7YqlI}@hw_\Ԁ֨eMsg>=Qp}.N3CNZz0/z{crHZ'O\R\"=Owʯ2yr=+stҮkl- ݄Ps銵ML 9y!sz5'rTK3k&M;mK/&/'-gc22%!-UvWBJXy]ҩ?AwU jwRC]2_U*jNdj&$;6ӧdfwpON2wv_FX@ZlC2,Ī|C "yŇ$vj0wY^Y~ [D:nor1.u|D9yO˵vmM;'GePϣiyQYzTxNSuSi 4'}ҺW/3-_O'O?DJv7#<"cccY4=J4etr\u9i%~ V ~G;`Ov5]`nvFtOS |>s88!/0ېEj*;Xzk*5~Go7OM\<i޷^_-?7߾,7x]*$uk4>>9S<-]4? 迺3'<5i~i&f[;]<zx*n0GN򖟪f;6}3Tj7D7x rvJmo M h]h gYm7s߽Qz3^j^V{o}O.v|I>[JrZ65mR]Jnc+xbEzx!#'ƹQ͝;>3hmf0P [s.]r"nT'|bFrқ=1,]홀/0۠NiKWKR.]#?~\pI._ _ _5?Zvm瞐yM{'{Emԯ}  Z2m/e/7#24fx<7eSUx1\;z_N'g1_UL9ؐEkO?>\駸ЪgԝdHV6Ϛr~Ovzڷm̼f7jj5|I~,j\`Y+[kV|5y?Co6O喅|DJM|:hP#A@\[%ts-ʠrWJ@ AjVJ@s-@DA@DA@DA@=yE=y2 ՏZ" <" <" <" <*7; mR#cq5m@f!z>˙iy1/ں˭|uvmbLM^Kt%K>-ҒlNTJRf- =j0TTؐCKd@UQ;DO8P#Y91,7/vڟ=IxBIh kdाK\MsdEy QIM I^˨|mWK#,j֜W^}n}}HzD#>(AoJ:>t!b:= F%K nj5KĨxs.#zzr0[o-Ȗ<@q-J A>A(_aW(K`Q=2I7k;dԝRS@Y˶E] 2o׶M]Kl|XlKCZkPFdd0']?-}2y.%X|W<`T+TzetUT$gųƧtdelD[:dz]%qD`[嫮-$LeEtڭNi_Bl_(--ұSb؇MZyӴ*ʬ#+$zU`:Cu;%9Ål  hǠ*n$^AsvVL^]Ƨ^:;KNzGwTmmj,V NċiVN x OH3-̣ 2.y%:kbOG)+EI4j;Ťk_蓖>hId02c~^@$ޙ>lL'U2p#(ʢʃVi{w9{bXcyt!!ʻ2<z>(q{hܴUӶXt:M ͉ ۷0Uu͋lg&X 1.dPz]{fk$ٯMW'鷻#j\ۼ*dW_J3IӎKߠx ~MVVM^NZA@ˠV]& އAq؅>ɵvmݖ*7\`f엱Tؖ+SinKMӰh&'V̔é_ 1PY~yԡ= ޜ=hsjZ lGij˛?9x5Y#Nm旖ib3/ v-,'k(fugA]+qٴSW]2dk9 ֥ZF7] Z/qv-TP7Gg$emA(y)(u]홚Br_: LU~?fm wt]]Niz闂Tx1_ D_Ik,#u+/k@Y͵R(<P[W,<`y#- SJ3yDAyDA{񂷿Ǣp</^f .DR>\ A(8?]{~JM~{ ުfGho D\)eTł<XNY/^Wht4{@{TR(<P[W,<`y Bzp, ǣ:- SuA ՁcQ]8Չ @5+|  /'hjߠYZcqExj8}Xq?;;Ԑ=R xKNXZ-X dl$)}|;|5 1v{O=%g0X3y;d#HK@/2Dˀ~VeIYx{otjuQV$ƉQ1*R%mvF-V۵xKUڻe֋n:I=Ǥ8iVmS +Py"y;Fkt 4M缄۵ OH}NH$<ꎥR'qf~>xU`kWEuxT'~ @5+|X AjVJBaQrɣXyJ)@YnAޙ3gPak֬!R(kw ;a_zF+Uk2Bdy`u2:1<Xfe'k)mOЌ=tM;`rg5ͣ{ܓ)ghHn_z\H@AHu{OZZd"nQT.ܟLIE\* 'TZv Hu&ҙ4Z2S&:d]Epo;xm3=!3zrҫ!-#Nm旖^Fӭ3<%gd`X_/Ceu߅:T`k"=~ȨhM*/σ:T_u@EZUW/a9aFgO &ntHB1e5ug6rZXzeWx^$4L|sKv/Fd^O79~oOZc˘[*ZodoSuʠ!Ie}QH@*xs9E+gå>#m3cL~@5/^xަ~^_"u^R2%ҸNRЁw& Ё[TA^kd*xL꩖XzKvr0 }"hVomm)9FeWܯq?A+GE X_Nː< `9!PJ)۵  "  "  "  "_(_ ŖQEy~4DA@DA@DA@DPIߓ3>Tچ l_x%yk֬C3gΔ\ AW_}U^z%|9EN͕ȥ5rYF.Qy>ԘaXJ#PqdR~n1;=ʯ6XyhP.]do,ty*N7Ӗm @"L_ə'5zW_}żZ.]U\y +[/|A>{B_7-/AqKd;/?"Z*Z4yO>2%]nd6$;j:^QZfN{vTz UA_f|7ޓLw_^kJ5 rR}/=j"s_rER;OyjԩO4=7)AڽmMѧ䁶0E⮼Jwdo-vMwWz܅v0om vti來;H&^i{)}Y}!T\ߓ'2'ls=/Q9O5KVbz3m*7*Eo~ܹC?Hww=yͳ Y9׵XR' @}ߖߏo-ˎu ~Bg:Ͳzڅ L7l o6ɳU2ږ[#PtoȥKp 7tS UKi6N7e4yTrs-Ǣ=Fs-@DA@DA@DA@=y,@d$۷>Ba|2 0Aޚ5kr9s y{7jm oaJ x& "  rO9;X X=qZ* /rgfzY;RNw+.XvtrtL_ה'OoݫE7U׳x.<9QLwO|㭅zJdE'yDݪO4=3^A<%]nuu!`915vOr7Sg]`iY^8-݄=sRayHe7 W <{ɩM9sziT2"D֋&찧מ֕'ۢk 2g\"`ϕZ:yT:;ya&4K;/6>η #2ՕI'wܪ*~`<0>G&C~y0x& "  "  "  "  "  "Go&p IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/arrayeditor.png0000644000175100017510000005657215114075001022304 0ustar00runnerrunnerPNG  IHDRZL<sRGBgAMA a pHYsod]IDATx^xT՝?w}v|}~Da8k#P6X0De4-T.M\66Ġ٦DIMRq*@3;;&3~gsϝ;s3 'N9hfo~|<'"""1@% \@% \կ;QGPoy:ϴi@+ԯ ^{5|5kUf q? AO뿊O|f/"""sCy6BRvzdK?#e^"""sAV2HX?"GM!C͒k9fm46}7'HG tp'|QVoUDЋZ"&ޯ^F9׊/R9gӒx \\`KmSij=Z*kG>]3ߏ+G؟KZDZ_[zjޖH}/zV[__Aif` 2PxnjD?(*"")K{`*}&B,z+n YߣO1cK*?ӹ?s|NV |^UЌ5jDox Vj9FR*lddhHRRY@TXl]4NP#6#=** YK^O/@)P=yA4Pr.:$y;RNhU|Skxx[nYZYp6܃j9__;r?|Y~^ Jش8/yh@(lc7Q,SE"i=U4l1AL/ 6ce\cFѝ$Pn^I`a"uNsk hΏYټeˏa}ͼرOڧݐU$nl+_ܯ~U/W4_atuX`^nsv/{ՅFst(ice ִD`ѣY|j8(_Sjcʌ:,D JgkehH Rߎ5S ?]5f+@Vt'N!gQ%н뺑4s<v)RHޖѤE9Kw㻛iZ8Ѭrԛa3 F~P^6J:idOMgo? H.23a PmRT/GDDƺ-$QAVL,R+뮻άY~^I`{͚gôiVS۲7DeJalԉjJ讞>%rdY&k}&5&_Ho;%)s-;狄li+coRӴh;}<m8Oޣ,ٷ{ɔՔHwPږtOķ(gmWҽ^ck1EBgx=U!]HJ[:p8 AuIuTԌo5zi%)+^<ڎ昵~n 勆n dSKː6ڏF cxE]HP44U\͏HM64 ޸[*4Z'/дG/Aem?eLۍh?s~lz)\3>H.d\rM&&^4R&kcפoyԏN:)}zbx.y}iߓrm%>bA{|+e޷Zv׏u_kΥvo)MHh[iW]I7>-OH09}eEq7zyHk\vi+K|> n'^_O;&j-h^ +f{kԕV2T..) (!LUB j쒞R/U-erW߆~S&P‡ZsNKhE{h)JgUOi~c&KF~>.J*5ו™HI8LE7ˬYA*_ OLcQ똣CoIH=V󋴏>dTi}:uM#5L a~OwfB/G# 6%D_q>^> rMJf޳g\CC|\d1RX$,̎k4+XDſJjvu]RwJ}XtdzYWLu,-%e&bX/G?/Ce.t>3R\5nVd J|^&l?B?\IgB-˂^=_".Sc %RI9>(gw  hER"(+nE7LgFz 05&XDUg&59k052=J@+IR !(#(ihrMZ#JkR&J0?Y]ftӌw6Wamg$=6ut.&:dSrMiY,5{r-_&ײ9I'AX㗥3ӃCh~/c/vaMbT.F=Zh#82F 8ׂZ~ޅ⛬9(d:`3ϝ^䪜Ց`Ћ "~=*VKSMG.Vm5y߰Mgm?lFS9F\GKL=:Uрk ʗ7q|Cl:݁eaҽWӍ70Klӈ>*f۔c^ׇӳh &_VEM5`FqiW/QSh,Ix.&^B0OkLEʵlJ#9[ Nzj=aa|Z)͘T%-iW({ٷ\CP\iӁ-x*ETM]uU@q |j 䪜 \Iz1zqaIG|q 2#SuuhSӑKWtux:#77ucz:&]>)m:%jw(Xh"k3Q^v|M9Goh?,Q+[|܄f˻ ؈* &_އ٘o}\:MAiON9UGgQEz(bJ><>ʶ}ԛ^kR5_k%Z\I7'Cujs(̷ 09i] sEҏR/IX#G#𸔹y72_|_H+_?K-x&r|=T$[F/oLOH@o\F!)Y3> _BK׍:G͉8^ hy}(ib~֋uuҠfwFR4Z6\R˚Fv_ƵCap(龭ԘѥQ FGR J(SEf.6-R}MS|qe7&YK?6nS޷%d\Su]r? c|MiI[Vg<|sIK&/|\ >ۊkmy\)ףd&ގYn2aEvu+=4 swCR^ΓI}XMlۊ☡QbXxҔwSpĉa| /f2|ZPtFmOA]f&ڞkpKqRW5k4ɩC"""-""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""[qmATe} zf{B5hPy@{_Ĥ鱾E?`>͖/<%D7f`,W%@H(%%W*y7>z@O/>ezh2^4n6)*9wY?}8 IZ߯LmLKY4V+񑴤cFB|trW"m}fY7;L"}~ }GeiVs.Bf]vpx?fz\Yrɲl+\&ߊ/5x0ǬI(W|ږttN3yӝ gZ(T-+:WFa<\<(^<ڎkMZ?o?߮v9ȅ$gTqJP\js4_ȓxZy:K~هΕ7.~u)oDǬ'@CYǬ훿o 'AXi1^An2 Jc-:M/+ҜW.:.eZ9)R&ȉs޷t,;|S,{i ֵZz*ߣ|lr^JWGu(@,(У>f31~4J$"6p˂f,Ii!~rԏ[oHulAM_e0rͯkeWdzh+BY*fk?`k傆44^/Gs\4 "VI{ohypb>΢[10TReCx+DЇcrZQ`^ ^n|()!zQ;ƱA̮XbZX6&&etgpߠt^ޏؚ>aFɅ>gtuyHGrT|w|=+C 7 OLisDb6R&!e2IzJo2y=s8;nJK4Ir)R,ZeYii䘓uakU6f^p@їg| QYFHSZ'_ՙjWKڞ_)}hDffG/E(_yp6ma<'ʹ_͔Jf%h\- >z'j\\{嚿3\oZ"|o i r>=r/"[Lz lϼʏΝ{TC-hX@>N$oxLuP(N P J)>UdtgkiL/6+LAؼӹR"hZ>gQ$hӃ~A:L`z:||_Ҭ)@}t-1_FUmʳC6ykLd8;.Ih)ǥ,4*)˫,S9~X+auo;7}RI*:4ϝrWoGnQ_3Ս*;hMп6J{W/)9a .]|Xk+=u(UWtZQAzS_4|AT r/k t2Kiغ-ZNi\؃Cqޮh 6lLJ%_Ã_EĥhFJWxh֛8ޤ_M5n%TI%j{q|˵2]Ĥ=&cFR$eJY.eb2 Ur7}lPS_BOW30Ʀ AY]OSZ,f5׏K&M&@%)_FV95؃KchB+&6IT?sQ>B[!׼|Y泝rL/>⮃H6N{py~{ԲB)2*G齩תs5%^'PB)Bk*S nofɻ5)󕨲ByY|3Ӄ_R,A_1}N3`1W)88_ʤA: .S+nK<)|$)˫GukqPT(}Zۻь'm094=ͦ:[WތP̤6._j~,0H$_5OI5O&鯲r ҏw5c65A &*e%}HiØTiK~,mCvT̋Ρ@kJڱA֌|7mGclSR iEͶiL 64Es(rs;,g1R!iI#UW 7kzJȑEpg׍S/ 43M8췦/ˤL~*ebAF"ׅkou) lJ|)^Whu_Y{7VJLv7~'Oʾ!%%5ӊSZ鯚M_Mr&ߤH\V30#WZiLRc}l\"B|:Xľx4<5/r[ҋmw_%~rETS pT>-ij0A4yNi_𖴇G{sԡ 5^ki˓o  ?`egJFH%V7v3 6\{%xRwct|5YKx{f!lKܸ!K= esߖ}5fk1^& JФ6mֈ1 W#9_ɗ&I|Idޒ]me/诤,}V$xRum記n3XF*֢\]osioWrwKuW\M7I+}E}[GV];}QG\RсפiJ6t׋UJl}RVW{DOS cm^W3okgɵ;[ݤ E;嚏9|t\wIo/=~zn~=iø4i]=,/C~+8qİy>f~d#?h֒` %^4Ƒ#GFe{0gtFmOA]f&ڞkp9f&I_}ԬDS;̙?{3#ZhKdMν_ZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD. Le?ЬQ_@kʳlh)w|H85h{ʮ}䊲؃a3DW 8uHDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.@h(ڎPB_CKlw}7#f7iFet}^6N {{"r^mp,khVeĖ-RK6^<-*I[TS/鷘\қ^r\g5庖e,ۄp mB$Җ$MO}w$mw:/]ӹZе+em œ` ֫iIw>)&M{mz [}#sf>={=c{/Њ`a *o-`5 o8p6H ǭ|}?!~JhG/Aek` io?cǬûa0W@gR$У>f oiZqkmQԌ'e^qJXzڴ-Wf, @]u&&~$mb\&KxgN>V)jn[Ӽޕ#iSmڙS>c:Kމoc^Ɔ Alh'r[ Mc!vXi^zt> lV[^Q$")-oEIWtOCm'͗>y{6jxzsUbp'}JӋi'3|LrZ _y:^/{hJdOWP=ff庇ُwQRa+xCk{"wJ.,2y, U g6`P7g诺Ћш,ffo'mxY 6yneiܽDk'Aa9`1s aZ}6q t72 | d\tZB?ꗚ$&M:^@ &igͷڙS L{.惭-e<k@@`QzoWht>+xCeP*ZUW4+lDų^ː-SZ^j. R?_YꓞbIk9MQ } aKI^R~)r٦\[r- e|t&0B~̶{|Db@=Xq3Z4/"GO9~kp+:LfoWOrL> .3JF|$_C-eE,/}UIMٙQ$Oe|mxuYl+pU"k)?>(266ۢGMxY6/s8|I"Ky}|iQ>v]),|# ` @\jX |(d:`?/di/O& uWsc!鱩CF"|cEW.֝ RԣWwHWH.>ewIz$)Mo46/i9f ,}`|*Xz_\5!VoB 6Px5 TX_C3WS|_Ԥ `nXEaPˆykËY KT]?~R/=X6Tք[l}{pIHZ|tdoSZV-o3F"I.86!N7}UR^e~A@ڙ\# ig)1S3*Te}Y l_Ă*PRhQ 1+QՃɯaǗԔa?Wcbi/ڋSZ޳WErK>#}ƤjtZfu jmS>W>!}௤McQ֪L`Rb}fZX ?8΍pEa5GE6e$a,QئEPXhF!_9@U@]]:`0)g"#x9F͏fh<]?ŴaSZ:fn\Rއ٘ GzAץM| mbV6q }zתfc ]),q)_c952:) c^4|_Ru2t_Ȥx>?.B [mt+u`utJ;zN=upT!)?-MZh֪%A|W.UVa$􁱠KMV[3 c\ O˾Hr8Z*Ȫ:?R oDeQ'֘$}'_%++1 geډ-!o i۾Yp*E'pkQ;ͯ^wO츢W_EՋVhbjʪR3x!UZ`{8K/vu-C%~Uo> ߊMDB}4./Z;CA յ(yi7ԟwji\S9fm@>em#B:\R;ujv/`YE֙TGI.N [O)zrB J}~Xg^kIJK'`16q\K&~$m}M(i f#EƯ#9Vӏu=ZP5ُ)>C\4)RHu7byncSomXb=U n gAv :$^FUJ_Sa *F̶4շzڔ*鯖&:xVԟih>I%链a$Ǵ>IlK~)vLg!jK@%˂tDWsƩ}o?Ԃ]nθZuaI)DW-:eZt<Т32ejEeܦ=~7F B$"""'~ \@% \@% \@% \2~O_+e bh]Y_Z䌩Rc}䌏J}3>WM=e2:$"""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""cAKRk6h [_+dY_f{C5hRyc}+A[ے."xϖzRcZ ^nٜM}H]vF/a{K}mj1_OӼ^~Gǧ|JgSC2$eLׅ&=)eBfcù|j^=ڣ-iI>|!eM)XOHI嵿Av}[sUvus"mGK-:9d^OaHA)'ҌKjce'N5-Σ~̕:{"=~Rv)_NiRcJ}7y/[|+Iu$eLE)̷FMgr޴_0[F8K/c4iOK{bRWH=mK3>#uu2K}J<$e`0HV潶u>L7 ڤzҌb)U^i;/K۹GTxLi|>}bRrqtva! ox\:cALBCVZ4o~|Vi ZHh)昵0#Jo>NC.I0_R?-z[^S5V9c3~tDbP5K J0oKd1 }1Pjzdv#\TbVXpo$yC!H2eA/cXԪcZ)T>^T)7{GLT0}>"ۮ6SM}ǴNjTjo>^1q>~>HyR)%jln\F?#q.zR_J]]oK 1e0]ʠR`g4UOJ!e>Vzhܦ-e7W啶M9u8M۱_czp+_I(qNѷ=h(W=zؚMBy|29C(X VZpI ZQ -J9o*"cZ*B? x[J.V~mu.rIӤ^"l{zzI4d]@4=,;#;. t]g%RI6ò4%ݻ-^d?>(*g/֫bFh:[SZxG=J}'{C )z%פ.:x+LOוz착9)MNMR_zX&ǾLH][&e?Ҝ8K9M{ӂk=~N%I]mX=zg<#e[=J|I SCYևW<6e%)sk)!Iɟ땲F")eW,eji;VpLM?=}}>ܨ6V|-b$0Ӄc B NF=Gn t%L l!:o}0Q!\>Gki XhmY NG/njԔB?+k{r uLN}snt8פp-fئ3`A c1)N+12|LBeRwH}d5l1+Ф;M#e̝Ҝ<5JiH{|D㽶)ԕ 8yVEXOJ̽2ȏ2Jr*;i;GڎRTi!iH(߿Gu0 S&!XB~o'A|4:(@Jb]^_M 6~.3|zCWP=4݆*)Ghy=ZH@xN)?Dc}?qD:)i;#Ԕ .%ZIA}#}S7.Ui6ڤgಲJxSNq:KcK}TH}'12i?>"x;,qL4UWK}\*gNSp1NR>feU~`+)- s]5(s?'vP2(֏A<)ep9*:hLv5f|0TtONO%i;^i;wlu>jI?AJ8BCIM*Y盄G/Qu?Uh혆cNb3Go-,~:WLz .\kڱt`tH{ԷAmo{bCqz뫭5XThr<ݨ@,< '1WcR{(>/e׈) gct?!Sf zYu\J)c`SEb!_H}$f=}R$ekD)s˶2W!:(rJTzns: ‭==b JtK$I}|S#Re[!@Qst^i^*Mu̒= J}x>ԽO|]ދ)F!"exV92H{=RKuG֋&)EM %}5~=p.#EicJ{lLjߕiC2J") n$ K}$޷eyXSIxc#PI=\vv^4m!9nԛZ"1gf&ڞk1gNo?Ԃ]nhyWge'"""/s9KhKhKhK/gIe?ЬQ_@$ QPHsX9r_>r]y_u@Kpꐈ% \@% \@% \@% \@% \2!V%@ "f}D[и\!<5۳]ܮv"$Ayz/ m]gK@d=T:l%-'Rk>Rԇ?ye}5'-1>rs<?Z[}ʣ<J}kv),1)ohb]݀y>"x0~a/ΑƑtx7- GXP e&uOf=Q%i^3>_/kmW^GUF¢f59(j>>}HEIu$e=y?K3>%eTe @ycJ=#CqGHǔp8$RvRv&^^fv \z/v0(1,?,Q'dkǬ' HtKTJG\^1TcExPx$D9&\s mc}R?~Mo~BR?0oJ]?H 7hEZPj@">/ zX]Mo</ӅxNOUۻb#aa쓴F MOUz4󕣩xL vpOc²`/J3T>6a`R24 D7ŸVERi{h1%p=1O\r^(M^S3v^\zJ[Ǻ7̭Q0K5iws~Y&k:L nԣԇn1aL-Aer4Ol/ c122Jǧ ؃<}\NOqzFx?gIy| e9ObpZDR?VP$r\%!h|R)K~lAs#e>7hIcF Wԣc&Sn쁷+ZhS"WSa܃n,NFhB'7mQc⌏IgA^nj,#>%ey)?TZf3FOI=@_H)A:0":8ح%\>fUji!JUrMr2SRӤ̟7m琴kk,QSf}<`ސɗTl˦>\\g)WMGMK|֙)22222i ԩ@Ig| (ܰs ymTc-ɲ~R?>QP%WsMf*K5Cr<>+e S?=XV|6^?iѸ@+R'sHO FG54+q6hQ#}ZYHG:f`鈮/D|+TSB\r\a;}|6TH~ RR>J3UwHݼԍZu\Q:[= R%A3Yɿq.J~47z \C8M~zTSr~FLבƏ+me ގIy6V5Totۉ-X6SR^QH1ԇ nRHH.2UAהkUOc|F Gz\@>irύ~93Q$B0Bk*s<#ԏy7&JgkqFu|h,%*3R?`9q (([:x󗇅t~kHU_%X8[v,fFT6_߰[^Wl"aW9~=<.ϧ yR>I} `>J}5b^t ̫zz,z|Vj*1K7' YV+6k:2.ϧ k>8&C#aIR F ڮ]U|2WS{*xP@FwY֛6z%XZ߷q;RRkL}D>F7R5R=Re ryR>~9[D5RRi0IPVT[TsVg31XacR?VlHU⼤ Z5"Zd_be`^)OIYw'voHwI~ ުk7X1q 6, ?QRҌpxŃ;5ûk"cuާiqWm5a{;2]rA7͒RoVBɫ^"&IWWOzP)Q$J'(H= I}oKʠE a.W'uD;ZZѕr.8$WGWcW8*^J|ŃriRzIcY^CR mz\zhIN˺q!)xyfߴ1URףCo4_/'yK})fFGbʱRgPG)52HN2JJ6#m-S#:Oyf8f~%VS>&'71~oˈ%zM<: MRc;j^@xUǬDsX9r9Gf&ڞ0gNo?Ԃ]n0Df  \@% \@% \@% \21ށ~Y#""|ſ bu&'{gDDDD; """s-""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""0""""r -""""8qb<3?<#"""ݻ<;ZDDDDĩC""""0""""r -""""0""""r -""""0""""rk_6(n3ɮR}c8`Vި Λ;Gyy9^|E% (> |¶Խd҈2ȏ+A/$)ΛZg܅g 3jRAVuu5f͚믿lM։8DhGr(;=u0Bc3jM۬Vc(6s Fyd&fNk'͘9橖P#] F#Z#e[t9N.Y֭ßٟ >#m#]Hg?ٜ(UTo*=8CR*[5]hW=ا˟ߊ`Fl$}GMAZψ,iy zO<޼}}F>21R7oD|*<$]oRN4^YIʁ4g#|L {uFAVlzfW7ЋZSM?չ;)#Z{[STs}DOS NRi[`gZS/w 4lo^5IXr419& 6lؠSԴ_|{QTt/VF߳gFpDfY7cV_.,4.7]Ѭ~hwj~,ayOGc{8Ҽ~\t"s 9 3 UکwU__#n] R*Gj*xvo/)cEDt֙˦ &G\@%Nl(h8uHDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.)8qİyNDDvm(..6RCG3Jͳ1""fNŇ~@k 0""w*{?iG~O'=ZDDDD.aEDDDZDDDD.aEDD䖓K/'jF;n10g#ZDDT@x,/:Cû篗]MYo5O>3 g7ZDDgc׶C6 r-;I'~V\CN ӗAl6 -""CSL5Lt%z zK~1FI_׻b۟F|@~m7f8|t5r#^xn#p>i,9;D٢>#^Abcӌ ކrx噩vxUIm}=Z$|خ49; _lphRS;89-"Ry1yך/#NơT3ғxUW vs^|$N,DŽ9Ͽ93gznlFv6r5(U}ӜH9af7ܽϋ坩טb!k'pGv^}_Oc4 LuI\ɩ&ljQOQK4BFyV@9<8դ7uKtv>gkKe>&^4OS:{hD1f\6͘9x SmfDK7TXnbEDDk5p]T"[Ѥĥ'=Pq4L&I03?6wyTufL5<̔b1TqZCNxP\6X)E vF4 ChQ~Q}PG:Sġ]prTYSTb3i=f IATⓗl7e[O:7}5)}K\qj4+1aJQ4J[m[X̆;ǛGǰZ߀hb#q\됈ux~iމG07 4zEyOZR2F{6 jd(%;l0lMFpѵ'O'.µcӯœ|ܨx ס}nӛfMܾkz`3wa]MZبGzrg4QbEDDDZDDDD.=ZDDx;{(= ODDg-hcuhޣEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD.aEDDDZDDDD2e}*IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/bool_selector.png0000644000175100017510000001737715114075001022612 0ustar00runnerrunnerPNG  IHDR80sRGBgAMA a pHYsodIDATx^lם/$i )!4Ew9Y:SRdG\HFJall+Qk%)Q%Wɐ("K!,B ŭ|mshڋC$ӄ${oڻk&?oo{?fP7g=IDr5ܦljA 8"pD1 Wݼyׯ_ǧ~GG Q6y},p \t W. 3|y Q-v̘s,>fϞ$"Fq=nwvCM`FoAw ?,w#Xx@ohz9J;wwl|>0trƇ׫q?kj]>.^ŋ 5Ydy|gϞS2Y| 6fŝ wo^E_s1zWQ<|?dߍUQê݈E(X݅8PuV O>>]]]x7ѡ>oe :9O.e Wo߮z܅7.^p?KќuKt;nE5&/}<8(<['oO~sbÆ xGJ[b~siAg\&ϧ"OW)r3qϱ+_~M|ŗz-OHŶn^4E"WWAW`|ܬVyw|? y{Epޥ\⇱ <˪ n]%[Ŕj~nU3%w9Ǐ&]&n2l;e8<˸)q<ױ:[2GixWU-WʜAW`|\oYf[&-gݩ>Ӫi9.Ez+QGe` 8=8|M45e'.*Y#`wF\~2}^~$Ѵݫnr&=`¾̓m^Q Nr8&w5J _BzI.3n~qzp1,>dob8~_~1,Ă V2I*=#.$y1:bsnopN,MԈD}uڳP[Z*O5>um$oɽW]8YsM/\A<wo+lF:+$[y'XpKzK0Puzzf޽K-[Ƞr O9=^5<7ٔk]C!V^N 2dj)R}JIW޲M5EI辻J4oq=[]z`wO3V.q_CVH٤[,&oB<DSp?j~%9y\2rNw"Q]_ZMfћיJYD 6)5U25eƀ#2Gd A 8"pD1 bĀ#2GdPѾKvZ=߉'Qw)p}}}z*+W2hRᗗeQ5HJ;d@ؿK ծaP{z~m?~ 'A 48~{IVRYk֛6D dJm#m}ֻ\r!'uznoNm33pN)d<~bҿVƎ>& L$c c5_vm(izfhȜzѕ\slCѺi6籩m-z\ױexu xspGfI/Qq:zmm0FkRIg7"k:΍[#^cQHŇ8cg 2~9ۜid:@V\nscr<\زsM*TX7H VGB\E٤"/h]g>w`{Pws&-sCDka`9BfgۨnR$JLdZZ'9j!$)Ig%WRZ_D>-g, na׏Qc""0; ub[!lO|}8ى`[0Y+luF\Ii`b'ͶO'd96͐owN"HEuWk_-ۏ%!x,-z@3&~&!ђC}+_ 8LJ*{hBTpD1 bĀ#2Gd ~'q=Fdcɀ[bٳ bڵkzMWs)jGd A 8"p/܉ {pUOy[ISx5ز};a? ?=/@؏ⷜ&븷rT8fK"x ^"zCMUĸ^e^ؾe5i/~˥; .al+lD`Wl/X'K4;zCWcpTP.ǗNdzⷜے'#U>=pƇ_^eDS 6)"crcpD1 bĀ#2GdR X@orE//_^&BpD1 bT&2e=NW2JN$s zr-x;3ICyN 9 M4 }h2^|=ys2/˄r)7Nr-X}-Lt%O YC$~3/_ŕrS}rQƴ_ ͼ|5 f5e\I,A휶o`iA4/J"O$mC.[KNdP,;;_f&$|xʶ<,K˖yy^-O6q3e5MwfpμlY2/ N՞M هIp~2*@N4ve:ߙ&%?va~q԰4L6~yh a&Zd2pD%f,>A 8"pD1 bĀ#2GdИ>#"K?&c A 8"pD1 bĀ#2GdȠpC;sP>Mpbv`dD61G8eַcHr^idy9.oz\ .uD8*ѣD%(+sQ˨ _J3g\ݪj+ S ף: #ĭ[P.߅"taA"um ħs_΀49]övY_.Lk9F<чգFW׀PozPQ^`o+^c] 5X.zO}5;d`U&utZn~[OoC*WD̕`O[Hs,sz%IV&/~4@Í I>:whFNV5xh(~ G4EL GD1 bĀ#2Gd A 8"pD1 bTR]Vw =FT]ʢ\__mʕ 8Te/tL`6DE2w%[ǎs\ToP<67 j[Qw8^N_OX`kjjBEEV^fPӆ~ X,rt3A2! ]vᮻK|wZ+5wRҞZB"lZ2#m}ֻ\r!'uznonEjL2S1ٵXFkUCQJQN6eQ-`s&}Q} c5帾AizΦQ72'^Ft,Pl.`}h%ylj2qWulhӆ8r\Ǒ Y&ote4KofEDyq!Y9֯ac!4PԀMEX2dfs7)$Bb3YHfrޝ;hܺ)ۈG*> u7VLc6gZ>Pܧ\&9.=la :֍."<# Jr-mLUԀ%q5#JSe}\܁-%4qlyC"\Mα2;FukPP:d DXbޢAW`9j!$)Ig%WRZ_D>-g, na׏QcDM> :_Z=jw&5D/K5lON|Kۂ^Id6?cV v_y,v"lt[.Mc YͿ $TTG|56wl?}X౔&#ZMhɡڕ/M&%pDSXbH, 8"pD1 bĀ#2Gd?~1"C=| zGE 86) bĀ#2GdȠ\2͚ӳ~>F8ׯ30C [Qs&3#ozگ[bttsx|+C LRgd %/TMHG2Q2XQx YS Q{nqkh܌P.tDYfa"h,bPOWΝ%+SWə9zDi2Ӈ+hD8WD$/3}BQO)3_'밡EIgTZD6.d)ʋo5m3>`#U8W?A!*Z@ȉ "pD1 bN`rcÇ&D%Gd A 8"RƏߥ$'~h`Ā#2GdȠ܄f^[.#9狁dɡx5Df^#Cq^,7*Ӡm%=z 9tsc73ɒfIi:rSͼD$V!X뜖5lRѽ`//J"r24WeժN*٤܀3VII fpye^|gT?M f,' 43}BQO}d^*SXh>>67r[5"\My9 n3/ݚhN<3ZIC8ԔO2@/"$Zk)Gd X 7X *1|hBTpD1 bĀ#2GdȠ1}GDMDc&%A 8"pD1 bĀ#2GdДnyĉz]Vǀʕ+1lv ܭRO_5؇#2Gd M]_M࢞, &,\01lzE7dZzROOô{p#Cqjxj@GAgaǩau3 g8r 8;~9z#Y`{֟ڏxx}NW%iM52uҭWZ5bB1P5%^ rpBP9-,l4//L4 0 b_|OSÍˍ9MƏGT؇#2Gd A 8"pD1 bĀ#2Gd 1˿nIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/codeeditor.png0000644000175100017510000012523415114075001022070 0ustar00runnerrunnerPNG  IHDR4`+sRGBgAMA a pHYsod1IDATx^[l$Yz ).ʌ"CR*+-Bq`R^zP;"EzXw, ITbA1>|pWNj@Xa`BwjfgDg84̎Q#"U.w1w3_x~'hf~1s윉W^5!  %\|Ms*  ׯ_̱   kD    …@  p!*  \FwߨfSVAAN/~gϞ_??O?115\ͷ;sn|  rX?/oD/H _x^o֭omBAA,\ /_?*~ecSȨ)M5U`b__oߞ^ڵkʵ  g#pަdrͿ7}sk/y۶;_%QkSO?wyg ׮_z[__/O?5A192LHWD= )"y"q paqϜGXN~U |8$\?sֽo;0䗛/8_+]Pbኧrց?Ork!㓓ʂ  iV;,WhG*@o>-/qP\#^i29  p. דL$kkߚ 8_=b_Tz $r sV-~"Uȶd=d\4eѵẽ72j[MTϴjlR# pדL$z7G_;_ſ/+j+>WS&wlBBb9{dzۭc ma6*jǘ~/7uID2HUwq25ԧɮdAA8 0#\L" [n_Ó~<$Sï_oO|?3?}Oz˄҅!ls؁s:Vl,L ,Xn\?ʍ3]hr/~K"Y59VADu E0NN8 " Wwu8 ~' w~SO`!k?_5~ wBbOuT2xoK7  B/sZNBF8 _U;_³+N6>}_Z߇uwCcȭdPNw]-y&[Z6lWc99=C/7G  \Bz}u5pe~~ _*~~ M9U|qw(/!Jf>g*Bk椖誱hvK k[0}ʘ_^Rx?VAAHsfD;9l*  w?{> wg  oo"  cWpKD  pE  I#UAApAA."\AA WAAB UAApAA.jk?9AAǫn ח{\>y]fAAz5 UYTAAB UAApAA.O:@6 LLhS˄S",r6࠘@e> NM)S͚ɢj.T<n!‰AehpvYDf cilٍZ@=ض6$بV;2*Buo'Ud̅]" jMqF~\ cd88@uS,8;'m̲1w^ULVJ@ܲlr{h6KHsCu?CuZ~*$I@\#ڟ 0~9 g8:9099 n_^WFsD$K!ALp$b VȽ K5KiClN'Ud:]6ٻ;Ȧp/sSL')UϏmg!_jyӻ4Jb; yDyJI*)*v;:tg~O3a!8S~d# iW:k7'Z Fxm1Pkv~־Ǖ>Aˈ+Z1qm = Wqtz X'KZWM^e"eνrvePL/[tl?Zq^L2j=2 :TmT8?rfxi-޴]E-Qp9m{9B5k<9BZU~lZVU8J(!V=>Uy78Tߎn+?D)G_ooLinTʅ aLs: GBXNWYx˯`ʟO(۟7J=} X$^y|I˗Ûwo>\{~Ǐo޻{yce.g1  .|t3O9<.$;'E(0zm+]?xƦ"Bw#Sh&(~^~ߏtWBкiU ._~'Z{:&$c {Z 1ḽ .U_ڷf \XozͿWe kU;zk;Opo5@Kеו{l5ʽD-='4"ʓWxu0DXrXpkEo3֚hѾO2} ^"5TZU#u<%7vztgs50JIZA3AwW }9p|7y}+joeNIP**~>λk Y"7L}'6??_7Ss f/_az/LWF3!t瓒OǽڷJZmegܺD82oAw0Ɲ:\{oѳtv97i 6sO^ Ӝ: cjEAYSYTx9֪>z{\a]Z{U< YEI֟Y*p % &zn)On4RA0TAAD  •B  p!*  \ΟpulЦc- S" 7@d7ErFfω,vqqWR}ʸ9 ϊd?T';{T.>ub&vڀs6Nd-l[e:jMF .gTɘ Zͳ9Hީ#qr[8 [NH_ }RndmnEG,wֹ2Y?{%z 8N6P9Ɣ5E,Nv} 쳻1L~􋈗 _)5\6R9ޯ^֏|9ȤrPJ]}~X[捠dVNcsXJoQ[UGrgg><a '\'g[&''1qݥ naָ0#'tN~/q=uJ{wAsD@uܫ0Ȯpyr{ YPDw{18dh; P~˝{?(+xI -YEST08V'|zqb۝ _o!AHG~NZ;_qnqg,8x>c=brD*?QHiS (m]zC?탢N _S6MY{j|ཿS(a/gejoaǾ@@1 ҽQ?_}Csshpuez븿ߢ[[="pp}qݻOo޻wySlV:s>|'Ȟҷpߕx%kZ{{^ '{^8%{y{/^s챯mv6elau[M?ag}3OPAo۝nox;>M&<|O(P#<: {Qmڢ/tOn{CSw O[qslUDttoߣ ~p~կkq t>ٟ@uCP?w"QitPQ4zː˶W0ƌ_ O{]8vo#?Q'.@0i cq_GogJݓ_]G^E{?u{] XobX_ho޽{=y=#X4x%!{ceDE43^օ3v:&k҄56w7MOt<z"_p{ _^]Gi;(S.5q/.\=0S!_81?!$ax_je+(@9Vi$~˫}GO>e >@[aRaH&6v15=cm/_=`05aiec9CCю̜(eҋ֡ȫ5lubmVGAٮuoss1|}6EQcNeI_ڤϩtI_{$Q tv]ƶǍW~V_ա9q&ˁ^䍽_BmK\OoqWן ØȐc.F?i|IJ(~h~L/?죞6uҭuu٫~Cy?Uu+ Tlrw:!G†ziPa3/og?*Յj+9MF8՛a7W]HG|-ܐW#*Y 5㫻AsA,z^g1v`^N~}u!};W?sC Td1k<>?OtWv.#6}_:_Q?qMS_Oθuzpd0"汳|qqmojZPڴ[ݮ[qzڦJc }D?: n7nyÉVmۛw6~tKwЂ yfh_=c=?Hl_a3sn<{y.y?Db:qN?~3[T&õkE{6Ҩѐ=Ku}2؜Dv֜SwQt*=57[Pk ]޿Мu_߀\ks4Hmp ;k}Xl/Ȉp=m.p%xȟ=} %6bj@κ/DA/JNOjD+rZisم0[}0 *  \%\G_UAAN  …@  p!8I`bBIbd" jtk U`88@uS,8;'i[e;cfoѥb|vJ j}F r_[g1*>NUΆA*O>xNN`!;31LNNb26ׁF;iyºn8GOq3uJ{wAsEX]#* /,Ag'U:.:ˆ9{w{ 18dɻh;]-oyz?03oAK~NZrj1gb~yk1X+Ol1EkpCe)_JoI ;{ W1qm"v kÇ6yD%$o-+B/ ;+ e@x{ Xe252;!U '`2!rz827#m_ڮ(0ج{Y?*Q0TT:6,Ɵ*QSۨP"}bn]I{m~c9.?}fGiptp&_:J>5}5k6] peɁjϘ~~ W%Lo] 84q, :sV-Za9,12,lӋ,Cw`;.Yv.7ModMJS$_]7Y]GuT۹!S9Ρ~G%j=? n^>T"Lڊ_+=گ7;|9!@n.II({AA8{ nbcSjxhѶ^¦ D缺x]xr49gx\fXJj^$Z\Щ_Qd?tZ@.FFJ}B#.j[kT:.6>gxdo }9elS%;UZga{mǯ礲5BmΕaWxu0D, &Q]T 4ս+cj>J5a իWM|I˗w6ͽͧ] l"A͹'Lyɐ_>C~ZpWS 7:WO*&i= ^H߂Ƿ׾Pcc:L;+t n(sOVQ`S<2dCߤ3A͟goIʹOBI:q{mo0_ˑyà ȴF_Փv-@߰OA8_ժwޢwp5stQ{Ҩl]rXsN0z{\a]PTQPySA?A ^GԂeIcG޻t D?AA#SAAE   W   …@  p!8I`bBk W$}H8,o+ j"E6=l8n˾tXmksq]&<+TIO.CJ>ޑD?1$0yV9H4p?Rzq-NA,:?A! W՝cLYX\\o`Ln࠺'Yeՠ R ?#F/+Bj߮xN, /tx0ÖwCA* J%PpƮ ^"qt1_WvuQY~ nנy|pnރs6{sV0`.7rTȽ lߗ>[ZV nyl RoZO(m wLQs[nvϖƿNCS]8E6>t([n[*{o?]]) lJoWHqC}{h/ Wn[F5-_$\;wo>x=>~a3,>d[>6_cz7y;D "+Hڔ"c~5Tnܰ1-,V];<]^_"LCҧŀ7ʽ?n,v7Q";!P] _WvZ탌:[Jnބ_9 6* 嫅uCn |N\hv2\]ipXڪf6QgFA7G1pY'ڟYt =`jIc?, c>ڞi=RtNpvWO1 ;z\n07TQ*#F/c|mWci7l2RX'P0Vu~FNb9GuJ4¤gӟ*mUe 1GKQt8;ت%PX9SqNcx&Af@(On O;_˞ceP`=]ӍBUqʞr+5Zx+J.sy~ucNSoyKy[9nbOAµq͝'~{ k9-}c= G[YLަc՟U^޽w/^瞸͸9ҤrBQt+C  ~8G˷OS׏xbuxN+nh?]IQT~$mOˉl_}W_TiӑWJG:LUd-[)?A!:# W ۋXt6x끝uiex2|R?jU/CYwUuwkPŽ=Ïz!zt]!7z&HO4Ix̉=ߚks'Zˏ{&;SL0X$+aX(I .ErroOA̐µMlcjvQ 6HR"*U2 厯%ĸkb>*xC'eA}>2@AAqWy>FQ׿8{_1U^]U jȼa_R _v,_9o}Bt}zk7lH/n^7N(.+py~h5q?ΚgxYz:\v]TּcX2䤁wgw Qrst]XOpNX8!X[=> Oks4U_}>/piraU Kjȟ9?(6% hp=8%XUl ۣW[~-"\uY˨TϻOA*   (2U@AARpAA."\AA M p8,"WP8E TL'&U`' oj"E6=lp۲/bs vY ,k8θp${'y/c KC'@zAQGvh@_7$I Gw13}I^aw |7z*N.Tzog| N6P9Ɣ5E,Nv[|;x2u9FAw@*#D^Ki**JxO,E~ yBSD}u#., P*Թ'``뇷,M눛+m؆.gͻ]AGziQ۟ pqo:y|pnރ潻<}p%l/znJfMV31vݶ|͐{3/-_+tRsS㮵J"nmszBimX;\z?0;h1( m|鄯Qt k_ݶ|U0R?-oyv.Կn Oįv|,`[oN~g§Ӿ oOzsn:9 4^S|p=^G[QKaߒ@KaN}Vu~)0i-}.է6B$tn,@?F,2tK4}Eղ'Xs>nOM{X)C*_][6Bs?v|mwCʄ0w"[yI wغ\w9$ y~J2_} 7ܑcf ea$_^YMׯ6 @^: uܫFYU jȼaP<Bh e-c[ ozOX޶iP;h?ڿv۸O/C-[OxO]!`*0\{o3tv9pϧy{vH4P VN ʐÐ@F{sRy{oȍAnA~ kW=UqTt9+j%5CyO`) sS\^V=нY^Rm0¹B׏Jey}  SAAE   W   …@  p!8I`bBA:NE2Yj/{4BʟjK5tbbmNA?C ׆s"Em6w62kiMm{zmm.rgv${'y/c镾ۨo_tʗU" j ѷ}\;mϻ\TE8qo&9Wgh3n=_A.C Webqq';8,}چяmLTdS ޞ)Wa]7}f) {,A_[ʍr&Kz=(rFt֓ŝ@a= R_ה>Y&iS__tg\Ч4=B.үz]; OPO _~x.ns6Hl~훧/;Ƚ! ٱ},z. 퀿}ۈw8Md̹WTn?n{&טLHI W1qm9bA:\jI)NtLNІ5ߦc>o=(uʭ%`zT!V/zVە.P=6[tlG_衚.==sX G(DACQFm6 V(oel{kswGf.RφsDIc]QPe0,}4Yԏ,lloS!ү ET82}-J~* !˗yߘAM)u/O,u8#lϐ0u1!k]p,o|7ۏCkҿP(U2$zTyyrkxz0ic.)G8 \%F._=۩HjHPPN`eGɢ_izے^z}-Z@712wGA>%Ctsq_odP͠B/:ӒZƼͽ/Ajb~YxP)L Tz7mUgnĚkC/L~u G|R@tدP g^t=B 3Yw/eoxl%Gf1Ck.(#}90m3Ϯ$>,z{+᠘ùKKKd=Cp>`TG::.SisɘesN AJa a%:Jv˘ 4^S}/'$5|%5GKO@(Ï8kz_O&L߉@~AT[,8ҤprV{Tmr3j ŒP㨰?=qڄj6rZzy5*Z0pm!)Se]P^bݧ \$JF"(Da~n+CuBqf[fڜcS>|5rXa\+> qeun<%Q o쵝?}> J2k<ڤ[aWxaH#*㪳wuVB$gTjʜ̔`|yk־w_ |d WW^5'͗/_`7߻ۼ{5< y|p^W^E]^S1v^2׵ϐdMzՓJIb D&CqB]Qٍ2vAD'!qxCK:Jj?\ `{Ϩ#A=}ۇ.ZeLu=ڹnOna'nWLo3=ڗ6QL]t8dGo__Q kU;\{oѳtv9pϧy{&qOI4C,21iT&4]g.zἧ3:TmT'8'u,\!t)"imarR!ׯ_`ꣷ3UG u.j8syX<ϸl R>78(#y<)pۇ*a/˜zu%*qTM𚇕BUlG]=qP\j>| q>ۇ k0ۜM/  ‰"SAA+WAAB UAApAA.CpP}/[H݉teG6ˑ|% pu8㏳%JabqqZ4[%AAA!k w0$&c3|u3lg)sIAAF` s\pmAAa$Q,n`ǞT{~   kN\O )  c*5}>*  C FCg'=rgAAaP\5\I̸•doZ\eWAAøqzQ* pu8 AAt*  \D    …@  p!*  \D  qm8> [H݂݉wӪ>zb:  W3^#f[8ƺAvS-)ntjhH  0$\" Sjoq)lAA?# ؝rߟck;Op D  2PsXr67ۋX0+   0 )\pH6 mH>z\oam`w;ǘ]D*FW[m+  2:v7wĬ\Uޜ~ 3* pe:Co@0   7 AAE  p!*  \D    …@m1|_-ޙYA5CV brD %ROd/nG)fZȮ ?Y II5kQ 6Aµ`sbQ]ލ"69?o8E%vqs0NL4h3*լDl~PVxFD] ;[ΤjuݯۨV;M1*5)6 (#oQ BC Wkbqq';8h'#c/.1#;O_v}ʉ*ş` V-)UT])PXR"0~t+ۣ/osH 2WAΒ!k w0$&c3+E}wYϔ$tmI[ ,]oO7f?"lncO{KId2H;{˂=¦gD @hzp;zl<˸_Z=T ZVCԏ`qvUKERVאgR *a@-(kբ7-}.=Vyv\೯^- vKa* N”A|29 8U6Hv!^CeI*zZ@!.rf:y&}7lCabM ca0P˧E-njyӣǘ?O,NپVNw iQWErV -s)0] @z!鳖Q)P鰾f-#J9ֹeLNC[n%rGTqccSs>tY[vަR3qIljS6s!5ZܬL6VWڰve,N*tql{tĢI qAA8eF._=۩&u0{/FeH,\ hu;`2 0pjA!F*JX75Q[/zTo [c(\}$,jv@fe,nױɬ`WCE'@ ۰Qafa-NaA;qe v |07bDp [VuhKXeq̢C(1~]n̠BM*E-@p5߂vcTʘ+AT~{(<AAa$C.76a-:NґS4 uXWb{b]{f*oj1IǃA0/b}.C'It+9U|`t1ne:| ˮCV:4|}  =9hM$ < OyyC{O3 1L>c!0{Vk,Ԇ)9EPi{{ڰAq:iH?K=3T29-t=޲!HB f>+nH_WL)ZrM,S\0ɺW hںGۇ 4 &s{TMGt-~K;4I`3\[UAR6FCg'a! k#]yHڍG8]jf٬Lx\[Uzz[af156KRsXsK* I3A)~*O"iz43+FϡWw>A.%e0E͇f#O]-"tU[\U58\ܣPu!QX֫P%W0^ȴͱ UjZ\3Bfa%=IaQXkn:?JZ=>x.mY`AA8E^zG4_|9yܼn]k4}/*R-$[\( >7tl +$qEg\?$<~0؅&&4f oV(~:wqؔDŽbRhO(  gp*T $%)%Ikp n-zO5n5M//̿Ot]fO9t`\%-s.72n['69}UOm΂7V.t>  ֛֪wޢwp5s$ j́%]XY{-l;xY4񥃗R [gXA~ kW=9pYMQz5XlŨy*H`޻>׈XRAƃCTs(\xD3OHY1xE'}nNX稼Ng1AkUN Ed +)ݿ'gpH=?k>JmϲLµ`sbQ] chY s.!'z`o!77}:j~ WpVmo4Qa g(n`݆};c. gQ}}7)|߿'3l~G8߿|R4j28>0nQӴf[xjTbVfugT~G7XIo%*\!{Xީ`zRވZ`僔/(=#?wcQEoYh )\c&''1uXٙA}FJ_l݇KM'WI> #7 #pװ}8pzȪ<$QDvCCmw$Ϩ|E>oQ|[(Lz{㧲=xɎoi ꖉ>ܩe3ׁ77"rEٴJ]{]7;梗Q?>4'n>޹ f,#,!߯ @TDO90SMFn> /jV)̽C^w>0cڀsP>nt1pD፛îjBQV~v5Zzq8M# Ӿkzi7k2lG#˫+zOm+מFʺ`:˷oQbf_!ǟ1Qt~? =z0@99nᏧ&,|i OtnۋPuxr饻_Fʛ#X)F/q#i|r`ݻdo>xcyQif<3}Mleȇ?qhsYx9]h&k}ʨp&$ΟeL?P+L0|凈`4Enگt7/Ag^+L{OH#?C2 _S:/aE\NK wo'n}*\~~ !,s {?`u鉛~g Z6]_G|izsZC.76_58uLr+[€ĿЯ<5iIz\NGgoB)LNZzD&Ouw=us؏P;WTxizF'c=9ThP~!Gs^ۇK5]geҢrC3i~/TUy7GSf @q}rxAl`';Uv=!(|ݼN4Nˁ P{GEȠI8W/: ]angܾߠ=-k3Tq0B}t&`Qtΰ}t~4M?o_TE0nԕczS8xDԝ:9zbfkqkv3k1c斵̘k)A~;4sOz\WUOVAstjSf:ss;ӿ9~~ޘa71?((fQxgu1՞8X_^#~|/nc1G?dX%n>%|U&$~>Cv`1~z?/aÍNM騫c&=CQ믊m*¸^>1dv1u{ W[REVC e5$ٶ'W1U7$UGYyeunӟկ-SXxrFrQXF`9=M߷|CF 󁫆9v_G&8h=q9/ތ(Q}rӊ*u&;an9z+*~_)춀_XG{ɽZeѳF²=`ןW+?#<~{\:y]fA;,${ބ@!!4$$0yy-R>(g! _꫏WAC+%}QV,WA*y*Z3^QkjG"Z/"\{w72' ^ŝqTڎ8y^.D pFVL"\AA WAAB UAApAA.CpP}/[H݂݉q}ci59u\AAgG6`bqq';8n3r6f  0 C ,,`a&ILf`]7V1  0,cڀsP>ntnQ,nbs\  p$\]رp;՞ߪ­۳xYNOK   I$ xuܙ!csׁ  C1$Rc3xrrm88=#*UAAC FCgO>vXS  !qu'eZxdiTnߺԝ%\eWAAøqzQ* pu8 AAt*  \D    …@  p!z uc1/,'s( g@hN^Y;1MT_{DrxG?:N1$eYV1q:=A&?"|jdJPC 󴫧N&0̞YAk9fŢ6:wxm9%wfW` Xzl[\X7)Q, ʉq ,|fR\XBF[џ᩹,6~g,6obwJMAR:8xDNkX<ɻkm`gS]L֥eRb$Xٜ.1#0̣^Tz ֓<1Ne) _✐Q0FM?1*P+_nWA Rpga 3bdlucepvw0[r#wI3̚kSqy}zT.uoiXUrM`mVt8{_l(,d~ď1){<4=ha:̹*7z-{2`U mc.׏)&隅| 孖;7$^;hzoH" spNW^AҌa+ObAyz0SvM%8?d22LD0Wn+t@'3m:sl8i 2 ${Q˧rnjy,yK?.yBSfz>D qc a{c>E,~k50 lFS_:<¿@ǦSP?_LBϦwR%GvBgrdW[% : 6n*52ս?2 Vj`ki+ʫ]ZQK0cBA<$\]ߺ{ Sh4p8~T$,k ;=M)7W rWPYHɎc)`Ƕ.+<4գ$J(JX7(`}Q9{^~ J")s 6H5-lI&`DM;͏ߣGwX2F9V,Ũ-۟]wIv)cpߦɬL%.mOe2rc9%dux"ty<`ۅ peI$ u Lf03sׁƹu #O`LXb$tM&1ˊЦc>o>Woj1Em|nD>zڒOgkxX|ACAcRs$>hw$j~ܡ j?.Փ9BouyPsD%dJ pT9YOkmyk4G=;cJS ̟؝)9ľ;&{1VӃk=XHP~,2Y C ^ uL}mw> YEqPI^ py*  \%\Dz   4"\AA WA8`a K/'Lۋwb! @fT-a- .{1 KIUN'sǫ,<O꧚N&0BWA8; El:흈uAp"AT}+Ux6;,C O!,|fskN~c eoG}W1a݊rP3烳TQ@y+1A`H:Y,..bqdw-̲1-垯g tJ,b۰+z <jzxL`:.ʏ*Z!zm0y#ޡԇNiZ0O0焳m1*z{3&pURpga 3bdluciyº+Cu ZRb k>$n;]zzZ|UG)&Ey nN?v-=y7*svm`$s$~N=<?xh{t& U8?n~$dai׏7$f=#iT0CRA8 0ǕDA ұ,.i8x밤+Q4d~Ox~Y?cS{"P l-mczYy|( 6# Wgn`Ǟ픿7usV&04WL~ivbJ~ixRe{Jp{=֛1S=6'I:ė龢?z_?_߲oM?MH9zi8ӣ>O< cd/tRcj#9zRsJvXU#AF;9r9,? {c] &0 Jz%]%$ RC6ð^0/}hw$,%*q#*m4~J>[5B ~t[`Y19kztC3l?щ~H ӼA1L>mC&ŽQ5Gt-~_QIq~~Qut'?p߭4I9ϏH_C+SnJX{o5=ݣk.3l?t'[AN!kC-h4qv{U 2M fX2TDթ!QX_Oo7ddZ×XVjq?0>J{̒0v֘tsƌ _G!@/P{'޺=񺆂|y`t:Q ˿oW@DŽoD8L~BOZ& vZ<3v.^7N4uFM!m]m~j@-  ֛֪wޢgp5s$I [6NNg-zQb¿A{?/ͩ *a1wZʗf]c݄?UQly{g<^KUq"z_aT^~m꫏ކsIiIV07‰E BU6vVA S(a vϘqqxeO8iJJ|X8eN>A.#"\/-zVuNE9^CD"u^:H ^ uL}mw> YEy&& CA  …`\u,a  I#UAApAA."\/ '~I Y2pup^],8{ x;>6fmG@ɡ+懩؏y) p )\c>n 4^ޟAؿOECko<8{cSR??xh{t"U8?n~u᠚MbbbBdε*Ic7D6V"i&;vewFrNLd)6)Ą^綖Z};NE.ryٟK\> •a s\pm50s:<tGǷp@c a{c>m/0e8C=ğZ G? [O/Y||TPa6 2k$[iy S+qIu\TߕCl4< (=?*|U sM,NvU`oc9 jZ j@ Uj7Q{>/y Wހ}|ͻwܻ|{lwOwA:qog3\b7kgkksnxVhn̓ge?oI* kiOɛ훓&!W.vDN3Q0 M 0Au'4 ?LLj pưŰV#$ 캽#lnYFˁm|n㪧7F5

a!cVn.H`WG;<62BPj6Qe:|A+ 5ciXkpׁF`h[h|VZ`$G$H/!)7{y\?ìM5v~JQ@n~@eT#0a%.A0pm! GOFb'xKd)78L./ϫxs*/2ܸ)sY㏋ݗt[C$4Ձ/O *Gsi:1֮jU^ͪ9#RsȠlUs(c9dG07|cJ㧥չ9VpWmkMMH)%F#FT1a\APX_kcq#s\{sin&gn6 M'8ۉn:/և;ϰYF>#MvfgGT2̈́/SQs;l~c|{ji{HiwPsDO)AWƸs\'k?^~-zg׮]3GQŃt߲oD;'O )* ׯ_`ꣷ3U8-^8"EXx pzQ[n*t]F) ¥@e[7?  9E  p!*  \D 005Z U@%*_xopNى d#H&ƌ H9-Y4dg k9fŢ6KS4ddX(H 8ӵj1j<߭( =cq\HvSNQ'()&[{M܉$uj Ջ/#^Ֆ&twMRJ[ !Gjf[KDL6~$n"1N]yZ0O0gNDGDv>ͺBŜ#wΖJHci`gz2¢M=T> \P)̜jĐ۫kUR2p499 Ɗi4p=3ӲO& u퉃'#5|owz\\Zp~Ʃ"<پmXK P[m7>qGv3OJti \9 g6T>nr*?R|4^{a?qz?~ro| L}XAq)2Q\+$}-a{,6ݕ7gYb♛6 ,1Q&Ӗ}-rXeP6W8 :+ħv>^G:0s4yWBE/#F?4*mb]ϩWBAy+AHR䵌U!.՞Mdcje:+?83=۔mcMSԏz pI:Eرp;kILKjn@:@2UQChCc$ q]nƹd gH5v'\.'aol=5;=u &i `^:ƅm|nD>zDOgkxX|AC#9oe݃ǤH|э uQBCC$Unx^Pj68J?ouI^]E5 $ʔL7nb|#Ub%>Pxigu9z?|8ժs9V2cD(m 1ܭ5T]{OXܔW&Wͪ99}Z\Y=`w@]mC-j򗦲#rw*a5UF`sP" ?#̡7z)--q{\7qPek?A6669E,̴kJv;p{D|V6|o iZgZzz\AAapAA."\AA WAAB UAAp8Yq;Y2Ci2  "\{PLɢ97Y;1MqbǡtMH9ܨ|N1$eYV1q:=A&?"|jL5kQ&0̞Q 0pm48 ,hvEz^ (C8`ƼM {8E%-E)` YcfaˈWl4MR:6t Of<߭( =u9UjRlPFRnAA8m G)G6,ܺn.]H͔J mǿm hd$W2'&o;?4|o~R'o E 2T˫* µĤ93,ܙAl\:Ld\ ͍sD$K&D˴{@3}}cf׿c78|Ūe7Z7D=v848;ت%PXIH 7<?xh{ts?U8?n~_Wo svhs/zAA!kcdwzɦI i2«S*t@'>E*͕26OEŽLd23hMk ƝXXTP1`[8k$ }–m!͒Y5|[ L/'̴Gۇ!O}oF ˕ y!Od]*M.SWhթ"  %\66 ]S%֮ XKǢѶQ)$2k̕yi=LO֦[f=y@b=?p߭4I9ϏH_C+SnJX{o5=ݣk.g+AA853 r3[tfܷmX3km5 57Z q%BǵHZR_ecG?Jkkު{ŪakϋW^27aWqSiXKkX8֖9ݝ?ϭM갲j Ukwϫxs 2ܸ 37/y 3ܗEsޝ*I&czz Aa< 95 'v;+V̞\K+zT xMk7lK֚_ZDsz-w5udP9w,SimVw湗6 ,O mBXQ4Wan^dυN6PÍx&uL}?043i=l޿$7T*6Qb}?2X:롆zٯ  īW~3[s5s$~x笥8,S&cW(%x0@atF h8XqxmWm4[E22n'UA ¹$Kc]P[n*tZzAAWjA ߭v_JAWAAB UAApAA."\sI?+0gH~uNO Xvx|Aµр㸻a9-_bs"]/7{4pA6=#Mgתs:jO 9k1wtA -\N;zzcʚ"gd'lXoިܞDCmط 4aZ+{h~Ļ"|o k05} ,pg&IrqCz]+C?_CurHCFj5]kg1$+ $]j{Egd ӛhk 'T/rҨ`;梗> #|tYXtjUؚͪ%7|j !kc:h{se̞qqsؕ읟ׁ71 /:XޘC5g59ԝ=Q%s ثɖ{"抾wnb8̞\|[RM+˘'"BW>67eJ2J4i G?AeϪUbtؗPwX*N} Bq,`=T6wyQ'>?$&u#<&C3jo|Cij?p'`ս۴E &J0'Z"(gwbϏP7 pJ6/ lld je}"fdY_oaջi=z@u wf?4(|&͖t?T>' r]5O81f"N3,XDs"$NhK#w@l}#wG0sa1K=al-ͱիM5|bJ֞i7ƌH%~SY,(>Tb)'%:H){a fZ u:[(9mif =eѢ_‰X>MC;8!JG?~홦^ pxU~{\:y]f<_[zހ+ ЛׯGcꣷOU."1̾.!InSAA83D ^3l~\AA"\cZ$A\!UAApAA."\ #[A."ow?C:˅WBH&c[?5 be!_lv$?N_'$SxXă[qPLN Y<'mO!f1A65\"Ky~}D/^j֢k>Ycl /\ 0 8-멐s"t&Y'K4=3'=|aTݿET6;lz[2f1L~c eoGz~Vbs*mؕЛ3gCNtFe?M⿡w3Z6Hh< *vvIa.CϿCzWJ J8@DW!kc:ppmANڶrjt3.; ;5NZ>fC:1g['ne:Rz?v{8Rᛛ9Dy < ڨp&; KvjMs|+B=Cm*?R|40e8C=ğZ G? [JGS%˜C:)P2(SYr3&q?s-SQ~W%`ʯ/a_:ULaYϵ+oZ'j[K W}fz%( X6;3Cэy͙X3jpVZ=ϗh?Q_OeaKב{>^G;?|~Vr2߭~C^i(P#}[XQ- 7M//c 2pU[7cOv*֣wV`臚fя_p wlh'XsH{XtO+wۚ{bc)NW`)O{"×ڰFI|7C7m&` ^K{m^*!sJ)J俤u[*vOܲ_:_\KS\ ?A5˺ SsJ(Dn7AiS\<=S>?yf<"H VU#.kz/Z]g^~(xNRGW=F5

a 龉w[`Y1 AG7P* V~-$N~w*T^pc%5tFMӽ}9(i&)t?z]q;虍ʣhB83#^="T>T0O? ~SYtg_A og$H76v?٩Kq}ϒF ;)sO"/;4d24 *?cD=Bổ?I; RKs}uwM|^ckל{tͅ0b7$*Q )PF?cN=L'gX?8_rO|>Gi'QWmnsqeIw?MRӞJN,ԶRܚ]zRY%)hsb&^Mߡ海bU+c]>T]7^xj_\s 3_%8<= Џ*YsqZ=u=1s <l)w;#$31~"7&=C2ϩMMLџπ/4Wb%>Pxxgb4h;aqGzE+-g~?ʠլ3/6U?ef_(׏0a3OD5Gc4! ?" 5QQmxhW;3j(cF s޻0/(x٤ss;sQ.va|z:=-nGuuٵ~}{~vng4u?4{w/DիWM|I˗#w6?߻ۼK״׼i˽.Oa5븃J&tdӵ Kr0ÈƸu)'2vMkM%Zvz.ɘtvO!pIT(^׽Ls3柴Lw|Vh~fst;o'YkDyL̟?O43>;N2 B>p%hcp9s;L6p[Pvqt_rQiLf(V@W(ʸoZ_akr"W>˿7/i o_o/JFٯ|.7^7=maK =gAzdwc磓`<`7g*.@/?7G1Us?Eu~:\v W 5o?{x;mXA:cDf1]PdP95/)GRKT߼\b"z^¼wLD ks4U_}6D Oe +M!uNo`!)x}<~A97"}X6p%~/7~Hs%6X?yFpAsVϯtUzRX߯ckaU+ WAx$/ko%@  p!prAAA8]D    …@  p!^6xwn]a  0C ׆SΣJ4^AAaxxLM\w2^AAaH@>S  ` /\Uj^AAc8h19AAj&6_3/q{q1}Y|AA*kסk'0ÝoWAA¸Ы   i2WAA  W   …@  p!*  \D  8PCAA8!DEs2Β߉ m2w(&)f, ٪8?N_'$ aU'0^ٶ' < F@CXEz^X-lyBomk뺓q*$&=D9M'}.,|fR\XBF[џ᩹,6~g,.:GQGJM*sT[ 0~ G!m..bј^"m]?+% FyesČ; y4Vg U*LId>1N_O aJa,1*]*ݮ  0pm&1i{19In9אLd\ ͍sD$KۛXTZ7"ZU溱+VgOZK9-dI7kvO|ڄNd~I&L9oe݃$(ztsSiSX!1?(coA1pY@.Yܢf0oo2'980=8؏1י@o+[͒H$S-xpkX GKC`UpUJ\vmKS5 ԩh枮*KrMC |HxiҝZ?5~l5Viϴ[ ca[KkX[c֜(OQCޝyOyA71E?xK/y .k<4h;'ZR'?1MxseFUbG6F6PuDoNtsmAApq sS3s\_>k6V2?h>5v璊}lq55okps]<pP3 %,d:EdԜCwNhq5/ju7~69˼IuFoT\#ϥ5wyin&gn6 M'8ۉn:/ևW0[Ѱ){3V.&?YO.x @Axc9ϵ /Nk׮#A=qi3*YKf0骱nBП*8o~ 9o`ma>uv^,K. h~ kW=:pJk67-Az4D+dEZM4D 'WRNg2-`Akʲg5é p  ‰"SAA+WAAB UAAp'Y$8M ,d" dӑA' F09-#7DyI#>'0o#ۓ!12An$w:/ب2sb?) Z6*v{11 XoަܞD4(u,sأ(Q|o U#ϛ#? /\/IҢfs 3ClRTk8 La25_'P T7rX'PA<'Q\V8$ZV+:{$[4F]M=z6x F1I_!"7kV;֔oV>w#l=)-A˜PA$A&Z!kczO1yz0Sv3@77]yxs9dPaK:yVޙ3=A+VVfx:\{FǸG}ۤndg$qof7wyVM[Pi #~x/29yFO vus( 煠H Pµ$K_`glx>+dY9Lk=a[inqO/.Ps~]C@X7$2]O_9qN|e^A8b5he3 r3[t1XIVK8A̰|a8ϋX8/-Yl?K`w<V$?XM%.a :7FT>*7Os0CB)[{3"MfMHw*HX  U2Cq c3$fzdq|kwz e 'L %qTYsđ f>2ayYӖVnV_ϑS-/3nܴ9Sxב۞i+ 灉W^5gx̥ڵkHuwl b  yz<֪>zz\";~TQ6AA3C x5S+8V5@AqК*   *9AAs Q+IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/collectioneditor.png0000644000175100017510000006236015114075001023311 0ustar00runnerrunnerPNG  IHDROsRGBgAMA a pHYsoddIDATx^L[W//g8=uѽD/-'.ADNN5MhN"UJTBl'\(zi.JD4PFNdy)p_*<b;LE'޻s᭵2`oc OgO{yeMΓ'OADDDD ?tNe{NFx)BEHDDDD#b`$""""S %_{-<9+9,)Ÿ?1^_`ZDDDYx4#N(0?3ݏA ?"""$ O_?IϭͿwIoKDDDD!(&"""4DL#믿֥TwZoz1#h ;rt:!BeN%ŕ6 DNӈ.ԲJvu)qKkJ#MkmQɬb6e{'QC\&ǤRB0 _-4_抨UYd|/GQo ?oU"ɠs18-r-crzD|+@29),^F͚۔ }e=WDDvQv T[7\Jghs@(a1]a1$l^󵏖pL!{‡Zeuӈ\_>xbԐmu6zyqr4|y(ئR,DؗQf9>Ĭ`Ű(i\~MC*$K%Nu)LD{먏Y^'] bԱ+s*Ũ=\q(1aQJk`L] V\?WurM ./8*V c cmAɰ _mxgꀱn8-u0j.md}"_0v8d|ڇÇ%?%xiih8|2!p=b[V𐧘=FpU6G!bU,yaqbZ:5PG M {w?/eTSW0=Ҳ$sOM: ۸=k->c,'NbNPp9 P.ZEX-, S_7WOG}[ iޫ:ЪhKuɞľ(7XQ\c""jϞ=Kh N=鿜?&vO.-'U=L S0GnZ`XѽtJZC}$V\.xm1M^o .`!e~)A<&u!*.ݯq%""dfq5P`L{EBw m\=kB"t= ๾XSVC5`Ҩ,_ŕ). QCa!LKZ-V^lRn1H1_$n_KQ^-%% . i 2 T_v[ c( -Zq]r7fuѕjN-mN$f KWr:0*SƻY m{3W]:lxcyNIkK2'h,}Ř2޽{WŐPhIݻRK{H10n O ^=(oIS<{ ~|1_eϡE/kp ^CUr kSpkkRv"" OQ@?OzIb2LM5?-^U21#"H$hkb`xӀDDDD}10)F""""2HDDDD#QLKèDDDDDpHL10)F""""2HDDDD#b`$""""S DDDDdL10)F""""2HDDDD#b`$""""S DDDDdL10)F""""2ɓE=2ޏm$=:i pcܹSgϞ mmmnoU}$GftFI MMM(DDDDو1 Baȑ#8~8c_]-{饗(k10U ŐPh(;00e߾}IF9eF""",Ң6㺌)."WerFtH89Ma)|4>!Be #k6"sY1F~؏5z eˋX\0~wm[,8MWPAn9製VJL ./qU}hF̵8nj.%8a m ^;\C>qK߹5^0׭ծx9>$>S4l1O}8!gzk'NB]5@I}L|`P&yeճs@am}(]CtV-5 Sp^9Tp|"}9s~MRQfK =D{ кP~"w`Gf rXq{xMIDܰx&a| Rs~(jC#Z^B]2Ё[E\V( b7/9 >(Bwш+?׽X ѹas@JK--M2- gVBLHG=0e P Bc .ٌkCÖ%}+9ͯ&1[Jڂpk֦%Lrh)Yr(ZԆuC4XV~9n;:=:ZT)5L!ɓ'z>eܽN]?~;wIO,::9_}FДs]NK"&S$929R갇q3\8Ǜ]mS J@ j) ׂOe&瓈6F""""2HDDDDԆ}Ύ;t@ BU0#wfPXXȯ!"""HDDDD#b`$""""S DDDDdL10)F""""2Ɂ1Q{]*}T/%C .QƇq?XK`ʎh30jGU(VIA⸋ 5?5G6'e̠ [^dAtèco3ӣRRM87{T׶/D690Z฻j]NиOτEW]?JUX*AkC`I͞y̻l)=ߜ<&ocD`Iĝ! z %k;`qq"%uj" iq ]1$=  .ԡh- mo[ɢ,=T93YOLUn,Q@h./hCxbn'X.ڴ[*ڿEF=PO`ފ!iw0[Shls@Gih{"LLj`w(j)Dwl2O7b,޻^WE"iwdvc`zC. m(ic^>X3#a]?ph4oD@- j4E &Ubah?=mhsͫQ&00S&'qpPhls " vNϻ`Ol. yb퓡uD٢ >Cge!2Ŧ_mհX,= 3O! PאH{6kXA0n}R,XD(!ti(n;.<6JK!:dD tPPP۫0 zNTq'n޼S8xG,%&\;S[/ mqHQ&.7Tը%+n眾ry/2ژqxؿ?$kގV1рvW3l6lЉyeEt?ws]EZW'"YqC$@ `Lrfч.E;MGeXjvHz,8T> Cϥ/BCS3,Ev]>[V,Kd;f" Ac5Sw08)ԹBIP,MKq6<<Qؤ(ol15^7R^ycԀaŁK%J.M#hn]u=xi5lc`ěy]h}Q w)4U;SP.)1z[c?ͶGE"zw8 Q>F\ǁ(-- ܑ4b n)Dq$z6%COu. Iw'!߉m%k{2HΓ'O|ʸ{?/:<~;vХM e2]\ʻF2=;w%vg35B]""2< S$929RlkȻ]ɑ"Q|#0Q#b`$""""S DDDDdL10)F""""2aٳgDDDDD+e_z64? :X~KDD({10)F""""2HDDDD#b`$""""S DDDDdhauMMzrykVhŲ)1Bnn1GҕWͷ cC^^19=zJAx6zL}<~ >.dm)\I t 9]ckLM^t-Vי +~IOz_Z81ͦF.0Ԩ7k9ãWy = &fR"0zmneԽ\.aN8R9$ER8'~b;|*݇mգRze4j6nֽ-b:oj1܍z^COF8b=8?GE)iS~%_ԙ|D(v:ݪᒀVjm7=qP!b^O~Y \shx聱AtWǔ_PχӃ@Q#H;.51&U7iG[8껂(+f Ƃ=xkKisFaL;x(gF0>Uٽ% NAkX^zïPj WK 6NV!l"ЎuPgBM1/{#B]j ')LawR>_NNz4a]ād'Bz.-]aڤ Yx+lxMwKeʺk>{8'QW8 vAa=Fl? MݫI[-Ԫ^\9! LܦF.}@9屉njY`mC!9f[8nt%T'm2UL { Ǩ`_uQ/Ǭpn.:i݂wp`)܄ʃe(m)sj4 S3-,xiCFkg╽Dhy%۲]^2 k#7QHLz>_W ɶOGm[omF.༧N3n7'7ZonꎋQ eYfk>ymWyF1e|J2pglSr,v2]bO2WFu|7I>.fLvCvY5$ga^,Kq}9O}$Gf0]J,h+=J>Vcu L4r&Jhcn/# by]TL2t:Vt봹nabwD}s'+b" 9qԎܪ*TUÆGlLUv7"FUĶj;>j%+n& ~v -ǹ֩F}+ V5hOhlq!1EI{TBc:JC;PzaSZA w7|~(Me"J {=)0  |zbr5[5xD, Q%e6<x( Wƃ-WVc=$\bCժ],FbY"]v20,m~}[1YPP۫(==?v e3؃X=tJzIDZߪӺKf9^[^\s1񞐏]3W%+sc"X6Hwd2'fr~ ?fa.A1s hWpr{u1usE=hE](l${kEDk]Z\#NRҏ:=ԛ['JlgYv 0|C0NM+_ z1:i=Lq3@5 O,^-9dk؇}aWIx|Ǒ{gsЮ^+$jQ_%ĻAzwoC sC]vkdISg6g\uDqQChsTZbmLIGKm5e>,ǕZC" 8X^B\{ijWNwիŰ^sg̚2ĭRC]ﻇ`{?X =Ƥ *DiiN:T:XI}}bf6\n(y68:iq~9 )^jx lZ=y <jԫ>A%7FP*8rθ)ݰ\zC7Xl7eˏcq$+4,oP^FK`gμ8rMo­at"J'O,q~_t8u x1vءKYD˛lV(4SwGơ܇vܩKpqD&:1iFB'7#n`J"O5܄g_ٴ;]wGsxWCD繙PXXO%ɑaGR'a$!isUa0afJy6#es©zl X\8aS68腒C=G2 0y>JD].4۬LIiBNe%*+sĿMhc\z>hyxMBI աÕa3gbB2b(qYٓ.MFFd}ZHSIqDm{sdØ>uCMᯧbSokii0!4NR88 {Q+K0==Zu"5phz wц|85O MZb{naLl3 (I,BGNQS BlN}R [O' D-a1 !a~b@|mQ̝ux>=ih"&x+>\ŊefE~cf/`TҭF +k.@:6ri Z}Wkb*vXkE\mlϟ8.ᇍax5]u&ֻk y|O\!|(ǥj_vH|x(lVohI=_#WZ{|%^Y!vAx/CÆ$e{4zZ`Ԫ49(y)Ա1k{=g"8ue^`k@]rعK(Æv@`W>˞rgʺU5j;P1ȦlW ~:AxrYfZ{zw†v3be0Aυձowa05W֭Qe719/f݋I RTϋ#+/zz(y % M\VJn 2&z N4LDii)Ns% (5nzq#|HGg:Jv`aR܎ٛ(--=|(eLfu˛R8jEx?k,N\톾>tWksvyej=? xPoLB7"O+MpĺXatb쿜.bbĶr؟׆dj9An|+#oT~Xm_gt~\ŇWLuF)\Γ'O|ʸ{?/:<~;v%ov;w%vg35,,,%Z"^a?{~h{Q O>K#3.NV]HDD[qhKDYhȯ;:|+r"p DDIjpY߀0va(10)F""""2HDDDD#b`$""""S^={KDDDDR&i@J i0u(i@""""j DDDDdL10)F""""2HDDDD#b`$""""SGaϭ;ܨc42M<'T$8mNu1ang&uK\͖<8~A*A6mOM{8"|4Cɰ~W"ܸx>%X#Q"E^i<kQf 6.R9$T{W.6J4iȞ&z/TFBoˆ7^Ŷ%/C+M.{C-h[-E5> FJT}ayu 0. hZR<) fzy_mPTס#1eʹbEx+jE1Ta-<÷ X>_kzqX0FcfԉSj)w%MO+{CuҴS/v2"B<=pN⁳h1t0% (CVN.، c2.u vzYģeU1PrD=:Pw=_([{!omA_ɰu-P1['y+>k.b\aB<*#4^J~_BCÈM4iZ?  \GE(,==} ~hP;/#~_[6BXm(#<D;1?+0<Bh]Y/yb9Jm0y`wŇ܍$^/%jscx;Z&]vH|xtqV~mšr2:Ug#WYqfaпWWOz`%Z/bٌ:4L NLo7 ŎMt}zfv)AON3k}Nv;H}VXt4D>'k{W*̩CuJz} +G#ptS1Fn&@ohs=8"x\m۸5|Y!"V)>18х5پ4׋0_.)O%,ka,y)~!gc2EP A=j æ[+@g+ЅR>aXLq"fP:GDؒݎ2a#i,MIDa<9K8p%i Q%އZ՛''9Lr%0]^-/D^35_FnR Vu˞C(U'P~ijXYNK)A~ Oai7^Gzz w†e (uĤb={t{ I//v'㑏ۍݯ:ਗtLk0YVG 6nTƚ:W?_B۩4mgw6zBqL6l?z^<7J1E4rQJu:Qxe@8X1෮$Ŕ~O_^=K)hFBrRxq]uQ$܆͕LQ ܖ4ݔXV>ktu!(aXKhաu\n0tJpCbu䆨3X.:|9~ĶC@?EE P6NA3>脾y7‡empMDAz&nb}iN$8\k;{AX=uLľ C'Nɺ'Nx$sfĿKDМQC:5#"q!ccv"#'ಿ}jVWvC㙭ÊsS1Yq\^zZQ't (6]Ŝs%E,p^FxOVE,VvuG~׆`vDZ&yŪ3+LʼnЯJ\Dt NvWOj=y q|"t^K'O,q~_t8u x1vءK)pMu(eov;wԥ(Af"7ĢX=x]9 OddeҾ6=0y, u;`qؿz~Sf]Yj]S733>}$Gf0]Jq"nZ1^~{c( !L1yz UC]aQpr)-(!D% O(]j*[6UF9E* ALwbҵHDYO~݉{z7cNe, 7eX.6z<6$U.3,e&""" HDDDD#b`$""""S DDDDdj˳gtV2Z? HY0u;]""2<74 e/F""""2HDDDD#b`$""""S DDDDdL10:xSP/INP"" [000jB;˴Ø iHv+YzN!sX+I>>fmHr_6g%KmR`84i= 8`b)S@kpսXr/wAm3ԨaL4Je8(ot恳h7zepͅNa u@hsͫmn2Sv~zy9RB)=>S+v˸U+>Rdǘv@Q${mxY}pOhTq}\Ø)Cjz$4[%n$ ZxH@ۺՆ@K҇u|э7E~Q K~{,ı3-zm~[|Oxb!(}!2LG/Y.t-w.7%"kLK6ն+x[W7Į3sXwקٹ5c:3g%{mR``O90ڀa Jh"6bR70Pm uϦu TAGIO`n _ٌV`:Y#7lZ&:NGz5+9Z/λa=8C>{j}xW+m=xCjuc5_ s~$CUh'~XQqҦ>Yϗ>RO`x'g+xE(\{I"qO|\])gUEMcg,Z/}䵞6D?w~:)fɞ[cV>M_fLڐ`"NiQ]g8} ;J"b_LsMBiwn̹׆)6)0$<*&,mh;$ǐ}P?P")4cya.Iu NP~raь }Ue71)oQ u _uQ/dz-F?SIڊ$η&xpzuy+ ;6sEƠՒ:o 1ssb_ćakorxu*ŜlE9LjSsAWE:*>T0>Q᳠~ݠǜz|8e;b],f#WhߎоZ?$3z&xmH˰gᎻwP p /wvCcXL'>fſ̎ucf4;Z}1kC:7gEY5LmAl!{ Y'ɓ'z>eܽN]?~;vmZ;we;3|{81NLzyH~9 {K15܄g~M<~K$p Ǭ(2|][M.sw?5$timόӶ=>=CĔĝ! z Y{x| u9Qng&ua3m^`*zrCv(eGkQhz^WVR RGqA `NN+RYnmthvl2Ncعw̆"L4mc?Vۤ8 {]?phzp"T.`aa(ǥi9/^Tͪ{0}I1 ։"?cb|8"v6{hyؙ4{1?َ ](]v&㌣.ÛhI]PJۙɖ:ӁǓ(M F$ m2F=]䤗R "vc8SG7AxRP4^ciXC"D4`X$ƀ E"JbxKyRc yiXM"ǜ?=6v(o.:Of%yڌsƯEZ|4m[)$l3xl:N&቎)ԫABL3h'Qr>kp u[vv񚿧WI\0z?BeW-_tث^Oj5[cW>Noѕ8Of}.jyztlD7PQ3t!|"6ZPy2`jC"ifۭG`nv{pz} [F1%[@ lu"TL~f0QVWU(x^Ys>q+=cgEۦ| xDSIx,Nmbc5zEu(**St޾~Z|cM䆨/[XE80p *t!lHl;8F R6o5.\>fEۏ .yAt8uo:J|cMD/[h"4NSlp#lHl; 2b?m0ߡ;wL>fE[T8hDn𢽲Oޒg̮#pT޹c(NQ^z-G6Ɩ7qL> ^"1>,PESgދuM[q]ox= X eJ4{1rMLT m Pމ8♝@=<~|cpy FïG[*oB'vX0}LX -yւO|e=fm7;ff8ֱיIGDgS]֡h-Caëzc# UUb]vonr_7 RuV>l˚`w#NQe{5m^PꩋE>TWe {? ~}U p:j,FEM͝k>fWV{.f̤N Ɂq7FI/M%/ aZ C 5 lb#3FmPUT3-l`7Ħ%/Y۰EiSCI/OLh@{{dUr,x .$-WL`1ci_יm0lvA8W\7(U\5c| xP1;vGrdK}iu([E TbX42LXWu]?{oHDpӷaM,`l{c&Eޕcg?\fBQ'QX^ybԠÐH>FQJnBUZDr@usJ>kfqB셌O)zǷćK(YQot834m嵕CP7ak(hnGp'pGy9De q$tcG).dZGpc 9|H_X-76ņ}_gƷ?5mlfD ]UAM^ys|9ɝ7 _pzTVqcAD?Xl>|2vBiqVlDL_Xpv,z\gC+ u3@Qt u磱M;ӅO†e kW:{6A3M)iX}K[Ѧ_>Ɇ?B<}T/I`;:[,Pݫ#ٞ㱹ݺ"V 0q=Π-;(00Ѻ, a~eeƌR7e: DҢ"Z. DDDDle8(10ePH܌(10en=×oF""" 2ntX2\($nFXfEi˳gtV? HiORVL(YӀDDDDTچhe5DDDDupHL10)F""""2HDDDD#b`$""""S DDDDdL10)F""""2HDDDD#b`$""""S9Oo =TD-rš|;uJOB17%uyz~^6|4'*8=t:1yhzrOe[!F QgC.|y,?ȃW'o2>=W2vl[KFY t_RïO {U|Z{Ek$3#f{Lr0%]{5/Pe? ̩)ʰp:%CS&ϣL VD=TIeR]z_EmQM+3#f!.|(Z,?|v`aN|%H!4S:X?y__W a"bT=r]}B](۱iMg|]O?+Py9𷿿 ep-x|lߒ&""@Kz<ٰG?LrX-CmUo/M- 1EԅE .PY~]`>7j@~9nr=r/ǡwG` ӎL[敿9. #|47[K,rxXT?/Pdoq_k+GY.z4#ZVz##b`$""""ShF7e8)y HDDDD)k#b`$""""S DDDDdL10)F""""2HDDDD#b`$""""S DDDDdG3TIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/console.png0000644000175100017510000003430715114075001021411 0ustar00runnerrunnerPNG  IHDRO2YsRGBgAMA a pHYsod8\IDATx^[X.f R.#r4 "\XEiBf:~CӛcxpphQ8g^^^:Ty]/jˮˮO?=PEQEQU:r.!.!.!_jm]U ]8<ܳó>;|O zÇϩ>wiL?k.) _o}|x=~G'Ï/~۵pWd_Ç}z_~aw48Uwg?}$mwv0_<>v_o~𳟿1kz?׾G0|+_{cxї;7|txf7 vNNEB} V+;,zC+_mye8<}K_ ^^B૯|b>{Qo<|T}k_ /5X#_>zng9|{)2 p_s~+OzowWsן-Y*m3aIMmSNqu"en {@%ܽ Û?|7^{}W2{~->>y0Ĺf>+a6)Yb,/z&NRu箍9tBK/4|߽V%EWURIUϫznxgу_7a{Alc-݇.agܾ2PC ?ZvC'_xnΫ~gj70ǟᇯګ'$ /TzLv!0 E!POO //||S/~K0|3Đ>w=ƶ˸ATA|;X™6 h($ܾr\nߟn+ϋJ_^ٶmG?oLJ݉ej7'+w mR҇6ړԟ;3u3 8^cη\NlNZ ٶ(վ 沲\~߃>곖vo@.5af(]{Wd֏y~!kkɿ?w|)ud߂Gyύ/6*HVs~s$K<,6YO}YȒc Oro}.^Ot"rs\U y:ɇ'$90$/?tv&kbsBis3v| g,/-iCoPfj}l8zꟻYȚm_/B`|{ 2KHfjo%! Cf(O.kAd$}M͔$/OWnBlp!fY. 8jC^/|4_.C7}}ǿ"o|/ ^/u|iBxKeIX%u2KmDg(6R맨ߌ{] E?Ӣӯ[Gݞb f|6EDfWc_3/Hr؅,gÿ y:I߅ &?CxN=Y-߀ITƾq01r'E^X_/@ UXw-![{ Aa'kl('e[>'9~V[Wš׾eτ@'mIn46?NC|B.\2*6[}Oγn{. pΎ8?@ D@@ tV!ڡi 5yǧjh:"BkSnVyǧfG(OaOFd`q:B!d@YW.u*8A.DZǕ`ڮ^N^n)FVGΖe\凝^}\}sNU>m~eeuۑY"ȱ~zwS^zϘ5%u?힏ZHP슟*U?˝6v݅J{LhO-K^!ʵ!˭#S ,#ԭl[c Uzx}4oB?Ĺ~aZEE!p4>^REV>8C2yτ@ Gr,CsqNWx㬍9tٿ]S6ß r7;h Fr߾c8^;^>'6r:[>kB=}@Ӧ t ~ UAZ5!PTS|;އ6_۶(}>?m8+fև,3QǶ oX9\S1Sok}Od<8 B1-?˘wc Rfu3Afo=zNרSsߙ/5;T .]0*8ok+/nG 4md}{o;.ڥަc"k6άcNYv"cfvB UI~;8lS'o<&}e={]BtYXeX4ڶw!N M$co͖mZ߄KcUut/6'mO0Δ i?8vx۶}S?(o eۻN> B^Y~X{9|s| D^ODїD]ߪT!s}F4l] 6q>63{7?;n/gYf?2 }t}F~~}Gq]d&p,W1V3lOpN Ϛ>6p3 B6䞼gr,:K"EN7!PԆ1B 8A~3v;'=yv]t?irpN'bTu5YNpμ?ok1wpNpnppp+93w4]⇮Gc?} 83 ǢA95*ܶjOԜoę7pmoӾ A;t=\ HVmVp'M;rNxp:!BR>R65}UTuuPw/LKxyXOu2c8_ef-*)cOR}:)٧԰sBr6þ]JWe[sO_󱟥OM嶏9n}U`Bt|l/:UK ?vpB|D\ꕠ߃0DYQf2N,C{C9]O|,b9ߗq'@ S6mg{k7~+nu]YlO9bGKI?rpfuS';w33<8AaOZ!"R'plc+$gg{:rB, !9FϩOO ǿW]Kc=>m8b_J&r{=M& ԶB^E_BsL6gyT/3q/fbNK)>R}`!P8M,dFD^frʅgG{$cN.b̶v\gɴIɴK kl7ߍ_:I1ed?y|zr6??Ktǿ?m2!Ao fzm6zO0ml?vpB̬]m =vi2dP}ȉI~{ok9əǬۓjr7%ym%3׷*9W%Ɵ +>>eu37g\Or Gc?JQl.لaNJ6-P7x 㥨5ihaBxx4ssﻲM@lpp@s9?1pB`cIyJ-$ SG}_6Eu\P fRRҏJKu{yȽ5lCB_\M!3:Mod&1q\qO`'Óxn| p2C Zom};'_jW`L U] I^mܥL߬= R`&;fw!pƅ5fjG//x4MeEQY8h1^mp7|(N!@ E.~(%@WS@ @,"(EL Rg0!@)B`!=KJEY" t5EPp![ D0W<}#-CQIJJ6C KU!-#mS=>]SDTu?nx:j},:wF!0a+># T!u!#u1QO¤ z1J8J¬Dcˮ!as%U/B RhBp ?0̆cPѽSۂ mEm{> fC;)[fnp0ϛ1_ٿ>SԩJN9A!0a5zAG?2!n {V˘dfLxT*Uf71ƙ-;5Sè߇Y_4Sj509'mϾq|O?~:1SiJ. ~X"&@ HnfKeھ68/3+ b:Ц۞ /f7v,HțH{^ `j>:Db k!PB\B4!$}Pq!hzRQe4HH-gP!05>YZ),J߮GnD!)!ЄL H~Mzfq$UgZ^˙ M]M"&DC`,{^ҡC if 2}yvz'Pv~΄M-3>6"~szKMw}:]0!@)B`Bt&(D%@WS@ @,"(EL Rg/EP@(jp[@ .EQ'/ wEP@(jp[@ @, wEP@(jp[@ @, wEPp![ /F(:p[@ x{ܘ[u%}hvPmC >EQY"r!0a+Jz>o o7v&QW"r!0a;&tCZFipjSekлi/ <շ_n=Yf|`Vx{l&Ep[@ 3~3qovt%,a5୅@"($f^'T} 8:/mx=o%k)r%b.jB%2@=6̅ `I4[ >k6CkAQ"EL؞ ޛrkP'7Bl,~?}ˮՄYߋ}(JJ]n"&_d&pV;=mFa@ڧD,!@-B`vtYU_Bb b2=z6$P%H~ 0M?WC}=۾4>yT9J(i mPe; wEP˼Sh;}~HRL+읇XR.ęh ~"KmCI{6c{ׇJ}tX-B ZxV܄JgPdgʚN+i7( 1W 7C,M3lk!PhztB}YFп rv:@&f:O=[GrK[$%^'8 a)B Rhfd&˻'Pτgtpr!E?tfe[!PH xcCCʾ}lP8_'<JgKeBˡAN헆@RGȽ|2eo5gǮՄYߋc 0!@)B`Bn4ZCbv4om38F7k 9 ]M"&l@wTf3a:HP" 1*- ԨfÍ~mۗ!PmXu2?S0f_oΘPiK߬+p:vP@0!@)B`V4@]7'W*߼XR.d(c33P Ö^aDDmnӌ!؀|oq`a)B Rx<5ja q6pOJv 2ifԂ{찁% t5EPOt˝8ۄ @/a)B Rf ]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EPp+!>FO0!@)B`B<ߩڕym=Crz _^{d& t5EPY.snOVC6 6@\JC} \j .X] Z<<=T ]5Vښ2N& t5EP?8A~i5YZeBUwz&sܾ js`xa)B RfLO CߔVumyh!mܮC& ]M"&lڗkY!/%OnTj!0!7`fo&pܨ0!@)B`vɽsCI og_o};8Fn ="(EL eT]k Pcb-nZlk]m?2B `i}C(!Qa)B Rx ]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EPǶ5G0d[_j!0a+ʳ^աEJȸ݆ 6@঄JC}Lau5Y8&.n>6M[3az:Nm<zۗǝUMzOf:y,`"(ELȟ TagHз|ĥ%gմn_BV݄e[Km߬wtmT_K|%@, L@e:N)LO뮇ml+o綯C y ]M"&lڗ*GuK}1?`[n@Hj!0!7`fvn>gg@0!@)B`vɽoK@ evгaݷg˷>k['p3} 1[ @0!@)B`V] U  4~3o΂ a7luhMf߶dos*ܶ&ܺGJ!N' t5EP@0!@)B`!@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EPP'vvhxpJC<xz4٠uzm @WS@ C%Z%,2474H2S'㗃Ldgg";y:01̈́[ݾ6'}R5ٿ %6Qa)B RXLp-Ӿ˔fg\FTJb(6R!!oua)B Rcf a3wvEDŽ @0!@)B`B4FB\". C`dTH7'E@WS@ {ZܟgZ!ЮuOӅ@ڶ)ݷW=lvp0F[f@Hj!0a-J_]جZFg lwN% j}OŬ 7erZbB P$ t5EP4@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ @8SS@\0!@)B`zƵc ^ۚ;>^r {"(ELX œ tӳ3SAOSym B pWJ! `CB%lcak3no/w*ŀAVcA]M"&DC` )l2eHh'%F-l f6%n?㶦u, Jb!0xL@OP c[FiJϮqܦy ]M"&TF.c/tLT mTj!0!#!mf e!80!@)B`B4*:z<=@y߬/l/˺(llÖOom7!80!@)B`Z vPǿl:T@2m+V O6o}:$L@/YK9o+C(!80!@)B`z]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ !m3\]]jڡmvƶ]5C;kTmƮߴ:zmjMMj!0a-ZC?}?t=AN`xPmu* ΄݄}z@9 ]M"&C`Bv 7L{3N7Ð> ]M"&DCe˵3 S2˿Rp].pJ 2 }"(EL@ @SQ{Cmղ^j!0a-Jp};Xb&O̡Vjf}.Zj!0a=iJ"(EL [j!0`oa)B RB J"(EL [j!0`oa)B RB Jb&fO~`vhx̝"(EL!&YͶn;8b~M{X<nYC'6l՞ﲄJf["N}u8ַ]0!@)B`zUFY{iK}74x9Qf&6bQ}s @fdn>n~滰y'MZ6Co[K^ku~B9]ł祈򅁮JB9ɪJ/r$N9qU'~s5,0r`B<~N}_ݥJU5-fzl6'ן$o1Kl?_ík7.kCj|.o{? ]M"&C9 O#Ɔ쭣g tXthOW萲 emUoY~Lؼwφ]1E!0qʅJ!0zB'Z~0ƅ ^kFd ɐqr֗6r\okyYT=!_j!0ؙq^䥯:oHcǗDf X!BLu1-a)B Rx'uDZ{O)ۨN1ndmI^ajdY:}l9'd=gǎ/c݋fCHroXfWyOjST#׶Mq/k{/ t|;m/EP%̾]靄9ᇖO33*mRs|E_2֗4T%W qR뛐3jv9Xؿ㓶>_۲^/G@eY?S8~GG? 0!0a=1;3X_s@ }X"&L]N+ tR~K@)B RasJ:r!@)B`!@WS@ @{ ]M"&- t5EP@0!@)B`!@WS@ .RE"(ELREC@ B "\ B "\ B "\ B "\ B "\ B "\ B "\ B "\ B Z@((2j EQEQ%ÿHu4M[կ7׶lM~=ofuv{W6m6 [y'}W_+I{U?C&I6u]n+!;WrklJlWq_ e drV*d??cB뇝Rh9]pneV<~l&/\oIoBAZZ&]JA]-TGUZ{|?UUd [ζmVR^sFGG5lfQt6w _swwlxI~0CTy^{r@7-2_6nWh~X(LZHёSEEEֺ-Ÿmզ{>2ן/?#_?@^=Z+)R=[AVDGHknW=ksItK_t^zWOe.{jT[PyfG~~yP7̗:[d&eWisHf~s矟o{V!Zzr RczLS]:wCnU.D؃?4w!/I?mOݛ5򻇿’ 69ymKLʶ?z2v}•G-Wвjj?ѿ;3g> ?w;yXVO0ۛC\0A,S{&:mY.`lBۥd?|Dϸs 9n={ 7|W/;= RЂ>\>?#mmѨ*?Ͼ?lק? o/P,#x\|`vK:-.?Iw dП6 (d͟ ZZsTX7P?׻?(s `v%|f[T2 ɶ;3ܽw)xl60ZZ# ֒V>JCe]6Wm_{_3=Mujxg']e.OƤ9)^ܹ͑mSٺ,C޷;ޘo7˅T6s/˲/˷Tز۬5<#Bn rѣր9<ڒqjΐVK_2>X IޤKn6׻iqQUgMKMini7꣗]sÿ?s⽫7CWOߝ9z`%tO굾٫z5=C2%pD:5٘CG` =ZnqX zCyVthd@}40 aq֑gպ=1]NݛmʱV7vDK]!}wf=y|濱_Jvw ZYn$t蛦zS ]M@)aE^D{imx_>ɷƻ^-VQhwߐߓo.DyR ?*k @;hĻ]7% ~f* ڱL]qu~ۋUNÂojV_{x!Tn9[!_̙QѲÀOkz|v0uޭ#ϘVe7\ۛ#i7rHF\HFRdOzUs=L^FR=v<4hG߶sݠfn#mz! @!sa2<֛Q4g<`_m Bआm3 2T KAA -l~MDq=!W^{zaQ}v5BLl*dR%%%cﱭ Z{v] g ZUXZc)ACBA -BA -`U *RV"+ _"PZdW6o|R5.:i7 @ hs-ܹs:w2Inʯ-@PZ^*]?u=\Q5uzgMd)in[ץ~0s=du퐗 +>L(vÀ!\mXU_b'4Q@vuWLJk2E ؤYE+ۥsޗ&kڟJ aXڼFEKOT4~MLL|L҈] P Zt]T5F\ QCN崮wL~hwܭ4DSwn5VIcRL[VPHm4&۔ P:щ1b=`>7~}(/Lڱc ms moUФ&'Ϫ#-JymIv>v_XI &mZS =r*1dҴuR ٮ4h6^ Akcjt`5~o &NW{fmfӱiTeep@:߮Jo;}LjOL`*KoTDlΒwD\c*--UXyBjVM{iiHZ6]~[K+IW^=ڳ+jŚZuWԖPk.nkS߱m }fXh+a>; ,΋Uk+'9Zj? L]'d $A -BA -ГV4Yj)O_ͽ0 _S@ WX5 Zh배 A rw@@Z!h@@Zʤ&ϞYL5ٳ<\ Ak8h]*;4 d2jև;ylSi+pDXEEEtPQI'\klR]uSvRNo٩T٨ CyfEϺ9^hK:~\X;[럾Ϋ.zj]`UѣLvŞ*R&54 5t۱CCsQY;vZR.I87/,moP-X-Zvnu|olC*~^6bJ*vyf GJ&%m)m&z"p3;. y,h},FAJOLfiRq53[[Q?(UNuyL*n9}k'sxnT^qMLL_W2Jk/S4-܌}_ͣ ūF5>F;WǦ5=Mi)> /NRVꮷalW߭NS XE%; .B_ &enma?Ŵ;g54%;Zx[BYL4&d.kLkmk41]N22RϮvфށz뽸٭v q٭S^f+X|B,X6Մ[ªfczK6|RKe[ nk[nޔ+iUhV: dI?ǮϏc[p{[7g{6E"L&L-^*i;zM\dJ3^]zͤCeU[UדC2nFwkm֔FNiG*۩cճo3쳵*+UY0v;o9Oyˤݿ]=ml XS}iJcv^ 5c^}X΋Rc[j=SvUsPkmzMnxо^{z©9ZiBk1ko$:gtUӫ=®Ʋj)WX8_th3FW]iɮj1;2,V\y^j?/m?pR?fymvUoT6lo^II+QFR~ѪVc턬BPi;=5irڥS+QnRY-l}}/[3 A +liVG:' A -BA -dxdK['X<~ł7ƛgv%5/WX5 ZhۣE*B] h@@Z!h@@Z`$p"F G ^cCBA(F Xu uxZӝ|( :7)RQQ5h]KD]Ʉ"aW?'e0pkO6/xL䶔j)"ܡAEv}ړ+)5XEb'bjP>LkKSZrūtU]K>s.J^Dmo^[}8 kzDnK1.1yťX;f8Ϩ>>?L3bV[٘ѺSWv;}}JI:zLU_Ѕ vS:KX K^6fy)TWͣVHeUmRGǀ.oeYum]PpÄ&} n4%'9ѪFvJo6֖-[T14S#:q"XGWfG1!ݪwCNU3Ѯez+VjVZ¹̱*+ڡ;vQjucPd_lU?!Vl3h )MmvLjL  z=X3=z:Akcjt`5~ѯ`ԪNWiiYOԧzl:n9~̅0+1Үk2VQi!7Q>5 c'5d'gjձ:!UZ[?,o mvU Mj;5|K27v;j'f{724vL䰴Joܬa︶-n5ǞŅx`-Xa_~ӭ%y\?{ȕW^v5XKo_>+a١?; `&E]޿e?Wyjt-> aj< v +lXCL V A -BA -u>Gy ,TKy`-n7毺ڵk  Ok* GU8Z`9 BA -BA -5LD.*RY‰)uo 30y6ڼyg]erc4: MLLXkHaMOOmb Gd`ֽI H m1p5ɵRvu֚2[&ֺWx\-;rrcbY Enhq^`aHB&^U4MD L(h(VѤ؉**TO6u;T 6z|y *RUwy7_>fl`'ԨJ/_ͪR|"MjV@ѺSWv;}}JI:zLU_Ѕ vS:KXnw@jwgX &/k̆Zoєt9ВJ$"D:4(!6֖-[T14S#:q"XGWn|j0ҘƵW{UYyk՟5SEuE;c"Jnrϱ3duuWs\mXP Jcoi0T.^*:^S*!5&U5Vbl;ܿÌ;sL@V=hM}l/~[VunpѯwVJUVS)O Uf}y]r1dv5[*-mҀj9WX+UX\+J^MR{\UfpjR׎Uں9mՃ֖O1;/˛DjUCSx_ksj)v6ɵ_DN\lSM0NM\OjL5oV {۴>6ӯL@6]~[K+IW^=ڳ+j>3o4ՕlQiII+QFR~VAA -BA -u>G=q,TKy`-n7y2|'X5 Zh A r<@@Z!h@@Z!hk"HXDҕAk<U]fm޼YѳkبF GD6AkݛЀWBR;cnp; h{jղs+c FTT/s) +(uߧ^U4(d*;SSE*)fZc_b'֒s?`=]߫cK/LYUOOOwjaU`ECSM:cb.\KjԡXʾ VP(ݍ@l٢p)щ1b=rFVHL Ў;t_(I?ǮpUoǠɤZ@aZ55lсժWJ^cURcJ=S"k;ݟ^mbS[yZUZZj& zڲ =fey_hӶSjhJSktmrZ-Ů̄%o"e5چZV ϟ^iRm (H_~ӭ%y\?{ȕW^v5XKo7JXk6ډ( vĕ`q^|Z#])??G ` h@@Z!h@@x`:cXs% ,TKy`-n7y2|'X5 Zh A r<@@Z!h@@Z¤vE묩IA|ףqW'W Š"Ea%-6qTK6٥hfml{>;ֶNls靮䬁v\-ўЀW3W=鬾7\zfec3:VP[m[t|JZ;?NI/uIk,%nlSN[6ų]kp\^rk̲ѫ%n銵ŝˤyqm=,\KLL[bc݊$" [e4',P$l1KZOS2VQdF6XͲի[tt=JR s^fyRdyp; .hM(T iДkm".mS E n?0ּ0nfꢩI]2m 5BL+աꂣϵInKj;gk];߫cK/b-t}&F]ޯ _ @ÓF|Ux2cR륩tv.5mv5j m^U}=τyh T{Pw M nO)xUBm3KB!]?n?6x?*.dN`q{i RC2jtTm}Z]ӵs'UӮـ7gzX-9FU=h4^ɓ'ul\ /m Bh\&(% w*%/k,}6ߘ.z\RcYhLm>ie9ۈ]z{' =r!b CI &biC I M(^eB5"y5%wL6ګ^z{5-dT5=U=f un +aL=wׄ7iay &hm fEuԔRkX-TW")6ۚXzԾRzD٨)Pt5gSO*5R1kT&cN<9VnY1G5:Vf i֫rafiiS09fFj܎*5&ked5;QUk՚3nO_~ӭ%y\?{ȕW^v5ԈN}u\*SB] x̰UWJ+eGFh҉)W_o9) +tu&:i7٨6{Ou)]uQuMz+W i&4pٲ.\c"`z(Db:jfMv7*\øڟ2  U Ak隷O}6m3%̺)g2\\]3kd0$4su#>AJ%iunl"awp$4Ҏͼ^ŜG87ϾF8mߥ%WέfrH}+> 4 P:ܢ-[r+ʮ5=T#15ڼ#1[n)]TGC=|Ncz7)<+=1wiKS?BU9 MOOQQMm'Ҙbi7): ߰4eT'NKѦo T1m[ h =f{]U헃 K/t$!tྤjT(G"4/=]Ty(ѠԴ+\ҧuac-ArPDDH\rJ^^(t ՛R~j~K?g\30%^RqN>I]:]%6\j5ѲN Á)[!zB;Ӟ0_S^ }*+ڡ;vP^.xɤ_bl`jT[kjmΘ?zzCTnk[]WNj[]WYḡ1ޟ.7'=b(\I?ǮBq:dWzT^(X|֖P7fcOij,W@^$뢆zF45e130nagS]swmJO1myïھ^kyn{MO/4tޱ5jřaރ!%XDB}UC5 3.g:3 AZiIkc @@Z!hu>Gy ,Tkdxn7W+x}2|(S <Z|!"h+x A -BA -6dҭd`1Mrp0V $cЕi@T2乛 i>‰REe ,jim*)D[ʔzUo_T.jtEУaԪszX{BjVg$U:;bt PPZ`HEE$&]{891Tys<1= *b"^w>{s~Ioss8Z2%~NTQ&ņ]%Dm紦2FEUI17Ҩ>OKѦtjfWU0:61혙3H*T&~*׸3.-uLjqwXm5g` БTM]pL4 k 9D"HCcJ^֘ Uh*o](RFSCr@vVoPV>V3N;f=.89ѪFv¡gVD0A-mWTiLګ{|[nޔQ$/L $hM}_{ w3jT[kjmĘ?8}BTnkZ􇐘 j| i@| VcըM &qAKj{=XiTɁC~6`ECS Pk,WIj^Zu<깘31ۯ7Y`4Z3)~jR1^ܴysϹ㗿%Ak AkZ#h-bǑĬ/tRsBVZRz5Rѣth-=ZؐlwAae[vFcSvuj rϩJz6 W\b}=+Gk9skzpoKysLMMN?я\}oO]:QfynUkn{*w>X,/\)ZSGZu:u}oWFOj֭?CWAnKZܥg^Z9+<}x,bVf3]?STtmCQ;'杭O1}t<~\gVce\osu^9=s?H?Y3Iօ #%u/ާs=g{\ۓ5ӣ5{-SwMߨ]̝܃!&gzJvs&} pZWmy+neftu@0g^Ty2Ӂ~9W=׼sfztG}!nRQjr?=ûMsf}v?%c,=Cz iVzW׾ޜq[Wּ3K4$z/-Ę*sϓC/UךJ?晟z }]_1g>-=s>ah3sc=ȷUrO|]}@.)߯Rj~j&=|D*\Щ7NTܯ迥*풙a/@W-sN~史찒^7EsM}߶#gfzI>].#k:Hə`jJ}K{l s;<3{=p9^oFsYsynGz ";|p]Sν2kgRj=әϳ1Rrkm?ǿ /jS0W|ٴ\=[4jCż;jfz|ܴQ65)gj=(gLm}ޭ2uTMor;gT"=(f28tC@ ~X3/辜,º-0?Kލ^'\cZ^~xoSںhރ'KW X;!@{=uBSmzzo²%}+n8nfMޜMz!˚3t3wzR~R殗,s|Kz&:&zyɣ;PU;wߣ;>ѧ&ᝳ>_vt*{B wp%ؐHIo(ZC)К^w;UXKL_ {37 {7Cz%}ho\TC0{{M,y՛ќ.bR+U1EγjqAņn&]/ݰs>'7ݩ$fvz%]c. Kʋr= VnHqlXЯ]R/GT݋RAP-ooua]T/_p^;67s y}⒮|7Y o˩ 8k(eT/I?ѣzN<[#gԫFNL'xaax զ~6BG3{׷j_}v5j#c͝5tؔ9\>-(ϪgH^=ܟY6z?uOGݡ﹡OD7۔l ùey /r>;;7ɛޏ]e뻺lXYVM~pvceqaMq1yCe螯lRθn<ݘ,KugK1cϰZ2Ǵ]^,R;/N7sFXcvOTzJ~ ^ _4kgym1O z;6{t}C 3 +xg9_C Ak Z7k{}ջ[ [['m%#U`Z˗5<|4@G+h-=ZGkӣVuA @:t[1G -BA -BA -B"wꍙIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/datasetgroup.png0000644000175100017510000010130315114075001022440 0ustar00runnerrunnerPNG  IHDRb$$SsRGBgAMA a pHYsodXIDATx^ x#}y$ڗȒes[c"";M١;6w aM(v;O ɻwάgDg%f|#"-K vǢ,K%u=)H駚u=U(P?*D666L O@l)?O3*K>kH^䟟~Ó{Vuq.O_'OWN5?o`5\#Ht7tSfC}OCiz(EyKKr^%zb?7).WY~/xDgz?ql+O%$[t'xB.?s9qℝ[N:%Lo4=v ɛ\hO/˟cw|SNܹry3vхet/qyֳ5__n n<)]S*X;}?~yyK/ɧu}3΅)stSm*I6wO9lSծ[}_xH7GM?cS+˼a|'C(5ɅFE/ȭy~_y>EaD%r^\um)͕GdndBƢd\^#?+j|#"z%#2)z9g8>]Zc 2aȨbH6&<̬^|<۴r\egՙEkr_v˂ ^W;o|<~O+c ^{Er9eaT8mfm3 Um癔)^:vވi~sj9E]_5"S+1=8t.v7۷oSwa\ϳĽ|;~)N5xlk.ʦ]oMoz\wurYyXcxڼƖ0ĎAsOM-–hߖ AAAAAA%Ԉ! ! ! ! ! ! ! ! ! !h'mbϞ=QGA|hhkkkq] >AC5 .B@B@BAD$"OZӚ%R ՕȶX"`lJLQ+)]Tme)뮘|2`oI؂LmevVd0,cQEQ;Z)e*'eG(7_~\inL8q1ypcC6t1)|8`o^I_w|A[Wd#vV<*vZyߛDkG?rFYס;a`~su&غqI׉7**ښhFe| ʑ*v]S^w,L࿹z)Zed*S4e>toEaS+o0_~6>*_!yCryO ̎J&cSsMurݛ_'N8| k>87#n"{l/.ŚޚH~Ԕo`>u_t@^Z؀wjqbA7:-ܢqotJJ핔 cNKp߫5>!R(MgcK4-IAl@u Aڽ3nMbv.ql$-i1u#H 9OtoHvizOq7O-EL03R,oVpm1mdfԴȁK*&+vH}&Mp 9tO|؆ /?.>xT*;őH mī& jMf'狎D>iU5Tf6۝n[Śۤ0<|X0.t\&ჶ6jG.yuxk݀ozOq7;`|MsB8+QАB'dϞ=v S}oc /-M)\+xi7{Pgɟ|)~ܧM %#_O2+3|𡏹@ C8 A]S]  6!m0: th+7i 8888888888RV`GVRDLZulǺn/1FRwx~fб nXv߷S;"d^2Ųewv֭hvR~tu'-hVȰloϪ~f-v4\.fdd$#EݯS;TZe(jwRtZVWHXy>O -YevZC(3c_JԨԆ&dsejiɜ$<&f&_筍4KM~wmͪnI͹tS[8cskf=gy}7|=uƏlGe͏>!kTfݯ<|ɾV>8]y5y|JvUٔ`NW0nk=d΄8 2Si@•}uH`_v)f$"/'oO@ \KK Y4 uMns\Sog|uW $}\^h>*Ml\; Dρ~N:˚r17*SCVh/IQ;ǃIrR4Oo|w* Amwl ɉ a565E4OT,euLJTLhA4{iס0y& LfwtvV=9NΉל Z|6/:t@vdAꞣ1>iZͶo7i(bv КRaE frT&BqED^bA֬޶4yѡNZ ʬ)g\Mtt[t@ 2謚,Sc# tM󆴘 A-7&P9rDcUp[WTnA||Tj=IVOOֶnVm̬yQ2#N-&j;y6mZbg]|:{v5{\Õ-iWGO~md=T#w0_nEjvҾ3l2 O:NTÕ؜*WoMimySڈN-l,GFcZe4d@M}q3.Huī& jMwKco$\ZylW}˦V[py~0"e  -Ky[Bzښٳڈ- mB8Z#oQ.\6$%+q}7aB}nRC6!01)ԝzz 1ԋݦAA@gW`;}mwټq7"cGx[wmo'.lݓTU؟Me߽*So mu&줤^HoQ￾S_@tzFdD2v]kmڵ57YI9Deuޓz@?O?a8'mCfEE2m~c2[yLo @H4Mm kאBVzJ[Ւd'ψ;6oz2mọ,US5.Ziwנjf'y?To"{ t>>WoY%LBՇڳfWo.qUy<~yA_jzlmv[+)IUǖh淝z|eG_}F3άӳc<rh[(>n͎?VcWWIgq[ݷB׭k%j>X}9o[2MhM[?܈y/L!V}tWׂx){D nMq* uR*ӧdHc-#}u(XAf2`t~hE@ Oͺ*彧uҿU%*.) 2tbn }+%ST2D~.$e}Sed6J2.ΠN]3"^kbII*{"l|: oSci랧Su~dž'zt-[4l)/iCF㳶<ߟ|z?q:Ph6>gNGhz}]9Zgfo4-c*8 Zh4ϺF+up2R ux~lAGvmWwMͺ:#M#:\~O@lW' 7ojވIRi~MW*ߦ:TZUYtk2S_g_ܢ:U(j$'Q2M>)'uXPfO_״c\&_T7zs婡3Vlkuli ԣ&CzkgU:<6훺qz +2~pX:m^^'hz}Tk[Ǹ8 ZkZv n\?Lg},$^'7zױSY[clt,@u_^NL3uzbҵ ]SW quhs=^,0*8tv훆Gǭ2+WǨ̫ł햴oKѶm-wxC8132gIiB}/,9<3+?#;D*=`uO͗[Ԡ4sY⦙պԕeo.^wTR$ׯizӮ\7?MQ]4'9Wٷy;c*̓Fp{h6Wa;=mO3kv>/h~χ[Tˑ#7Mt{YEaɱd[=G^ yid{Or-of ^0t vj5Hm*楃unJm͖xZbtC`bWud=jTuPZ +9 \7iSB*]Δn"OY1s;~>i<۪ܯe6w%F|>v^/>eu|7͖̾mϽϓX)8ԓl6Gz<)1x*78861錫ݷی_7z>wݢf2\~ƀϟW7] Mgi[^d՚S/h Y~[+u{:ƽtGr{eegq\xŶ&Z ;zA:|%{nyΩqD~sEqύ\t hɝw~Lَ2; 괭庶*xvUjCtq'|o/5箔3wWSYO)}СNxr*d}퉳rN&)Uw즫c_P]Tw~UwumygO~@ H j܉ȗ|R}v#@\/Hsk%_P{ts򢿾P}2?y}19r&;\dz':r׵v:_SӯA:tʹJ[1Koq1 PV=w ֎gСN#.Rȵvnwvkbo٠L@C>7ɱ7:D>YӾیĽpɯi^bMc'k˼[@E666~t}}]Pkwy۷xlo\R[+cԩS~;ښٳ w+3H|t 1uEO{L^ _Uy+^aN|U]/SZTPA>SO=%Νcy/nqڈ@tֵںI;^ !J_ Lq`PF3q q q q q ܾp9|@#G}qqx `7j'/qql/e%Hvl:>;T4`@hOdXrYw/&Ӳ:-Q; nJQ$ɴriZSMZ󔬸53R+NI5jgv4OIRJ֢2=eY= |Vux-OW$KK|َ/@ >:kSs]=T(?X-ذKko4?'vi5:kڈO-J(\ 9  o \A\LdFc;'*n(i'o nTf㒎妩!@߉lllm 244dzJJ"R6wdA+G!k'/ɞ={P{F|xniI,g2Wb[}/88ڢ<n'uk    2ܾ5n_mx `7j'/qBQD"+%;ؖ, l,Yњ6܀XTO!xhOdXrYw/&Ӳ:-Q;~Tl")le$jpӔ UΎ*M%KKLג̘l֜53Rnj]M{בR6Q݇_Xxo>"W3_Q )O6:.S ׎9IVIZT322b,^#Bya\מΪQkUd* wk[,;;^uٿqIS./x{%={]-fG=yY xA|tEƴ&{P~&[a'$h~[SyfadB7ѱ ѵ٥%Y,lّsoGMSYȗejnq DpofA5aI րSOͰio K4s:Owii?5.1W=υ521;'I4Auu %:-3xͼʬn6{\ \0d!^ )lK>5e|1Y(41+QА:-KلEra9z@vښٳ;mķي疆}#B8L_XLWwCe]G[t'4׍6qCbMpה][o}h䳟k"0ăXQ;y}    *e%J;iGx"HKɊv;[#>b,e-$q5M\AMxjf yJM{I oevln;_,-9=肮R̍ KLGWZrSSqU(Leَ_7.:$q7/-H|JdѤD")2L^)3H.-q=~VF+32츝 *lslaB59t9IYƩSyY+]a$^Z||HHF?6!#sN g; tQڈ{CxlA&5ѣ2kEu')Wd1?,LU.$>N@^q 3i^bf2u.Jf$/kS[Ę Qу"æ݋nE3Od46liWLN ƒ9U"1Y^ϫRMzp.?lý ŌN1|֯\ͧۧF.CC*/z~^;yimmMcN`#! !.<n'bMpה]ö=z5 A,ݨ >CB@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@B@BA<lْچRVt$Гv4e)322WtNvpJ-yBJIyk{s\PǻF5&{CmA̙3Jk)dE3\rE}`R}Vr;7޻β~e]{wXmtc{~7:'ΝQ2~_Ѻ9ٞ))Ov^3:e ]MStk;> on2*ظ,_GuX FvŌ\'hM9Y\YŹ)AK9u>F5uLC+ȋY5+H̠fс{S㕦!L\pܦ[VendBƚ~ZjvNk7=͖ir3O&%9X; n)9=ZBzI:f/ļLgZ3yI && J {ia qU3OdS4O}'mY,벢mjh|NWϽ5d=mjΜk.lL3ȈLPvbͰ/ 碊诬\@o2V;3N:sA6@}p#_;?>/kOGZy;v=R($ߘ._8#sFoȩS;m5 A,7d뿍:wy\Yg+OWY]A}' 4{d2)ccc.$cW_>!j妛~E~>$ćTz)[Jo#u+uAr?ddWIDR+vMn[-w%dwf'ZA ?u^>vign e'_ٮۏn/Pɶ_h=qהs9TV%jmeKل 3RcӨܼȤ򝔫a)X+$e]OLqUzkvהo۶m?_h+σz[VX5E73qo՘>rTum6XOKU";Ƈ IlBf5__C<8#?!xÛdw>'='33T+\^kJoDv{B:XväNrkcR)Kޠ⸳L&z-xàVAWa;>k{"?qo[oƷ/7X Z1+;,?'_uEo`׿iً_vL=O3S<$SպF2iYaoV^wW#ר}"=!WIs}~#.'wtŽӝDCɴriv+272!chMA:ܲuUuekI\Zlɦybjf;}lvYQZljEdܾD&d$V>BքLs˦BTRH3Kf{+7.7;)No^ evҚ%.CmnOK&E$+HQ_ZȢ)(5<ͅqwS2wm ͓x\;UEe YW[K<ߜ$s 't!wA-Kn7еnd=A*tmg*vԿ~I:*ȋ[K|V>/rɑ~6;u35r<)~Wt,%SAqd( I -4=oBzMYe= J@_I%d O!uЬI-|5;; UNv߬ս6osF|4Y~Y?Smgڈeӌ|ϳ&{5s+wΞww)oHP.~%-_G' 䂗TOWț)7'TSݼ^wǫMyʪda{;!^|y#7! /OhǼ(dX_ܞV(OaEd"(+łݏYS=.u4汙IHVۛ5=_Yxh$&Ý~@% ^y|$&  *P/˯Wu\ ݏ$mm\uJ&ߔ}5%~%"/}Yy+ڌtܟ6\oT:c5w櫞8*:di!'tY^W,ZD.^/}VN>/>{]p/ yNR-?4Hiu&$q'm3u4hjaq,,U(}ɑ#7Ut{kEaTڴFu+P0YgRyl d7s+72*Ss]͋A}4+-BnژU *TL\gWO?/CoD.}׋z"ykCR_/bKAy&lK٢N+:ue:Lemv /7SnԧLO~Rn7/-T^A_Fӽ?o^w?胏 *ȚOY'nwPNgն哅x@Tm6iU(=W^~'l׃sJi/lJ~t?A_eUǦ۶Cc6b'xxtV`pu;L{VW>noI|+TLX&5Pqt_+%oxErK%qҗ_ y/g?RjRϛeRW~E;c*z?_Mn=FGVG w8h#h#Wn{f+m=uU8ݶkڑߞ~W}dU^7_{y'duByK/s/{<? }\y)޶Mmmer6WxӜ6bn64]w}vߛ_+ovqi#ޫ';e^a o}ĽiYuKmuWayы^$hTq{&l_J &+zKk?,?Ѧ!\6WlqVLs.D6Cip#A[~5z{Ů WI0o08v>._+Z~ߓ~ft_ &}UsW[uP>__[ۏCoaߘ̷L .RӾʦ)_cq|)ߚ\EPtkkz)9 :/T[O#T: }@G ۍ  ^G @! qUq= )@@@@Xs>=.^D.ӭ5 <=kt zāt+4AAAAѱ ɯ=5RIuŸ;mkFc1kj?^/^z"/2T9AaSW3B@ 3nM`qBܯ+nyJQW';[:]1L\^Iߔ̇ JFǧ$Tsb%W(ڡx -4|.~k$-qT׎nayZZ͎6^_Umxn}iFaT ;\ZI=sONbMjpk~3Q1Rq5ORkfCs%T{׈`jRls0ourNcufٚFxMPOx}PBRܯuSŇ5ʄ·ާCxL},MfۭyM4^Wz"̈c'ە|jo|=[ 5_{5/iL7KɔkylŻO2>5'vSK>GMC{pji55'}M^00z"6ͨ,)$yB<`:3OV˫uBt!_FY o?sQi&ˢvgt\܊.|Us_j\׈ӞK4X_^zMm7["F.CCCvGyDk>! 3[G_a)NwrQ;FkjE3p>L񺶶&{C鍋5@tz^2Y3|޶Fk Eez>+U0 5.^S>FB@]D%D5NF~j#N`āt+4AAAAwME5 n_ln5 ν`p}_'Vi 888KYI$RJ͖]II$1놷=*&$nG݊yR.fdvtb_.Q#>2%If]&yP`葦)rp&.+vCڦHJ9JLK.X}m"2It.4Lj<٘)YHNiw\hY|ә6/ctvYZo1%sMm^7xƝ 27p_08>8"QАjG{!rQ;wA{|&l;ummMcӃ5#]jERMZMMBx|8uKʜw=D.zh#趄B-СxRd~uZ6^oq sa{7LQڴ֣F  N)[Mm]@ bn[NVi i~׶D㫷#Nd́݉ z7xuokۣ2ӿou f hH$2>5'H!6׶[js= AdžLW?)f"~7$ѿ-tp|_n`p&o/ ; @#ݺX  [A)@zF|߾}/SN> bP#[5=9;? S@{iROߺ(UλJ7Ku <__j^s ʽ]nEwox38>ޭ­JU#E+}/#靽7`g^umR,T,xWd@x[cMB,T~ْd'?6-6ye[KmJezݺO|̵Wvm:(~bBzTp }`qFiyԿ@"XZNP1yl4ޥdR4! Λvtz帤߳U/dnS-Tu22ZFn;pWY~N=W~k۰6MvtLVNfQ[{4A!,%:q w,!d=Wv,{aC;^k@ WenDPZoWqm4' Zu̶{jOAi@׾ȑ#EV:7ϲSrʺ=W~}x/lh[ 5n#.lxbFIg|,Y6hv;iW3YQ dۮM9_U ԛ>LسWOv.uk-jy^S?'o~W"5Ǟ[si:"{L دl{^sZ5{rRd[e~YhO~Ygx84^S@{vU; [A|p/zAAAAAA3v9rq=ݺ8AzhWc-ovW~3q q q q q q q q q qc=z뭒L&|x@o#@:w]'N׾5Wz /`8cǎ\ G$嵯}\|/B>ghb%%Ht;nHddq;?7i7[T,-9nr~^BlJ7WItu|%bjKTSkU;R'KL;mWY_mx]YyIȦ@ @(|+Mw];/ ӿ٨322b,;nKQ0ƕgeTt\<[(Q.:V!=_w7ѕ2U<%sa n-fiS֨^ 3v\I?q3:0]yo=w:M샣?:$qSkVΪpLWZ,W7񌏎MܢS+eZ|Zp }F77yLԔ[WWӧOv߹Q5h¯m>&ҵp= }FOOKK~V袋_=|7z>=2u /Jf$/kn;LHZI뺶=W j+nWq3o{dhhH/`׿uWzOϿ%*J:f/ļ&ir^'mչknSw޺mVj1:Ї"eycG{!@3g_?/bܢ믯\X,&ԧLM9Scdv׵5ٳgj5Їtg>#'O5?ma=^O'@"@Nt+Wo9 `pB*wyY}@pg]A^88O9^rB.ra7؎=j6A\AMH$)\.Kv˲(Dqs؂LԲ E贬NKӳe;WxZ$sp!\ =%#i9ޅ7Ї>dqvhA|iA,=]Zp7ֆdS O~|KKΎF?P%ʬ}pʗP5ǂk+5V$)#ur}a躲 AaeQF&dL[7~exvyԬɯmo|Cwء}oڡjcVEjtAO70x7PF_vj$+Z鹴ڀfXmD8! +[.fd.7>5'I4j^3c@uS2w$sXW\ǍK7ڈ K(I~$#7ѱ 4Yi63>GGMwĖ˩rEAc|y^ ;sXҒ,Oַ5SVmS9-oPQ5o\M0M;;='H:mk)Rú&W fn?VIf: o 9l͓L5`XDЋ[} i&Ըvm?TV|5a[-??1t=ߗ@2 u /Jf$/k`x.6=Z_G膞⺭i~U7Sur(f$tt[ƚې`j̅Njn;sMSk'ϧr|mo8+^QǽnVaVlCEtZfivcdi!'q ^6/| F׏T(Uǘ,Ltm\~n9~ " 7 yٻwB>,GCc;^o*י=kNۡ>8pD$V}udX[m^;3?KX4(+f(&<ɧ>)袋R4A^8 O9^dϞ=v=q&Ep-:=/@Md^2u:\3O|?яwԨme .bz;a\*Jg?YYYY2p(AǮlmw~z7hv6*3ѮL/['[i#NǮx7 3ѮL/['qg]A^88O9^k g@@@@z![%L;NW Gy9?HX}CruW\!O<<''S\tEv) L/;چmB9^}!2ǎ .@Hk_Z_=_x}.@)+DVJvmAz`@=CABq{nOkLW?Wq3 9Jz;u(:-]1V$C>HJ Tփ^C>o|Cwء'7Pd'Ӓ˥%N7cR?")}wk߬nC*K&T'֣yr2.!Y?bj;U&9;g%Y.K\b&/I{6Hؑ/?Wv/\666PLgdd$#Eu_= Ή<%sGj +geTb6(,;[ jjzj<|d0Y{~{a<:uܔ:Cѱ ɯчփA>CAx`*'~ o)5{dA|[O]y&x-8b9IG R1\]߱UM [ }F77yPsOvh@kZr貦 =Mx9Nd́݉ }fbb'v?=]ϧoZ6SC KʯxtL&$-ǃ4m5U5MWC_Yu;|c3,1;XC̺ts|o0sTYA6j_̉܏ Eefʩs/ N帤cNd!iO@չknS!ŝ7nۧeL.@{wQWE: Caq1 eMg ˚_}L_3p>ގmp/܎)t;T7ݚlݤĽʔ,nzb+֛zIm;u+I{C=d+oQ^ۄ(Mk13p>ގm 4J{&cu+A6@@@@@@@@@eM=h ln&AGӭ N q q q q q ܾzĝwio@{uB8gN:Ev@8MSVJ"lVXIDR+vxXVR$$郿\uU;t%y  *#*.`hM۴줤RcB0:+帤';y<ˡg䶓gw2\`[ā-D,%^(DeuuZv-۹""!pAHZ[1crFkVWLc{CK2`oپ͚MПP%ɎʬvklZc^iQWިŊ"_6`yOvF4T)&LQ~=4c e/{vmrքqݯiz  Y'@>JذKk S3+HM,-ɂ`}ѱ [$d5*Ѻݚt~=MԴ[=|~m_}\kj͝ꛎpLWn,yMLu8_Y9ם?? 0NAtbLyCa#łloC*lutͷ] .oC;>&9fjo{LGWP `#*7 TnAUF$kqatL&^XW^iiArSPWVmPLS%:@9Pe噦2-=%9da@~Rn<񫉾]fu]~:_[Nj+nt p`ā5O:M7LrQշڋy})5|Nݦ Fezvbjˋ\sAVlRu9݄ 1[NlaBffdi!'ճG_F{ar I_DbM܋57sکިۢmu2V F.CCCvGyDkc4އ{ Jل 3oޏG▌wy۷:uJo1ummMcC8+[ FF4kyo/T&“yFԨm_.l**ӫqa\,*Ӥp@8ڈ@māvF~j#N`āt+4AAAAAAG#nG|G80A \ ?={qaO ~zWCs[y}8.SKOLLIGIw.s?=}|gE~"7I\)rNkuG@!s4٩ = ާZ}{;m%xj^~__o-v) ⥬$"VJJKdR7tYevԊ. ުɺV# &$Y.leYTQ8f{nzy y}=Ev޴wQ;Fz>ey /^IziʰO,,9I|xZjQLqVFmtvXϗLK.X$"}MDZq"HBZ4v2[;o:5onzgs6t9P8.L rCef"_ؐ }ET #gd+v|E};ѽKzCv<(Ǽ zJϵMH<}\VLmxF %Ye(j #)˲:EWenjFR e5O2b 걂̸ Y콦'?P@ke"wC2A5xT.cfvL?$2!c {r=&>;?7-xLntL7ZIWbpO&%9u]CrܷaxY] oTs ?pmrҽ҉rؚbm&[G.]r' #|و29X ? JLD-b/4e黱02[ԡ'z+N9~wR)[#gS{)M#MWZp7nkoaot8!}Ӟpok;;w7ANxsoYF#iqIMVSoj.~[lʎxtzN{cj}X6FRjz嶂o~|ކbES={%uzԿCU- [}fuVY8-F.CCCv4@㞰oQ8)2 dwO{C<*v7s!{kV4AR5Cw8mw_Gꫦ)[kkj׿\a{-w{]Y-|t_e]f)q@nq! ! ! ! ! ! ! ! ! ! ! ! ^J"HjŎpԸDVJvX7tYevԊ.ɺV# &$Y.leYTQ8f{agMSe|BdaI+P#,-d2jhzdZr"Idl""ՊIEOך$yyөyTwGW\T>(F.CCCv>#ݒ_E/=:6!#sf=_Y9םA%l]FO=#75*d\3LDcj+]HA=@͔2  (Y՚DdBr+:_UYv*IwިFb&/JB|d0I3~A GmlxsN/I;짴$ 3zʋMܢS+9I5V>W Fg*}ܩ@?As dہ6Ʉ帷I#꼦j+nģ234p/ nTf㒎9B& q6N;|ҽzfbuX:.˳޺mVj1:}bf`pOeoA`w"N ]S/kۍzX8MSD666ʶ?uCͭ>ٳVtui׎'|80ȶi#    B9&G9Mܾ>|ѣGmzA$\Aq`&-8{A hG3q q q <$H$bTJ"Ur햻H$!ٶ7aѻ$"ə)Y.ʨDn(e+Hy9רܼȤ򝔫a)X*IE2gDF$S\n> jvmۇ0 m_sCַھpz&pq֭ BynNrkcR)K`e"Z+^j;>k{+l:~zLPyZxV"+TD"S!*@N%KKLKz^;OMso_z)YY ӯr;o,w~nUezݶkick7mivZAoآ261"JӅ tzaR.nyJMT322:]T 3z'daRϧ>_vǫeLzm$[ZĘi;:$\Az҂ħDͫ(5k<&LenrYOylѡ KonN ˼% oY]OLI 4̙3)SYjFtn.ˍ:bf6wXIU۴d!0˙!Γa;CN̔+l1#z qIj-ԿՋW8atŜ^mk6 Jdab^*jd!nۺJx;j٦vP3A#'v;I7{q=|~}ā"l_s=pAچL}ݾpxnsI,oL>/k Kf>{m۝ 0U/2`Lt__q`PoqtDyvz6ڈpm @vŚ ! ! ! ! ! ! ! ! ! !lllm ={l_{)@@@@@@@8h{l>wC#y{l_pq0 Cw)8@=7%/3/A|k: B@B@B@{Kq; ;/헫Cfnawu; tɳgNȵX .Nc7qr@Der(NV"}cr#r;y_ք~wǪ3WV1Wn: ~_g-—UkЫˡv2Ζуwׇ뫯kO13p|rWpݧk6IUzWz!p˸v9][|-_OئP]ccrwˍ"FG4qkoswTh]m3^AV7I6%n=u3/mglss㡚3?d=z˃Ʈ~|@na~ݸEfvG8;75Z>vg; 0n;Sm>rtׯ>YE_Ms~X︫zF ?=-/1Qu17F>ٷoBSN{;5@@@h#FF|k:m#N Z @888888888888u"!nذibIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/editgroupbox.png0000644000175100017510000006070615114075001022464 0ustar00runnerrunnerPNG  IHDRJ8sRGBgAMA a pHYsoda[IDATx^|}l &$MR@V5=S{rJ8ɱE˭hϗJm%WsW*.n5N9 עr)kjjI\QYB@Bȁw;ծݕvv_O2wfgw=gf&&&& T IĉZdA!<By @$ONNw~3 |ɇY?{URgqwۥZVРtï0@w%˜[?? -zg*;uR:ƿv 3eeei _!ޗv|MFw&v9! o`퍟fTnoTe-8hJZZձbFsTm_6n_RMUvnKq{|:X3o2q-̬ѝcf0`g7q/s-EgvI|> /ANSNA0=kx9I{XNSzCDP>ӼdLH^W^Yۯ:M{tֳV͸qc@<?.x<޼ّ$u8ެ]Nstw毓&e3YW@O-c SoV7an;-9zK<'8}Co륗^9-WVhjL|dG0:Ad+Mqg8f9hߤےowG6i=cN_dmY'`wN6=Q Ǣ3o_-k0:g;r`M>M>zCj7ٟtҿ\zF ⋽9~GpZδ1;:LfaJHLfvwEldz8gw`ZM|b|M%KW}Yj8a~KϮw[>xy.-'R[Ncz/B]znO ܿGu)ӞXBʡ{&=Xj2Ӽd*m7k+6sXOZdKdj[ﭧ̭'yp x6t,ݙVۣǴ?5T:n[@~GZjZa,Ki_G>Gza鍗^Q>%qPk><ƿ@1:AWLZqP|~v)2 ̿n}~Rș9X8s.@"!DB A!:v@H#A!<By 2:Ϸcrĉvlv!DB A!<B9LeaQitVe|Ίx,q1ׂg3d'=uLjro*a8vͩ=l_D+xv{w'׳7w`ڮ׵)Jkj< HĤoډ' F@@"!ɮ|9}]FA@acjhИI՞ln6Jxx-%+OA~L *OE|sE>oYQvhϨX_tlSⵐ^ [-:ɫU>Ej48 3tz;ŏxx-ȯCRV5V;.;ojHߘ:i`M5;#o#,3:b//o߹! -v+W$|#Lb3;[\߳L_gxU2vDVh濙f_qW66[ݟR>N&~?~^ %ܞZ3>>nǒ[l aWDTJn/i c>uw ?4e4j"}^>7L&Qݤ]w}GQmOwE9Xh}&]|,1۰DhzR=>_m/~͝DKWvK߬M0~dc(q=imc ,aJ9}fPω0d7am֕e5Me^qjiޡCcjio.U:59o R֨7ü7XAmw4_ۤ5U^ImzFU^بԩrnkq;z2)_FlJUMy:Ynl'A&5WfzG'7vgļy1_ٮWxt{A ?AmLᵐ7漴Ɣ_7N%_^jQ_ϐ Bp{SK4(=MrzE3=~96QullϠ\g&s/)x-y ?Cj Z4}9 _&(i`{BkPӚ@ϻ{q[Lsp}2S'ƭ{ >d9ofϼy vH$+t=;UNo\o/)x-y 3TuW7oҩfgam,S"3)m7nl_-S=Λ}ua Kc:tU&a3gZ|9Yu7Rk 'ΰcچe7Ki(Na?ۿ?ZY'RV:Rmo<̗LWsyyEB A!<By @"!DB PĤ-[fPRmoPJۿ1[oR|} *mMlB(kW< 554thN@ABQyy32@J A򨬬̎u4YꚘЄԣ|Ez{[Ua'7< jLmQ>꩏uԛX~ q=}ݦA=B]'xy&\lv-POOvݩKަJ۔ON6y4_'f']j 26UwM(jfU=^nxvMl5:Pv.{ߏ%m _$""f{ғE&yj{lKT/=y؃ ڹz"1[/{0Cm>ʹ' v#|]Mv 49(tǓLZ/PZ /:vֶkv[VjI7tODUWՈwwީ3u +֡g zEqMy-PtoԱMmqCޗ_]P}N:>iuKvYi3=eesԶv&>@ m.0v[-SkԤ8B3~PCH<P]S/}pI[ ĻJm{`pE6/mrZ=| @z*Z{5>Al8{]C޲5m*wx1"}qs`|(¶ըiZ k3  ب9fL= q{䗛^!@:L*yq/'6mm{ׄ wYzI@h5!Zkgy '=U oIm\8hy U[2]SڹrnLVG:?ܓc#RmoPJ?#_ :^/B yDo< _@Q#RmoPJȣ4Q#`> Rmo<پx5@"!DB]R~P'B]}v,}=+ Ebۡ\>&5m͡|ݗR#PD*Z{511 ]j' r`HU0.mPט:.!Zogs j98 n&un_A~6F=8j`ݯ svwNih4|IkkT;0gp;nkk{بj9S|}iy } m_>WrxŶ䆻އCQM;xe@w`L}QM]„n5 !`dw|l(?=}\7rn1i}zΚ/'mÍI(AgD lKn򸖭2][`m zMp@Cyz:Bz(|;߱c4zu9}-zX&)UM1!uN(%9|;"5+> r}5>'?2szG}^#_VlܙuZ^M]X]dÌ2j-;yX|bINutK>^Ju"T'ɉ_q'!ٞ{СH² m<:g(K\ml9_CGޡ+/D{mW?v~o{O6's,j¼/9a0~;;/gohe^/Sć]-ܱpoUb.ӥᦍkSڻ&puHNi{|;;T?qZĞtyds߮0oMg)ѩYo4,+͙s0k;GNvuGc?Q,S N0Gtg 9p}ǵR.UGN0yYqkU\ڵ ƭmW+{#I>պ5z-q{Mo fAr s ̌zk WNrOhz'X]^5@w_5zb NwnO{*:7.[+pO\+캒.%0x*cM5d5P=TCJXS *5ȍƏ'.Vz!17^]>(E{t !u6U㏫wFX80o~^>ӡ${ &cm(4lCIgJQk^tO%ottKi>\=9:f o5h`zu_b|b/պuQqd'~%ڮ|[Z4|f'u32UCm5s_TG76į90x cM(yO{bŧruE`Nkc5o=ٱm35.ÏND5J]R۴_We֣n7LdY.r%뮋x^ƖE=J{^sš95ى_'!մU+ E~B\qT?.A7mܫKNv*?alUl' xŮlbbbҎj||\˖9!=aV&VNb׷..痶Lj{R+ٴk,o%_]>U7:r䈮j;Z3b~Kk0wnZwskC<ܓ\o"A2pU"P@Q#RmR"d׉G5vjjM `/˖Az #T;iW] JO^3~Ai /ܠ;? ' =H)Nw?h >O_$"mҎ=A^U޼k4o76t MDO簾zs'dbk,_dKGJ~xu44ĴnKkfhKׄ> A;e,r-U)籽xl p_q5iOҚUXw:@`dBi {Z@'HjE;Lҝ@^s>>R1'6I!Zon3vzbI[rW4Ncm窼XbX9^{ nSt}Shyl&Ч@##jt][^˫Tkj(^I=N[6T_oz=Lĉ &t״-sUnKiȚL7K<łɑU%+sIj?KڒnzmM}|#q]9 ztE~}IuM;ngʌR&3}5d֘;3rԲa/}Ɏ|v,Rz-XXR^i 㕺+1퇷kb먝Fj4ӻy6םI+V:Rmb 'Q')qCI%6( c6n01AS(z+A@A} r@l  L37߬.dWGm\C_Jr _ M+yćWi+O/My&|ByD/Ti+O|i{̎(EW_}Yy/W)m|i j*;9r$oAycNvvo\TÜ`+83yNکee*s  /+u7i݉.U.W7OW'ݹ N'sp:&''5=u@yW544cǎ;Sǎ럾;οꟿ/Fwju4k~緎<+TM(gq[p?%㣕N+?ŋ58_.D6ܯ4- A@^ZnTUƽ/V'z%4,sVmj2\m{:f]vgxZ:Ǯcmsܐyq{՚GL.Hviw呕:۞ `ҚS;CWo-ۿ|i;3/rk{ sKo+n?nao [jf,  oN\5'ɩ޼h׼>:|o4:uIsκ~;b }MINM{՚IҒfp3z6ܭVcX[o __?^tѣwJgzßI||l6疶͙[pf!v%tYIfm|[>,A@QrOrݞYowe::g"u^{ghRue}>qZ΍kKo[UcwJܦ{W{7ޤ ̿<}ҷxGeO<3i{:;߉,_aЮ˖ ;׃߲W~.o\ՠv]ƆtŒ ŋuꟷk;N?uV;VXG3ʻa|.yeB'lOiRƽ}q(r|+H{q-[Nإ%;Rs7۱rZxǴj*;~WkSnqxAҗc's2' Wj&SR.qCn2/\eM#7ߏ/FO$ǸytG]MA8r䈮j;5L?c H)^/?k 0z)}T^Vmڴe*;']d}^y,;a:a6?vL57$_dٍN L?ka\q4|Zb<D.>^lA~a5-ڼ ax.r=1-c3S#\7]_p{M@_?56np3\ׇ{MRAaG}tjPd>Qozc=/țBH]mԑϮ{Sr% Wޡ9 ַw'ndkZKo絙Jw6Rݰo(]q6_j㗽}R9眣o]}}}qaތ63,3Sm.Զk5ycgc WiRmgg쐺anN5=΁sg5n;3ګwѳ@/{^i٩Q=ۿ|.Mn#uwJ]Vd+1/D7Q@N{!g^8!  mB'l[I~^L<ۇ  <y]juV_$~:NEt]`PZBy5ij+6yue:nß^~܊DW\Oؐ[ތEQթv<պta]v>uE(Ga~!BA *Z{:؉jV OS`ekڪ .]]S鋿;RWԨ_镹|q;Oܮ[bs=KoMUwJ6}ۜhSjTWW&/D7&&&&ǵl2;bj{l? ]r)=VZedȑ#Tj^-~D& ·t>,lR @vw|xl? GLv_.} d'_Ay GJBl6IVuS6?,^xCNI7WK-̹R&Y:}&'g26PS:Λ2}}Heee(QZyŇWivu?1!=]a ov7oHjywWHZdA~hhH'ͷj s??[o8>'O ҟv]w:R<0}oFOX'#S!x 9iRϿpT2A=w;xIo S#Ro?Iߟ64m,bfVNCKl,`9463Jߔdpn[j3%Kŋ `,(?gR>E˾H2:2_jx:%'T6ٞXG2οf737=;wWp_7G|B_70$z(qy *5c N0mV⛥ M8Cok ߭|mkj|?Qgz{[ѣ1O}j!7?3X{۶x|lR]yPg>K N^{cz+ĉ_1V%Ta;!U]J;QRCivgrykZ5j¹Y[M fHFƷe~IE֯-9Kx]7 2%Җd? `~Yg2;>[54za?أd;zb_]>kz ϟsw%k}%'ƝE*;lٲQm^V kuqovTi<ߤ-3{e)Yꩧk/`<w٢͛[ Nԭޚ Қ>oEa~̟wӐ+5]R8U0:>;@hծ!5רiWhxm`#NԅW 5g=߿m|hӺ>z]{sz7/Km][D~y#o Ө|++!>sb7|SO;<3sIOĆE~s9 w8o:2Nê~i ^k}NĴ=3.kꏍ;󓸰Ə"; **X*֨vH֏=t@Zsgjw9oG>O^i:[g|]Z3up Wcˀ;TPZo/~tǿE.~oPl֬>+ݡW\=ȿ ]-kqu"=y&MWzliо?{\ҙΎ^YSinv3{ZW/Y_cue/۟:[ siį.՛M%+Cg͋~Q{۬ csSOg.wFM9x#uO/5?92Pj`~QF> 9y=gu>rQ{DL<EF_ ?LܫԴi`M5eV[蠚kK3'&+>-:_ a@_DYɵo X˖h>OVߪ_sVѪ΁HdE_f묳TQQ]Ѩn׏wZsg%7w^z]kTYYKg>Xq/?P&LG^}=0ld=%ͷ&3g 莗\&A5UN׃Qssҽb 0_yBty穪im}?ݭ7Wr~NY y1曷w Zoy>//V_lуgԭmyC'LO~06;Wl3`k}=nJjeZ^oϺO6$Ɯ4{ anEZ`\+mnٽ[~p{YO^{MN-1!sϝ[_t/$q1y @Q# -mnlRMݻnWH.F^mM8ٵ^Ѯj'ejRT;˙4{մU+|Tje:|Ώ^ݿogh 1;y̿dg5{bg7Bm)NY gB3;}ìm:鼹֚Sbu`at/197=at:~StH6涉4n}Srm{o4:ut >&w0;Q>oib۳g'k4h,(eNײe]~`jwM;S^pBAS^3]Q%o:-g& S2i-wsTdޓ:pN}uxves}ȥlto/jŊv?~܎/c Jno}ׄ\Ǯ EҮ4ۦo)O+X-]'f^Hds~AG>e/{R@3y,l?nn\'ySr'+AyEn2^!,ʹEPLl?0'M/{i; h_}lQ={LyFtTTLy'ɭ @<< _v2&vA@i1ML[:cW;ԭJUd:H)k'#ܲsO}ٛbfǿ.7?269wee܎v_N\G(] L^l\\y`&ޞ .<L^l? ]r'_v0 A!<By @"!uRNFeOPݗӽ=ב[oՎ箻c`BeOPݗӽ=A~Lef}0 A!<By ѝZzF$p""T7lA;ocZ39j`3l^5 ky¤.IMƆZkg-!s<?>9;sFzSkH;Vcvtjh8;7'Z[cGU]gGs٤>*UݱIJF4?֮צ!ՌYnVTI IDcAyy$+>cݬ瘪ۥ^uöTd|DCM,a]|Zix,ᛃ7fD L֌1}{z~Ln&¶`=N.=߁f6-LgZV/r+^mͿz| AxL7A|$D~9NUֶ|xZdm:ǟtZ_Cɻ vnID~hߴ^kS{1q큞zۋo>+^2#ҵr;7zM} ᑗ wyr㸯e;cFc_2P^CvwHrƔDulWRf jmZ @=ȿ ]Ygº[n 6?l?%S/7mڷ_ǿ(GT.;JWASVcztM$Gȁy'k{}#Se5}Cr^O.VuL N(8nt\)vL~ԭ$\Q$5UzY2yZk}&@שiө^olmqҨ &/N`t*$tQ EeOPݗӽ=5sgꕁ mdA~ =x@0NvMN|tp+-~B @ ɮ Jn_$&fz{m3x Js5Uο`tMOV>%!7dC< [^zQBeOPݗӽ==@*y9CB A!NvEJٞpve/{{Nvo>;_6lcȣ8dpve/{{Peb2A~ժUv*?9BG g?Av_N3A>5۶&'GGJ~x!ܲ'(tޞ ?wpUjr҉f:ayzN' cdW }7u guh349çw.k[<p7wv7ݮ|O! lN?]l9 3<ﵙA DV,0l>hg;5j',WAm}Tx'"y1u4̨.'29ձu6WnT-UFwv@irc_t)A;n8uIg» wԨߓnlz77m^ ?qy}D-wWnX_}8[v]o~Tn;i k&Ń 7v[jڪuJcj(/W?{툘-h ߈"{{Z?;rzk[kM^>bMY vԶm{oqnJHi=>ݶI{ykϦ۴п@Q0g Oz~_q #ȍ4x5ol@$5}ԴkB0خZ;Gmn4Q',7>ْvvن+.@sv|iN07G+\2涁uU6^=^}kkwpmZ?ӊ'ޙ>񣧴Z29Ł 2Njpe5Mrr$$5cmz;lOXbMj;{4 sh=N&wӋkk~?xPl9계x5ZvfMǂ2e5mqK[թ$5 ;LLD+o$W&uQ*I$vaoZ `vs:nÁg9]|YbA2e5jKfly˴T=**Uc݃FzmvKwfo ce5 %3IC{]j'ִwlP\M[+ZZ W{8C*rx~]/'gI鲥W611qlW(wKavQ,/jŊv [u]w)ٶ}j*;Gц Tn?~\_|]yKavQ,@;P:vR^/)GAȞ KavQ,@JW<'!DB 5H)ۺP”_zvlforn^ y ~ϻCb; (f}CCǘmLԧ]6ݨi|sgLcmMO b)LnT6=5>-pK|s wmAQZYɥzҹ`.=Û;ҿXEf ɮ_5i4Q Vr;i;`ތu7x7q㮿{)2yES;+ՌR@*M!靏]m]^zfP+Uq;`:uJ'r0vo(z[Mr8*Ro4!sN5BhSM,hOX`ݮTf=CKm[^v|M,RW.2]괁74:R싘s6pR]O]6UB+#1(:ntzAȵd5O߷kMkT٣Tfv*l2q=͝&5{'֓9ppb֜zgES$ҚjYfgJkdnvDul]>%ݫָ7^f?ByX=Hl,U2N/ߡ eZ{uvJMkw~}^fҿd2f>e˖)T;0e(Zxb ;Lz뭺뮻lz߾}Zjʏ#GhÆ v*7?/N.N|FĤ-[f΁C)911aRKy l_& |^|E;]|vlv龯A~l?y lJ~Z (GbIcg|7x,K1mþCd7`y,_B>'|S%czR ؖ8-ohb3ZfgO yf;P/ԶkyL|~G0<11S;0uN9~iUV5-N*/L!ȣprd5{Xhmv[I\UԕO@*+d6ҎN3"\ t^Wk. 5<@jbA}ԴK֛j7R.]j̛5z֝Zm/9;hS}0G7'&룦愺Z|JkXxJkF5obMj;{ԗL6±>0,S׃) z'֓\Z{iTONϟTW[ڧwh&בGJ^;9Lˮ`kMamҎLk4땫%m .?Hz'6)޼9HxS~X;:"u45Gntoc1rW۱Zf}I JwAޔД7'@kkk"[GM%ȏuDthM֪DzCU@R5g-p iZO- |x#U9r 73M*|U`ju6U#Pku^ѪS[e8JƻM{0S7Uk+$7l}}Z{>C<(#l{_ Sۏ͌y wx=w7{~[_xz5CuC9[̌ HxP[t9D=w6l_Iq&Ny;vj~[6Wj蝧w|v)s% `d yWPD;>ƿm=o/Yr:]%羮GQ}$ oG^}{~۵aOzgwtSV|Wgu|̥v S5PuTWmj3=gxC3xmSm  x <u_ݩ .9Oޫ.y]/7O]:3z^?4|YKβzN'.U uѯO}}tX˿:7joK19CRpk[ލ7KIMn?̗uCoOLhuUU?8y->}^upi{\\ɪG_w_a 3OzgTkGЕ-ګsv6U㏫wF&{y@qt:}R,;.6vگWq_wW֦gvڗͷkE÷ov>foCLP^z tyz_NhU56_]zΜEwoS] ]sn-,ܕMLLLYkٲev .Nw?`)Ln?k8[&92WTo%_]'f[. GW_mfk73M*Rwp>TdIg7e 0~9<`F&sQq ڗw+U{ڟW;@Q&3y @q+R/ S/폅eN>8 of] H<̗LW(B A!<By @"!B?`dPy @"!DB 1tJ9#IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/images/screenshots/importwizard.png0000644000175100017510000003600515114075001022477 0ustar00runnerrunnerPNG  IHDRgoi@sRGBgAMA a pHYsod;IDATx^}l'v7c7u{/qJʩJhd6[I;HyЊmbP^Ndmch KDs*qbǹeXdd%:= 5H(i1Ɯy3gtpEC 3EDD~!j*ę3gzjp8_\->M6XjN:-[/1u8ή=ۗ#Wկs`ƍ+ל\pOUzͿ*(Y}=_jmmmzhشi.d[199iߡCL2o>1|aukpr` W/S"mfe,v#W߻|ؽ{//"F>S5H̓eZiN`jj oK>_Xɕf*P'ZZomSs|s/k׮EOOn߶nf<7A-e,Jsn\" տuD @DdGс*B K^SqV K.՟`jVqeq"e)kIwp֋mvaY$j|z!-^-dztw?`VTs$5"֑nox't+ϼA`! P0uϾ{^|{e]/97Ͻc38'o |QƨxX C]mŰӚP.<@Ъp &35/"a95H`Q?b-3\H:)w=یals쇤 TُAtV0*0Q+IWWؾ}I!p 9/5^_8v z$OW]Oά:an*;{t6z:RY6Ї,5^PKyg;M@.3CtgT".BZĻJUiHk˳$J5 VS?SFWG~5);:O޻r's;nح?șV t#j FrQtwtJUUwa ƄtH<-ADKgŵ^kϿ@5U:_qǣ/K{5OMS8y$9szx*oBȖd r{#Eа;\[Mt#RwZe2p*7* w@w}ԩ3>ge`Q?#~VBe}wplj~rGs/|ϝm;C +caN?w]:1͍נ<]2e:~X$BPjt ]Fn[$bg?H (ӽyj>{nja > )T.7:M? Hw _/<G~?ֆ[Ӆe]\.O U{7;/ڇk/݈wj|~]W'Q,u'׼~׿ pe9 -"jqDD2qDD20YR DDb ",`Ɖ"lYR DDb ",@Dd)""K1YR|˿K3FD80, ""ZX{o]@DDb ",p1lɒZSDR(ɒZDDF >Ƕmc̋Q{]mޝZ9-4~▤~?LogFOoG_o<0Y}I"Vi:QDۆ U`jޏ%$-}x嵣j醩לmƷ;@¼oblqg޷ ő1Pcq3]LEfRR75T\r@73<)|nIOv'E|I? ޮ}HtzU{lI1 8lW/D( |{7UE1wJi) ? -]5RN57>}(p+n|]8_o=.=f{OlƧ2٣ C2#9$Mw  ` '.RQt>@wlgR3L _Y_˼?-woϣ'x۩KzaSp^*vkz(oY/>L/VG27L)i0h' j]KECЁ_ށR_܏itFkk`yq]wJ~M}ءjfsxrtGs ힾ멹G:5N7]LZlnoͼ4OD-5ԿRj;p"ю5z˫M/p[ϾIUkt0;:OmwFϒo9 ;L *B MHo>TF]oyUtAo2]02coIޟZO] |(-&> GK?Id kubƬ;"R@DDDvYR DDb "Љ'ymذ-s f-; -  tD+@ HX~ZN>@&0,@Dd C c#ODL-| DB`>~a<\`f͋lwNI"F,nP!3*ցF[n(" *7Ql8?^FUY]|{p V9$"j  ;4" !K$y]3.#/ u '6>U㇣65q:tGݪL?J|>J@zf M!-470-4=z -%9}ToGOj#3WAqouRh[{(nRtPo>TjHA. jLր,ʨ3tDRAן2]@2rXJi tbP7F*[n7BwUJU+4=Ӫ.+묷>YӬYG= |?DⳀZg ؽ5j&]8fRzVw ,>^lg?2Y@D+ @ apD-n,{-`/2&йvYR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb "&''xCN8v3E9rtZ+p 7 d%XiC TsP(;M7ݤ )h>={  _lt,b K/=܃6:mjj <~aΝ&u"x&i1,ȱ4ǽCb b/2^uf߾}[a֭U%] YNo),#)|UK5 v, 3s\,y N.82&mA-'ҵ ߏ;vرc8w~i B{'ye6~?Ҏh<(L Ӥ-g/n=>88_+=u˖-x饗xUL [J)C)͊He UzD%V_qcͬǢ{/mKP˗_yszȼ*-u#q.xF](K4^>TM*0gi=9s֭ǏׯkbrrRϤ UƦtJ3'70Bbm}n0x_rlX0Y~|d]Y%=fBZr;s]oTN{iD [-1.0;_.4 Y>|K WTA7Re>ʣ`F )hƍk%7] )h*N Z캆Djl(Ҩ8 tGAqK: Zv<[ÒۂHGU pr;TA37Mt .5 UVS #/nU9XJ.}PcMuo|ͥҵ mӕt,b=Q80RHt-ԭ]J:Ҏ@Dd){8 "ী,@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",? y1""jooKh LioQ0YR DDb " "fi]hYAS S񌙫揦h7frig__M;u+/ERZj{<}UzmJ$)T0dEoSSSa,;f.PKEHE1c@)kz'}o:,zY>xc!l>KtZH.|) ŰTf02B~ζ~l =fl^VWf$da*i':1X992Qx@DV=̮.\Umv(3¶s(Ր{%%DpJHFo=SN¿Ëb!"_Q{Ry߉BRDScfW#\}o\#mE&qf>|fU4 s;I ø@;T;`t#Ze/{I<7lj Uj("(5~^U{?14c@€{WUޣŰ>F>.Q"Z$OsSVԲg~|^ т~uM])*, Zh|\i޸JDAGz4_ODr<BY>@>2ϫw}"f @퇙U{Hy?Mo.|R[ߏ_rUgQ7qKk<̬C܇em+$s*+=Ν*ck? wfyț\3cI@IU!e?"hmuVLD6=)j>C2]#eoh3֨癈P!e= N>v!-uapR7<+> ${HY'xhCjfgy?y3hoj*{sW>ٓ ;W&>L]|C*GD4% ) g08c͛DD+GDP,@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb "&''xCN8v3U߽kƈobb6l0SY@DD k>]@DDb ",@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)3ސ'NL-_{[\ lذL5%q#Z ""h#G{uV*Ӓwyt=*9<~a y睸馛pWwÇgA}ݸZݯl8SDR(Ij|V?/| G?K.Dʴ_tEx衇-Т%ytG6=3fx믣_ {G/'TD*;zHjᎢ5Ton`z35x_Z9ݷoѨWnֳJ ”0R"Nu3ܓO>i7Bp7 eZ5Q04 }Sgacǎ8vΝ;_eZ{Nז<5, LA3k`n-UY{pkqSgkaaK :0m폩Vw{e([}瘪.1՛@6@PtQe{rޚ^xr-f-[ॗ^2S~"]~{'zb9L/Cع3t.y-qF>h"?i2X5{NXtYCپq9}R8ڱTiKګVT&sjd]1ljzw|*{향TMJ eiҦ Q{sd@߸CH2_z'U_Nզk}b{sq[LշvZLNN>O5pkZ'Gu ~gBQ?!Z7UU;UT~ nB vɫ ǏJnzsZ:8GۯqN]w[{ԺD55[Su5znYK0.S-fj"*Sg=*0bjI]i\NWQ=銘G3.rEoZ1}qya}}>QѮ ?* bqlyȫVީdfT'6{N7nܨ_+ui3qsZ8njڒP6 ]N GFt3ML>mgSxS]4Yend&:oL5h.P|{A0wߍ/جEtDdDQ!2-s^NJoZw-"e"3,*{5>y>հ-p)LP"#cfIӺA߿;vcp9*,OS=/t3o Dp:X{g3y'jQ6i6Tdf(qnNvG6kSH%yߪ~&&T麰+{nYnqZE/n=>88_+IWزe ^z%=޸ v%pT,0:1XHRpکf^-_U'ugkt?|Ȟ6WȌ`(܁3x0ИS[,$s蒜*A"GA-QM#Ft, w]CeDc9dytk5!CǰIaq'Me.uQ{$}Pe o8b:s ֭[Ǐ?_+k׮6noqpӥϦyqTWzS$׻.._Z-,:P75D]`$or\;Јʦ0rf!BOrdPvLͥSl;e9fS]4YP=%}jF]C@n¹0^guQ/Waٟʹsj) UEHN݈TF/̥ԅPFt6M^MUӈ̲fV)YΧ{z<Sͪ*.*cxtM?ʈ E}6s:h溠?]@RC -ݓ ) V5drd,ET HUۭIwG5j2To979tbq'}.\m݆x6mҟ;W޶m/DH mw3y-_VXNCf9]BϹArA4YeV)]!ֿDiҶ/P]=7PƤB|z;r2/4#`ZB7pߣ>kR?S8p~iI,_< brM{I"W hTj;O37gs])D{&Kj۽|y7RۊG~7Rq ^`x}8:Pе m];e1UڱZ0ЫuKzד3߉ DT xCGzmYjfپo;hA,,Ћ (clO'ǁAg\vr5&&&a3$ Gjbh($TAOMJDYN7^mzMUK HujնQm۵ot;iٞZ"P)Ս ˢ Hܮ6UBE7RZOA ]`3}>ˈ @,bca,lz@;B:PHJ< fT%;b=Qt~#vfCm{U HsM"Rp=^jc!$ |k{36.5?ITZ|Y/aLw -SRgۥ֍{߽wВ}l:ζۂHG{ Dd=_n e{$2o>7EDKR DDb ",@Dd)""K1YR|-{׌-ߌm, Zpr1.vaI<LJђc ",V$-QO\\CU0(\1L^37Co?RX6 L#ZԔ c fN–e̡߱Wa1q~_;g\T|Uz:MŖQPR=]K ԆtHEtmFYcjN7Ѳ'ͭan͵z^ٲjX532.@w܄ki YT9nZ砙W]<#yo`(ۮ?z=7zW&&Ti2f^)[!ʦ1&*OKm^WxPrS &~01DP!R \f,&D#71tdȌ`"FwI$˼tdc=vFw292bj+ 6- ]gԐ>3WZPΤ/' B4WuʼTlan+yq&SLGY6 rĺYFt>c%>iRrב{~:H]4$rR;5L.Ld~"C5'eBAANv~}**GjuʼoWZ}iEFyc?Q;Gb> Tj 1 BQ]lYF NFyOoђc ",@Dd)""K1Yj|`˖-fq/#"~̣LDD_#"%gQ("ȆgKbDD+E q$ž]O&$")"p~xZ T˖s˖:J?kMp=u\="n-0BrЀSošB9t {>x0ИoV"CmȲy4iS(ҽ>,Q\$yw  .@X^A~۩w:A1096QFZVP#5h?FּNA_S'u-#i\mxU8 .DDP^M#VF\wK!^@2jo:J2#m~^ =Jz۱Uۯ /KDJ 3@tC[v tiڂHGwJW.`LH*U"Pm#PܛDDo/FuOD7H F""? OD "%@Dd)""K1YR DDb ",o/^@DDsGAQ,@Dd)""K1YR DDb ",@Dd)""K1YR DDb ",@Dd)""KGቈ?M""ZDDd)""K1YR DDb "=ʌQ-q3KfjXu7cT?y3Vfˇ0,.r3E-[Uz ̇5Wx֯4~_0 7{DDb " o_ҏ$";p5L;53h: ك=ymf͋lw+8k&'u|G|*8Uf=x%|їpv`oM33`X| wʰxO?f{o &xOsv7ӽ n5)iJ-/9P~c0`x;J˛tSC*-}SyOHiVx^-<WeЊ`AĿ V6UuRj5edwáC6 yP e˛SK)ڌMך ~J7w{2ʠmnWC*~I7%Ju!vl< p|jy ꬷSg\j0jl5u Ff֊p냪Sxg߇R?;|kGo>~ո0WիZ*+X)aFF^v9MftoeG_w oAnaS7˫x(m|Vj7nfS36[WcuܟtKgrϹ&u/>i4 D:OVRd=vVƚo95j⮺Erk8+CFy;U^1o|5kGK݄͇ժRVj&nEL̬ڌ*x&Z@޾z` >O muVnV2}zV)؋iQ'_i27f>Pe7}aolj&ot/9]ǸT4^ZVjn_U@*;[x"ԺtyY 5"_97J7jz^;tnlխdӡ*,X5u H؆[Um曛QYZNMU O^87A+ǁOVV{FoQO5zixƼ3x[0c+2=^5M`)5 ouȚ={XNHVjk1> .|apLJQS,@Dd)"=;oۣ@7o-@Dd)""K1YR DDb ",@Dd)""K1YJ%IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/index.rst0000644000175100017510000000233215114075001015266 0ustar00runnerrunnerWelcome to :mod:`guidata`'s documentation! ========================================== .. image:: images/guidata-banner.png :align: center Based on the Qt library :mod:`guidata` is a Python library generating graphical user interfaces for easy dataset editing and display. It also provides :ref:`widgets` (Python console, code editor, array editor, etc. - see ), helpers and application development tools for Qt. .. figure:: images/layout_example.png :class: invert-in-dark-mode Simple example of layout generated by :mod:`guidata` (see :ref:`examples`). :mod:`guidata` is part of the `PlotPyStack`_ project, which aims at providing a full set of Python libraries for data plotting and data analysis. External resources: * Python Package Index: `PyPI`_ * Bug reports and feature requests: `GitHub`_ .. _PyPI: https://pypi.python.org/pypi/guidata .. _GitHub: https://github.com/PlotPyStack/guidata/ .. _PlotPyStack: https://github.com/PlotPyStack .. module:: guidata Table of contents ----------------- .. toctree:: :maxdepth: 2 overview installation examples widgets autodoc/index dev/index reference/index release_notes/index * :ref:`genindex`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/installation.rst0000644000175100017510000000103415114075001016656 0ustar00runnerrunnerInstallation ============ Dependencies ------------ .. include:: requirements.rst Installation using pip ---------------------- The easiest way to install guidata is using `pip `_:: pip install guidata Installation from source ------------------------ To install from source, clone the repository or download the source package from `PyPI `_. Then run the following command (using `build `_):: python -m build ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/overview.rst0000644000175100017510000000442015114075001016025 0ustar00runnerrunnerOverview ======== When developping scientific software, from the simplest script to the most complex application, one systematically needs to manipulate data sets (e.g. parameters for a data processing feature). These data sets may consist of various data types: real numbers (e.g. physical quantities), integers (e.g. array indexes), strings (e.g. filenames), booleans (e.g. enable/disable an option), and so on. Most of the time, the programmer will need the following features: * allow the user to enter each parameter through a graphical user interface, using widgets which are adapted to data types (e.g. a single combo box or check boxes are suitable for presenting an option selection among multiple choices) * entered values have to be stored by the program with a convention which is again adapted to data types (e.g. when storing a combo box selection value, should we store the option string, the list index or an associated key?) * showing the stored values in a dialog box or within a graphical user interface layout, again with widgets adapted to data types * using the stored values easily (e.g. for data processing) by regrouping parameters in data structures * using those data structures to easily construct application data models (e.g. for storing application settings or data processing parameters) and to serialize and deserialize them (i.e. save and load them to/from HDF5, JSON or INI files) * update and restore a data set to/from a dictionary * generate a data set from a function signature (i.e. a function prototype) and use it to automatically generate a graphical user interface for calling the function This library aims to provide these features thanks to automatic graphical user interface generation for data set editing and display. Widgets inside GUIs are automatically generated depending on each data item type. The :mod:`guidata` library provides the following modules: * :py:mod:`guidata.dataset`: data set definition and manipulation * :py:mod:`guidata.widgets`: ready-to-use Qt widgets (console, code editor, array editor, etc.) * :py:mod:`guidata.qthelpers`: Qt helpers * :py:mod:`guidata.configtools`: library/application data management * :py:mod:`guidata.guitest`: automatic GUI-based test launcher * :py:mod:`guidata.utils`: utilities ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6258857 guidata-3.13.4/doc/reference/0000755000175100017510000000000015114075015015370 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6268857 guidata-3.13.4/doc/reference/dataset/0000755000175100017510000000000015114075015017015 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/dataset/conv.rst0000644000175100017510000000006315114075001020506 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.dataset.conv ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/dataset/dataitems.rst0000644000175100017510000000007015114075001021512 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.dataset.dataitems ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/dataset/datatypes.rst0000644000175100017510000000007015114075001021535 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.dataset.datatypes ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/dataset/index.rst0000644000175100017510000000021615114075001020650 0ustar00runnerrunnerData set features ================= .. toctree:: :maxdepth: 2 :caption: Contents: datatypes dataitems conv io qtwidgets././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/dataset/io.rst0000644000175100017510000000005015114075001020144 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.io././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/dataset/qtwidgets.rst0000644000175100017510000000007015114075001021552 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.dataset.qtwidgets ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/guitest.rst0000644000175100017510000000005615114075001017602 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.guitest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/index.rst0000644000175100017510000000021015114075001017215 0ustar00runnerrunnerReference --------- .. toctree:: :maxdepth: 2 :caption: Contents: dataset/index utils widgets userconfig guitest ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/userconfig.rst0000644000175100017510000000006015114075001020255 0ustar00runnerrunner:tocdepth: 3 .. automodule:: guidata.userconfig././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/utils.rst0000644000175100017510000000026515114075001017260 0ustar00runnerrunner:tocdepth: 3 Utilities ========= .. automodule:: guidata.utils .. automodule:: guidata.utils.misc .. automodule:: guidata.configtools .. automodule:: guidata.utils.translations././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/reference/widgets.rst0000644000175100017510000000020015114075001017553 0ustar00runnerrunner:tocdepth: 3 Widgets and Qt helpers ====================== .. automodule:: guidata.qthelpers .. automodule:: guidata.widgets ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6298857 guidata-3.13.4/doc/release_notes/0000755000175100017510000000000015114075015016262 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/index.rst0000644000175100017510000000037115114075001020117 0ustar00runnerrunnerRelease notes ============= This section contains the release notes for all versions of :mod:`guidata`, documenting new features, improvements, bug fixes, and breaking changes. .. toctree:: :maxdepth: 1 :glob: :reversed: release_*././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_1.md0000644000175100017510000003222115114075001020437 0ustar00runnerrunner# Version 1 # ## Version 1.8.0 ## Changes: * Added generic widgets: array, dictionary, text and code editors. * Removed `spyderlib`/`spyder` dependency. * Added setter method on DataItem object for "help" text (fixed part of the tooltip). ## Version 1.7.9 ## Changes: * Added PySide2 support: guidata is now compatible with Python 2.7, Python 3.4+, PyQt4, PyQt5 and PySide2! ## Version 1.7.8 ## Changes: * Added PyQt4/PyQt5/PySide automatic switch depending on installed libraries * Moved documentation to ## Version 1.7.7 ## Bug fixes: * Fixed Spyder v4.0 compatibility issues. ## Version 1.7.6 ## Bug fixes: * Fixed Spyder v3.0 compatibility issues. ## Version 1.7.5 ## Bug fixes: * `FilesOpenItem.check_value` : if value is None, return False (avoids "None Type object is not iterable" error) ## Version 1.7.4 ## Bug fixes: * Fixed compatibility issue with Python 3.5.1rc1 (Issue #32: RecursionError in `userconfig.UserConfig.get`) * `HDF5Reader.read_object_list`: fixed division by zero (when count was 1) * `hdf5io`: fixed Python3 compatibility issue with unicode_hdf type converter ## Version 1.7.3 ## Features: * Added CHM documentation to wheel package * hdf5io: added support for a progress bar callback in "read_object_list" (this allows implementing a progress dialog widget showing the progress when reading an object list in an HDF5 file) Bug fixes: * Python 3 compatibility: fixed `hdf5io.HDF5Writer.write_object_list` method * data items: * StringItem: when `notempty` parameter was set to True, item value was not checked at startup (expected orange background) * disthelpers: * Supporting recent versions of SciPy, h5py and IPython * Fixed compatibility issue (workaround) with IPython on Python 2.7 (that is the "collection.sys cx_Freeze error") ## Version 1.7.2 ## Bug fixes: * Fixed compatibility issues with old versions of Spyder () * disthelpers: * vs2008 option was ignored * added 'C:\Program Files (x86)' to bin includes (cx_Freeze) * Data items/callbacks: fixed callbacks for ChoiceItem (or derived items) which were triggered when other widgets were triggering their own callbacks... Other changes: * Added test for item callbacks * dataset.datatypes.FormatProp/new behavior: added `ignore_error` argument, default to True (ignores string formatting error: ValueError) * disthelpers: * Distribution.Setup: added `target_dir` option * Distribution.build: added `create_archive` option to create a ZIP archive after building the package * cx_Freeze: added support for multiple executables * added support for h5py 2.0 * added support for Maplotlib 1.1 * Allow DateTime edit widgets to popup calendar ## Version 1.4.0 ## Possible API compatibility issues: * disthelpers: removed functions remove_build_dist, add_module_data_files, add_text_data_file, get_default_excludes, get_default_includes, get_default_dll_excludes, create_vs2008_data_files (...) which were replaced by a class named Distribution, see the new disthelpers test for more details (tests/dishelpers.py) * reorganized utils and configtools modules Other changes: * disthelpers: replaced almost all functions by a class named Distribution, and added support for cx_Freeze (module remains compatible with py2exe), see the new disthelpers test for more details (tests/dishelpers.py) * reorganized utils and configtools modules ## Version 1.3.2 ## Since this version, `guidata` is compatible with PyQt4 API #1 *and* API #2. Please read carefully the coding guidelines which have been recently added to the documentation. Possible API compatibility issues: * Removed deprecated wrappers around QFileDialog's static methods (use the wrappers provided by `guidata.qt.compat` instead): * getExistingDirectory, getOpenFileName, getOpenFileNames, getSaveFileName Bug fixes: * qtwidgets.ShowFloatArrayWidget: fixed string float formatting issue (replaced %f by %g) * Fixed compatiblity issues with PyQt v4.4 (Contributor: Carlos Pascual) * Fixed missing 'child_title' attribute error with FileOpenItem, FilesOpenItem, FileSaveItem and DirectoryItem * (Fixes Issue 8) disthelpers.add_modules was failing when vs2008=False Other changes: * added *this* changelog * qtwidgets: removed ProgressPopUp dialog (it is now recommended to use QProgressDialog instead, which is pretty much identical) * Replaced QScintilla by spyderlib (as a dependency for array editor, code editor (test launcher) and dict editor) * qtwidgets.DockWidgetMixin: added method 'setup_dockwidget' to change dockwidget's features, location and allowed areas after class instantiation * guidata.utils.utf8_to_unicode: translated error message in english * Add support for 'int' in hdf5 save function * guidata.dataset/Numeric items (FloatItem, IntItem): added option 'unit' (automatically add suffix ' (unit)' to label in edit mode and suffix ' unit' to value in read-only mode) * Improved dataset `__str__` method: code refactoring with read-only dataset widgets (DataItem: added methods 'format_string' and 'get_string_value', DataSet: added method 'to_string') * Added coding guidelines to the documentation * guidata.dataset.qtwidget: added specific widget (ShowBooleanWidget) for read-only display of bool items (text is striked out when value is False) * guidata.hdf5io.Dset: added missing keyword argument 'optional' (same effect as parent class Attr) * guidata.dataset.dataitems.IntItem objects: added support for sliders (fixes Issue 9) with option slider=True (see documentation) ## Version 1.3.1 ## Bug fixes: * setup.py: added svg icons to data files * gettext helpers were not working on Linux (Windows install pygettext was hardcoded) Other changes: * hdf5io: printing error messages in sys.stderr + added more infos when failing to load attribute ## Version 1.3.0 ## Bug fixes: * setup.py: added svg icons to data files * gettext helpers were not working on Linux (Windows install pygettext was hardcoded) * DataSet/bugfix: comment/title options now override the DataSet class `__doc__` attribute * Added missing option 'basedir' for FilesOpenItem * DirectoryItem: fixed missing child_title attribute bug * For all DataSet GUI representation, the comment text is now word-wrapped * Bugfix: recent versions of PyQt don't like the QApplication reference to be stored in modules (why is that?) * Bugfix/tests: always keep a reference to the QApplication instance Other changes: * setup.py: added source archive download url * Tests: now creating real temporary files and cleaning up at exit * qtAllow a callback on LineEditWidget to notify about text changes (use set_prop("display", "callback", callback)) * qthelpers: provide wrapper for qt.getOpen/SaveFileName to work around win32 bug * qtwidgets: optionally hide apply button in DataSetEditGroupBox * added module guidata.qtwidgets (moved some generic widgets from guidata.qthelpers and from other external packages) * qthelpers: added helper 'create_groupbox' (QGroupBox object creation) * Array editor: updated code from Spyder's array editor (original code) * Added package guidata.editors: contains editor widgets derived from Spyder editor widgets (array editor, dictionary editor, text editor) * Array editor: added option to set row/col labels (resp. ylabels and xlabels) * ButtonItem: changed callback arguments to*instance* (DataSet object), *value* (item value), *parent* (button's parent widget) * editors.DictEditor.DictEditor: moved options from constructor to 'setup' method (like ArrayEditor's setup_and_check), added parent widget to constructor options * Added DictItem type: simple button to edit a dictionary * editors.DictEditor.DictEditor/bugfixes: added action "Insert" to context menu for an empty dictionary + fixed inline unicode editing (was showing the error message "Unable to assign data to item") * guidata.qtwidgets: added 'DockableWidgetMixin' to fabricate any dockable QWidget class * gettext helpers: added support for individual module translation (until now, only whole packages were supported) * DataSetShowGroupBox/DataSetEditGroupBox: **kwargs may now be passed to the DataSet constructor * disthelpers: added 'scipy.io' to supported modules (includes) * Added new "value_callback" display property: this function is called when QLineEdit text has changed (item value is passed) * Added option to pass a text formatting function in DataSetShowWidget ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_2.md0000644000175100017510000000544015114075001020443 0ustar00runnerrunner# Version 2 # ## Version 2.3.1 ## Bug fixes: * Fixed critical compatibility issue with Python 3.11 (`codeset` argument was removed from `gettext.translation` function) * Fixed support for `DateTimeItem` and `DateItem` objects serializing (HDF5 and JSON) * Fixed JSONReader constructor documentation: more explicit docstring * Fixed test_dataframeeditor.py test script (issue with QApplication creation) ## Version 2.3.0 ## Changes: * Added JSON serialize/deserialize support for `DataSet` objects (from CodraFT project, ) * Array editor: switching to read-only mode when array is not writeable * Object editor (`oedit` function): cleaner implementation, handling widget parenting (code specifically related to Spyder internal shell was removed) Bug fixes: * Array editor: fixed error when NumPy array flag "writeable" is False, do not try to change flag value since it's a deprecated feature since NumPy v1.17 * Do not install Qt translator and set color mode (dark/light) on Qt application if it already has been initialized (QApplication instance is not None) ## Version 2.2.1 ## Bug fixes: * Collection editor: fixed "Save array" feature * Console widget context menu: added missing icons ## Version 2.2.0 ## Changes: * FloatArrayItem: added data type information on associated widget * guitest.TestModule.run: added timeout argument to wait for process termination Bug fixes: * FloatArrayItem: avoid RuntimeWarning when dealing with complex data * external/darkdetect: fixed compatibility issue with Windows Server 2008 R2 ## Version 2.1.1 ## Bug fixes: * win32_fix_title_bar_background: not working in 32bits ## Version 2.1.0 ## Changes: * Dark mode may be overriden by QT_COLOR_MODE environment variable ## Version 2.0.4 ## Bug fixes: * Fixed missing import for DictItem callback ## Version 2.0.3 ## Changes: * Code editor: added support for other languages than Python (C++, XML, ...) Bug fixes: * Fixed Qt5 translation standard support * Fixed code editor/console widgets dark mode default settings ## Version 2.0.2 ## Bug fixes: * Fixed PySide6 compatibility issues * Fixed remaining Python 3 compatibility issues ## Version 2.0.1 ## Bug fixes: * Fixed Python 3 compatibility issues ## Version 2.0.0 ## Changes: * Removed support for Python 2.7 and PyQt4 (guidata supports Python >=3.6 and PyQt5, PySide2, PyQt6, PySide6 through QtPy 2) * Added support for dark theme mode on Windows (including windows title bar background), MacOS and GNU/Linux. * Added embbeded Qt-based Python console widget * Dataset edit layout: now disabling/enabling "Apply" button depending on widget value changes * Code editor: widget minimum size area may now be set using rows and columns size * Test launcher: redesigned, added support for dark mode ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.00.md0000644000175100017510000001270015114075001020657 0ustar00runnerrunner# Version 3.0 # ## Version 3.0.6 ## Bug fixes: * `widgets.console.interpreter`: replaced threading.Thread.isAlive (deprecated since Python 3.8) Other changes: * `DataSet.edit`, `DataSet.view` and `DataSetGroup.edit`: added missing arguments `size` and `wordwrap` * Documentation: added check-list before submitting a patch (see [`contribute.rst`](https://github.com/PlotPyStack/guidata/blob/master/doc/dev/contribute.rst) file) * Fixed some typing annotations and docstrings, as well as Pylint false positives * Removed unused functions from `guidata.utils.encoding` module: * `transcode` * `getfilesystemencoding` * Added missing docstrings and typing annotations in modules: * `guidata.dataset.qtitemwidgets` * `guidata.dataset.qtwidgets` * `guidata.utils.encoding` * `guidata.utils.misc` ## Version 3.0.5 ## Bug fixes: * [Issue #65](https://github.com/PlotPyStack/guidata/issues/65) - QVariant import erroneously used in typing annotations Other changes: * `tests.test_callbacks`: added an example of a callback function for dynamically changing the list of choices of a `ChoiceItem` object ## Version 3.0.4 ## Bug fixes: * [Issue #63](https://github.com/PlotPyStack/guidata/issues/63) - [3.0.2] there is no more guidata-test script * [Issue #62](https://github.com/PlotPyStack/guidata/issues/62) - [3.0.2] sphinx doc hang when building on the Debian infra Other changes: * [Issue #64](https://github.com/PlotPyStack/guidata/issues/64) - Add guidata-tests.desktop file to repository ## Version 3.0.3 ## Fixed project description: * This could be seen as a detail, but as this description text is used by PyPI, it is important to have a correct description. * Of course, nobody reads the description text, so it was not noticed since the first release of guidata v3.0. ## Version 3.0.2 ## Bug fixes: * [Pull Request #61](https://github.com/PlotPyStack/guidata/pull/61) - Make the build reproducible, by [@lamby](https://github.com/lamby) * [Issue #59](https://github.com/PlotPyStack/guidata/issues/59) - [3.0.1] the doc is missing * [Issue #60](https://github.com/PlotPyStack/guidata/issues/60) - [3.0.1] pyproject.toml/setuptools: automatic package discovery does not work on debian ## Version 3.0.1 ## API changes (fixes inconsistencies in API): * Moved `guidata.dataset.iniio.WriterMixin` to `guidata.dataset.io.WriterMixin` * Moved `guidata.dataset.iniio.BaseIOHandler` to `guidata.dataset.io.BaseIOHandler` * Moved `guidata.dataset.iniio` to `guidata.dataset.io.inifmt` and renamed: * `UserConfigIOHandler` to `INIHandler` * `UserConfigWriter` to `INIWriter` * `UserConfigReader` to `INIReader` * Moved `guidata.dataset.jsonio` to `guidata.dataset.io.jsonfmt` * Moved `guidata.dataset.hdf5io` to `guidata.dataset.io.h5fmt` Bug fixes: * [Issue #57](https://github.com/PlotPyStack/guidata/issues/57) - [Errno 2] No such file or directory: 'doc/dev/v2_to_v3.csv' * [Issue #58](https://github.com/PlotPyStack/guidata/issues/58) - Test suite: missing dependencies (pandas, Pillow) * Modules `guidata.dataset.datatypes` and `guidata.dataset.dataitems` should not critically depend on Qt (only modules specific to GUI should depend on Qt, such as `guidata.dataset.qtwidgets`). This was a regression introduced in version 3.0.0. A new unit test was added to prevent this kind of regression in the future. * Fixed documentation generation `.readthedocs.yaml` file (Qt 5.15 was not installed on ReadTheDocs servers, causing documentation build to fail) Other changes: * [Pull Request #55](https://github.com/PlotPyStack/guidata/pull/55) - DateItem and DateTimeItem: added 'format' parameter for formatting, by [@robochat](https://github.com/robochat) * Packaging: still using `setuptools`, switched from `setup.cfg` to `pyproject.toml` for configuration (see [PEP 517](https://www.python.org/dev/peps/pep-0517/)) ## Version 3.0.0 ## New major release: * New BSD 3-Clause License * Black code formatting on all Python files * New automated test suite: * Added module `guidata.env` to handle execution environment * Added support for an "unattended" execution mode (Qt loop is bypassed) * Added support for pytest fixtures * Added support for coverage testing: 70% coverage to date * Documentation was entirely rewritten using Sphinx * Reorganized modules: * Moved `guidata.hd5io` to `guidata.dataset.hdf5io` * Moved `guidata.jsonio` to `guidata.dataset.jsonio` * Renamed `guidata.userconfigio` to `guidata.dataset.iniio` * New package `guidata.utils` for utility functions: * Removed deprecated or unused functions in old `guidata.utils` module * Moved old `guidata.utils` module to `guidata.utils.misc`, except the functions `update_dataset` and `restore_dataset` which are still in `guidata.utils` (root module) * Moved `guidata.encoding` to `guidata.utils.encoding` * Moved `guidata.gettext_helpers` to `guidata.utils.gettext_helpers` * Splitted `guidata.qtwidgets` in two modules: * `guidata.widgets.dockable` for dockable widgets * `guidata.widgets.rotatedlabel` for rotated label * Other changes: * `guidata.guitest`: * Added support for subpackages * New comment directive (`# guitest: show`) to add test module to test suite or to show test module in test launcher (this replaces the old `SHOW = True` line) * `guidata.dataset.datatypes.DataSet`: new `create` class method for concise dataset creation, allowing to create a dataset with a single line of code by passing default item values as keyword arguments ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.01.md0000644000175100017510000000542715114075001020670 0ustar00runnerrunner# Version 3.1 # ## Version 3.1.1 ## 🛠️ Bug fixes: * 'Apply' button state is now correctly updated when modifying one of the following items: * `dataset.MultipleChoiceItem` * `dataset.dataitems.DictItem` * `dataset.dataitems.FloatArrayItem` * Fixed minor deprecation and other issues related to locale 💥 Changes: * Removed `--unattended` command line option for `pytest`: * Before: `pytest --unattended guidata` (to run tests without Qt event loop) * Now: `pytest guidata` (there is no use case for running tests with Qt event loop, so the `--unattended` option was removed and the *unattended* mode is now the default) * Removed CHM documentation (obsolete format) ## Version 3.1.0 ## ⚠ Exceptionally, this release contains the following API breaking changes: * Moved `utils.update_dataset` to `dataset.conv.update_dataset` * Moved `utils.restore_dataset` to `dataset.conv.restore_dataset` ✔ API simplification (backward compatible): * Dataset items may now be imported from `guidata.dataset` instead of `guidata.dataset.dataitems` * Dataset types may now be imported from `guidata.dataset` instead of `guidata.dataset.datatypes` * Examples: * `from guidata.dataset.dataitems import FloatItem` becomes `from guidata.dataset import FloatItem` * `from guidata.dataset.datatypes import DataSet` becomes `from guidata.dataset import DataSet` * Or you may now write: ```python import guidata.dataset as gds class MyParameters(gds.DataSet): """My parameters""" freq = gds.FloatItem("Frequency", default=1.0, min=0.0, nonzero=True) amp = gds.FloatItem("Amplitude", default=1.0, min=0.0) ``` 💥 New features: * New `dataset.create_dataset_from_dict`: create a dataset from a dictionary, using keys and values to create the dataset items * New `dataset.create_dataset_from_func`: create a dataset from a function signature, using type annotations and default values to create the dataset items * `dataset.dataitems.StringItem`: * Added argument `password` to hide text (useful for passwords) * Added argument `regexp` to validate text using a regular expression * `dataset.dataitems.FileSaveItem`, `dataset.dataitems.FileOpenItem`, `dataset.dataitems.FilesOpenItem` and `dataset.dataitems.DirectoryItem`: added argument `regexp` to validate file/dir name using a regular expression * `dataset.dataitems.DictItem`: added support for HDF5 and JSON serialization * `dataset.io.h5fmt` and `dataset.io.jsonfmt`: added support for lists and dictionnaries serialization ♻ New PlotPyStack internal features: * `widgets.about`: handle about dialog box informations (Python, Qt, Qt bindings, ...) * Renamed development environment variable `GUIDATA_PYTHONEXE` to `PPSTACK_PYTHONEXE` 🧹 Bug fixes: * Fixed Qt6 compatibility issue with `QFontDatabase` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.02.md0000644000175100017510000000543015114075001020663 0ustar00runnerrunner# Version 3.2 # ## Version 3.2.2 ## 🛠️ Bug fixes: * Fixed translation support (`gettext`): * Locale detection has been fixed in 3.1.1 (deprecation of `locale.getdefaultlocale`) * However, on frozen distributions on Windows (e.g. with `pyinstaller`), function `locale.getlocale` is returning `(None, None)` instead of proper locale infos * Added a workaround: on Windows, if locale can't be detected, we now use the Windows API to retrieve it (using the `GetUserDefaultLocaleName` function) * [Issue #68](https://github.com/PlotPyStack/guidata/issues/68) - Windows: gettext translation is not working on frozen applications * Embedded Qt console: * Fixed default encoding detection on frozen applications on Windows * [Issue #69](https://github.com/PlotPyStack/guidata/issues/69) - Windows/Qt console: output encoding is not detected on frozen applications ## Version 3.2.1 ## 🛠️ Bug fixes: * Tests only: `qthelpers.close_widgets_and_quit` now ignores deleted widgets 💥 Changes: * `dataset.ImageChoiceItem` and `dataset.ButtonItem`: added `size` argument to set the icon size * `dataset.io` reader and writer classes: removed deprecated `write_unicode` method ## Version 3.2.0 ## 🛠️ Bug fixes: * [Issue #67](https://github.com/PlotPyStack/guidata/issues/67) - JSONReader/Deserializing object list: TypeError: 'NoneType' object is not subscriptable 💥 Changes: * `qthelpers.qt_wait`: added `show_message` and `parent` arguments (backward compatible) * `qthelpers.qt_app_context`: removed `faulthandler` support (this need to be handled at the application level, see for example [DataLab's implementation](https://github.com/Codra-Ingenierie-Informatique/DataLab/blob/2a7e95477a8dfd827b037b39ef5e045309760dc8/cdlapp/utils/qthelpers.py#L87)) * Disabled command line argument parsing in `guidata.env` module: * The `guidata` library is parsing command line arguments for the purpose of creating the environment execution object named `execenv` (see `guidata.env` module). This object is used to determine the execution environment mainly for testing purposes: for example, to bypass the Qt event loop when running tests thanks to the `--unattended` command line option. * However this argument parsing is not always desirable, for example when using `guidata` as a dependency in another library or application. This is why the parsing mechanism is now disabled by default, and may be enabled by setting the environment variable `GUIDATA_PARSE_ARGS` to `1` (or any other non-empty value). As of today, it is still unclear if there will be a need to enable this mechanism in the future, so this is why the environment variable is used instead of a function argument. * Removed deprecated `guidata.disthelpers` module (we recommend using [PyInstaller](https://www.pyinstaller.org/) instead) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.03.md0000644000175100017510000000341615114075001020666 0ustar00runnerrunner# Version 3.3 # ## Version 3.3.0 ## In this release, test coverage is 72%. 💥 New features: * Array editor now supports row/column insertion/deletion: * Added `variable_size` argument to `setup_and_check` method * The feature is disabled by default (backward compatible) * It supports standard arrays, masked arrays, record arrays and N-dimensional arrays * New dataset read-only mode: * Added `readonly` argument to `DataSet` constructor * This is useful to create a dataset that will be displayed in read-only mode (e.g. string editing widgets will be in read-only mode: text will be selectable but not editable) * The items remain modifiable programmatically (e.g. `dataset.item = 42`) * New dataset group edit mode: * Added `mode` argument to `DataSetGroup.edit` method, with the following options: * `mode='tabs'` (default): each dataset is displayed in a separate tab * `mode='table'`: all datasets are displayed in a single table * In the new table mode, the datasets are displayed in a single table with one row per dataset and one column per item * Clicking on a row will display the corresponding dataset in a modal dialog box 🛠️ Bug fixes: * Qt console: * Fixed `RuntimeError: wrapped C/C++ object of type DockableConsole has been deleted` when closing the console widget (parent widget, e.g. a `QMainWindow`, was deleted) while an output stream is still writing to the console (e.g. a `logging` handler which will flush the output stream when closing the application) * This concerns all console-related widgets: `DockableConsole`, `Console`, `InternalShell`, `PythonShellWidget` and `ShellBaseWidget` * Code editor: fixed compatibility issue with PySide6 (`AttributeError: 'QFont' object has no attribute 'Bold'`) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.04.md0000644000175100017510000000462315114075001020670 0ustar00runnerrunner# Version 3.4 # ## Version 3.4.1 ## In this release, test coverage is 76%. 🛠️ Bug fixes: * [Issue #71](https://github.com/PlotPyStack/guidata/issues/71) - Random segmentation faults with applications embedding `CodeEditor` * [Issue #70](https://github.com/PlotPyStack/guidata/issues/70) - PermissionError: [Errno 13] Permission denied: '/usr/lib/python3/dist-packages/guidata/tests/data/genreqs/requirements.rst' ## Version 3.4.0 ## In this release, test coverage is 76%. 💥 New features: * `dataset.io.h5fmt.HDF5Reader.read` method: added new `default` argument to set default value for missing data in the HDF5 file (backward compatible). The default value of `default` is `NoDefault` (a special value to indicate that no default value should be used, and that an exception should be raised if the data is missing). * `widgets.codeeditor.CodeEditor`: added new `inactivity_timeout` argument to set the time (in milliseconds) to wait after the user has stopped typing before emitting the `CodeEditor.SIG_EDIT_STOPPED` signal. * Added `execenv.accept_dialogs` attribute to control whether dialogs should be automatically accepted or not (default is `None`, meaning no automatic acceptance): this allows more coverage of the test suite. For now, this attribute has only been proven useful in `tests/dataset/test_all_features.py`. * Added unit tests for HDF5 and JSON serialization/deserialization: * Testing an arbitrary data model saved/loaded to/from HDF5 and JSON files, with various data sets and other data types. * Testing for backward compatibility with previous versions of the data model (e.g. new attributes, removed attributes, etc.) ⚠️ API breaking changes: * `guidata.dataset.io` module is now deprecated and will be removed in a future release. Please use `guidata.io` instead. This change is backward compatible (the old module is still available and will be removed in a future release). The motivation for this change is to simplify the module structure and to help understand that the scope of the `io` module is not limited to `dataset.DataSet` objects, but may be used for any kind of data serialization/deserialization. 📖 Documentation: * Added missing `DataSetEditDialog` and `DataSetEditLayout` classes * Added missing inheritance/member details on some classes * Reduced table of contents depth in left sidebar for better readability ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.05.md0000644000175100017510000000716315114075001020673 0ustar00runnerrunner# Version 3.5 # ## Version 3.5.3 ## In this release, test coverage is 74%. 🛠️ Bug fixes: * Configuration initialization on Windows: * For various reasons, a `PermissionError` exception may be raised when trying to remove the configuration file on Windows, just after having created it for the first time. This is due to the fact that the file is still locked by the file system, even if the file has been closed. This is a known issue with Windows file system, and the solution is to wait a little bit before trying to remove the file. * To fix this issue, a new `try_remove_file` function has been added to the `userconfig` module, which tries multiple times to remove the file before raising an exception. * Moved back `conftest.py` to the `tests` folder (was in the root folder), so that `pytest` can be executed with proper configuration when running the test suite from the installed package ## Version 3.5.2 ## In this release, test coverage is 74%. 🛠️ Bug fixes: * Add support for NumPy 2.0: * Use `numpy.asarray` instead of `numpy.array(..., copy=False)` * Remove deprecated `numpy.core.multiarray` module import ## Version 3.5.1 ## In this release, test coverage is 74%. 🛠️ Bug fixes: * [PR #74](https://github.com/PlotPyStack/guidata/pull/74) - `configtools.font_is_installed`: fix PySide2 compat. issue (thanks to @xiaodaxia-2008) * Creating a dataset using the `create` class method: * Before, passing unknown keyword arguments failed silently (e.g. `MyParameters.create(unknown=42)`). * Now, an `AttributeError` exception is raised when passing unknown keyword arguments, as expected. * Processing Qt event loop in unattended mode before closing widgets and quitting the application, so that all pending events are processed before quitting: this includes for instance the drawing events of widgets, which may be necessary to avoid a crash when closing the application (e.g. if drawing the widget is required for some reason before closing it) or at least to ensure that test coverage includes all possible code paths. ℹ️ Other changes: * Preparing for NumPy V2 compatibility: this is a work in progress, as NumPy V2 is not yet released. In the meantime, requirements have been updated to exclude NumPy V2. * Internal package reorganization: moved icons to `guidata/data/icons` folder * The `delay` command line option for environment execution object `execenv` is now expressed in milliseconds (before it was in seconds), for practical reasons * Explicitely exclude NumPy V2 from the dependencies (not compatible yet) ## Version 3.5.0 ## In this release, test coverage is 74%. 💥 New features: * New Sphinx autodoc extension: * Allows to document dataset classes and functions using Sphinx directives, thus generating a comprehensive documentation for datasets with labels, descriptions, default values, etc. * The extension is available in the `guidata.dataset.autodoc` module * Directives: * `autodataset`: document a dataset class * `autodataset_create`: document a dataset creation function * `datasetnote`: add a note explaining how to use a dataset * `BoolItem`/`TextItem`: add support for callbacks when the item value changes 🛠️ Bug fixes: * Documentation generation: automatic requirement table generation feature was failing when using version conditions in the `pyproject.toml` file (e.g. `pyqt5 >= 5.15`). * [Issue #72](https://github.com/PlotPyStack/guidata/issues/72) - unit test leave files during the build usr/lib/python3/dist-packages/test.json * [Issue #73](https://github.com/PlotPyStack/guidata/issues/73) - `ChoiceItem` radio buttons are duplicated when using callbacks ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.06.md0000644000175100017510000000526515114075001020675 0ustar00runnerrunner# Version 3.6 # ## Version 3.6.3 ## In this release, test coverage is 74%. 💥 New features: * MultipleChoiceItem: implemented `callback` property feature (was unexpectedly not supported) 🛠️ Bug fixes: * [Issue #78](https://github.com/PlotPyStack/guidata/issues/78) - PySide6 on Linux: `AttributeError: 'DataFrameView' object has no attribute 'MoveLeft'` * [Issue #77](https://github.com/PlotPyStack/guidata/issues/77) - PyQt6/PySide6 on Linux: `AttributeError: type object 'PySide6.QtGui.QPalette' has no attribute 'Background'` * Add 'Monospace' and 'Menlo' to the list of fixed-width supported fonts * Font warning message in *configtools.py*: replace `print` by `warnings.warn` ## Version 3.6.2 ## In this release, test coverage is 74%. 🛠️ Bug fixes: * Light/dark theme support: * Fix default color mode issues * Color theme test: allow to derive from, so that the test may be completed by other widgets ## Version 3.6.1 ## In this release, test coverage is 74%. 🛠️ Bug fixes: * Light/dark theme support: * Auto light/dark theme: quering OS setting only once, or each time the `set_color_mode('auto')` function is called * Fix console widget color theme: existing text in console widget was not updated when changing color theme * Fixed issue with dark theme on Windows: the windows title bar background was not updated when the theme was changed from dark to light (the inverse was working) - this is now fixed in `guidata.qthelpers.win32_fix_title_bar_background` function * Added `guidata.qthelpers.set_color_mode` function to set the color mode ('dark', 'light' or 'auto' for system default) * Added `guidata.qthelpers.get_color_mode` function to get the current color mode ('dark', 'light' or 'auto' for system default) * Added `guidata.qthelpers.get_color_theme` function to get the current color theme ('dark' or 'light') * Added `guidata.qthelpers.get_background_color` function to get the current background `QColor` associated with the current color theme * Added `guidata.qthelpers.get_foreground_color` function to get the current foreground `QColor` associated with the current color theme * Added `guidata.qthelpers.is_dark_theme` function to check if the current theme is dark) * As a consequence, `guidata.qthelpers.is_dark_mode` and `guidata.qthelpers.set_dark_mode` functions are deprecated, respectively in favor of `guidata.qthelpers.is_dark_theme` and `guidata.qthelpers.set_color_mode` ## Version 3.6.0 ## In this release, test coverage is 74%. 💥 New features: * Improved dark/light mode theme update: * The theme mode may be changed during the application lifetime * Added methods `update_color_mode` on `CodeEditor` and `ConsoleBaseWidget` widgets ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.07.md0000644000175100017510000000037415114075001020672 0ustar00runnerrunner# Version 3.7 # ## Version 3.7.1 ## ℹ️ Changes: * Fixed `ResourceWarning: unclosed file` on some platforms (e.g. CentOS Stream 8). * Update GitHub Actions to use setup-python@v5 and checkout@v4 ## Version 3.7.0 ## Drop support for Python 3.8. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.08.md0000644000175100017510000000240215114075001020665 0ustar00runnerrunner# Version 3.8 # ## Version 3.8.0 ## ℹ️ Changes: * `utils.gettext_helpers`: * `do_rescan_files`: use `--no-location` option to avoid including the file location in the translation files * `msgmerge`: use `--update` option to avoid regenerating the translation files * Replace `flake8` with `ruff` for linting in GitHub Actions workflow 🛠️ Bug fixes: * [Issue #84](https://github.com/PlotPyStack/guidata/issues/84) - Side effects of `win32_fix_title_bar_background` with `QGraphicsEffect` active * [Issue #82](https://github.com/PlotPyStack/guidata/issues/82) - Autodoc extension: translation of generic documentation text * Initially, the generic documentation text like "Returns a new instance of" was translated using the `gettext` function. * This was a mistake, as this text should be translated only after the documentation has been generated, i.e. by the `sphinx-intl` tool. * In other words, translating those generic texts should be done in the application documentation, not in the library itself. * To fix this issue, the generic documentation text is no longer translated using `gettext`, but is left as is in the source code. * [Issue #80](https://github.com/PlotPyStack/guidata/issues/80) - `ValueError` when trying to show/edit an empty array ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.09.md0000644000175100017510000000066415114075001020676 0ustar00runnerrunner# Version 3.9 # ## Version 3.9.0 ## 💥 New features: * [Issue #87](https://github.com/PlotPyStack/guidata/issues/87) - Array editor: add an option to paste data (Ctrl+V) * [Issue #85](https://github.com/PlotPyStack/guidata/issues/85) - Array editor: add a button to export data as CSV * [Issue #86](https://github.com/PlotPyStack/guidata/issues/86) - Array editor: add "Copy all" feature for copying array and headers to clipboard ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.10.md0000644000175100017510000000340015114075001020655 0ustar00runnerrunner# Version 3.10 # ## Version 3.10.0 ## 💥 New features: * [Issue #81](https://github.com/PlotPyStack/guidata/issues/81) - Modernize the internationalization utilities * The `guidata.utils.gettext_helpers` module, based on the `gettext` module, has been deprecated. * It has been replaced by a new module `guidata.utils.translations`, which provides a more modern and flexible way to handle translations, thanks to the `babel` library. * This change introduces a new script for managing translations, which may be used as follows: * Scan for new translations: * `python -m guidata.utils.translations scan --name --directory ` * or `guidata-translations scan --name --directory ` * Compile translations: * `python -m guidata.utils.translations compile --name --directory ` * or `guidata-translations compile --name --directory ` * More options are available, see the help message of the script: * `python -m guidata.utils.translations --help` * or `guidata-translations --help` 🛠️ Bug fixes: * [Issue #88](https://github.com/PlotPyStack/guidata/issues/88) - `DictItem` default value persists across dataset instances (missing `deepcopy`) * This issue is as old as the `DictItem` class itself. * When using a `DictItem` in a dataset, if a value is set to the item instance, this value was incorrectly used as the default for the next instance of the same dataset class. * This happened because a `deepcopy` was not made when setting the defaults of the class items in `guidata.dataset.datatypes`. * The fix ensures that each dataset instance has its own independent default value for `DictItem`, preventing side effects from one instance to another. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.11.md0000644000175100017510000000055315114075001020664 0ustar00runnerrunner# Version 3.11 # ## Version 3.11.0 ## 💥 New features: * New `utils.genreqs` module for generating installation requirements files: * Function `generate_requirements_txt` generates a `requirements.txt` file * Function `generate_requirements_rst` generates a `requirements.rst` file * The module is used by the new command line script `guidata-genreqs` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.12.md0000644000175100017510000000714315114075001020667 0ustar00runnerrunner# Version 3.12 # ## Version 3.12.1 ## 🛠️ Bug fixes: * [Issue #92](https://github.com/PlotPyStack/guidata/issues/92) - Changing base class order in DataSet inheritance does not affect item order as expected * When using multiple inheritance with DataSet classes, changing the order of the base classes in the child class definition did not affect the order of the items in the resulting dataset. * One would expect the items to appear in the order in which the base classes are listed, and thanks to this fix, this is now the case. ## Version 3.12.0 ## 💥 New features: * New operator property `FuncPropMulti` for handling multiple properties: * This property allows you to apply a function to multiple item properties at once. * It can be used to create more complex dependencies between items in a dataset. * See the `guidata.tests.dataset.test_activable_items` module for an example of usage. * New script `gbuild` for building the package: * This script is a wrapper around the `guidata.utils.securebuild` module, which ensures that the build process is secure and reproducible. * It checks that the `pyproject.toml` file is present in the root of the repository, and that it is committed to Git. * It also ensures that the build process is reproducible by using a temporary directory for the build artifacts. * New `qt_wait_until` function (`guidata.qthelpers`) for waiting until a condition is met: * This function allows you to wait for a specific condition to be true, while still processing Qt events. * It can be useful in situations where you need to wait for a background task to complete or for a specific UI state to be reached. * Renamed scripts associated to `guidata.utils.translations` and `guidata.utils.genreqs` modules: * `guidata-translations` is now `gtrans` * `guidata-genreqs` is now `greqs` 🛠️ Bug fixes: * [Issue #90](https://github.com/PlotPyStack/guidata/issues/90) - `BoolItem`: Fix checkbox state management in `qtitemwidgets` * Before this fix, the checkbox state was not correctly managed when the item's active state changed. * In other words, when using `set_prop("display", active=` on `BoolItem`, the checkbox was not updated. * The checkbox state is now correctly managed based on the item's active state. * This fixes a regression introduced in version 3.3.0 with the new dataset read-only mode feature. * Requirements generation scripts (`greqs` or `python -m guidata.utils.genreqs`): * Before this fix, strict superior version requirements (e.g. `pyqt5 > 5.15`) were skipped in the generated requirements files (with a warning message). * Now, these strict superior version requirements are included but the version is not specified (e.g. `pyqt5` instead of `pyqt5 > 5.15`). * A warning message is still displayed to inform the user that the version is not specified. * Issue with automated test suite using `exec_dialog`: * The `exec_dialog` function was not properly handling the dialog closure in automated tests. * This could lead to unexpected behavior and side effects between tests. * The fix ensures that all pending Qt events are processed before scheduling the dialog closure. * This avoids the necessity to use timeouts in tests, which can lead to flaky tests. ℹ️ Other changes: * Updated dependencies following the latest security advisories (NumPy >= 1.22) * Added `pre-commit` hook to run `ruff` (both `ruff check` and `ruff format`) on commit * Added missing `build` optional dependency to development dependencies in `pyproject.toml` * Visual Studio Code tasks: * Major overhaul (cleanup and simplification) * Removal of no longer used batch files ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/release_notes/release_3.13.md0000644000175100017510000006730415114075001020675 0ustar00runnerrunner # Version 3.13 # ## Version 3.13.4 (2025-12-03) ## 🛠️ Bug fixes: * **BoolItem numpy compatibility**: Fixed `numpy.bool_` type conversion issue * `BoolItem` now ensures all assigned values are converted to Python `bool` type * Added `__set__` override to convert `numpy.bool_` values to native Python `bool` * Fixes compatibility issues with Qt APIs that strictly require Python `bool` (e.g., `QAction.setChecked()`) * Prevents `TypeError: setChecked(self, a0: bool): argument 1 has unexpected type 'numpy.bool'` * Affects applications using `BoolItem` values with Qt widgets after HDF5 deserialization * Maintains backward compatibility as `bool(bool)` is a no-op * This closes [Issue #96](https://github.com/PlotPyStack/guidata/issues/96) - `BoolItem`: `numpy.bool_` compatibility fix * Fix documentation build error due to the fact that Qt is needed for some parts of the building process ## guidata Version 3.13.3 (2025-11-10) ## 🛠️ Bug fixes: * **ButtonItem callbacks**: Fixed critical regression breaking callbacks with 4 parameters * In v3.13.2, the auto-apply feature unconditionally passed a 5th parameter (`trigger_auto_apply`) to all ButtonItem callbacks * This broke all existing callbacks expecting only 4 parameters (instance, item, value, parent) * Now uses `inspect.signature()` to check callback parameter count at runtime * Callbacks with fewer than 5 parameters receive only the standard 4 arguments * Callbacks with 5+ parameters receive the additional `trigger_auto_apply` function * Maintains full backward compatibility while supporting the new auto-apply feature * Fixes `TypeError: callback() takes 4 positional arguments but 5 were given` ## guidata Version 3.13.2 (2025-11-03) ## ✨ New features: * **DataSet setter methods**: Added public setter methods for title, comment, and icon * New `set_title()` method: Sets the DataSet's title * New `set_comment()` method: Sets the DataSet's comment * New `set_icon()` method: Sets the DataSet's icon * These methods provide a clean public API to modify DataSet metadata previously stored in private attributes * Useful for applications that need to dynamically update DataSet metadata programmatically * **Auto-apply for DictItem and FloatArrayItem in DataSetEditGroupBox**: Improved user experience when editing dictionaries and arrays * When a `DictItem` or `FloatArrayItem` is modified within a `DataSetEditGroupBox` (with an Apply button), changes are now automatically applied when the editor dialog is validated * Previously, users had to click "Save & Close" in the dictionary/array editor, then click the "Apply" button in the dataset widget layout * Now, clicking "Save & Close" automatically triggers the apply action, making changes immediately effective * Implementation: The auto-apply trigger function is passed as an optional 5th parameter to button callbacks * This behavior only applies to dataset layouts with an Apply button (DataSetEditGroupBox), not to standalone editors * Provides more intuitive workflow and reduces the number of clicks required to apply changes * Affects both `DictItem` (dictionary editor) and `FloatArrayItem` (array editor) 🛠️ Bug fixes: * **Git report utility**: Fixed UnicodeDecodeError on Windows when commit messages contain non-ASCII characters * The `guidata.utils.gitreport` module now explicitly uses UTF-8 encoding when reading Git command output * Previously, on Windows systems with cp1252 default encoding, Git commit messages containing Unicode characters (emoji, accented characters, etc.) would cause a `UnicodeDecodeError` * Fixed by adding `encoding="utf-8"` parameter to all `subprocess.check_output()` calls in `_extract_git_information()` * This ensures proper decoding of Git output which is always UTF-8 encoded, regardless of the system's default encoding * **DataSet.to_html()**: Improved color contrast for dark mode * Changed title and comment color from standard blue (#0000FF) to a lighter shade (#5294e2) * Provides better visibility in dark mode while maintaining good appearance in light mode * Affects the HTML representation of DataSets displayed in applications with dark themes * **ChoiceItem validation**: Fixed tuple/list equivalence during JSON deserialization * When a `ChoiceItem` has tuple values (e.g., `((10, 90), "10% - 90%")`), JSON serialization converts tuples to lists * During deserialization, validation failed because `[10, 90]` was not recognized as equivalent to `(10, 90)` * Modified `ChoiceItem.check_value()` to compare sequence contents when both the value and choice are sequences (list/tuple) * This ensures that ChoiceItems with tuple values work correctly with `dataset_to_json()`/`json_to_dataset()` round-trips * Added regression test in `test_choice_tuple_serialization.py` * Fix the `AboutInfo.about` method: renamed parameter `addinfos` to `addinfo` for consistency ## guidata Version 3.13.1 (2025-10-28) ## 🛠️ Bug fixes: * **DataSet.to_string()**: Fixed missing labels for BoolItem when only text is provided * When a `BoolItem` is defined with only the `text` parameter (first argument) and no explicit `label` parameter (second argument), the label was displayed as empty in `to_string()` output, resulting in `: ☐` or `: ☑` instead of the expected `Item text: ☐` * Added fallback logic to use the `text` property as label when `label` is empty, matching the behavior already implemented in `to_html()` * This ensures consistency between text and HTML representations of DataSets containing BoolItems * **Qt scraper**: Fixed thumbnail generation for sphinx-gallery examples in subdirectories * The `qt_scraper` now correctly detects and handles examples organized in subsections (e.g., `examples/features/`, `examples/advanced/`) * Thumbnails are now saved in the correct subdirectory-specific `images/thumb/` folders instead of the top-level directory * Image paths in generated RST files now include the subdirectory path * Added new `_get_example_subdirectory()` helper function to extract subdirectory from source file path and avoid code duplication ## guidata Version 3.13.0 (2025-10-24) ## ✨ New features: * **JSON Serialization for DataSets**: Added new functions for serializing/deserializing DataSet objects to/from JSON: * New `dataset.dataset_to_json()` function: Serialize a DataSet instance to a JSON string * New `dataset.json_to_dataset()` function: Deserialize a JSON string back to a DataSet instance * The JSON format includes class module and name information for automatic type restoration * Enables easy data interchange, storage, and transmission of DataSet configurations * Example usage: ```python from guidata.dataset import dataset_to_json, json_to_dataset # Serialize to JSON json_str = dataset_to_json(my_dataset) # Deserialize from JSON restored_dataset = json_to_dataset(json_str) ``` * **DataSet Class-Level Configuration**: Added support for configuring DataSet metadata at the class definition level using `__init_subclass__`: * DataSet title, comment, icon, and readonly state can now be configured directly in the class inheritance declaration * Uses Python's standard `__init_subclass__` mechanism (PEP 487) for explicit, type-safe configuration * Configuration is embedded in the class definition, making it impossible to accidentally remove or forget * Instance parameters can still override class-level settings for flexibility * **Improved docstring handling**: When title is explicitly set (even to empty string), the entire docstring becomes the comment * Backward compatibility: When no title is set at all, docstring first line is still used as title (old behavior) * Example usage: ```python class MyParameters(DataSet, title="Analysis Parameters", comment="Configure your analysis options", icon="params.png"): """This docstring is for developer documentation only""" threshold = FloatItem("Threshold", default=0.5) method = StringItem("Method", default="auto") # No need to pass title when instantiating params = MyParameters() # Can still override at instance level if needed params_custom = MyParameters(title="Custom Title") ``` * Priority order: instance parameter > class-level config > empty/default * Makes it explicit when title is intentionally set vs. accidentally left empty * Improves code clarity by separating user-facing metadata from developer documentation * **SeparatorItem**: Added a new visual separator data item for better dataset organization: * New `SeparatorItem` class allows inserting visual separators between sections in datasets * In textual representation, separators appear as a line of dashes (`--------------------------------------------------`) * In GUI dialogs, separators display as horizontal gray lines spanning the full width * Separators don't store any data - they are purely visual elements for organizing forms * Example usage: ```python class PersonDataSet(DataSet): name = StringItem("Name", default="John Doe") age = IntItem("Age", default=30) # Visual separator with label sep1 = SeparatorItem("Contact Information") email = StringItem("Email", default="john@example.com") phone = StringItem("Phone", default="123-456-7890") # Visual separator without label sep2 = SeparatorItem() notes = StringItem("Notes", default="Additional notes") ``` * Improves readability and visual organization of complex datasets * Fully integrated with existing DataSet serialization/deserialization (separators are ignored during save/load) * Compatible with both edit and show modes in dataset dialogs * **Computed Items**: Added support for computed/calculated data items in datasets: * New `ComputedProp` class allows defining items whose values are automatically calculated from other items * Items can be marked as computed using the `set_computed(method_name)` method * Computed items are automatically read-only and update in real-time when their dependencies change * Example usage: ```python class DataSet(gdt.DataSet): def compute_sum(self) -> float: return self.x + self.y x = gdt.FloatItem("X", default=1.0) y = gdt.FloatItem("Y", default=2.0) sum_xy = gdt.FloatItem("Sum", default=0.0).set_computed(compute_sum) ``` * Computed items automatically display with visual distinction (neutral background color) in GUI forms * Supports complex calculations and can access any other items in the dataset * **Improved Visual Distinction for Read-only Fields**: Enhanced user interface to clearly identify non-editable fields: * Read-only text fields now display with a subtle gray background and darker text color * Visual styling automatically adapts to your theme (light or dark mode) * Applies to computed fields, locked parameters, and any field marked as read-only * Makes it immediately clear which fields you can edit and which are display-only * Validation errors are still highlighted with orange background when they occur * **DataSet HTML Export**: Added HTML representation method for datasets: * New `to_html()` method on `DataSet` class generates HTML representation similar to Sigima's TableResult format * Features blue-styled title and comment section derived from the dataset's docstring * Two-column table layout with right-aligned item names and left-aligned values * Special handling for `BoolItem` with checkbox characters (☑ for True, ☐ for False) * Monospace font styling for consistent alignment and professional appearance * Proper handling of None values (displayed as "-") and nested ObjectItem datasets * Example usage: ```python class PersonDataSet(DataSet): """Personal Information Dataset This dataset collects basic personal information. """ name = StringItem("Full Name", default="John Doe") age = IntItem("Age", default=30) active = BoolItem("Account Active", default=True) dataset = PersonDataSet() html_output = dataset.to_html() # Generate HTML representation ``` * Ideal for reports, documentation, and web-based dataset visualization * Comprehensive unit test coverage ensures reliability across all item types * `guidata.configtools.get_icon`: * This function retrieves a QIcon from the specified image file. * Now supports Qt standard icons (e.g. "MessageBoxInformation" or "DialogApplyButton"). * Removed `requirements-min.txt` generation feature from `guidata.utils.genreqs`: * The minimal requirements feature was causing platform compatibility issues when specific minimum versions weren't available on all platforms (e.g., `SciPy==1.7.3` works on Windows but fails on Linux) * Removed `__extract_min_requirements()` function and `--min` CLI flag * The `genreqs` tool now only generates `requirements.txt` and `requirements.rst` files * Updated documentation and MANIFEST.in files to remove references to `requirements-min.txt` * Added a `readonly` parameter to `StringItem` and `TextItem` in `guidata.dataset.dataitems`: * This allows these items to be set as read-only, preventing user edits in the GUI. * The `readonly` property is now respected in the corresponding widgets (see `guidata.dataset.qtitemwidgets`). * Example usage: ```python text = gds.TextItem("Text", default="Multi-line text", readonly=True) string = gds.StringItem("String", readonly=True) ``` * Note: Any other item type can also be turned into read-only mode by using `set_prop("display", readonly=True)`. This is a generic mechanism, but the main use case is for `StringItem` and `TextItem` (hence the dedicated input parameter for convenience). * [Issue #94](https://github.com/PlotPyStack/guidata/issues/94) - Make dataset description text selectable * New `guidata.utils.cleanup` utility: * Added a comprehensive repository cleanup utility similar to `genreqs` and `securebuild` * Provides both programmatic API (`from guidata.utils.cleanup import run_cleanup`) and command-line interface (`python -m guidata.utils.cleanup`) * Automatically detects repository type and cleans Python cache files, build artifacts, temporary files, coverage data, backup files, and empty directories * Features comprehensive Google-style docstrings and full typing annotations * Cross-platform compatible with proper logging and error handling * Can be integrated into project workflows via VSCode tasks or build scripts * New validation modes for `DataItem` objects: * Validation modes allow you to control how `DataItem` values are validated when they are set. * `ValidationMode.DISABLED`: no validation is performed (default behavior, for backward compatibility) * `ValidationMode.ENABLED`: validation is performed, but warnings are raised instead of exceptions * `ValidationMode.STRICT`: validation is performed, and exceptions are raised if the value is invalid * To use these validation modes, you need to set the option: ```python from guidata.config import set_validation_mode, ValidationMode set_validation_mode(ValidationMode.STRICT) ``` * New `check_callback` parameter for `FloatArrayItem`: * The `check_callback` parameter allows you to specify a custom validation function for the item. * This function will be called to validate the item's value whenever it is set. * If the function returns `False`, the value will be considered invalid. * New `allow_none` parameter for `DataItem` objects: * The `allow_none` parameter allows you to specify whether `None` is a valid value for the item, which can be especially useful when validation modes are used. * If `allow_none` is set to `True`, `None` is considered a valid value regardless of other constraints. * If `allow_none` is set to `False`, `None` is considered an invalid value. * The default value for `allow_none` is `False`, except for `FloatArrayItem`, `ColorItem` and `ChoiceItem` and its subclasses, where it is set to `True` by default. * Enhanced default value handling for `DataItem` objects: * Default values can now be `None` even when `allow_none=False` is set on the item. * This allows developers to use `None` as a sensible default value while still preventing users from setting `None` at runtime. * This feature provides better flexibility for data item initialization without compromising runtime validation. * The implementation uses a clean internal architecture that separates default value setting from regular value setting, maintaining the standard Python descriptor protocol. * Improved type handling in `IntItem` and `FloatItem`: * `IntItem` and `FloatItem` now automatically convert NumPy numeric types (like `np.int32` or `np.float64`) to native Python types (`int` or `float`) during validation * `FloatItem` now accepts integer values and silently converts them to float values * This makes it easier to use these items with NumPy arrays and other numeric libraries * `ChoiceItem` now supports `Enum` subclasses: * You can now use `Enum` subclasses as choices for `ChoiceItem` and its subclasses. * The enum members will be displayed in the UI, and their values will be used for validation. * Valid values for the item may be one of the following: * The members themselves (as enum instances) - this is the recommended usage for setting values as it corresponds to the value returned by the item * The names of the enum members (as strings) * The index of the enum member (as an integer) * **LabeledEnum with seamless interoperability**: Enhanced the `LabeledEnum` class to provide true seamless interoperability between enum members and their string values: * Added `__eq__` and `__hash__` methods that enable bidirectional equality: `enum_member == "string_value"` and `"string_value" == enum_member` both work correctly * Functions can now seamlessly accept both enum members and string values: `process(EnumType.VALUE)` works identically to `process("value")` * Set operations correctly deduplicate enum members and their corresponding strings: `{EnumType.VALUE, "value"}` has length 1 * This enables API flexibility while maintaining type safety, allowing users to pass either enum instances or their string representations interchangeably * Example usage: ```python class ProcessingMode(LabeledEnum): FAST = ("fast_mode", "Fast Processing") ACCURATE = ("accurate_mode", "Accurate Processing") def process_data(mode): if mode == ProcessingMode.FAST: # Works with both enum and string return "fast_processing" # ... # Both calls work identically: result1 = process_data(ProcessingMode.FAST) # Using enum result2 = process_data("fast_mode") # Using string # result1 == result2 is True! ``` * `StringItem` behavior change: * The `StringItem` class now uses the new validation modes (see above). * As a side effect, the `StringItem` class now considers `None` as an invalid default value, and highlights it in the UI. * **DataFrame Editor:** * Read-only mode support: * The DataFrame editor (`guidata.widgets.dataframeeditor.DataFrameEditor`) now supports a `readonly` parameter. * When `readonly=True`, the editor disables all editing features, making it suitable for display-only use cases. * The context menu disables type conversion actions in read-only mode. * This improves integration in applications where users should only view, not modify, DataFrame content. * Copy all to clipboard and export features: * Added "Copy all" button to copy the entire DataFrame content (including headers) to the clipboard in a tab-separated format * Added "Export" button to save the DataFrame content to a CSV file with UTF-8 BOM encoding * These features enhance data sharing and exporting capabilities directly from the editor 🛠️ Bug fixes: * [Issue #95](https://github.com/PlotPyStack/guidata/issues/95) - Limited tomli dependency to Python < 3.11: * The `tomli` package is now only required for Python versions before 3.11 * Python 3.11+ includes `tomllib` in the standard library, making the external dependency unnecessary * Code was already using `tomllib` when available, so this change only affects the declared dependencies * Enhanced `genreqs.py` utility to properly handle environment markers when generating documentation * Thanks to @tobypeterson for reporting the issue * Fixed HDF5 serialization and deserialization for datetime and date objects: * Previously, datetime and date objects were serialized as numerical values (timestamp for datetime, ordinal for date) but were not properly restored as the original object types upon deserialization. * This caused `datetime.datetime` objects to be restored as `float` values and `datetime.date` objects to be restored as `int` values. * The fix ensures that these temporal objects are now correctly restored as their original types, maintaining data integrity across save/load cycles. * Updated the HDF5Reader to detect and convert numerical values back to datetime/date objects when appropriate. * This affects all DataSet instances containing `DateItem` or `DateTimeItem` objects that are saved to and loaded from HDF5 files. * Fixed `FilesOpenItem` serialization bug in HDF5 files: * Previously, when serializing file paths in `FilesOpenItem`, the paths were encoded to UTF-8 bytes but not properly decoded during deserialization. * This caused file paths to be incorrectly restored as lists of individual characters instead of complete path strings. * The fix ensures that file paths are properly decoded from bytes to strings during HDF5 deserialization. * This resolves data corruption issues when saving and loading datasets containing multiple file selections. * Enhanced HDF5 serialization test to prevent regressions: * The automatic unit test for HDF5 serialization (`test_loadsave_hdf5.py`) now properly validates dataset integrity after serialization/deserialization cycles. * Previously, the test could pass even when values were corrupted during the save/load process due to improper initialization. * The test now explicitly sets all items to `None` after creating the target dataset, ensuring that deserialized values truly come from the HDF5 file rather than from default initialization. * This improvement helps catch serialization bugs early and prevents future regressions in HDF5 I/O functionality. * Fixed font hinting preference in `RotatedLabel` initialization for improved text rendering * Fixed dataset corruption in `DataSetShowGroupBox.get()` when updating widgets with dependencies: * When updating widgets from dataset values (e.g., when switching between objects in DataLab), the `get()` method would set `build_mode=True` on widgets sequentially while calling their `get()` methods. * This caused Qt signal callbacks to invoke `update_widgets()` on other widgets that hadn't yet had their `build_mode` set, leading `_display_callback()` to call `update_dataitems()`. * As a result, stale widget values would be written back to the dataset before those widgets were updated from the new dataset values, corrupting the data. * The fix uses a three-phase approach: (1) set `build_mode=True` on ALL terminal widgets (including nested ones) before any updates, (2) update all widgets from dataset values, (3) reset `build_mode=False` on all terminal widgets. * This ensures callbacks during the update phase find all widgets with `build_mode=True`, preventing premature writes of stale widget values to the dataset. * This issue was particularly visible when switching between images in DataLab where field values (like `zscalemin`) would incorrectly retain values from the previously selected object instead of showing `None` or the new object's actual values. * Fixed widget `get()` methods to properly reset widgets to default state when item value is `None`: * Previously, when a data item value was `None`, widgets would retain their previous displayed values instead of resetting to a default state. * This affected multiple widget types: `LineEditWidget` (text fields), `TextEditWidget` (text areas), `CheckBoxWidget` (checkboxes), `DateWidget` (date pickers), `DateTimeWidget` (datetime pickers), `ChoiceWidget` (combo boxes/radio buttons), `MultipleChoiceWidget` (multiple checkboxes), and `FloatArrayWidget` (array editor). * The fix ensures that when `item.get()` returns `None`, each widget resets to an appropriate default state: empty string for text fields, unchecked for checkboxes, today's date for date pickers, first choice for choice widgets, empty array for array widgets, etc. * This prevents widgets from displaying stale values when the underlying data item is `None`, improving data integrity and user experience. * Fixed `DataSet` inheritance bug where attribute redefinition in intermediate base classes was not properly propagated to child classes: * Previously, when a `DataItem` was redefined in an intermediate base class (e.g., `MiddleClass` redefining an attribute from `BaseClass`), child classes would still inherit the original grandparent version instead of the redefined version from their immediate parent. * This was caused by the `collect_items_in_bases_order` function using a depth-first traversal with a `seen` set that prevented processing of redefined attributes. * The fix modifies the inheritance collection logic to respect Method Resolution Order (MRO) and ensures that more specific class definitions properly override parent class definitions while maintaining the expected item ordering (parent class items first, then child class items). * This enables cleaner inheritance patterns where intermediate base classes can redefine common attributes (like default values) that are automatically inherited by all child classes. * Example: Now `BasePeriodicParam` can redefine `xunit = StringItem("X unit", default="s")` and all child parameter classes (`SineParam`, `CosineParam`, etc.) will correctly inherit the "s" default value instead of the empty string from the grandparent class. * Fixed `DataSet` multiple inheritance item ordering to follow Python's Method Resolution Order (MRO): * Previously, in multiple inheritance scenarios like `class Derived(BaseA, BaseB)`, items from `BaseB` would appear before items from `BaseA`, which was counterintuitive. * Now the item ordering correctly follows Python's MRO: items from `BaseA` appear first, then items from `BaseB`, then items from `Derived`. * This makes the behavior predictable and consistent with Python's standard inheritance semantics. * Example: `class FormData(UserData, ValidationData)` now shows `UserData` fields first, then `ValidationData` fields, as users would naturally expect. * Callbacks for `DataItem` objects: * Before this fix, callbacks were inoperative when the item to be updated was in a different group than the item that triggered the callback. * Now, callbacks work across different groups in the dataset, allowing for more flexible inter-item dependencies. * Handle exceptions in `FloatArrayItem`'s string representation method: * The `__str__` method of `FloatArrayItem` could raise exceptions when the internal NumPy array was in an unexpected state (e.g., `None` or malformed). * The fix ensures that the `__str__` method handles such exceptions gracefully, returning a meaningful string representation without crashing. * Add `None` check in `FloatArrayWidget`'s `get` method to prevent errors: * The `get` method of `FloatArrayWidget` was not handling the case where the internal data was `None`, leading to unexpected behavior. * In particular, this would lead to replace the `None` value by `numpy.ndarray(None, object)` when showing the widget. * Fixed performance issue in `is_dark_theme()` function in `qthelpers` module: * The `CURRENT_THEME` cache mechanism was not being properly utilized, causing expensive `darkdetect.isDark()` system queries on every call. * Added early return check in `get_color_theme()` to use cached theme value when available, significantly improving performance after the first call. * Improved documentation consistency across color-related functions by standardizing terminology and removing duplicate caching documentation. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/requirements.rst0000644000175100017510000000505215114075001016704 0ustar00runnerrunnerThe `guidata` package requires the following Python modules: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - Python - >=3.9, <4 - Python programming language * - h5py - >= 3.6 - Read and write HDF5 files from Python * - NumPy - >= 1.22 - Fundamental package for array computing in Python * - QtPy - >= 1.9 - Provides an abstraction layer on top of the various Qt bindings (PyQt5/6 and PySide2/6). * - requests - - Python HTTP for Humans. * - tomli - (python_version < '3.11') - A lil' TOML parser Optional modules for GUI support (Qt): .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - PyQt5 - > 5.15.5 - Python bindings for the Qt cross platform application toolkit Optional modules for development: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - build - - A simple, correct Python build frontend * - babel - - Internationalization utilities * - Coverage - - Code coverage measurement for Python * - pylint - - python code static checker * - ruff - - An extremely fast Python linter and code formatter, written in Rust. * - pre-commit - - A framework for managing and maintaining multi-language pre-commit hooks. Optional modules for building the documentation: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - pillow - - Python Imaging Library (fork) * - pandas - - Powerful data structures for data analysis, time series, and statistics * - sphinx - - Python documentation generator * - myst_parser - - An extended [CommonMark](https://spec.commonmark.org/) compliant parser, * - sphinx-copybutton - - Add a copy button to each of your code cells. * - sphinx_qt_documentation - - Plugin for proper resolve intersphinx references for Qt elements * - python-docs-theme - - The Sphinx theme for the CPython docs and related projects Optional modules for running test suite: .. list-table:: :header-rows: 1 :align: left * - Name - Version - Summary * - pytest - - pytest: simple powerful testing with Python * - pytest-xvfb - - A pytest plugin to run Xvfb (or Xephyr/Xvnc) for tests.././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/doc/widgets.rst0000644000175100017510000000314415114075001015627 0ustar00runnerrunner.. _widgets: Ready-to-use widgets ==================== As a complement to the :py:mod:`guidata.dataset` module which focus on automatic GUI generation for data sets editing and display, the :py:mod:`guidata.widgets` module provides a set of ready-to-use widgets for building interactive GUIs. .. note:: Most of the widgets originally come from the `Spyder`_ project (Copyright © Spyder Project Contributors, MIT-licensed). They were adapted to be used outside of the Spyder IDE and to be compatible with guidata internals. .. _Spyder: https://github.com/spyder-ide/spyder Python console -------------- .. literalinclude:: ../guidata/tests/widgets/test_console.py :start-after: guitest: .. image:: images/screenshots/console.png Code editor ----------- .. literalinclude:: ../guidata/tests/widgets/test_codeeditor.py :start-after: guitest: .. image:: images/screenshots/codeeditor.png Array editor ------------ .. literalinclude:: ../guidata/tests/widgets/test_arrayeditor.py :start-after: guitest: .. image:: images/screenshots/arrayeditor.png Collection editor ----------------- .. literalinclude:: ../guidata/tests/widgets/test_collectionseditor.py :start-after: guitest: .. image:: images/screenshots/collectioneditor.png Dataframe editor ---------------- .. literalinclude:: ../guidata/tests/widgets/test_dataframeeditor.py :start-after: guitest: .. image:: images/screenshots/dataframeeditor.png Import wizard ------------- .. literalinclude:: ../guidata/tests/widgets/test_importwizard.py :start-after: guitest: .. image:: images/screenshots/importwizard.png ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6308856 guidata-3.13.4/guidata/0000755000175100017510000000000015114075015014303 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/__init__.py0000644000175100017510000000307715114075001016416 0ustar00runnerrunner# -*- coding: utf-8 -*- """ guidata ======= Based on the Qt library :mod:`guidata` is a Python library generating graphical user interfaces for easy dataset editing and display. It also provides helpers and application development tools for Qt. """ __version__ = "3.13.4" # Dear (Debian, RPM, ...) package makers, please feel free to customize the # following path to module's data (images) and translations: DATAPATH = LOCALEPATH = "" import guidata.config # noqa: E402, F401 def qapplication(): """ Return QApplication instance Creates it if it doesn't already exist """ from qtpy.QtWidgets import QApplication app = QApplication.instance() if not app: app = QApplication([]) install_translator(app) from guidata import qthelpers qthelpers.set_color_mode() return app QT_TRANSLATOR = None def install_translator(qapp): """Install Qt translator to the QApplication instance""" global QT_TRANSLATOR if QT_TRANSLATOR is None: from qtpy.QtCore import QLibraryInfo, QLocale, QTranslator locale = QLocale.system().name() # Qt-specific translator qt_translator = QTranslator() paths = QLibraryInfo.location(QLibraryInfo.TranslationsPath) for prefix in ("qt", "qtbase"): if qt_translator.load(prefix + "_" + locale, paths): QT_TRANSLATOR = qt_translator # Keep reference alive break if QT_TRANSLATOR is not None: qapp.installTranslator(QT_TRANSLATOR) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/config.py0000644000175100017510000003063515114075001016124 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Handle *guidata* module configuration (options, images and icons) """ import contextlib import enum import os.path as osp from typing import Generator from guidata.configtools import add_image_module_path, get_translation from guidata.userconfig import UserConfig APP_NAME = "guidata" APP_PATH = osp.dirname(__file__) add_image_module_path("guidata", "data/icons") _ = get_translation("guidata") def gen_mono_font_settings(size, other_settings=None): """Generate mono font settings""" settings = dict({} if other_settings is None else other_settings) settings.update( { "font/family/nt": ["Cascadia Code", "Consolas", "Courier New"], "font/family/posix": "Bitstream Vera Sans Mono", "font/family/mac": "Monaco", "font/size": size, } ) return settings def get_old_log_fname(fname): """Return old log fname from current log fname""" return osp.splitext(fname)[0] + ".1.log" DEFAULTS = { "faulthandler": {"enabled": False, "log_path": f".{APP_NAME}_faulthandler.log"}, "arrayeditor": gen_mono_font_settings(9), "dicteditor": gen_mono_font_settings(9), "texteditor": gen_mono_font_settings(9), "codeeditor": gen_mono_font_settings(10), "console": gen_mono_font_settings( 9, { "cursor/width": 2, "codecompletion/size": (300, 180), "codecompletion/case_sensitive": True, "external_editor/path": "SciTE", "external_editor/gotoline": "-goto:", }, ), "color_schemes": { "names": [ "emacs", "idle", "monokai", "pydev", "scintilla", "spyder", "spyder/dark", "zenburn", "solarized/light", "solarized/dark", ], "default/light": "spyder", "default/dark": "spyder/dark", # ---- Emacs ---- "emacs/name": "Emacs", # Name Color Bold Italic "emacs/background": "#000000", "emacs/currentline": "#2b2b43", "emacs/currentcell": "#1c1c2d", "emacs/occurrence": "#abab67", "emacs/ctrlclick": "#0000ff", "emacs/sideareas": "#555555", "emacs/matched_p": "#009800", "emacs/unmatched_p": "#c80000", "emacs/normal": ("#ffffff", False, False), "emacs/keyword": ("#3c51e8", False, False), "emacs/builtin": ("#900090", False, False), "emacs/definition": ("#ff8040", True, False), "emacs/comment": ("#005100", False, False), "emacs/string": ("#00aa00", False, True), "emacs/number": ("#800000", False, False), "emacs/instance": ("#ffffff", False, True), # ---- IDLE ---- "idle/name": "IDLE", # Name Color Bold Italic "idle/background": "#ffffff", "idle/currentline": "#f2e6f3", "idle/currentcell": "#feefff", "idle/occurrence": "#e8f2fe", "idle/ctrlclick": "#0000ff", "idle/sideareas": "#efefef", "idle/matched_p": "#99ff99", "idle/unmatched_p": "#ff9999", "idle/normal": ("#000000", False, False), "idle/keyword": ("#ff7700", True, False), "idle/builtin": ("#900090", False, False), "idle/definition": ("#0000ff", False, False), "idle/comment": ("#dd0000", False, True), "idle/string": ("#00aa00", False, False), "idle/number": ("#924900", False, False), "idle/instance": ("#777777", True, True), # ---- Monokai ---- "monokai/name": "Monokai", # Name Color Bold Italic "monokai/background": "#1f1f1f", "monokai/currentline": "#484848", "monokai/currentcell": "#3d3d3d", "monokai/occurrence": "#666666", "monokai/ctrlclick": "#0000ff", "monokai/sideareas": "#2a2b24", "monokai/matched_p": "#688060", "monokai/unmatched_p": "#bd6e76", "monokai/normal": ("#ddddda", False, False), "monokai/keyword": ("#f92672", False, False), "monokai/builtin": ("#ae81ff", False, False), "monokai/definition": ("#a6e22e", False, False), "monokai/comment": ("#75715e", False, True), "monokai/string": ("#e6db74", False, False), "monokai/number": ("#ae81ff", False, False), "monokai/instance": ("#ddddda", False, True), # ---- Pydev ---- "pydev/name": "Pydev", # Name Color Bold Italic "pydev/background": "#ffffff", "pydev/currentline": "#e8f2fe", "pydev/currentcell": "#eff8fe", "pydev/occurrence": "#ffff99", "pydev/ctrlclick": "#0000ff", "pydev/sideareas": "#efefef", "pydev/matched_p": "#99ff99", "pydev/unmatched_p": "#ff99992", "pydev/normal": ("#000000", False, False), "pydev/keyword": ("#0000ff", False, False), "pydev/builtin": ("#900090", False, False), "pydev/definition": ("#000000", True, False), "pydev/comment": ("#c0c0c0", False, False), "pydev/string": ("#00aa00", False, True), "pydev/number": ("#800000", False, False), "pydev/instance": ("#000000", False, True), # ---- Scintilla ---- "scintilla/name": "Scintilla", # Name Color Bold Italic "scintilla/background": "#ffffff", "scintilla/currentline": "#e1f0d1", "scintilla/currentcell": "#edfcdc", "scintilla/occurrence": "#ffff99", "scintilla/ctrlclick": "#0000ff", "scintilla/sideareas": "#efefef", "scintilla/matched_p": "#99ff99", "scintilla/unmatched_p": "#ff9999", "scintilla/normal": ("#000000", False, False), "scintilla/keyword": ("#00007f", True, False), "scintilla/builtin": ("#000000", False, False), "scintilla/definition": ("#007f7f", True, False), "scintilla/comment": ("#007f00", False, False), "scintilla/string": ("#7f007f", False, False), "scintilla/number": ("#007f7f", False, False), "scintilla/instance": ("#000000", False, True), # ---- Spyder ---- "spyder/name": "Spyder", # Name Color Bold Italic "spyder/background": "#ffffff", "spyder/currentline": "#f7ecf8", "spyder/currentcell": "#fdfdde", "spyder/occurrence": "#ffff99", "spyder/ctrlclick": "#0000ff", "spyder/sideareas": "#efefef", "spyder/matched_p": "#99ff99", "spyder/unmatched_p": "#ff9999", "spyder/normal": ("#000000", False, False), "spyder/keyword": ("#0000ff", False, False), "spyder/builtin": ("#900090", False, False), "spyder/definition": ("#000000", True, False), "spyder/comment": ("#adadad", False, True), "spyder/string": ("#00aa00", False, False), "spyder/number": ("#800000", False, False), "spyder/instance": ("#924900", False, True), # ---- Spyder/Dark ---- "spyder/dark/name": "Spyder Dark", # Name Color Bold Italic "spyder/dark/background": "#1f1f1f", "spyder/dark/currentline": "#2b2b43", "spyder/dark/currentcell": "#31314e", "spyder/dark/occurrence": "#abab67", "spyder/dark/ctrlclick": "#0000ff", "spyder/dark/sideareas": "#282828", "spyder/dark/matched_p": "#009800", "spyder/dark/unmatched_p": "#c80000", "spyder/dark/normal": ("#ffffff", False, False), "spyder/dark/keyword": ("#558eff", False, False), "spyder/dark/builtin": ("#aa00aa", False, False), "spyder/dark/definition": ("#ffffff", True, False), "spyder/dark/comment": ("#7f7f7f", False, False), "spyder/dark/string": ("#11a642", False, True), "spyder/dark/number": ("#c80000", False, False), "spyder/dark/instance": ("#be5f00", False, True), # ---- Zenburn ---- "zenburn/name": "Zenburn", # Name Color Bold Italic "zenburn/background": "#1f1f1f", "zenburn/currentline": "#333333", "zenburn/currentcell": "#2c2c2c", "zenburn/occurrence": "#7a738f", "zenburn/ctrlclick": "#0000ff", "zenburn/sideareas": "#3f3f3f", "zenburn/matched_p": "#688060", "zenburn/unmatched_p": "#bd6e76", "zenburn/normal": ("#dcdccc", False, False), "zenburn/keyword": ("#dfaf8f", True, False), "zenburn/builtin": ("#efef8f", False, False), "zenburn/definition": ("#efef8f", False, False), "zenburn/comment": ("#7f9f7f", False, True), "zenburn/string": ("#cc9393", False, False), "zenburn/number": ("#8cd0d3", False, False), "zenburn/instance": ("#dcdccc", False, True), # ---- Solarized Light ---- "solarized/light/name": "Solarized Light", # Name Color Bold Italic "solarized/light/background": "#fdf6e3", "solarized/light/currentline": "#f5efdB", "solarized/light/currentcell": "#eee8d5", "solarized/light/occurrence": "#839496", "solarized/light/ctrlclick": "#d33682", "solarized/light/sideareas": "#eee8d5", "solarized/light/matched_p": "#586e75", "solarized/light/unmatched_p": "#dc322f", "solarized/light/normal": ("#657b83", False, False), "solarized/light/keyword": ("#859900", False, False), "solarized/light/builtin": ("#6c71c4", False, False), "solarized/light/definition": ("#268bd2", True, False), "solarized/light/comment": ("#93a1a1", False, True), "solarized/light/string": ("#2aa198", False, False), "solarized/light/number": ("#cb4b16", False, False), "solarized/light/instance": ("#b58900", False, True), # ---- Solarized Dark ---- "solarized/dark/name": "Solarized Dark", # Name Color Bold Italic "solarized/dark/background": "#1f1f1f", "solarized/dark/currentline": "#083f4d", "solarized/dark/currentcell": "#073642", "solarized/dark/occurrence": "#657b83", "solarized/dark/ctrlclick": "#d33682", "solarized/dark/sideareas": "#073642", "solarized/dark/matched_p": "#93a1a1", "solarized/dark/unmatched_p": "#dc322f", "solarized/dark/normal": ("#839496", False, False), "solarized/dark/keyword": ("#859900", False, False), "solarized/dark/builtin": ("#6c71c4", False, False), "solarized/dark/definition": ("#268bd2", True, False), "solarized/dark/comment": ("#586e75", False, True), "solarized/dark/string": ("#2aa198", False, False), "solarized/dark/number": ("#cb4b16", False, False), "solarized/dark/instance": ("#b58900", False, True), }, } CONF = UserConfig(DEFAULTS) class ValidationMode(enum.Enum): """Type checking modes for DataItems""" DISABLED = 0 # No checking at all ENABLED = 1 # Warnings on validation error STRICT = 2 # Exceptions on validation error # Internal (non-user) runtime configuration class InternalConfig: """Internal guidata configuration flags""" def __init__(self): # Validation mode is disabled by default for backward compatibility self.validation_mode = ValidationMode.DISABLED _INTERNAL_CONF = InternalConfig() def set_validation_mode(mode: ValidationMode) -> None: """Set the validation mode for DataItems""" if not isinstance(mode, ValidationMode): raise ValueError("Invalid validation mode") _INTERNAL_CONF.validation_mode = mode def get_validation_mode() -> ValidationMode: """Get the current validation mode for DataItems""" return _INTERNAL_CONF.validation_mode # Add a context manager to temporarily set validation mode @contextlib.contextmanager def temporary_validation_mode(mode: ValidationMode) -> Generator[None, None, None]: """Temporarily set the validation mode for DataItems""" original_mode = get_validation_mode() set_validation_mode(mode) try: yield finally: set_validation_mode(original_mode) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/configtools.py0000644000175100017510000003452615114075001017210 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Configuration related functions ------------------------------- Access configured options ^^^^^^^^^^^^^^^^^^^^^^^^^ .. autofunction:: get_icon .. autofunction:: get_image_file_path .. autofunction:: get_image_label .. autofunction:: get_image_layout .. autofunction:: get_family .. autofunction:: get_font .. autofunction:: get_pen .. autofunction:: get_brush Add image paths ^^^^^^^^^^^^^^^ .. autofunction:: add_image_path .. autofunction:: add_image_module_path """ from __future__ import annotations import gettext import os import os.path as osp import sys import warnings from collections.abc import Callable from typing import TYPE_CHECKING from guidata.utils.misc import decode_fs_string, get_module_path, get_system_lang if TYPE_CHECKING: from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from guidata.userconfig import UserConfig IMG_PATH = [] def get_module_data_path(modname: str, relpath: str | None = None) -> str: """Return module *modname* data path Handles py2exe/cx_Freeze distributions Args: modname (str): module name relpath (str): relative path to module data directory Returns: str: module data path """ datapath = getattr(sys.modules[modname], "DATAPATH", "") if not datapath: datapath = get_module_path(modname) parentdir = osp.normpath(osp.join(datapath, osp.pardir)) if osp.isfile(parentdir): # Parent directory is not a directory but the 'library.zip' file: # this is either a py2exe or a cx_Freeze distribution datapath = osp.abspath(osp.join(osp.join(parentdir, osp.pardir), modname)) if relpath is not None: datapath = osp.abspath(osp.join(datapath, relpath)) return datapath def get_translation(modname: str, dirname: str | None = None) -> Callable[[str], str]: """Return translation callback for module *modname* Args: modname (str): module name dirname (str): module directory Returns: Callable[[str], str]: translation callback """ if dirname is None: dirname = modname # fixup environment var LANG in case it's unknown if "LANG" not in os.environ: lang = get_system_lang() if lang is not None: os.environ["LANG"] = lang modlocpath = get_module_locale_path(dirname) return gettext.translation(modname, modlocpath, fallback=True).gettext def get_module_locale_path(modname: str) -> str: """Return module *modname* gettext translation path Args: modname (str): module name Returns: str: module gettext translation path """ localepath = getattr(sys.modules[modname], "LOCALEPATH", "") if not localepath: localepath = get_module_data_path(modname, relpath="locale") return localepath def add_image_path(path: str, subfolders: bool = True) -> None: """Append image path (opt. with its subfolders) to global list IMG_PATH Args: path (str): image path subfolders (bool): include subfolders """ if not isinstance(path, str): path = decode_fs_string(path) global IMG_PATH IMG_PATH.append(path) if subfolders: for fileobj in os.listdir(path): pth = osp.join(path, fileobj) if osp.isdir(pth): IMG_PATH.append(pth) def add_image_module_path(modname: str, relpath: str, subfolders: bool = True) -> None: """ Appends image data path relative to a module name. Used to add module local data that resides in a module directory but will be shipped under sys.prefix / share/ ... modname must be the name of an already imported module as found in sys.modules Args: modname (str): module name relpath (str): relative path to module data directory subfolders (bool): include subfolders """ add_image_path(get_module_data_path(modname, relpath=relpath), subfolders) def get_image_file_path(name: str, default: str = "not_found.png") -> str: """ Return the absolute path to image with specified name name, default: filenames with extensions Args: name (str): name of the image default (str): default image name. Defaults to "not_found.png". Raises: RuntimeError: if image file not found Returns: str: absolute path to image """ for pth in IMG_PATH: full_path = osp.join(pth, name) if osp.isfile(full_path): return osp.abspath(full_path) if default is not None: try: return get_image_file_path(default, None) except RuntimeError: raise RuntimeError("Image file %r not found" % name) else: raise RuntimeError() ICON_CACHE = {} def get_std_icon(name: str) -> QG.QIcon | None: """Get standard icon by name""" # Importing Qt objects here because this module should not depend on them # pylint: disable=import-outside-toplevel # Try to get standard icon first from guidata.qthelpers import get_std_icon try: return get_std_icon(name) except AttributeError: return None def get_icon(name: str, default: str = "not_found.png") -> QG.QIcon: """ Construct a QIcon from the file with specified name name, default: filenames with extensions or standard Qt icon names Args: name (str): name of the icon default (str): default icon name. Defaults to "not_found.png". Returns: QG.QIcon: icon """ try: return ICON_CACHE[name] except KeyError: std_icon = get_std_icon(name) if std_icon is not None: return std_icon std_default_icon = get_std_icon(default) # Importing Qt objects here because this module should not depend on them # pylint: disable=import-outside-toplevel # Retrieve icon from file (original implementation) from qtpy import QtGui as QG try: icon = QG.QIcon(get_image_file_path(name, default)) ICON_CACHE[name] = icon except RuntimeError: # default is a standard icon name if std_default_icon is not None: return std_default_icon raise return icon def get_image_label(name, default="not_found.png") -> QW.QLabel: """ Construct a QLabel from the file with specified name name, default: filenames with extensions Args: name (str): name of the icon default (str): default icon name. Defaults to "not_found.png". Returns: QW.QLabel: label """ # Importing Qt here because this module should be independent from it from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel from qtpy import QtWidgets as QW # pylint: disable=import-outside-toplevel label = QW.QLabel() pixmap = QG.QPixmap(get_image_file_path(name, default)) label.setPixmap(pixmap) return label def get_image_layout( imagename: str, text: str = "", tooltip: str = "", alignment: QC.Qt.Alignment = None ) -> tuple[QW.QHBoxLayout, QW.QLabel]: """ Construct a QHBoxLayout including image from the file with specified name, left-aligned text [with specified tooltip] Args: imagename (str): name of the icon text (str): text to display. Defaults to "". tooltip (str): tooltip to display. Defaults to "". alignment (QC.Qt.Alignment): alignment of the text. Defaults to None. Returns: tuple[QW.QHBoxLayout, QW.QLabel]: layout, label """ # Importing Qt here because this module should be independent from it from qtpy import QtCore as QC # pylint: disable=import-outside-toplevel from qtpy import QtWidgets as QW # pylint: disable=import-outside-toplevel if alignment is None: alignment = QC.Qt.AlignLeft layout = QW.QHBoxLayout() if alignment in (QC.Qt.AlignCenter, QC.Qt.AlignRight): layout.addStretch() layout.addWidget(get_image_label(imagename)) label = QW.QLabel(text) label.setToolTip(tooltip) layout.addWidget(label) if alignment in (QC.Qt.AlignCenter, QC.Qt.AlignLeft): layout.addStretch() return (layout, label) def font_is_installed(font: str) -> list[str]: """Check if font is installed Args: font (str): font name Returns: list[str]: list of installed fonts """ # Importing Qt here because this module should be independent from it from qtpy import PYQT5 from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel if PYQT5: fontfamilies = QG.QFontDatabase().families() else: # Qt6 fontfamilies = QG.QFontDatabase.families() return [fam for fam in fontfamilies if str(fam) == font] MONOSPACE = [ "Cascadia Code PL", "Cascadia Mono PL", "Cascadia Code", "Cascadia Mono", "Consolas", "Lucida Console", "Lucida Sans Typewriter", "Monospace", "Menlo", "Courier New", "Courier", "Bitstream Vera Sans Mono", "Andale Mono", "Liberation Mono", "Monaco", "Fixedsys", "monospace", "Fixed", "Terminal", ] def get_family(families: str | list[str]) -> str: """Return the first installed font family in family list Args: families (str|list[str]): font family or list of font families Returns: str: first installed font family """ if not isinstance(families, list): families = [families] for family in families: if font_is_installed(family): return family else: # On Windows, in offscreen/headless mode, Qt may not detect fonts properly # Don't warn in this case, just return the first family as a fallback is_windows_offscreen = ( sys.platform == "win32" and os.environ.get("QT_QPA_PLATFORM") == "offscreen" ) if not is_windows_offscreen: warnings.warn("None of the following fonts is installed: %r" % families) return families[0] if families else "" def get_font(conf: UserConfig, section: str, option: str = "") -> QG.QFont: """ Construct a QFont from the specified configuration file entry conf: UserConfig instance section [, option]: configuration entry Args: conf (UserConfig): UserConfig instance section (str): configuration entry option (str): configuration entry. Defaults to "". Returns: QG.QFont: font """ # Importing Qt here because this module should be independent from it from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel if not option: option = "font" if "font" not in option: option += "/font" font = QG.QFont() if conf.has_option(section, option + "/family/nt"): families = conf.get(section, option + "/family/" + os.name) elif conf.has_option(section, option + "/family"): families = conf.get(section, option + "/family") else: families = None if families is not None: if not isinstance(families, list): families = [families] family = None for family in families: if font_is_installed(family): break font.setFamily(family) if conf.has_option(section, option + "/size"): font.setPointSize(conf.get(section, option + "/size")) if conf.get(section, option + "/bold", False): font.setWeight(QG.QFont.Bold) else: font.setWeight(QG.QFont.Normal) return font def get_pen( conf: UserConfig, section: str, option: str = "", color: str = "black", width: int = 1, style: str = "SolidLine", ) -> QG.QPen: """ Construct a QPen from the specified configuration file entry conf: UserConfig instance section [, option]: configuration entry [color]: default color [width]: default width [style]: default style Args: conf (UserConfig): UserConfig instance section (str): configuration entry option (str): configuration entry. Defaults to "". color (str): default color. Defaults to "black". width (int): default width. Defaults to 1. style (str): default style. Defaults to "SolidLine". Returns: QG.QPen: pen """ # Importing Qt here because this module should be independent from it from qtpy import QtCore as QC # pylint: disable=import-outside-toplevel from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel if "pen" not in option: option += "/pen" color = conf.get(section, option + "/color", color) color = QG.QColor(color) width = conf.get(section, option + "/width", width) style_name = conf.get(section, option + "/style", style) style = getattr(QC.Qt, style_name) return QG.QPen(color, width, style) def get_brush( conf: UserConfig, section: str, option: str = "", color: str = "black", alpha: float = 1.0, ) -> QG.QBrush: """ Construct a QBrush from the specified configuration file entry conf: UserConfig instance section [, option]: configuration entry [color]: default color [alpha]: default alpha-channel Args: conf (UserConfig): UserConfig instance section (str): configuration entry option (str): configuration entry. Defaults to "". color (str): default color. Defaults to "black". alpha (float): default alpha-channel. Defaults to 1.0. Returns: QG.QBrush: brush """ # Importing Qt here because this module should be independent from it from qtpy import QtGui as QG # pylint: disable=import-outside-toplevel if "brush" not in option: option += "/brush" color = conf.get(section, option + "/color", color) color = QG.QColor(color) alpha = conf.get(section, option + "/alphaF", alpha) color.setAlphaF(alpha) return QG.QBrush(color) return QG.QBrush(color) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6138856 guidata-3.13.4/guidata/data/0000755000175100017510000000000015114075015015214 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6388857 guidata-3.13.4/guidata/data/icons/0000755000175100017510000000000015114075015016327 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/apply.png0000644000175100017510000000142115114075001020153 0ustar00runnerrunnerPNG  IHDR(-SgAMA a cHRMz&u0`:pQ<5PLTE v] xMwE wS H:4D?  JF ID5/"LHRf](#%} WfO0$ bO0$l +T@6rI:XWN"t rd _ #gY׺]V׶ZRA:QIܴZQ `U% ݵکj`7(/"yoߝߘtiI9=1_S䈖厥|qYJL?^OxygXVJo`xieXw;tRNS ͷ д Ѳ5 A <   #lbKGDHtIMEURIDATc`L,(|V6kv$>9:9 00 00{K00HJyzIy+*** S ԂP )9 &f(4Dnd X%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/arredit.png0000644000175100017510000000214315114075001020462 0ustar00runnerrunnerPNG  IHDR DgAMA7 cHRMz&u0`:pQ<nPLTE~{|z{zzyyywxxuwwtvvsuurttq}}|ssrookmmillhkkgjjfiieiifmmkxxxooliighhehhfhhdggcdd`eeaYYTXXSVVQXXT\\[YYmm4eOKtRNSk%&($i}jbKGDKi PtIMEURwIDAT8˅WSP`aLB`/DJ n!od6Ei?084<2:6>19s$KU  0O#qD(ф e!!, & B]Xi"U edOcѼPPḛۻB0`ǀPS^y^61пI$Q1,fi @Ƞ JPP0ڧꠤ*ak[l6 @ %m 2(w+|@QJho )>ˍ.x]Ʀqy=hI%nZ<s/j}%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.30@GIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/busy.png0000644000175100017510000000132115114075001020007 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMR@}y<s ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/delete.png0000644000175100017510000000132215114075001020270 0ustar00runnerrunnerPNG  IHDR(-SgAMA7 cHRMz&u0`:pQ<PLTE7JS!%,r wLsS+ 5T65sJ&]?B( dD&:_@F, GnO_AK0VC%mO":^@;"  +pfHsvXU86M¡9tRNS'kd*.+0k{+E ,bKGDB=tIMEURIDATӍ@DWErP]JA5O)#TzUP \f4f '/ $J4%浛=;dz`wiv <0(98IϏ0~gy(7(>eघ?s U-U}3 b9a%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.36%IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/dictedit.png0000644000175100017510000000161715114075001020626 0ustar00runnerrunnerPNG  IHDR DgAMA7 cHRMz&u0`:pQ<PLTE~{|z{zzyyywxxuwwtvvsuurttq}}|ssrookmmillhkkgjjfiieiifmmkxxxooliighhehhfhhdggcdd`eeaYYTXXSVVQXXT\\[m%vKtRNSk%&($i}jbKGDYtIMEURIDAT8˅kO0NE@P@T. 0&L/ M?c!03g8zq1-8r%]BpNhah AU$c[J6H=RFOm&ikFg͗IDÖ剶1U#+q삹<'(|:qnz}glUxs.L & X=:]Z{t˞R_+QsXjl E-H_mjw_mjwn-V9E,o%ӖbIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/edit.png0000644000175100017510000000164115114075001017757 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_F\PLTEĠssrQPQ@>?~|}eee FFFܰأߜ󳱱ߡб񰰳ċ5tRNS; o[<@CCCCCCCCF8cbKGDH pHYs  tIMEURIDAT-gWP UT{p EU?ǴoyΛ7PQYeFuM$R[W+?&RCsEDH- Yen"a(shuMpKxҹnvpexn~wo\} O^zN/.uN9)[UUww𨋚3!<=^K7" GMKFF?}LNM%Tjaqi`46%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6408856 guidata-3.13.4/guidata/data/icons/editors/0000755000175100017510000000000015114075015020000 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/copywop.png0000644000175100017510000000161715114075001022206 0ustar00runnerrunnerPNG  IHDRasRGBgAMA a pHYs  $IDAT8O}MhAd4mƴ6IT(DAыzP*aw T/]Eӊ$Uip,j5h%`׮2-'ϡGØfnQ-[pjBS.Sήq?hu'b&f!7ʒD. lk u ?~|}eee FFFܰأߜ󳱱ߡб񰰳ċ5tRNS; o[<@CCCCCCCCF8cbKGDH pHYs  tIMEURIDAT-gWP UT{p EU?ǴoyΛ7PQYeFuM$R[W+?&RCsEDH- Yen"a(shuMpKxҹnvpexn~wo\} O^zN/.uN9)[UUww𨋚3!<=^K7" GMKFF?}LNM%Tjaqi`46%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/edit_add.png0000644000175100017510000000175115114075001022242 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FPLTE;r>:q=?yBFGGJ@yC8m;7n:4f83i75j84f76h:;q>;l?:e>5f93f7-Y1b_QP3b86k95j94j87m;:q=9z:h^YR@yA1{1C~C0v1@@7v8 <1>y?7h:6h:8m<>lA;g?>jA:f>:f?;q>9x:2254:{;MMeakhRR_\r|ed^]ympg\[d`xk_XUtaR95E=[Vmiif`NN?SP[W_\eb='bRp`VD=/;.G7**(A:*)"@733+<8?tRNS#,,***##",***LL***,"",***LL**,##***,,#h 7xbKGDi+9 pHYs  tIMEURIDATc`F&fVF`wptbGpvquDpBばyDD%$CBee#"cbSR32sr KJʕ+*kTjT54ut[Z Vwt ô YDs$ILD3,%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/editclear.png0000644000175100017510000000336115114075001022440 0ustar00runnerrunnerPNG  IHDR szzsRGBgAMA a pHYsodIDATXGWklUffgg_vw]ZRE4(E5ƀQFQ0D(/`B""bKKnwgfgwvf{Yl+/rvg={,QX~82ע|a@]|Spyg)IE>s| B5]&iqeigGDu ~ #PrVi.x}sύH&&pPbԻ̘T|z2!@g5wy+fCKPd<>ă3mߵA?CmuǑVYPu;zM )] M5$JYaX;>/:O6W葃i) P8̬uEMeM^h$O;ԫ1<pԸLՂBDe*Yi)y>P$Umqޭ ̂5ÀIMUGc݆w=>لH9:I9>736iiH2pl΂7`or5XRv-+˵KCI"ƀlU%3QіqpZo5`ʈjZ'QH%NbeH"!:XHŁ$a{nݹVؼ-f1qZ'})EA/HiR6TNmO^C(y1.}I%a( #"$  YTɄ"r U--Wq%暡N0:`<ˉt!67`>N"\Foa{f&P$P6cɖk5߯D$%]E?))*GGG677ǵGPtRNS: pU3 Þ|V3 ͖"~θuV72tN1 ݝdhbKGDH pHYs  tIMEURIDATc`F&fV66vN.7 ED@ 8@LA" $4,< $e"cb ^LAN> !1&) RR32 KEA5 ;'7/ ԀEũ%`P*Hͯ*m]Z07``04ohllljln16a`05kmk,*1``0qdk[;{89{x2?d H^L%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/editcut.png0000644000175100017510000000147015114075001022144 0ustar00runnerrunnerPNG  IHDRasRGBgAMA a pHYs  IDAT8OSKLQﴥ!bAHH cQ$r+VD&$Fԝ&4К`aڡv;DN2y3!G***D"$I4w4EAXl0`P@?z$Y%@`F,b%#m66wA\nZ?Y$0L*E).jll s~uuur"H]W;/K]^w;Â=;,ǝ ,X$$qH̯m &~]Ył| >X 4kH (p,8G C,;*6Qk11CR ma$g1 "EeY,^**S@VBulcd252hFR)+GUӒ Xjjjv,rS$y1̪eXpp{X0z iQˁbG\NMo q$;~ww9!}IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/editdelete.png0000644000175100017510000000265515114075001022621 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FPLTEv]r\[30h=q^mtKFDGH-12$73;852 :6=:ZS^YE>s N<y IBy!F2T@^ /K Sij= Up{iaJbanz ta$_g KZwK_%Muvv mF  !2 ,8hp"#(DddF)%z¼ÿnhlfz{vkd84>;kfwoMHHC:4F>E@MF&"$"805.   ~`d w s        2(E5 72NGPH:3,tRNS^s N[e|CX dAgTm Ȑ fש Ȏ9sS}7ܭQSl>RC|Oz:bKGD۶x pHYs  tIMEURIDAT()*+,-./0123 4567  89:;<=>? @ABCD EFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~ツ뇈 !"# !$%&'scO!x%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/editpaste.png0000644000175100017510000000241715114075001022467 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FPLTEtLk8s[?=89:=hefgbmGjf\wPuHl;tIǒg2]+]/\`0\^-yQ[*^G,26?889:::Z(򚚛Y'X&W%~V$~U"}T!}T!vLxP|S |R |R uI|lPSNBz|z{{aaaٰ{aVخźŹŹ˿Ƚy]|Lѩe}LzGjCd@b;a=kHkCͣ{•g}{ysr͝tvxD]꛾șp_8Q@O[YlR{TE;UragQ}I`{h~\azFl[# !~j\vEm:b-s`ZtCsBf.xZvPh=ePDP2 #s25'3 33gs\M&\ pB)uA!^-v^;YqNɖ=Ac2JT*W ,f T jmث >|SŒs!-*JMi&QԨ'3fÀ @Pň^VxsgR'@+%ꎩJ)b&.ԨJ| Cāf)W UY' (Gb[ѱjlMHjoH黬&l ?uhAPDiCĄ& },~T-hD' }((ݝ0X.(k-1(Xzc Q `c'J+2h1JP% Zy5g6sy9F ã~tPgGX@D q|ANJFByGĜa){ BTr]xOB 1JBrPf̵SnOpO2/s-_ Z.EB21Ffp<Ҙq.ċٍmO )ѦPiH. gWs{/cuu"A*BQ.paF {"C)`J0 & Y~o3VRk1E}u#;~I[$EOє&OH|B" Ir$Ɖ=.dIJ >,H31N/%Zc5λf뾊BWvRM6pݣqq/@OS'ec[olY ɢ DmV~AmQ%`j a=lYw[bCW0A̿|7p%JVƽ$7a8 [WA>BVX,7|5qXbpp9iKW}-ޡ330`TǟJ!8sʯQJl9/Gi8VdopG^ `ؾ}"pjƻ.Z5k .ް>n_Ak5ysw2F-H[s9I=ByL/Q~g#V#ڃR̎CwLxA,%qx?%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/filesave.png0000644000175100017510000000217015114075001022277 0ustar00runnerrunnerPNG  IHDRw=gAMA a cHRMz&u0`:pQ<bKGDIDATHǝˋ\E{nOc8!N MpIH\H DA+ܻtޅ;7".ąBdKF#><\۝.֭s|_ٳoOǎ?ugϔWDVK)s;?|A_n@NFO;͛7˼.]z87_.# g2/]z_nnJIRp"a6@#0Sl !ܝNq+k $CfD?(j $@DO-ڮ`ڭY0Pe@1QKQm}.rDA@WS]2fK "U(?$徖h4_ޝۜ?’c~&\wbUF@o?CBF?wOwNzn``gᒐbCR!,1CE -j*(P'RT 2cz%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/plot.png0000644000175100017510000000100315114075001021451 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FZPLTELDeLDeiSr>>̀*+ xdtRNS4&bKGDH pHYs  #utIMEURIDAT]0E*_&jvMN)m?SJy. 8%\wZ: rE;`uqƊ{$ 2Rahcwrhγ5^~'+߾^ SL%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/rename.png0000644000175100017510000000126115114075001021750 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FPLTEfff+f8tRNS4hbKGDH pHYs  d_tIMEURIDATӅY@ᙲE 3" I";Yw:90 #%!$S錐͉ RYTk NhCojFlw,?0`LeK3/Bťlnee;zXi0 E,$KdqW%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/editors/selectall.png0000644000175100017510000000224015114075001022447 0ustar00runnerrunnerPNG  IHDR szzsRGBgAMA a pHYsod5IDATXG[oE׎M-BJع|>H੭@JyV|$B _%93.A>s{ٵ0 _ !bh9/~4-]OxD=~XA~uqlՠZ@{`X*8Fz{gRE؇is*Mh/t(j&ሑ6ǩ1j8[̂+R=ۋ؅݈QXnd 0 LlS8f4\͂;Wfd2Be[Ёp1C7 FW`4= ԡ^CՖEc$\Yu띝 T*X__Ujq& UehpRx6 ࢿ.L﬏{ySH0 ?ǰ p=F9 'SX+GW$%dZpttTg\\1LԢ11:~>o38KL2/E؋u;E1~ a7~ɣ/;@ɉ=<}83NzS C Tz|d>s%=x)K9Ig fq=m7*s(- %*s!0p48,1!/o9|B]tssrAZ:}KB]bqbqC^L_PfX0"IAibngNF/" ;32) 祡ᐌ70矛 "魪2, up@9 /'.' ?9rnxtA:$#@9vr{B<ꢞ&&衞>7쨥rlrl餡 "2),$.!91_W^W4,%H:xC5tf=tRNS'ab'##; ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/file.png0000644000175100017510000000102215114075001017742 0ustar00runnerrunnerPNG  IHDR(-SgAMA a cHRMz&u0`:pQ<xPLTEbġf̔}yuqkho|֋ܘ楿tRNSǜ+!bKGD hVtIMEURdIDATm70#LdCSYA 2HDh"2fZ.?*acaXVX8c?`~&"%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.30@GIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/fileclose.png0000644000175100017510000000262015114075001020775 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FPLTE7Pi.CZ2MkIhHgQp^}VuXw^~;Sl!-6?`<\{^}Qq0Op6Wx -7<]|]|Rr2Qs]| )%'5H$6#5 /E9]1Qs^{Xubb\{`ZyCbc\{]|[zGfKkYw;Wv ;Ic>]{=[zPj ?Rbv6Wv1Qq[k.9BU[c}6Tt-LkyV`jGWjh~YwPog}AUj%"! .'.)EVtLj.K *GfF]vH_x,Ii=]}NgOh;Z{4UuNgMe-KjE`{WpSkF`{XrdWxTtTllcE`|dSrQpZqJdaCdUoeZyYx\tUoJiUuYsngYtXwեԆЪВ֠twchwby`widwq_|[zHhKjca~JjXwXvNnB`?_?_~Cb8Wv;Zy7VuSq[x>]}=[z=\|=\{=]{\zEeGfLlb~gMmIhFe^{[xMlSrYxnmZyRr\yTnRrWwaxycWvSsUob}q}nb|tm]tRNS&+)NF52VO;LhbUnh3D,!0&,>BbOKbN\-R??@ABCDE FGHIJKLMNOPQRSTUVWXYZ[ \]^_`a  bcde!"#$%fghi&'(jklm)nopqrstuvwxyz{|}*~瀁+脅,-鈉.+/01-203…qmA̵%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/fileimport.png0000644000175100017510000000302415114075001021201 0ustar00runnerrunnerPNG  IHDRw=gAMA a cHRMz&u0`:pQ<bKGD+IDATH}m\WϹwf3$m$5+AԾ6Z aVbAE BXP꧶5RKmie)*diMww2۹3sΝ{nf7?W3+B=yAVMm6#zzo_Pl!_ڶfgddnJ/kń Ujjo Օx d0 "X#<vr5arqZu cb0xA)HŅ,0kKm/һpMbE)R rm7 3)j. ƣm@-#%k=.UAsbFl-}?cb ĄQ@GEawG'>5Y?VGu*A3N1]W$CH o~zj 1^!eȏbal3@؁qȠ(Gz멍֮_+pW]J| xT#IJx1g-iF))hDj)WBaڂf++i&i@ RuuW5}BY3]d gkuKeC3["#9>dW_#$4M ]@a trٍmFr 뀶9^>:gpwNyR͜~,cPE7tG'Ґ'(;Ջ<|)mXN/9{r9TUm%&,9\\~ԛ|)c/"N jR?a߭M;}~H6*=q<О挙& #8};_.d6P=ۯ;gh7:,/h.0r=|k&i%Z4/5i$ ݜSOm=>xhvuIG:>`ط_y- / P(™{Ip@0KXs%`OH1շ&!xka , j^ES `]ՠ`@yk 1mY}** `7#Ft㳎Q?;&fc[CvsT)ll.%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/fileopen.png0000644000175100017510000000300315114075001020625 0ustar00runnerrunnerPNG  IHDRשgAMA a cHRMz&u0`:pQ<PLTEެXXXNNNKKKJJJHHHFFFDDDCCCBBB???<<<|wwwzdbbx(%%(%%WTTu(%%(%%(%%(%%!WUUr(%%(%%(%%!VTTq(%%(%%(%%!USSd(%%!USSƚ]I/}(y(%%!WUU֪mgd:Xspp v(%%%"":77̸;Uyc%6I(%%%""uss >Y_&,5)+1(')洿ᦽ菾呿ૻǯ쌻㎻㍻সƪ鋸ኸॷƨ臵އ݈ߤŨ牴܀$ɹutRNS5[r0@Ru0SRRRRRRRRQ[9O\&)$g 73q@B{о.Nߖ^q+dn5ap @+ dl  ebKGDHtIMEURIDAT(c`@L,lN.n^>~AҲ aheT7%RM-mp J 뗂KHOKL4yiӦ%d!3&M9krp yP .ZD.$l+W*%AVYn*p M![n۾C .vٻo FġG;ОN0uRjէ_}|΁AUVа(ccbSR32ˇ  %C%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filesave.png0000644000175100017510000000217015114075001020626 0ustar00runnerrunnerPNG  IHDRw=gAMA a cHRMz&u0`:pQ<bKGDIDATHǝˋ\E{nOc8!N MpIH\H DA+ܻtޅ;7".ąBdKF#><\۝.֭s|_ٳoOǎ?ugϔWDVK)s;?|A_n@NFO;͛7˼.]z87_.# g2/]z_nnJIRp"a6@#0Sl !ܝNq+k $CfD?(j $@DO-ڮ`ڭY0Pe@1QKQm}.rDA@WS]2fK "U(?$徖h4_ޝۜ?’c~&\wbUF@o?CBF?wOwNzn̬l]CKeέm-tk׬}rKH:{?L"D -R(hT?hDpƟ2u9 D5T!ƈIoY5Td*JBiR.1.4lC$(Iꑐ' 9]ֶv?bV(hD} kuN|>͛{A$! a-5$207Coo/&&YXν$pE kEU%M\YhٳLjpeU˴ aCTQ[7 b1\0?y-sg8BV;ZŘvڹ0Ͷm̊) 184 1??GX\SիרvA>_̪Վi$k-"rD8i}5rVҠY晴s#[]"~ @IbsK a~qfg/R(@ H\ 4Mp< f)hoK/'ׯ1z=;w)d%tqI>rBX*mpQTJEɑNq9wnaK[+*^g]h>l`HT,1p^UN:ʼn03sbH2i~g xO!Ƹ^10NT*cEB{OzWusΑi!`~TT*72<[o291c줥}s /]B6ADɾbLa,(حDDL224Ҕ0(ahruyyNsIU::vR8$u, -ԥt, P(*(**(ɭNYmr 8|}*|e*؉W `B %tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/gif.png0000644000175100017510000000275015114075001021605 0ustar00runnerrunnerPNG  IHDR szzgAMA|Q cHRMz%u0`:o_FbKGD pHYs  IDATXŗ]hUw>vfWئ mkJ+*j}HJQQP&P 6BJ/RMmHlI6d7ݝ{}()fR/,ws+R(M!PJ!D4? !VB@={ږ@,(Kɖ@H)QJib ]m $+ ^)lI _/4&$7CPI%@zBh[ӵJ 2cyT6@ƾFh`쯘+Fy5LdٓX/ZUFKF \x?P4sAVnE6 Zf]C n:0JyhBN&uv&jBS=@>g/(VN^9?}N>_O$AA[{=в,a9ojw ~ 69^pIxi vc=zp]hl(‘Sr )i`"[`:Ќ꿄 úLNQ|8y6GyA)Bsݳį I !?;| ~8X=n>uy7ȷI)Eh<2fXdri;I` i7bxIjӌ% RIs s HJC \qjZb 3xe '0 4`U`61MIak=lsO*ۄu4B[h@z I\;rZ]I{;,g#d`9} c N\xqI15RJBhCa Oy\D&a ~><~ \*?Xɳ#|c}frȫ}gH M5P[ ]Թ/\nqTo<3dFTl5a)|^xr\o|-,ٵ!ЩKOLXgq%Ui3XmJ)<2’@_@YzKscjrcKֶcok&?}X_6cd}PSٌX܈!x,_},VR[+-޽]Wɯq/Ɩ[Q85M`ےzJ+ -zaT׹m00^s{i !Wy\<[ T)\T?zͰVOz{m޻#c Fxl4;n҈֚6|)hdž0LI)x ̈́J`9s,o~YF($z.6 )h c A6Cy6V@l4;y={5`)Ou;&5nVP r'Q1~O24P.eDcN{(+5yߦ6`t(p DZ6SZבdI/ZR4ĊO1C8H8`O0KscBЈd]4}8RTXocuR"Xĵ%R%A R`8E t\t tj;CD!xG%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/jpg.png0000644000175100017510000000275015114075001021620 0ustar00runnerrunnerPNG  IHDR szzgAMA|Q cHRMz%u0`:o_FbKGD pHYs  IDATXŗ]hUw>vfWئ mkJ+*j}HJQQP&P 6BJ/RMmHlI6d7ݝ{}()fR/,ws+R(M!PJ!D4? !VB@={ږ@,(Kɖ@H)QJib ]m $+ ^)lI _/4&$7CPI%@zBh[ӵJ 2cyT6@ƾFh`쯘+Fy5LdٓX/ZUFKF \x?P4sAVnE6 Zf]C n:0JyhBN&uv&jBS=@>g/(VN^9?}N>_O$AA[{=в,a9ojw ~ 69^pIxi vc=zp]hl(‘Sr )i`"[`:Ќ꿄 úLNQ|8y6GyA)Bsݳį I !?;| ~8X=n>uy7ȷI)Eh<2fXdri;I` i7bxIjӌ% RIs s HJC \qjZb 3xe '0 4`U`61MIak=lsO*ۄu13_]sz ԁDUD@$H)VY*͇uGk$MFU3#jREO=M}(LTP ("*DJ'ՍY1c>( sXmla+dHٍ:A"EŊ[J  C l?'м@DPSixW@ 'aRG`emH5 HABDTe``NI, o/jS phFY|128хȤ!I0 qU Z[) 2 skQ24;=/DA Rx,{p=ۼ҂okuu/|j4Np ܒx!ԩE(҄HYnqw7V_߸1RrOQ|WW @@HB*6 1|Ha) wt 7o\i F|\Od۶4k~&dxv/"V5Zl!k$ħO3}z* իIolrTBzNL1q\Y `1~f] ,W|qmmdj^lŊJ¨- =y(£ IL>%ݰgOay{O=i֬;A"ب\AV{C,;~ݻ?KH +Zq uK$%R(aPojbYg''N4’4rAE3_T3'$7!SoM c0M `2-ͺPU%iΓ`xlk2 :<Q!4|uu&kERmCDUyP? \;n1CY:<5ȦrG%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/png.png0000644000175100017510000000275015114075001021624 0ustar00runnerrunnerPNG  IHDR szzgAMA|Q cHRMz%u0`:o_FbKGD pHYs  IDATXŗ]hUw>vfWئ mkJ+*j}HJQQP&P 6BJ/RMmHlI6d7ݝ{}()fR/,ws+R(M!PJ!D4? !VB@={ږ@,(Kɖ@H)QJib ]m $+ ^)lI _/4&$7CPI%@zBh[ӵJ 2cyT6@ƾFh`쯘+Fy5LdٓX/ZUFKF \x?P4sAVnE6 Zf]C n:0JyhBN&uv&jBS=@>g/(VN^9?}N>_O$AA[{=в,a9ojw ~ 69^pIxi vc=zp]hl(‘Sr )i`"[`:Ќ꿄 úLNQ|8y6GyA)Bsݳį I !?;| ~8X=n>uy7ȷI)Eh<2fXdri;I` i7bxIjӌ% RIs s HJC \qjZb 3xe '0 4`U`61MIak=lsO*ۄuvą:J`>񤢢kvm`cga^pH򱯭Gle]Ql[b🟟b댾씽뎹xuoۓډ։֊نىӠ~ͭ퐐)tRNS帹b;bKGDH pHYs  tIMEURIDAT8ˍwPۚuv#}"lmwVl;  lh,nr C/w9'99لqGKy@A%Y,DQUp>h0"H{@|ES2,{jz { L{`((%)LPEV۴m׾CNJN=2:Kn{ջO__z 4xPT c LjeUU.qML8iӦϘY-Hx651o/Yl9Vd+VXv 7m޲dvT0sW={?p2@:#GM;~gΞ; T ~eW^~w$,h><|g_+QdPaں 0#ۡ#>/ Jip8W7D?uBw,+B`@@HFIHFH@?A}~䊊xxxkkkzzzwwwyyyabaݎЇDZrktRNS帹b;bKGDH pHYs  tIMEURIDAT8c` 0212323hF& +`@(`$IR@R@I(P$+B8+ %-##+@BB⒒ bbP$& 4 MBR\\RYQEE ԁcC !.!.! Z@w#LE("6$:( DQ +Y-v;)I{G'gGGW7wOO/Io>~A!!aaQH`OHLJNIIMǨFV_PPXT\RZZV^QYUU]c! YA76pQ=}&ILfGR6m3gAٳ̝'`/Y ˖Xr WYn``i-ّlYev60غJGB\@Ʉ-lٱS wHp")ز{18ؾGGBRa-wݷ8x : lY:;''&NUǮݻ0T4/s}ɱ]%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/tar.png0000644000175100017510000000305115114075001021621 0ustar00runnerrunnerPNG  IHDR szzgAMA|Q cHRMz%u0`:o_FbKGD pHYs  +IDATXŗ[{.;;{񒵝8 p.F+HƆG)tf49՟sZ5LF5) GH/1*\eVcZl,¿XGʵXL|C%;%RrP!],o/^W_fB8 ⌻ %TQ ߍBWqbĘBg ȡұ#Z]3!FŇb7y@dHWc 1%RCpˎ}`.D읔L Jg zCjUYw%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/tgz.png0000644000175100017510000000305115114075001021637 0ustar00runnerrunnerPNG  IHDR szzgAMA|Q cHRMz%u0`:o_FbKGD pHYs  +IDATXŗ[{.;;{񒵝8 p.F+HƆG)tf49՟sZ5LF5) GH/1*\eVcZl,¿XGʵXL|C%;%RrP!],o/^W_fB8 ⌻ %TQ ߍBWqbĘBg ȡұ#Z]3!FŇb7y@dHWc 1%RCpˎ}`.D읔L Jg zCjUYw%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/tif.png0000644000175100017510000000275015114075001021622 0ustar00runnerrunnerPNG  IHDR szzgAMA|Q cHRMz%u0`:o_FbKGD pHYs  IDATXŗ]hUw>vfWئ mkJ+*j}HJQQP&P 6BJ/RMmHlI6d7ݝ{}()fR/,ws+R(M!PJ!D4? !VB@={ږ@,(Kɖ@H)QJib ]m $+ ^)lI _/4&$7CPI%@zBh[ӵJ 2cyT6@ƾFh`쯘+Fy5LdٓX/ZUFKF \x?P4sAVnE6 Zf]C n:0JyhBN&uv&jBS=@>g/(VN^9?}N>_O$AA[{=в,a9ojw ~ 69^pIxi vc=zp]hl(‘Sr )i`"[`:Ќ꿄 úLNQ|8y6GyA)Bsݳį I !?;| ~8X=n>uy7ȷI)Eh<2fXdri;I` i7bxIjӌ% RIs s HJC \qjZb 3xe '0 4`U`61MIak=lsO*ۄumE9m~',ft ˢ9t:/xj3"/67)?kƀ)%ii )2dYA%G`XE\\ymb[e=._ pK\vq?awWM,`{sFc^Yz/}͛O Q0 G5"EX&I"$i4M`0Y,d2s)y,d2y֭Gw*Ah 0<H^'I(`nf\8ϋj'0,[-2O,,\>wp0{V05/ 0=}ƹi_t14盛#at6Ba;;LpP) Ţ<0pls (@JeY|ry |QkB0V#19Fc󋲠z0?7hM&!?Y+WXgl76ז8;B|AM ӦRW=H2t|/~"^FЏ{;ވ^ E-еnXhPTU4M3^ak*~a)o+2( c:2PPuZSM4MC:V V C1QUng 0 |LLO!=-q_OYfd63hGyyl`!:i6J3tG/a6!ٚfb8o/ކ;ڌ4M蚉i R$B p>N#f[<ω$NHØ0)AgADb'6gڦ\yajwg 2v{(0$Sm cvwwH[]G/b&bMӨULӤ\.*Z-Dqp UkNF^c>Y<ڦZ^_gբӣ\*f~nl0N#R BЏCJaJ< ANINGH2E!'1Op2st(0a(NH )2ˈˆ(' i Tʀm,--zc;Be0dqZ-4!FS~ף>t2:F?[{%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/filetypes/xls.png0000644000175100017510000000232515114075001021644 0ustar00runnerrunnerPNG  IHDR DgAMA|Q cHRMz%u0`:o_FPLTE̷΋ל||##::QQee\[mm ''@@U\uHc7] ##;;LTA`3[']*pX$&G@e7N(L"HX*).C*E0=2'.\zS v1[0) TqS{22"+y v yHaG +3&̐`habmbxNNM 0;1%W < tQQ+j)AL܀6I]4%wufO?nRƆG)tf49՟sZ5LF5) GH/1*\eVcZl,¿XGʵXL|C%;%RrP!],o/^W_fB8 ⌻ %TQ ߍBWqbĘBg ȡұ#Z]3!FŇb7y@dHWc 1%RCpˎ}`.D읔L Jg zCjUYw%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/format.svg0000644000175100017510000000152615114075001020337 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/guidata-banner.svg0000644000175100017510000004037615114075001021736 0ustar00runnerrunner image/svg+xml guidata 01 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/guidata-vertical.svg0000644000175100017510000004034215114075001022273 0ustar00runnerrunner image/svg+xml guidata 01 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/guidata.svg0000644000175100017510000003713015114075001020465 0ustar00runnerrunner image/svg+xml 01 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/hist.png0000644000175100017510000000021415114075001017774 0ustar00runnerrunnerPNG  IHDRRPLTEj&9V<tRNS@f%IDATxc```d, %\ cA%L V *#!'D SIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/max.png0000644000175100017510000000054415114075001017620 0ustar00runnerrunnerPNG  IHDRbgAMA a cHRMz&u0`:pQ< PLTEPYʆtRNS+NbKGD LtIMEUR#IDATc`P%  b:ci%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.30@GIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/min.png0000644000175100017510000000053715114075001017620 0ustar00runnerrunnerPNG  IHDRbgAMA a cHRMz&u0`:pQ< PLTEPYʆtRNS+NbKGD LtIMEURIDATc`@| Y14B c9ԧ%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.30@GIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/none.png0000644000175100017510000000051315114075001017766 0ustar00runnerrunnerPNG  IHDR7gAMA a cHRMz&u0`:pQ<tRNSv8bKGD݊ pHYs ;ttIMEUR IDATc` 0Ǫ%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.30@GIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/not_found.png0000644000175100017510000000102315114075001021017 0ustar00runnerrunnerPNG  IHDR(-SgAMA a cHRMz&u0`:pQ<QPLTEji_jiiioigjjjjgiiijiiiiijAtRNSHh ߧ0Ѽ埇znbKGDug2 pHYs[ tIMEURaIDAT}I [EDܷGطPB:`t@nBiEd~Ge}a\G=u@oBjDeFd}[OP=v?qBktRG@FjEd}`VK@9aZODe\Q?]HIDA'tRNS% !)*#P; (#PbKGD-tIMEURIDATc`F&ufV8`a prqԍMLy`L]h#N'gW733wO/o9~@MA!| aQ11q < I)@~jZzFf0zLL6*.q II)nܼ|uiY9S K.U(-+R ()H"+ͭ%%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/quickview.png0000644000175100017510000000157015114075001021042 0ustar00runnerrunnerPNG  IHDR(-SgAMA|Q cHRMz%u0`:o_FYPLTEffffffffffffffffffffffffffffffUUUUUUffffffUUUQQQMMMUUUUUUPPPUUUfffUUUJJJIIIzzzPPPUUUiiiNNNpppfffAAA555111;;;KKK&,3^ys]9RMMMYB$1tFFFSoE2躺OXk???333c6;dFOX_7CCC솺Yim?b漼===ĖE8ikkkꬬڷ&tRNSo/_O??/_Oϯo?oobKGDH pHYs  tIMEURIDATc````dbffa@V65 `g9ffv56 , R<<@x"| ZB `ut 5DD98%ML-,5$E9xml%EY8x\\=<}|"| RA^!a@CEcbSd@ȦgdfgɁ'._^Q *(T) l%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/resize.svg0000644000175100017510000000767515114075001020363 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/save_all.png0000644000175100017510000000307715114075001020625 0ustar00runnerrunnerPNG  IHDRw=gAMA a cHRMz&u0`:pQ<bKGDVIDATHǝˏ\Guϙx왉 c,LHV% $X0 +6(A@"F,0!q&I4'v^qOO?{T"9#]UΧ}{ᯜ87NիFf$ vxyUNaVVxϳMk,[}h޽{ߝ;ǿMɜLu+8*+8@UՈ8Dxɧ1} hل0x!`"۵րKƵ~` UJ"I@DpsFS_旿5G~}.ʢ"C)E- ]~p4ִt+W6jpwq4C񱟮G ~xωS1o,|"#D C@=d׻#Q(JPj0FmΫ,"V $ʢbZ *IX4js#MS# Y`1;]\$Qe NnJ{O EhZ[fLk1BŤҥW"9q""wE ( ]?q=GUU8H"ڔI1!xL&%шxh\0{H$O0dyy7 ^cj&BN CUUX[#a?ZTTBLPَ\J)r nuԕxa:%UUcdZcP5"bDI3V={nXrR3g9t| \pA)E7v\>zcǏ- [[uuSBls$>|~/e] Sn-1}};Sm`X+N<`C&* KS*%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/selection.png0000644000175100017510000000135015114075001021014 0ustar00runnerrunnerPNG  IHDR(-SgAMA a cHRMz&u0`:pQ<PLTE/Ɯˑˍ˔˞egsY[hTVcLN\CETgitz|KM[02BsuˌlnyCET-Z\i24Eϴceqʔ𛝤홚򒔜񌍖ӎ|}Y[gÃČabnxzfhsqs~#$tRNS/;FD50yrJYk3QH"ɔɈabKGD.TtIMEURIDATc````Tab@,jl(Z(:\zz<(چF\H&F3M-,١v1Z6vP{G'g>~AO/aoQ14L@FVA^!0HŇJ! h(%tEXtdate:create2015-08-31T15:00:15+02:00qH%tEXtdate:modify2015-08-31T15:00:15+02:00ErtEXtSoftwarePaint.NET v3.36%IENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/data/icons/settings.png0000644000175100017510000000461515114075001020676 0ustar00runnerrunnerPNG  IHDR(-SiCCPiccH{LSW TZ "+hW!ǐ(Hg_BmAMeAT|@6dsAppIb (:H@;]799==ݪ", &2!2LV&Ax&+T[7{?>}8$#8b?x0!NXB91LJG\x&~VUQIGc{Klgѵ9ZQGr A>l*w"v9%(ll*?%C8{?'?!߁Xj*^.XC%ݣ|Th`"X "8ê\WhmN2范(+N.>a7|Nc9EpKX4o˔k ^aގ|xGJT(:5a \5;ʐ䰦-UUuhgm3 O ?S8nՒ#),&ju #7w8Vn\}n}A?G ݽ~,ٮɧ/2-7 `o &"!Zc$20YScG';DrCD8& .\ҼPW~BLxyE> ɤJʀrBLI kpfҮ䈵/Su~<1l5`/SH&KNmw>VUcMH퍺/76E:9Ԗѳ[o6^zj5;3⍵A>|ٰ~K``IˤZ8be'svcFs'85 \6V^(tbys|D_t[\(<EpDjs*)ޚS?_Y ̬?RHƞMGڱ58vvk-{˪(Mכ8dqN,Bk^>ˀyG7{G#>Xl^9MthgfTLh,XmXq6+y9 =7*wU54x*8\W*1=h@ Bm5L 2^! H}zItkґqTO"JeT?۹K[Kg7nwPcnPLTEƙƥUUU~~||hhlhhi```^^^jlxdfnegoUUUaciabi000\]j]_mEEEacsEEE\^k555 NPYQR\ͼӘؾ֡ݽٝح¥ҥѣĬҢ̡̬ӫ¢ŋřƬƔuxǰȮǏtwx}uy}|txy}bduPtRNS O= AE堝NV建 a_ *WodcnE"  '+-,%K7bKGD k=tIMEURIDATc`Ya|V6v.v6V'0(8$4>~ȨXA!~>H\|BbRRrJ\0HhZzFxVvN+)_P( ST\R* ++W`P(iQSVohlRU՚[Z::T=}}&jih3L)" to the regex # and handling the cases in the replace_html_tags function or by adding a new dictionary # entry for specific tags if needed. This could be useful for tags like "
" or # "


" or "". The current does not handle tag attributes. # The regex pattern can only match specified tag but could be made generic using the # following pattern: "<(.+?)>(.*?)" and then using rhe dict.get() method in # replace_html_tags function with an empty pattern. _tags = "|".join(REPLACABLE_HTML_TAGS.keys()) HTML_TAG_PATTERN = re.compile(f"<({_tags})>(.*?)") def replace_html_tags(match: re.Match): """Replace HTML tags with reST directives. Args: match: Match object. Returns: New string with reST directives. """ tag = match.group(1) value = match.group(2) new_string = REPLACABLE_HTML_TAGS[tag].format(value) return new_string def datasetnote_option(arg: str) -> tuple[bool, int | None]: """Handles the datasetnote option for the datasetnote directive. Args: arg: Argument to parse (set after the directive). Returns: Returns True to signal the option exists and the number of example note_lines to display if set else None. """ if arg is None: return True, None try: return True, int(arg) except ValueError: return True, None def document_choice_item(item: gds.ChoiceItem) -> str: """Additional documentation for ChoiceItem containing the available choices. Args: item: ChoiceItem to document. Returns: Additional choice documentation. """ doc = "" choices = item.get_prop("data", "choices") if not isinstance(choices, gds.ItemProperty): str_choices = ", ".join(object_description(key) for key, *_ in choices) doc = f"Single choice from: {str_choices}." return doc def document_multiple_choice_item(item: gds.MultipleChoiceItem) -> str: """Additional documentation for MultipleChoiceItem containing the available choices. Args: item: ChoiceItem to document. Returns: Additional choice documentation. """ doc = "" choices = item.get_prop("data", "choices") if not isinstance(choices, gds.ItemProperty): str_choices = ", ".join(object_description(key) for key, *_ in choices) doc = f"Multiple choice from: {str_choices}." return doc def get_auto_help(item: gds.DataItem, dataset: gds.DataSet) -> str: """Get the auto-generated help for a DataItem. Args: item: DataItem to get the help from. Returns: Auto-generated help for the DataItem. """ auto_help = item.get_auto_help(dataset).rstrip(" .") if not auto_help or auto_help in IGNORED_AUTO_HELP: return "" return capitalize_sentences(auto_help) + "\\." def get_choice_help(item: gds.DataItem) -> str: """Get the choice help for a DataItem if it is a ChoiceItem or MultipleChoiceItem. Args: item: DataItem to get the choice help from. Returns: Choice help for the DataItem. If the DataItem is not a ChoiceItem or MultipleChoiceItem, an empty string is returned. """ choice_help = "" if isinstance(item, gds.MultipleChoiceItem): choice_help = document_multiple_choice_item(item) elif isinstance(item, gds.ChoiceItem): choice_help = document_choice_item(item) return choice_help def escape_docline(line: str) -> str: """Escape a line of documentation. Args: line: Line of documentation. Returns: Escaped line of documentation. """ return line.replace("*", "\\*").replace("\n", " ") def is_label_redundant(label: str, item_name: str) -> bool: """Check if the label is redundant with the item name. Args: label: Label to check. item_name: Item name to check against. Returns: True if the label is redundant with the item name, False otherwise. """ item_name = item_name.lower() return not any(word.strip() not in item_name for word in label.lower().split()) def capitalize_sentences(text: str) -> str: """Capitalize each sentence in a text. Args: text: Text to capitalize. Returns: Capitalized text. """ sentences = re.split("(?<=[.!?]) +", text) capitalized_sentences = [sentence.capitalize() for sentence in sentences] return " ".join(capitalized_sentences) class ItemDoc: """Wrapper class around a DataItem used to document it.""" def __init__(self, dataset: gds.DataSet, item: gds.DataItem) -> None: self.item = item self.item_type = stringify_annotation(type(item)) type_ = item.type if not type_: type_ = Any if isinstance(item, gds.ChoiceItem): choices = item.get_prop("data", "choices") if not isinstance(choices, gds.ItemProperty): types = set(type(key) for key, *_ in item.get_prop("data", "choices")) if types: if len(types) == 1: type_ = types.pop() else: type_ = f"Union[{', '.join(t.__name__ for t in types)}]" else: type_ = Any self.type_ = stringify_annotation(type_) label = item.get_prop("display", "label") if is_label_redundant(label, item.get_name()): label = "" if len(label) > 0 and not label.endswith("."): label += "\\." label = re.sub(HTML_TAG_PATTERN, replace_html_tags, label) self.label = label help_ = item._help or "" help_ = capitalize_sentences(help_) if len(help_) > 0 and not help_.endswith("."): help_ += "\\." auto_help = get_auto_help(item, dataset) if auto_help: help_ += " " + auto_help choice_help = get_choice_help(item) if choice_help: help_ += " " + choice_help self.help_ = help_ self.name = item.get_name() self.default = object_description(item.get_default()) def to_function_parameter(self) -> str: """Convert the item to a parameter docstring (e.g. used for Dataset.create()). Returns: Formated docstring of the item. """ return escape_docline( f"\t{self.name} ({self.type_}): {self.label} " f"{self.help_} Default: {self.default}." ) def to_attribute(self) -> str: """Convert the item to an attribute used in the DataSet docstring. Returns: Formated docstring of the item. """ return escape_docline( f"\t{self.name} ({self.item_type}): {self.label} {self.help_} " f"Default: {self.default}." ) class CreateMethodDocumenter(MethodDocumenter): """Custom MethodDocumented specific to DataSet.create() method.""" objtype = "dataset_create" directivetype = MethodDocumenter.objtype priority = 10 + MethodDocumenter.priority option_spec = dict(MethodDocumenter.option_spec) parent: type[gds.DataSet] @classmethod def can_document_member(cls, member, membername, isattr, parent): """Override the parent method to only document the DataSet.create() method.""" is_create_method = ( membername == "create" and isinstance(member, classmethod) and issubclass(member.__class__, gds.DataSet) ) return is_create_method def format_signature(self, **kwargs: Any) -> str: """Override the parent method to dynamically generate a signature for the parent DataSet.create() method depending on the DataItem of the DatSet.""" instance = self.parent() params = [ Parameter( item.get_name(), Parameter.POSITIONAL_OR_KEYWORD, annotation=ItemDoc(instance, item).type_, ) for item in instance.get_items() ] sig = Signature(parameters=params, return_annotation=self.parent) return stringify_signature(sig, **kwargs) def get_doc(self) -> list[list[str]]: """Override the parent method to dynamically generate a docstring for the create method depending on the DataItem of the DatSet. Returns: list of docstring note_lines. """ self.object.__annotations__["return"] = self.parent docstring_lines = [ f"Returns a new instance of :py:class:`{self.parent.__name__}` " f"with the fields set to the given values.", "", "Args:", ] try: dataset = self.parent() except TypeError: return [""] for item in dataset.get_items(): docstring_lines.append(ItemDoc(dataset, item).to_function_parameter()) docstring_lines.extend( ( "", "Returns:", f"\tNew instance of :py:class:`{self.parent.__name__}`.", ) ) docstring = prepare_docstring( "\n".join(docstring_lines), tabsize=self.directive.state.document.settings.tab_width, ) # return [[html.unescape(s) for s in docstring]] return [docstring] class DataSetDocumenter(ClassDocumenter): """ Specialized Documenter subclass for DataSet classes. """ objtype = "dataset" directivetype = ClassDocumenter.objtype priority = 10 + ClassDocumenter.priority option_spec = dict(ClassDocumenter.option_spec) option_spec.update( { "hideattr": bool_option, "hidecreate": bool_option, "showsig": bool_option, "shownote": datasetnote_option, } ) object: Type[gds.DataSet] @classmethod def can_document_member(cls, member, membername, isattr, parent) -> bool: """Override the parent method to only document DataSet classes.""" try: return issubclass(member, gds.DataSet) except TypeError: return False def format_signature(self, **kwargs) -> str: """Override the parent method to dynamically generate a signature for the DataSet class depending on the DataItem of the DatSet and if the 'showsig' option is set. Returns: Formated signature of the DataSet class. """ if self.options.get("showsig", False): return super().format_signature(**kwargs) return "" def get_doc(self) -> list[list[str]]: """Override the parent method to dynamically generate a docstring for the DataSet class depending on the DataItem of the DatSet. By default the dataset attributes are documented but can be hidden using the 'hideattr' option. Returns: Docstring note_lines. """ first_line = getdoc( self.object, self.get_attr, self.config.autodoc_inherit_docstrings, self.object, ) docstring_lines = [ first_line or "", ] if not self.options.get("hideattr", False): docstring_lines.extend(("", "Attributes:")) try: dataset = self.object() except TypeError: # May occur when trying to instantiate an abstract class return [[""]] for item in dataset.get_items(): docstring_lines.append(ItemDoc(dataset, item).to_attribute()) return [ prepare_docstring( "\n".join(docstring_lines), tabsize=self.directive.state.document.settings.tab_width, ) ] def add_content(self, more_content: Any | None) -> None: """Override the parent method to hide the create method documentation if the 'hidecreate' option is used. Also add a datasetnote directive if the 'shownote' option is used. Args: more_content: Additional content to show/hide. """ source = self.get_sourcename() hide_create: bool = self.options.get("hidecreate", False) create_method_overwritten = "create" in self.object.__dict__ if hide_create or self.options.inherited_members and not hide_create: if self.options.exclude_members is None: self.options["exclude-members"] = set(("create",)) else: self.options["exclude-members"].add("create") super().add_content(more_content=more_content) if not hide_create and not create_method_overwritten: fullname = self.fullname + ".create" method_documenter = CreateMethodDocumenter( self.directive, fullname, indent=self.content_indent ) method_documenter.generate(more_content=more_content) show_note, example_lines = self.options.get("shownote", (False, None)) if show_note: self.add_line( ".. datasetnote:: " f"{self.object.__module__ + '.' + self.object.__qualname__} " f"{example_lines or ''}", source, ) class DatasetNoteDirective(SphinxDirective): """Custom directive to add a note about how to instanciate and modify a DataSet class.""" required_arguments = 1 # the class name is a required argument optional_arguments = 1 # the number of example note_lines to display is optional final_argument_whitespace = True has_content = True def __init__( self, name: str, arguments: list[str], options: dict[str, Any], content: StringList, lineno: int, content_offset: int, block_text: str, state: RSTState, state_machine: RSTStateMachine, ) -> None: super().__init__( name, arguments, options, content, lineno, content_offset, block_text, state, state_machine, ) self.current_line_offset = self.content_offset def add_lines(self, stringlist: StringList, *lines: str) -> None: """Add lines to the stringlist. Args: stringlist: StringList to add the lines to. lines: Lines to add. """ source = self.get_source_info()[0] new_offset = self.current_line_offset + 1 i = new_offset for i, line in enumerate(lines, start=new_offset): stringlist.append(line, source=source, offset=i) new_offset = i self.current_line_offset += new_offset def add_code_lines(self, stringlist: StringList, *lines: str) -> None: """Add code lines to the stringlist. Args: stringlist: StringList to add the lines to. lines: Lines to add. """ source = self.get_source_info()[0] new_offset = self.current_line_offset + 1 tab = " " * self.state.document.settings.tab_width stringlist.append("", source=source, offset=new_offset) stringlist.append( ".. code-block:: python", source=source, offset=new_offset + 2 ) stringlist.append("", source=source, offset=new_offset + 3) new_offset += 4 i = new_offset for i, line in enumerate(lines, start=new_offset): stringlist.append(tab + line, source=source, offset=i) new_offset = i + 1 stringlist.append("", source=source, offset=new_offset) self.current_line_offset += new_offset def run(self): """Run the directive. Returns: list of returned nodes. """ class_name = self.arguments[0] example_lines: int | None if len(self.arguments) > self.required_arguments: example_lines = int(self.arguments[self.required_arguments]) else: example_lines = None cls: Type[gds.DataSet] try: # Try to import the class module_name, class_name = class_name.rsplit(".", 1) module = __import__(module_name, fromlist=[class_name]) cls = getattr(module, class_name) except Exception as e: # pylint: disable=broad-except logging.error(f"Failed to import class {class_name}: {e}") instance_str = f"Failed to import class {class_name}" note_node = nodes.error(instance_str) return [note_node] # Create an instance of the class and get its string representation instance = cls() instance_str = str(instance) items = instance.get_items() formated_args = ", ".join( f"{item.get_name()}={object_description(item.get_value(instance))}" for item in instance.get_items() ) node = nodes.note() # Create a new ViewList instance and add your rst text to it self.current_line_offset = self.content_offset note_lines = StringList() self.add_lines( note_lines, f"To instanciate a new :py:class:`{cls.__name__}` dataset, you can use the " f"classmethod :py:meth:`{cls.__name__}.create()` like this:", "", ) self.add_code_lines( note_lines, f"{cls.__name__}.create({formated_args})", ) self.add_lines( note_lines, f"You can also first instanciate a default :py:class:`{cls.__name__}` " f"and then set the fields like this:", ) example_lines = min(len(items), example_lines) if example_lines else len(items) code_lines = [ f"param = {cls.__name__}()", *( f"param.{items[i].get_name()} = " f"{object_description(items[i].get_value(instance))}" for i in range(example_lines) ), ] if len(items) > example_lines: code_lines.append("...") self.add_code_lines(note_lines, *code_lines) nested_parse_with_titles(self.state, note_lines, node) return [node] def setup(app: Sphinx) -> None: """Setup extension""" app.setup_extension("sphinx.ext.autodoc") app.add_autodocumenter(CreateMethodDocumenter) app.add_autodocumenter(DataSetDocumenter) app.add_directive("datasetnote", DatasetNoteDirective) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/conv.py0000644000175100017510000002122015114075001017237 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ DataSet class conversion/creation functions =========================================== Update and restore datasets --------------------------- .. autofunction:: guidata.dataset.update_dataset .. autofunction:: guidata.dataset.restore_dataset Create dataset classes ---------------------- .. autofunction:: guidata.dataset.create_dataset_from_func .. autofunction:: guidata.dataset.create_dataset_from_dict """ from __future__ import annotations import importlib import inspect from typing import TYPE_CHECKING, Any import guidata.dataset.dataitems as gdi import guidata.dataset.datatypes as gdt from guidata.io.jsonfmt import JSONReader, JSONWriter if TYPE_CHECKING: import guidata.dataset.datatypes as gdt # ============================================================================== # Updating, restoring datasets # ============================================================================== def update_dataset( dest: gdt.DataSet, source: Any | dict[str, Any], visible_only: bool = False ) -> None: """Update `dest` dataset items from `source` dataset. Args: dest (DataSet): The destination dataset object to update. source (Union[Any, Dict[str, Any]]): The source object or dictionary containing matching attribute names. visible_only (bool): If True, update only visible items. Defaults to False. For each DataSet item, the function will try to get the attribute of the same name from the source. If the attribute exists in the source object or the key exists in the dictionary, it will be set as the corresponding attribute in the destination dataset. Computed items are automatically skipped as they are read-only and their values are calculated automatically based on other dataset items. Returns: None """ for item in dest._items: key = item._name if isinstance(item.get_prop("data", "computed", None), gdt.ComputedProp): continue # Skip computed items if hasattr(source, key): try: hide = item.get_prop_value("display", source, "hide", False) except AttributeError: # FIXME: Remove this try...except hide = False if visible_only and hide: continue setattr(dest, key, getattr(source, key)) elif isinstance(source, dict) and key in source: setattr(dest, key, source[key]) def restore_dataset(source: gdt.DataSet, dest: Any | dict[str, Any]) -> None: """Restore `dest` dataset items from `source` dataset. Args: source (DataSet): The source dataset object to restore from. dest (Union[Any, Dict[str, Any]]): The destination object or dictionary. This function is almost the same as `update_dataset` but requires the source to be a DataSet instead of the destination. Symmetrically from `update_dataset`, `dest` may also be a dictionary. Computed items are automatically skipped when restoring to another dataset object (since computed values should be recalculated), but are included when restoring to a dictionary (since dictionaries store all current values). Returns: None """ for item in source._items: key = item._name value = getattr(source, key) if hasattr(dest, key): if isinstance(item.get_prop("data", "computed", None), gdt.ComputedProp): continue # Skip computed items if destination is not a dictionary try: setattr(dest, key, value) except AttributeError: # This attribute is a property, skipping this iteration continue elif isinstance(dest, dict): dest[key] = value # ============================================================================== # Generating a dataset class from a function signature # ============================================================================== def get_arg_info(func) -> dict[str, tuple[Any, Any]]: """Returns a dictionary where keys are the function argument names and values are tuples containing (default argument value, argument data type). Note: If the argument has no default value, it will be set to None. If the argument has no data type annotation, it will be set to None. Args: func: The function to get argument info from. Returns: The argument info dictionary. """ signature = inspect.signature(func) arg_info = {} for name, param in signature.parameters.items(): default_value = param.default if param.default != param.empty else None data_type = param.annotation if param.annotation != param.empty else None arg_info[name] = (default_value, data_type) return arg_info def __get_dataitem_from_type(data_type: Any) -> gdi.DataItem: """Returns a DataItem instance from a data type. Args: data_type: The data type to get the DataItem from. Returns: The DataItem. """ if not isinstance(data_type, str): # In case we are not using "from __future__ import annotations" data_type = data_type.__name__ data_type = data_type.split("[")[0].split(".")[-1] typemap = { "int": gdi.IntItem, "float": gdi.FloatItem, "bool": gdi.BoolItem, "str": gdi.StringItem, "dict": gdi.DictItem, "ndarray": gdi.FloatArrayItem, } ditem_klass = typemap.get(data_type) if ditem_klass is None: raise ValueError(f"Unsupported data type: {data_type}") return ditem_klass def create_dataset_from_func(func) -> gdt.DataSet: """Creates a DataSet class from a function signature. Args: func: The function to create the DataSet from. Returns: The DataSet class. Note: Supported data types are: int, float, bool, str, dict, np.ndarray. """ klassname = "".join([s.capitalize() for s in func.__name__.split("_")]) + "DataSet" arg_info = get_arg_info(func) klassattrs = {} for name, (default_value, data_type) in arg_info.items(): if data_type is None: raise ValueError(f"Argument '{name}' has no data type annotation.") ditem = __get_dataitem_from_type(data_type) klassattrs[name] = ditem(name, default=default_value) return type(klassname, (gdt.DataSet,), klassattrs) # ============================================================================== # Generating a dataset class from a dictionary # ============================================================================== def create_dataset_from_dict( dictionary: dict[str, Any], klassname: str | None = None ) -> gdt.DataSet: """Creates a DataSet class from a dictionary. Args: dictionary: The dictionary to create the DataSet class from. klassname: The name of the DataSet class. If None, the name is 'DictDataSet'. Returns: The DataSet class. Note: Supported data types are: int, float, bool, str, dict, np.ndarray. """ klassname = "DictDataSet" if klassname is None else klassname klassattrs = {} for name, value in dictionary.items(): ditem = __get_dataitem_from_type(type(value)) klassattrs[name] = ditem(name, default=value) return type(klassname, (gdt.DataSet,), klassattrs) # ============================================================================== # JSON serialization/deserialization of datasets # ============================================================================== def dataset_to_json(param: gdt.DataSet) -> str: """Serialize dataset to JSON string. Args: param: dataset (gdt.DataSet) Returns: JSON string representation of the dataset """ writer = JSONWriter(None) # No filename, we'll get JSON text # Store the class name so we can deserialize to the correct type writer.write(param.__class__.__module__, "class_module") writer.write(param.__class__.__name__, "class_name") param.serialize(writer) return writer.get_json() def json_to_dataset(json_str: str) -> gdt.DataSet: """Deserialize dataset from JSON string. Args: json_str: JSON string representation Returns: Deserialized dataset object """ reader = JSONReader(json_str) # Read the class information class_module = reader.read("class_module") class_name = reader.read("class_name") module = importlib.import_module(class_module) param_class = getattr(module, class_name) # Create instance and deserialize param: gdt.DataSet = param_class() param.deserialize(reader) return param ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/dataitems.py0000644000175100017510000015064615114075001020264 0ustar00runnerrunner# # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Data items ---------- Base class ^^^^^^^^^^ .. autoclass:: guidata.dataset.DataItem Numeric items ^^^^^^^^^^^^^ .. autoclass:: guidata.dataset.FloatItem :members: .. autoclass:: guidata.dataset.IntItem :members: .. autoclass:: guidata.dataset.FloatArrayItem :members: Text items ^^^^^^^^^^ .. autoclass:: guidata.dataset.StringItem :members: .. autoclass:: guidata.dataset.TextItem :members: Date and time items ^^^^^^^^^^^^^^^^^^^ .. autoclass:: guidata.dataset.DateItem :members: .. autoclass:: guidata.dataset.DateTimeItem :members: Color items ^^^^^^^^^^^ .. autoclass:: guidata.dataset.ColorItem :members: File items ^^^^^^^^^^ .. autoclass:: guidata.dataset.FileSaveItem :members: .. autoclass:: guidata.dataset.FileOpenItem :members: .. autoclass:: guidata.dataset.FilesOpenItem :members: .. autoclass:: guidata.dataset.DirectoryItem :members: Choice items ^^^^^^^^^^^^ .. autoclass:: guidata.dataset.BoolItem :members: .. autoclass:: guidata.dataset.ChoiceItem :members: .. autoclass:: guidata.dataset.MultipleChoiceItem :members: .. autoclass:: guidata.dataset.ImageChoiceItem :members: Other items ^^^^^^^^^^^ .. autoclass:: guidata.dataset.ButtonItem :members: .. autoclass:: guidata.dataset.DictItem :members: .. autoclass:: guidata.dataset.FontFamilyItem :members: """ from __future__ import annotations import datetime import os import re from collections.abc import Callable, Sequence from enum import Enum, EnumMeta from typing import TYPE_CHECKING, Any, Generic, Iterable, TypeVar import numpy as np from guidata.config import _ from guidata.dataset.datatypes import DataItem, DataSet, ItemProperty if TYPE_CHECKING: from numpy.typing import NDArray from guidata.io import ( HDF5Reader, HDF5Writer, INIReader, INIWriter, JSONReader, JSONWriter, ) _T = TypeVar("_T") class LabeledEnum(str, Enum): """Enum with invariant key and optional translated label. This enum supports Pattern 1: - .value is the invariant key (used for API, serialization, comparisons) - .label is the human-readable string (translated or not) used for UI display - Seamless interoperability: enum_member == string_value works Example: class MyEnum(LabeledEnum): OPTION1 = "opt1", _("Option 1") OPTION2 = "opt2", _("Option 2") OPTION3 = "opt3" # Uses key as label # These are equivalent: MyEnum.OPTION1 == "opt1" # True func(MyEnum.OPTION1) == func("opt1") # Same behavior """ def __new__(cls, value, label=None): if label is None: label = value # Initialize the str part with the value for consistent string behavior # This ensures string operations, comparisons, and isinstance(obj, str) work obj = str.__new__(cls, value) obj._value_ = value # stable key obj.label = label # UI label (possibly translated) return obj def __str__(self) -> str: """Return the label for display purposes.""" return str(self.label) def __repr__(self) -> str: """Return the string representation of the enum value. This is crucial for code generation where enum members are used as default parameter values. By returning the string value directly, we avoid the need for makefun to resolve enum class references in generated function signatures. """ return repr(self._value_) def __format__(self, format_spec): """Use the label when formatting for display.""" return format(str(self.label), format_spec) def __eq__(self, other) -> bool: """Enable seamless comparison between enum members and their string values.""" if isinstance(other, self.__class__): return self._value_ == other._value_ elif isinstance(other, str): return self._value_ == other return False def __hash__(self) -> int: """Use the value for hashing to enable set operations with strings.""" return hash(self._value_) class NumericTypeItem(DataItem): """Numeric data item Args: label: item name default: default value (optional) min: minimum value (optional) max: maximum value (optional) nonzero: if True, zero is not a valid value (optional) unit: physical unit (optional) even: if True, even values are valid, if False, odd values are valid if None (default), ignored (optional) slider: if True, shows a slider widget right after the line edit widget (default is False) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type: type[int | float] def __init__( self, label: str, default: float | int | None = None, min: float | int | None = None, max: float | int | None = None, nonzero: bool | None = None, unit: str = "", help: str = "", check: bool = True, allow_none: bool = False, ) -> None: super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("data", min=min, max=max, nonzero=nonzero, check_value=check) self.set_prop("display", unit=unit) def get_auto_help(self, instance: DataSet) -> str: """Override DataItem method""" auto_help = {int: _("integer"), float: _("float")}[self.type] _min = self.get_prop_value("data", instance, "min") _max = self.get_prop_value("data", instance, "max") nonzero = self.get_prop_value("data", instance, "nonzero") unit = self.get_prop_value("display", instance, "unit") if _min is not None and _max is not None: auto_help += _(" between ") + str(_min) + _(" and ") + str(_max) elif _min is not None: auto_help += _(" higher than ") + str(_min) elif _max is not None: auto_help += _(" lower than ") + str(_max) if nonzero: auto_help += ", " + _("non zero") if unit: auto_help += ", %s %s" % (_("unit:"), unit) return auto_help def format_string( self, instance: DataSet, value: float | int, fmt: str, func: Callable ) -> str: """Override DataItem method""" text = fmt % (func(value),) # We add directly the unit to 'text' (instead of adding it # to 'fmt') to avoid string formatting error if '%' is in unit unit = self.get_prop_value("display", instance, "unit", "") if unit: text += " " + unit return text def check_value(self, value: float | int, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False if self.get_prop("data", "nonzero") and value == 0: if raise_exception: raise ValueError("Zero is not a valid value") return False _min = self.get_prop("data", "min") if _min is not None and value < _min: if raise_exception: raise ValueError(f"Value {value} is lower than minimum {_min}") return False _max = self.get_prop("data", "max") if _max is not None and value > _max: if raise_exception: raise ValueError(f"Value {value} is greater than maximum {_max}") return False return True def from_string(self, value: str) -> Any | None: """Override DataItem method""" # String may contains numerical operands: if re.match(r"^([\d\(\)\+/\-\*.]|e)+$", value): # pylint: disable=eval-used # pylint: disable=broad-except try: # pylint: disable=not-callable return self.type(eval(value)) except: # noqa pass return None class FloatItem(NumericTypeItem): """Construct a float data item Args: label: item name default: default value (optional) min: minimum value (optional) max: maximum value (optional) nonzero: if True, zero is not a valid value (optional) unit: physical unit (optional) even: if True, even values are valid, if False, odd values are valid if None (default), ignored (optional) slider: if True, shows a slider widget right after the line edit widget (default is False) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type = float def __init__( self, label: str, default: float | None = None, min: float | None = None, max: float | None = None, nonzero: bool | None = None, unit: str = "", step: float = 0.1, slider: bool = False, help: str = "", check: bool = True, allow_none: bool = False, ) -> None: super().__init__( label, default=default, min=min, max=max, nonzero=nonzero, unit=unit, help=help, check=check, allow_none=allow_none, ) self.set_prop("display", slider=slider) self.set_prop("data", step=step) def _set_value_with_validation( self, instance: Any, value: Any, force_allow_none: bool = False ) -> None: """Override DataItem._set_value_with_validation to convert integers to float""" # Try to convert NumPy numeric types to Python float # (will convert silently either floating point or integer types to float) try: if hasattr(value, "dtype") and not isinstance(value, self.type): value = self.type(value) except (TypeError, ValueError): pass # Now acceptable values could be either float or int # (no more NumPy types at this point) if isinstance(value, int): value = float(value) super()._set_value_with_validation(instance, value, force_allow_none) def get_value_from_reader( self, reader: HDF5Reader | JSONReader | INIReader ) -> float: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_float() class IntItem(NumericTypeItem): """Construct an integer data item Args: label: item name default: default value (optional) min: minimum value (optional) max: maximum value (optional) nonzero: if True, zero is not a valid value (optional) unit: physical unit (optional) even: if True, even values are valid, if False, odd values are valid if None (default), ignored (optional) slider: if True, shows a slider widget right after the line edit widget (default is False) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type = int def __init__( self, label: str, default: int | None = None, min: int | None = None, max: int | None = None, nonzero: bool | None = None, unit: str = "", even: bool | None = None, slider: bool = False, help: str = "", check: bool = True, allow_none: bool = False, ) -> None: super().__init__( label, default=default, min=min, max=max, nonzero=nonzero, unit=unit, help=help, check=check, allow_none=allow_none, ) self.set_prop("data", even=even) self.set_prop("display", slider=slider) def get_auto_help(self, instance: DataSet) -> str: """Override DataItem method""" auto_help = super().get_auto_help(instance) even = self.get_prop_value("data", instance, "even") if even is not None: if even: auto_help += ", " + _("even") else: auto_help += ", " + _("odd") return auto_help def _set_value_with_validation( self, instance: Any, value: Any, force_allow_none: bool = False ) -> None: """Override DataItem._set_value_with_validation to convert NumPy numeric types to int""" # Try to convert NumPy integer types to Python int # (will convert silently only integer types to int) try: if isinstance(value, np.integer): value = self.type(value) except (TypeError, ValueError): pass super()._set_value_with_validation(instance, value, force_allow_none) def check_value(self, value: int, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True valid = super().check_value(value, raise_exception=raise_exception) if not valid: return False even = self.get_prop("data", "even") if even is not None: is_even = value // 2 == value / 2.0 if (even and not is_even) or (not even and is_even): if raise_exception: oddity = "even" if even else "odd" raise ValueError(f"Value {value} is not {oddity}") return False return True def get_value_from_reader(self, reader: HDF5Reader | JSONReader | INIReader) -> Any: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_int() class StringItem(DataItem): """Construct a string data item Args: label: item name default: default value (optional) notempty: if True, empty string is not a valid value (optional) wordwrap: toggle word wrapping (optional) password: if True, text is hidden (optional) regexp: regular expression for checking value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (ineffective for strings) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) readonly: if True, the item is read-only (optional, default=False) """ type: Any = str def __init__( self, label: str, default: str | None = None, notempty: bool | None = None, wordwrap: bool = False, password: bool = False, regexp: str | None = None, help: str = "", check: bool = True, allow_none: bool = False, readonly: bool = False, ) -> None: super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("data", notempty=notempty, regexp=regexp) self.set_prop( "display", wordwrap=wordwrap, password=password, readonly=readonly ) def get_auto_help(self, instance: DataSet) -> str: """Override DataItem method""" auto_help = _("string") notempty = self.get_prop_value("data", instance, "notempty") if notempty: auto_help += ", " + _("not empty") regexp = self.get_prop_value("data", instance, "regexp") if regexp: # Wrap regexp in backticks to prevent Sphinx from interpreting # it as a reference auto_help += ", " + _("regexp:") + f" ``{regexp}``" return auto_help def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True # Convert LabeledEnum members to their string values for validation if isinstance(value, LabeledEnum): value = value.value if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False # Get all data properties at once to reduce property lookups data_props = self._props.get("data", {}) notempty = data_props.get("notempty") if notempty and not value: if raise_exception: raise ValueError("Empty string is not a valid value") return False regexp = data_props.get("regexp") if regexp is not None: ok = bool(re.match(regexp, value or "")) if not ok and raise_exception: raise ValueError(f"Value {value} does not match regexp {regexp}") return ok return True def from_string(self, value: str) -> str: """Override DataItem method""" return value def get_string_value(self, instance: DataSet) -> str: """Override DataItem method""" strval = super().get_string_value(instance) if self.get_prop("display", "password"): return "*" * len(strval) return strval def _set_value_with_validation( self, instance: Any, value: Any, force_allow_none: bool = False ) -> None: """Override DataItem._set_value_with_validation to handle LabeledEnum members""" # Convert LabeledEnum members to their string values before validation if isinstance(value, LabeledEnum): value = value.value super()._set_value_with_validation(instance, value, force_allow_none) def get_value_from_reader(self, reader: HDF5Reader | JSONReader | INIReader) -> Any: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_unicode() class TextItem(StringItem): """Construct a text data item (multiline string) Args: label: item name default: default value (optional) notempty: if True, empty string is not a valid value (optional) wordwrap: toggle word wrapping (optional) help: text shown in tooltip (optional) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) readonly: if True, the item is read-only (optional, default=False) regexp: regular expression for value validation (optional) """ def __init__( self, label: str, default: str | None = None, notempty: bool | None = None, wordwrap: bool = True, help: str = "", allow_none: bool = False, readonly: bool = False, regexp: str | None = None, ) -> None: super().__init__( label, default=default, notempty=notempty, wordwrap=wordwrap, help=help, allow_none=allow_none, readonly=readonly, regexp=regexp, ) class BoolItem(DataItem): """Construct a boolean data item Args: text: form's field name (optional) label: item name default: default value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type = bool def __init__( self, text: str = "", label: str = "", default: bool | None = None, help: str = "", check: bool = True, allow_none: bool = False, ) -> None: super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("display", text=text) def get_string_value(self, instance: DataSet) -> str: """Override DataItem method""" value_str = "☑" if self.get_value(instance) else "☐" label = self.get_prop_value("display", self, "label") text = self.get_prop_value("display", instance, "text", "") if label and text: value_str += " " + text return value_str def get_value_from_reader( self, reader: HDF5Reader | JSONReader | INIReader ) -> bool: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_bool() def __set__(self, instance: DataSet, value: bool | None) -> None: """Set data item's value, ensuring it's a Python bool This override ensures that numpy.bool_ values are converted to Python bool, which is necessary for compatibility with Qt APIs that strictly require Python bool type (e.g., QAction.setChecked()). Args: instance: instance of the DataSet value: value to set (will be converted to Python bool if not None) """ if value is not None: value = bool(value) super().__set__(instance, value) class DateItem(DataItem): """DataSet data item Args: label: item label default: default value (optional) format: date format (as in :py:func:`datetime.date.strftime`) help: text displayed on data item's tooltip check: check value (default: True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type = datetime.date def __init__( self, label: str, default: datetime.date | None = None, format: str | None = None, help: str | None = "", check: bool | None = True, allow_none: bool = False, ) -> None: super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("display", format=format) class DateTimeItem(DateItem): """DataSet data item Args: label: item label default: default value (optional) format: date format (as in :py:func:`datetime.date.strftime`) help: text displayed on data item's tooltip check: check value (default: True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type = datetime.datetime def __init__( self, label: str, default: datetime.datetime | None = None, format: str | None = None, help: str | None = "", check: bool | None = True, allow_none: bool = False, ) -> None: super().__init__(label, default, format, help, check, allow_none=allow_none) class ColorItem(StringItem): """Construct a color data item Args: label: item name default: default value (optional) notempty: if True, empty string is not a valid value (optional) regexp: regular expression for checking value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (ineffective for strings) allow_none: if True, None is a valid value regardless of other constraints (optional, default=True) """ def __init__( self, label: str, default: str | None = None, notempty: bool | None = None, regexp: str | None = None, help: str = "", check: bool = True, allow_none: bool = True, ) -> None: super().__init__( label, default=default, notempty=notempty, regexp=regexp, help=help, check=check, allow_none=allow_none, ) def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False from qtpy import QtGui as QG ok = QG.QColor(value).isValid() if not ok and raise_exception: raise ValueError(f"Value {value} is not a valid color") return ok def get_value_from_reader(self, reader: HDF5Reader | JSONReader | INIReader) -> str: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" # Using read_str converts `numpy.string_` to `str` -- otherwise, # when passing the string to a QColor Qt object, any numpy.string_ will # be interpreted as no color (black) return reader.read_str() class FileSaveItem(StringItem): """Construct a path data item for a file to be saved Args: label: item name formats: wildcard filter default: default value (optional) basedir: default base directory (optional) regexp: regular expression for checking value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) all_files_first: if True, "All files" is the first item in the list (optional, default=False) """ def __init__( self, label: str, formats: tuple[str, ...] | str = "*", default: list[str] | str | None = None, basedir: str | None = None, all_files_first: bool = False, regexp: str | None = None, help: str = "", check: bool = True, allow_none: bool = False, ) -> None: default = os.path.join(*default) if isinstance(default, list) else default super().__init__( label, default=default, regexp=regexp, help=help, check=check, allow_none=allow_none, ) if isinstance(formats, str): formats = [formats] # type:ignore self.set_prop("data", formats=formats) self.set_prop("data", basedir=basedir) self.set_prop("data", all_files_first=all_files_first) self.set_prop("display", func=os.path.basename) def get_auto_help(self, instance: DataSet) -> str: """Override DataItem method""" formats = self.get_prop("data", "formats") return ( _("all file types") if formats == ["*"] else _("supported file types:") + " *.%s" % ", *.".join(formats) ) def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False ok = len(value) > 0 if not ok and raise_exception: raise ValueError("Empty string is not a valid value") return ok def from_string(self, value) -> str: """Override DataItem method""" return self.add_extension(value) def add_extension(self, value) -> str: """Add extension to filename `value`: possible value for data item""" value = str(value) formats = self.get_prop("data", "formats") if ( len(formats) == 1 and formats[0] != "*" and not value.endswith("." + formats[0]) and len(value) > 0 ): return value + "." + formats[0] return value class FileOpenItem(FileSaveItem): """Construct a path data item for a file to be opened Args: label: item name formats: wildcard filter default: default value (optional) basedir: default base directory (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) """ def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False ok = os.path.exists(value) and os.path.isfile(value) if not ok and raise_exception: raise ValueError(f"File {value} does not exist or is not a file") return ok class FilesOpenItem(FileSaveItem): """Construct a path data item for multiple files to be opened. Args: label: item name formats: wildcard filter default: default value (optional) basedir: default base directory (optional) regexp: regular expression for checking value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) all_files_first: if True, "All files" is the first item in the list (optional, default=False) """ type = list def __init__( self, label: str, formats: str = "*", default: list[str] | str | None = None, basedir: str | None = None, all_files_first: bool = False, regexp: str | None = None, help: str = "", check: bool = True, allow_none: bool = False, ) -> None: if isinstance(default, str): default = [default] StringItem.__init__( self, label, default=default, regexp=regexp, help=help, check=check, allow_none=allow_none, ) if isinstance(formats, str): formats = [formats] # type:ignore self.set_prop("data", formats=formats) self.set_prop("data", basedir=basedir) self.set_prop("data", all_files_first=all_files_first) self.set_prop("display", func=self.paths_basename) @staticmethod def paths_basename(paths: str | list[str]): """Return the basename of a path or a list of paths""" return ( [os.path.basename(p) for p in paths] if isinstance(paths, list) else os.path.basename(paths) ) def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if value is None: if raise_exception: raise ValueError("Value cannot be None") return False allexist = True for path in value: allexist = allexist and os.path.exists(path) and os.path.isfile(path) if not allexist and raise_exception: raise ValueError(f"Some files do not exist or are not files: {value}") return allexist def from_string(self, value: Any) -> list[str]: # type:ignore """Override DataItem method""" value = eval(value) if value.endswith("']") or value.endswith('"]') else [value] return [self.add_extension(path) for path in value] def serialize( self, instance: DataSet, writer: HDF5Writer | JSONWriter | INIWriter, ) -> None: """Serialize this item""" value = self.get_value(instance) if value is not None and not isinstance(value, (tuple, list)): value = [value] writer.write_sequence([fname.encode("utf-8") for fname in value]) def get_value_from_reader( self, reader: HDF5Reader | JSONReader | INIReader ) -> list[str]: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return [fname for fname in reader.read_sequence()] class DirectoryItem(StringItem): """Construct a path data item for a directory. Args: label: item name default: default value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) """ def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False ok = os.path.exists(value) and os.path.isdir(value) if not ok and raise_exception: raise ValueError(f"Directory {value} does not exist or is not a directory") return ok class FirstChoice: """ Special object that means the default value of a ChoiceItem is the first item. """ pass class ChoiceItem(DataItem, Generic[_T]): """Construct a data item for a list of choices. Args: label: item name choices: string list or (key, label) list function of two arguments (item, value) returning a list of tuples (key, label, image) where image is an icon path, a QIcon instance or a function of one argument (key) returning a QIcon instance default: default value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) radio: if True, shows radio buttons instead of a combo box (default is False) size: size (optional) of the combo box or button widget (for radio buttons) allow_none: if True, None is a valid value regardless of other constraints (optional, default=True) """ type = Any def __init__( self, label: str, choices: Iterable[_T] | Callable[[Any], Iterable[_T]] | EnumMeta, default: tuple[()] | type[FirstChoice] | int | _T | Enum | None = FirstChoice, help: str = "", check: bool = True, radio: bool = False, size: tuple[int, int] | None = None, allow_none: bool = True, ) -> None: self._enum_cls: type[Enum] | None = None _choices_data: Any if isinstance(choices, EnumMeta): self._enum_cls = choices # Build _choices_data as [(m.name, m.label, None)] for each member _choices_data = [ (m.name, getattr(m, "label", str(m.value)), None) for m in choices ] # Only coerce if default is a real value if default not in (FirstChoice, None): default = self._enum_coerce_in(default) elif isinstance(choices, Callable): _choices_data = ItemProperty(choices) else: _choices_data = [] for idx, choice in enumerate(choices): _choices_data.append(self._normalize_choice(idx, choice)) if ( default is FirstChoice and isinstance(_choices_data, Sequence) and isinstance(_choices_data[0], Sequence) ): default = _choices_data[0][0] elif default is FirstChoice: default = None super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("data", choices=_choices_data) self.set_prop("display", radio=radio) self.set_prop("display", size=size) def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True choices = self.get_prop("data", "choices", []) if not isinstance(choices, ItemProperty): values = [v for v, _k, _i in choices] # Check if value is in values, with special handling for sequences # (JSON serialization converts tuples to lists, so we need to compare # their content rather than using strict equality) value_found = False for v in values: if v == value: value_found = True break # If both are sequences (list/tuple), compare their content if isinstance(v, (list, tuple)) and isinstance(value, (list, tuple)): if len(v) == len(value) and all(a == b for a, b in zip(v, value)): value_found = True break if not value_found: if raise_exception: raise ValueError( f"Invalid value '{value}' (valid values: {values})" ) return False return True def _enum_coerce_in(self, v: object) -> str: """Accept Enum member | name | value | label | index → return string name (the storage key). """ if self._enum_cls is None: raise TypeError("No Enum class set in ChoiceItem") # Enum member if isinstance(v, self._enum_cls): return v.name # name (e.g. "LINEAR") if isinstance(v, str) and v in self._enum_cls.__members__: return v # value (stable key) or label (UI string) for m in self._enum_cls: if v == m.value or v == getattr(m, "label", str(m.value)): return m.name # index if isinstance(v, int): members = list(self._enum_cls) if 0 <= v < len(members): return members[v].name raise ValueError( f"Invalid value '{v}' for {self._enum_cls.__name__} " f"(expected a member, name, value, label, or index)" ) def __get__(self, instance: DataSet, owner: type | None = None) -> Any: """Override DataItem.__get__ to return Enum member if applicable""" # descriptor for user access → return Enum member if applicable if instance is None: return self raw = super().__get__(instance, owner) # stored key (string) from DataItem if self._enum_cls is not None and raw is not None: return self._enum_cls[raw] # Enum member return raw # legacy: keep as-is def get_value(self, instance: DataSet) -> str | None: """Override DataItem.get_value""" # guidata internals should call this; keep it returning the raw key return super().__get__(instance, instance.__class__) # string (or None) def _set_value_with_validation( self, instance: Any, value: Any, force_allow_none: bool = False ) -> None: """Override DataItem._set_value_with_validation to accept Enum members""" if self._enum_cls is not None and value is not None: value = self._enum_coerce_in(value) # → member.name super()._set_value_with_validation(instance, value, force_allow_none) def _normalize_choice( self, idx: int, choice_tuple: tuple[Any, ...] ) -> tuple[int, str, None] | tuple[str, str, None]: if isinstance(choice_tuple, tuple): key, value = choice_tuple else: key = idx value = choice_tuple return (key, value, None) def get_string_value(self, instance: DataSet) -> str: """Override DataItem method""" value = self.get_value(instance) choices = self.get_prop_value("data", instance, "choices") # print "ShowChoiceWidget:", choices, value for choice in choices: if choice[0] == value: return str(choice[1]) return DataItem.get_string_value(self, instance) class MultipleChoiceItem(ChoiceItem): """Construct a data item for a list of choices -- multiple choices can be selected Args: label: item name choices: string list or (key, label) list default: default value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=True) """ def __init__( self, label: str, choices: list[str], default: tuple[()] = (), help: str = "", check: bool = True, allow_none: bool = True, ) -> None: super().__init__( label, choices, default, help, check=check, allow_none=allow_none ) self.set_prop("display", shape=(1, -1)) def check_value(self, value: str, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, tuple): if raise_exception: raise ValueError(f"Invalid value '{value}' (expecting tuple)") return False for val in value: if val not in [v for v, _k, _i in self.get_prop("data", "choices", [])]: if raise_exception: raise ValueError(f"Invalid value '{value}'") return False return True def horizontal(self, row_nb: int = 1) -> MultipleChoiceItem: """ Method to arange choice list horizontally on `n` rows Example: nb = MultipleChoiceItem("Number", ['1', '2', '3'] ).horizontal(2) """ self.set_prop("display", shape=(row_nb, -1)) return self def vertical(self, col_nb: int = 1) -> MultipleChoiceItem: """ Method to arange choice list vertically on `n` columns Example: nb = MultipleChoiceItem("Number", ['1', '2', '3'] ).vertical(2) """ self.set_prop("display", shape=(-1, col_nb)) return self def serialize( self, instance: DataSet, writer: HDF5Writer | JSONWriter | INIWriter, ) -> None: """Serialize this item""" value = self.get_value(instance) seq = [] _choices = self.get_prop_value("data", instance, "choices") for key, _label, _img in _choices: seq.append(key in value) writer.write_sequence(seq) def deserialize( self, instance: DataSet, reader: HDF5Reader | JSONReader | INIReader, ) -> None: """Deserialize this item""" try: flags = reader.read_sequence() except KeyError: self.set_default(instance) else: # We could have trouble with objects providing their own choice # function which depend on not yet deserialized values _choices = self.get_prop_value("data", instance, "choices") value = [] for idx, flag in enumerate(flags): if flag: value.append(_choices[idx][0]) self.__set__(instance, value) class ImageChoiceItem(ChoiceItem): """Construct a data item for a list of choices with images Args: label: item name choices: (label, image) list, or (key, label, image) list, or function of two arguments (item, value) returning a list of tuples (key, label, image) where image is an icon path, a QIcon instance or a function of one argument (key) returning a QIcon instance default: default value (optional) help: text shown in tooltip (optional) radio: if True, shows radio buttons instead of a combo box (default is False) size: size (optional) of the combo box or button widget (for radio buttons) allow_none: if True, None is a valid value regardless of other constraints (optional, default=True) """ def _normalize_choice( self, idx: int, choice_tuple: tuple[Any, ...] ) -> tuple[Any, Any, Any]: assert isinstance(choice_tuple, tuple) if len(choice_tuple) == 3: key, value, img = choice_tuple else: key = idx value, img = choice_tuple return (key, value, img) class FloatArrayItem(DataItem): """Construct a float array data item Args: label: item name default: default value (optional) help: text shown in tooltip (optional) format: formatting string (example: '%.3f') (optional) transpose: transpose matrix (display only) large: view all float of the array minmax: "all" (default), "columns", "rows" check: if False, value is not checked (optional, default=True) variable_size: if True, allows to add/remove row/columns on all axis allow_none: if True, None is a valid value regardless of other constraints (optional, default=True) check_callback: additional callback to check the value (function of two arguments (value, raise_exception) returning a boolean, where value is the value to check and raise_exception is a boolean indicating whether to raise an exception on invalid value) """ type = np.ndarray def __init__( self, label: str, default: NDArray | None = None, help: str = "", format: str = "%.3f", transpose: bool = False, minmax: str = "all", check: bool = True, variable_size=False, allow_none: bool = True, check_callback: Callable[[np.ndarray, bool], bool] | None = None, ) -> None: super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("display", format=format, transpose=transpose, minmax=minmax) self.set_prop("edit", variable_size=variable_size) self.check_callback = check_callback def check_value(self, value: np.ndarray, raise_exception: bool = False) -> bool: """Override DataItem method""" if not self.get_prop("data", "check_value", True): return True if not isinstance(value, self.type): if raise_exception: raise TypeError(f"Expected {self.type}, got {type(value)}") return False if self.check_callback is not None: return self.check_callback(value, raise_exception) return True def format_string( self, instance: DataSet, value: Any, fmt: str, func: Callable ) -> str: """Override DataItem method""" larg = self.get_prop_value("display", instance, "large", False) fmt = self.get_prop_value("display", instance, "format", "%s") unit = self.get_prop_value("display", instance, "unit", "") v: np.ndarray = func(value) if v.size == 0: return "= []" try: if larg: text = "= [" for flt in v[:-1]: text += fmt % flt + "; " text += fmt % v[-1] + "]" else: text = "~= " + fmt % v.mean() text += " [" + fmt % v.min() text += " .. " + fmt % v.max() text += "]" text += " %s" % unit return str(text) except (ValueError, TypeError): return "= %s %s" % (str(value), unit) def serialize( self, instance: DataSet, writer: HDF5Writer | JSONWriter | INIWriter, ) -> None: """Serialize this item""" value = self.get_value(instance) writer.write_array(value) def get_value_from_reader(self, reader: HDF5Reader | JSONReader | INIReader) -> Any: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_array() class DictItem(DataItem): """Construct a data item representing a dictionary Args: label: item name default: default value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) allow_none: if True, None is a valid value regardless of other constraints (optional, default=False) """ type: type[dict[str, Any]] = dict # pylint: disable=redefined-builtin,abstract-method def __init__( self, label, default: dict | None = None, help="", check=True, allow_none: bool = False, ): super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("display", callback=self.__dictedit) self.set_prop("display", icon="dictedit.png") @staticmethod # pylint: disable=unused-argument def __dictedit( instance: DataSet, item: DataItem, value: dict, parent, trigger_apply=None, ): """Open a dictionary editor Args: instance: DataSet instance item: DataItem instance value: Current dictionary value parent: Parent widget trigger_apply: Optional callback to trigger auto-apply """ # pylint: disable=import-outside-toplevel from guidata.qthelpers import exec_dialog from guidata.widgets.collectionseditor import CollectionsEditor editor = CollectionsEditor(parent) value_was_none = value is None if value_was_none: value = {} editor.setup(value, readonly=instance.is_readonly()) result = exec_dialog(editor) if result: new_value = editor.get_value() # Auto-apply changes if trigger function was provided if trigger_apply is not None: trigger_apply() return new_value if value_was_none: return None return value def serialize(self, instance, writer): """Serialize this item""" value = self.get_value(instance) writer.write_dict(value) def get_value_from_reader(self, reader): """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method""" return reader.read_dict() class ButtonItem(DataItem): """Construct a simple button that calls a method when hit Args: label: item name callback: function with four parameters (dataset, item, value, parent) where dataset (DataSet) is an instance of the parent dataset, item (DataItem) is an instance of ButtonItem (i.e. self), value (unspecified) is the value of ButtonItem (default ButtonItem value or last value returned by the callback) and parent (QObject) is button's parent widget icon: icon show on the button (optional) (str: icon filename as in guidata/guiqwt image search paths) default: default value passed to the callback (optional) help: text shown in button's tooltip (optional) check: if False, value is not checked (optional, default=True) size: size (optional) of the button widget allow_none: if True, None is a valid value regardless of other constraints (optional, default=True) The value of this item is unspecified but is passed to the callback along with the whole dataset. The value is assigned the callback`s return value. """ def __init__( self, label: str, callback: Callable, icon: str | None = None, default: Any | None = None, help: str = "", check: bool = True, size: tuple[int, int] | None = None, allow_none: bool = True, ) -> None: super().__init__( label, default=default, help=help, check=check, allow_none=allow_none ) self.set_prop("display", callback=callback) self.set_prop("display", icon=icon) self.set_prop("display", size=size) def serialize( self, instance: DataSet, writer: HDF5Writer | JSONWriter | INIWriter, ) -> Any: pass def deserialize( self, instance: DataSet, reader: HDF5Reader | JSONReader | INIReader, ) -> Any: pass class FontFamilyItem(StringItem): """Construct a font family name item Args: label: item name default: default value (optional) help: text shown in tooltip (optional) check: if False, value is not checked (optional, default=True) """ pass ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/datatypes.py0000644000175100017510000021204215114075001020274 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Data sets --------- Defining data sets ^^^^^^^^^^^^^^^^^^ .. autoclass:: guidata.dataset.DataSet :members: .. autoclass:: guidata.dataset.DataSetGroup :members: .. autoclass:: guidata.dataset.ActivableDataSet :members: Grouping items ^^^^^^^^^^^^^^ .. autoclass:: guidata.dataset.BeginGroup :members: .. autoclass:: guidata.dataset.EndGroup :members: .. autoclass:: guidata.dataset.BeginTabGroup :members: .. autoclass:: guidata.dataset.EndTabGroup :members: .. autoclass:: guidata.dataset.SeparatorItem :members: Handling item properties ^^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: guidata.dataset.ItemProperty .. autoclass:: guidata.dataset.FormatProp :members: .. autoclass:: guidata.dataset.GetAttrProp :members: .. autoclass:: guidata.dataset.ValueProp :members: .. autoclass:: guidata.dataset.NotProp :members: .. autoclass:: guidata.dataset.FuncProp :members: .. autoclass:: guidata.dataset.FuncPropMulti :members: """ # pylint: disable-msg=W0622 # pylint: disable-msg=W0212 from __future__ import annotations import re import sys import warnings from abc import ABC, abstractmethod from collections.abc import Callable from copy import deepcopy from typing import TYPE_CHECKING, Any, TypeVar import numpy as np from guidata.config import ValidationMode, get_validation_mode from guidata.io import INIReader, INIWriter from guidata.userconfig import UserConfig DEBUG_DESERIALIZE = False if TYPE_CHECKING: from qtpy.QtCore import QSize from qtpy.QtWidgets import QDialog, QWidget from guidata.io import HDF5Reader, HDF5Writer, JSONReader, JSONWriter class DataItemValidationWarning(UserWarning): """Warning raised when a DataItem value is invalid and validation mode is 'ENABLED'""" pass class DataItemValidationError(ValueError): """Error raised when a DataItem value is invalid during validation. Provides more context about where the error occurred. Args: instance: the DataSet instance where the error occurred item: the DataItem that caused the error msg: error message operation: operation that failed, if applicable """ def __init__( self, instance: DataSet, item: DataItem, msg: str, operation: str | None = None ) -> None: self.item = item self.operation = operation if operation: full_msg = f"{operation} failed for {item}: {msg}" else: full_msg = f"Checking {instance.__class__.__name__}.{item}: {msg}" super().__init__(full_msg) class NoDefault: """Special value used to indicate that no default value is set for a DataItem""" pass class ItemProperty: """Base class for item properties Args: callable (Callable): callable to use to evaluate the value of the property """ def __init__(self, callable: Callable) -> None: self.callable = callable def __call__(self, instance: DataSet, item: Any, value: Any) -> Any: """Evaluate the value of the property given, the instance, the item and the value maintained in the instance by the item""" return self.callable(instance, item, value) def set(self, instance: DataSet, item: Any, value: Any) -> Any: """Sets the value of the property given an instance, item and value Depending on implementation the value will be stored either on the instance, item or self Args: instance (DataSet): instance of the DataSet item (Any): item to set the value of value (Any): value to set """ raise NotImplementedError FMT_GROUPS = re.compile(r"(? None: """`fmt` is a format string it can contain a single anonymous substition or several named substitions. """ self.fmt = fmt self.ignore_error = ignore_error self.attrs = FMT_GROUPS.findall(fmt) def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: if not self.attrs: return self.fmt.format(value) dic = {} for attr in self.attrs: dic[attr] = getattr(instance, attr) try: return self.fmt % dic except TypeError: if not self.ignore_error: print(f"Wrong Format for {item._name} : {self.fmt!r} % {dic!r}") raise class GetAttrProp(ItemProperty): """A property that matches the value of an instance's attribute Args: attr (str): attribute to match """ def __init__(self, attr: str) -> None: self.attr = attr def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: val = getattr(instance, self.attr) return val def set(self, instance: DataSet, item: DataItem, value: Any) -> None: setattr(instance, self.attr, value) class ValueProp(ItemProperty): """A property that retrieves a value stored elsewhere Args: value (Any): value to store """ def __init__(self, value: Any) -> None: self.value = value def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: return self.value def set(self, instance: DataSet, item: DataItem, value: Any) -> None: """Sets the value of the property given an instance, item and value Args: instance (DataSet): instance of the DataSet item (Any): item to set the value of value (Any): value to set """ self.value = value class NotProp(ItemProperty): """Not property Args: prop (ItemProperty): property to negate """ def __init__(self, prop: ItemProperty): self.property = prop def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: return not self.property(instance, item, value) def set(self, instance: DataSet, item: DataItem, value: Any) -> None: """Sets the value of the property given an instance, item and value Args: instance (DataSet): instance of the DataSet item (Any): item to set the value of value (Any): value to set """ self.property.set(instance, item, not value) class FuncProp(ItemProperty): """An 'operator property' Args: prop (ItemProperty): property to apply function to func (function): function to apply invfunc (function): inverse function (default: func) """ def __init__( self, prop: ItemProperty, func: Callable, invfunc: Callable | None = None, ) -> None: self.property = prop self.function = func if invfunc is None: invfunc = func self.inverse_function = invfunc def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: return self.function(self.property(instance, item, value)) def set(self, instance: DataSet, item: DataItem, value: Any) -> None: """Sets the value of the property given an instance, item and value Args: instance (DataSet): instance of the DataSet item (Any): item to set the value of value (Any): value to set """ self.property.set(instance, item, self.inverse_function(value)) class FuncPropMulti(ItemProperty): """An 'operator property' for multiple properties Args: props (list[ItemProperty]): properties to apply function to func (function): function to apply invfunc (function): inverse function (default: func) """ def __init__( self, props: list[ItemProperty], func: Callable, invfunc: Callable | None = None, ) -> None: self.properties = props self.function = func if invfunc is None: invfunc = func self.inverse_function = invfunc def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: return self.function(*[prop(instance, item, value) for prop in self.properties]) def set(self, instance: DataSet, item: DataItem, value: Any) -> None: """Sets the value of the property given an instance, item and value Args: instance (DataSet): instance of the DataSet item (Any): item to set the value of value (Any): value to set """ for prop in self.properties: prop.set(instance, item, self.inverse_function(value)) class ComputedProp(ItemProperty): """A computed property that calls a method of the dataset to calculate values Args: method_or_name: name of the method to call on the dataset instance, or the function object itself """ def __init__(self, method_or_name: str | callable) -> None: if callable(method_or_name): self.method = method_or_name self.method_name = getattr(method_or_name, "__name__", str(method_or_name)) else: self.method = None self.method_name = method_or_name # pylint: disable=unused-argument def __call__(self, instance: DataSet, item: DataItem, value: Any) -> Any: """Compute the value by calling the specified method on the dataset instance Args: instance (DataSet): dataset instance item (DataItem): the data item value (Any): current value (ignored for computed items) Returns: Any: computed value """ if self.method is not None: # Function object was provided directly return self.method(instance) else: # Method name was provided, get it from the instance method = getattr(instance, self.method_name, None) if method is None: raise AttributeError( f"Dataset {instance.__class__.__name__} has no method " f"'{self.method_name}'" ) if not callable(method): raise TypeError( f"Attribute '{self.method_name}' of {instance.__class__.__name__} " "is not callable" ) return method() # pylint: disable=unused-argument def set(self, instance: DataSet, item: DataItem, value: Any) -> None: """Computed properties cannot be set directly - they are read-only Args: instance (DataSet): instance of the DataSet item (DataItem): item to set the value of value (Any): value to set Raises: ValueError: Always, since computed items are read-only """ raise ValueError(f"Computed item '{item.get_name()}' is read-only") class DataItem(ABC): """DataSet data item Args: label (str): item label default (Any): default value help (str): text displayed on data item's tooltip check (bool): check value (default: True) allow_none (bool): if True, None values are allowed regardless of the expected type (default: False) """ type = type count = 0 def __init__( self, label: str, default: Any | None = None, help: str | None = "", check: bool | None = True, allow_none: bool = False, ) -> None: self._order = DataItem.count DataItem.count += 1 self._name: str | None = None self._default = default self._help = help self._props: dict[ Any, Any ] = {} # a dict realm->dict containing realm-specific properties self.set_prop("display", col=0, colspan=None, row=None, label=label) self.set_prop("data", check_value=check, allow_none=allow_none) def get_prop(self, realm: str, name: str, default: Any = NoDefault) -> Any: """Get one property of this item Args: realm (str): realm name name (str): property name default (Any): default value (default: NoDefault) Returns: Any: property value """ realm_props = self._props.get(realm) if realm_props is None: if default is NoDefault: raise KeyError(name) return default if default is NoDefault: return realm_props[name] # Let KeyError propagate return realm_props.get(name, default) def get_prop_value( self, realm: str, instance: DataSet, name: str, default: Any = NoDefault ) -> Any: """Get one property of this item Args: realm (str): realm name instance (DataSet): instance of the DataSet name (str): property name default (Any): default value (default: NoDefault) Returns: Any: property value """ value = self.get_prop(realm, name, default) if isinstance(value, ItemProperty): return value(instance, self, self.get_value(instance)) else: return value def set_prop(self, realm: str, **kwargs) -> DataItem: """Set one or several properties using the syntax:: set_prop(name1=value1, ..., nameX=valueX) It returns self so that we can assign to the result like this:: item = Item().set_prop(x=y) Args: realm (str): realm name kwargs: properties to set Returns: DataItem: self """ # noqa prop = self._props.setdefault(realm, {}) prop.update(kwargs) return self def set_pos( self, col: int = 0, colspan: int | None = None, row: int | None = None ) -> DataItem: """Set data item's position on a GUI layout Args: col (int): column number (default: 0) colspan (int): number of columns (default: None) row (int): row number (default: None) """ self.set_prop("display", col=col, colspan=colspan, row=row) return self def set_computed(self, method_or_name: str | callable) -> DataItem: """Set data item as computed using the specified method Args: method_or_name: name of the method to call on the dataset instance to compute the value, or the function object itself Returns: DataItem: self """ computed_prop = ComputedProp(method_or_name) self.set_prop("data", computed=computed_prop) # Also make it readonly in the display self.set_prop("display", readonly=True) return self def __str__(self) -> str: return f"{self._name} : {self.__class__.__name__}" def get_help(self, instance: DataSet) -> str: """Return data item's tooltip Args: instance (DataSet): instance of the DataSet Returns: str: tooltip """ auto_help = self.get_auto_help(instance) help = self._help or "" if auto_help: help = help + "\n(" + auto_help + ")" if help else auto_help.capitalize() return help def get_auto_help(self, instance: DataSet) -> str: """Return the automatically generated part of data item's tooltip Args: instance (DataSet): instance of the DataSet Returns: str: automatically generated part of tooltip """ return "" def format_string(self, instance: Any, value: Any, fmt: str, func: Callable) -> str: """Apply format to string representation of the item's value Args: instance (Any): instance of the DataSet value (Any): item's value fmt (str): format string func (Callable): function to apply to the value before formatting Returns: str: formatted string """ return fmt % (func(value),) def get_string_value(self, instance: DataSet) -> str: """Return a formatted unicode representation of the item's value obeying 'display' or 'repr' properties Args: instance (DataSet): instance of the DataSet Returns: str: formatted string """ value = self.get_value(instance) repval = self.get_prop_value("display", instance, "repr", None) if repval is not None: return repval else: fmt = self.get_prop_value("display", instance, "format", "%s") fmt = "%s" if fmt is None else fmt func = self.get_prop_value("display", instance, "func", lambda x: x) if ( isinstance(fmt, Callable) # type:ignore and value is not None ): return fmt(func(value)) if value is not None: text = self.format_string(instance, value, fmt, func) else: text = "-" return text def get_name(self) -> str: """Return data item's name Returns: str: name """ return self._name or "" def set_name(self, new_name: str) -> None: """Set data item's name Args: new_name (str): new name """ self._name = new_name def set_help(self, new_help: str) -> None: """Set data item's help text Args: new_help (str): new help text """ self._help = new_help def set_from_string(self, instance: DataSet, string_value: str) -> None: """Set data item's value from specified string Args: instance (DataSet): instance of the DataSet string_value (str): string value """ value = self.from_string(string_value) self.__set__(instance, value) def get_default(self) -> Any: """Return data item's default value Returns: Any: default value """ return self._default def set_default(self, instance: DataSet) -> None: """Set data item's value to default Args: instance (DataSet): instance of the DataSet """ try: value = deepcopy(self._default) self._set_value_with_validation(instance, value, force_allow_none=True) except ValueError as exc: # Convert generic ValueError to a more specific DataItemValidationError # to provide clearer context when setting default values fails raise DataItemValidationError( instance, self, str(exc.__cause__ or exc), operation=f"Setting default value ({instance.__class__.__name__})", ) from exc def accept(self, visitor: object) -> None: """This is the visitor pattern's accept function. It calls the corresponding visit_MYCLASS method of the visitor object. Python's allow a generic base class implementation of this method so there's no need to write an accept function for each derived class unless you need to override the default behavior Args: visitor (object) """ funcname = "visit_" + self.__class__.__name__ func = getattr(visitor, funcname) func(self) def __set__(self, instance: Any, value: Any) -> None: """Set data item's value Args: instance (Any): instance of the DataSet value (Any): value to set """ # Check if this item is computed (read-only) computed_prop = self.get_prop("data", "computed", None) if isinstance(computed_prop, ComputedProp): raise ValueError(f"Computed item '{self.get_name()}' is read-only") self._set_value_with_validation(instance, value, force_allow_none=False) def _set_value_with_validation( self, instance: Any, value: Any, force_allow_none: bool = False ) -> None: """Internal method to set data item's value with validation Args: instance (Any): instance of the DataSet value (Any): value to set force_allow_none (bool): if True, allow None values even when allow_none is False (used for default values) """ vmode = get_validation_mode() # Early exit if validation is disabled if vmode == ValidationMode.DISABLED: setattr(instance, f"_{self._name}", value) return # Check if validation should be skipped for None values if value is None: if force_allow_none or self.get_prop("data", "allow_none", False): setattr(instance, f"_{self._name}", value) return # Perform validation try: self.check_value(value, raise_exception=True) except NotImplementedError: # Checking is not implemented for this item pass except Exception as exc: if vmode == ValidationMode.ENABLED: # Show a warning in replacement of the exception msg = f"Checking {instance.__class__.__name__}.{str(self)}: {exc}" warnings.warn(msg, DataItemValidationWarning) elif vmode == ValidationMode.STRICT: # Raise an exception if strict validation is enabled raise DataItemValidationError(instance, self, str(exc)) from exc else: raise ValueError(f"Unknown validation mode: {vmode}") from exc setattr(instance, f"_{self._name}", value) def __get__(self, instance: Any, klass: type) -> Any | None: if instance is not None: # Check if this item is computed computed_prop = self.get_prop("data", "computed", None) if isinstance(computed_prop, ComputedProp): # For computed items, calculate the value using the computed property return computed_prop(instance, self, None) else: # For regular items, return the stored value or default return getattr(instance, "_%s" % (self._name), self._default) else: return self def get_value(self, instance: Any) -> Any: """Return data item's value Args: instance (Any): instance of the DataSet Returns: Any: data item's value """ return self.__get__(instance, instance.__class__) def check_item(self, instance: Any) -> bool: """Check data item's current value (calling method check_value) Args: instance (Any): instance of the DataSet Returns: Any: data item's value """ value = getattr(instance, "_%s" % (self._name)) return self.check_value(value) def check_value(self, value: Any, raise_exception: bool = False) -> bool: """Check if `value` is valid for this data item Args: value (Any): value to check raise_exception (bool): if True, raise an exception if the value is invalid. Defaults to True. Returns: bool: value """ raise NotImplementedError() def from_string(self, string_value: str) -> Any: """Transform string into valid data item's value Args: string_value (str): string value Returns: Any: data item's value """ raise NotImplementedError() def bind(self, instance: DataSet) -> DataItemVariable: """Return a DataItemVariable instance bound to the data item Args: instance (DataSet): instance of the DataSet Returns: DataItemVariable: DataItemVariable instance """ return DataItemVariable(self, instance) def serialize( self, instance: DataSet, writer: HDF5Writer | JSONWriter | INIWriter, ) -> None: """Serialize this item using the writer object This is a default implementation that should work for everything but new datatypes Args: instance (DataSet): instance of the DataSet writer (HDF5Writer | JSONWriter | INIWriter): writer object """ value = self.get_value(instance) writer.write(value) def get_value_from_reader(self, reader: HDF5Reader | JSONReader | INIReader) -> Any: """Reads value from the reader object, inside the try...except statement defined in the base item `deserialize` method This method is reimplemented in some child classes Args: reader (HDF5Reader | JSONReader | INIReader): reader object """ return reader.read_any() def deserialize( self, instance: Any, reader: HDF5Reader | JSONReader | INIReader, ) -> None: """Deserialize this item using the reader object Default base implementation supposes the reader can detect expected datatype from the stream Args: instance (Any): instance of the DataSet reader (HDF5Reader | JSONReader | INIReader): reader object """ try: value = self.get_value_from_reader(reader) except KeyError: self.set_default(instance) return except RuntimeError as e: if DEBUG_DESERIALIZE: import traceback print("DEBUG_DESERIALIZE enabled in datatypes.py", file=sys.stderr) traceback.print_stack() print(e, file=sys.stderr) self.set_default(instance) return self.__set__(instance, value) class Obj: """An object that helps build default instances for ObjectItems""" def __init__(self, **kwargs) -> None: self.__dict__.update(kwargs) class ObjectItem(DataItem): """Simple helper class implementing default for composite objects""" klass: type | None = None def set_default(self, instance: DataSet) -> None: """Make a copy of the default value Args: instance (DataSet): instance of the DataSet """ # Avoid circular import # pylint: disable=import-outside-toplevel from guidata.dataset.conv import update_dataset if self.klass is not None: value = self.klass() # pylint: disable=not-callable if self._default is not None: update_dataset(value, self._default) self.__set__(instance, value) def deserialize( self, instance: DataSet, reader: HDF5Reader | JSONReader | INIReader, ) -> None: """Deserialize this item using the reader object We build a new default object and deserialize it Args: instance (DataSet): instance of the DataSet reader (HDF5Reader | JSONReader | INIReader): reader object """ if self.klass is not None: value = self.klass() # pylint: disable=not-callable value.deserialize(reader) self.__set__(instance, value) class DataItemProxy: """ Proxy for DataItem objects This class is needed to construct GroupItem class (see module guidata.qtwidgets) Args: item (DataItem): data item to proxy """ def __init__(self, item: DataItem): self.item = item def __str__(self): return self.item._name + "_proxy: " + self.__class__.__name__ def get_name(self) -> str: """DataItem method proxy Returns: str: name """ return self.item.get_name() def get_help(self, instance: DataSet) -> str: """DataItem method proxy Args: instance (DataSet): instance of the DataSet Returns: str: help string """ return self.item.get_help(instance) def get_auto_help(self, instance: DataSet) -> str: """DataItem method proxy Args: instance (DataSet): instance of the DataSet Returns: str: help string """ return self.item.get_auto_help(instance) def get_string_value(self, instance: DataSet) -> str: """DataItem method proxy Args: instance (DataSet): instance of the DataSet Returns: str: string value """ return self.item.get_string_value(instance) def set_from_string(self, instance: DataSet, string_value: str) -> None: """DataItem method proxy Args: instance (DataSet): instance of the DataSet string_value (str): string value """ self.item.set_from_string(instance, string_value) def set_default(self, instance: DataSet) -> None: """DataItem method proxy Args: instance (DataSet): instance of the DataSet """ self.item.set_default(instance) def __set__(self, instance: Any, value: Any): pass def accept(self, visitor: object) -> None: """DataItem method proxy Args: visitor (object): visitor object """ self.item.accept(visitor) def get_value(self, instance: DataItem) -> Any: """DataItem method proxy Args: instance (DataItem): instance of the DataItem Returns: Any: value """ return self.item.get_value(instance) def check_item(self, instance: DataItem) -> bool: """DataItem method proxy Args: instance (DataItem): instance of the DataItem Returns: Any: value """ return self.item.check_item(instance) def check_value(self, value: Any, raise_exception: bool = False) -> bool: """DataItem method proxy Args: value (Any): value raise_exception (bool): if True, raise an exception if the value is invalid. Defaults to True. Returns: Any: value """ return self.item.check_value(value, raise_exception) def from_string(self, string_value: str) -> Any: """DataItem method proxy Args: string_value (str): string value Returns: Any: value """ return self.item.from_string(string_value) def get_prop(self, realm: str, name: str, default=NoDefault) -> Any: """DataItem method proxy Args: realm (str): realm name (str): name default (Any): default value Returns: Any: value """ return self.item.get_prop(realm, name, default) def get_prop_value( self, realm, instance: DataSet, name: str, default: Any = NoDefault ) -> Any: """DataItem method proxy Args: realm (str): realm instance (DataSet): instance of the DataSet name (str): name default (Any): default value Returns: Any: value """ return self.item.get_prop_value(realm, instance, name, default) def set_prop(self, realm: str, **kwargs) -> DataItem: """DataItem method proxy Args: realm (str): realm kwargs: keyword arguments Returns: DataItem: data item """ # noqa return self.item.set_prop(realm, **kwargs) def bind(self, instance: DataSet) -> DataItemVariable: """DataItem method proxy Args: instance (DataSet): instance of the DataSet Returns: DataItemVariable: data item variable """ return DataItemVariable(self, instance) class DataItemVariable: """An instance of a DataItemVariable represent a binding between an item and a dataset. could be called a bound property. since DataItem instances are class attributes they need to have a DataSet instance to store their value. This class binds the two together. Args: item (DataItem): data item instance (DataSet): instance of the DataSet """ def __init__( self, item: DataItem, instance: DataSet, ): self.item = item self.instance = instance def get_prop_value(self, realm: str, name: str, default: object = NoDefault) -> Any: """DataItem method proxy Args: realm (str): realm name (str): name default (object): default value Returns: Any: value """ return self.item.get_prop_value(realm, self.instance, name, default) def get_prop(self, realm: str, name: str, default: type | None = NoDefault) -> Any: """DataItem method proxy Args: realm (str): realm name (str): name default (type | None): default value Returns: Any: value """ return self.item.get_prop(realm, name, default) def get_help(self) -> str: """Re-implement DataItem method Returns: str: help string """ return self.item.get_help(self.instance) def get_auto_help(self) -> str: """Re-implement DataItem method Returns: str: help string """ return self.item.get_auto_help(self.instance) def get_string_value(self) -> str: """Return a unicode representation of the item's value obeying 'display' or 'repr' properties Returns: str: string value """ return self.item.get_string_value(self.instance) def set_default(self) -> None: """Re-implement DataItem method""" return self.item.set_default(self.instance) def get(self) -> Any: """Re-implement DataItem method Returns: Any: value """ return self.item.get_value(self.instance) def set(self, value: Any) -> None: """Re-implement DataItem method Args: value (Any): value """ return self.item.__set__(self.instance, value) def set_from_string(self, string_value) -> None: """Re-implement DataItem method Args: string_value (str): string value """ return self.item.set_from_string(self.instance, string_value) def check_item(self) -> bool: """Re-implement DataItem method Returns: Any: value """ return self.item.check_item(self.instance) def check_value(self, value: Any, raise_exception: bool = False) -> bool: """Re-implement DataItem method Args: value (Any): value raise_exception (bool): if True, raise an exception if the value is invalid. Defaults to True. Returns: Any: value """ return self.item.check_value(value, raise_exception) def from_string(self, string_value: str) -> Any: """Re-implement DataItem method Args: string_value (str): string value Returns: Any: value """ return self.item.from_string(string_value) def label(self) -> str: """Re-implement DataItem method Returns: str: label """ return self.item.get_prop("display", "label") def collect_items_in_bases_order( bases: tuple[type], seen: set | None = None ) -> list[DataItem]: """Collect items in bases order Args: bases: tuple of base classes seen: set of already seen items to avoid duplicates Returns: List of data items """ if seen is None: seen = set() # Collect all bases, respecting the order they appear in class definition # For DerivedAB(DatasetA, DatasetB), we want DatasetA items first all_bases = [] for base in bases: # Get all bases of this base class in MRO order (excluding object) base_mro = [cls for cls in base.__mro__ if cls is not object] all_bases.extend(base_mro) # Remove duplicates while preserving order (use dict as ordered set) unique_bases = list(dict.fromkeys(all_bases)) # Build a mapping of overridden items (most specific class wins) items_dict = {} # name -> DataItem mapping for base in reversed(unique_bases): # Reverse MRO: most specific first base_dict = getattr(base, "__dict__", {}) for name, obj in base_dict.items(): if isinstance(obj, DataItem): items_dict[name] = obj # Later (more specific) definitions override # Now collect items following the inheritance chain order in bases # Process each base and its ancestors in the order they appear final_items = [] processed_bases = set() for base in bases: # Process in the order bases are listed in class definition # Get MRO for this base, but exclude DataSet and object base_chain = [ cls for cls in base.__mro__ if cls is not object and issubclass(cls, DataSet) ] # Process from most ancestral to most derived within this chain for cls in reversed(base_chain): if cls in processed_bases: continue # Skip if already processed processed_bases.add(cls) cls_dict = getattr(cls, "__dict__", {}) for name, obj in cls_dict.items(): if isinstance(obj, DataItem) and name not in seen: final_items.append(items_dict[name]) # Use the overridden version seen.add(name) return final_items class DataSetMeta(type): """ DataSet metaclass Create class attribute `_items`: list of the DataSet class attributes, created in the same order as these attributes were written """ def __new__( cls: type, name: str, bases: Any, dct: dict[str, Any], **kwargs ) -> type: # Filter out DataSet configuration kwargs (handled by __init_subclass__) # These include: title, comment, icon, readonly items = {item._name: item for item in collect_items_in_bases_order(bases)} for attrname, value in list(dct.items()): if isinstance(value, DataItem): value.set_name(attrname) if attrname in items: value._order = items[attrname]._order items[attrname] = value dct["_items"] = list(items.values()) # Pass kwargs through to type.__new__ (which will trigger __init_subclass__) return super().__new__(cls, name, bases, dct, **kwargs) Meta_Py3Compat = DataSetMeta("Meta_Py3Compat", (object,), {}) AnyDataSet = TypeVar("AnyDataSet", bound="DataSet") def assert_datasets_equal(ds1: DataSet, ds2: DataSet, msg: str | None = None) -> None: """Assert that two DataSet objects are equal. Exception message shows details on which attributes are different, if any. Args: ds1: The first DataSet to compare. ds2: The second DataSet to compare. msg: Optional message to include in the assertion error. Raises: AssertionError: If the DataSet objects are not equal. """ diff: list[str] = [] items1, items2 = ds1.get_items(), ds2.get_items() if len(items1) != len(items2): diff.append(f"Different number of items: {len(items1)} != {len(items2)}") else: for item1, item2 in zip(items1, items2): name1, name2 = item1.get_name(), item2.get_name() if item1 != item2: diff.append(f"Item '{name1}' differs from '{name2}'") val1, val2 = item1.get_value(ds1), item2.get_value(ds2) diffval = False if isinstance(val1, (list, tuple, set)): if not all(v1 == v2 for v1, v2 in zip(val1, val2)): diffval = True elif isinstance(val1, np.ndarray) and isinstance(val2, np.ndarray): if not np.array_equal(val1, val2): diffval = True elif val1 != val2: diffval = True if diffval: diff.append(f"Item '{name1}' has different value: {val1} != {val2}") if diff: msg = "" if msg is None else msg + "\n" raise AssertionError(msg + "Datasets are not equal:\n" + "\n".join(diff)) class DataSet(metaclass=DataSetMeta): """Construct a DataSet object is a set of DataItem objects Args: title (str): title comment (str): comment. Text shown on the top of the first data item icon (str): icon filename as in image search paths readonly (bool): if True, the DataSet is read-only skip_defaults (bool): if True, do not set default values for items """ _items: list[DataItem] = [] __metaclass__ = DataSetMeta # keep it even with Python 3 (see DataSetMeta) # Class-level configuration (set via __init_subclass__) _class_title: str | None = None _class_comment: str | None = None _class_icon: str = "" _class_readonly: bool = False def __init_subclass__( cls, title: str | None = None, comment: str | None = None, icon: str = "", readonly: bool = False, **kwargs, ) -> None: """Called when a class inherits from DataSet. This allows configuring DataSet metadata in the class definition: class MyParams(DataSet, title="My Parameters", icon="myicon.png"): param1 = FloatItem("Parameter 1") Args: title: Default title for this DataSet class comment: Default comment for this DataSet class icon: Default icon for this DataSet class readonly: Default readonly state for this DataSet class **kwargs: Additional arguments passed to parent __init_subclass__ """ super().__init_subclass__(**kwargs) cls._class_title = title cls._class_comment = comment cls._class_icon = icon cls._class_readonly = readonly def __init__( self, title: str | None = None, comment: str | None = None, icon: str = "", readonly: bool = False, skip_defaults: bool = False, ): # Priority: instance parameter > class-level config > empty/computed default self.__icon = icon if icon else self._class_icon self.__readonly: bool = readonly or self._class_readonly # Handle title: instance param > class config > docstring fallback if title is not None: # Explicitly passed to __init__ (even if empty string) self.__title = title elif self._class_title is not None: # Set at class level via __init_subclass__ (even if empty string) self.__title = self._class_title else: # Fall back to docstring (for backward compatibility) comp_title, comp_comment = self._compute_title_and_comment() self.__title = comp_title # Handle comment: instance param > class config > None (no docstring fallback) if comment is not None: # Explicitly passed to __init__ self.__comment = comment elif self._class_comment is not None: # Set at class level via __init_subclass__ self.__comment = self._class_comment else: # No automatic fallback to docstring for comment self.__comment = None if not skip_defaults: self.set_defaults() def get_items(self, copy=False) -> list[DataItem]: """Returns all the DataItem objects from the DataSet instance. Ignore private items that have a name starting with an underscore (e.g. '_private_item = ...') Args: copy: If True, deepcopy the DataItem list, else return the original. Defaults to False. Returns: _description_ """ result_items = self._items if not copy else deepcopy(self._items) return list(filter(lambda s: not s.get_name().startswith("_"), result_items)) @classmethod def create(cls: type[AnyDataSet], **kwargs) -> AnyDataSet: """Create a new instance of the DataSet class Args: kwargs: keyword arguments to set the DataItem values Returns: DataSet instance """ # noqa # Validate that all kwargs correspond to actual items in the class for name in kwargs: for item in cls._items: if item._name == name: break else: raise AttributeError( f"DataSet class '{cls.__name__}' has no attribute '{name}'" ) # Create the instance but skip set_defaults in __init__ instance = cls(skip_defaults=True) # Set only the defaults for items that don't have values in kwargs for item in instance._items: if item._name not in kwargs: item.set_default(instance) # Now set the provided values for name, value in kwargs.items(): setattr(instance, name, value) return instance def _get_translation(self): """We try to find the translation function (_) from the module this class was created in This function is unused but could be useful to translate strings that cannot be translated at the time they are created. """ module = sys.modules[self.__class__.__module__] if hasattr(module, "_"): return module._ else: return lambda x: x def _compute_title_and_comment(self) -> tuple[str, str | None]: """ Private method to compute title and comment of the data set from docstring. Returns tuple of (title, comment) where: - title: first line of docstring (stripped), or class name if no docstring - comment: remaining lines of docstring (stripped), or None if no lines """ comp_title = self.__class__.__name__ comp_comment = None if self.__doc__: doc_lines = self.__doc__.splitlines() # Remove empty lines at the beginning while doc_lines and not doc_lines[0].strip(): del doc_lines[0] if doc_lines: # First line becomes the title comp_title = doc_lines.pop(0).strip() if doc_lines: # Remaining lines become the comment comp_comment = "\n".join([x.strip() for x in doc_lines]) return comp_title, comp_comment def get_title(self) -> str: """Return data set title Returns: str: title """ return self.__title def set_title(self, title: str) -> None: """Set data set title Args: title (str): title """ self.__title = title def get_comment(self) -> str | None: """Return data set comment Returns: str | None: comment """ return self.__comment def set_comment(self, comment: str | None) -> None: """Set data set comment Args: comment (str | None): comment """ self.__comment = comment def get_icon(self) -> str | None: """Return data set icon Returns: str | None: icon """ return self.__icon def set_icon(self, icon: str) -> None: """Set data set icon Args: icon (str): icon """ self.__icon = icon def set_defaults(self) -> None: """Set default values""" for item in self._items: item.set_default(self) def __str__(self) -> str: """Return string representation of the data set""" return self.to_string(debug=False) def check(self) -> list[str]: """Check the dataset item values Returns: list[str]: list of errors """ errors = [] for item in self._items: if not item.check_item(self) and item._name is not None: errors.append(item._name) return errors def text_edit(self) -> None: """Edit data set with text input only""" from guidata.dataset import textedit self.accept(textedit.TextEditVisitor(self)) def edit( self, parent: QWidget | None = None, apply: Callable | None = None, wordwrap: bool = True, size: QSize | tuple[int, int] | None = None, object_name: str | None = None, ) -> int: """Open a dialog box to edit data set Args: parent: parent widget (default is None, meaning no parent) apply: apply callback (default is None) wordwrap: if True, comment text is wordwrapped size: dialog size (QSize object or integer tuple (width, height)) object_name: object name for the dialog (default is None, meaning use class name + "Dialog") Returns: Dialog exit code. """ # Importing those modules here avoids Qt dependency when # guidata is used without Qt # pylint: disable=import-outside-toplevel from guidata.dataset.qtwidgets import DataSetEditDialog from guidata.qthelpers import exec_dialog dlg = DataSetEditDialog( self, icon=self.__icon, parent=parent, apply=apply, wordwrap=wordwrap, size=size, ) dlg.setObjectName(object_name or self.__class__.__name__ + "Dialog") return exec_dialog(dlg) def view( self, parent: QWidget | None = None, wordwrap: bool = True, size: QSize | tuple[int, int] | None = None, ) -> None: """Open a dialog box to view data set Args: parent: parent widget (default is None, meaning no parent) wordwrap: if True, comment text is wordwrapped size: dialog size (QSize object or integer tuple (width, height)) """ # Importing those modules here avoids Qt dependency when # guidata is used without Qt # pylint: disable=import-outside-toplevel from guidata.dataset.qtwidgets import DataSetShowDialog from guidata.qthelpers import exec_dialog dial = DataSetShowDialog( self, icon=self.__icon, parent=parent, wordwrap=wordwrap, size=size ) return exec_dialog(dial) def is_readonly(self) -> bool: return bool(self.__readonly) def set_readonly(self, readonly: bool = True): self.__readonly = readonly def _get_items_for_text_representation(self) -> list[DataItem]: """Get items for text representation, excluding trailing separators Returns: list: List of items with trailing separators filtered out """ filtered_items = list(self._items) while filtered_items and isinstance(filtered_items[-1], SeparatorItem): filtered_items.pop() return filtered_items def to_string( self, debug: bool | None = False, indent: str | None = None, align: bool | None = False, show_hidden: bool | None = True, ) -> str: """Return readable string representation of the data set If debug is True, add more details on data items Args: debug (bool): if True, add more details on data items indent (str): indentation string (default is None, meaning no indentation) align (bool): if True, align data items (default is False) show_hidden (bool): if True, show hidden data items (default is True) Returns: str: string representation of the data set """ if indent is None: indent = "\n " txt = "%s:" % (self.__title) def _get_label(item): if debug: return item._name else: return item.get_prop_value("display", self, "label") # Get items for text representation (excluding trailing separators) filtered_items = self._get_items_for_text_representation() length = 0 if align: for item in filtered_items: item_length = len(_get_label(item)) if item_length > length: length = item_length for item in filtered_items: try: hide = item.get_prop_value("display", self, "hide") if not show_hidden and hide is True: continue except KeyError: pass if isinstance(item, ObjectItem): composite_dataset = item.get_value(self) txt += indent + composite_dataset.to_string( debug=debug, indent=indent + " " ) continue elif isinstance(item, BeginGroup): txt += "%s%s:" % (indent, item._name) indent += " " continue elif isinstance(item, EndGroup): indent = indent[:-2] continue value = getattr(self, "_%s" % (item._name)) value_str = "-" if value is None else item.get_string_value(self) if debug: label = item._name else: label = item.get_prop_value("display", self, "label") if not label: # For BoolItem without label, use text as label label = item.get_prop_value("display", self, "text", "") if length and label is not None: label = label.ljust(length) txt += f"{indent}{label}: {value_str}" if debug: txt += " (" + item.__class__.__name__ + ")" return txt def accept(self, vis: object) -> None: """Helper function that passes the visitor to the accept methods of all the items in this dataset Args: vis (object): visitor object """ for item in self._items: item.accept(vis) def to_html(self) -> str: """Return HTML representation of the dataset. Similar to Sigima's TableResult transpose format with: - Title and comment in blue - Two-column table: item names (right-aligned) and values (left-aligned) - For BoolItem: checkbox in first column with label:text formatting Returns: HTML representation """ # Create the title with comment # Use a lighter blue (#5294e2) that works well in both light and dark modes html = f'{self.__title}:' if self.__comment: # Add comment on new line, also in blue html += f'
{self.__comment}' # Get items for representation (excluding trailing separators) filtered_items = self._get_items_for_text_representation() if not filtered_items: html += "
No items to display" return html # Start table html += '' for item in filtered_items: # Skip group items and separators for HTML representation if isinstance(item, (BeginGroup, EndGroup, SeparatorItem)): continue # Skip hidden items try: hide = item.get_prop_value("display", self, "hide") if hide is True: continue except KeyError: pass # Handle ObjectItem (nested datasets) if isinstance(item, ObjectItem): composite_dataset = item.get_value(self) if hasattr(composite_dataset, "to_html"): item_label = item.get_prop_value("display", self, "label") html += ( f'" ) nested_html = composite_dataset.to_html(transpose=False) html += ( f'" ) continue # Get item label and value label = item.get_prop_value("display", self, "label") if not label: # For BoolItem without label, use name:text formatting label = item.get_prop_value("display", self, "text", "") # Get string representation of value value_str = item.get_string_value(self) html += ( f'' ) html += ( f'" ) html += "
' f"{item_label}:' f"{nested_html}
{label}:' f"{value_str}
" return html def serialize(self, writer: HDF5Writer | JSONWriter | INIWriter) -> None: """Serialize the dataset Args: writer (HDF5Writer | JSONWriter | INIWriter): writer object """ for item in self._items: with writer.group(item._name): item.serialize(self, writer) def deserialize(self, reader: HDF5Reader | JSONReader | INIReader) -> None: """Deserialize the dataset Args: reader (HDF5Reader | JSONReader | INIReader): reader object """ for item in self._items: with reader.group(item._name): if item.get_prop("data", "computed", None) is not None: continue # Skip computed items try: item.deserialize(self, reader) except RuntimeError as error: if DEBUG_DESERIALIZE: import traceback print( "DEBUG_DESERIALIZE enabled in datatypes.py", file=sys.stderr ) traceback.print_stack() print(error, file=sys.stderr) item.set_default(self) def read_config(self, conf: UserConfig, section: str, option: str) -> None: """Read configuration from a UserConfig instance Args: conf (UserConfig): UserConfig instance section (str): section name option (str): option name """ reader = INIReader(conf, section, option) self.deserialize(reader) def write_config(self, conf: UserConfig, section: str, option: str) -> None: """Write configuration to a UserConfig instance Args: conf (UserConfig): UserConfig instance section (str): section name option (str): option name """ writer = INIWriter(conf, section, option) self.serialize(writer) @classmethod def set_global_prop(klass, realm: str, **kwargs) -> None: """Set global properties for all data items in the dataset Args: realm (str): realm name kwargs (dict): properties to set """ # noqa for item in klass._items: item.set_prop(realm, **kwargs) class ActivableDataSet(DataSet): """An ActivableDataSet instance must have an "enable" class attribute which will set the active state of the dataset instance (see example in: tests/activable_dataset.py) Args: title (str): dataset title (optional) comment (str): dataset comment (optional) icon (str): dataset icon. Default is "" (no icon) """ _activable = True # default *instance* attribute value _active = True _activable_prop = GetAttrProp("_activable") _active_prop = GetAttrProp("_active") @property @abstractmethod def enable(self) -> DataItem: ... def __init__( self, title: str | None = None, comment: str | None = None, icon: str = "", ): DataSet.__init__(self, title, comment, icon) @classmethod def active_setup(cls) -> None: """ This class method must be called after the child class definition in order to setup the dataset active state """ cls.set_global_prop("display", active=cls._active_prop) cls.enable.set_prop( # type:ignore "display", active=True, hide=cls._activable_prop, store=cls._active_prop ) def set_activable(self, activable: bool): self._activable = not activable self._active = self.enable class DataSetGroup: """Construct a DataSetGroup object, used to group several datasets together Args: datasets (list[DataSet]): list of datasets title (str): group title (optional) icon (str): group icon. Default is "" (no icon) This class tries to mimics the DataSet interface. The GUI should represent it as a notebook with one page for each contained dataset. """ ALLOWED_MODES = ("tabs", "table", None) def __init__( self, datasets: list[DataSet], title: str | None = None, icon: str = "", ) -> None: self.__icon = icon self.datasets = datasets if title: self.__title = title else: self.__title = self.__class__.__name__ def __str__(self) -> str: return "\n".join([dataset.__str__() for dataset in self.datasets]) def get_title(self) -> str: """Return data set group title Returns: str: data set group title """ return self.__title def get_comment(self) -> None: """Return data set group comment --> not implemented (will return None) Returns: None: data set group comment """ return None def get_icon(self) -> str | None: """Return data set icon Returns: str | None: data set icon """ return self.__icon def check(self) -> list[list[str]]: """Check data set group items Returns: list[list[str]]: list of errors """ return [dataset.check() for dataset in self.datasets] def text_edit(self) -> None: """Edit data set with text input only""" raise NotImplementedError() def edit( self, parent: QWidget | None = None, apply: Callable | None = None, wordwrap: bool = True, size: QSize | tuple[int, int] | None = None, mode: str | None = None, ) -> int: """Open a dialog box to edit data set Args: parent: parent widget. Defaults to None. apply: apply callback. Defaults to None. wordwrap: if True, comment text is wordwrapped size: dialog size (default: None) mode: (str): dialog window style to use. Allowed values are "tabs", "table" and None. Use "tabs" to navigate between datasets with tabs. Use "table" to create a table with one dataset by row (allows dataset editing by double clicking on a row). Defaults to None. Returns: int: dialog box return code """ # Importing those modules here avoids Qt dependency when # guidata is used without Qt # pylint: disable=import-outside-toplevel assert mode in self.ALLOWED_MODES from guidata.dataset.qtwidgets import ( DataSetGroupEditDialog, DataSetGroupTableEditDialog, ) from guidata.qthelpers import exec_dialog dial: QDialog if mode in ("tabs", None): dial = DataSetGroupEditDialog( instance=self, # type: ignore icon=self.__icon, parent=parent, apply=apply, wordwrap=wordwrap, size=size, ) return exec_dialog(dial) else: dial = DataSetGroupTableEditDialog( instance=self, icon=self.__icon, parent=parent, apply=apply, wordwrap=wordwrap, size=size, ) return exec_dialog(dial) def accept(self, vis: object) -> None: """Helper function that passes the visitor to the accept methods of all the items in this dataset Args: vis (object): visitor """ for dataset in self.datasets: dataset.accept(vis) def is_readonly(self) -> bool: """Return True if all datasets in the DataSetGroup are in readonly mode. Returns: True if all datasets are in readonly, else False """ return all((ds.is_readonly() for ds in self.datasets)) def set_readonly(self, readonly=True): """Set all datasets of the dataset group to readonly mode Args: readonly: Readonly flag. Defaults to True. """ _ = [d.set_readonly(readonly) for d in self.datasets] class GroupItem(DataItemProxy): """GroupItem proxy Args: item (DataItem): data item """ def __init__(self, item: DataItem) -> None: DataItemProxy.__init__(self, item) self.group: list[Any] = [] class BeginGroup(DataItem): """Data item which does not represent anything but a begin flag to define a data set group Args: label (str): group label """ def __init__(self, label: str) -> None: super().__init__(label) def serialize(self, instance, writer) -> None: pass def deserialize(self, instance, reader) -> None: pass def get_group(self) -> "GroupItem": return GroupItem(self) class EndGroup(DataItem): """Data item which does not represent anything but an end flag to define a data set group Args: label (str): group label """ def __init__(self, label: str) -> None: super().__init__(label) def serialize(self, instance, writer) -> None: pass def deserialize(self, instance, reader) -> None: pass class TabGroupItem(GroupItem): pass class BeginTabGroup(BeginGroup): """Data item which does not represent anything but a begin flag to define a data set tab group Args: label (str): group label """ def get_group(self) -> "TabGroupItem": return TabGroupItem(self) class EndTabGroup(EndGroup): """Data item which does not represent anything but an end flag to define a data set tab group Args: label (str): group label """ pass class SeparatorItem(DataItem): """Data item which represents a visual separator between other items In textual representation, it appears as a series of dashes. In GUI, it appears as a horizontal gray line. Args: label (str): optional label for the separator (default: "") """ def __init__(self, label: str = "") -> None: super().__init__(label) def get_string_value(self, instance: DataSet) -> str: """Return a formatted string representation of the separator Args: instance (DataSet): instance of the DataSet Returns: str: string representation as a series of dashes """ return "-" * 50 # Return a line of 50 dashes def serialize( self, instance: DataSet, writer: HDF5Writer | JSONWriter | INIWriter ) -> None: """Serialize this item using the writer object Separators don't store any data, so this is a no-op. Args: instance (DataSet): instance of the DataSet writer (HDF5Writer | JSONWriter | INIWriter): writer object """ pass def deserialize( self, instance: DataSet, reader: HDF5Reader | JSONReader | INIReader ) -> None: """Deserialize this item using the reader object Separators don't store any data, so this is a no-op. Args: instance (DataSet): instance of the DataSet reader (HDF5Reader | JSONReader | INIReader): reader object """ pass def check_value(self, value: Any, raise_exception: bool = False) -> bool: """Check if `value` is valid for this data item Separators don't store values, so always return True. Args: value (Any): value to check raise_exception (bool): if True, raise an exception if the value is invalid Returns: bool: True (separators always have valid "values") """ return True def from_string(self, string_value: str) -> None: """Transform string into valid data item's value Separators don't store values, so return None. Args: string_value (str): string value Returns: None: separators don't have values """ return None def get_value(self, instance: DataSet) -> None: """Return data item's value Separators don't store values. Args: instance (DataSet): instance of the DataSet Returns: None: separators don't have values """ return None def __set__(self, instance: DataSet, value: Any) -> None: """Set data item's value Separators don't store values, so this is a no-op. Args: instance (DataSet): instance of the DataSet value (Any): value to set (ignored) """ pass # Separators don't store any value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/io.py0000644000175100017510000000157615114075001016715 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) from __future__ import annotations import warnings # Compatibility imports with guidata <= 3.3 from guidata.io.base import BaseIOHandler, GroupContext, WriterMixin from guidata.io.h5fmt import HDF5Handler, HDF5Reader, HDF5Writer from guidata.io.inifmt import INIHandler, INIReader, INIWriter from guidata.io.jsonfmt import JSONHandler, JSONReader, JSONWriter __all__ = [ "BaseIOHandler", "GroupContext", "HDF5Handler", "HDF5Reader", "HDF5Writer", "INIHandler", "INIReader", "INIWriter", "JSONHandler", "JSONReader", "JSONWriter", "WriterMixin", ] warnings.warn( "guidata.dataset.io module is deprecated and will be removed in a future release. " "Please use guidata.io instead.", DeprecationWarning, stacklevel=2, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/note_directive.py0000644000175100017510000000667715114075001021320 0ustar00runnerrunner# # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Sphinx directive to display a note about how to instanciate a dataset class""" from __future__ import annotations from typing import TYPE_CHECKING, Type from docutils import nodes from sphinx.util import logging from sphinx.util.docutils import SphinxDirective import guidata.dataset as gds from guidata import __version__ if TYPE_CHECKING: import sphinx.application logger = logging.getLogger(__name__) class DatasetNoteDirective(SphinxDirective): """Directive to display a note about how to instanciate a dataset class""" required_arguments = 1 # the class name is a required argument optional_arguments = 1 # the number of example lines to display is optional final_argument_whitespace = True has_content = True def run(self): class_name = self.arguments[0] example_lines: int | None if len(self.arguments) > self.required_arguments: example_lines = int(self.arguments[self.required_arguments]) else: example_lines = None cls: Type[gds.DataSet] try: # Try to import the class module_name, class_name = class_name.rsplit(".", 1) module = __import__(module_name, fromlist=[class_name]) cls = getattr(module, class_name) except Exception as e: logger.warning(f"Failed to import class {class_name}: {e}") instance_str = f"Failed to import class {class_name}" note_node = nodes.error(instance_str) return [note_node] # Create an instance of the class and get its string representation instance = cls() instance_str = str(instance) items = instance.get_items() formated_args = ", ".join( f"{item.get_name()}={item._default}" for item in instance.get_items() ) note_node = nodes.note() paragraph1 = nodes.paragraph() paragraph1 += nodes.Text("To instanciate a new ") paragraph1 += nodes.literal(text=f"{cls.__name__}") paragraph1 += nodes.Text(" , you can use the create() classmethod like this:") paragraph1 += nodes.literal_block( text=f"{cls.__name__}.create({formated_args})", language="python" ) note_node += paragraph1 paragraph2 = nodes.paragraph() paragraph2 += nodes.Text("You can also first instanciate a default ") paragraph2 += nodes.literal(text=f" {cls.__name__}") paragraph2 += nodes.Text(" and then set the fields like this:") example_lines = min(len(items), example_lines) if example_lines else len(items) code_lines = [ f"dataset = {cls.__name__}()", *( f"dataset.{items[i].get_name()} = {repr(items[i]._default)}" for i in range(example_lines) ), ] if len(items) > example_lines: code_lines.append("...") paragraph2 += nodes.literal_block( text="\n".join(code_lines), language="python", ) note_node += paragraph2 # Create a note node return [note_node] def setup(app: sphinx.application.Sphinx) -> dict[str, object]: """Initialize the Sphinx extension""" app.add_directive("datasetnote", DatasetNoteDirective) return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/qtitemwidgets.py0000644000175100017510000016126515114075001021202 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ dataset.qtitemwidgets ===================== Widget factories used to edit data items (factory registration is done in guidata.dataset.qtwidgets) (data item types are defined in guidata.dataset.datatypes) There is one widget type for each data item type. Example: ChoiceWidget <--> ChoiceItem, ImageChoiceItem """ from __future__ import annotations import datetime import inspect import os import os.path as osp import sys from abc import abstractmethod from collections.abc import Callable from typing import TYPE_CHECKING, Any, Protocol import numpy as np from qtpy.compat import getexistingdirectory from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QColor, QIcon, QPixmap from qtpy.QtWidgets import ( QAbstractButton, QCheckBox, QColorDialog, QComboBox, QDateEdit, QDateTimeEdit, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QRadioButton, QSlider, QTabWidget, QTextEdit, QVBoxLayout, QWidget, ) from guidata.config import _ from guidata.configtools import get_icon, get_image_file_path, get_image_layout from guidata.dataset.conv import restore_dataset, update_dataset from guidata.dataset.datatypes import ComputedProp, DataItemVariable from guidata.qthelpers import get_std_icon, is_dark_theme from guidata.utils.misc import convert_date_format from guidata.widgets.arrayeditor import ArrayEditor # ========================== IMPORTANT ================================= # # In this module, `item` is an instance of DataItemVariable (not DataItem) # (see guidata.datatypes for details) # # ========================== IMPORTANT ================================= # XXX: consider providing an interface here... def _get_readonly_stylesheet() -> str: """Get CSS stylesheet for read-only text widgets. Returns CSS that automatically applies styling based on readOnly property. Returns: CSS stylesheet string """ if is_dark_theme(): # Dark theme colors bg_color = "#505050" # Darker gray background text_color = "#cccccc" # Light gray text else: # Light theme colors bg_color = "#f8f8f8" # Light gray background text_color = "#333333" # Darker gray text return f""" QLineEdit[readOnly="true"] {{ background-color: {bg_color}; color: {text_color}; }} QTextEdit[readOnly="true"] {{ background-color: {bg_color}; color: {text_color}; }} """ if TYPE_CHECKING: from guidata.dataset.qtwidgets import DataSetEditLayout class AbstractDataSetWidget: """Base class for 'widgets' handled by `DataSetEditLayout` and it's derived classes. This is a generic representation of an input (or display) widget that has a label and one or more entry field. `DataSetEditLayout` uses a registry of *Item* to *Widget* mapping in order to automatically create a GUI for a `DataSet` structure Args: item: instance of `DataItemVariable` (not `DataItem`) parent_layout: parent `DataSetEditLayout` instance """ READ_ONLY = False def __init__( self, item: DataItemVariable, parent_layout: DataSetEditLayout ) -> None: # Derived constructors should create the necessary widgets. # The base class keeps a reference to item and parent self.item = item self.parent_layout = parent_layout self.group: QWidget | None = None # Layout/Widget grouping items self.label: QLabel | None = None self.build_mode = False def place_label(self, layout: QGridLayout, row: int, column: int) -> None: """Place item label on layout at specified position (row, column) Args: layout: parent layout row: row index column: column index """ label_text = self.item.get_prop_value("display", "label") unit = self.item.get_prop_value("display", "unit", "") if unit and not self.READ_ONLY: label_text += " (%s)" % unit self.label = QLabel(label_text) self.label.setToolTip(self.item.get_help()) layout.addWidget(self.label, row, column) def place_on_grid( self, layout: QGridLayout, row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ self.place_label(layout, row, label_column) layout.addWidget(self.group, row, widget_column, row_span, column_span) def is_active(self) -> bool: """Is associated item active? Returns: True if associated item is active """ return self.item.get_prop_value("display", "active", True) def is_readonly(self) -> bool: """Is the parent dataset in readonly mode Returns: True if associated dataset is readonly """ return self.item.instance.is_readonly() or self.item.get_prop_value( "display", "readonly", False ) def check(self) -> bool: """Item validator Returns: True if item value is valid """ return True def set(self) -> None: """Update data item value from widget contents""" # Skip setting computed items as they are read-only computed_prop = self.item.get_prop("data", "computed", None) if isinstance(computed_prop, ComputedProp): return # Don't try to set computed items # XXX: consider using item.set instead of item.set_from_string... self.item.set_from_string(self.value()) def get(self) -> None: """Update widget contents from data item value""" pass def value(self) -> Any: """Returns the widget's current value Returns: Widget value """ return None def set_state(self) -> None: """Update the visual status of the widget and enables/disables the widget if necessary""" active = self.is_active() if active is not None: if self.group: self.group.setEnabled(active) if self.label: self.label.setEnabled(active) def notify_value_change(self) -> None: """Notify parent layout that widget value has changed""" if not self.build_mode: self.parent_layout.widget_value_changed() def _trigger_auto_apply(self) -> None: """Automatically trigger the apply action if in DataSetEditGroupBox context. This method checks if the parent layout is part of a DataSetEditGroupBox (which has an Apply button), and if so, automatically triggers the apply action. This provides a better user experience for editors like the dictionary and array editors, where users expect changes to be applied when they click "Save & Close" rather than requiring an additional "Apply" button click. The apply is deferred to the next event loop iteration to ensure that the callback has finished updating the widget's value before apply is triggered. """ # pylint: disable=import-outside-toplevel from qtpy.QtCore import QTimer from guidata.dataset.qtwidgets import DataSetEditGroupBox # Walk up the widget hierarchy to find DataSetEditGroupBox # The parent_layout.parent may not directly be the DataSetEditGroupBox, # especially when the layout is embedded in tabs or other containers current = self.parent_layout.parent while current is not None: if isinstance(current, DataSetEditGroupBox): # Defer the apply to the next event loop iteration to ensure the # callback has finished updating the value QTimer.singleShot(0, lambda gb=current: gb.set(check=False)) return current = current.parent() if hasattr(current, "parent") else None def retrieve_top_level_layout(self) -> DataSetEditLayout: """Retrieve the top-level layout associated with this widget. If the current widget is part of a group, this method returns the parent layout of the group widget. Otherwise, it returns the immediate parent layout. Returns: DataSetEditLayout: The top-level layout for this widget. """ top_level_layout = self.parent_layout if self.parent_layout.group_widget is not None: # If we are in a group, we need to iterate over widgets of this group but # also over widgets of the other groups top_level_layout = self.parent_layout.group_widget.parent_layout return top_level_layout def contains_computed_items(self) -> bool: """Check if there are any computed items in the layout.""" widgets = self.retrieve_top_level_layout().get_terminal_widgets() for widget in widgets: computed_prop = widget.item.get_prop("data", "computed", None) if isinstance(computed_prop, ComputedProp): return True return False class GroupWidget(AbstractDataSetWidget): """GroupItem widget Args: item: instance of `DataItemVariable` (not `DataItem`) parent_layout: parent `DataSetEditLayout` instance """ def __init__( self, item: DataItemVariable, parent_layout: DataSetEditLayout ) -> None: super().__init__(item, parent_layout) embedded = item.get_prop_value("display", "embedded", False) if not embedded: self.group = QGroupBox(item.get_prop_value("display", "label")) else: self.group = QFrame() self.layout = QGridLayout() EditLayoutClass = parent_layout.__class__ self.edit = EditLayoutClass( self.group, item.instance, self.layout, item.item.group, change_callback=self.notify_value_change, group_widget=self, ) self.group.setLayout(self.layout) def get(self) -> None: """Update widget contents from data item value""" self.edit.update_widgets() def set(self) -> None: """Update data item value from widget contents""" self.edit.accept_changes() def check(self) -> bool: """Item validator Returns: True if item value is valid """ return self.edit.check_all_values() def place_on_grid( self, layout: QGridLayout, row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ layout.addWidget(self.group, row, label_column, row_span, column_span + 1) def set_state(self) -> None: """Update the visual status of the widget""" super().set_state() self.edit.refresh_widgets() class TabGroupWidget(AbstractDataSetWidget): """TabGroupItem widget Args: item: instance of `DataItemVariable` (not `DataItem`) parent_layout: parent `DataSetEditLayout` instance """ def __init__( self, item_var: DataItemVariable, parent_layout: DataSetEditLayout ) -> None: super().__init__(item_var, parent_layout) self.tabs = QTabWidget() items = item_var.item.group self.widgets = [] for item in items: if item.get_prop_value("display", parent_layout.instance, "hide", False): continue item.set_prop("display", embedded=True) widget = parent_layout.build_widget(item) frame = QFrame() label = widget.item.get_prop_value("display", "label") icon = widget.item.get_prop_value("display", "icon", None) if icon is not None: self.tabs.addTab(frame, get_icon(icon), label) else: self.tabs.addTab(frame, label) layout = QGridLayout() layout.setAlignment(Qt.AlignTop) # type:ignore frame.setLayout(layout) widget.place_on_grid(layout, 0, 0, 1) try: widget.get() except Exception: print("Error building item :", item.item._name) raise self.widgets.append(widget) def get(self) -> None: """Update widget contents from data item value""" for widget in self.widgets: widget.get() def set(self) -> None: """Update data item value from widget contents""" for widget in self.widgets: widget.set() def check(self) -> bool: """Item validator Returns: True if item value is valid """ return True def place_on_grid( self, layout: QGridLayout, row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ layout.addWidget(self.tabs, row, label_column, row_span, column_span + 1) def set_state(self) -> None: """Update the visual status of the widget and all the contained item widgets""" super().set_state() for widget in self.widgets: widget.set_state() def _display_callback(widget: AbstractDataSetWidget, value): """Handling of display callback""" cb = widget.item.get_prop_value("display", "callback", None) if widget.contains_computed_items() or cb is not None: top_level_layout = widget.retrieve_top_level_layout() if widget.build_mode: widget.set() else: top_level_layout.update_dataitems() if cb is not None: cb(widget.item.instance, widget.item.item, value) top_level_layout.update_widgets(except_this_one=widget) class LineEditWidget(AbstractDataSetWidget): """ QLineEdit-based widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.edit = self.group = QLineEdit() self.edit.setToolTip(item.get_help()) password = self.item.get_prop_value("display", "password", False) if password: self.edit.setEchoMode(QLineEdit.Password) self.edit.setStyleSheet(_get_readonly_stylesheet()) self.edit.textChanged.connect(self.line_edit_changed) # type:ignore def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() old_value = str(self.value()) if value is not None: if isinstance(value, QColor): # if item is a ColorItem object value = value.name() value = str(value) if value != old_value: self.edit.setText(value) else: # Reset widget if value is None if self.edit.text() != "": self.edit.setText("") else: # Trigger callbacks even if widget is already empty, as the logical # state changed from empty string to None self.line_edit_changed(value) def line_edit_changed(self, qvalue: str | None) -> None: """QLineEdit validator""" value = self.item.from_string(str(qvalue)) if qvalue is not None else None if not self.item.check_value(value): self.edit.setStyleSheet("background-color:rgb(255, 175, 90);") else: self.edit.setStyleSheet(_get_readonly_stylesheet()) _display_callback(self, value) self.update(value) self.notify_value_change() def update(self, value: Any) -> None: cb = self.item.get_prop_value("display", "value_callback", None) if cb is not None: cb(value) def value(self) -> str: """Returns the widget's current value Returns: Widget value """ return str(self.edit.text()) def check(self) -> bool: """Item validator Returns: True if item value is valid """ value = self.item.from_string(str(self.edit.text())) return self.item.check_value(value) def set_state(self): """Update the visual status of the widget and modify the widget to readonly if necessary""" super().set_state() self.edit.setReadOnly(self.is_readonly()) class TextEditWidget(AbstractDataSetWidget): """ QTextEdit-based widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.edit = self.group = QTextEdit() self.edit.setToolTip(item.get_help()) self.edit.setStyleSheet(_get_readonly_stylesheet()) self.edit.textChanged.connect(self.text_changed) # type:ignore def __get_text(self) -> str: """Get QTextEdit text, replacing UTF-8 EOL chars by os.linesep""" return str(self.edit.toPlainText()).replace("\u2029", os.linesep) def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() self.edit.setPlainText(value or "") # Reset widget if value is None self.text_changed() def text_changed(self) -> None: """QLineEdit validator""" value = self.item.from_string(self.__get_text()) if not self.item.check_value(value): self.edit.setStyleSheet("background-color:rgb(255, 175, 90);") else: self.edit.setStyleSheet(_get_readonly_stylesheet()) self.update(value) _display_callback(self, value) self.notify_value_change() def update(self, value: Any) -> Any: pass def value(self) -> str: """Returns the widget's current value Returns: Widget value """ return self.edit.toPlainText() def check(self) -> bool: """Item validator Returns: True if item value is valid """ value = self.item.from_string(self.__get_text()) return self.item.check_value(value) def set_state(self): """Update the visual status of the widget and modify the widget to readonly if necessary""" super().set_state() self.edit.setReadOnly(self.is_readonly()) class CheckBoxWidget(AbstractDataSetWidget): """ BoolItem widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.checkbox = QCheckBox(self.item.get_prop_value("display", "text")) self.checkbox.setToolTip(item.get_help()) self.group = self.checkbox self.store = self.item.get_prop("display", "store", None) self.checkbox.stateChanged.connect(self.state_changed) # type:ignore def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() if value is not None: self.checkbox.setChecked(value) else: # Reset widget if value is None self.checkbox.setChecked(False) def set(self) -> None: """Update data item value from widget contents""" self.item.set(self.value()) def value(self) -> bool: """Returns the widget's current value Returns: Widget value """ return self.checkbox.isChecked() def place_on_grid( self, layout: "QGridLayout", row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ if not self.item.get_prop_value("display", "label"): widget_column = label_column column_span += 1 else: self.place_label(layout, row, label_column) layout.addWidget(self.group, row, widget_column, row_span, column_span) def state_changed(self, state: bool) -> None: _display_callback(self, state) self.notify_value_change() if self.store: self.do_store(state) def do_store(self, state: bool) -> None: self.store.set(self.item.instance, self.item.item, state) self.parent_layout.refresh_widgets() def set_state(self): """Update the visual status of the widget and enables/disables it if necessary""" super().set_state() if self.is_readonly(): # Widget does not support readonly mode, disable it self.group.setDisabled(True) class DateWidget(AbstractDataSetWidget): """ DateItem widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.dateedit = self.group = QDateEdit() self.dateedit.setToolTip(item.get_help()) self.dateedit.dateTimeChanged.connect(self.date_changed) fmt = self.item.get_prop("display", "format", None) if fmt: qt_fmt = convert_date_format(fmt) self.dateedit.setDisplayFormat(qt_fmt) def date_changed(self, value): """Date changed""" _display_callback(self, value) self.notify_value_change() def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() if value: if not isinstance(value, datetime.date): value = datetime.date.fromordinal(value) self.dateedit.setDate(value) elif value is None: # Reset widget if value is None self.dateedit.setDate(datetime.date.today()) def set(self) -> None: """Update data item value from widget contents""" self.item.set(self.value()) def value(self) -> datetime: # type:ignore """Returns the widget's current value Returns: Widget value """ try: return self.dateedit.date().toPyDate() except AttributeError: return self.dateedit.dateTime().toPython().date() # type:ignore # PySide def set_state(self): """Update the visual status of the widget and modify the widget to readonly if necessary""" super().set_state() self.dateedit.setReadOnly(self.is_readonly()) class DateTimeWidget(AbstractDataSetWidget): """ DateTimeItem widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.dateedit = self.group = QDateTimeEdit() self.dateedit.setCalendarPopup(True) self.dateedit.setToolTip(item.get_help()) self.dateedit.dateTimeChanged.connect( # type:ignore lambda value: self.notify_value_change() ) fmt = self.item.get_prop("display", "format", None) if fmt: qt_fmt = convert_date_format(fmt) self.dateedit.setDisplayFormat(qt_fmt) def date_changed(self, value): """Date changed""" _display_callback(self, value) self.notify_value_change() def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() if value: if not isinstance(value, datetime.datetime): value = datetime.datetime.fromtimestamp(value) self.dateedit.setDateTime(value) elif value is None: # Reset widget if value is None self.dateedit.setDateTime(datetime.datetime.now()) def set(self) -> None: """Update data item value from widget contents""" self.item.set(self.value()) def value(self) -> datetime: # type:ignore """Returns the widget's current value Returns: Widget value """ try: return self.dateedit.dateTime().toPyDateTime() except AttributeError: return self.dateedit.dateTime().toPython() # type:ignore # PySide def set_state(self): """Update the visual status of the widget and modify the widget to readonly if necessary""" super().set_state() self.dateedit.setReadOnly(self.is_readonly()) class GroupLayout(QHBoxLayout): def __init__(self) -> None: QHBoxLayout.__init__(self) self.widgets: list[QWidget] = [] def addWidget(self, widget: QWidget) -> None: # type:ignore QHBoxLayout.addWidget(self, widget) self.widgets.append(widget) def setEnabled(self, state: bool) -> None: for widget in self.widgets: widget.setEnabled(state) class HasGroupProtocol(Protocol): @property def group(self): pass def place_label(self, layout: QGridLayout, row: int, column: int) -> None: """Place item label on layout at specified position (row, column) Args: layout: parent layout row: row index column: column index """ pass class HLayoutMixin: def __init__( self: "HasGroupProtocol", item: "DataItemVariable", parent_layout: "DataSetEditLayout", ) -> None: super().__init__(item, parent_layout) # type:ignore old_group = self.group self.group = GroupLayout() self.group.addWidget(old_group) def place_on_grid( self: "HasGroupProtocol", layout: "QGridLayout", row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ): """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ self.place_label(layout, row, label_column) layout.addLayout(self.group, row, widget_column, row_span, column_span) class ColorWidget(HLayoutMixin, LineEditWidget): """ ColorItem widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.button = QPushButton("") self.button.setMaximumWidth(32) self.__signal_connected: bool = False self.__handle_button_connection() self.group.addWidget(self.button) def update(self, value: str) -> None: """Reimplement LineEditWidget method""" LineEditWidget.update(self, value) color = QColor("" if value is None else value) if color.isValid(): bitmap = QPixmap(16, 16) bitmap.fill(color) icon = QIcon(bitmap) else: icon = get_icon("not_found.png") self.button.setIcon(icon) def select_color(self) -> None: """Open a color selection dialog box""" color = QColor(self.edit.text()) if not color.isValid(): color = Qt.gray # type:ignore color = QColorDialog.getColor(color, self.parent_layout.parent) if color.isValid(): value = color.name() self.edit.setText(value) self.update(value) self.notify_value_change() def __handle_button_connection(self): """Connects the button for the color selection function if parent dataset is not in readonly mode and signal is not already connected but disconnects it if the dataset is readonly. """ if not self.__signal_connected and not self.is_readonly(): self.__signal_connected = True self.button.clicked.connect(self.select_color) elif self.__signal_connected and self.is_readonly(): self.button.clicked.disconnect() self.__signal_connected = False def set_state(self): """Update the visual status of the widget and disconnects/reconnects the button action if necessary""" super().set_state() self.__handle_button_connection() class SliderWidget(HLayoutMixin, LineEditWidget): """ IntItem with Slider """ DATA_TYPE: type = int def __init__( self, item: DataItemVariable, parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.slider = self.vmin = self.vmax = None if item.get_prop_value("display", "slider"): self.vmin = item.get_prop_value("data", "min") self.vmax = item.get_prop_value("data", "max") assert self.vmin is not None and self.vmax is not None, ( "SliderWidget requires that item min/max have been defined" ) self.slider = QSlider() self.slider.setOrientation(Qt.Horizontal) # type:ignore self.setup_slider(item) self.slider.valueChanged.connect(self.value_changed) # type:ignore self.group.addWidget(self.slider) def value_to_slider(self, value): return value def slider_to_value(self, value): return value def setup_slider(self, item): self.slider.setRange(self.vmin, self.vmax) def update(self, value): """Reimplement LineEditWidget method""" LineEditWidget.update(self, value) if self.slider is not None and isinstance(value, self.DATA_TYPE): self.slider.blockSignals(True) self.slider.setValue(self.value_to_slider(value)) self.slider.blockSignals(False) def value_changed(self, ivalue): """Update the lineedit""" value = str(self.slider_to_value(ivalue)) self.edit.setText(value) self.update(value) def set_state(self): """Update the visual status of the widget and enables/disables it if necessary""" super().set_state() if self.slider is not None: if self.is_readonly(): # Widget does not support readonly mode, disable it self.slider.setDisabled(True) class FloatSliderWidget(SliderWidget): """ FloatItem with Slider """ DATA_TYPE: type = float def value_to_slider(self, value): return int((value - self.vmin) * 100 / (self.vmax - self.vmin)) def slider_to_value(self, value): return value * (self.vmax - self.vmin) / 100 + self.vmin def setup_slider(self, item): self.slider.setRange(0, 100) def _get_child_title_func(ancestor): previous_ancestor = None while True: try: if previous_ancestor is ancestor: break return ancestor.child_title except AttributeError: previous_ancestor = ancestor ancestor = ancestor.parent() return lambda item: "" class FileWidget(HLayoutMixin, LineEditWidget): """ File path item widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout", filedialog: Callable, ) -> None: super().__init__(item, parent_layout) self.filedialog = filedialog self.button = QPushButton(_("Browse...")) fmt = item.get_prop_value("data", "formats") self.button.setIcon(get_icon("%s.png" % fmt[0].lower(), default="FileIcon")) self.button.clicked.connect(self.select_file) # type:ignore self.group.addWidget(self.button) self.basedir = item.get_prop_value("data", "basedir") self.all_files_first = item.get_prop_value("data", "all_files_first") def select_file(self) -> None: """Open a file selection dialog box""" fname = self.item.from_string(str(self.edit.text())) if isinstance(fname, list): fname = osp.dirname(fname[0]) parent = self.parent_layout.parent _temp = sys.stdout sys.stdout = None # type:ignore if len(fname) == 0: fname = self.basedir _formats = self.item.get_prop_value("data", "formats") formats = [str(format).lower() for format in _formats] filter_lines = [ (_("%s files") + " (*.%s)") % (format.upper(), format) for format in formats ] all_filter = _("All supported files") + " (*.%s)" % " *.".join(formats) if len(formats) > 1: if self.all_files_first: filter_lines.insert(0, all_filter) else: filter_lines.append(all_filter) if fname is None: fname = "" child_title = _get_child_title_func(parent) fname, _filter = self.filedialog( parent, child_title(self.item), fname, "\n".join(filter_lines) ) sys.stdout = _temp if fname: if isinstance(fname, list): fname = str(fname) self.edit.setText(fname) def set_state(self): """Update the visual status of the widget and disbales/enables it if necessary""" super().set_state() if self.is_readonly(): # Widget does not support readonly mode, disable it self.button.setDisabled(True) class DirectoryWidget(HLayoutMixin, LineEditWidget): """ Directory path item widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.button = QPushButton(_("Browse...")) self.button.setIcon(get_std_icon("DirOpenIcon")) self.button.clicked.connect(self.select_directory) # type:ignore self.group.addWidget(self.button) def select_directory(self) -> None: """Open a directory selection dialog box""" value = self.item.from_string(str(self.edit.text())) parent = self.parent_layout.parent child_title = _get_child_title_func(parent) dname = getexistingdirectory(parent, child_title(self.item), value) if dname: self.edit.setText(dname) def set_state(self): """Update the visual status of the widget and disbales/enables it if necessary""" super().set_state() if self.is_readonly(): # Widget does not support readonly mode, disable it self.button.setDisabled(True) class ChoiceWidget(AbstractDataSetWidget): """ Choice item widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self._first_call = True self.is_radio = item.get_prop_value("display", "radio") self.image_size = item.get_prop_value("display", "size", None) self.store = self.item.get_prop("display", "store", None) if self.is_radio: self.group = QGroupBox() self.group.setToolTip(item.get_help()) self.vbox = QVBoxLayout() self.group.setLayout(self.vbox) self._buttons: list[QAbstractButton] = [] else: self.combobox = self.group = QComboBox() if self.image_size is not None: width, height = self.image_size self.combobox.setIconSize(QSize(width, height)) self.combobox.setToolTip(item.get_help()) self.combobox.currentIndexChanged.connect(self.index_changed) # type:ignore def index_changed(self, index: int) -> None: """Update the data item value when the index of the combobox changes Args: index: index of the combobox, unused (but required by the signal) """ if self.store: self.store.set(self.item.instance, self.item.item, self.value()) self.parent_layout.refresh_widgets() _display_callback(self, self.value()) self.notify_value_change() def initialize_widget(self) -> None: """Widget initialization depending on the type of the widget (combobox or radiobuttons) """ if self.is_radio: for button in self._buttons: button.hide() button.toggled.disconnect(self.index_changed) # type:ignore self.vbox.removeWidget(button) button.deleteLater() self._buttons = [] else: self.combobox.blockSignals(True) while self.combobox.count(): self.combobox.removeItem(0) _choices = self.item.get_prop_value("data", "choices") for key, lbl, img in _choices: if self.is_radio: button = QRadioButton(lbl, self.group) if self.image_size is not None: width, height = self.image_size button.setIconSize(QSize(width, height)) if img: if isinstance(img, str): if not osp.isfile(img): img = get_image_file_path(img) img = QIcon(img) elif isinstance(img, Callable): # type:ignore img = img(key) if self.is_radio: button.setIcon(img) else: self.combobox.addItem(img, lbl) elif not self.is_radio: self.combobox.addItem(lbl) if self.is_radio: self._buttons.append(button) self.vbox.addWidget(button) button.toggled.connect(self.index_changed) # type:ignore if not self.is_radio: self.combobox.blockSignals(False) def set_widget_value(self, idx: int) -> None: """Set the value of the widget to the given index depending on the type of the widget (combobox or radiobuttons) Args: idx: index to set """ if self.is_radio: for button in self._buttons: button.blockSignals(True) self._buttons[idx].setChecked(True) for button in self._buttons: button.blockSignals(False) else: self.combobox.blockSignals(True) self.combobox.setCurrentIndex(idx) self.combobox.blockSignals(False) def get_widget_value(self) -> int | None: """Returns the index of the widget depending on the type of the widget (combobox or radiobuttons). Returns: current index """ if self.is_radio: for index, widget in enumerate(self._buttons): if widget.isChecked(): return index return None return self.combobox.currentIndex() def get(self) -> None: """Update widget contents from data item value""" self.initialize_widget() value = self.item.get() if value is not None: idx = 0 _choices = self.item.get_prop_value("data", "choices") for key, _val, _img in _choices: if key == value: break idx += 1 self.set_widget_value(idx) if self._first_call: self.index_changed(idx) self._first_call = False else: # Reset widget if value is None self.set_widget_value(0) if self._first_call: self.index_changed(0) self._first_call = False def set(self) -> None: """Update data item value from widget contents""" try: value = self.value() except IndexError: return self.item.set(value) def value(self) -> Any: """Returns the widget's current value Returns: Widget value """ index = self.get_widget_value() choices = self.item.get_prop_value("data", "choices") return choices[index][0] def set_state(self): """Update the visual status of the widget and disables/enables it if necessary""" super().set_state() if self.is_readonly(): # Widget does not support readonly mode, disable it self.group.setDisabled(True) class MultipleChoiceWidget(AbstractDataSetWidget): """ Multiple choice item widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.groupbox = self.group = QGroupBox(item.get_prop_value("display", "label")) layout = QGridLayout() self.boxes = [] nx, ny = item.get_prop_value("display", "shape") cx, cy = 0, 0 _choices = item.get_prop_value("data", "choices") for _k, choice, _img in _choices: checkbox = QCheckBox(choice) checkbox.stateChanged.connect(lambda: self.checkbox_clicked()) layout.addWidget(checkbox, cx, cy) if nx < 0: cy += 1 if cy >= ny: cy = 0 cx += 1 else: cx += 1 if cx >= nx: cx = 0 cy += 1 self.boxes.append(checkbox) self.groupbox.setLayout(layout) def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() _choices = self.item.get_prop_value("data", "choices") for (i, _choice, _img), checkbox in zip(_choices, self.boxes): if value is not None and i in value: checkbox.setChecked(True) else: # Reset widget if value is None or item not in value checkbox.setChecked(False) def set(self) -> None: """Update data item value from widget contents""" _choices = self.item.get_prop_value("data", "choices") choices = [_choices[i][0] for i in self.value()] self.item.set(choices) def value(self) -> list[int]: """Returns the widget's current value Returns: Widget value """ return [i for i, w in enumerate(self.boxes) if w.isChecked()] def place_on_grid( self, layout: "QGridLayout", row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ layout.addWidget(self.group, row, label_column, row_span, column_span + 1) def set_state(self): """Update the visual status of the widget and disables/enables it if necessary""" super().set_state() if self.is_readonly(): # Widget does not support readonly mode, disable it self.group.setDisabled(True) def checkbox_clicked(self): """Update the data item value when a checkbox is clicked""" self.notify_value_change() _display_callback(self, self.value()) class FloatArrayWidget(AbstractDataSetWidget): """ FloatArrayItem widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) _label = item.get_prop_value("display", "label") self.groupbox = self.group = QGroupBox(_label) self.layout = QGridLayout() self.layout.setAlignment(Qt.AlignLeft) # type:ignore self.groupbox.setLayout(self.layout) self.first_line, self.dim_label = get_image_layout( "shape.png", _("Number of rows x Number of columns") ) edit_button = QPushButton(get_icon("arredit.png"), "") edit_button.setToolTip(_("Edit array contents")) edit_button.setMaximumWidth(32) self.first_line.addWidget(edit_button) self.layout.addLayout(self.first_line, 0, 0) self.min_line, self.min_label = get_image_layout( "min.png", _("Smallest element in array") ) self.layout.addLayout(self.min_line, 1, 0) self.max_line, self.max_label = get_image_layout( "max.png", _("Largest element in array") ) self.layout.addLayout(self.max_line, 2, 0) edit_button.clicked.connect(self.edit_array) # type:ignore self.arr = np.array([]) # le tableau si il a été modifié self.instance = None self.dtype_line, self.dtype_label = get_image_layout("dtype.png", "") self.first_line.insertSpacing(2, 5) self.first_line.insertLayout(3, self.dtype_line) def edit_array(self) -> None: """Open an array editor dialog""" parent = self.parent_layout.parent label = self.item.get_prop_value("display", "label") variable_size = self.item.get_prop_value("edit", "variable_size", default=False) editor = ArrayEditor(parent) if ( editor.setup_and_check( self.arr, title=label, readonly=self.is_readonly(), variable_size=variable_size, ) and editor.exec() ): self.update(self.arr) self.notify_value_change() # Auto-apply changes if in a DataSetEditGroupBox context self._trigger_auto_apply() def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() if value is not None: self.arr = np.asarray(value) if self.item.get_prop_value("display", "transpose"): self.arr = self.arr.T self.update(self.arr) else: # Reset widget if value is None self.arr = np.array([]) self.update(self.arr) def update(self, arr: np.ndarray) -> None: shape = arr.shape if len(shape) == 1: shape = (1,) + shape dim = " x ".join([str(d) for d in shape]) self.dim_label.setText(dim) format = self.item.get_prop_value("display", "format") minmax = self.item.get_prop_value("display", "minmax") real_arr = np.real(arr) try: if minmax == "all": mint = format % real_arr.min() maxt = format % real_arr.max() elif minmax == "columns": mint = ", ".join( [format % real_arr[r, :].min() for r in range(arr.shape[0])] ) maxt = ", ".join( [format % real_arr[r, :].max() for r in range(arr.shape[0])] ) else: mint = ", ".join( [format % real_arr[:, r].min() for r in range(arr.shape[1])] ) maxt = ", ".join( [format % real_arr[:, r].max() for r in range(arr.shape[1])] ) except (TypeError, IndexError, ValueError): mint, maxt = "-", "-" self.min_label.setText(mint) self.max_label.setText(maxt) typestr = str(arr.dtype) self.dtype_label.setText("-" if typestr == "object" else typestr) def set(self) -> None: """Update data item value from widget contents""" if self.item.get_prop_value("display", "transpose"): value = self.value().T else: value = self.value() self.item.set(value) def value(self) -> np.ndarray: """Returns the widget's current value Returns: Widget value """ return self.arr def place_on_grid( self, layout: "QGridLayout", row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ layout.addWidget(self.group, row, label_column, row_span, column_span + 1) class ButtonWidget(AbstractDataSetWidget): """ BoolItem widget """ def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) _label = self.item.get_prop_value("display", "label") self.button = self.group = QPushButton(_label) self.button.setToolTip(item.get_help()) image_size = item.get_prop_value("display", "size", None) if image_size is not None: width, height = image_size self.button.setIconSize(QSize(width, height)) _icon = self.item.get_prop_value("display", "icon") if _icon is not None: if isinstance(_icon, str): _icon = get_icon(_icon) self.button.setIcon(_icon) self.button.clicked.connect(self.clicked) # type:ignore self.cb_value = None def get(self) -> None: """Update widget contents from data item value""" self.cb_value = self.item.get() def set(self) -> None: """Update data item value from widget contents""" self.item.set(self.value()) def value(self) -> Any | None: """Returns the widget's current value Returns: Widget value """ return self.cb_value def place_on_grid( self, layout: "QGridLayout", row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ): """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ layout.addWidget(self.group, row, label_column, row_span, column_span + 1) def clicked(self, *args) -> None: """Execute callback function when button is clicked and updates the items and widget. """ self.parent_layout.update_dataitems() callback = self.item.get_prop_value("display", "callback") # Pass auto-apply trigger function as optional 5th parameter, only if callback # takes 5 parameters: sig = inspect.signature(callback) if len(sig.parameters) < 5: self.cb_value = callback( self.item.instance, self.item.item, self.cb_value, self.button.parent(), ) else: self.cb_value = callback( self.item.instance, self.item.item, self.cb_value, self.button.parent(), self._trigger_auto_apply, ) self.set() self.parent_layout.update_widgets() self.notify_value_change() class DataSetWidget(AbstractDataSetWidget): """ DataSet widget """ @property @abstractmethod def klass(self) -> type: """Return the class of the dataset Returns: class of the dataset """ pass def __init__( self, item: "DataItemVariable", parent_layout: "DataSetEditLayout" ) -> None: super().__init__(item, parent_layout) self.dataset = self.klass() # Création du layout contenant les champs d'édition du signal embedded = item.get_prop_value("display", "embedded", False) if not embedded: self.group = QGroupBox(item.get_prop_value("display", "label")) else: self.group = QFrame() self.layout = QGridLayout() self.group.setLayout(self.layout) EditLayoutClass = parent_layout.__class__ self.edit = EditLayoutClass( self.parent_layout.parent, self.dataset, self.layout ) def get(self) -> None: """Update widget contents from data item value""" self.get_dataset() for widget in self.edit.widgets: widget.get() def set(self) -> None: """Update data item value from widget contents""" for widget in self.edit.widgets: widget.set() self.set_dataset() def get_dataset(self) -> None: """Update's internal parameter representation from the item's stored value default behavior uses update_dataset and assumes internal dataset class is the same as item's value class""" item = self.item.get() update_dataset(self.dataset, item) def set_dataset(self) -> None: """Update the item's value from the internal data representation default behavior uses restore_dataset and assumes internal dataset class is the same as item's value class""" item = self.item.get() restore_dataset(self.dataset, item) def place_on_grid( self, layout: "QGridLayout", row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ layout.addWidget(self.group, row, label_column, row_span, column_span + 1) class SeparatorWidget(AbstractDataSetWidget): """SeparatorItem widget Displays a horizontal gray line as a visual separator between other items. Args: item: instance of `DataItemVariable` (not `DataItem`) parent_layout: parent `DataSetEditLayout` instance """ def __init__( self, item: DataItemVariable, parent_layout: DataSetEditLayout ) -> None: super().__init__(item, parent_layout) # Create a frame to hold the separator line self.group = QFrame() self.group.setFrameShape(QFrame.HLine) self.group.setFrameShadow(QFrame.Sunken) # Set the color to gray self.group.setStyleSheet("QFrame { color: gray; }") # Set minimum height for visibility self.group.setMinimumHeight(10) self.group.setMaximumHeight(10) def get(self) -> None: """Update widget contents from data item value Separators don't have values, so this is a no-op. """ pass def set(self) -> None: """Update data item value from widget contents Separators don't have values, so this is a no-op. """ pass def value(self) -> None: """Returns the widget's current value Separators don't have values. Returns: None: separators don't have values """ return None def place_on_grid( self, layout: QGridLayout, row: int, label_column: int, widget_column: int, row_span: int = 1, column_span: int = 1, ) -> None: """Place widget on layout at specified position For separators, we span across both label and widget columns. Args: layout: parent layout row: row index label_column: column index for label (ignored for separators) widget_column: column index for widget (ignored for separators) row_span: number of rows to span column_span: number of columns to span """ # Span across both label and widget columns for full-width separator layout.addWidget(self.group, row, label_column, row_span, column_span + 1) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/qtwidgets.py0000644000175100017510000011157615114075001020323 0ustar00runnerrunner# # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Qt widgets for data sets ------------------------ This module provides a set of widgets to edit and show data sets, using ready-to-use dialog boxes, layouts and group boxes. Dialog boxes ^^^^^^^^^^^^ .. autoclass:: DataSetEditDialog :show-inheritance: :members: .. autoclass:: DataSetShowDialog :show-inheritance: :members: .. autoclass:: DataSetGroupEditDialog :show-inheritance: :members: Layouts ^^^^^^^ .. autoclass:: DataSetEditLayout :show-inheritance: :members: .. autoclass:: DataSetShowLayout :show-inheritance: :members: Group boxes ^^^^^^^^^^^ .. autoclass:: DataSetShowGroupBox :show-inheritance: :members: .. autoclass:: DataSetEditGroupBox :show-inheritance: :members: """ from __future__ import annotations from typing import TYPE_CHECKING, Any, Generic from qtpy.compat import getopenfilename, getopenfilenames, getsavefilename from qtpy.QtCore import ( QAbstractTableModel, QModelIndex, QObject, QRect, QSize, Qt, Signal, ) from qtpy.QtGui import QBrush, QColor, QCursor, QIcon, QPainter, QPicture from qtpy.QtWidgets import ( QAbstractButton, QApplication, QDialog, QDialogButtonBox, QGridLayout, QGroupBox, QLabel, QMessageBox, QPushButton, QSpacerItem, QTableView, QTabWidget, QVBoxLayout, QWidget, ) from guidata.config import CONF, _ from guidata.configtools import get_font, get_icon from guidata.dataset.datatypes import ( AnyDataSet, BeginGroup, DataItem, DataItemVariable, DataSet, DataSetGroup, EndGroup, GroupItem, SeparatorItem, TabGroupItem, ) from guidata.qthelpers import win32_fix_title_bar_background if TYPE_CHECKING: from typing import Callable class DataSetEditDialog(QDialog): """Dialog box for DataSet editing Args: instance: DataSet instance to edit icon: icon name (default: "guidata.svg") parent: parent widget apply: function called when Apply button is clicked wordwrap: if True, comment text is wordwrapped size: dialog size (default: None) """ def __init__( self, instance: DataSet | DataSetGroup, icon: str | QIcon = "", parent: QWidget | None = None, apply: Callable | None = None, wordwrap: bool = True, size: QSize | tuple[int, int] | None = None, ) -> None: super().__init__(parent) win32_fix_title_bar_background(self) self.wordwrap = wordwrap self.apply_func = apply self._layout = QVBoxLayout() if instance.get_comment(): label = QLabel(instance.get_comment()) label.setTextInteractionFlags(Qt.TextSelectableByMouse) label.setWordWrap(wordwrap) self._layout.addWidget(label) self.instance = instance self.edit_layout: list[DataSetEditLayout] = [] self.setup_instance(instance) if apply is not None: apply_button = QDialogButtonBox.Apply else: apply_button = QDialogButtonBox.NoButton if not instance.is_readonly(): bbox = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel | apply_button ) self.bbox = bbox bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) bbox.clicked.connect(self.button_clicked) self._layout.addWidget(bbox) self.setLayout(self._layout) if parent is None: if not isinstance(icon, QIcon): icon = get_icon(icon, default="guidata.svg") self.setWindowIcon(icon) self.setModal(True) self.setWindowTitle(instance.get_title()) if size is not None: if isinstance(size, QSize): self.resize(size) else: self.resize(*size) def button_clicked(self, button: QAbstractButton) -> None: """Handle button click Args: button: button that was clicked """ role = self.bbox.buttonRole(button) if ( role == QDialogButtonBox.ApplyRole # type:ignore and self.apply_func is not None ) and self.check(): for edl in self.edit_layout: edl.accept_changes() self.apply_func(self.instance) def setup_instance(self, instance: Any) -> None: """Construct main layout Args: instance: DataSet instance to edit """ grid = QGridLayout() grid.setAlignment(Qt.AlignTop) # type:ignore self._layout.addLayout(grid) self.edit_layout.append(self.layout_factory(instance, grid)) def layout_factory(self, instance: DataSet, grid: QGridLayout) -> DataSetEditLayout: """A factory method that produces instances of DataSetEditLayout or derived classes (see DataSetShowDialog) Args: instance: DataSet instance to edit grid: grid layout Returns: DataSetEditLayout instance """ return DataSetEditLayout(self, instance, grid) def child_title(self, item: DataItemVariable) -> str: """Return data item title combined with QApplication title Args: item: data item Returns: title """ app_name = QApplication.applicationName() if not app_name: app_name = self.instance.get_title() return f"{app_name} - {item.label()}" def check(self) -> bool: """Check input of all widgets Returns: True if all widgets are valid """ is_ok = True for edl in self.edit_layout: if not edl.check_all_values(): is_ok = False if not is_ok: QMessageBox.warning( self, self.instance.get_title(), _("Some required entries are incorrect") + "\n" + _("Please check highlighted fields."), ) return False return True def accept(self) -> None: """Validate inputs""" if self.check(): for edl in self.edit_layout: edl.accept_changes() QDialog.accept(self) class DataSetGroupEditDialog(DataSetEditDialog): """Tabbed dialog box for DataSet editing Args: instance: DataSetGroup instance to edit icon: icon name (default: "guidata.svg") parent: parent widget apply: function called when Apply button is clicked wordwrap: if True, comment text is wordwrapped size: dialog size (default: None) """ def setup_instance(self, instance: DataSetGroup) -> None: """Construct main layout Args: instance: DataSetGroup instance to edit """ assert isinstance(instance, DataSetGroup) tabs = QTabWidget() # tabs.setUsesScrollButtons(False) self._layout.addWidget(tabs) for dataset in instance.datasets: layout = QVBoxLayout() layout.setAlignment(Qt.AlignmentFlag.AlignTop) if dataset.get_comment(): label = QLabel(dataset.get_comment()) label.setTextInteractionFlags(Qt.TextSelectableByMouse) label.setWordWrap(self.wordwrap) layout.addWidget(label) grid = QGridLayout() self.edit_layout.append(self.layout_factory(dataset, grid)) layout.addLayout(grid) page = QWidget() page.setLayout(layout) if dataset.get_icon(): tabs.addTab(page, get_icon(dataset.get_icon()), dataset.get_title()) else: tabs.addTab(page, dataset.get_title()) class DataSetEditLayout(Generic[AnyDataSet]): """Layout in which data item widgets are placed Args: parent: parent widget instance: DataSet instance to edit layout: grid layout items: list of data items first_line: first line of grid layout change_callback: function called when any widget's value has changed group_widget: group widget associated with this layout, if any """ _widget_factory: dict[Any, Any] = {} @classmethod def register(cls: type, item_type: type, factory: Any) -> None: """Register a factory for a new item_type Args: item_type: item type factory: factory function """ cls._widget_factory[item_type] = factory def __init__( self, parent: QWidget | None, instance: AnyDataSet, layout: QGridLayout, items: list[DataItem] | None = None, first_line: int = 0, change_callback: Callable | None = None, group_widget: GroupWidget | None = None, ) -> None: self.parent = parent self.instance = instance self.layout = layout self.first_line = first_line self.change_callback = change_callback self.group_widget = group_widget self.widgets: list[AbstractDataSetWidget] = [] # self.linenos = {} # prochaine ligne à remplir par colonne self.items_pos: dict[DataItem, list[int]] = {} if not items: items = self.instance._items # Filter out trailing separators for GUI display while items and isinstance(items[-1], SeparatorItem): items = items[:-1] items = self.transform_items(items) # type:ignore self.setup_layout(items) def transform_items(self, items: list[DataItem]) -> list[DataItem]: """Handle group of items: transform items into a GroupItem instance if they are located between BeginGroup and EndGroup Args: items: list of data items Returns: list of data items """ item_lists: Any = [[]] for item in items: if isinstance(item, BeginGroup): group_item = item.get_group() item_lists[-1].append(group_item) item_lists.append(group_item.group) elif isinstance(item, EndGroup): item_lists.pop() else: item_lists[-1].append(item) assert len(item_lists) == 1 return item_lists[-1] def check_all_values(self) -> bool: """Check input of all widgets Returns: True if all widgets are valid """ for widget in self.widgets: if widget.is_active() and not widget.check(): return False return True def accept_changes(self) -> None: """Accept changes made to widget inputs""" self.update_dataitems() def setup_layout(self, items: list[DataItem]) -> None: """Place items on layout Args: items: list of data items """ def last_col(col, span): """Return last column (which depends on column span)""" if not span: return col return col + span - 1 colmax = max( last_col( item.get_prop("display", "col"), item.get_prop("display", "colspan") ) for item in items ) # Check if specified rows are consistent sorted_items: list[DataItem | None] = [None] * len(items) rows = [] other_items = [] for item in items: row = item.get_prop("display", "row") if row is not None: if row in rows: raise ValueError( f"Duplicate row index ({row}) for item {item.get_name()}" ) if row < 0 or row >= len(items): raise ValueError( f"Out of range row index ({row}) for item {item.get_name()}" ) rows.append(row) sorted_items[row] = item else: other_items.append(item) for idx, item in enumerate(sorted_items[:]): # type:ignore if item is None: sorted_items[idx] = other_items.pop(0) self.items_pos = {} line = self.first_line - 1 last_item = [-1, 0, colmax] for item in sorted_items: # type:ignore col = item.get_prop("display", "col") colspan = item.get_prop("display", "colspan") if colspan is None: colspan = colmax - col + 1 if col <= last_item[1]: # on passe à la ligne si la colonne de debut de cet item # est avant la colonne de debut de l'item précédent line += 1 else: last_item[2] = col - last_item[1] last_item = [line, col, colspan] self.items_pos[item] = last_item for item in items: hide = item.get_prop_value("display", self.instance, "hide", False) if hide: continue widget = self.build_widget(item) self.add_row(widget) self.refresh_widgets() def build_widget(self, item: DataItem) -> DataSetShowWidget: """Build widget for item Args: item: data item Returns: widget """ factory = self._widget_factory[type(item)] widget = factory(item.bind(self.instance), self) self.widgets.append(widget) return widget def add_row(self, widget: DataSetShowWidget) -> None: """Add widget to row Args: widget: widget to add """ item = widget.item line, col, span = self.items_pos[item.item] if col > 0: self.layout.addItem(QSpacerItem(20, 1), line, col * 3 - 1) widget.place_on_grid(self.layout, line, col * 3, col * 3 + 1, 1, 3 * span - 2) try: widget.get() except Exception: print("Error building item :", item.item.get_name()) raise def refresh_widgets(self) -> None: """Refresh the status of all widgets""" for widget in self.widgets: widget.set_state() def update_dataitems(self) -> None: """Refresh the content of all data items""" for widget in self.widgets: if widget.is_active(): widget.set() def update_widgets( self, except_this_one: QWidget | AbstractDataSetWidget | None = None ) -> None: """Refresh the content of all widgets Args: except_this_one: widget to skip """ for widget in self.widgets: if widget is not except_this_one: widget.get() def widget_value_changed(self) -> None: """Method called when any widget's value has changed""" if self.change_callback is not None: self.change_callback() def get_terminal_widgets(self) -> list[AbstractDataSetWidget]: """Get all terminal widgets (i.e. not GroupWidget or TabGroupWidget). Returns: List of terminal widgets """ stack = self.widgets[:] terminal_widgets = [] while stack: widget = stack.pop() if isinstance(widget, GroupWidget): stack.extend(widget.edit.widgets) elif isinstance(widget, TabGroupWidget): stack.extend(widget.widgets) else: terminal_widgets.append(widget) return terminal_widgets from guidata.dataset.dataitems import ( # noqa: E402 BoolItem, ButtonItem, ChoiceItem, ColorItem, DateItem, DateTimeItem, DictItem, DirectoryItem, FileOpenItem, FileSaveItem, FilesOpenItem, FloatArrayItem, FloatItem, ImageChoiceItem, IntItem, MultipleChoiceItem, StringItem, TextItem, ) # Enregistrement des correspondances avec les widgets from guidata.dataset.qtitemwidgets import ( # noqa: E402 AbstractDataSetWidget, ButtonWidget, CheckBoxWidget, ChoiceWidget, ColorWidget, DateTimeWidget, DateWidget, DirectoryWidget, FileWidget, FloatArrayWidget, FloatSliderWidget, GroupWidget, LineEditWidget, MultipleChoiceWidget, SeparatorWidget, SliderWidget, TabGroupWidget, TextEditWidget, ) DataSetEditLayout.register(GroupItem, GroupWidget) DataSetEditLayout.register(TabGroupItem, TabGroupWidget) DataSetEditLayout.register(FloatItem, LineEditWidget) DataSetEditLayout.register(StringItem, LineEditWidget) DataSetEditLayout.register(TextItem, TextEditWidget) DataSetEditLayout.register(IntItem, SliderWidget) DataSetEditLayout.register(FloatItem, FloatSliderWidget) DataSetEditLayout.register(BoolItem, CheckBoxWidget) DataSetEditLayout.register(DateItem, DateWidget) DataSetEditLayout.register(DateTimeItem, DateTimeWidget) DataSetEditLayout.register(ColorItem, ColorWidget) DataSetEditLayout.register( FileOpenItem, lambda item, parent: FileWidget(item, parent, getopenfilename) ) DataSetEditLayout.register( FilesOpenItem, lambda item, parent: FileWidget(item, parent, getopenfilenames) ) DataSetEditLayout.register( FileSaveItem, lambda item, parent: FileWidget(item, parent, getsavefilename) ) DataSetEditLayout.register(DirectoryItem, DirectoryWidget) DataSetEditLayout.register(ChoiceItem, ChoiceWidget) DataSetEditLayout.register(ImageChoiceItem, ChoiceWidget) DataSetEditLayout.register(MultipleChoiceItem, MultipleChoiceWidget) DataSetEditLayout.register(FloatArrayItem, FloatArrayWidget) DataSetEditLayout.register(ButtonItem, ButtonWidget) DataSetEditLayout.register(DictItem, ButtonWidget) DataSetEditLayout.register(SeparatorItem, SeparatorWidget) LABEL_CSS = """ QLabel { font-weight: bold; color: blue } QLabel:disabled { font-weight: bold; color: grey } """ class DataSetShowWidget(AbstractDataSetWidget): """Read-only base widget Args: item: data item variable (``DataItemVariable``) parent_layout: parent layout (``DataSetEditLayout``) """ READ_ONLY = True def __init__( self, item: DataItemVariable, parent_layout: DataSetEditLayout ) -> None: AbstractDataSetWidget.__init__(self, item, parent_layout) self.group = QLabel() wordwrap = item.get_prop_value("display", "wordwrap", False) self.group.setWordWrap(wordwrap) self.group.setToolTip(item.get_help()) self.group.setStyleSheet(LABEL_CSS) self.group.setTextInteractionFlags(Qt.TextSelectableByMouse) # type:ignore def get(self) -> None: """Update widget contents from data item value""" self.set_state() text = self.item.get_string_value() self.group.setText(text) def set(self) -> None: """Update data item value from widget contents""" # Do nothing: read-only widget pass class ShowColorWidget(DataSetShowWidget): """Read-only color item widget Args: item: data item variable (``DataItemVariable``) parent_layout: parent layout (``DataSetEditLayout``) """ def __init__( self, item: DataItemVariable, parent_layout: DataSetEditLayout ) -> None: DataSetShowWidget.__init__(self, item, parent_layout) self.picture: QPicture | None = None def get(self) -> None: """Update widget contents from data item value""" value = self.item.get() if value is not None: color = QColor(value) self.picture = QPicture() painter = QPainter() painter.begin(self.picture) painter.fillRect(QRect(0, 0, 60, 20), QBrush(color)) painter.end() self.group.setPicture(self.picture) class ShowBooleanWidget(DataSetShowWidget): """Read-only bool item widget Args: item: data item variable (``DataItemVariable``) parent_layout: parent layout (``DataSetEditLayout``) """ def place_on_grid( self, layout: QGridLayout, row: int, label_column: int, widget_column, row_span: int = 1, column_span: int = 1, ): """Place widget on layout at specified position Args: layout: parent layout row: row index label_column: column index for label widget_column: column index for widget row_span: number of rows to span column_span: number of columns to span """ if not self.item.get_prop_value("display", "label"): widget_column = label_column column_span += 1 else: self.place_label(layout, row, label_column) layout.addWidget(self.group, row, widget_column, row_span, column_span) def get(self) -> None: """Update widget contents from data item value""" DataSetShowWidget.get(self) text = self.item.get_prop_value("display", "text") self.group.setText(text) font = self.group.font() value = self.item.get() state = bool(value) font.setStrikeOut(not state) self.group.setFont(font) self.group.setEnabled(state) class DataSetShowLayout(DataSetEditLayout): """Read-only layout Args: parent: parent widget instance: DataSet instance to edit layout: grid layout items: list of data items first_line: first line of grid layout change_callback: function called when any widget's value has changed """ _widget_factory = {} class DataSetShowDialog(DataSetEditDialog): """Read-only dialog box Args: instance: DataSet instance to edit icon: icon name (default: "guidata.svg") parent: parent widget apply: function called when Apply button is clicked wordwrap: if True, comment text is wordwrapped size: dialog size (default: None) """ def layout_factory(self, instance: DataSet, grid: QGridLayout) -> DataSetShowLayout: """A factory method that produces instances of DataSetEditLayout or derived classes (see DataSetShowDialog) Args: instance: DataSet instance to edit grid: grid layout Returns: DataSetEditLayout instance """ return DataSetShowLayout(self, instance, grid) DataSetShowLayout.register(GroupItem, GroupWidget) DataSetShowLayout.register(TabGroupItem, TabGroupWidget) DataSetShowLayout.register(FloatItem, DataSetShowWidget) DataSetShowLayout.register(StringItem, DataSetShowWidget) DataSetShowLayout.register(TextItem, DataSetShowWidget) DataSetShowLayout.register(IntItem, DataSetShowWidget) DataSetShowLayout.register(BoolItem, ShowBooleanWidget) DataSetShowLayout.register(DateItem, DataSetShowWidget) DataSetShowLayout.register(DateTimeItem, DataSetShowWidget) DataSetShowLayout.register(ColorItem, ShowColorWidget) DataSetShowLayout.register(FileOpenItem, DataSetShowWidget) DataSetShowLayout.register(FilesOpenItem, DataSetShowWidget) DataSetShowLayout.register(FileSaveItem, DataSetShowWidget) DataSetShowLayout.register(DirectoryItem, DataSetShowWidget) DataSetShowLayout.register(ChoiceItem, DataSetShowWidget) DataSetShowLayout.register(ImageChoiceItem, DataSetShowWidget) DataSetShowLayout.register(MultipleChoiceItem, DataSetShowWidget) DataSetShowLayout.register(FloatArrayItem, DataSetShowWidget) DataSetShowLayout.register(DictItem, DataSetShowWidget) DataSetShowLayout.register(SeparatorItem, SeparatorWidget) class DataSetShowGroupBox(Generic[AnyDataSet], QGroupBox): """Group box widget showing a read-only DataSet Args: label: group box label (string) klass: guidata.DataSet class wordwrap: if True, comment text is wordwrapped kwargs: keyword arguments passed to DataSet constructor """ def __init__( self, label: QLabel | str, klass: type[AnyDataSet], wordwrap: bool = False, **kwargs, ) -> None: QGroupBox.__init__(self, label) self.apply_button: QPushButton | None = None self.klass = klass self.dataset: AnyDataSet = klass(**kwargs) self._layout = QVBoxLayout() if self.dataset.get_comment(): label = QLabel(self.dataset.get_comment()) label.setTextInteractionFlags(Qt.TextSelectableByMouse) label.setWordWrap(wordwrap) self._layout.addWidget(label) self.grid_layout = QGridLayout() self._layout.addLayout(self.grid_layout) self.setLayout(self._layout) self.edit = self.get_edit_layout() def get_edit_layout(self) -> DataSetEditLayout[AnyDataSet]: """Return edit layout Returns: edit layout """ return DataSetShowLayout(self, self.dataset, self.grid_layout) def get(self) -> None: """Update group box contents from data item values""" # Set build_mode=True for ALL widgets (including nested ones) FIRST # to prevent update_dataitems() from being called during callbacks # (which would write stale widget values back to the dataset before # those widgets have been updated) all_widgets = self.edit.get_terminal_widgets() for widget in all_widgets: widget.build_mode = True # Now update all widgets from dataset for widget in self.edit.widgets: widget.get() widget.set_state() # Reset build_mode after all updates are complete for widget in all_widgets: widget.build_mode = False if self.apply_button is not None: self.apply_button.setVisible(not self.dataset.is_readonly()) class DataSetEditGroupBox(DataSetShowGroupBox[AnyDataSet]): """Group box widget including a DataSet Args: label: group box label (string) klass: guidata.DataSet class button_text: text of apply button (default: "Apply") button_icon: icon of apply button (default: "apply.png") show_button: if True, show apply button (default: True) wordwrap: if True, comment text is wordwrapped kwargs: keyword arguments passed to DataSet constructor When the "Apply" button is clicked, the :py:attr:`SIG_APPLY_BUTTON_CLICKED` signal is emitted. """ #: Signal emitted when Apply button is clicked SIG_APPLY_BUTTON_CLICKED = Signal() def __init__( self, label: QLabel | str, klass: type[AnyDataSet], button_text: str | None = None, button_icon: QIcon | str | None = None, show_button: bool = True, wordwrap: bool = False, **kwargs, ): DataSetShowGroupBox.__init__(self, label, klass, wordwrap=wordwrap, **kwargs) if show_button: if button_text is None: button_text = _("Apply") if button_icon is None: button_icon = get_icon("apply.png") elif isinstance(button_icon, str): button_icon = get_icon(button_icon) self.apply_button = applyb = QPushButton(button_icon, button_text, self) applyb.clicked.connect(self.set) # type:ignore layout = self.edit.layout layout.addWidget( applyb, layout.rowCount(), 0, 1, -1, Qt.AlignRight, # type:ignore ) layout.setRowStretch(layout.rowCount() + 1, 1) def get_edit_layout(self) -> DataSetEditLayout[AnyDataSet]: """Return edit layout Returns: edit layout """ return DataSetEditLayout( self, self.dataset, self.grid_layout, change_callback=self.change_callback ) def change_callback(self) -> None: """Method called when any widget's value has changed""" self.set_apply_button_state(True) def set(self, check: bool = True) -> None: """Update data item values from layout contents Args: check: if True, check input of all widgets """ for widget in self.edit.widgets: if widget.is_active() and (not check or widget.check()): widget.set() self.SIG_APPLY_BUTTON_CLICKED.emit() self.set_apply_button_state(False) def set_apply_button_state(self, state: bool) -> None: """Set apply button enable/disable state Args: state: if True, enable apply button """ if self.apply_button is not None: self.apply_button.setEnabled(state) def child_title(self, item: DataItemVariable) -> str: """Return data item title combined with QApplication title Args: item: data item Returns: title """ app_name = QApplication.applicationName() if not app_name: app_name = str(self.title()) return f"{app_name} - {item.label()}" class DataSetTableModel(QAbstractTableModel, Generic[AnyDataSet]): """DataSet Table Model. Args: datasets: list of DataSet object. The Datasets must all contain identical \ DataItem(s) (content can vary) so they can be decomposed into table \ columns. parent: Parent. Defaults to None. """ def __init__( self, datasets: list[AnyDataSet], parent: QObject | None = None ) -> None: super().__init__(parent) self.datasets = datasets ref_col_names = self.datasets[0].get_items(copy=False) self._col_names = tuple(item.get_name() for item in ref_col_names) self._col_count = len(self._col_names) self.validate_datasets() self._row_names = tuple(dataset.get_title() for dataset in datasets) self._row_count = len(self._row_names) self.item_pointers = [dataset.get_items() for dataset in datasets] def validate_datasets(self): """Checks that all datasets present in the list of datasets are of the same type. Raises: ValueError: signals that the datasets are not of the same type. """ reference_instance = type(self.datasets[0]) for dataset in self.datasets[1:]: if not isinstance(dataset, reference_instance): raise ValueError( "All datasets must be of the same type. " f"Expected {reference_instance}, got {type(dataset)}" ) def rowCount(self, _parent: QModelIndex | None = None) -> int: """Number of rows Args: parent: Parent QModelIndex (not used). Defaults to None. Returns: the number of rows in the table """ return self._row_count def columnCount(self, _parent: QModelIndex | None = None) -> int: """Number of columns Args: parent: Parent QModelIndex (not used). Defaults to None. Returns: the number of columns in the table """ return self._col_count def headerData( self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole = Qt.ItemDataRole.DisplayRole, ) -> Any: """Returns the data for the given role and section in the header with the specified orientation. Args: section: section from which to retrieve the data orientation: orientation from which to retrieve the data (row or columns) role: Flag used to chose the return value. Defaults to Qt.ItemDataRole.DisplayRole. Returns: _description_ """ if ( orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole ): return self._col_names[section] if ( orientation == Qt.Orientation.Vertical and role == Qt.ItemDataRole.DisplayRole ): return self._row_names[section] return super().headerData(section, orientation, role) def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole) -> Any: """Returns the table data stored under the given role for the item referred to by the index. Args: index: index of the item to retrieve (e.g. row and column) role: Flag that determines the type of data requested. Defaults to Qt.ItemDataRole.DisplayRole. Returns: the data stored under the given role for the item referred to by the index. """ if role == Qt.ItemDataRole.DisplayRole: item = self.item_pointers[index.row()][index.column()] return item.get_string_value(self.datasets[index.row()]) if role == Qt.ItemDataRole.TextAlignmentRole: return int(Qt.AlignCenter | Qt.AlignVCenter) # type: ignore if role == Qt.ItemDataRole.FontRole: return get_font(CONF, "arrayeditor", "font") return None class DatasetTableView(QTableView): """Array view class""" def __init__(self, model: DataSetTableModel, parent: QWidget | None = None) -> None: QTableView.__init__(self, parent) self.setModel(model) total_width = 0 self.shape = (model.rowCount(), model.columnCount()) for k in range(self.shape[1]): total_width += self.columnWidth(k) if viewport := self.viewport(): viewport.resize(min(total_width, 1024), self.height()) self.doubleClicked.connect(self.open_dataset_dialog) self.setSelectionMode(self.SelectionMode.SingleSelection) self.setSelectionBehavior(self.SelectionBehavior.SelectRows) def resize_to_contents(self): """Resize cells to contents""" QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) self.resizeColumnsToContents() QApplication.restoreOverrideCursor() def open_dataset_dialog(self, index: QModelIndex) -> None: """Opens a new dialog box to edit the dataset Args: index: index of the dataset to edit """ if isinstance((model := self.model()), DataSetTableModel): model.datasets[index.row()].edit(self) class DataSetGroupTableEditDialog(QDialog): """DataSetGroup Table Edit Dialog use to edit DataSet in a DataSetGroup object using a table where each row represents a dataset""" def __init__( self, instance: DataSetGroup, icon: str | QIcon = "", parent: QWidget | None = None, apply: Callable | None = None, wordwrap: bool = True, size: QSize | tuple[int, int] | None = None, ): super().__init__(parent) win32_fix_title_bar_background(self) self.wordwrap = wordwrap self.apply_func = apply self._layout = QVBoxLayout() if instance.get_comment(): label = QLabel(instance.get_comment()) label.setTextInteractionFlags(Qt.TextSelectableByMouse) label.setWordWrap(wordwrap) self._layout.addWidget(label) self.instance = instance self.setup_instance(instance) self.setLayout(self._layout) if parent is None: if not isinstance(icon, QIcon): icon = get_icon(icon, default="guidata.svg") self.setWindowIcon(icon) # type:ignore self.setModal(True) self.setWindowTitle(instance.get_title()) if size is not None: if isinstance(size, QSize): self.resize(size) else: self.resize(*size) def setup_instance(self, instance: DataSetGroup) -> None: """ Setup DataSetGroupTableEditDialog: return False if data is not supported, True otherwise. Constructs main layout Args: instance: DataSet instance to edit """ grid = QGridLayout() grid.setAlignment(Qt.AlignTop) # type:ignore self._layout.addLayout(grid) table_model = DataSetTableModel(instance.datasets, parent=self) self._layout.addWidget(DatasetTableView(table_model, parent=self)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/dataset/textedit.py0000644000175100017510000000146415114075001020134 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Text visitor for DataItem objects (for test purpose only) """ def prompt(item): """Get item value""" return input(item.get_prop("display", "label") + " ? ") class TextEditVisitor: """Text visitor""" def __init__(self, instance): self.instance = instance def visit_generic(self, item): """Generic visitor""" while True: value = prompt(item) item.set_from_string(self.instance, value) if item.check_item(self.instance): break print("Incorrect value!") visit_FloatItem = visit_generic visit_IntItem = visit_generic visit_StringItem = visit_generic ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/env.py0000644000175100017510000002367215114075001015452 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Execution environmnent utilities """ from __future__ import annotations import argparse import enum import os import pprint import sys from contextlib import contextmanager from typing import Any, Generator DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") PARSE = os.environ.get("GUIDATA_PARSE_ARGS", "").lower() in ("1", "true") class VerbosityLevels(enum.Enum): """Print verbosity levels (for testing purpose)""" QUIET = "quiet" NORMAL = "normal" DEBUG = "debug" class ExecEnv: """Object representing execution environment""" UNATTENDED_ARG = "unattended" ACCEPT_DIALOGS_ARG = "accept_dialogs" VERBOSE_ARG = "verbose" SCREENSHOT_ARG = "screenshot" SCREENSHOT_PATH_ARG = "screenshot_path" DELAY_ARG = "delay" UNATTENDED_ENV = "GUIDATA_UNATTENDED" ACCEPT_DIALOGS_ENV = "GUIDATA_ACCEPT_DIALOGS" VERBOSE_ENV = "GUIDATA_VERBOSE" SCREENSHOT_ENV = "GUIDATA_SCREENSHOT" SCREENSHOT_PATH_ENV = "GUIDATA_SCREENSHOT_PATH" DELAY_ENV = "GUIDATA_DELAY" def __init__(self): if PARSE: self.parse_args() if self.unattended: # Do not execute this code in production # Check that calling `to_dict` do not raise any exception self.to_dict() def iterate_over_attrs_envvars(self) -> Generator[tuple[str, str], None, None]: """Iterate over guidata environment variables Yields: A tuple (attribute name, environment variable name) """ for name in dir(self): if name.endswith("_ENV"): envvar: str = getattr(self, name) attrname = "_".join(name.split("_")[:-1]).lower() yield attrname, envvar def to_dict(self): """Return a dictionary representation of the object""" # The list of properties match the list of environment variable attribute names, # modulo the "_ENV" suffix: props = [attrname for attrname, _envvar in self.iterate_over_attrs_envvars()] # Check that all properties are defined in the class and that they are # really properties: for prop in props: assert hasattr(self, prop), ( f"Property {prop} is not defined in class {self.__class__.__name__}" ) assert isinstance(getattr(self.__class__, prop), property), ( f"Attribute {prop} is not a property in class {self.__class__.__name__}" ) # Return a dictionary with the properties as keys and their values as values: return {p: getattr(self, p) for p in props} def __str__(self): """Return a string representation of the object""" return pprint.pformat(self.to_dict()) @staticmethod def __get_mode(env): """Get mode value""" env_val = os.environ.get(env) if env_val is None: return False return env_val.lower() in ("1", "true", "yes", "on", "enable", "enabled") @staticmethod def __set_mode(env, value): """Set mode value""" if env in os.environ: os.environ.pop(env) if value: os.environ[env] = "1" @property def unattended(self): """Get unattended value""" return self.__get_mode(self.UNATTENDED_ENV) @unattended.setter def unattended(self, value): """Set unattended value""" self.__set_mode(self.UNATTENDED_ENV, value) @property def accept_dialogs(self): """Whether to accept dialogs in unattended mode""" return self.__get_mode(self.ACCEPT_DIALOGS_ENV) @accept_dialogs.setter def accept_dialogs(self, value): """Set whether to accept dialogs in unattended mode""" self.__set_mode(self.ACCEPT_DIALOGS_ENV, value) @property def screenshot(self): """Get screenshot value""" return self.__get_mode(self.SCREENSHOT_ENV) @screenshot.setter def screenshot(self, value): """Set screenshot value""" self.__set_mode(self.SCREENSHOT_ENV, value) @property def screenshot_path(self): """Get screenshot path""" return os.environ.get(self.SCREENSHOT_PATH_ENV, "") @screenshot_path.setter def screenshot_path(self, value): """Set screenshot path""" if value: os.environ[self.SCREENSHOT_PATH_ENV] = str(value) elif self.SCREENSHOT_PATH_ENV in os.environ: os.environ.pop(self.SCREENSHOT_PATH_ENV) @property def verbose(self): """Get verbosity level""" env_val = os.environ.get(self.VERBOSE_ENV) if env_val in (None, ""): return VerbosityLevels.NORMAL.value return env_val.lower() @verbose.setter def verbose(self, value): """Set verbosity level""" os.environ[self.VERBOSE_ENV] = value @property def delay(self): """Delay (ms) before quitting application in unattended mode""" try: return int(os.environ.get(self.DELAY_ENV)) except (TypeError, ValueError): return 0 @delay.setter def delay(self, value: int): """Set delay (ms) before quitting application in unattended mode""" os.environ[self.DELAY_ENV] = str(value) def parse_args(self): """Parse command line arguments""" parser = argparse.ArgumentParser(description="Run test") parser.add_argument( "--" + self.UNATTENDED_ARG, action="store_true", help="non-interactive mode", default=None, ) parser.add_argument( "--" + self.ACCEPT_DIALOGS_ARG, action="store_true", help="accept dialogs in unattended mode", default=None, ) parser.add_argument( "--" + self.SCREENSHOT_ARG, action="store_true", help="automatic screenshots", default=None, ) parser.add_argument( "--" + self.SCREENSHOT_PATH_ARG, type=str, help="path to save screenshots", default=None, ) parser.add_argument( "--" + self.DELAY_ARG, type=int, default=0, help="delay (ms) before quitting application in unattended mode", ) parser.add_argument( "--" + self.VERBOSE_ARG, choices=[lvl.value for lvl in VerbosityLevels], required=False, default=VerbosityLevels.NORMAL.value, help="verbosity level: for debugging/testing purpose", ) args, _unknown = parser.parse_known_args() self.set_env_from_args(args) def set_env_from_args(self, args): """Set appropriate environment variables""" for argname in ( self.UNATTENDED_ARG, self.ACCEPT_DIALOGS_ARG, self.SCREENSHOT_ARG, self.SCREENSHOT_PATH_ARG, self.VERBOSE_ARG, self.DELAY_ARG, ): argvalue = getattr(args, argname) if argvalue is not None: setattr(self, argname, argvalue) def log(self, source: Any, *objects: Any) -> None: """Log text on screen Args: source: object from which the log is issued *objects: objects to log """ if DEBUG or self.verbose == VerbosityLevels.DEBUG.value: print(str(source) + ":", *objects) def print(self, *objects, sep=" ", end="\n", file=sys.stdout, flush=False): """Print in file, depending on verbosity level""" # print(f"unattended={self.unattended} ; verbose={self.verbose} ; ") # print(f"screenshot={self.screenshot}; delay={self.delay}") if self.verbose != VerbosityLevels.QUIET.value or DEBUG: print(*objects, sep=sep, end=end, file=file, flush=flush) def pprint( self, obj, stream=None, indent=1, width=80, depth=None, compact=False, sort_dicts=True, ): """Pretty-print in stream, depending on verbosity level""" if self.verbose != VerbosityLevels.QUIET.value or DEBUG: pprint.pprint( obj, stream=stream, indent=indent, width=width, depth=depth, compact=compact, sort_dicts=sort_dicts, ) @contextmanager def context( self, unattended=None, accept_dialogs=None, screenshot=None, screenshot_path=None, delay=None, verbose=None, ) -> Generator[None, None, None]: """Return a context manager that sets some execenv properties at enter, and restores them at exit. This is useful to run some code in a controlled environment, for example to accept dialogs in unattended mode, and restore the previous value at exit. Args: unattended: whether to run in unattended mode accept_dialogs: whether to accept dialogs in unattended mode screenshot: whether to take screenshots screenshot_path: path to save screenshots delay: delay (ms) before quitting application in unattended mode verbose: verbosity level .. note:: If a passed value is None, the corresponding property is not changed. """ old_values = self.to_dict() new_values = { "unattended": unattended, "accept_dialogs": accept_dialogs, "screenshot": screenshot, "screenshot_path": screenshot_path, "delay": delay, "verbose": verbose, } for key, value in new_values.items(): if value is not None: setattr(self, key, value) try: yield finally: for key, value in old_values.items(): setattr(self, key, value) execenv = ExecEnv() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6458857 guidata-3.13.4/guidata/external/0000755000175100017510000000000015114075015016125 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/external/__init__.py0000644000175100017510000000000215114075001020221 0ustar00runnerrunner# ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6468856 guidata-3.13.4/guidata/external/darkdetect/0000755000175100017510000000000015114075015020237 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/external/darkdetect/__init__.py0000644000175100017510000000234315114075001022345 0ustar00runnerrunner# ----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. # ----------------------------------------------------------------------------- __version__ = "0.5.2" # Patched (see pull request below) import sys import platform if sys.platform == "darwin": from distutils.version import LooseVersion as V if V(platform.mac_ver()[0]) < V("10.14"): from ._dummy import * else: from ._mac_detect import * del V # Patch: https://github.com/albertosottile/darkdetect/pull/21 elif ( sys.platform == "win32" and platform.release().isdigit() and int(platform.release()) >= 10 ): # Checks if running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. The getwindowsversion method returns a tuple. # The third item is the build number that we can use to check if the user has a new enough version of Windows. winver = int(platform.version().split(".")[2]) if winver >= 14393: from ._windows_detect import * else: from ._dummy import * elif sys.platform == "linux": from ._linux_detect import * else: from ._dummy import * del sys, platform ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/external/darkdetect/_dummy.py0000644000175100017510000000055315114075001022101 0ustar00runnerrunner#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- def theme(): return None def isDark(): return None def isLight(): return None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/external/darkdetect/_linux_detect.py0000644000175100017510000000153415114075001023435 0ustar00runnerrunner#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile, Eric Larson # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- import subprocess def theme(): # Here we just triage to GTK settings for now try: out = subprocess.run( ['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], capture_output=True) stdout = out.stdout.decode() except Exception: return 'Light' # we have a string, now remove start and end quote theme = stdout.lower().strip()[1:-1] if theme.endswith('-dark'): return 'Dark' else: return 'Light' def isDark(): return theme() == 'Dark' def isLight(): return theme() == 'Light' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/external/darkdetect/_mac_detect.py0000644000175100017510000000413615114075001023037 0ustar00runnerrunner#----------------------------------------------------------------------------- # Copyright (C) 2019 Alberto Sottile # # Distributed under the terms of the 3-clause BSD License. #----------------------------------------------------------------------------- import ctypes import ctypes.util try: # macOS Big Sur+ use "a built-in dynamic linker cache of all system-provided libraries" appkit = ctypes.cdll.LoadLibrary('AppKit.framework/AppKit') objc = ctypes.cdll.LoadLibrary('libobjc.dylib') except OSError: # revert to full path for older OS versions and hardened programs appkit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('AppKit')) objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('objc')) void_p = ctypes.c_void_p ull = ctypes.c_uint64 objc.objc_getClass.restype = void_p objc.sel_registerName.restype = void_p # See https://docs.python.org/3/library/ctypes.html#function-prototypes for arguments description MSGPROTOTYPE = ctypes.CFUNCTYPE(void_p, void_p, void_p, void_p) msg = MSGPROTOTYPE(('objc_msgSend', objc), ((1 ,'', None), (1, '', None), (1, '', None))) def _utf8(s): if not isinstance(s, bytes): s = s.encode('utf8') return s def n(name): return objc.sel_registerName(_utf8(name)) def C(classname): return objc.objc_getClass(_utf8(classname)) def theme(): NSAutoreleasePool = objc.objc_getClass('NSAutoreleasePool') pool = msg(NSAutoreleasePool, n('alloc')) pool = msg(pool, n('init')) NSUserDefaults = C('NSUserDefaults') stdUserDef = msg(NSUserDefaults, n('standardUserDefaults')) NSString = C('NSString') key = msg(NSString, n("stringWithUTF8String:"), _utf8('AppleInterfaceStyle')) appearanceNS = msg(stdUserDef, n('stringForKey:'), void_p(key)) appearanceC = msg(appearanceNS, n('UTF8String')) if appearanceC is not None: out = ctypes.string_at(appearanceC) else: out = None msg(pool, n('release')) if out is not None: return out.decode('utf-8') else: return 'Light' def isDark(): return theme() == 'Dark' def isLight(): return theme() == 'Light' ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/external/darkdetect/_windows_detect.py0000644000175100017510000000232215114075001023764 0ustar00runnerrunnerfrom winreg import HKEY_CURRENT_USER as hkey, QueryValueEx as getSubkeyValue, OpenKey as getKey def theme(): """ Uses the Windows Registry to detect if the user is using Dark Mode """ # Registry will return 0 if Windows is in Dark Mode and 1 if Windows is in Light Mode. This dictionary converts that output into the text that the program is expecting. valueMeaning = {0: "Dark", 1: "Light"} # In HKEY_CURRENT_USER, get the Personalisation Key. try: key = getKey(hkey, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") # In the Personalisation Key, get the AppsUseLightTheme subkey. This returns a tuple. # The first item in the tuple is the result we want (0 or 1 indicating Dark Mode or Light Mode); the other value is the type of subkey e.g. DWORD, QWORD, String, etc. subkey = getSubkeyValue(key, "AppsUseLightTheme")[0] except FileNotFoundError: # some headless Windows instances (e.g. GitHub Actions or Docker images) do not have this key return None return valueMeaning[subkey] def isDark(): if theme() is not None: return theme() == 'Dark' def isLight(): if theme() is not None: return theme() == 'Light'././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/guitest.py0000644000175100017510000002621215114075001016337 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ GUI-based test launcher ----------------------- Overview ^^^^^^^^ This module provides a GUI-based test launcher for any Python package. Usage example:: import your_package from guidata.guitest import run_testlauncher run_testlauncher(your_package) Reference/API ^^^^^^^^^^^^^ .. autofunction:: run_testlauncher .. autoclass:: TestModule .. autofunction:: get_tests """ from __future__ import annotations import os import os.path as osp import re import subprocess import sys import traceback from types import ModuleType from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW from guidata.config import _ from guidata.configtools import MONOSPACE, get_family, get_icon from guidata.qthelpers import get_std_icon, win32_fix_title_bar_background from guidata.widgets.codeeditor import CodeEditor def get_test_package(package): """Return test package for package Args: package (module): package to test Returns: str: test package """ test_package_name = "%s.tests" % package.__name__ _temp = __import__(test_package_name) return sys.modules[test_package_name] def get_tests(package, category: str) -> list[TestModule]: """Retrieve test scripts from test package Args: package (module): package to test category (str): test category (values: "all", "visible", "batch") Returns: list[TestModule]: list of test modules """ assert category in ("all", "visible", "batch") tests = [] test_package = get_test_package(package) if test_package.__file__ is None: print("Returning empty list") return tests test_path = osp.dirname(osp.realpath(test_package.__file__)) # Iterate over test scripts recursively within test package: for root, _dirs, files in os.walk(test_path): for fname in files: path = osp.join(root, fname) if ( fname.endswith((".py", ".pyw")) and not fname.startswith("_") and fname != "conftest.py" ): test = TestModule(test_package, path) if ( category == "all" or (category == "visible" and test.is_visible()) or (category == "batch" and not test.is_skipped()) ): tests.append(test) return tests class TestModule: """Object representing a test module (Python script) Args: test_package (module): test package path (str): test module path """ def __init__(self, test_package, path: str) -> None: self.path = path test_package_path = osp.dirname(osp.realpath(test_package.__file__)) self.name = osp.relpath(self.path, test_package_path) module_name, _ext = osp.splitext(osp.basename(path)) subpkgname = test_package.__name__ if len(self.name.split(os.sep)) > 1: subpkgname += "." + ".".join(self.name.split(os.sep)[:-1]) try: self.error_msg = "" _temp = __import__(subpkgname, fromlist=[module_name]) self.module = getattr(_temp, module_name) except ImportError: self.error_msg = traceback.format_exc() self.module = None self.__is_visible = False self.__is_skipped = False self.__contents = "" self.__read_contents() def __read_contents(self) -> str: """Read test module contents""" with open(self.path, "r", encoding="utf-8") as fdesc: lines = fdesc.readlines() startline = 0 for lineno, line in enumerate(lines): if re.match(r"^#[ ]*guitest[ ]*:", line.strip()): if "show" in line: self.__is_visible = True startline = lineno + 1 if "skip" in line: self.__is_skipped = True self.__contents = "".join(lines[startline:]).strip() def is_visible(self) -> bool: """Returns True if test module is visible""" return self.__is_visible def is_skipped(self) -> bool: """Returns True if test module is skipped""" return self.__is_skipped def get_contents(self) -> str: """Returns test module contents""" return self.__contents def is_valid(self) -> bool: """Returns True if test module is valid and can be executed""" return self.module is not None def get_description(self) -> str: """Returns test module description""" if self.is_valid(): doc = self.module.__doc__ if doc is None or not doc.strip(): return _("No description available") lines = doc.strip().splitlines() fmt = "%s" lines[0] = fmt % lines[0] return "
".join(lines) return self.error_msg def run(self, args: str = "", timeout: int = None) -> None: """Run test script Args: args (str): arguments to pass to the script timeout (int): timeout in seconds """ # Keep the same sys.path environment in child process: # (useful when the program is executed from Spyder, for example) os.environ["PYTHONPATH"] = os.pathsep.join(sys.path) command = [sys.executable, '"' + self.path + '"'] if args: command.append(args) proc = subprocess.Popen(" ".join(command), shell=True) if timeout is not None: proc.wait(timeout) class TestPropertiesWidget(QW.QWidget): """Test module properties panel Args: parent (QWidget): parent widget """ def __init__(self, parent: QW.QWidget = None) -> None: super().__init__(parent) self.lbl_icon = QW.QLabel() self.lbl_icon.setFixedWidth(32) self.desc_label = QW.QLabel() self.desc_label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse) self.desc_label.setWordWrap(True) group_desc = QW.QGroupBox(_("Description"), self) layout = QW.QHBoxLayout() for label in (self.lbl_icon, self.desc_label): label.setAlignment(QC.Qt.AlignTop) layout.addWidget(label) group_desc.setLayout(layout) font = QG.QFont(get_family(MONOSPACE), 9, QG.QFont.Normal) self.editor = CodeEditor( self, columns=85, rows=30, language="python", font=font ) self.editor.setFont(font) self.editor.setReadOnly(True) self.desc_label.setFont(font) vlayout = QW.QVBoxLayout() vlayout.addWidget(group_desc) vlayout.addWidget(self.editor) self.setLayout(vlayout) def set_item(self, test: TestModule) -> None: """Set current item Args: test (TestModule): test module """ self.desc_label.setText(test.get_description()) self.editor.setPlainText(test.get_contents()) txt = "Information" if test.is_valid() else "Critical" self.lbl_icon.setPixmap(get_std_icon("MessageBox" + txt).pixmap(24, 24)) class TestMainView(QW.QSplitter): """Test launcher main view Args: package (module): test package parent (QWidget): parent widget """ def __init__(self, package, parent=None): super().__init__(parent) self.tests = get_tests(package, category="visible") listgroup = QW.QFrame() self.addWidget(listgroup) self.props = TestPropertiesWidget(self) font = self.props.editor.font() self.addWidget(self.props) vlayout = QW.QVBoxLayout() self.run_button = self.create_run_button(font) self.listw = self.create_test_listwidget(font) vlayout.addWidget(self.listw) vlayout.addWidget(self.run_button) listgroup.setLayout(vlayout) self.setStretchFactor(1, 1) enabled = len(self.tests) > 0 self.run_button.setEnabled(enabled) if enabled: self.props.set_item(self.tests[0]) def create_test_listwidget(self, font: QG.QFont) -> QW.QListWidget: """Create and setup test list widget Args: font (QFont): font to use Returns: QListWidget: test list widget """ listw = QW.QListWidget(self) listw.addItems([test.name for test in self.tests]) for index in range(listw.count()): item = listw.item(index) item.setSizeHint(QC.QSize(1, 25)) if not self.tests[index].is_valid(): item.setForeground(QG.QColor("#FF3333")) listw.setFont(font) listw.currentRowChanged.connect(self.current_row_changed) listw.itemActivated.connect(self.run_current_script) listw.setCurrentRow(0) return listw def create_run_button(self, font: QG.QFont) -> QW.QPushButton: """Create and setup run button Args: font (QFont): font to use Returns: QPushButton: run button """ btn = QW.QPushButton(get_icon("apply.png"), _("Run this script"), self) btn.setFont(font) btn.clicked.connect(self.run_current_script) return btn def current_row_changed(self, row: int) -> None: """Current list widget row has changed Args: row (int): row index """ current_test = self.tests[row] self.props.set_item(current_test) self.run_button.setEnabled(current_test.is_valid()) def run_current_script(self) -> None: """Run current script""" self.tests[self.listw.currentRow()].run() class TestLauncherWindow(QW.QMainWindow): """Test launcher main window Args: package (module): test package parent (QWidget): parent widget """ def __init__(self, package, parent: QW.QWidget = None) -> None: super().__init__(parent) win32_fix_title_bar_background(self) self.setWindowTitle(_("Tests - %s module") % package.__name__) self.setWindowIcon(get_icon("%s.svg" % package.__name__, "guidata.svg")) self.mainview = TestMainView(package, self) self.setCentralWidget(self.mainview) QW.QShortcut(QG.QKeySequence("Escape"), self, self.close) def show(self): """Show window""" super().show() if not self.mainview.tests: msg = _("No test found in this package.") QW.QMessageBox.critical(self, _("Error"), msg) def run_testlauncher(package: ModuleType) -> None: """Run test launcher Args: package (module): test package """ from guidata import qapplication # pylint: disable=import-outside-toplevel app = qapplication() win = TestLauncherWindow(package) win.show() app.exec_() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6478856 guidata-3.13.4/guidata/io/0000755000175100017510000000000015114075015014712 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/io/__init__.py0000644000175100017510000000317515114075001017024 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Data serialization and deserialization -------------------------------------- The ``guidata.io`` package provides the core features for data (:py:class:`guidata.dataset.DataSet` or other objects) serialization and deserialization. Base classes for I/O handlers ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Base classes for writing custom readers and writers: * :py:class:`BaseIOHandler` * :py:class:`WriterMixin` .. autoclass:: GroupContext .. autoclass:: BaseIOHandler :members: .. autoclass:: WriterMixin :members: Configuration files (.ini) ^^^^^^^^^^^^^^^^^^^^^^^^^^ Reader and writer for the serialization of data sets into .ini files: * :py:class:`INIReader` * :py:class:`INIWriter` .. autoclass:: INIReader :members: .. autoclass:: INIWriter :members: JSON files (.json) ^^^^^^^^^^^^^^^^^^ Reader and writer for the serialization of data sets into .json files: * :py:class:`JSONReader` * :py:class:`JSONWriter` .. autoclass:: JSONReader :members: .. autoclass:: JSONWriter :members: HDF5 files (.h5) ^^^^^^^^^^^^^^^^ Reader and writer for the serialization of data sets into .h5 files: * :py:class:`HDF5Reader` * :py:class:`HDF5Writer` .. autoclass:: HDF5Reader :members: .. autoclass:: HDF5Writer :members: """ # pylint: disable=unused-import from .base import BaseIOHandler, GroupContext, WriterMixin # noqa from .h5fmt import HDF5Handler, HDF5Reader, HDF5Writer # noqa from .inifmt import INIHandler, INIReader, INIWriter # noqa from .jsonfmt import JSONHandler, JSONReader, JSONWriter # noqa ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/io/base.py0000644000175100017510000001316715114075001016201 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Base classes for I/O handlers """ from __future__ import annotations import datetime from collections.abc import Callable from typing import Any import numpy as np class GroupContext: """ Group context manager object. This class provides a context manager for managing a group within a handler. Args: handler (BaseIOHandler): The handler object. It should be an instance of a subclass of BaseIOHandler. group_name (str): The group name. Represents the name of the group in the context. """ def __init__(self, handler: BaseIOHandler, group_name: str) -> None: """ Initialization method for GroupContext. """ self.handler = handler self.group_name = group_name def __enter__(self) -> GroupContext: """ Enter the context. This method is called when entering the 'with' statement. It begins a new group on the handler. Returns: self (GroupContext): An instance of the GroupContext class. """ self.handler.begin(self.group_name) return self def __exit__( self, exc_type: type | None, exc_value: Exception | None, traceback: Any | None ) -> bool: """ Exit the context. This method is called when exiting the 'with' statement. It ends the group on the handler if no exception occurred within the context. Args: exc_type (Optional[type]): The type of exception that occurred, if any. exc_value (Optional[Exception]): The instance of Exception that occurred, if any. traceback (Optional[Any]): A traceback object encapsulating the call stack at the point where the exception originally occurred, if any. Returns: False (bool): A boolean False value indicating that the exception was not handled here. """ if exc_type is None: self.handler.end(self.group_name) return False class BaseIOHandler: """ Base I/O Handler with group context manager. This class serves as the base class for I/O handlers. It provides methods for managing sections of a file, referred to as "groups", as well as context management for these groups. """ def __init__(self) -> None: """ Initialization method for BaseIOHandler. This method initializes the option list, which will be used to manage the current section or "group". """ self.option = [] def group(self, group_name: str) -> GroupContext: """ Enter a group and return a context manager to be used with the `with` statement. Args: group_name (str): The name of the group to enter. Returns: GroupContext: A context manager for the group. """ return GroupContext(self, group_name) def begin(self, section: str) -> None: """ Begin a new section. This method is called when a new section is started. It adds the section to the list of options, which effectively makes it the current section. Args: section (str): The name of the section to begin. """ self.option.append(section) def end(self, section: str) -> None: """ End the current section. This method is called when a section is ended. It removes the section from the list of options, asserting it's the expected one, and moves to the previous section if any. Args: section (str): The name of the section to end. """ sect = self.option.pop(-1) assert sect == section, ( "Ending section does not match the current section: %s != %s" % ( sect, section, ) ) class WriterMixin: """ Mixin class providing the write() method. This mixin class is intended to be used with classes that need to write different types of values. """ def write(self, val: Any, group_name: str | None = None) -> None: """ Write a value depending on its type, optionally within a named group. Args: val (Any): The value to be written. group_name (Optional[str]): The name of the group. If provided, the group context will be used for writing the value. """ if group_name: self.begin(group_name) if isinstance(val, bool): self.write_bool(val) elif isinstance(val, int): self.write_int(val) elif isinstance(val, float): self.write_float(val) elif isinstance(val, str): self.write_any(val) elif isinstance(val, np.ndarray): self.write_array(val) elif np.isscalar(val): self.write_any(val) elif val is None: self.write_none() elif isinstance(val, (list, tuple)): self.write_sequence(val) elif isinstance(val, datetime.datetime): self.write_float(val.timestamp()) elif isinstance(val, datetime.date): self.write_int(val.toordinal()) elif hasattr(val, "serialize") and isinstance(val.serialize, Callable): # The object has a DataSet-like `serialize` method val.serialize(self) else: raise NotImplementedError( "cannot serialize %r of type %r" % (val, type(val)) ) if group_name: self.end(group_name) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/io/h5fmt.py0000644000175100017510000007063015114075001016310 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ HDF5 files (.h5) """ from __future__ import annotations import datetime import sys from collections.abc import Callable, Sequence from typing import Any from uuid import uuid1 import h5py import numpy as np from guidata.io.base import BaseIOHandler, WriterMixin class TypeConverter: """Handles conversion between types for HDF5 serialization. Args: to_type: The target type for the HDF5 representation. from_type: The original type from the HDF5 representation. Defaults to `to_type` if not specified. .. note:: Instances of this class are used to ensure data consistency when serializing and deserializing data to and from HDF5 format. """ def __init__( self, to_type: Callable[[Any], Any], from_type: Callable[[Any], Any] | None = None, ) -> None: self._to_type = to_type self._from_type = to_type if from_type is None else from_type def to_hdf(self, value: Any) -> Any: """Converts the value to the target type for HDF5 serialization. Args: value: The value to be converted. Returns: The converted value in the target type. Raises: Exception: If the conversion to the target type fails. """ try: return self._to_type(value) except Exception: print("ERR", repr(value), file=sys.stderr) raise def from_hdf(self, value: Any) -> Any: """Converts the value from the HDF5 representation to target type. Args: value: The HDF5 value to be converted. Returns: The converted value in the original type. """ return self._from_type(value) class Attr: """Helper class representing class attribute for HDF5 serialization. Args: hdf_name: Name of the attribute in the HDF5 file. struct_name: Name of the attribute in the object. Defaults to `hdf_name` if not specified. type: Attribute type. If None, type is guessed. optional: If True, attribute absence will not raise error. .. note:: This class manages serialization and deserialization of the object's attributes to and from HDF5 format. """ def __init__( self, hdf_name: str, struct_name: str | None = None, type: TypeConverter | None = None, optional: bool = False, ) -> None: self.hdf_name = hdf_name self.struct_name = hdf_name if struct_name is None else struct_name self.type = type self.optional = optional def get_value(self, struct: Any) -> Any: """Get the value of the attribute from the object. Args: struct: The object to extract the attribute from. Returns: The value of the attribute. """ if self.optional: return getattr(struct, self.struct_name, None) return getattr(struct, self.struct_name) def set_value(self, struct: Any, value: Any) -> None: """Set the value of the attribute in the object. Args: struct: The object to set the attribute value in. value: The value to set. """ setattr(struct, self.struct_name, value) def save(self, group: h5py.Group, struct: Any) -> None: """Save the attribute to an HDF5 group. Args: group: The HDF5 group to save the attribute to. struct: The object to save the attribute from. Raises: Exception: If an error occurs while saving the attribute. """ value = self.get_value(struct) if self.optional and value is None: if self.hdf_name in group.attrs: del group.attrs[self.hdf_name] return if self.type is not None: value = self.type.to_hdf(value) try: group.attrs[self.hdf_name] = value except Exception: # pylint: disable=broad-except print("ERROR saving:", repr(value), "into", self.hdf_name, file=sys.stderr) raise def load(self, group: h5py.Group, struct: Any) -> None: """Load the attribute from an HDF5 group into an object. Args: group: The HDF5 group to load the attribute from. struct: The object to load the attribute into. Raises: KeyError: If the attribute is not found in the HDF5 group. """ if self.optional and self.hdf_name not in group.attrs: self.set_value(struct, None) return try: value = group.attrs[self.hdf_name] except KeyError as err: raise KeyError(f"Unable to locate attribute {self.hdf_name}") from err if self.type is not None: value = self.type.from_hdf(value) self.set_value(struct, value) def createdset(group: h5py.Group, name: str, value: np.ndarray | list) -> None: """ Creates a dataset in the provided HDF5 group. Args: group: The group in the HDF5 file to add the dataset to. name: The name of the dataset. value: The data to be stored in the dataset. Returns: None """ group.create_dataset(name, compression=None, data=value) class Dset(Attr): """ Class for generic load/save for an hdf5 dataset. Handles the conversion of the scalar value, if any. Args: hdf_name: The name of the HDF5 attribute. struct_name: The name of the structure. Defaults to None. type: The expected data type of the attribute. Defaults to None. scalar: Function to convert the scalar value, if any. Defaults to None. optional: Whether the attribute is optional. Defaults to False. """ def __init__( self, hdf_name: str, struct_name: str | None = None, type: type | None = None, scalar: Callable | None = None, optional: bool = False, ) -> None: super().__init__(hdf_name, struct_name, type, optional) self.scalar = scalar def save(self, group: h5py.Group, struct: Any) -> None: """ Save the attribute to the given HDF5 group. Args: group: The group in the HDF5 file to save the attribute to. struct: The structure containing the attribute. """ value = self.get_value(struct) if isinstance(value, float): value = np.float64(value) elif isinstance(value, int): value = np.int32(value) if value is None or value.size == 0: value = np.array([0.0]) if value.shape == (): value = value.reshape((1,)) group.require_dataset( self.hdf_name, shape=value.shape, dtype=value.dtype, data=value, compression="gzip", compression_opts=1, ) def load(self, group: h5py.Group, struct: Any) -> None: """ Load the attribute from the given HDF5 group. Args: group: The group in the HDF5 file to load the attribute from. struct: The structure to load the attribute into. Raises: KeyError: If the attribute cannot be found in the HDF5 group. """ if self.optional: if self.hdf_name not in group: self.set_value(struct, None) return try: value = group[self.hdf_name][...] except KeyError as err: raise KeyError("Unable to locate dataset {}".format(self.hdf_name)) from err if self.scalar is not None: value = self.scalar(value) self.set_value(struct, value) class Dlist(Dset): """ Class for handling lists in HDF5 datasets. Inherits from the Dset class. Overrides the get_value and set_value methods from the Dset class to handle lists specifically. Args: hdf_name: The name of the HDF5 attribute. struct_name: The name of the structure. Defaults to None. type: The expected data type of the attribute. Defaults to None. scalar: Function to convert the scalar value, if any. Defaults to None. optional: Whether the attribute is optional. Defaults to False. """ def get_value(self, struct: Any) -> np.ndarray: """ Returns the value of the attribute in the given structure as a numpy array. Args: struct: The structure containing the attribute. Returns: The value of the attribute in the given structure as a numpy array. """ return np.array(getattr(struct, self.struct_name)) def set_value(self, struct: Any, value: np.ndarray) -> None: """ Sets the value of the attribute in the given structure to a list containing the values of the given numpy array. Args: struct: The structure in which to set the attribute. value: A numpy array containing the values to set the attribute to. """ setattr(struct, self.struct_name, list(value)) # ============================================================================== # Base HDF5 Store object: do not break API compatibility here as this class is # used in various critical projects for saving/loading application data # ============================================================================== class H5Store: """ Class for managing HDF5 files. Args: filename: The name of the HDF5 file. """ def __init__(self, filename: str) -> None: self.filename = filename self.h5 = None def open(self, mode: str = "a") -> h5py._hl.files.File: """ Opens an HDF5 file in the given mode. Args: mode: The mode in which to open the file. Defaults to "a". Returns: The opened HDF5 file. Raises: Exception: If there is an error while trying to open the file. """ if self.h5: return self.h5 try: self.h5 = h5py.File(self.filename, mode=mode) except Exception: print( "Error trying to load:", self.filename, "in mode:", mode, file=sys.stderr, ) raise return self.h5 def close(self) -> None: """ Closes the HDF5 file if it is open. """ if self.h5: self.h5.close() self.h5 = None def __enter__(self) -> "H5Store": """ Support for 'with' statement. Returns: The instance of the class itself. """ return self def __exit__(self, *args) -> None: """ Support for 'with' statement. Closes the HDF5 file on exiting the 'with' block. """ self.close() def generic_save(self, parent: Any, source: Any, structure: list[Attr]) -> None: """ Saves the data from source into the file using 'structure' as a descriptor. Args: parent: The parent HDF5 group. source: The source of the data to save. structure: A list of attribute descriptors (Attr, Dset, Dlist, etc.) that describes the conversion of data and the names of the attributes in the source and in the file. """ for instr in structure: instr.save(parent, source) def generic_load(self, parent: Any, dest: Any, structure: list[Attr]) -> None: """ Loads the data from the file into 'dest' using 'structure' as a descriptor. Args: parent: The parent HDF5 group. dest: The destination to load the data into. structure: A list of attribute descriptors (Attr, Dset, Dlist, etc.) that describes the conversion of data and the names of the attributes in the file and in the destination. Raises: Exception: If there is an error while trying to load an item. """ for instr in structure: try: instr.load(parent, dest) except Exception as err: print("Error loading HDF5 item:", instr.hdf_name, file=sys.stderr) raise err # ============================================================================== # HDF5 reader/writer: do not break API compatibility here as this class is # used in various critical projects for saving/loading application data and # in guiqwt for saving/loading plot items. # ============================================================================== class HDF5Handler(H5Store, BaseIOHandler): """ Base HDF5 I/O Handler object. Inherits from H5Store and BaseIOHandler. Args: filename: The name of the HDF5 file. """ def __init__(self, filename: str) -> None: super().__init__(filename) self.option = [] def get_parent_group(self) -> h5py._hl.group.Group: """ Returns the parent group in the HDF5 file based on the current option. Returns: The parent group in the HDF5 file. """ parent = self.h5 for option in self.option[:-1]: parent = parent.require_group(option) return parent SEQUENCE_NAME = "__seq" DICT_NAME = "__dict" class HDF5Writer(HDF5Handler, WriterMixin): """ Writer for HDF5 files. Inherits from HDF5Handler and WriterMixin. Args: filename: The name of the HDF5 file. """ def __init__(self, filename: str) -> None: super().__init__(filename) self.open("w") def write(self, val: Any, group_name: str | None = None) -> None: """ Write a value depending on its type, optionally within a named group. Args: val: The value to be written. group_name: The name of the group. If provided, the group context will be used for writing the value. """ if group_name: self.begin(group_name) if val is None: self.write_none() elif isinstance(val, (list, tuple)): self.write_sequence(val) elif isinstance(val, dict): self.write_dict(val) elif isinstance(val, datetime.datetime): self.write_datetime(val) elif isinstance(val, datetime.date): self.write_date(val) elif isinstance(val, np.ndarray): self.write_array(val) elif hasattr(val, "serialize") and isinstance(val.serialize, Callable): # The object has a DataSet-like `serialize` method val.serialize(self) else: group = self.get_parent_group() try: group.attrs[self.option[-1]] = val except TypeError as exc: raise NotImplementedError( "cannot serialize %r of type %r" % (val, type(val)) ) from exc if group_name: self.end(group_name) def write_any(self, val: Any) -> None: """ Write the value to the HDF5 file as an attribute. Args: val: The value to write. """ group = self.get_parent_group() group.attrs[self.option[-1]] = val write_str = write_list = write_int = write_float = write_any def write_bool(self, val: bool) -> None: """ Write the boolean value to the HDF5 file as an attribute. Args: val: The boolean value to write. """ self.write_int(int(val)) def write_datetime(self, val: datetime.datetime) -> None: """ Write a datetime value to the HDF5 file with type metadata. Args: val: The datetime value to write. """ group = self.get_parent_group() attr_name = self.option[-1] group.attrs[attr_name] = val.timestamp() group.attrs[f"{attr_name}__type__"] = "datetime" def write_date(self, val: datetime.date) -> None: """ Write a date value to the HDF5 file with type metadata. Args: val: The date value to write. """ group = self.get_parent_group() attr_name = self.option[-1] group.attrs[attr_name] = val.toordinal() group.attrs[f"{attr_name}__type__"] = "date" def write_array(self, val: np.ndarray) -> None: """ Write the numpy array value to the HDF5 file. Args: val: The numpy array value to write. """ group = self.get_parent_group() group[self.option[-1]] = val def write_none(self) -> None: """ Write a None value to the HDF5 file as an attribute. """ group = self.get_parent_group() group.attrs[self.option[-1]] = "" def write_sequence(self, val: list | tuple) -> None: """ Write the list or tuple value to the HDF5 file as an attribute. Args: The value to write. """ # Check if all elements are of the same type, raise an error if not for index, obj in enumerate(val): if val is None: raise ValueError("cannot serialize None value in sequence") with self.group(f"{SEQUENCE_NAME}{index}"): self.write(obj) self.write(len(val), SEQUENCE_NAME) def write_dict(self, val: dict[str, Any]) -> None: """Write dictionary to h5 file Args: val: dictionary to write """ # Check if keys are all strings, raise an error if not if not all(isinstance(key, str) for key in val.keys()): raise ValueError("cannot serialize dict with non-string keys") for key, value in val.items(): with self.group(key): if value is None: raise ValueError("cannot serialize None value in dict") self.write(value) self.write(len(val), DICT_NAME) def write_object_list(self, seq: Sequence[Any] | None, group_name: str) -> None: """ Write an object sequence to the HDF5 file in a group. Objects must implement the DataSet-like `serialize` method. Args: seq: The object sequence to write. Defaults to None. group_name: The name of the group in which to write the objects. """ with self.group(group_name): if seq is None: self.write_none() else: ids = [] for obj in seq: guid = bytes(str(uuid1()), "utf-8") ids.append(guid) with self.group(guid): if obj is None: self.write_none() else: obj.serialize(self) with self.group("IDs"): self.write_list(ids) class NoDefault: """Class to represent the absence of a default value.""" pass class HDF5Reader(HDF5Handler): """ Reader for HDF5 files. Inherits from HDF5Handler. Args: filename: The name of the HDF5 file. """ def __init__(self, filename: str): super().__init__(filename) self.open("r") def read( self, group_name: str | None = None, func: Callable[[], Any] | None = None, instance: Any | None = None, default: Any | NoDefault = NoDefault, ) -> Any: """ Read a value from the current group or specified group_name. Args: group_name: The name of the group to read from. Defaults to None. func: The function to use for reading the value. Defaults to None. instance: An object that implements the DataSet-like `deserialize` method. Defaults to None. default: The default value to return if the value is not found. Defaults to `NoDefault` (no default value: raises an exception if the value is not found). Returns: The read value. """ if group_name: self.begin(group_name) try: if instance is None: if func is None: func = self.read_any val = func() else: group = self.get_parent_group() if group_name in group.attrs: # This is an attribute (not a group), meaning that # the object was None when deserializing it val = None else: instance.deserialize(self) val = instance except Exception: # pylint:disable=broad-except if default is NoDefault: raise val = default if group_name: self.end(group_name) return val def read_any( self, ) -> ( str | bytes | int | float | datetime.date | datetime.datetime | list[Any] | np.ndarray ): """ Read a value from the current group as a generic type. Returns: The read value. """ group = self.get_parent_group() attr_name = self.option[-1] try: value = group.attrs[attr_name] except KeyError: if self.read(SEQUENCE_NAME, func=self.read_int, default=None) is None: # No sequence found, this means that the data we are trying to read # is not here (e.g. compatibility issue), so we raise an error raise value = self.read_sequence() # Check for type metadata type_key = f"{attr_name}__type__" if type_key in group.attrs: type_hint = group.attrs[type_key] if isinstance(type_hint, bytes): type_hint = type_hint.decode("utf-8") if type_hint == "datetime": return datetime.datetime.fromtimestamp(value) if type_hint == "date": return datetime.date.fromordinal(int(value)) if isinstance(value, bytes): return value.decode("utf-8") return value def read_bool(self) -> bool | None: """ Read a boolean value from the current group. Returns: The read boolean value, or None if the value is not found. """ val = self.read_any() if val != "": return bool(val) def read_int(self) -> int | None: """ Read an integer value from the current group. Returns: The read integer value, or None if the value is not found. """ val = self.read_any() if val != "": return int(val) def read_float(self) -> float | None: """ Read a float value from the current group. Returns: The read float value, or None if the value is not found. """ val = self.read_any() if val != "": return float(val) read_unicode = read_str = read_any def read_array(self) -> np.ndarray: """ Read a numpy array from the current group. Returns: The read numpy array. """ group = self.get_parent_group() return group[self.option[-1]][...] def read_sequence(self) -> list[Any]: """ Read a sequence from the current group. Returns: The read sequence. """ length = self.read(SEQUENCE_NAME, func=self.read_int) if length is None: return [] seq = [] for index in range(length): name = f"{SEQUENCE_NAME}{index}" with self.group(name): dspath = "/".join(self.option) errormsg = f"cannot deserialize sequence at '{dspath}' (name '{name}')" try: group = self.get_parent_group() if name in group.attrs: obj = self.read_any() else: try: obj = self.read_array() except TypeError: obj_group = group[name] if DICT_NAME in obj_group.attrs: obj = self.read_dict() elif SEQUENCE_NAME in obj_group.attrs: obj = self.read_sequence() else: dspath = "/".join(self.option) raise ValueError(errormsg) except ValueError as err: raise ValueError(errormsg) from err seq.append(obj) return seq def read_dict(self) -> dict[str, Any]: """Read dictionary from h5 file Returns: Dictionary read from h5 file """ group = self.get_parent_group() dict_group = group[self.option[-1]] dict_val = {} for key, value in dict_group.attrs.items(): if key == DICT_NAME or key.endswith("__type__"): continue # Check for type metadata type_key = f"{key}__type__" if type_key in dict_group.attrs: type_hint = dict_group.attrs[type_key] if isinstance(type_hint, bytes): type_hint = type_hint.decode("utf-8") if type_hint == "datetime": dict_val[key] = datetime.datetime.fromtimestamp(value) continue if type_hint == "date": dict_val[key] = datetime.date.fromordinal(int(value)) continue dict_val[key] = value for key in dict_group: with self.group(key): try: dict_val[key] = self.read_array() except TypeError: key_group = dict_group[self.option[-1]] if DICT_NAME in key_group.attrs: dict_val[key] = self.read_dict() elif SEQUENCE_NAME in key_group.attrs: dict_val[key] = self.read_sequence() else: dspath = "/".join(self.option) raise ValueError( f"cannot deserialize dict at '{dspath}' (key '{key}'))" ) return dict_val def read_list(self) -> list[Any]: """ Read a list from the current group. Returns: The read list. """ group = self.get_parent_group() return list(group.attrs[self.option[-1]]) def read_object_list( self, group_name: str, klass: type[Any], progress_callback: Callable[[int], bool] | None = None, ) -> list[Any]: """Read an object sequence from a group. Objects must implement the DataSet-like `deserialize` method. `klass` is the object class which constructor requires no argument. Args: group_name: The name of the group to read the object sequence from. klass: The object class which constructor requires no argument. progress_callback: A function to call with an integer argument (progress: 0 --> 100). The function returns the `cancel` state (True: progress dialog has been canceled, False otherwise). """ with self.group(group_name): try: ids = self.read("IDs", func=self.read_list) except ValueError: # None was saved instead of list of objects self.end("IDs") return seq = [] count = len(ids) for idx, name in enumerate(ids): if progress_callback is not None: if progress_callback(int(100 * float(idx) / count)): break with self.group(name): try: group = self.get_parent_group() if name in group.attrs: # This is an attribute (not a group), meaning that # the object was None when deserializing it obj = None else: obj = klass() obj.deserialize(self) except ValueError: break seq.append(obj) return seq read_none = read_any read_none = read_any ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/io/inifmt.py0000644000175100017510000001042415114075001016546 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Configuration files (.ini) """ from __future__ import annotations from typing import Any from guidata.io.base import BaseIOHandler, GroupContext, WriterMixin class INIHandler(BaseIOHandler): """ User configuration IO handler. This class extends the BaseIOHandler to provide methods for handling configuration in a user-specific context. It overrides some of the methods in the base class to tailor them for a user-specific configuration. Args: conf (Any): The configuration object. section (str): The section of the configuration. option (str): The option within the section of the configuration. """ def __init__(self, conf: Any, section: str, option: str) -> None: """ Initialize the INIHandler with the given configuration, section, and option. """ super().__init__() self.conf = conf self.section = section self.option = [option] def begin(self, section: str) -> None: """ Begin a new section. This overrides the `begin` method of the base class. It appends a new section to the current list of options. Args: section (str): The name of the section to begin. """ self.option.append(section) def end(self, section: str) -> None: """ End the current section. This overrides the `end` method of the base class. It pops the last section from the list of options and ensures it matches the expected section. Args: section (str): The name of the section to end. """ sect = self.option.pop(-1) assert sect == section, ( "Ending section does not match the current section: %s != %s" % ( sect, section, ) ) def group(self, option: str) -> GroupContext: """ Enter a group. This returns a context manager, to be used with the `with` statement. Args: option (str): The name of the group to enter. Returns: GroupContext: A context manager for the group. """ return GroupContext(self, option) class INIWriter(INIHandler, WriterMixin): """ User configuration writer. This class extends INIHandler and WriterMixin to provide methods for writing different types of values into the user configuration. """ def write_any(self, val: Any) -> None: """ Write any value into the configuration. This method is used to write a value of any type into the configuration. It creates an option path by joining all the current options and writes the value into this path. Args: val (Any): The value to be written. """ option = "/".join(self.option) self.conf.set(self.section, option, val) # Make write_bool, write_int, write_float, write_array, write_sequence, # alias to write_any write_bool = write_int = write_str = write_float = write_array = write_sequence = ( write_dict ) = write_any def write_none(self) -> None: """ Write a None value into the configuration. This method writes a None value into the configuration. """ self.write_any(None) class INIReader(INIHandler): """ User configuration reader. This class extends the INIHandler to provide methods for reading different types of values from the user configuration. """ def read_any(self) -> Any: """ Read any value from the configuration. This method reads a value from the configuration located by an option path, formed by joining all the current options. Returns: Any: The value read from the configuration. """ option = "/".join(self.option) val = self.conf.get(self.section, option) return val # Make read_bool, read_int, read_float, read_array, read_sequence, read_none # and read_str alias to read_any read_bool = read_int = read_float = read_array = read_sequence = read_dict = ( read_none ) = read_str = read_unicode = read_any ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/io/jsonfmt.py0000644000175100017510000002372515114075001016750 0ustar00runnerrunner# -*- coding: utf-8 -*- # # This file is part of CodraFT Project # https://codra-ingenierie-informatique.github.io/CodraFT/ # # Licensed under the terms of the BSD 3-Clause or the CeCILL-B License # (see codraft/LICENSE for details) """ JSON files (.json) """ # pylint: disable=invalid-name # Allows short reference names like x, y, ... from __future__ import annotations import json import os from collections.abc import Callable, Sequence from typing import Any from uuid import uuid1 import numpy as np from guidata.io.base import BaseIOHandler, WriterMixin class CustomJSONEncoder(json.JSONEncoder): """Custom JSON Encoder""" def default(self, o: Any) -> Any: """Override JSONEncoder method""" if isinstance(o, np.ndarray): olist = o.tolist() if o.dtype in (np.complex64, np.complex128): olist = o.real.tolist() + o.imag.tolist() return ["array", olist, str(o.dtype)] if isinstance(o, np.generic): if isinstance(o, np.integer): return int(o) try: return float(o) except ValueError: return str(o) if isinstance(o, bytes): return o.decode() return json.JSONEncoder.default(self, o) class CustomJSONDecoder(json.JSONDecoder): """Custom JSON Decoder""" def __init__(self, *args, **kwargs) -> None: json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs) def __iterate_dict(self, obj: Any) -> Any: """Iterate dictionaries""" if isinstance(obj, list): if len(obj) == 3: family, data, dtypestr = obj try: dtype = np.dtype(dtypestr) if family == "array": if dtype in (np.complex64, np.complex128): return np.asarray( data[: len(data) // 2], dtype ) + 1j * np.asarray(data[len(data) // 2 :], dtype) return np.asarray(data, dtype) except (TypeError, ValueError): pass return [self.__iterate_dict(item) for item in obj] elif isinstance(obj, dict): for key, value in list(obj.items()): obj[key] = self.__iterate_dict(value) return obj def object_hook(self, obj: dict) -> dict: # pylint: disable=E0202 """Object hook""" for key, value in list(obj.items()): obj[key] = self.__iterate_dict(value) return obj class JSONHandler(BaseIOHandler): """Class handling JSON r/w Args: filename: JSON filename (if None, use `jsontext` attribute) """ def __init__(self, filename: str | None = None) -> None: super().__init__() self.jsondata = {} self.jsontext: str | None = None self.filename = filename def get_parent_group(self) -> dict: """Get parent group""" parent = self.jsondata for option in self.option[:-1]: parent = parent.setdefault(option, {}) return parent def set_json_dict(self, jsondata: dict) -> None: """Set JSON data dictionary Args: jsondata: JSON data dictionary """ self.jsondata = jsondata def get_json_dict(self) -> dict: """Return JSON data dictionary""" return self.jsondata def get_json(self, indent: int | None = None) -> str | None: """Get JSON string Args: indent: Indentation level Returns: JSON string """ if self.jsondata is not None: return json.dumps(self.jsondata, indent=indent, cls=CustomJSONEncoder) return None def load(self) -> None: """Load JSON file""" if self.filename is not None: with open(self.filename, mode="rb") as fdesc: self.jsontext = fdesc.read().decode() self.jsondata = json.loads(self.jsontext, cls=CustomJSONDecoder) def save(self, path: str | None = None) -> None: """Save JSON file Args: path: Path to save the JSON file (if None, implies current directory) """ if self.filename is not None: filepath = self.filename if path: filepath = os.path.join(path, filepath) with open(filepath, mode="wb") as fdesc: fdesc.write(self.get_json(indent=4).encode()) def close(self) -> None: """Expected close method: do nothing for JSON I/O handler classes""" class JSONWriter(JSONHandler, WriterMixin): """Class handling JSON serialization""" def write_any(self, val) -> None: """Write any value type""" group = self.get_parent_group() group[self.option[-1]] = val def write_none(self) -> None: """Write None""" self.write_any(None) write_sequence = write_dict = write_str = write_bool = write_int = write_float = ( write_array ) = write_any def write_object_list(self, seq: Sequence[Any] | None, group_name: str) -> None: """ Write an object sequence to the HDF5 file in a group. Objects must implement the DataSet-like `serialize` method. Args: seq: The object sequence to write. Defaults to None. group_name: The name of the group in which to write the objects. """ with self.group(group_name): if seq is None: self.write_none() else: ids = [] for obj in seq: guid = str(uuid1()) ids.append(guid) with self.group(guid): if obj is None: self.write_none() else: obj.serialize(self) self.write(ids, "IDs") class NoDefault: """Class to represent the absence of a default value.""" pass class JSONReader(JSONHandler): """Class handling JSON deserialization Args: fname_or_jsontext: JSON filename or JSON text """ def __init__(self, fname_or_jsontext: str) -> None: """JSONReader constructor""" JSONHandler.__init__(self, fname_or_jsontext) if fname_or_jsontext is not None and not os.path.isfile(fname_or_jsontext): self.filename = None self.jsontext = fname_or_jsontext self.load() def read( self, group_name: str | None = None, func: Callable[[], Any] | None = None, instance: Any | None = None, default: Any | NoDefault = NoDefault, ) -> Any: """ Read a value from the current group or specified group_name. Args: group_name: The name of the group to read from. Defaults to None. func: The function to use for reading the value. Defaults to None. instance: An object that implements the DataSet-like `deserialize` method. Defaults to None. default: The default value to return if the value is not found. Defaults to `NoDefault` (no default value: raises an exception if the value is not found). Returns: The read value. """ if group_name: self.begin(group_name) try: if instance is None: if func is None: func = self.read_any val = func() else: group = self.get_parent_group() if group_name not in group: # This is an attribute (not a group), meaning that # the object was None when deserializing it val = None else: instance.deserialize(self) val = instance except Exception: # pylint:disable=broad-except if default is NoDefault: raise val = default if group_name: self.end(group_name) return val def read_any(self) -> Any: """Read any value type""" group = self.get_parent_group() return group[self.option[-1]] def read_object_list( self, group_name: str, klass: type[Any], progress_callback: Callable[[int], bool] | None = None, ) -> list[Any]: """Read an object sequence from a group. Objects must implement the DataSet-like `deserialize` method. `klass` is the object class which constructor requires no argument. Args: group_name: The name of the group to read the object sequence from. klass: The object class which constructor requires no argument. progress_callback: A function to call with an integer argument (progress: 0 --> 100). The function returns the `cancel` state (True: progress dialog has been canceled, False otherwise). """ with self.group(group_name): try: ids = self.read("IDs", func=self.read_sequence) except ValueError: # None was saved instead of list of objects self.end("IDs") return None seq = [] count = len(ids) for idx, name in enumerate(ids): if progress_callback is not None: if progress_callback(int(100 * float(idx) / count)): break with self.group(name): group = self.get_parent_group() if group[name] is None: # The object was None when deserializing it obj = None else: obj = klass() obj.deserialize(self) seq.append(obj) return seq read_unicode = read_sequence = read_dict = read_float = read_int = read_str = ( read_bool ) = read_array = read_any ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6148856 guidata-3.13.4/guidata/locale/0000755000175100017510000000000015114075015015542 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6148856 guidata-3.13.4/guidata/locale/fr/0000755000175100017510000000000015114075015016151 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6478856 guidata-3.13.4/guidata/locale/fr/LC_MESSAGES/0000755000175100017510000000000015114075015017736 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784650.0 guidata-3.13.4/guidata/locale/fr/LC_MESSAGES/guidata.mo0000644000175100017510000003363715114075012021722 0ustar00runnerrunnerDlm s }   A7cV78.+Zclr{  JE4Y =     ($4Ylr  * > LZ^ c my  )(  *.3 GQWgm tm(/H!a     M9?FM ^l } W&*%1P2!  !2TYlq &  ": BBN 2  $1EZcx  # 2E=    # =:E.  iXn"   39?NTY^glrz   !!!!(!!" ""I("Ir"x"J5#D#># $ $$ #$/$ H$ i$ s$ }$$e$T%FW%%^%& && "& /& 9&F&N&a&1t&&&&&& ' &'4'C'b' i'<u'''''( ( ( ( +(!5(W( w( (8(4( ( ) ))#),)K)T)Y)m)t)})#))))b*i**,* *** * *++!%+G+Z_+++++++, ,,7,DJ,,!,0,0-3-8- ?-J-e-l-r--------..#;. _.@m. ....%./ /^*/// /5/0 0 00 10?0X0 r0|0 00000001%1=1S1i1}11\12 2<2O2!f222222c3;~3 33333T4RV434 4 4 4 5 5 5+515d65!5"50566.676G6Y6 h6v6<}666666 6667 777#7*7 07 >7L7#T7x7777 and between higher than lower than %s are currently not supported%s arrays%s editor%s files2D rampUnable to assign data to item.

Error message:
%sUnable to plot data.

Error message:
%sUnable to proceed to next step

Please check your entries.

Error message:
%sUnable to save array

Error message:
%sUnable to show image.

Error message:
%sWarning: changes are applied separatelyA bananaA cherryAboutAbout...Additional optionsAll supported filesAn appleApplyArgumentsArray editorArray editor does not support array with x/y labels in variable size mode.Array editor was initialized in both readonly and variable size mode.Arrays with more than 3 dimensions are not supportedAttributeAutomatic GUI generation for easy dataset editing and displayAxis:Background colorBackground:Browse...Builtin:CSV FilesCancelClear lineClear shellClear shell contents ('cls' command)Clipboard contentsCloseColumn min/maxColumn separator:Column(s) deletionColumn(s) insertionComment:Comments:Conflicing edition flagsCopyCopy allCopy all array data to clipboardCopy without promptsCreated by %s in %dCurrent cell:Current line:CutDataDataFrameDefinition:DeleteDelete from columnDelete from rowDescriptionDictionaryDo you want to remove all selected items?Do you want to remove the selected item?DocumentationDoneDuplicateEOLEditEdit array contentsEdit itemEmptyEmpty clipboardErrorExportExport arrayExport array to a fileExternal editor:Float formattingFor performance reasons, changes applied to masked array won't be reflected in array's data (and vice-versa).FormatFormat (%s) is incorrectFormat ({}) is incorrectFormat ({}) should start with '%'GaussianHelpHelp...HistogramHistory logsImport asImport errorImport from clipboardImport wizardIn order to use commands like "input" run console with the multithread optionIndexIndex:InsertInsert at columnInsert at rowInsert column(s)Insert row(s)Instance:Internal editor:It is not possible to display this value because an error ocurred while trying to do itIt was not possible to copy this arrayIt was not possible to copy this dataframeIt was not possible to copy values for this arrayIt was not possible to paste values for this arrayKeyKey:Keyword:Largest element in arrayLink:ListMaintained by the %s organizationMaskMasked dataMatched
parens:More details about %s on %s or %sNameNew variable name:NextNo description availableNo test found in this package.Normal text:Nothing to be imported from clipboard.NumPy arrayNumPy arraysNumber of columnsNumber of rowsNumber of rows x Number of columnsNumber:Occurrence:Opening this variable can be slow Do you want to continue anyway?Overflow error: %sPastePlease check highlighted fields.Please install PlotPy or matplotlib.PlotPreviewPreviousProject websitePython help:Random (normal law)Random (uniform law)Raw textRecord array fields:RemoveRemove column(s)Remove references:Remove row(s)RenameResizeResize rows to contentsRow separator:Row(s) deletionRow(s) insertionRun script:Run this scriptSave and CloseSave arraySave current history log (i.e. all inputs and outputs) in a text fileSave history logSave history log...Select AllShell special commands:Show arrays min/maxShow imageSide areas:SizeSkip rows:Slice %s is not valid. Expected an Iterable of slices and int like (slice(None), slice(None), n1, n2, ..., nX) with maximum two slices.Smallest element in arraySome required entries are incorrectString:System commands:TabTests - %s moduleText editorThe 'xlabels' argument length do no match array column numberThe 'ylabels' argument length do no match array row numberThe array editor will remain in readonly mode.To boolTo complexTo floatTo intTo strTransposeTupleTypeUnable to retrieve the value of this variable from the console.

The error mesage was:
%sUnmatched
parens:Unsupported array formatUnsupported type %s, defaults to: ValueValue error: %sValue:Variable NameVariable name:Waiting: %s sWarningWhitespaceYou will not be able to add or remove rows/columns.Zerosall file typesarraycodedataelementsevenfloatintegerlistnon zeronot emptyoddotherread onlyregexp:stringsupported file types:tabletextunit:variable_nameProject-Id-Version: guidata 3.10.0 Report-Msgid-Bugs-To: p.raybaut@codra.fr POT-Creation-Date: 2025-09-19 11:57+0200 PO-Revision-Date: 2025-06-17 15:47+0200 Last-Translator: Christophe Debonnel Language: fr Language-Team: fr Plural-Forms: nplurals=2; plural=(n > 1); MIME-Version: 1.0 Content-Type: text/plain; charset=utf-8 Content-Transfer-Encoding: 8bit Generated-By: Babel 2.17.0 et compris entre supérieur à inférieur à Attention: %s ne sont pas pris en chargeles tableaux %sÉditeur de %sFichiers %sRampe 2DImpossible d'accéder aux données

Message d'erreur :
%sImpossible d'afficher les données

Message d'erreur :
%sImpossible de passer à l'étape suivante

Merci de vérifier votre saisie.

Message d'erreur :
%sImpossible d'enregistrer le tableau

Message d'erreur :
%sImpossible d'afficher l'image

Message d'erreur :
%sAttention: les changements sont appliqués séparémentUne bananeUne ceriseA proposA propos...Options supplémentairesTous les fichiers pris en chargeUne pommeAppliquerArgumentsÉditeur de tableauxL'editeur de tableau ne prend pas en charge les tableaux avec des labels x/y en mode taille variable.L'éditeur de tableaux a été initialisé en mode lecture seule et taille variable.Les tableaux ayant plus de trois dimensions ne sont pas pris en chargeAttributGénération automatique d'interfaces graphiques pour éditer et afficher des jeux de donnéesAxe:Couleur de fondFond :Parcourir...Builtin :Fichiers CSVAnnulerSupprimer la ligneEffacer la consoleEffacer le contenu de la console (commande 'cls')Contenu du presse-papiersFermerMin/max de colonneSéparateur de colonne :Suppression de colonne(s)Insertion de colonnes(s)Commentaire :Commentaires :Modes d'éditions conflictuelsCopierCopier toutCopier toutes les données du tableau dans le presse-papiersCopier sans les entêtesCréé par %s en %dCellule courante :Ligne courante :CouperDonnéesDataFrameDéfinition :SupprimerSupprimer à partir de la colonneSupprimer à partir de la ligneDescriptionDictionnaireSouhaitez-vous supprimer les éléments sélectionnés ?Souhaitez-vous supprimer l'élément sélectionné ?DocumentationTerminerDupliquerEOLModifierModifier le contenu du tableauModifierVidePresse-papiers videErreurExporterExporter le tableauExporter le tableau vers un fichierÉditeur externe :Format de flottantPour des raisons de performance, les changements appliqués au masque ne sont pas reflétés dans le tableau associé (et vice-versa).FormatLe format (%s) est incorrectLe format ({}) est incorrectLe format ({}) ne doit pas commencer par '%'GaussienneAideAide...HistogrammeHistoriquesImporter en tant queErreur d'importImporter depuis le presse-papiersAssistant d'importationPour utiliser des commandes du type "input" dans la console, utiliser l'option multithreadIndiceIndice:InsérerInsérer à la colonneInsérer à la ligneInsertion de colonne(s)Insertion de ligne(s)Instance :Éditeur interne :Impossible d'afficher cette valeur en raison d'une erreur inattendueImpossible de copier ce tableauImpossible de copier ce DataFrameImpossible de copier les valeurs pour ce tableauImpossible de coller les valeurs pour ce tableauCléClé :Mot-clé :Valeur maximale du tableauLien :ListeMaintenu par l'organisation %sMasqueDonnées masquéesParenthèses
appariées :Plus de détails à propos de %s sur %s ou %sNomNouveau nom de variable :SuivantAucune description disponibleAucun test trouvé dans ce package.Text normal :Aucune donnée ne peut être importée depuis le presse-papiers.Tableau NumPyTableaux NumPyNombre de colonnesNombre de lignesNombre de lignes x Nombre de colonnesNombre :Occurence :Editer cette variable sera vraisemblablement très long. Souhaitez-vous néanmoins continuer ?Erreur de dépassement : %sCollerVeuillez vérifier votre saisie.Merci d'installer PlotPy ou matplotlib.TracerAperçuPrécédentSite web du projetAide Python :Aléatoire (loi normale)Aléatoire (loi uniforme)Text brutChamps:SupprimerSuppression de colonne(s)Supprimer les références :Suppression de ligne(s)RenommerAjusterAjuster les colonnes au contenuSéparateur de ligne :Suppression de ligne(s)Insertion de ligne(s)Exécuter le script :Exécuter ce scriptEnregistrer et FermerEnregistrer le tableauEnregistrer l'historique actuel (c.-à-d. toutes les entrées-sorties) dans un fichier texteEnregistrer l'historiqueEnregistrer l'historique...Sélectionner toutCommandes spéciales :Afficher les min/max des tableauxAfficher l'imageZone latérale :TailleSauter des lignes :Slice %s incorrecte. Un itérable de slices et d'entiers au format (slice(None), slice(None), n1, n2, ..., nX) avec un maximum de deux slices était attendu.Valeur minimale du tableauLes champs surlignés n'ont pas été remplis correctement.Chaîne :Commandes système :TabTests - Module %sÉditeur de texteLa taille de l'argument 'xlabels' ne correspond pas au nombre de colonnes du tableauLa taille de l'argument 'ylabels' ne correspond pas au nombre de lignes du tableauL'éditeur de tableaux reste en mode lecture seule.Vers booléenVers complexeVers flottantVers entierVers chaîneTransposerTupleTypeImpossible de récupérer la valeur de cette variable.

Le message d'erreur est :
%sParenthèses
non appariées :Type de tableau non pris en chargeType %s non pris en charge, valeur par défaut: ValeurErreur de valeur : %sValeur :Nom de variableNom de variable :Attente : %s sAvertissementEspaceVous ne pourrez pas insérer ou supprimer de lignes/colonnesZérostout type de fichiertableaucodedonnéesélémentspairflottantentierlistenon nulnon videimpairautrelecture seuleexpr. rég. :chaînetypes de fichiers pris en charge : tableautexteunité :nom_de_variable././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/qthelpers.py0000644000175100017510000006513415114075001016670 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Qt helpers ---------- Actions ^^^^^^^ .. autofunction:: create_action .. autofunction:: add_actions .. autofunction:: add_separator .. autofunction:: keybinding Simple widgets ^^^^^^^^^^^^^^ .. autofunction:: create_toolbutton .. autofunction:: create_groupbox Icons ^^^^^ .. autofunction:: get_std_icon .. autofunction:: show_std_icons Application ^^^^^^^^^^^ .. autofunction:: qt_app_context .. autofunction:: exec_dialog Other ^^^^^ .. autofunction:: grab_save_window .. autofunction:: block_signals .. autofunction:: qt_wait .. autofunction:: save_restore_stds """ from __future__ import annotations import os import os.path as osp import sys import time import warnings from contextlib import contextmanager from datetime import datetime from typing import TYPE_CHECKING, Generator, Iterable, Literal from qtpy import QtCore as QC from qtpy import QtGui as QG from qtpy import QtWidgets as QW import guidata from guidata.config import _ from guidata.configtools import get_icon from guidata.env import execenv from guidata.external import darkdetect if TYPE_CHECKING: from collections.abc import Callable ENV_COLOR_MODE = "QT_COLOR_MODE" COLOR_MODES = LIGHT, DARK, AUTO = "light", "dark", "auto" CURRENT_THEME = None def set_dark_mode(state: bool) -> None: """Set dark mode for Qt application (deprecated, use `set_color_mode` instead) Args: state: True to enable dark mode """ mode = DARK if state else LIGHT warnings.warn( f"`set_dark_mode` is deprecated and will be removed in a future version. " f"Use `set_color_mode('{mode}')` instead.", DeprecationWarning, ) set_color_mode(mode) def is_dark_mode() -> bool: """Return True if current color theme is dark (deprecated, use `is_dark_theme` instead) Returns: True if dark theme is enabled """ warnings.warn( "`is_dark_mode` is deprecated and will be removed in a future version. " "Use `is_dark_theme` instead.", DeprecationWarning, ) return is_dark_theme() def get_color_theme() -> Literal["light", "dark"]: """Get color theme. Color theme value is updated only once per session because the query to the system may be expensive depending on the platform. It is updated when the color mode is set to 'auto' using `set_color_mode('auto')`. Returns: Color theme ('light' or 'dark') """ global CURRENT_THEME # Return cached theme if available if CURRENT_THEME is not None: return CURRENT_THEME mode = get_color_mode() if mode == AUTO: theme = DARK if darkdetect.isDark() else LIGHT else: theme = mode CURRENT_THEME = theme return theme def is_dark_theme() -> bool: """Return True if current color theme is dark. Returns: True if dark theme is enabled """ return get_color_theme() == DARK def get_color_mode() -> Literal["light", "dark", "auto"]: """Get color mode setting. Returns: Color mode ('light', 'dark' or 'auto') """ mode = os.environ.get(ENV_COLOR_MODE, AUTO).lower() if mode not in COLOR_MODES: # Just show a warning, the mode will be set to 'auto': warnings.warn( f"Invalid color mode: {mode} (expected {COLOR_MODES}). " "Using 'auto' instead.", UserWarning, ) mode = AUTO return mode DEFAULT_STYLES = None def get_background_color() -> QG.QColor: """Get current theme background color""" return QW.QApplication.instance().palette().color(QG.QPalette.Base) def get_foreground_color() -> QG.QColor: """Get current theme foreground color""" return QW.QApplication.instance().palette().color(QG.QPalette.Text) def set_color_mode(mode: Literal["light", "dark", "auto"] | None = None): """Set color mode. Args: mode: Color mode ('light', 'dark' or 'auto'). If 'auto', the system color mode is used. If None, the `QT_COLOR_MODE` environment variable is used. """ global DEFAULT_STYLES, CURRENT_THEME CURRENT_THEME = None if mode is not None: assert mode in COLOR_MODES, ( f"Invalid color mode: {mode} (expected {COLOR_MODES})" ) os.environ[ENV_COLOR_MODE] = mode app = QW.QApplication.instance() if DEFAULT_STYLES is None: # Store default palette to be able to restore it: DEFAULT_STYLES = app.style().objectName(), app.palette(), app.styleSheet() if is_dark_theme(): app.setStyle(QW.QStyleFactory.create("Fusion")) dark_palette = QG.QPalette() dark_color = QG.QColor(50, 50, 50) disabled_color = QG.QColor(127, 127, 127) dpsc = dark_palette.setColor dpsc(QG.QPalette.Window, dark_color) dpsc(QG.QPalette.WindowText, QC.Qt.white) dpsc(QG.QPalette.Base, QG.QColor(31, 31, 31)) dpsc(QG.QPalette.AlternateBase, dark_color) dpsc(QG.QPalette.ToolTipBase, QC.Qt.white) dpsc(QG.QPalette.ToolTipText, QC.Qt.white) dpsc(QG.QPalette.Text, QC.Qt.white) dpsc(QG.QPalette.Disabled, QG.QPalette.Text, disabled_color) dpsc(QG.QPalette.Button, dark_color) dpsc(QG.QPalette.ButtonText, QC.Qt.white) dpsc(QG.QPalette.Disabled, QG.QPalette.ButtonText, disabled_color) dpsc(QG.QPalette.BrightText, QC.Qt.red) dpsc(QG.QPalette.Link, QG.QColor(42, 130, 218)) dpsc(QG.QPalette.Highlight, QG.QColor(42, 130, 218)) dpsc(QG.QPalette.HighlightedText, QC.Qt.black) dpsc(QG.QPalette.Disabled, QG.QPalette.HighlightedText, disabled_color) app.setPalette(dark_palette) app.setStyleSheet( "QToolTip { " "color: white; background-color: #2a82da; border: 1px solid white;" " }" ) elif DEFAULT_STYLES is not None: style, palette, stylesheet = DEFAULT_STYLES app.setStyle(QW.QStyleFactory.create(style)) app.setPalette(palette) app.setStyleSheet(stylesheet) # Iterate over all top-level widgets: for widget in QW.QApplication.instance().topLevelWidgets(): win32_fix_title_bar_background(widget) def win32_fix_title_bar_background(widget: QW.QWidget) -> None: """Fix window title bar background for Windows 10+ dark theme Args: widget (QW.QWidget): Widget to fix """ if os.name != "nt" or sys.maxsize == 2**31 - 1: return # See Issue #84: SetWindowCompositionAttribute() is incompatible with # QGraphicsEffect (e.g. QGraphicsDropShadowEffect) applied on the widget or # any of its parents. # Qt performs its own offscreen rendering when a QGraphicsEffect is active, # preventing Windows from properly applying composition effects and potentially # causing rendering glitches or crashes. # This check avoids applying system-level visual modifications in such cases. obj = widget while obj is not None: if obj.graphicsEffect(): return obj = obj.parent() import ctypes from ctypes import wintypes class ACCENTPOLICY(ctypes.Structure): _fields_ = [ ("AccentState", ctypes.c_uint), ("AccentFlags", ctypes.c_uint), ("GradientColor", ctypes.c_uint), ("AnimationId", ctypes.c_uint), ] class WINDOWCOMPOSITIONATTRIBDATA(ctypes.Structure): _fields_ = [ ("Attribute", ctypes.c_int), ("Data", ctypes.POINTER(ctypes.c_int)), ("SizeOfData", ctypes.c_size_t), ] accent = ACCENTPOLICY() data = WINDOWCOMPOSITIONATTRIBDATA() dark = is_dark_theme() if dark: accent.AccentState = 3 # ACCENT_ENABLE_ACRYLICBLURBEHIND data.Attribute = 26 # WCA_USEDARKMODECOLORS else: accent.AccentState = 0 # ACCENT_DISABLED data.Attribute = 19 # WCA_ACCENT_POLICY data.SizeOfData = ctypes.sizeof(accent) data.Data = ctypes.cast(ctypes.pointer(accent), ctypes.POINTER(ctypes.c_int)) if not hasattr(ctypes.windll.user32, "SetWindowCompositionAttribute"): # Windows 7 and 8 do not support this function, so we skip it return set_win_cpa = ctypes.windll.user32.SetWindowCompositionAttribute set_win_cpa.argtypes = (wintypes.HWND, ctypes.POINTER(WINDOWCOMPOSITIONATTRIBDATA)) set_win_cpa.restype = ctypes.c_int set_win_cpa(int(widget.winId()), data) if not dark: # Setting dark mode attribute to False (0) to ensure the default light mode attribute_value = ctypes.c_int(0) hwnd = wintypes.HWND(int(widget.winId())) ctypes.windll.dwmapi.DwmSetWindowAttribute( hwnd, 20, # DWMWA_USE_IMMERSIVE_DARK_MODE, # Dark mode attribute ctypes.byref(attribute_value), ctypes.sizeof(attribute_value), ) def create_action( parent: QW.QWidget | None, title: str, triggered: Callable | None = None, toggled: Callable | None = None, shortcut: QG.QKeySequence | None = None, icon: QG.QIcon | None = None, tip: str | None = None, checkable: bool | None = None, context: QC.Qt.ShortcutContext = QC.Qt.WindowShortcut, enabled: bool | None = None, ) -> QW.QAction: """Create a new QAction Args: parent (QWidget or None): Parent widget title (str): Action title triggered (Callable or None): Triggered callback toggled (Callable or None): Toggled callback shortcut (QKeySequence or None): Shortcut icon (QIcon or None): Icon tip (str or None): Tooltip checkable (bool or None): Checkable context (Qt.ShortcutContext): Shortcut context enabled (bool or None): Enabled Returns: QAction: New action """ if isinstance(title, bytes): title = str(title, "utf8") action = QW.QAction(title, parent) if triggered: if checkable: action.triggered.connect(triggered) else: action.triggered.connect(lambda checked=False: triggered()) if checkable is not None: # Action may be checkable even if the toggled signal is not connected action.setCheckable(checkable) if toggled: action.toggled.connect(toggled) action.setCheckable(True) if icon is not None: assert isinstance(icon, QG.QIcon) action.setIcon(icon) if shortcut is not None: action.setShortcut(shortcut) if tip is not None: action.setToolTip(tip) action.setStatusTip(tip) if enabled is not None: action.setEnabled(enabled) action.setShortcutContext(context) return action def create_toolbutton( parent: QW.QWidget, icon: QG.QIcon | str | None = None, text: str | None = None, triggered: Callable | None = None, tip: str | None = None, toggled: Callable | None = None, shortcut: QG.QKeySequence | None = None, autoraise: bool = True, enabled: bool | None = None, ) -> QW.QToolButton: """Create a QToolButton Args: parent (QWidget): Parent widget icon (QIcon or str or None): Icon text (str or None): Text triggered (Callable or None): Triggered callback tip (str or None): Tooltip toggled (Callable or None): Toggled callback shortcut (QKeySequence or None): Shortcut autoraise (bool): Auto raise enabled (bool or None): Enabled Returns: QToolButton: New toolbutton """ if autoraise: button = QW.QToolButton(parent) else: button = QW.QPushButton(parent) if text is not None: button.setText(text) if icon is not None: if isinstance(icon, str): icon = get_icon(icon) button.setIcon(icon) if text is not None or tip is not None: button.setToolTip(text if tip is None else tip) if autoraise: button.setToolButtonStyle(QC.Qt.ToolButtonTextBesideIcon) button.setAutoRaise(True) if triggered is not None: button.clicked.connect(lambda checked=False: triggered()) if toggled is not None: button.toggled.connect(toggled) button.setCheckable(True) if shortcut is not None: button.setShortcut(shortcut) if enabled is not None: button.setEnabled(enabled) return button def create_groupbox( parent: QW.QWidget, title: str | None = None, toggled: Callable | None = None, checked: bool | None = None, flat: bool = False, layout: QW.QLayout | None = None, ) -> QW.QGroupBox: """Create a QGroupBox Args: parent (QWidget): Parent widget title (str or None): Title toggled (Callable or None): Toggled callback checked (bool or None): Checked flat (bool): Flat layout (QLayout or None): Layout Returns: QGroupBox: New groupbox """ if title is None: group = QW.QGroupBox(parent) else: group = QW.QGroupBox(title, parent) group.setFlat(flat) if toggled is not None: group.setCheckable(True) if checked is not None: group.setChecked(checked) group.toggled.connect(toggled) if layout is not None: group.setLayout(layout) return group def keybinding(attr: str) -> str: """Return keybinding Args: attr (str): Attribute name Returns: str: Keybinding """ ks = getattr(QG.QKeySequence, attr) return QG.QKeySequence.keyBindings(ks)[0].toString() def add_separator(target: QW.QMenu | QW.QToolBar) -> None: """Add separator to target only if last action is not a separator Args: target (QMenu or QToolBar): Target menu or toolbar """ target_actions = list(target.actions()) if target_actions: if not target_actions[-1].isSeparator(): target.addSeparator() def add_actions( target: QW.QMenu | QW.QToolBar, actions: Iterable[QW.QAction | QW.QMenu | QW.QToolButton | QW.QPushButton | None], ) -> None: """ Add actions (list of QAction instances) to target (menu, toolbar) Args: target (QMenu or QToolBar): Target menu or toolbar actions (list): List of actions (QAction, QMenu, QToolButton, QPushButton, None) """ for action in actions: if isinstance(action, QW.QAction): target.addAction(action) elif isinstance(action, QW.QMenu): target.addMenu(action) elif isinstance(action, QW.QToolButton) or isinstance(action, QW.QPushButton): target.addWidget(action) elif action is None: add_separator(target) def _process_mime_path(path: str, extlist: tuple[str, ...] | None = None) -> str | None: """Process path from MIME data Args: path (str): Path extlist (tuple or None): Extension list Returns: str or None: Processed path """ if path.startswith(r"file://"): if os.name == "nt": # On Windows platforms, a local path reads: file:///c:/... # and a UNC based path reads like: file://server/share if path.startswith(r"file:///"): # this is a local path path = path[8:] else: # this is a unc path path = path[5:] else: path = path[7:] path = path.replace("%5C", os.sep) # Transforming backslashes if osp.exists(path): if extlist is None or osp.splitext(path)[1] in extlist: return path def mimedata2url( source: QC.QMimeData, extlist: tuple[str, ...] | None = None ) -> list[str]: """ Extract url list from MIME data extlist: for example ('.py', '.pyw') Args: source (QMimeData): Source extlist (tuple or None): Extension list Returns: list: List of paths """ pathlist = [] if source.hasUrls(): for url in source.urls(): path = _process_mime_path(str(url.toString()), extlist) if path is not None: pathlist.append(path) elif source.hasText(): for rawpath in str(source.text()).splitlines(): path = _process_mime_path(rawpath, extlist) if path is not None: pathlist.append(path) if pathlist: return pathlist def get_std_icon(name: str, size: int | None = None) -> QG.QIcon: """ Get standard platform icon Call 'show_std_icons()' for details Args: name (str): Icon name size (int or None): Size Returns: QIcon: Icon """ if not name.startswith("SP_"): name = "SP_" + name icon = QW.QWidget().style().standardIcon(getattr(QW.QStyle, name)) if size is None: return icon else: return QG.QIcon(icon.pixmap(size, size)) class ShowStdIcons(QW.QWidget): """ Dialog showing standard icons Args: parent (QWidget): Parent widget """ def __init__(self, parent) -> None: QW.QWidget.__init__(self, parent) layout = QW.QHBoxLayout() row_nb = 14 cindex = 0 col_layout = QW.QVBoxLayout() for child in dir(QW.QStyle): if child.startswith("SP_"): if cindex == 0: col_layout = QW.QVBoxLayout() icon_layout = QW.QHBoxLayout() icon = get_std_icon(child) label = QW.QLabel() label.setPixmap(icon.pixmap(32, 32)) icon_layout.addWidget(label) icon_layout.addWidget(QW.QLineEdit(child.replace("SP_", ""))) col_layout.addLayout(icon_layout) cindex = (cindex + 1) % row_nb if cindex == 0: layout.addLayout(col_layout) self.setLayout(layout) self.setWindowTitle("Standard Platform Icons") self.setWindowIcon(get_std_icon("TitleBarMenuButton")) def show_std_icons() -> None: """Show all standard Icons""" app = QW.QApplication(sys.argv) dialog = ShowStdIcons(None) dialog.show() sys.exit(app.exec()) def close_widgets_and_quit(screenshot: bool = False) -> None: """Close Qt top level widgets and quit Qt event loop Args: screenshot (bool): If True, save a screenshot of each widget """ for widget in QW.QApplication.instance().topLevelWidgets(): try: wname = widget.objectName() except RuntimeError: # Widget has been deleted continue if screenshot and wname and widget.isVisible(): # pragma: no cover grab_save_window(widget, wname.lower()) assert widget.close() QW.QApplication.instance().quit() def close_dialog_and_quit(widget, screenshot: bool = False) -> None: """Close QDialog and quit Qt event loop Args: widget (QDialog): Dialog to close """ try: # Workaround for pytest wname = widget.objectName() if screenshot and wname and widget.isVisible(): # pragma: no cover grab_save_window(widget, wname.lower()) else: QW.QApplication.processEvents() if execenv.accept_dialogs: widget.accept() else: widget.done(QW.QDialog.Accepted) except Exception: # pylint: disable=broad-except pass QAPP_INSTANCE = None @contextmanager def qt_app_context(exec_loop: bool = False) -> Generator[QW.QApplication, None, None]: """Context manager handling Qt application creation and persistance Args: exec_loop (bool): If True, execute Qt event loop .. note:: This context manager was strongly inspired by the one in the `DataLab `_ project which is more advanced and complete than this one (it handles faulthandler and traceback log files, which need to be implemented at application level, that is why they were not included here). """ global QAPP_INSTANCE # pylint: disable=global-statement if QAPP_INSTANCE is None: QAPP_INSTANCE = guidata.qapplication() exception_occured = False try: yield QAPP_INSTANCE except Exception: # pylint: disable=broad-except exception_occured = True finally: if execenv.unattended: # pragma: no cover if execenv.delay > 0: mode = "Screenshot" if execenv.screenshot else "Unattended" message = f"{mode} mode (delay: {execenv.delay}ms)" msec = execenv.delay - 200 for widget in QW.QApplication.instance().topLevelWidgets(): if isinstance(widget, QW.QMainWindow): widget.statusBar().showMessage(message, msec) QC.QTimer.singleShot( execenv.delay, lambda: close_widgets_and_quit(screenshot=execenv.screenshot), ) if exec_loop and not exception_occured: QAPP_INSTANCE.exec() if exception_occured: raise # pylint: disable=misplaced-bare-raise def exec_dialog(dlg: QW.QDialog) -> int: """Run QDialog Qt execution loop without blocking, depending on environment test mode Args: dlg (QDialog): Dialog to execute Returns: int: Dialog exit code """ if execenv.unattended: # Important: process all pending Qt events before scheduling dialog closure. # This avoids side-effects between tests (timers, widgets) and ensures # clean dialog lifecycle in non-interactive automated test environments. QW.QApplication.processEvents() QC.QTimer.singleShot( execenv.delay, lambda: close_dialog_and_quit(dlg, screenshot=execenv.screenshot), ) delete_later = not dlg.testAttribute(QC.Qt.WA_DeleteOnClose) result = dlg.exec() if delete_later: dlg.deleteLater() return result def grab_save_window( widget: QW.QWidget, name: str | None = None, save_dir: str | None = None, add_timestamp: bool = False, ) -> None: # pragma: no cover """Grab window screenshot and save it Args: widget: Widget to grab name: Widget name. If None, uses ``widget.objectName()`` save_dir: Directory to save screenshot. If None, uses ``execenv.screenshot_path`` or current working directory add_timestamp: Whether to add timestamp suffix to filename """ if name is None: name = widget.objectName() widget.activateWindow() widget.raise_() QW.QApplication.processEvents() pixmap = widget.grab() suffix = "" if add_timestamp: suffix = "_" + datetime.now().strftime("%Y-%m-%d-%H%M%S") if save_dir is None: save_dir = execenv.screenshot_path or os.getcwd() os.makedirs(save_dir, exist_ok=True) pixmap.save(osp.join(save_dir, f"{name}{suffix}.png")) @contextmanager def block_signals(widget: QW.QWidget, enable: bool) -> Generator[None, None, None]: """Eventually block/unblock widget Qt signals before/after doing some things (enable: True if feature is enabled) Args: widget (QWidget): Widget to block/unblock enable (bool): True to block signals """ if enable: widget.blockSignals(True) try: yield finally: if enable: widget.blockSignals(False) class TopMessageBox(QW.QWidget): """Widget containing a message box, shown on top of all windows""" def __init__(self, parent: QW.QWidget | None = None) -> None: super().__init__(parent) self.__label = QW.QLabel() font = self.__label.font() font.setPointSize(20) self.__label.setFont(font) self.__label.setAlignment(QC.Qt.AlignCenter) layout = QW.QVBoxLayout() layout.addWidget(self.__label) self.setLayout(layout) self.setWindowFlags(QC.Qt.WindowStaysOnTopHint | QC.Qt.SplashScreen) def set_text(self, text: str) -> None: """Set message box text""" self.__label.setText(text) def qt_wait( timeout: float, except_unattended: bool = False, show_message: bool = False, parent: QW.QWidget | None = None, ) -> None: # pragma: no cover """Freeze GUI during timeout (seconds) while processing Qt events. Args: timeout: timeout in seconds except_unattended: if True, do not wait if unattended mode is enabled show_message: if True, show a message box with a timeout parent: parent widget of the message box """ if except_unattended and execenv.unattended: return start = time.time() msgbox = None if show_message: # Show a message box with a timeout msgbox = TopMessageBox(parent) msgbox.show() while time.time() <= start + timeout: time.sleep(0.01) if msgbox is not None: msgbox.set_text(_("Waiting: %s s") % int(timeout - (time.time() - start))) QW.QApplication.processEvents() if msgbox is not None: msgbox.close() msgbox.deleteLater() def qt_wait_until( condition: Callable[[], bool], timeout: float = 5.0, interval: float = 0.05 ) -> None: """Wait until a condition is met or timeout occurs, processing Qt events. Args: condition: A callable that returns True when the condition is met. timeout: Maximum time to wait in seconds. interval: Time to wait between checks in seconds. """ end = time.time() + timeout while not condition() and time.time() < end: QW.QApplication.processEvents() time.sleep(interval) assert condition(), "Condition not met within timeout" @contextmanager def save_restore_stds() -> Generator[None, None, None]: """Save/restore standard I/O before/after doing some things (e.g. calling Qt open/save dialogs)""" saved_in, saved_out, saved_err = sys.stdin, sys.stdout, sys.stderr sys.stdout = None try: yield finally: sys.stdin, sys.stdout, sys.stderr = saved_in, saved_out, saved_err if __name__ == "__main__": show_std_icons() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6478856 guidata-3.13.4/guidata/tests/0000755000175100017510000000000015114075015015445 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/__init__.py0000644000175100017510000000121515114075001017550 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ guidata test package ==================== """ import os.path as osp import guidata from guidata.configtools import get_module_data_path TESTDATAPATH = get_module_data_path("guidata", osp.join("tests", "data")) def get_path(filename: str) -> str: """Return absolute path of test file""" return osp.join(TESTDATAPATH, filename) def run(): """Run guidata test launcher""" from guidata.guitest import run_testlauncher run_testlauncher(guidata) if __name__ == "__main__": run() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/conftest.py0000644000175100017510000000316415114075001017643 0ustar00runnerrunner# content of conftest.py import os import qtpy import guidata from guidata.env import execenv from guidata.utils.gitreport import format_git_info_for_pytest, get_git_info_for_modules # Turn on unattended mode for executing tests without user interaction execenv.unattended = True execenv.verbose = "quiet" def pytest_addoption(parser): """Add custom command line options to pytest.""" parser.addoption( "--show-windows", action="store_true", default=False, help="Display Qt windows during tests (disables QT_QPA_PLATFORM=offscreen)", ) def pytest_configure(config): """Configure pytest based on command line options.""" if config.option.durations is None: config.option.durations = 10 # Default to showing 10 slowest tests if not config.getoption("--show-windows"): os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") def pytest_report_header(config): """Add additional information to the pytest report header.""" qtbindings_version = qtpy.PYSIDE_VERSION if qtbindings_version is None: qtbindings_version = qtpy.PYQT_VERSION infolist = [ f"guidata {guidata.__version__}, " f"{qtpy.API_NAME} {qtbindings_version} [Qt version: {qtpy.QT_VERSION}]", ] # Git information for all modules using the gitreport module modules_config = [ ("guidata", guidata, "."), # guidata uses current directory ] git_repos = get_git_info_for_modules(modules_config) git_info_lines = format_git_info_for_pytest(git_repos, "guidata") if git_info_lines: infolist.extend(git_info_lines) return infolist ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6158855 guidata-3.13.4/guidata/tests/data/0000755000175100017510000000000015114075015016356 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6478856 guidata-3.13.4/guidata/tests/data/genreqs/0000755000175100017510000000000015114075015020022 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/data/genreqs/pyproject.toml0000644000175100017510000000341015114075001022727 0ustar00runnerrunner# guidata setup configuration file # Important note: # Requirements (see [options]) are parsed by utils\genreqs.py to generate documentation [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "guidata" authors = [{ name = "Codra", email = "p.raybaut@codra.fr" }] description = "Signal and image processing software" readme = "README.md" license = { file = "LICENSE" } classifiers = [ "Topic :: Scientific/Engineering", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Human Machine Interfaces", "Topic :: Software Development :: User Interfaces", "Operating System :: MacOS", "Operating System :: Microsoft :: Windows", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: Unix", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] requires-python = ">=3.9, <4" dependencies = ["h5py>=3.0", "NumPy>=1.21", "QtPy>=1.9"] dynamic = ["version"] [project.urls] Homepage = "https://github.com/PlotPyStack/guidata/" Documentation = "https://guidata.readthedocs.io/en/latest/" [project.scripts] [project.optional-dependencies] dev = ["ruff", "pylint", "Coverage"] doc = [ "PyQt5", "pillow", "pandas", "sphinx", "sphinx-copybutton", "sphinx_qt_documentation", "python-docs-theme", ] test = ["pytest", "pytest-cov", "pytest-qt", "pytest-xvfb"] [tool.setuptools] include-package-data = false [tool.setuptools.package-data] "*" = ["*.png", "*.svg", "*.mo", "*.cfg", "*.toml", "*.rst"] [tool.setuptools.dynamic] version = { attr = "guidata.__version__" } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/data/genreqs/setup.cfg0000644000175100017510000000252315114075001021640 0ustar00runnerrunner [metadata] name = guidata version = attr: guidata.__version__ author = Codra author_email = p.raybaut@codra.fr url = https://github.com/PlotPyStack/guidata/ description = Signal and image processing software long_description = file: README.md long_description_content_type = text/markdown license = BSD 3-Clause License classifiers = Topic :: Scientific/Engineering Topic :: Software Development :: Libraries :: Python Modules Topic :: Utilities Topic :: Scientific/Engineering Topic :: Scientific/Engineering :: Human Machine Interfaces Topic :: Software Development :: User Interfaces Operating System :: MacOS Operating System :: Microsoft :: Windows Operating System :: OS Independent Operating System :: POSIX Operating System :: Unix Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 [options] python_requires = >=3.9, <4 install_requires = h5py>=3.0 NumPy>=1.21 QtPy>=1.9 packages = find_namespace: include_package_data = True [options.packages.find] include = guidata* [options.extras_require] dev = ruff pylint Coverage doc = PyQt5 pillow pandas sphinx sphinx-copybutton sphinx_qt_documentation python-docs-theme test = pytest pytest-cov pytest-qt pytest-xvfb ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6518857 guidata-3.13.4/guidata/tests/dataset/0000755000175100017510000000000015114075015017072 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/__init__.py0000644000175100017510000000000015114075001021164 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_activable_dataset.py0000644000175100017510000000323415114075001024137 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ ActivableDataSet example Warning: ActivableDataSet objects were made to be integrated inside GUI layouts. So this example with dialog boxes may be confusing. --> see tests/editgroupbox.py to understand the activable dataset usage """ # When editing, all items are shown. # When showing dataset in read-only mode (e.g. inside another layout), all items # are shown except the enable item. import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context # guitest: show class Parameters(gds.ActivableDataSet): """ Example Activable dataset example """ def __init__(self, title=None, comment=None, icon=""): gds.ActivableDataSet.__init__(self, title, comment, icon) enable = gds.BoolItem( "Enable parameter set", help="If disabled, the following parameters will be ignored", default=False, ) param0 = gds.ChoiceItem("Param 0", ["choice #1", "choice #2", "choice #3"]) param1 = gds.FloatItem("Param 1", default=0, min=0) param2 = gds.FloatItem("Param 2", default=0.93) color = gds.ColorItem("Color", default="red") Parameters.active_setup() def test_activable_dataset(): """Test activable dataset""" with qt_app_context(): prm = Parameters() prm.set_activable(True) prm.edit() prm.set_activable(False) prm.edit() prm.set_readonly() prm.edit() prm.set_readonly(False) prm.edit() execenv.print("OK") if __name__ == "__main__": test_activable_dataset() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_activable_items.py0000644000175100017510000000275415114075001023641 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Example with activable items: items which active state is changed depending on another item's value. """ # guitest: show import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context choices = (("A", "Choice #1: A"), ("B", "Choice #2: B"), ("C", "Choice #3: C")) class Parameters(gds.DataSet): """Example dataset""" _prop1 = gds.GetAttrProp("choice1") choice1 = gds.ChoiceItem("Choice 1", choices).set_prop("display", store=_prop1) _prop2 = gds.GetAttrProp("choice2") choice2 = gds.ChoiceItem("Choice 2", choices).set_prop("display", store=_prop2) x1 = gds.FloatItem("x1") x2 = gds.FloatItem("x2").set_prop( "display", active=gds.FuncProp(_prop1, lambda x: x == "B") ) x3 = gds.FloatItem("x3").set_prop( "display", active=gds.FuncProp(_prop1, lambda x: x == "C") ) b1 = gds.BoolItem("A and C").set_prop( "display", active=gds.FuncPropMulti([_prop1, _prop2], lambda x, y: x == "A" and y == "C"), ) b2 = gds.BoolItem("A or B").set_prop( "display", active=gds.FuncPropMulti([_prop1, _prop2], lambda x, y: x == "A" or y == "B"), ) def test_activable_items(): """Test activable items""" with qt_app_context(): test = Parameters() test.edit() execenv.print("OK") if __name__ == "__main__": test_activable_items() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_all_features.py0000644000175100017510000001232215114075001023144 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ All guidata item/group features demo """ # guitest: show import atexit import shutil import tempfile import numpy as np import guidata.dataset as gds from guidata.config import ValidationMode, get_validation_mode, set_validation_mode from guidata.dataset.qtitemwidgets import DataSetWidget from guidata.dataset.qtwidgets import DataSetEditLayout, DataSetShowLayout from guidata.env import execenv from guidata.qthelpers import qt_app_context # Creating temporary files and registering cleanup functions TEMPDIR = tempfile.mkdtemp(prefix="test_") atexit.register(shutil.rmtree, TEMPDIR) FILE_ETA = tempfile.NamedTemporaryFile(suffix=".eta", dir=TEMPDIR) atexit.register(FILE_ETA.close) FILE_CSV = tempfile.NamedTemporaryFile(suffix=".csv", dir=TEMPDIR) atexit.register(FILE_CSV.close) class SubDataSet(gds.DataSet): dir = gds.DirectoryItem("Directory", TEMPDIR) fname = gds.FileOpenItem("Single file (open)", ("csv", "eta"), FILE_CSV.name) fnames = gds.FilesOpenItem("Multiple files", "csv", [FILE_CSV.name]) fname_s = gds.FileSaveItem("Single file (save)", "eta", FILE_ETA.name) class SubDataSetWidget(DataSetWidget): klass = SubDataSet class SubDataSetItem(gds.ObjectItem): klass = SubDataSet DataSetEditLayout.register(SubDataSetItem, SubDataSetWidget) DataSetShowLayout.register(SubDataSetItem, SubDataSetWidget) class Parameters(gds.DataSet): """ DataSet test The following text is the DataSet 'comment':
Plain text or rich text2 are both supported, as well as special characters (α, β, γ, δ, ...) """ dict_ = gds.DictItem( "dict_item", { "strings_col": ["a", "b", "c"], "_col": [1, 2.0, 3], "float_col": 1.0, }, ) string = gds.StringItem("String", default="") string_regexp = gds.StringItem("String", regexp=r"^[a-z]+[0-9]$", default="abcd9") password = gds.StringItem("Password", default="", password=True) text = gds.TextItem("Text", default="") _bg = gds.BeginGroup("A sub group") float_slider = gds.FloatItem( "Float (with slider)", default=0.5, min=0, max=1, step=0.01, slider=True ) fl1 = gds.FloatItem( "Current", default=10.0, min=1, max=30, unit="mA", help="Threshold current" ) fl2 = gds.FloatItem("Float (col=1)", default=1.0, min=1, max=1).set_pos(col=1) fl3 = gds.FloatItem("Not checked float").set_prop("data", check_value=False) bool1 = gds.BoolItem("Boolean option without label") bool2 = gds.BoolItem("Boolean option with label", "Label").set_pos(col=1, colspan=2) color = gds.ColorItem("Color", default="red") choice1 = gds.ChoiceItem( "Single choice (radio)", [(16, "first choice"), (32, "second choice"), (64, "third choice")], radio=True, ).set_pos(col=1, colspan=2) choice2 = gds.ChoiceItem( "Single choice (combo)", [(16, "first choice"), (32, "second choice"), (64, "third choice")], ).set_pos(col=1, colspan=2) _eg = gds.EndGroup("A sub group") floatarray = gds.FloatArrayItem( "Float array", default=np.ones((50, 5), float), format=" %.2e " ).set_pos(col=1) floatarray2 = gds.FloatArrayItem( "Empty array", default=np.array([], float) ).set_pos(col=2) g0 = gds.BeginTabGroup("group") mchoice1 = gds.MultipleChoiceItem( "MC type 1", ["first choice", "second choice", "third choice"] ).vertical(2) mchoice2 = ( gds.ImageChoiceItem( "MC type 2", [ ("rect", "first choice", "gif.png"), ("ell", "second choice", "txt.png"), ("qcq", "third choice", "html.png"), ], ) .set_pos(col=1) .set_prop("display", icon="file.png") .set_prop("display", size=(32, 32)) ) mchoice3 = gds.MultipleChoiceItem( "MC type 3", [str(i) for i in range(10)] ).horizontal(2) eg0 = gds.EndTabGroup("group") integer_slider = gds.IntItem( "Integer (with slider)", default=5, min=-50, max=50, slider=True ) integer = gds.IntItem("Integer", default=5, min=3, max=60).set_pos(col=1) def test_all_features(): """Test all guidata item/group features""" old_mode = get_validation_mode() set_validation_mode(ValidationMode.STRICT) with execenv.context(accept_dialogs=True): with qt_app_context(): prm1 = Parameters() prm1.floatarray[:, 0] = np.linspace(-5, 5, 50) execenv.print(prm1) if prm1.edit(): prm1.edit() execenv.print(prm1) prm1.view() prm2 = Parameters.create(integer=59, string="Using `create`") assert prm2.integer == 59 print(prm2) try: # Try to set an unknown attribute using the `create` method: Parameters.create(unknown_attribute=42) except AttributeError: pass else: raise AssertionError("AttributeError not raised") execenv.print("OK") set_validation_mode(old_mode) if __name__ == "__main__": test_all_features() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_all_items.py0000644000175100017510000001456015114075001022455 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ All guidata DataItem objects demo A DataSet object is a set of parameters of various types (integer, float, boolean, string, etc.) which may be edited in a dialog box thanks to the 'edit' method. Parameters are defined by assigning DataItem objects to a DataSet class definition: each parameter type has its own DataItem class (IntItem for integers, FloatItem for floats, StringItem for strings, etc.) """ # guitest: show import atexit import datetime import shutil import tempfile from enum import Enum import numpy as np import guidata.dataset as gds from guidata.config import _ from guidata.env import execenv from guidata.qthelpers import qt_app_context # Creating temporary files and registering cleanup functions TEMPDIR = tempfile.mkdtemp(prefix="test_") atexit.register(shutil.rmtree, TEMPDIR) FILE_ETA = tempfile.NamedTemporaryFile(suffix=".eta", dir=TEMPDIR) atexit.register(FILE_ETA.close) FILE_CSV = tempfile.NamedTemporaryFile(suffix=".csv", dir=TEMPDIR) atexit.register(FILE_CSV.close) class ImageTypes(Enum): """Image types.""" #: Image filled with zeros ZEROS = _("Zeros") #: Empty image (filled with data from memory state) EMPTY = _("Empty") #: Image filled with random data (uniform law) UNIFORMRANDOM = _("Random (uniform law)") #: Image filled with random data (normal law) NORMALRANDOM = _("Random (normal law)") #: 2D Gaussian image GAUSS = _("Gaussian") #: Bilinear form image RAMP = _("2D ramp") class FilteringMethod(gds.LabeledEnum): """LabeledEnum example with invariant keys and UI labels.""" GAUSSIAN = "gaussian", _("Gaussian filter") MEDIAN = "median", _("Median filter") SOBEL = "sobel", _("Sobel edge detection") LAPLACIAN = "laplacian", _("Laplacian filter") BILATERAL = "bilateral", _("Bilateral filter") MORPHOLOGICAL = "morphological" # Uses key as label class ThresholdMode(gds.LabeledEnum): """Another LabeledEnum example.""" OTSU = "otsu_method", _("Otsu's method") ADAPTIVE = "adaptive_mean", _("Adaptive mean") GLOBAL = "global_threshold", _("Global threshold") TRIANGLE = "triangle_algorithm", _("Triangle algorithm") class Parameters(gds.DataSet): """ DataSet test The following text is the DataSet 'comment':
Plain text or rich text2 are both supported, as well as special characters (α, β, γ, δ, ...) """ dir = gds.DirectoryItem("Directory", default=TEMPDIR) preview = gds.TextItem("File names preview", default="-") option = gds.ChoiceItem("Option", (("1", "first choice"), ("2", "second choice"))) fname = gds.FileOpenItem("Open file", ("csv", "eta"), FILE_CSV.name) fnames = gds.FilesOpenItem("Open files", "csv", [FILE_CSV.name]) fname_s = gds.FileSaveItem("Save file", "eta", FILE_ETA.name) string = gds.StringItem("String", default="default string !?") text = gds.TextItem("Text", default="default\nmultiline\ntext") float_slider = gds.FloatItem( "Float (with slider)", default=0.5, min=0, max=1, step=0.01, slider=True ) integer = gds.IntItem("Integer", default=5, min=3, max=16, slider=True).set_pos( col=1 ) dtime = gds.DateTimeItem("Date/time", default=datetime.datetime(2010, 10, 10)) date = gds.DateItem("Date", default=datetime.date(2010, 10, 10)).set_pos(col=1) bool1 = gds.BoolItem("Boolean option without label") bool2 = gds.BoolItem("Boolean option with label", "Label") _bg = gds.BeginGroup("A sub group") color = gds.ColorItem("Color", default="red") choice1 = gds.ChoiceItem( "Single choice 1", [("16", "first choice"), ("32", "second choice"), ("64", "third choice")], ) choice2 = gds.ChoiceItem("Image type", ImageTypes, default=ImageTypes.GAUSS) # LabeledEnum examples - Pattern 1 with invariant keys and UI labels filter_method = gds.ChoiceItem( "Filtering method", FilteringMethod, default=FilteringMethod.GAUSSIAN ) threshold_mode = gds.ChoiceItem( "Threshold mode", ThresholdMode, default=ThresholdMode.OTSU ) mchoice2 = gds.ImageChoiceItem( "Single choice 2", [ ("rect", "first choice", "gif.png"), ("ell", "second choice", "txt.png"), ("qcq", "third choice", "file.png"), ], ) _eg = gds.EndGroup("A sub group") floatarray = gds.FloatArrayItem( "Float array", default=np.ones((50, 5), float), format=" %.2e " ).set_pos(col=1) mchoice3 = gds.MultipleChoiceItem( "MC type 1", [str(i) for i in range(12)] ).horizontal(4) mchoice1 = ( gds.MultipleChoiceItem( "MC type 2", ["first choice", "second choice", "third choice"] ) .vertical(1) .set_pos(col=1) ) dictionary = gds.DictItem( "Dictionary", default={ "lkl": 2, "tototo": 3, "zzzz": "lklk", "bool": True, "float": 1.234, "list": [1, 2.5, 3, "str", False, 5, {"lkl": 2, "l": [1, 2, 3]}], }, help="This is a dictionary", ) def test_all_items(): """Test all DataItem objects""" with qt_app_context(): prm = Parameters() prm.floatarray[:, 0] = np.linspace(-5, 5, 50) # Demonstrate LabeledEnum functionality execenv.print("=== LabeledEnum Pattern 1 Demo ===") execenv.print(f"Filter method (enum member): {prm.filter_method!r}") execenv.print(f"Filter value (invariant key): {prm.filter_method.value!r}") execenv.print(f"Filter label (UI display): {prm.filter_method.label!r}") execenv.print(f"Filter str(): {str(prm.filter_method)!r}") # Show different ways to set LabeledEnum values prm.filter_method = "sobel" # Set by invariant key execenv.print(f"Set by key 'sobel': {prm.filter_method!r}") prm.filter_method = "Median filter" # Set by UI label execenv.print(f"Set by label 'Median filter': {prm.filter_method!r}") prm.filter_method = 0 # Set by index execenv.print(f"Set by index 0: {prm.filter_method!r}") execenv.print("=== End LabeledEnum Demo ===") execenv.print(prm) if prm.edit(): execenv.print(prm) prm.view() execenv.print("OK") if __name__ == "__main__": test_all_items() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_all_items_readonly.py0000644000175100017510000000202015114075001024336 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Same as test_all_items.py but with readonly=True to check that the readonly mode works on all DataItem types. """ # guitest: show import numpy as np from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.tests.dataset.test_all_items import Parameters # check if array variable_size is ignored thanks to readonly Parameters.floatarray.set_prop("edit", variable_size=True) def test_all_features(): """Test all guidata item/group features""" with qt_app_context(): prm1 = Parameters(readonly=True) prm1.floatarray[:, 0] = np.linspace(-5, 5, 50) execenv.print(prm1) if prm1.edit(): prm1.edit() execenv.print(prm1) prm1.view() prm2 = Parameters.create(integer=10101010, string="Using create class method") print(prm2) execenv.print("OK") if __name__ == "__main__": test_all_features() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_auto_apply.py0000644000175100017510000001543215114075001022660 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Auto-apply functionality test This test verifies that DictItem and FloatArrayItem editors automatically trigger the apply action when used within a DataSetEditGroupBox context. """ # guitest: show import numpy as np import guidata.dataset as gds from guidata.dataset.qtwidgets import DataSetEditGroupBox from guidata.env import execenv from guidata.qthelpers import qt_app_context class AutoApplyDataSet(gds.DataSet): """Test dataset with DictItem and FloatArrayItem""" dictionary = gds.DictItem("Dictionary", default={"a": 1, "b": 2, "c": 3}) array = gds.FloatArrayItem("Array", default=np.array([[1, 2], [3, 4]])) string = gds.StringItem("String", default="test") class AutoApplySignalChecker: """Helper class to track signal emissions""" def __init__(self, groupbox: DataSetEditGroupBox): self.groupbox = groupbox self.signal_received = False groupbox.SIG_APPLY_BUTTON_CLICKED.connect(self.on_signal) def on_signal(self): """Signal handler""" self.signal_received = True execenv.print("Signal received: SIG_APPLY_BUTTON_CLICKED") def reset(self): """Reset the checker state""" self.signal_received = False def test_auto_apply_dictitem(): """Test that DictItem widget has auto-apply functionality""" with qt_app_context(): # Create the groupbox and signal checker groupbox = DataSetEditGroupBox("Test", AutoApplyDataSet) checker = AutoApplySignalChecker(groupbox) # Get the DictItem widget - widget.item is a DataItemVariable, # widget.item.item is the actual DataItem with the type/name info dict_widget = None for widget in groupbox.edit.widgets: if ( hasattr(widget, "item") and hasattr(widget.item, "item") and isinstance(widget.item.item, gds.DictItem) ): dict_widget = widget break assert dict_widget is not None, "DictItem widget not found" # Verify the widget has the _trigger_auto_apply method assert hasattr(dict_widget, "_trigger_auto_apply"), ( "DictItem widget missing _trigger_auto_apply method" ) # Call _trigger_auto_apply to simulate what the dictionary editor does dict_widget._trigger_auto_apply() # Process events to allow deferred execution from qtpy.QtWidgets import QApplication QApplication.processEvents() # Verify signal was received assert checker.signal_received, "Signal was not emitted after auto-apply" execenv.print("✓ DictItem auto-apply triggered signal") # Verify button is disabled after processing events assert not groupbox.apply_button.isEnabled(), ( "Apply button should be disabled after auto-apply" ) execenv.print("✓ Apply button is disabled after auto-apply") def test_auto_apply_floatarrayitem(): """Test that FloatArrayItem widget has auto-apply functionality""" with qt_app_context(): # Create the groupbox and signal checker groupbox = DataSetEditGroupBox("Test", AutoApplyDataSet) checker = AutoApplySignalChecker(groupbox) # Get the FloatArrayItem widget array_widget = None for widget in groupbox.edit.widgets: if ( hasattr(widget, "item") and hasattr(widget.item, "item") and isinstance(widget.item.item, gds.FloatArrayItem) ): array_widget = widget break assert array_widget is not None, "FloatArrayItem widget not found" # Verify the widget has the _trigger_auto_apply method assert hasattr(array_widget, "_trigger_auto_apply"), ( "FloatArrayItem widget missing _trigger_auto_apply method" ) # Call _trigger_auto_apply to simulate what the array editor does array_widget._trigger_auto_apply() # Process events to allow deferred execution from qtpy.QtWidgets import QApplication QApplication.processEvents() # Verify signal was received assert checker.signal_received, "Signal was not emitted after auto-apply" execenv.print("✓ FloatArrayItem auto-apply triggered signal") # Verify button is disabled after processing events assert not groupbox.apply_button.isEnabled(), ( "Apply button should be disabled after auto-apply" ) execenv.print("✓ Apply button is disabled after auto-apply") def test_auto_apply_widget_hierarchy(): """Test auto-apply works when DataSetEditGroupBox is in widget hierarchy""" with qt_app_context(): from qtpy.QtWidgets import QFrame, QStackedWidget, QTabWidget, QVBoxLayout # Create a complex widget hierarchy similar to DataLab's Properties panel tab_widget = QTabWidget() stacked = QStackedWidget() frame1 = QFrame() frame2 = QFrame() # Create the groupbox inside the hierarchy groupbox = DataSetEditGroupBox("Test", AutoApplyDataSet) checker = AutoApplySignalChecker(groupbox) # Build the hierarchy layout = QVBoxLayout() layout.addWidget(groupbox) frame2.setLayout(layout) frame1_layout = QVBoxLayout() frame1_layout.addWidget(frame2) frame1.setLayout(frame1_layout) stacked.addWidget(frame1) tab_widget.addTab(stacked, "Test Tab") # Get the widget and trigger auto-apply dict_widget = None for widget in groupbox.edit.widgets: if ( hasattr(widget, "item") and hasattr(widget.item, "item") and isinstance(widget.item.item, gds.DictItem) ): dict_widget = widget break assert dict_widget is not None, "DictItem widget not found" # Simulate dictionary update and auto-apply dict_widget._trigger_auto_apply() # Process events from qtpy.QtWidgets import QApplication QApplication.processEvents() # Verify it still works even with complex hierarchy assert checker.signal_received, "Signal was not emitted in complex hierarchy" assert not groupbox.apply_button.isEnabled(), ( "Apply button should be disabled after auto-apply" ) execenv.print("✓ Auto-apply works correctly in complex widget hierarchy") if __name__ == "__main__": # Run all tests test_auto_apply_dictitem() execenv.print("\n" + "=" * 80 + "\n") test_auto_apply_floatarrayitem() execenv.print("\n" + "=" * 80 + "\n") test_auto_apply_widget_hierarchy() execenv.print("\nAll tests passed!") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_bool_selector.py0000644000175100017510000000334415114075001023335 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ DataItem groups and group selection DataSet items may be included in groups (these items are then shown in group box widgets in the editing dialog box) and item groups may be enabled/disabled using one group parameter (a boolean item). """ # guitest: show import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context prop1 = gds.ValueProp(False) prop2 = gds.ValueProp(False) class Parameters(gds.DataSet): """ Group selection test Group selection example: """ g1 = gds.BeginGroup("group 1") enable1 = gds.BoolItem( "Enable parameter set #1", help="If disabled, the following parameters will be ignored", default=False, ).set_prop("display", store=prop1) prm11 = gds.FloatItem("Prm 1.1", default=0, min=0).set_prop("display", active=prop1) prm12 = gds.FloatItem("Prm 1.2", default=0.93).set_prop("display", active=prop1) _g1 = gds.EndGroup("group 1") g2 = gds.BeginGroup("group 2") enable2 = gds.BoolItem( "Enable parameter set #2", help="If disabled, the following parameters will be ignored", default=True, ).set_prop("display", store=prop2) prm21 = gds.FloatItem("Prm 2.1", default=0, min=0).set_prop("display", active=prop2) prm22 = gds.FloatItem("Prm 2.2", default=0.93).set_prop("display", active=prop2) _g2 = gds.EndGroup("group 2") def test_bool_selector(): """Test bool selector""" with qt_app_context(): prm = Parameters() prm.edit() execenv.print(prm) execenv.print("OK") if __name__ == "__main__": test_bool_selector() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_button_item.py0000644000175100017510000000515515114075001023035 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Button item test This item is tested separately from other items because it is special: contrary to other items, it is purely GUI oriented and has no sense in a non-GUI context. """ # guitest: show from __future__ import annotations import os.path as osp import re from qtpy import QtCore as QC from qtpy import QtWidgets as QW import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context def information_selectable(parent: QW.QWidget, title: str, text: str) -> None: """Show an information message box with selectable text. Dialog box is *not* modal.""" box = QW.QMessageBox(parent) box.setIcon(QW.QMessageBox.Information) box.setWindowTitle(title) if re.search(r"<[a-zA-Z/][^>]*>", text): box.setTextFormat(QC.Qt.RichText) box.setTextInteractionFlags(QC.Qt.TextBrowserInteraction) else: box.setTextFormat(QC.Qt.PlainText) box.setTextInteractionFlags( QC.Qt.TextSelectableByMouse | QC.Qt.TextSelectableByKeyboard ) box.setText(text) box.setStandardButtons(QW.QMessageBox.Close) box.setDefaultButton(QW.QMessageBox.Close) box.setWindowFlags(QC.Qt.Window) # This is necessary only on non-Windows platforms box.setModal(False) box.show() class Parameters(gds.DataSet): """ DataSet test The following text is the DataSet 'comment':
Plain text or rich text2 are both supported, as well as special characters (α, β, γ, δ, ...) """ def button_cb( dataset: Parameters, item: gds.ButtonItem, value: None, parent: QW.QWidget ) -> None: """Button callback""" execenv.print(f"Button clicked: {dataset}, {item}, {value}, {parent}") text = "
".join( [ f"Dataset: {'
' + '
'.join(str(dataset).splitlines())}", f"Item: {item}", f"Value: {value}", ] ) information_selectable(parent, "Button Clicked", text) dir = gds.DirectoryItem("Directory", osp.dirname(__file__)) pattern = gds.StringItem("File pattern", "*.py") button = gds.ButtonItem("Help", button_cb, "MessageBoxInformation").set_pos(col=1) preview = gds.TextItem("File names preview") def test_button_item(): """Test button item""" with qt_app_context(): prm = Parameters() execenv.print(prm) if prm.edit(): execenv.print(prm) if __name__ == "__main__": test_button_item() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_callbacks.py0000644000175100017510000000544115114075001022421 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Demonstrates how items may trigger callbacks when activated, or how the list of choices may be dynamically changed. """ # guitest: show import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context class Parameters(gds.DataSet): """Example dataset""" def cb_example(self, item, value): execenv.print("\nitem: ", item, "\nvalue:", value) if self.results is None: self.results = "" self.results += str(value) + "\n" execenv.print("results:", self.results) def update_x1plusx2(self, item, value): execenv.print("\nitem: ", item, "\nvalue:", value) if self.x1 is not None and self.x2 is not None: self.x1plusx2 = self.x1 + self.x2 else: self.x1plusx2 = None string = gds.StringItem("String", default="foobar").set_prop( "display", callback=cb_example ) grp1 = gds.BeginGroup("Group 1") x1 = gds.FloatItem("x1").set_prop("display", callback=update_x1plusx2) x2 = gds.FloatItem("x2").set_prop("display", callback=update_x1plusx2) _grp1 = gds.EndGroup("Group 1") grp2 = gds.BeginGroup("Group 2") x1plusx2 = gds.FloatItem("x1+x2").set_prop("display", active=False) _grp2 = gds.EndGroup("Group 2") boolean = gds.BoolItem("Boolean", default=True).set_prop( "display", callback=cb_example ) color = gds.ColorItem("Color", default="red").set_prop( "display", callback=cb_example ) def choices_callback(self, item, value): """Choices callback: this demonstrates how to dynamically change the list of choices... even if it is not very useful in this case Note that `None` is systematically added as the third element of the returned tuples: that is to ensure the compatibility between `ChoiceItem` and `ImageChoiceItem` (see `guidata.dataset.dataitems`) """ execenv.print(f"[choices_callback]: item={item}, value={value}") return [ (16, "first choice", None), (32, "second choice", None), (64, "third choice", None), ] choice = ( gds.ChoiceItem( "Single choice", choices_callback, default=64, ) .set_pos(col=1, colspan=2) .set_prop("display", callback=cb_example) ) results = gds.TextItem("Results").set_prop("display", callback=cb_example) def test_callbacks(): """Test callbacks""" with qt_app_context(): prm = Parameters() execenv.print(prm) if prm.edit(): execenv.print(prm) prm.view() execenv.print("OK") if __name__ == "__main__": test_callbacks() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_computed_items.py0000644000175100017510000000624115114075001023522 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Demonstrates how items may be computed based on other items' values. The computed items feature allows you to create read-only items whose values are automatically calculated using a method of the dataset. This is similar to computed properties in other frameworks. """ # guitest: show import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context class Parameters(gds.DataSet): """Example dataset with computed items""" def compute_sum(self) -> float: """Compute the sum of x1 and x2""" return self.x1 + self.x2 def compute_results(self) -> str: """Compute a results string based on current values""" sum_value = self.x1 + self.x2 return f"""Results: String: {self.string} Sum: {sum_value:.2f} Boolean: {self.boolean} Color: {self.color} Choice: {self.choice}""" string = gds.StringItem("String", default="foobar") x1 = gds.FloatItem("x1", default=2.0) x2 = gds.FloatItem("x2", default=3.0) # This item will be computed by calling the compute_sum method x1plusx2 = gds.FloatItem("x1+x2 (computed)").set_computed(compute_sum) boolean = gds.BoolItem("Boolean", default=True) color = gds.ColorItem("Color", default="red") choice = gds.ChoiceItem( "Single choice", (("First", "first"), ("Second", "second"), ("Third", "third")), default="first", ).set_pos(col=1, colspan=2) # This item will be computed by calling the compute_results method results = gds.TextItem("Results (computed)").set_computed(compute_results) def test_computed_items(): """Test computed items""" with qt_app_context(): prm = Parameters() # Test that computed items work execenv.print(f"x1: {prm.x1}") execenv.print(f"x2: {prm.x2}") execenv.print(f"x1+x2 (computed): {prm.x1plusx2}") execenv.print(f"Results (computed):\n{prm.results}") # Test that computed items are read-only try: prm.x1plusx2 = 10.0 raise Exception("Should not be able to set computed item!") except ValueError as e: execenv.print(f"✓ Correctly prevented setting computed item: {e}") # Test that computed items update when dependencies change prm.x1 = 5.0 prm.x2 = 7.0 execenv.print("\nAfter changing x1=5.0, x2=7.0:") execenv.print(f"x1+x2 (computed): {prm.x1plusx2}") execenv.print(f"Results (computed):\n{prm.results}") execenv.print("\n" + "=" * 60) execenv.print("REAL-TIME GUI UPDATE TEST:") execenv.print("When you edit values in the GUI, computed items should") execenv.print("update automatically in real-time as you type!") execenv.print("Try changing x1 and x2 values and watch the computed") execenv.print("fields update automatically.") execenv.print("=" * 60) execenv.print(prm) if prm.edit(): execenv.print(prm) prm.view() execenv.print("OK") if __name__ == "__main__": test_computed_items() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_datasetgroup.py0000644000175100017510000000226415114075001023204 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ DataSetGroup demo DataSet objects may be grouped into DataSetGroup, allowing them to be edited in a single dialog box (with one tab per DataSet object). This code tests both the normal dataset group mode and the table mode (with one tab per DataSet object). """ # guitest: show from guidata.dataset import DataSetGroup from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.tests.dataset.test_all_features import Parameters def test_dataset_group(): """Test DataSetGroup""" with qt_app_context(): e1 = Parameters("DataSet #1") e2 = Parameters("DataSet #2") g = DataSetGroup([e1, e2], title="Parameters group") g.edit() execenv.print(e1) g.edit() execenv.print("OK") g = DataSetGroup([e1, e2], title="Parameters group in table mode") g.edit(mode="table") execenv.print(e1) g.edit() execenv.print("OK") g.edit() execenv.print(e1) g.edit() execenv.print("OK") if __name__ == "__main__": test_dataset_group() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_editgroupbox.py0000644000175100017510000001545615114075001023224 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ DataSetEditGroupBox and DataSetShowGroupBox demo These group box widgets are intended to be integrated in a GUI application layout, showing read-only parameter sets or allowing to edit parameter values. """ # guitest: show import numpy as np from qtpy.QtWidgets import QMainWindow, QSplitter import guidata.dataset as gds from guidata.configtools import get_icon from guidata.dataset.qtwidgets import DataSetEditGroupBox, DataSetShowGroupBox from guidata.env import execenv from guidata.qthelpers import ( add_actions, create_action, get_std_icon, qt_app_context, win32_fix_title_bar_background, ) from guidata.tests.dataset.test_activable_dataset import Parameters from guidata.widgets import about class AnotherDataSet(gds.DataSet): """ Example 2 Simple dataset example """ param0 = gds.ChoiceItem("Choice", ["deazdazk", "aeazee", "87575757"]) param1 = gds.FloatItem("Foobar 1", default=0, min=0) a_group = gds.BeginGroup("A group") param2 = gds.FloatItem("Foobar 2", default=0.93) param3 = gds.FloatItem("Foobar 3", default=123) _a_group = gds.EndGroup("A group") class ExampleMultiGroupDataSet(gds.DataSet): """Example DS with multiple groups""" choices = gds.MultipleChoiceItem("Choices", ["a", "b", "c", "d", "e"]) dictionary = gds.DictItem("Dictionary", {"a": 1, "b": 2, "c": 3}) param0 = gds.ChoiceItem("Choice", ["deazdazk", "aeazee", "87575757"]) param1 = gds.FloatItem("Foobar 1", default=0, min=0) t_group = gds.BeginTabGroup("T group") a_group = gds.BeginGroup("A group") param2 = gds.FloatItem("Foobar 2", default=0.93) dir1 = gds.DirectoryItem("Directory 1") file1 = gds.FileOpenItem("File 1") _a_group = gds.EndGroup("A group") b_group = gds.BeginGroup("B group") param3 = gds.FloatItem("Foobar 3", default=123) param4 = gds.BoolItem("Boolean") _b_group = gds.EndGroup("B group") c_group = gds.BeginGroup("C group") param5 = gds.FloatItem("Foobar 4", default=250) param6 = gds.DateItem("Date").set_prop("display", format="dd.MM.yyyy") param7 = gds.ColorItem("Color") _c_group = gds.EndGroup("C group") _t_group = gds.EndTabGroup("T group") class OtherDataSet(gds.DataSet): """Another example dataset""" group1 = gds.BeginGroup("Group 1") title = gds.StringItem("Title", default="Title") icon = gds.ChoiceItem( "Icon", ( ("python.png", "Python"), ("guidata.svg", "guidata"), ("settings.png", "Settings"), ), ) opacity = gds.FloatItem("Opacity", default=1.0, min=0.1, max=1) transform = gds.FloatArrayItem("Transform", default=np.array([1, 2, 3, 4, 5, 6])) _group1 = gds.EndGroup("Group 1") group2 = gds.BeginGroup("Group 2") computed = gds.StringItem("Computed").set_computed( lambda ds: f"T: {ds.title}, I: {ds.icon}, O: {ds.opacity}" ) _group2 = gds.EndGroup("Group 2") class MainWindow(QMainWindow): """Main window""" def __init__(self): QMainWindow.__init__(self) win32_fix_title_bar_background(self) self.setWindowIcon(get_icon("python.png")) self.setWindowTitle("Application example") # Instantiate dataset-related widgets: self.groupbox1 = DataSetShowGroupBox( "Activable dataset", Parameters, comment="" ) self.groupbox2 = DataSetShowGroupBox( "Standard dataset", AnotherDataSet, comment="" ) self.groupbox3 = DataSetEditGroupBox( "Standard dataset", OtherDataSet, comment="" ) self.groupbox4 = DataSetEditGroupBox( "Standard dataset", ExampleMultiGroupDataSet, comment="" ) self.groupbox3.SIG_APPLY_BUTTON_CLICKED.connect(self.update_window) self.update_groupboxes() splitter = QSplitter(self) splitter.addWidget(self.groupbox1) splitter.addWidget(self.groupbox2) splitter.addWidget(self.groupbox3) splitter.addWidget(self.groupbox4) self.setCentralWidget(splitter) self.setContentsMargins(10, 5, 10, 5) # File menu file_menu = self.menuBar().addMenu("File") quit_action = create_action( self, "Quit", shortcut="Ctrl+Q", icon=get_std_icon("DialogCloseButton"), tip="Quit application", triggered=self.close, ) add_actions(file_menu, (quit_action,)) # Edit menu edit_menu = self.menuBar().addMenu("Edit") editparam1_action = create_action( self, "Edit dataset 1", triggered=self.edit_dataset1 ) editparam2_action = create_action( self, "Edit dataset 2", triggered=self.edit_dataset2 ) editparam4_action = create_action( self, "Edit dataset 4", triggered=self.edit_dataset4 ) ro_param4_action = create_action( self, "Dataset 4: read-only", toggled=self.ro_dataset4 ) add_actions( edit_menu, (editparam1_action, editparam2_action, editparam4_action, ro_param4_action), ) # ? menu help_menu = self.menuBar().addMenu("?") about_action = create_action( self, "About guidata", icon=get_std_icon("MessageBoxInformation"), triggered=about.show_about_dialog, ) add_actions(help_menu, (about_action,)) def update_window(self): """Update window""" dataset = self.groupbox3.dataset self.setWindowTitle(dataset.title) self.setWindowIcon(get_icon(dataset.icon)) self.setWindowOpacity(dataset.opacity) def update_groupboxes(self): """Update groupboxes""" self.groupbox1.dataset.set_activable(False) # This is an activable dataset self.groupbox1.get() self.groupbox2.get() self.groupbox4.get() def ro_dataset4(self, readonly: bool): self.groupbox4.dataset.set_readonly(readonly) self.groupbox4.get() def edit_dataset1(self): """Edit dataset 1""" self.groupbox1.dataset.set_activable(True) # This is an activable dataset if self.groupbox1.dataset.edit(self): self.update_groupboxes() def edit_dataset2(self): """Edit dataset 2""" if self.groupbox2.dataset.edit(self): self.update_groupboxes() def edit_dataset4(self): """Edit dataset 4""" if self.groupbox4.dataset.edit(self): self.update_groupboxes() def test_editgroupbox(): """Test editgroupbox""" with qt_app_context(exec_loop=True): window = MainWindow() window.show() execenv.print("OK") if __name__ == "__main__": test_editgroupbox() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_inheritance.py0000644000175100017510000000444015114075001022771 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ DataSet objects inheritance test From time to time, it may be useful to derive a DataSet from another. The main application is to extend a parameter set with additionnal parameters. """ # guitest: show import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context class OriginalDataset1(gds.DataSet): """Original dataset This is the original dataset""" text1 = gds.TextItem("Text 1") int1 = gds.IntItem("Integer 1", default=111) class DerivedDataset(OriginalDataset1): """Derived dataset This is the derived dataset""" bool = gds.BoolItem("Boolean (modified in derived dataset)") a = gds.FloatItem("Level 1 (added in derived dataset)", default=0) b = gds.FloatItem("Level 2 (added in derived dataset)", default=0) c = gds.FloatItem("Level 3 (added in derived dataset)", default=0) def test_inheritance(): """Test DataSet inheritance""" with qt_app_context(): e = OriginalDataset1() e.edit() execenv.print(e) e = DerivedDataset() e.edit() execenv.print(e) execenv.print("OK") class OriginalDataset2(gds.DataSet): """Original dataset 2 This is another original dataset""" text2 = gds.TextItem("Text 2") int2 = gds.IntItem("Integer 2", default=222) class DoubleInheritedDataset1(OriginalDataset1, OriginalDataset2): """Double inherited dataset This is a dataset that inherits from two original datasets""" text3 = gds.TextItem("Text 3") int3 = gds.IntItem("Integer 3", default=333) class DoubleInheritedDataset2(OriginalDataset2, OriginalDataset1): """Double inherited dataset 2 This is a dataset that inherits from two original datasets in reverse order""" text4 = gds.TextItem("Text 4") int4 = gds.IntItem("Integer 4", default=444) def test_double_inheritance(): """Test DataSet double inheritance""" with qt_app_context(): e = DoubleInheritedDataset1() e.edit() execenv.print(e) e = DoubleInheritedDataset2() e.edit() execenv.print(e) execenv.print("OK") if __name__ == "__main__": test_double_inheritance() test_inheritance() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_item_order.py0000644000175100017510000001060715114075001022633 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ DataSet item order test From time to time, it may be useful to change the item order, for example when deriving a dataset from another. """ # guitest: show import guidata.dataset as gds from guidata.env import execenv from guidata.qthelpers import qt_app_context class OriginalDataset(gds.DataSet): """Original dataset This is the original dataset""" param1 = gds.BoolItem("P1 | Boolean") param2 = gds.StringItem("P2 | String") param3 = gds.TextItem("P3 | Text") param4 = gds.FloatItem("P4 | Float", default=0) class DerivedDataset(OriginalDataset): """Derived dataset This is the derived dataset, with modified item order""" param5 = gds.IntItem("P5 | Int", default=0).set_pos(row=2) param6 = gds.DateItem("P6 | Date", default=0).set_pos(row=4) def test_item_order(): """Test DataSet item order""" with qt_app_context(): e = OriginalDataset() e.edit() execenv.print(e) e = DerivedDataset() e.edit() execenv.print(e) execenv.print("OK") class BaseDataset(gds.DataSet): """Base dataset for inheritance ordering test""" base_param1 = gds.StringItem("Base Param 1", default="base1") base_param2 = gds.StringItem("Base Param 2", default="base2") class MiddleDataset(BaseDataset): """Middle dataset that overrides a base parameter""" base_param1 = gds.StringItem("Base Param 1", default="overridden") # Override middle_param = gds.FloatItem("Middle Param", default=1.0) class ChildDataset(MiddleDataset): """Child dataset that adds more parameters""" child_param1 = gds.IntItem("Child Param 1", default=42) child_param2 = gds.BoolItem("Child Param 2", default=True) def test_inheritance_item_ordering_and_overrides(): """Test that DataSet inheritance preserves correct item ordering and overrides This test ensures that: 1. Parent class items appear before child class items in the final dataset 2. Attribute redefinition in intermediate classes properly overrides parent values 3. Child classes inherit the overridden values, not the original parent values This prevents regression of the inheritance bug where child classes would get the original grandparent values instead of the redefined parent values. """ # Test the child dataset child = ChildDataset() # Get the item names in order item_names = [item._name for item in child._items] # Verify ordering: BaseDataset items first, then MiddleDataset items, # then ChildDataset items expected_order = [ "base_param1", # From BaseDataset (but overridden by MiddleDataset) "base_param2", # From BaseDataset "middle_param", # From MiddleDataset "child_param1", # From ChildDataset "child_param2", # From ChildDataset ] # Assert item ordering is correct assert item_names == expected_order, ( f"Item ordering is incorrect. Expected: {expected_order}, Got: {item_names}" ) # Verify that override values are correctly inherited assert child.base_param1 == "overridden", ( f"Override inheritance failed. Expected 'overridden', got '{child.base_param1}'" ) assert child.base_param2 == "base2", ( f"Normal inheritance failed. Expected 'base2', got '{child.base_param2}'" ) assert child.middle_param == 1.0, ( f"Middle class value failed. Expected 1.0, got {child.middle_param}" ) # Test that direct middle class also has correct values middle = MiddleDataset() assert middle.base_param1 == "overridden", ( f"Middle class override failed. Expected 'overridden', " f"got '{middle.base_param1}'" ) assert middle.base_param2 == "base2", ( f"Middle class inheritance failed. Expected 'base2', got '{middle.base_param2}'" ) # Test that base class has original values base = BaseDataset() assert base.base_param1 == "base1", ( f"Base class value changed. Expected 'base1', got '{base.base_param1}'" ) assert base.base_param2 == "base2", ( f"Base class value changed. Expected 'base2', got '{base.base_param2}'" ) execenv.print("Inheritance item ordering and overrides test: OK") if __name__ == "__main__": test_inheritance_item_ordering_and_overrides() test_item_order() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_loadsave_hdf5.py0000644000175100017510000000261415114075001023205 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ HDF5 I/O demo DataSet objects may be saved in HDF5 files, a universal hierarchical dataset file type. This script shows how to save in and then reload data from a HDF5 file. """ # guitest: show import os.path as osp import tempfile from guidata.dataset import assert_datasets_equal from guidata.env import execenv from guidata.io import HDF5Reader, HDF5Writer from guidata.qthelpers import qt_app_context from guidata.tests.dataset.test_all_items import Parameters def test_loadsave_hdf5(): """Test HDF5 I/O""" with tempfile.TemporaryDirectory() as temp_dir: fname = osp.join(temp_dir, "test.h5") with qt_app_context(): p1 = Parameters() # p1.edit() # Save to HDF5 file writer = HDF5Writer(fname) p1.serialize(writer) writer.close() p2 = Parameters() # Set all items to None for testing purposes: for item in p2._items: item.__set__(p2, None) # Load from HDF5 file reader = HDF5Reader(fname) p2.deserialize(reader) reader.close() assert_datasets_equal(p1, p2, "Parameters do not match after HDF5 I/O") execenv.print("OK") if __name__ == "__main__": test_loadsave_hdf5() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_loadsave_json.py0000644000175100017510000000234115114075001023325 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ JSON I/O demo DataSet objects may be saved in JSON files. This script shows how to save in and then reload data from a JSON file. """ # guitest: show import os from guidata.env import execenv from guidata.io import JSONReader, JSONWriter from guidata.qthelpers import qt_app_context from guidata.tests.dataset.test_all_items import Parameters def test_loadsave_json(): """Test JSON I/O""" fname = "test.json" with qt_app_context(): if os.path.exists(fname): os.unlink(fname) p1 = Parameters() if execenv.unattended or p1.edit(): writer = JSONWriter(fname) p1.serialize(writer) writer.save() p2 = Parameters() reader = JSONReader(fname) p2.deserialize(reader) reader.close() p2.edit() os.unlink(fname) # TODO: Uncomment this part of the test, and make it work! # if execenv.unattended: # assert_datasets_equal(p1, p2, "Parameters do not match after HDF5 I/O") execenv.print("OK") if __name__ == "__main__": test_loadsave_json() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_rotatedlabel.py0000644000175100017510000000243515114075001023144 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ RotatedLabel test RotatedLabel is derived from QLabel: it provides rotated text display. """ # guitest: show from qtpy.QtCore import Qt from qtpy.QtWidgets import QFrame, QGridLayout from guidata.env import execenv from guidata.qthelpers import qt_app_context, win32_fix_title_bar_background from guidata.widgets.rotatedlabel import RotatedLabel class Frame(QFrame): """Test frame""" def __init__(self, parent=None): QFrame.__init__(self, parent) win32_fix_title_bar_background(self) layout = QGridLayout() self.setLayout(layout) angle = 0 for row in range(7): for column in range(7): layout.addWidget( RotatedLabel( "Label %03d°" % angle, angle=angle, color=Qt.blue, bold=False ), row, column, Qt.AlignCenter, ) angle += 10 def test_rotatedlabel(): """Test RotatedLabel""" with qt_app_context(exec_loop=True): frame = Frame() frame.show() execenv.print("OK") if __name__ == "__main__": test_rotatedlabel() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/dataset/test_separator_item_trailing.py0000644000175100017510000001575315114075001025420 0ustar00runnerrunner#!/usr/bin/env python3 """ Test script for SeparatorItem trailing separator filtering functionality """ import pytest from guidata.dataset import DataSet, IntItem, SeparatorItem, StringItem class DataSetNormal(DataSet): """Dataset with separator not at the end""" name = StringItem("Name", default="test") separator1 = SeparatorItem("sep1") value = IntItem("Value", default=42) separator2 = SeparatorItem("sep2") another_value = IntItem("Another Value", default=100) class DataSetWithTrailingSeparator(DataSet): """Dataset with trailing separators""" name = StringItem("Name", default="test") separator1 = SeparatorItem("sep1") value = IntItem("Value", default=42) separator2 = SeparatorItem("sep2") separator3 = SeparatorItem("sep3") # Trailing separator class DataSetOnlySeparators(DataSet): """Dataset with only separators""" separator1 = SeparatorItem("sep1") separator2 = SeparatorItem("sep2") separator3 = SeparatorItem("sep3") class DataSetMultipleTrailingSeparators(DataSet): """Dataset with multiple trailing separators""" name = StringItem("Name", default="test") separator1 = SeparatorItem("sep1") separator2 = SeparatorItem("sep2") separator3 = SeparatorItem("sep3") separator4 = SeparatorItem("sep4") class DataSetEmpty(DataSet): """Empty dataset""" pass def test_normal_dataset_no_trailing_separator(): """Test dataset with separator not at the end - no filtering should occur""" ds = DataSetNormal() items = ds.get_items() item_names = [item._name for item in items] # Should have all items since separator is not trailing assert len(items) == 5 assert item_names == ["name", "separator1", "value", "separator2", "another_value"] # Check string representation string_repr = str(ds) assert "sep1: -" in string_repr assert "sep2: -" in string_repr assert "Another Value: 100" in string_repr def test_trailing_separator_filtering(): """Test dataset with trailing separators - they should be filtered out""" ds = DataSetWithTrailingSeparator() items = ds.get_items() item_names = [item._name for item in items] # Should have 5 items (trailing separators NOT filtered in get_items()) assert len(items) == 5 assert item_names == ["name", "separator1", "value", "separator2", "separator3"] assert items[-1]._name == "separator3" # get_items() includes trailing separators # Check string representation doesn't include trailing separators string_repr = str(ds) assert "sep1: -" in string_repr assert "sep2: -" not in string_repr # Trailing separator should not appear assert "sep3: -" not in string_repr # Trailing separator should not appear def test_only_separators_dataset(): """Test dataset with only separators - all should be filtered out""" ds = DataSetOnlySeparators() items = ds.get_items() # Should have 3 items (separators NOT filtered in get_items()) assert len(items) == 3 # Check string representation string_repr = str(ds) lines = string_repr.strip().split("\n") # Should only contain the dataset title assert len(lines) == 1 assert "Dataset with only separators:" in lines[0] def test_multiple_trailing_separators(): """Test dataset with multiple trailing separators - all should be filtered""" ds = DataSetMultipleTrailingSeparators() items = ds.get_items() item_names = [item._name for item in items] # Should have 5 items (trailing separators NOT filtered in get_items()) assert len(items) == 5 expected_names = ["name", "separator1", "separator2", "separator3", "separator4"] assert item_names == expected_names # Check string representation string_repr = str(ds) assert "sep1: -" not in string_repr # All separators should be filtered assert "sep2: -" not in string_repr assert "sep3: -" not in string_repr assert "sep4: -" not in string_repr def test_empty_dataset(): """Test empty dataset""" ds = DataSetEmpty() items = ds.get_items() # Should have 0 items assert len(items) == 0 # Check string representation string_repr = str(ds) lines = string_repr.strip().split("\n") assert len(lines) == 1 assert "Empty dataset:" in lines[0] def test_get_items_copy_behavior(): """Test that the copy parameter works correctly with filtering""" ds = DataSetWithTrailingSeparator() # Test without copy items1 = ds.get_items(copy=False) items2 = ds.get_items(copy=False) # Both should have 5 items (no filtering in get_items()) assert len(items1) == 5 assert len(items2) == 5 # Test with copy items3 = ds.get_items(copy=True) items4 = ds.get_items(copy=True) # Both should have 5 items (no filtering in get_items()) assert len(items3) == 5 assert len(items4) == 5 # Copied items should be different objects assert items3 is not items4 assert items3[0] is not items4[0] def test_string_representation_consistency(): """Test that string representation is consistent with get_items filtering""" ds = DataSetWithTrailingSeparator() items = ds.get_items() string_repr = str(ds) # Count the number of items in string representation lines = [line.strip() for line in string_repr.split("\n") if ":" in line] # Remove the dataset title line data_lines = [line for line in lines if not line.endswith(":")] # String representation should have fewer lines due to trailing separator filtering # get_items() returns 5 items, but string representation should show only 3 lines assert len(items) == 5 # get_items includes trailing separators assert len(data_lines) == 3 # but string representation filters them def test_gui_trailing_separator_filtering(): """Test that GUI widgets filter trailing separators correctly""" try: from guidata.dataset.qtwidgets import DataSetEditGroupBox, DataSetShowGroupBox from guidata.qthelpers import qt_app_context with qt_app_context(): # Test the edit widget dataset = DataSetWithTrailingSeparator() edit_group = DataSetEditGroupBox("Edit Test", DataSetWithTrailingSeparator) edit_group.instance = dataset show_group = DataSetShowGroupBox("Show Test", DataSetWithTrailingSeparator) show_group.instance = dataset # Count the number of child widgets that are actual item widgets edit_widgets = [w for w in edit_group.edit.widgets if hasattr(w, "item")] show_widgets = [w for w in show_group.edit.widgets if hasattr(w, "item")] # Should have 3 widgets (name, separator1, value) # - trailing separators filtered expected_count = 3 assert len(edit_widgets) == expected_count assert len(show_widgets) == expected_count except ImportError: # Skip GUI tests if Qt is not available pytest.skip("Qt not available for GUI testing") if __name__ == "__main__": pytest.main([__file__]) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6548858 guidata-3.13.4/guidata/tests/unit/0000755000175100017510000000000015114075015016424 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/__init__.py0000644000175100017510000000000015114075001020516 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_assert_datasets_equal.py0000644000175100017510000000172515114075001024415 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Regression test for DataSet equality/identity semantics. This test ensures DataSet does NOT implement value-based __eq__ (identity only) and remains hashable, so instances can be used as dict/set keys. It guards against reintroducing __eq__, which would implicitly disable __hash__ and break collections. For value comparisons in tests, use guidata.testing.assert_datasets_equal. """ import guidata.dataset as gds def test_dataset_identity_semantics(): """Test identity semantics for datasets""" class _DS(gds.DataSet): a = gds.IntItem("a", default=1) d1, d2 = _DS.create(a=1), _DS.create(a=1) # identity equality assert d1 != d2 assert d1 == d1 # hashable (usable as dict/set keys) s = {d1, d2} assert d1 in s and d2 in s assert type(_DS.__eq__) is type(object.__eq__) assert _DS.__hash__ is object.__hash__ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_boolitem_numpy.py0000644000175100017510000000663415114075001023103 0ustar00runnerrunner"""Test BoolItem with numpy bool types This test ensures that BoolItem properly converts numpy.bool_ values to Python bool, which is necessary for compatibility with Qt APIs and other code that strictly requires Python bool type. """ import os import tempfile import numpy as np import pytest import guidata.dataset as gds from guidata.io import HDF5Reader, HDF5Writer class BoolDataSet(gds.DataSet): """Test dataset with boolean items""" bool_true = gds.BoolItem("Boolean True", default=True) bool_false = gds.BoolItem("Boolean False", default=False) bool_none = gds.BoolItem("Boolean None", default=None, allow_none=True) class TestBoolItemNumpy: """Test BoolItem with numpy bool types""" def test_numpy_bool_assignment(self): """Test that assigning numpy.bool_ is converted to Python bool""" ds = BoolDataSet() # Test True ds.bool_true = np.bool_(True) assert ds.bool_true is True assert type(ds.bool_true) is bool # Test False ds.bool_false = np.bool_(False) assert ds.bool_false is False assert type(ds.bool_false) is bool def test_python_bool_assignment(self): """Test that assigning Python bool still works""" ds = BoolDataSet() # Test True ds.bool_true = True assert ds.bool_true is True assert type(ds.bool_true) is bool # Test False ds.bool_false = False assert ds.bool_false is False assert type(ds.bool_false) is bool def test_none_assignment(self): """Test that None assignment works when allow_none=True""" ds = BoolDataSet() ds.bool_none = None assert ds.bool_none is None def test_hdf5_serialization(self): """Test that HDF5 serialization/deserialization maintains Python bool type""" ds = BoolDataSet() ds.bool_true = True ds.bool_false = False with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as tmp: tmp_path = tmp.name try: # Write with HDF5Writer(tmp_path) as writer: writer.write(ds, group_name="test") # Read back ds2 = BoolDataSet() with HDF5Reader(tmp_path) as reader: reader.read("test", instance=ds2) # Verify types assert type(ds2.bool_true) is bool assert ds2.bool_true is True assert type(ds2.bool_false) is bool assert ds2.bool_false is False finally: os.unlink(tmp_path) def test_numpy_bool_after_deserialization(self): """Test that numpy.bool_ assignment works after HDF5 deserialization""" ds = BoolDataSet() ds.bool_true = True with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as tmp: tmp_path = tmp.name try: # Write and read with HDF5Writer(tmp_path) as writer: writer.write(ds, group_name="test") ds2 = BoolDataSet() with HDF5Reader(tmp_path) as reader: reader.read("test", instance=ds2) # Now assign numpy.bool_ and verify it's converted ds2.bool_true = np.bool_(False) assert type(ds2.bool_true) is bool assert ds2.bool_true is False finally: os.unlink(tmp_path) if __name__ == "__main__": pytest.main([__file__, "-v"]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_choice_tuple_serialization.py0000644000175100017510000000362415114075001025435 0ustar00runnerrunner"""Test ChoiceItem serialization with tuple values. This is a regression test for the tuple/list issue in JSON serialization. """ import guidata.dataset as gds class TupleChoiceParam(gds.DataSet): """Test parameter with tuple choices.""" reference_levels = gds.ChoiceItem( "Reference levels", [ ((5, 95), "5% - 95%"), ((10, 90), "10% - 90%"), ((20, 80), "20% - 80%"), ], default=(10, 90), ) def test_choice_item_tuple_serialization(): """Test that ChoiceItem with tuple values survives JSON serialization. This is a regression test for the issue where JSON serialization converts tuples to lists, which then fail validation when deserializing. """ # Create a parameter with a tuple choice param = TupleChoiceParam() assert param.reference_levels == (10, 90) # Serialize to JSON json_str = gds.dataset_to_json(param) # Deserialize from JSON # This should work even though JSON converts tuples to lists param2 = gds.json_to_dataset(json_str) # Verify the value is restored correctly # Note: After deserialization, it might be a list, but validation should accept it assert param2.reference_levels == [10, 90] or param2.reference_levels == (10, 90) def test_choice_item_tuple_vs_list_validation(): """Test that ChoiceItem validation accepts tuples and lists with same content.""" param = TupleChoiceParam() # Setting with a tuple should work param.reference_levels = (20, 80) assert param.reference_levels == (20, 80) # Setting with a list with same content should also work (after our fix) param.reference_levels = [10, 90] assert param.reference_levels == [10, 90] or param.reference_levels == (10, 90) if __name__ == "__main__": test_choice_item_tuple_serialization() test_choice_item_tuple_vs_list_validation() print("All tests passed!") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_config.py0000644000175100017510000000200415114075001021271 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Config test""" import pytest from guidata.config import UserConfig from guidata.tests.dataset.test_all_features import Parameters @pytest.fixture() def config(): """Create a config object""" CONF = UserConfig({}) eta = Parameters() eta.write_config(CONF, "TestParameters", "") yield CONF def test_load(config): """Test load config""" eta = Parameters() eta.read_config(config, "TestParameters", "") def test_default(config): """Test default config""" eta = Parameters() eta.write_config(config, "etagere2", "") eta = Parameters() eta.read_config(config, "etagere2", "") def test_restore(config): """Test restore config""" eta = Parameters() eta.fl2 = 2 eta.integer = 6 eta.write_config(config, "etagere3", "") eta = Parameters() eta.read_config(config, "etagere3", "") assert eta.fl2 == 2.0 assert eta.integer == 6 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_data.py0000644000175100017510000003346115114075001020750 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Unit tests""" import enum import unittest import guidata.dataset as gds from guidata.config import _ from guidata.dataset.conv import update_dataset from guidata.env import execenv class Fruit(enum.Enum): """Example enumeration""" apple = _("An apple") banana = _("A banana") cherry = _("A cherry") class InterpolationMethod(gds.LabeledEnum): """Example LabeledEnum with invariant keys and translated labels""" LINEAR = "linear", _("Linear interpolation") SPLINE = "spline", _("Spline interpolation") PCHIP = "pchip", _("PCHIP interpolation") NEAREST = "nearest" # Uses key as label class ProcessingMode(gds.LabeledEnum): """Another LabeledEnum example for testing""" FAST = "fast_mode", _("Fast processing") ACCURATE = "accurate_mode", _("Accurate processing") BALANCED = "balanced_mode", _("Balanced processing") CHOICE_DEFAULT = Fruit.banana class Parameters(gds.DataSet): """Example dataset""" float1 = gds.FloatItem("float #1", default=150.0, min=1.0, max=250.0) float2 = gds.FloatItem("float #2", default=200.0, min=1.0, max=250.0) number = gds.IntItem("number", default=5, min=3, max=20) string = gds.StringItem("string", default="default string", help="a string item") fruit1 = gds.ChoiceItem("fruit", choices=Fruit, default=CHOICE_DEFAULT) fruit2 = gds.ChoiceItem("fruit", choices=Fruit) fruit3 = gds.ChoiceItem("fruit", choices=Fruit, default=None) # LabeledEnum examples interpolation = gds.ChoiceItem( "interpolation method", choices=InterpolationMethod, default=InterpolationMethod.LINEAR, ) processing_mode = gds.ChoiceItem( "processing mode", choices=ProcessingMode, default=ProcessingMode.BALANCED ) class TestCheck(unittest.TestCase): def test_range(self): """Test range checking of FloatItem""" e = Parameters() e.float1 = 150.0 e.float2 = 400.0 e.number = 4 e.fruit3 = CHOICE_DEFAULT # Avoid None value (error) e.interpolation = InterpolationMethod.LINEAR # Avoid None value (error) errors = e.check() self.assertEqual(errors, ["float2"]) def test_typechecking(self): """Test type checking of FloatItem""" e = Parameters() e.string = 400 e.number = 4.0 e.fruit3 = CHOICE_DEFAULT # Avoid None value (error) e.interpolation = InterpolationMethod.LINEAR # Avoid None value (error) errors = e.check() self.assertEqual(errors, ["number", "string"]) def test_update(self): """Test dataset update""" e1 = Parameters() e2 = Parameters() e1.float1 = 23 update_dataset(e2, e1) self.assertEqual(e2.float1, 23) def test_choice_item_default(self): """Test choice item default values""" e = Parameters() # Check default value: self.assertEqual(e.fruit1, CHOICE_DEFAULT) self.assertIs(e.fruit2, Fruit.apple) # First choice by default self.assertIsNone(e.fruit3) # Default is None, value is None def test_choice_item_set(self): """Test choice item setting values""" e = Parameters() # Test standard values e.fruit1 = Fruit.cherry e.fruit2 = Fruit.banana e.fruit3 = Fruit.apple self.assertEqual(e.fruit1, Fruit.cherry) self.assertEqual(e.fruit2, Fruit.banana) self.assertEqual(e.fruit3, Fruit.apple) # Test index assignment e.fruit1 = 2 e.fruit2 = 1 e.fruit3 = 0 self.assertEqual(e.fruit1, Fruit.cherry) self.assertEqual(e.fruit2, Fruit.banana) self.assertEqual(e.fruit3, Fruit.apple) # Test names e.fruit1 = "cherry" e.fruit2 = "banana" e.fruit3 = "apple" self.assertEqual(e.fruit1, Fruit.cherry) self.assertEqual(e.fruit2, Fruit.banana) self.assertEqual(e.fruit3, Fruit.apple) def test_choice_item_invalid(self): """Test choice item invalid values""" e = Parameters() with self.assertRaises(ValueError): e.fruit1 = "invalid" with self.assertRaises(ValueError): e.fruit2 = 2.3 def test_labeled_enum_structure(self): """Test LabeledEnum structure and properties""" # Test that LabeledEnum has correct structure self.assertEqual(InterpolationMethod.LINEAR.value, "linear") self.assertEqual(InterpolationMethod.LINEAR.label, "Linear interpolation") self.assertEqual(str(InterpolationMethod.LINEAR), "Linear interpolation") # Test enum without explicit label uses value as label self.assertEqual(InterpolationMethod.NEAREST.value, "nearest") self.assertEqual(InterpolationMethod.NEAREST.label, "nearest") self.assertEqual(str(InterpolationMethod.NEAREST), "nearest") def test_labeled_enum_choice_item_default(self): """Test LabeledEnum choice item default values""" e = Parameters() # Check default values self.assertEqual(e.interpolation, InterpolationMethod.LINEAR) self.assertEqual(e.processing_mode, ProcessingMode.BALANCED) # Check that we get back enum members, not raw strings self.assertIsInstance(e.interpolation, InterpolationMethod) self.assertIsInstance(e.processing_mode, ProcessingMode) def test_labeled_enum_choice_item_set(self): """Test LabeledEnum choice item setting values with different input types""" e = Parameters() # Test setting with enum members e.interpolation = InterpolationMethod.SPLINE self.assertEqual(e.interpolation, InterpolationMethod.SPLINE) # Test setting with name strings e.interpolation = "PCHIP" self.assertEqual(e.interpolation, InterpolationMethod.PCHIP) # Test setting with value strings (invariant keys) e.interpolation = "linear" self.assertEqual(e.interpolation, InterpolationMethod.LINEAR) # Test setting with label strings (UI display) e.interpolation = "Spline interpolation" self.assertEqual(e.interpolation, InterpolationMethod.SPLINE) # Test setting with indices e.interpolation = 0 # LINEAR self.assertEqual(e.interpolation, InterpolationMethod.LINEAR) e.interpolation = 1 # SPLINE self.assertEqual(e.interpolation, InterpolationMethod.SPLINE) def test_labeled_enum_internal_storage(self): """Test that LabeledEnum items store the name internally""" e = Parameters() e.interpolation = InterpolationMethod.SPLINE # get_value should return the raw storage key (name) choice_item = Parameters.interpolation internal_value = choice_item.get_value(e) self.assertEqual(internal_value, "SPLINE") # But __get__ should return the enum member self.assertEqual(e.interpolation, InterpolationMethod.SPLINE) self.assertEqual(e.interpolation.value, "spline") self.assertEqual(e.interpolation.label, "Spline interpolation") def test_labeled_enum_choices_structure(self): """Test that LabeledEnum generates correct choices structure""" choice_item = Parameters.interpolation choices = choice_item.get_prop("data", "choices") # Should be [(name, label, None), ...] expected_choices = [ ("LINEAR", "Linear interpolation", None), ("SPLINE", "Spline interpolation", None), ("PCHIP", "PCHIP interpolation", None), ("NEAREST", "nearest", None), # Uses key as label ] self.assertEqual(choices, expected_choices) def test_labeled_enum_invalid_values(self): """Test that invalid LabeledEnum values raise appropriate errors""" e = Parameters() with self.assertRaises(ValueError) as cm: e.interpolation = "invalid_method" self.assertIn("Invalid value 'invalid_method'", str(cm.exception)) self.assertIn("InterpolationMethod", str(cm.exception)) with self.assertRaises(ValueError): e.interpolation = 99 # Invalid index with self.assertRaises(ValueError): e.interpolation = 3.14 # Invalid type def test_labeled_enum_string_interoperability(self): """Test seamless interoperability between LabeledEnum members and strings""" # Test basic equality self.assertTrue(InterpolationMethod.LINEAR == "linear") self.assertTrue("linear" == InterpolationMethod.LINEAR) self.assertTrue(InterpolationMethod.SPLINE == "spline") self.assertFalse(InterpolationMethod.LINEAR == "spline") self.assertFalse(InterpolationMethod.LINEAR == "invalid") # Test single-value enum (where value == label) self.assertTrue(InterpolationMethod.NEAREST == "nearest") self.assertTrue("nearest" == InterpolationMethod.NEAREST) # Test ProcessingMode enum self.assertTrue(ProcessingMode.FAST == "fast_mode") self.assertTrue("fast_mode" == ProcessingMode.FAST) def test_labeled_enum_function_interoperability(self): """Test that functions can accept both enum members and their string values""" def process_interpolation(method): """Function that should work with both enum and string""" if method == InterpolationMethod.LINEAR: return "linear_processing" elif method == InterpolationMethod.SPLINE: return "spline_processing" elif method == InterpolationMethod.PCHIP: return "pchip_processing" elif method == InterpolationMethod.NEAREST: return "nearest_processing" else: return "unknown" # Test with enum members result = process_interpolation(InterpolationMethod.LINEAR) self.assertEqual(result, "linear_processing") result = process_interpolation(InterpolationMethod.SPLINE) self.assertEqual(result, "spline_processing") result = process_interpolation(InterpolationMethod.NEAREST) self.assertEqual(result, "nearest_processing") # Test with string values - should work identically self.assertEqual(process_interpolation("linear"), "linear_processing") self.assertEqual(process_interpolation("spline"), "spline_processing") self.assertEqual(process_interpolation("nearest"), "nearest_processing") # Test ProcessingMode def process_mode(mode): if mode == ProcessingMode.FAST: return "fast_result" elif mode == ProcessingMode.ACCURATE: return "accurate_result" else: return "unknown" self.assertEqual(process_mode(ProcessingMode.FAST), "fast_result") self.assertEqual(process_mode("fast_mode"), "fast_result") def test_labeled_enum_set_operations(self): """Test that enum members and strings deduplicate correctly in sets""" # Test set deduplication mixed_set = {InterpolationMethod.LINEAR, "linear", InterpolationMethod.SPLINE} self.assertEqual(len(mixed_set), 2) # Should deduplicate enum and string # Test with more complex case mode_set = { ProcessingMode.FAST, "fast_mode", ProcessingMode.ACCURATE, "accurate_mode", ProcessingMode.BALANCED, } self.assertEqual(len(mode_set), 3) # Should deduplicate the two FAST entries # Test that correct values are in set self.assertIn(InterpolationMethod.LINEAR, mixed_set) self.assertIn("linear", mixed_set) self.assertIn(InterpolationMethod.SPLINE, mixed_set) def test_labeled_enum_hash_consistency(self): """Test that enum members and their string values have consistent hashing""" # Hash should be based on the value, enabling set deduplication self.assertEqual(hash(InterpolationMethod.LINEAR), hash("linear")) self.assertEqual(hash(InterpolationMethod.SPLINE), hash("spline")) self.assertEqual(hash(ProcessingMode.FAST), hash("fast_mode")) # Different enum members should have different hashes hash1 = hash(InterpolationMethod.LINEAR) hash2 = hash(InterpolationMethod.SPLINE) self.assertNotEqual(hash1, hash2) def test_labeled_enum_inequality_behavior(self): """Test inequality behavior with non-matching values""" # Test inequality with strings self.assertFalse(InterpolationMethod.LINEAR == "wrong_value") self.assertFalse("wrong_value" == InterpolationMethod.LINEAR) # Test inequality with other types self.assertFalse(InterpolationMethod.LINEAR == 42) self.assertFalse(InterpolationMethod.LINEAR is None) self.assertFalse(InterpolationMethod.LINEAR == []) # Test inequality between different enum members self.assertFalse(InterpolationMethod.LINEAR == InterpolationMethod.SPLINE) self.assertFalse(InterpolationMethod.LINEAR == ProcessingMode.FAST) def test_labeled_enum_translated_vs_non_translated(self): """Test behavior with translated vs non-translated enum members""" # InterpolationMethod.NEAREST is non-translated (single value) self.assertTrue(InterpolationMethod.NEAREST == "nearest") self.assertEqual(InterpolationMethod.NEAREST.value, "nearest") self.assertEqual(InterpolationMethod.NEAREST.label, "nearest") # InterpolationMethod.LINEAR is translated (tuple value) self.assertTrue(InterpolationMethod.LINEAR == "linear") self.assertEqual(InterpolationMethod.LINEAR.value, "linear") # Should be translated (different from value) self.assertNotEqual(InterpolationMethod.LINEAR.label, "linear") if __name__ == "__main__": unittest.main() execenv.print("OK") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_dataset_class_config.py0000644000175100017510000001376015114075001024176 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test DataSet class-level configuration via __init_subclass__ """ # guitest: show from guidata.dataset import DataSet, FloatItem, StringItem from guidata.env import execenv def test_dataset_class_config(): """Test DataSet class-level configuration using __init_subclass__""" # Test 1: Basic class-level configuration class ConfiguredDataSet( DataSet, title="My Configured Dataset", comment="This is configured at class level", icon="myicon.png", ): """Developer documentation - not used as title""" param1 = FloatItem("Parameter 1", default=1.0) param2 = StringItem("Parameter 2", default="test") instance1 = ConfiguredDataSet() execenv.print("Test 1: Class-level configuration") execenv.print(f" Title: '{instance1.get_title()}'") execenv.print(f" Comment: '{instance1.get_comment()}'") execenv.print(f" Icon: '{instance1.get_icon()}'") assert instance1.get_title() == "My Configured Dataset" assert instance1.get_comment() == "This is configured at class level" assert instance1.get_icon() == "myicon.png" execenv.print(" ✓ Passed\n") # Test 2: Instance parameter overrides class-level configuration instance2 = ConfiguredDataSet( title="Override Title", comment="Override Comment", icon="override.png" ) execenv.print("Test 2: Instance override") execenv.print(f" Title: '{instance2.get_title()}'") execenv.print(f" Comment: '{instance2.get_comment()}'") execenv.print(f" Icon: '{instance2.get_icon()}'") assert instance2.get_title() == "Override Title" assert instance2.get_comment() == "Override Comment" assert instance2.get_icon() == "override.png" execenv.print(" ✓ Passed\n") # Test 3: No configuration - falls back to docstring for title only class UnconfiguredDataSet(DataSet): """First line of docstring This is the remaining comment part """ param1 = FloatItem("Parameter 1") instance3 = UnconfiguredDataSet() execenv.print("Test 3: No class-level configuration (backward compatibility)") execenv.print(f" Title: '{instance3.get_title()}'") execenv.print(f" Comment: '{instance3.get_comment()}'") assert instance3.get_title() == "First line of docstring" # Uses docstring assert instance3.get_comment() is None # No automatic comment from docstring execenv.print(" ✓ Passed (docstring used for title, no comment)\n") # Test 3b: Explicitly set empty title - no comment from docstring class EmptyTitleDataSet(DataSet, title=""): """Docstring is for developer documentation only""" param1 = FloatItem("Parameter 1") instance3b = EmptyTitleDataSet() execenv.print("Test 3b: Explicitly empty title") execenv.print(f" Title: '{instance3b.get_title()}'") execenv.print(f" Comment: '{instance3b.get_comment()}'") assert instance3b.get_title() == "" # Explicitly empty assert instance3b.get_comment() is None # No automatic comment from docstring execenv.print(" ✓ Passed (empty title, no comment from docstring)\n") # Test 4: Partial configuration class PartialConfigDataSet(DataSet, title="Partial Config"): """Docstring comment""" param1 = FloatItem("Parameter 1") instance4 = PartialConfigDataSet() execenv.print("Test 4: Partial configuration (title only)") execenv.print(f" Title: '{instance4.get_title()}'") execenv.print(f" Comment: '{instance4.get_comment()}'") execenv.print(f" Icon: '{instance4.get_icon()}'") assert instance4.get_title() == "Partial Config" assert instance4.get_comment() is None # No automatic comment from docstring assert instance4.get_icon() == "" execenv.print(" ✓ Passed\n") # Test 5: Readonly configuration class ReadOnlyDataSet(DataSet, title="Read Only", readonly=True): """Read-only dataset""" param1 = FloatItem("Parameter 1", default=5.0) instance5 = ReadOnlyDataSet() execenv.print("Test 5: Readonly configuration") execenv.print(f" Is readonly: {instance5.is_readonly()}") assert instance5.is_readonly() is True execenv.print(" ✓ Passed\n") # Test 6: Inheritance of class-level configuration class DerivedDataSet(ConfiguredDataSet): """This inherits from ConfiguredDataSet""" param3 = FloatItem("Parameter 3", default=3.0) instance6 = DerivedDataSet() execenv.print("Test 6: Inheritance (inherits parent config)") execenv.print(f" Title: '{instance6.get_title()}'") execenv.print(f" Comment: '{instance6.get_comment()}'") # Note: Inheritance doesn't automatically pass down class config # Each class needs its own configuration execenv.print(" ✓ Passed\n") # Test 7: Override inherited configuration class OverriddenDerivedDataSet( ConfiguredDataSet, title="Overridden Title", icon="new.png" ): """Overriding parent configuration""" param3 = FloatItem("Parameter 3", default=3.0) instance7 = OverriddenDerivedDataSet() execenv.print("Test 7: Override inherited configuration") execenv.print(f" Title: '{instance7.get_title()}'") execenv.print(f" Icon: '{instance7.get_icon()}'") assert instance7.get_title() == "Overridden Title" assert instance7.get_icon() == "new.png" execenv.print(" ✓ Passed\n") # Test 8: Empty strings vs None class EmptyStringDataSet(DataSet, title="", comment=""): """Docstring""" param1 = FloatItem("Parameter 1") instance8 = EmptyStringDataSet() execenv.print("Test 8: Explicit empty strings") execenv.print(f" Title: '{instance8.get_title()}'") execenv.print(f" Comment: '{instance8.get_comment()}'") assert instance8.get_title() == "" assert instance8.get_comment() == "" execenv.print(" ✓ Passed\n") execenv.print("All tests passed! ✓") if __name__ == "__main__": test_dataset_class_config() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_dataset_from_dict.py0000644000175100017510000000175715114075001023515 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Generate a dataset class from a dictionary """ # guitest: show import numpy as np from guidata.dataset import create_dataset_from_dict from guidata.env import execenv TEST_DICT1 = { "a": 1, "b": 2.0, "c": "test", "d": {"x": 1, "y": 3}, "data": np.array([1, 2, 3]), } TEST_DICT2 = { "a": 1, "unsupported": [2.0, 3.0], } def test_dataset_from_dict(): """Test generate dataset class from a dictionary""" for dictionary in (TEST_DICT1,): execenv.print(dictionary) dataset = create_dataset_from_dict(dictionary) execenv.print(dataset) execenv.print(dataset.create()) execenv.print("") try: create_dataset_from_dict(TEST_DICT2) assert False, "Should have raised a ValueError" except ValueError: # This is expected pass if __name__ == "__main__": test_dataset_from_dict() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_dataset_from_func.py0000644000175100017510000000256115114075001023517 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Generate a dataset class from a function signature This function is used to generate a dataset from a function signature which has type annotations. See the example below. """ # guitest: show from __future__ import annotations import numpy as np from guidata.dataset import create_dataset_from_func from guidata.env import execenv def func_ok( a: int, b: float, c: str = "test", d: dict[str, int] = {"x": 1, "y": 3} ) -> None: """A function with type annotations""" pass def func_no_type(a, b, c="test"): """A function without type annotations""" pass def func_no_default(a: int, b: float, c: str, data: np.ndarray) -> None: """A function without default values""" pass def test_dataset_from_func(): """Test generate dataset class from function""" for func in (func_ok, func_no_default): execenv.print(func.__name__) dataset = create_dataset_from_func(func) execenv.print(dataset) execenv.print(dataset.create(a=1, b=2.0)) execenv.print("") func = func_no_type try: create_dataset_from_func(func) assert False, "Should have raised a ValueError" except ValueError: # This is expected pass if __name__ == "__main__": test_dataset_from_func() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_dataset_to_html.py0000644000175100017510000003152415114075001023210 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Unit tests for DataSet.to_html() method Tests various scenarios for HTML generation including: - Basic dataset with title and comment - BoolItem with different text/label combinations - Various data types (string, int, float, etc.) - Edge cases (empty datasets, None values, etc.) """ import datetime import pytest import guidata.dataset as gds class SimpleDataset(gds.DataSet): """Simple dataset for testing HTML output""" name = gds.StringItem("Name", default="Test Name") value = gds.IntItem("Value", default=42) enabled = gds.BoolItem("Enabled option") class ComplexDataset(gds.DataSet): """Dataset with various item types and BoolItem configurations""" # Basic items text = gds.StringItem("Text field", default="Hello World") number = gds.FloatItem("Number", default=3.14159) date = gds.DateItem("Date", default=datetime.date(2023, 1, 1)) # BoolItem variations bool_simple = gds.BoolItem("Simple boolean") bool_with_label = gds.BoolItem("Boolean text", "Label name") bool_enabled = gds.BoolItem("Feature enabled", default=True) bool_disabled = gds.BoolItem("Feature disabled", default=False) # None value test optional_text = gds.StringItem("Optional", default=None, allow_none=True) class DatasetWithComment( gds.DataSet, comment=( "This is the comment that should appear in lighter blue\nin the HTML output." ), ): """A dataset with title and comment""" param = gds.StringItem("Parameter", default="value") class EmptyDataset(gds.DataSet): """Empty dataset for edge case testing""" pass def test_simple_dataset_to_html(): """Test basic HTML generation for simple dataset""" dataset = SimpleDataset() html = dataset.to_html() # Check title is present and styled (uses first line of docstring) expected_title = ( 'Simple dataset for testing HTML output' ) assert expected_title in html # Check table structure assert '' in html assert "
" in html # Check item names and values are present assert "Name:" in html assert "Test Name" in html assert "Value:" in html assert "42" in html # Check checkbox for boolean assert "Enabled option:" in html assert "☐" in html # Unchecked by default def test_complex_dataset_to_html(): """Test HTML generation for dataset with various item types""" dataset = ComplexDataset() html = dataset.to_html() # Check title (uses first line of docstring) expected_title = ( '' "Dataset with various item types and BoolItem configurations" "" ) assert expected_title in html # Check basic items assert "Text field:" in html assert "Hello World" in html assert "Number:" in html assert "3.14159" in html # Check BoolItem variations assert "Simple boolean:" in html assert "☐" in html # Default false # Check BoolItem with label - should show "Label name: ☐ Boolean text" assert "Label name:" in html and "☐ Boolean text" in html # Check enabled/disabled states assert "Feature enabled:" in html assert "☑" in html # Should be checked assert "Feature disabled:" in html # Should have both checked and unchecked boxes # Check None value handling assert "Optional:" in html assert "-" in html # None should be displayed as '-' def test_dataset_with_comment(): """Test HTML generation for dataset with title and comment""" dataset = DatasetWithComment() html = dataset.to_html() # Check title (uses first line of docstring) expected_title = ( 'A dataset with title and comment' ) assert expected_title in html # Check comment is present and styled with the lighter blue assert '' in html assert "This is the comment that should appear in lighter blue" in html def test_bool_item_combinations(): """Test various BoolItem text/label combinations""" class BoolTestDataset(gds.DataSet): # Only text, no label bool1 = gds.BoolItem("Just text") # Both text and label bool2 = gds.BoolItem("Text part", "Label part") # Empty text, only label (edge case) bool3 = gds.BoolItem("", "Only label") dataset = BoolTestDataset() dataset.bool1 = True dataset.bool2 = False dataset.bool3 = True html = dataset.to_html() # Check first boolean (text only) assert "Just text:" in html assert "☑" in html # Check second boolean (text and label) assert "Label part" in html and "☐ Text part" in html # Check third boolean (label only) assert "Only label:" in html def test_empty_dataset(): """Test HTML generation for empty dataset""" dataset = EmptyDataset() html = dataset.to_html() # Should have title (uses first line of docstring) expected_title = ( 'Empty dataset for edge case testing' ) assert expected_title in html # Should indicate no items assert "No items to display" in html def test_dataset_with_none_values(): """Test handling of None values in various item types""" class NoneTestDataset(gds.DataSet): text_none = gds.StringItem("Text", default=None, allow_none=True) int_none = gds.IntItem("Integer", default=None, allow_none=True) bool_none = gds.BoolItem("Boolean", default=None, allow_none=True) dataset = NoneTestDataset() html = dataset.to_html() # All None values should display as '-' # Count occurrences of '-' in table cells html_lines = html.split("\n") dash_count = sum(line.count('">-') for line in html_lines) assert dash_count >= 2 # At least text and int should show '-' def test_html_structure_and_styling(): """Test the HTML structure and CSS styling""" dataset = SimpleDataset() html = dataset.to_html() # Check CSS styles assert "text-align: right" in html assert "text-align: left" in html assert "vertical-align: top" in html assert "padding-left: 10px" in html # Check table structure assert html.count("") == html.count("") assert html.count("") # Should be well-formed HTML table assert "" in html def test_checkbox_characters(): """Test that proper checkbox characters are used""" class CheckboxDataset(gds.DataSet): checked = gds.BoolItem("Checked", default=True) unchecked = gds.BoolItem("Unchecked", default=False) dataset = CheckboxDataset() html = dataset.to_html() # Should contain both checked and unchecked boxes assert "☑" in html # Checked box assert "☐" in html # Unchecked box def visualize_html_in_browser(): """Generate and open HTML visualization in web browser for manual testing""" import datetime import os import tempfile import webbrowser # Create a comprehensive dataset for visualization class VisualizationDataset(gds.DataSet): """HTML Visualization Test Dataset This dataset contains various item types to demonstrate the HTML output formatting capabilities. """ # Basic data types name = gds.StringItem("Full Name", default="John Smith") age = gds.IntItem("Age", default=35) salary = gds.FloatItem("Annual Salary", default=75000.50) start_date = gds.DateItem("Start Date", default=datetime.date(2020, 3, 15)) birth_datetime = gds.DateTimeItem( "Birth Date/Time", default=datetime.datetime(1988, 7, 22, 14, 30) ) # BoolItem variations to test checkbox rendering newsletter = gds.BoolItem("Subscribe to newsletter") notifications = gds.BoolItem("Email notifications", "Enable emails") premium = gds.BoolItem("Premium membership", default=True) marketing = gds.BoolItem("Marketing consent", default=False) # Optional fields middle_name = gds.StringItem("Middle Name", default=None, allow_none=True) phone = gds.StringItem("Phone Number", default="", allow_none=True) # Choice items department = gds.ChoiceItem( "Department", [ ("IT", "Information Technology"), ("HR", "Human Resources"), ("FIN", "Finance"), ], ) # Create and populate dataset dataset = VisualizationDataset() dataset.newsletter = True dataset.notifications = False dataset.phone = None # Generate complete HTML page html_content = f""" DataSet to_html() Visualization

DataSet to_html() Method Visualization

Dataset Definition

class VisualizationDataset(gds.DataSet): \"\"\"HTML Visualization Test Dataset This dataset contains various item types to demonstrate the HTML output formatting capabilities. \"\"\" # Basic data types name = gds.StringItem("Full Name", default="John Smith") age = gds.IntItem("Age", default=35) salary = gds.FloatItem("Annual Salary", default=75000.50) # BoolItem variations newsletter = gds.BoolItem("Subscribe to newsletter") notifications = gds.BoolItem("Email notifications", "Enable emails") premium = gds.BoolItem("Premium membership", default=True) # Optional fields (None values) middle_name = gds.StringItem("Middle Name", default=None, allow_none=True)

Generated HTML Output

The following is the direct output from dataset.to_html():

{dataset.to_html()}

Key Features Demonstrated

  • Title and Comment: Displayed in lighter blue (#5294e2) using the first line of the docstring as title
  • Two-column Layout: Item names right-aligned, values left-aligned
  • BoolItem Checkboxes: ☑ for True, ☐ for False
  • BoolItem Labels: Shows "Label: Text" format when both are provided
  • None Values: Displayed as "-" for better readability
  • Monospace Font: Table uses monospace font for consistent alignment

Raw HTML Code

Raw HTML source code generated by the method:

{dataset.to_html().replace("<", "<").replace(">", ">")}
""" # Write to temporary file temp_file_args = { "mode": "w", "suffix": ".html", "delete": False, "encoding": "utf-8", } with tempfile.NamedTemporaryFile(**temp_file_args) as f: f.write(html_content) temp_file = f.name print(f"HTML file created: {temp_file}") print("Opening in default web browser...") # Open in web browser webbrowser.open(f"file://{os.path.abspath(temp_file)}") return temp_file if __name__ == "__main__": pytest.main([__file__, "-v"]) visualize_html_in_browser() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_force_allow_none.py0000644000175100017510000000652715114075001023355 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test None default values with allow_none=False This test verifies that DataItem objects can have None as default values even when allow_none=False is set, while still preventing users from setting None values at runtime. """ import pytest from guidata.config import ValidationMode, set_validation_mode from guidata.dataset import DataSet from guidata.dataset.dataitems import StringItem from guidata.dataset.datatypes import DataItemValidationError class _TestDataSet(DataSet): """Test dataset for None default values""" name = StringItem("Name", default=None, allow_none=False) description = StringItem("Description", default=None, allow_none=True) title = StringItem("Title", default="Default Title", allow_none=False) def test_none_defaults_creation(): """Test that datasets can be created with None defaults even when allow_none=False""" # Set validation to strict mode to catch any issues set_validation_mode(ValidationMode.STRICT) # This should work: None default is allowed even when allow_none=False dataset = _TestDataSet() # Verify the default values are set correctly assert dataset.name is None assert dataset.description is None assert dataset.title == "Default Title" def test_allow_none_true_accepts_none(): """Test that items with allow_none=True accept None values at runtime""" set_validation_mode(ValidationMode.STRICT) dataset = _TestDataSet() # This should work: allow_none=True dataset.description = None assert dataset.description is None # Should also accept valid string values dataset.description = "Test Description" assert dataset.description == "Test Description" def test_allow_none_false_rejects_none(): """Test that items with allow_none=False reject None values at runtime""" set_validation_mode(ValidationMode.STRICT) dataset = _TestDataSet() # This should fail: allow_none=False with pytest.raises(DataItemValidationError): dataset.name = None def test_allow_none_false_accepts_valid_values(): """Test that items with allow_none=False accept valid non-None values""" set_validation_mode(ValidationMode.STRICT) dataset = _TestDataSet() # This should work: valid string value dataset.name = "Test Name" assert dataset.name == "Test Name" # Test changing values dataset.name = "Another Name" assert dataset.name == "Another Name" def test_validation_mode_disabled(): """Test that validation is skipped when validation mode is disabled""" set_validation_mode(ValidationMode.DISABLED) dataset = _TestDataSet() # Even with allow_none=False, None should be accepted in disabled mode dataset.name = None assert dataset.name is None def test_validation_mode_enabled_warnings(): """Test that warnings are shown instead of exceptions in enabled mode""" set_validation_mode(ValidationMode.ENABLED) dataset = _TestDataSet() # Should show warnings but not raise exceptions with pytest.warns(UserWarning): dataset.name = None assert dataset.name is None @pytest.fixture(autouse=True) def reset_validation_mode(): """Reset validation mode after each test""" yield set_validation_mode(ValidationMode.DISABLED) # Reset to default ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_h5fmt.py0000644000175100017510000001430415114075001021055 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test HDF5 I/O ------------- Testing various use cases of HDF5 I/O: * Serialize and deserialize a data model, handling versioning and compatibility breaks. """ from __future__ import annotations import atexit import os import os.path as osp import guidata.dataset as gds from guidata.env import execenv from guidata.io import HDF5Reader, HDF5Writer # The following class represents a data model that we want to serialize and deserialize. # This is the first version of the data model. class MyFirstDataSetV10(gds.DataSet): """First data set version 1.0""" alpha = gds.FloatItem("Alpha", default=0.0) number = gds.IntItem("Number", default=0) text = gds.StringItem("Text", default="") class MySecondDataSetV10(gds.DataSet): """Second data set version 1.0""" length = gds.FloatItem("Length", default=0.0) duration = gds.IntItem("Duration", default=0) class MyDataObjectV10: """Data object version 1.0""" def __init__(self, title: str = "") -> None: self.title = title self.metadata = {"author": "John Doe", "age": 24, "skills": ["Python", "C++"]} def __str__(self) -> str: """Return the string representation of the object""" return f"{self.__class__.__name__}({self.title})" def serialize(self, writer: HDF5Writer) -> None: """Serialize the data model to an HDF5 file""" writer.write(self.title, "title") with writer.group("metadata"): writer.write_dict(self.metadata) def deserialize(self, reader: HDF5Reader) -> None: """Deserialize the data model from an HDF5 file""" self.title = reader.read("title") with reader.group("metadata"): self.metadata = reader.read_dict() class MyDataModelV10: """Data model version 1.0""" VERSION = "1.0" MYDATAOBJCLASS = MyDataObjectV10 MYDATASETCLASS1 = MyFirstDataSetV10 MYDATASETCLASS2 = MySecondDataSetV10 def __init__(self) -> None: self.obj1 = MyDataObjectV10("first_obj_title") self.obj2 = MyDataObjectV10("second_obj_title") self.obj3 = MyDataObjectV10("third_obj_title") self.param1 = MyFirstDataSetV10() self.param2 = MySecondDataSetV10() def __str__(self) -> str: """Return the string representation of the object""" text = f"{self.__class__.__name__}:" text += f"\n {self.obj1}" text += f"\n {self.obj2}" text += f"\n {self.obj3}" text += f"\n {self.param1}" text += f"\n {self.param2}" return text def save(self, filename: str) -> None: """Save the data model from an HDF5 file""" objs = [self.obj1, self.obj2] writer = HDF5Writer(filename) writer.write(self.VERSION, "created_version") writer.write_object_list(objs, "ObjList") writer.write(self.obj3, "IndividualObj") writer.write(self.param1, "Param1") writer.write(self.param2, "Param2") writer.close() def load(self, filename: str) -> None: """Load the data model to an HDF5 file""" reader = HDF5Reader(filename) created_version = reader.read("created_version") self.obj1, self.obj2 = reader.read_object_list("ObjList", self.MYDATAOBJCLASS) self.obj3 = reader.read("IndividualObj", self.MYDATAOBJCLASS) self.param1 = reader.read("Param1", self.MYDATASETCLASS1) self.param2 = reader.read("Param2", self.MYDATASETCLASS2) execenv.print("Created version:", created_version) execenv.print("Current version:", self.VERSION) execenv.print("Model data:", self) reader.close() # The following class represents a new version of the data model: let's assume that # it replaces the previous version and we want to be able to deserialize the old # version as well as the new version. class MyFirstDataSetV11(MyFirstDataSetV10): """First data set version 1.1""" # Adding a new item beta = gds.FloatItem("Beta", default=0.0) class MySecondDataSetV11(gds.DataSet): """Second data set version 1.1""" # Redefining the data set with new items (replacing the previous version) width = gds.FloatItem("Width", default=10.0) height = gds.FloatItem("Height", default=20.0) class MyDataObjectV11(MyDataObjectV10): """Data object version 1.1""" def __init__(self, title: str = "", subtitle: str = "") -> None: super().__init__(title) self.subtitle = subtitle # New attribute def __str__(self) -> str: """Return the string representation of the object""" return f"{self.__class__.__name__}({self.title}, {self.subtitle})" def serialize(self, writer: HDF5Writer): """Serialize the data model to an HDF5 file""" super().serialize(writer) writer.write(self.subtitle, "subtitle") def deserialize(self, reader: HDF5Reader): """Deserialize the data model from an HDF5 file""" super().deserialize(reader) # Handling compatibility with the previous version is done by providing a # default value for the new attribute: self.subtitle = reader.read("subtitle", default="") class MyDataModelV11(MyDataModelV10): """Data model version 1.1""" VERSION = "1.1" MYDATAOBJCLASS = MyDataObjectV11 MYDATASETCLASS1 = MyFirstDataSetV11 MYDATASETCLASS2 = MySecondDataSetV11 def __init__(self) -> None: self.obj1 = MyDataObjectV11("first_obj_title") self.obj2 = MyDataObjectV11("second_obj_title") self.obj3 = MyDataObjectV11("third_obj_title") self.param1 = MyFirstDataSetV11() self.param2 = MySecondDataSetV11() def test_hdf5_datamodel_compatiblity(): """Test HDF5 I/O with data model compatibility""" path = osp.abspath("test.h5") atexit.register(os.unlink, path) # Serialize the first version of the data model model_v10 = MyDataModelV10() model_v10.save(path) # Deserialize the first version of the data model model_v10.load(path) # Deserialize using the new version of the data model model_v11 = MyDataModelV11() model_v11.load(path) if __name__ == "__main__": test_hdf5_datamodel_compatiblity() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_h5fmt_datetime.py0000644000175100017510000001366515114075001022742 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test HDF5 DateTime Serialization --------------------------------- Testing datetime/date serialization with metadata to avoid false positives. """ from __future__ import annotations import atexit import datetime import os import os.path as osp import numpy as np from guidata.io import HDF5Reader, HDF5Writer class DateTimeTestObject: """Test object with various datetime and numeric values""" def __init__(self) -> None: # Datetime values that should be preserved self.dt1 = datetime.datetime(2024, 1, 15, 10, 30, 45) self.dt2 = datetime.datetime.now() self.date1 = datetime.date(2024, 6, 1) self.date2 = datetime.date.today() # Numeric values that could be mistaken for datetime # (these should remain as numbers) self.timestamp_like_float = 1500000000.0 # Within datetime range self.timestamp_like_int = 1500000000 # Within datetime range self.ordinal_like_int = 737000 # Within date ordinal range self.regular_float = 3.14159 self.regular_int = 42 # Dictionary with mixed types self.metadata = { "created": datetime.datetime(2023, 12, 25, 12, 0, 0), "modified": datetime.date(2024, 1, 1), "count": 100000, # Could be mistaken for date ordinal "score": 1.5e8, # Could be mistaken for timestamp "name": "Test Object", } def __eq__(self, other: object) -> bool: """Check equality""" if not isinstance(other, DateTimeTestObject): return False return ( self.dt1 == other.dt1 and self.dt2 == other.dt2 and self.date1 == other.date1 and self.date2 == other.date2 and self.timestamp_like_float == other.timestamp_like_float and self.timestamp_like_int == other.timestamp_like_int and self.ordinal_like_int == other.ordinal_like_int and self.regular_float == other.regular_float and self.regular_int == other.regular_int and self.metadata == other.metadata ) def serialize(self, writer: HDF5Writer) -> None: """Serialize to HDF5""" writer.write(self.dt1, "dt1") writer.write(self.dt2, "dt2") writer.write(self.date1, "date1") writer.write(self.date2, "date2") writer.write(self.timestamp_like_float, "timestamp_like_float") writer.write(self.timestamp_like_int, "timestamp_like_int") writer.write(self.ordinal_like_int, "ordinal_like_int") writer.write(self.regular_float, "regular_float") writer.write(self.regular_int, "regular_int") with writer.group("metadata"): writer.write_dict(self.metadata) def deserialize(self, reader: HDF5Reader) -> None: """Deserialize from HDF5""" self.dt1 = reader.read("dt1") self.dt2 = reader.read("dt2") self.date1 = reader.read("date1") self.date2 = reader.read("date2") self.timestamp_like_float = reader.read("timestamp_like_float") self.timestamp_like_int = reader.read("timestamp_like_int") self.ordinal_like_int = reader.read("ordinal_like_int") self.regular_float = reader.read("regular_float") self.regular_int = reader.read("regular_int") with reader.group("metadata"): self.metadata = reader.read_dict() def test_h5fmt_datetime_serialization(): """Test datetime serialization with metadata to avoid false positives""" path = osp.abspath("test_datetime.h5") atexit.register(os.unlink, path) # Create and serialize the object original = DateTimeTestObject() writer = HDF5Writer(path) original.serialize(writer) writer.close() # Deserialize the object loaded = DateTimeTestObject() reader = HDF5Reader(path) loaded.deserialize(reader) reader.close() # Verify datetime objects are correctly restored assert isinstance(loaded.dt1, datetime.datetime) assert loaded.dt1 == original.dt1 assert isinstance(loaded.dt2, datetime.datetime) assert loaded.dt2 == original.dt2 assert isinstance(loaded.date1, datetime.date) assert loaded.date1 == original.date1 assert isinstance(loaded.date2, datetime.date) assert loaded.date2 == original.date2 # Verify numeric values are NOT converted to datetime (no false positives) assert isinstance(loaded.timestamp_like_float, (float, np.floating)) assert loaded.timestamp_like_float == original.timestamp_like_float assert isinstance(loaded.timestamp_like_int, (int, np.integer)) assert loaded.timestamp_like_int == original.timestamp_like_int assert isinstance(loaded.ordinal_like_int, (int, np.integer)) assert loaded.ordinal_like_int == original.ordinal_like_int assert isinstance(loaded.regular_float, (float, np.floating)) assert loaded.regular_float == original.regular_float assert isinstance(loaded.regular_int, (int, np.integer)) assert loaded.regular_int == original.regular_int # Verify dictionary values assert isinstance(loaded.metadata["created"], datetime.datetime) assert loaded.metadata["created"] == original.metadata["created"] assert isinstance(loaded.metadata["modified"], datetime.date) assert loaded.metadata["modified"] == original.metadata["modified"] assert isinstance(loaded.metadata["count"], (int, np.integer)) assert loaded.metadata["count"] == original.metadata["count"] assert isinstance(loaded.metadata["score"], (float, np.floating)) assert loaded.metadata["score"] == original.metadata["score"] assert isinstance(loaded.metadata["name"], str) assert loaded.metadata["name"] == original.metadata["name"] # Overall equality check assert loaded == original print("All datetime serialization tests passed!") if __name__ == "__main__": test_h5fmt_datetime_serialization() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_item_order.py0000644000175100017510000000561415114075001022167 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Unit tests for DataSet item order in inheritance""" import unittest import guidata.dataset as gds class DatasetA(gds.DataSet): """Dataset A with two items""" a1 = gds.FloatItem("a1") a2 = gds.IntItem("a2") class DatasetB(gds.DataSet): """Dataset B with two items""" b1 = gds.TextItem("b1") b2 = gds.BoolItem("b2") class DerivedAB(DatasetA, DatasetB): """Derived dataset from A and B""" d = gds.FloatItem("d") class DerivedBA(DatasetB, DatasetA): """Derived dataset from B and A""" d = gds.FloatItem("d") class DerivedSimple(DatasetA): """Derived dataset from DatasetA with an additional item""" d = gds.IntItem("d") class TestDataSetItemOrder(unittest.TestCase): """Test DataSet item order in inheritance""" def assertItemNames( self, dataset_cls: type[gds.DataSet], expected_names: list[str] ) -> None: """Assert that the item names in the dataset match the expected names""" instance = dataset_cls() actual_names = [item.get_name() for item in instance.get_items()] self.assertEqual(actual_names, expected_names) def test_single_dataset(self) -> None: """Test single dataset item order""" self.assertItemNames(DatasetA, ["a1", "a2"]) def test_simple_inheritance(self) -> None: """Test simple inheritance with one additional item""" self.assertItemNames(DerivedSimple, ["a1", "a2", "d"]) def test_multiple_inheritance_AB(self) -> None: """Test multiple inheritance with DatasetA and DatasetB""" self.assertItemNames(DerivedAB, ["a1", "a2", "b1", "b2", "d"]) def test_multiple_inheritance_BA(self) -> None: """Test multiple inheritance with DatasetB and DatasetA""" self.assertItemNames(DerivedBA, ["b1", "b2", "a1", "a2", "d"]) def test_original_test_case(self) -> None: """Test original test case with double inheritance""" class OriginalDataset1(gds.DataSet): text1 = gds.TextItem("Text 1") int1 = gds.IntItem("Integer 1") class OriginalDataset2(gds.DataSet): text2 = gds.TextItem("Text 2") int2 = gds.IntItem("Integer 2") class DoubleInheritedDataset1(OriginalDataset1, OriginalDataset2): text3 = gds.TextItem("Text 3") int3 = gds.IntItem("Integer 3") class DoubleInheritedDataset2(OriginalDataset2, OriginalDataset1): text4 = gds.TextItem("Text 4") int4 = gds.IntItem("Integer 4") self.assertItemNames( DoubleInheritedDataset1, ["text1", "int1", "text2", "int2", "text3", "int3"] ) self.assertItemNames( DoubleInheritedDataset2, ["text2", "int2", "text1", "int1", "text4", "int4"] ) if __name__ == "__main__": unittest.main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_jsonfmt.py0000644000175100017510000001036015114075001021510 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test JSON I/O ------------- Testing various use cases of JSON I/O: * Serialize and deserialize a data model, handling versioning and compatibility breaks. """ from __future__ import annotations import atexit import os import os.path as osp from guidata.env import execenv from guidata.io import JSONReader, JSONWriter # The following class represents a data model that we want to serialize and deserialize. # This is the first version of the data model. class MyDataObjectV10: """Data object version 1.0""" def __init__(self, title: str = "") -> None: self.title = title def __str__(self) -> str: """Return the string representation of the object""" return f"{self.__class__.__name__}({self.title})" def serialize(self, writer: JSONWriter) -> None: """Serialize the data model to an JSON file""" writer.write(self.title, "title") def deserialize(self, reader: JSONReader) -> None: """Deserialize the data model from an JSON file""" self.title = reader.read("title") class MyDataModelV10: """Data model version 1.0""" VERSION = "1.0" MYDATAOBJCLASS = MyDataObjectV10 def __init__(self) -> None: self.obj1 = MyDataObjectV10("first_obj_title") self.obj2 = MyDataObjectV10("second_obj_title") def __str__(self) -> str: """Return the string representation of the object""" return f"{self.__class__.__name__}({self.obj1}, {self.obj2})" def save(self, filename: str) -> None: """Save the data model from an JSON file""" objs = [self.obj1, self.obj2] writer = JSONWriter(filename) writer.write(self.VERSION, "created_version") writer.write_object_list(objs, "ObjList") writer.save() def load(self, filename: str) -> None: """Load the data model to an JSON file""" reader = JSONReader(filename) created_version = reader.read("created_version") self.obj1, self.obj2 = reader.read_object_list("ObjList", self.MYDATAOBJCLASS) execenv.print("Created version:", created_version) execenv.print("Current version:", self.VERSION) execenv.print("Model data:", self) reader.close() # The following class represents a new version of the data model: let's assume that # it replaces the previous version and we want to be able to deserialize the old # version as well as the new version. class MyDataObjectV11(MyDataObjectV10): """Data object version 1.1""" def __init__(self, title: str = "", subtitle: str = "") -> None: super().__init__(title) self.subtitle = subtitle # New attribute def __str__(self) -> str: """Return the string representation of the object""" return f"{self.__class__.__name__}({self.title}, {self.subtitle})" def serialize(self, writer: JSONWriter): """Serialize the data model to an JSON file""" super().serialize(writer) writer.write(self.subtitle, "subtitle") def deserialize(self, reader: JSONReader): """Deserialize the data model from an JSON file""" super().deserialize(reader) # Handling compatibility with the previous version is done by providing a # default value for the new attribute: self.subtitle = reader.read("subtitle", default="") class MyDataModelV11(MyDataModelV10): """Data model version 1.1""" VERSION = "1.1" MYDATAOBJCLASS = MyDataObjectV11 def __init__(self) -> None: self.obj1 = MyDataObjectV11("first_obj_title") self.obj2 = MyDataObjectV11("second_obj_title") def test_json_datamodel_compatiblity(): """Test JSON I/O with data model compatibility""" path = osp.abspath("test.json") atexit.register(os.unlink, path) # atexit.register(lambda: os.unlink(path)) # Serialize the first version of the data model model_v10 = MyDataModelV10() model_v10.save(path) # Deserialize the first version of the data model model_v10.load(path) # Deserialize using the new version of the data model model_v11 = MyDataModelV11() model_v11.load(path) if __name__ == "__main__": test_json_datamodel_compatiblity() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_no_qt.py0000644000175100017510000000131515114075001021150 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test if some guidata features work without Qt (and they should!) """ import os def test_imports_without_qt(): """Test if some guidata features work without Qt""" os.environ["QT_API"] = "invalid_value" # Invalid Qt API try: # pylint: disable=unused-import # pylint: disable=import-outside-toplevel import guidata.dataset.dataitems # noqa: F401 import guidata.dataset.datatypes # noqa: F401 except ValueError as exc: raise AssertionError("guidata imports failed without Qt") from exc if __name__ == "__main__": test_imports_without_qt() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_text.py0000644000175100017510000000133415114075001021015 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Test in text mode""" import pytest import guidata.dataset as gds from guidata.env import execenv class Parameters(gds.DataSet): """Example dataset""" height = gds.FloatItem("Height", min=1, max=250, help="height in cm") width = gds.FloatItem("Width", min=1, max=250, help="width in cm") number = gds.IntItem("Number", min=3, max=20) @pytest.mark.skip(reason="interactive text mode: not suitable for automated testing") def test_text(): """Test text mode""" prm = Parameters() prm.text_edit() execenv.print(prm) execenv.print("OK") if __name__ == "__main__": test_text() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_translations.py0000644000175100017510000000072515114075001022555 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Little translation test""" # guitest: show from guidata.config import _ from guidata.env import execenv translations = (_("Some required entries are incorrect"),) def test_translations(): """Test translations""" for text in translations: execenv.print(text) execenv.print("OK") if __name__ == "__main__": test_translations() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_updaterestoredataset.py0000644000175100017510000000374015114075001024270 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Update/Restore dataset from/to another dataset or dictionary """ # guitest: show import numpy as np from guidata.dataset.conv import restore_dataset, update_dataset from guidata.tests.dataset.test_all_items import Parameters def test_update_restore_dataset(): """Test update/restore dataset from/to another dataset or dictionary""" dataset = Parameters() dsdict = { "integer": 1, "float_slider": 1.0, "bool1": True, "string": "test", "floatarray": np.array([1, 2, 3]), "dictionary": {"a": 1, "b": 2}, } # Update dataset from dictionary update_dataset(dataset, dsdict) # Check dataset values assert dataset.integer == dsdict["integer"] assert dataset.float_slider == dsdict["float_slider"] assert dataset.bool1 == dsdict["bool1"] assert dataset.string == dsdict["string"] assert np.all(dataset.floatarray == dsdict["floatarray"]) assert dataset.dictionary == dsdict["dictionary"] # Update dataset from another dataset dataset2 = Parameters() update_dataset(dataset2, dataset) # Check dataset values assert dataset2.integer == dataset.integer assert dataset2.float_slider == dataset.float_slider assert dataset2.bool1 == dataset.bool1 assert dataset2.string == dataset.string assert np.all(dataset2.floatarray == dataset.floatarray) assert dataset2.dictionary == dataset.dictionary # Restore dataset from dictionary restore_dataset(dataset, dsdict) # Check dataset values assert dataset.integer == dsdict["integer"] assert dataset.float_slider == dsdict["float_slider"] assert dataset.bool1 == dsdict["bool1"] assert dataset.string == dsdict["string"] assert np.all(dataset.floatarray == dsdict["floatarray"]) assert dataset.dictionary == dsdict["dictionary"] if __name__ == "__main__": test_update_restore_dataset() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_userconfig_app.py0000644000175100017510000000124515114075001023036 0ustar00runnerrunner# -*- coding: utf-8 -*- """ userconfig Application settings example This should create a file named ".app.ini" in your HOME directory containing: [main] version = 1.0.0 [a] b/f = 1.0 """ import guidata.dataset as gds from guidata import userconfig from guidata.env import execenv class DS(gds.DataSet): """Example dataset""" f = gds.FloatItem("F", 1.0) def test_userconfig_app(): """Test userconfig""" ds = DS("") uc = userconfig.UserConfig({}) uc.set_application("app", "1.0.0") ds.write_config(uc, "a", "b") print("Settings saved in: ", uc.filename()) execenv.print("OK") if __name__ == "__main__": test_userconfig_app() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/unit/test_validationmodes.py0000644000175100017510000001501615114075001023215 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """Validation modes tests""" import os.path as osp import numpy as np import pytest import guidata.dataset as gds from guidata.config import ValidationMode, get_validation_mode, set_validation_mode from guidata.env import execenv def check_array(value: np.ndarray, raise_exception: bool = False) -> bool: """Check if value is a valid 2D array of floats. Args: value: value to check raise_exception: if True, raise an exception on invalid value Returns: True if value is valid, False otherwise """ if ( not isinstance(value, np.ndarray) or value.ndim != 2 or not np.issubdtype(value.dtype, np.floating) ): if raise_exception: raise TypeError("Float array must be a 2D numpy array of floats") return False return True class Parameters(gds.DataSet): """Example dataset""" fitem = gds.FloatItem("Float", min=1, max=250) iitem = gds.IntItem("Integer", max=20, nonzero=True) sitem = gds.StringItem("String", notempty=True) aitem = gds.FloatArrayItem("Array", check_callback=check_array) fileopenitem = gds.FileOpenItem("File", ("py",)) filesopenitem = gds.FilesOpenItem("Files", ("py",)) filesaveitem = gds.FileSaveItem("Save file", ("py",)) directoryitem = gds.DirectoryItem("Directory") VALID_DATA = { "fitem": 100.0, "iitem": 10, "sitem": "test", "aitem": np.array([[1.0, 2.0], [3.0, 4.0]]), "fileopenitem": __file__, "filesopenitem": [ __file__, osp.join(osp.dirname(__file__), "__init__.py"), ], "filesaveitem": "test.py", "directoryitem": osp.dirname(__file__), } INVALID_DATA = { "fitem": ( 300.0, # Out of range "10", # Not a float ), "iitem": ( 30, # Out of range 0, # Zero not allowed "test", # Not an integer 23.2323, # Not an integer ), "aitem": ( np.array([1.0, 2.0]), # Not a 2D array np.array([[1, 2], [3, 4]]), # Not a float array ), "sitem": ( "", # Empty string not allowed 123, # Not a string ), "fileopenitem": ( "nonexistent.py", # Nonexistent file ), "filesopenitem": (["nonexistent1.py", "nonexistent2.py"],), "filesaveitem": ( "", # Invalid empty file name ), "directoryitem": ( "nonexistent_dir", # Nonexistent directory ), } def test_default_validation_mode(): """Test default validation mode""" execenv.print("Testing default validation mode: ", end="") assert get_validation_mode() == ValidationMode.DISABLED execenv.print("OK") def __check_assigned_value_is_equal(assigned_value, expected_value): """Check if the assigned value is correctly set""" if isinstance(expected_value, np.ndarray): # For arrays, we check if the value is set correctly assert isinstance(assigned_value, np.ndarray) assert assigned_value.shape == expected_value.shape assert np.all(assigned_value == expected_value) else: # For other types, we check if the value is set correctly assert assigned_value == expected_value def __check_assigned_value_is_not_equal(assigned_value, expected_value): """Check if the assigned value is not equal to the real value""" if isinstance(expected_value, np.ndarray): # For arrays, we check if the value is set correctly if isinstance(assigned_value, np.ndarray): assert assigned_value.shape == expected_value.shape assert not np.all(assigned_value == expected_value) else: assert assigned_value is None else: # For other types, we check if the value is set correctly assert assigned_value != expected_value def test_valid_data(): """Test valid data""" params = Parameters() execenv.print("Testing valid data: ", end="") for name, value in VALID_DATA.items(): setattr(params, name, value) __check_assigned_value_is_equal(getattr(params, name), value) execenv.print("OK") def test_invalid_data_with_no_validation(): """Test invalid data with validation disabled""" old_mode = get_validation_mode() params = Parameters() set_validation_mode(ValidationMode.DISABLED) assert get_validation_mode() == ValidationMode.DISABLED execenv.print("Testing invalid data with validation disabled") for name, values in INVALID_DATA.items(): for value in values: execenv.print(f" Testing {name} with value: {value}") setattr(params, name, value) # No exception should be raised __check_assigned_value_is_equal(getattr(params, name), value) set_validation_mode(old_mode) def test_invalid_data_with_enabled_validation(): """Test invalid data with validation enabled""" old_mode = get_validation_mode() params = Parameters() set_validation_mode(ValidationMode.ENABLED) assert get_validation_mode() == ValidationMode.ENABLED execenv.print("Testing invalid data with validation enabled:") for name, values in INVALID_DATA.items(): for value in values: execenv.print(f" Testing {name} with value: {value}") # Check that a warning is raised with pytest.warns(gds.DataItemValidationWarning): setattr(params, name, value) # The value should be set anyway __check_assigned_value_is_equal(getattr(params, name), value) set_validation_mode(old_mode) def test_invalid_data_with_strict_validation(): """Test invalid data with strict validation""" old_mode = get_validation_mode() params = Parameters() set_validation_mode(ValidationMode.STRICT) assert get_validation_mode() == ValidationMode.STRICT execenv.print("Testing invalid data with strict validation:") for name, values in INVALID_DATA.items(): for value in values: execenv.print(f" Testing {name} with value: {value}") # Check that an exception is raised with pytest.raises(gds.DataItemValidationError): setattr(params, name, value) # The value should not be set __check_assigned_value_is_not_equal(getattr(params, name), value) set_validation_mode(old_mode) if __name__ == "__main__": test_default_validation_mode() test_valid_data() test_invalid_data_with_no_validation() test_invalid_data_with_enabled_validation() test_invalid_data_with_strict_validation() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6568856 guidata-3.13.4/guidata/tests/widgets/0000755000175100017510000000000015114075015017113 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/__init__.py0000644000175100017510000000000015114075001021205 0ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_arrayeditor.py0000644000175100017510000000567515114075001023061 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for arrayeditor.py """ # guitest: show import numpy as np from guidata.env import execenv from guidata.qthelpers import exec_dialog, qt_app_context from guidata.widgets.arrayeditor import ArrayEditor def launch_arrayeditor(data, title="", xlabels=None, ylabels=None, variable_size=False): """Helper routine to launch an arrayeditor and return its result""" dlg = ArrayEditor() dlg.setup_and_check( data, title, xlabels=xlabels, ylabels=ylabels, variable_size=variable_size ) exec_dialog(dlg) return dlg.get_value() def test_arrayeditor(): """Test array editor for all supported data types""" with qt_app_context(): # Variable size version for title, data in ( ("string array", np.array(["kjrekrjkejr"])), ( "masked array", np.ma.array([[1, 0], [1, 0]], mask=[[True, False], [False, False]]), ), ("int array", np.array([1, 2, 3], dtype="int8")), ): launch_arrayeditor(data, "[Variable size] " + title, variable_size=True) for title, data in ( ("string array", np.array(["kjrekrjkejr"])), ("unicode array", np.array(["ñññéáíó"])), ( "masked array", np.ma.array([[1, 0], [1, 0]], mask=[[True, False], [False, False]]), ), ( "record array", np.zeros( (2, 2), { "names": ("red", "green", "blue"), "formats": (np.float32, np.float32, np.float32), }, ), ), ( "record array with titles", np.array( [(0, 0.0), (0, 0.0), (0, 0.0)], dtype=[(("title 1", "x"), "|i1"), (("title 2", "y"), ">f4")], ), ), ("bool array", np.array([True, False, True])), ("int array", np.array([1, 2, 3], dtype="int8")), ("float16 array", np.zeros((5, 5), dtype=np.float16)), ): launch_arrayeditor(data, title) for title, data, xlabels, ylabels in ( ("float array", np.random.rand(5, 5), ["a", "b", "c", "d", "e"], None), ( "complex array", np.round(np.random.rand(5, 5) * 10) + np.round(np.random.rand(5, 5) * 10) * 1j, np.linspace(-12, 12, 5), np.linspace(-12, 12, 5), ), ): launch_arrayeditor(data, title, xlabels, ylabels) arr = np.zeros((3, 3, 4)) arr[0, 0, 0] = 1 arr[0, 0, 1] = 2 arr[0, 0, 2] = 3 launch_arrayeditor(arr, "3D array") execenv.print("OK") if __name__ == "__main__": test_arrayeditor() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_arrayeditor_unit.py0000644000175100017510000001536315114075001024113 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Unit tests for arrayeditor.py and its rows/columns insertion/deletion features """ # guitest: show from __future__ import annotations from typing import Any import numpy as np from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.widgets.arrayeditor import ArrayEditor DEFAULT_ROW_VALUE = 1 DEFAULT_COL_VALUE = 2 DEFAULT_MASK_VALUE = True DEFAULT_INS_DEL_COUNT = 3 DEFAULT_INSERTION_INDEX = 1 def _create_3d_array() -> np.ndarray: """Creates a 3D numpy array with a single element in the first slice Returns: A 3D numpy array with a single element in the first slice """ arr_3d = np.zeros((3, 3, 4)) arr_3d[0, 0, 0] = 1 arr_3d[0, 0, 1] = 2 arr_3d[0, 0, 2] = 3 return arr_3d REQUIRED_3D_SLICE = [slice(None), slice(None), 0] BASIC_ARRAYS = ( ("string array", np.array(["kjrekrjkejr"])), ("unicode array", np.array(["ñññéáíó"])), ( "masked array", np.ma.array([[1, 0], [1, 0]], mask=[[True, False], [False, False]]), ), ( "record array", np.zeros( (2, 2), { "names": ("red", "green", "blue"), "formats": (np.float32, np.float32, np.float32), }, ), ), ( "record array with titles", np.array( [(0, 0.0), (0, 0.0), (0, 0.0)], dtype=[(("title 1", "x"), "|i1"), (("title 2", "y"), ">f4")], ), ), ("bool array", np.array([True, False, True])), ("int array", np.array([1, 2, 3], dtype="int8")), ("float16 array", np.zeros((5, 5), dtype=np.float16)), ("3D array", _create_3d_array()), ) LABELED_ARRAYS = ( ("float array", np.random.rand(5, 5), ["a", "b", "c", "d", "e"], None), ( "complex array", np.round(np.random.rand(5, 5) * 10) + np.round(np.random.rand(5, 5) * 10) * 1j, np.linspace(-12, 12, 5), np.linspace(-12, 12, 5), ), ) def insert_rows_and_cols( arr: np.ndarray | np.ma.MaskedArray, default_row_value: Any = DEFAULT_ROW_VALUE, default_col_value: Any = DEFAULT_COL_VALUE, index=DEFAULT_INSERTION_INDEX, insert_size=DEFAULT_INS_DEL_COUNT, default_mask_value=DEFAULT_MASK_VALUE, ) -> np.ndarray | np.ma.MaskedArray: """Inserts new rows and columns into a numpy array and returns the result. Args: arr: numpy array to be edited. default_row_value: Default value to insert. Defaults to DEFAULT_ROW_VALUE. default_col_value: Default value to insert. Defaults to DEFAULT_COL_VALUE. index: index at which to insert. Defaults to DEFAULT_INSERTION_INDEX. insert_size: number of rows/cols to insert. Defaults to DEFAULT_INS_DEL_COUNT. default_mask_value: Default mask value in case the input array is a MaskedArray. Defaults to DEFAULT_MASK_VALUE. Returns: A numpy array with the new rows and columns inserted. """ if arr.ndim == 1: arr.shape = (arr.size, 1) (default_np_row_value,) = np.array([default_row_value], dtype=arr.dtype) arr_2 = np.insert(arr, (index,) * insert_size, default_np_row_value, 0) (default_np_col_value,) = np.array([default_col_value], dtype=arr.dtype) arr_3 = np.insert(arr_2, (index,) * insert_size, default_np_col_value, 1) if isinstance(arr, np.ma.MaskedArray): mask_2 = np.insert(arr.mask, (index,) * insert_size, default_mask_value, 0) mask_3 = np.insert(mask_2, (index,) * insert_size, default_mask_value, 1) arr_3.mask = mask_3 return arr_3 MODIFIED_BASIC_ARRAYS = tuple( (name, insert_rows_and_cols(arr, 1, 2)) for name, arr in BASIC_ARRAYS ) MODIFIED_LABELED_ARRAYS = tuple( (name, insert_rows_and_cols(arr, 1, 2), labelx, labely) for (name, arr, labelx, labely) in LABELED_ARRAYS ) def launch_arrayeditor_insert( data, title="", xlabels=None, ylabels=None ) -> ArrayEditor: """Helper routine to launch an arrayeditor and return its result Args: data: numpy array to be edited. title: title of the arrayeditor. Defaults to "". xlabels: xlabels to use in the ArrayEditor. Defaults to None. ylabels: ylabels to use in the ArrayEditor. Defaults to None. Returns: An ArrayEditor instance with the given data and labels. """ dlg = ArrayEditor() dlg.setup_and_check( data, title, xlabels=xlabels, ylabels=ylabels, variable_size=True ) if data.ndim == 3: dlg.arraywidget.view.model().set_slice(REQUIRED_3D_SLICE) dlg.arraywidget.view.model().insert_row( DEFAULT_INSERTION_INDEX, DEFAULT_INS_DEL_COUNT, DEFAULT_ROW_VALUE ) dlg.arraywidget.view.model().insert_column( DEFAULT_INSERTION_INDEX, DEFAULT_INS_DEL_COUNT, DEFAULT_COL_VALUE ) return dlg def launch_arrayeditor_insert_delete( data: np.ndarray | np.ma.MaskedArray, title="", xlabels=None, ylabels=None, ) -> ArrayEditor: """Creates a new arrayeditor with given data, adds new rows and columns, and then deletes them before opening a new dialog box with the result. Args: data: numpy array to be edited. title: title of the arrayeditor. Defaults to "". xlabels: xlabels to use in the ArrayEditor. Defaults to None. ylabels: ylabels to use in the ArrayEditor. Defaults to None. Returns: An ArrayEditor instance with the given data and labels. """ dlg = launch_arrayeditor_insert(data, title, xlabels, ylabels) dlg.arraywidget.view.model().remove_row( DEFAULT_INSERTION_INDEX, DEFAULT_INS_DEL_COUNT ) dlg.arraywidget.view.model().remove_column( DEFAULT_INSERTION_INDEX, DEFAULT_INS_DEL_COUNT ) return dlg def test_arrayeditor() -> None: """Test array editor for all supported data types""" with qt_app_context(): for (title, data), (_, awaited_result) in zip( BASIC_ARRAYS, MODIFIED_BASIC_ARRAYS ): new_arr_1 = launch_arrayeditor_insert(data, title).get_value() assert (new_arr_1 == awaited_result).all() new_arr_2 = launch_arrayeditor_insert_delete(data, title).get_value() assert (new_arr_2 == data).all() # # TODO: This section can be uncommented when the support for label insertion # # alongside new row/values works # for (title, data, xlabels, ylabels), (*_, awaited_result) in zip( # LABELED_ARRAYS, MODIFIED_LABELED_ARRAYS # ): # new_arr = launch_arrayeditor_insert(data, title, xlabels, ylabels) # # assert (new_arr == awaited_result).all() execenv.print("OK") if __name__ == "__main__": test_arrayeditor() # pprint(MODIFIED_BASIC_ARRAYS) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_codeeditor.py0000644000175100017510000000137315114075001022644 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for codeeditor.py """ # guitest: show from guidata.configtools import get_icon from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.widgets import codeeditor def test_codeeditor(): """Test Code editor.""" with qt_app_context(exec_loop=True): widget = codeeditor.CodeEditor(language="python") widget.set_text_from_file(codeeditor.__file__) widget.resize(800, 600) widget.setWindowTitle("Code editor") widget.setWindowIcon(get_icon("guidata.svg")) widget.show() execenv.print("OK") if __name__ == "__main__": test_codeeditor() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_collectionseditor.py0000644000175100017510000000737515114075001024260 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for collectionseditor.py """ # guitest: show import datetime import numpy as np from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.widgets.collectionseditor import CollectionsEditor try: from PIL import Image as PILImage except ImportError: # PIL is not installed PILImage = None def get_test_data(): """Create test data.""" testdict = {"d": 1, "a": np.random.rand(10, 10), "b": [1, 2]} testdate = datetime.date(1945, 5, 8) test_timedelta = datetime.timedelta(days=-1, minutes=42, seconds=13) try: import pandas as pd except (ModuleNotFoundError, ImportError): test_timestamp = None test_pd_td = None test_dtindex = None test_series = None test_df = None else: test_timestamp = pd.Timestamp("1945-05-08T23:01:00.12345") test_pd_td = pd.Timedelta(days=2193, hours=12) test_dtindex = pd.date_range(start="1939-09-01T", end="1939-10-06", freq="12h") test_series = pd.Series({"series_name": [0, 1, 2, 3, 4, 5]}) test_df = pd.DataFrame( { "string_col": ["a", "b", "c", "d"], "int_col": [0, 1, 2, 3], "float_col": [1.1, 2.2, 3.3, 4.4], "bool_col": [True, False, False, True], } ) class Foobar: """ """ def __init__(self): self.text = "toto" self.testdict = testdict self.testdate = testdate foobar = Foobar() test_data = { "object": foobar, "module": np, "str": "kjkj kj k j j kj k jkj", "unicode": "éù", "list": [1, 3, [sorted, 5, 6], "kjkj", None], "tuple": ([1, testdate, testdict, test_timedelta], "kjkj", None), "dict": testdict, "float": 1.2233, "int": 223, "bool": True, "array": np.random.rand(10, 10).astype(np.int64), "masked_array": np.ma.array( [[1, 0], [1, 0]], mask=[[True, False], [False, False]] ), "1D-array": np.linspace(-10, 10).astype(np.float16), "3D-array": np.random.randint(2, size=(5, 5, 5)).astype(np.bool_), "empty_array": np.array([]), "date": testdate, "datetime": datetime.datetime(1945, 5, 8, 23, 1, 0, int(1.5e5)), "timedelta": test_timedelta, "complex": 2 + 1j, "complex64": np.complex64(2 + 1j), "complex128": np.complex128(9j), "int8_scalar": np.int8(8), "int16_scalar": np.int16(16), "int32_scalar": np.int32(32), "int64_scalar": np.int64(64), "float16_scalar": np.float16(16), "float32_scalar": np.float32(32), "float64_scalar": np.float64(64), "bool_scalar": bool, "bool__scalar": np.bool_(8), "timestamp": test_timestamp, "timedelta_pd": test_pd_td, "datetimeindex": test_dtindex, "series": test_series, "ddataframe": test_df, "None": None, "unsupported1": np.arccos, # Test for Issue #3518 "big_struct_array": np.zeros( 1000, dtype=[("ID", "f8"), ("param1", "f8", 5000)] ), } if PILImage is not None: image = PILImage.fromarray( np.random.randint(256, size=(100, 100)).astype(np.uint8) ).convert("P") test_data["image"] = image return test_data def test_collectionseditor(): """Test Collections editor.""" with qt_app_context(exec_loop=True): dialog = CollectionsEditor() dialog.setup(get_test_data()) dialog.show() execenv.print("OK") if __name__ == "__main__": test_collectionseditor() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_console.py0000644000175100017510000000130215114075001022155 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for codeeditor.py """ # guitest: show from guidata.configtools import get_icon from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.widgets.console import Console def test_console(): """Test Console widget.""" with qt_app_context(exec_loop=True): widget = Console(debug=False, multithreaded=True) widget.resize(800, 600) widget.setWindowTitle("Console") widget.setWindowIcon(get_icon("guidata.svg")) widget.show() execenv.print("OK") if __name__ == "__main__": test_console() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_dataframeeditor.py0000644000175100017510000000375115114075001023660 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for dataframeeditor.py """ # guitest: show import numpy as np import pytest try: import pandas as pd import pandas.testing as pdt from guidata.widgets.dataframeeditor import DataFrameEditor except ImportError: # pandas is not installed pd = pdt = DataFrameEditor = None from guidata.env import execenv from guidata.qthelpers import exec_dialog, qt_app_context @pytest.mark.skipif(pd is None, reason="pandas is not installed") def test_dataframeeditor(): """DataFrame editor test""" def test_edit(data, title="", parent=None): """Test subroutine""" dlg = DataFrameEditor(parent=parent) if dlg.setup_and_check(data, title=title): exec_dialog(dlg) return dlg.get_value() else: import sys sys.exit(1) with qt_app_context(): df1 = pd.DataFrame( [ [True, "bool"], [1 + 1j, "complex"], ["test", "string"], [1.11, "float"], [1, "int"], [np.random.rand(3, 3), "Unkown type"], ["Large value", 100], ["áéí", "unicode"], ], index=["a", "b", np.nan, np.nan, np.nan, "c", "Test global max", "d"], columns=[np.nan, "Type"], ) out = test_edit(df1) pdt.assert_frame_equal(df1, out) result = pd.Series([True, "bool"], index=[np.nan, "Type"], name="a") out = test_edit(df1.iloc[0]) pdt.assert_series_equal(result, out) df1 = pd.DataFrame(np.random.rand(100100, 10)) out = test_edit(df1) pdt.assert_frame_equal(out, df1) series = pd.Series(np.arange(10), name=0) out = test_edit(series) pdt.assert_series_equal(series, out) execenv.print("OK") if __name__ == "__main__": test_dataframeeditor() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_importwizard.py0000644000175100017510000000134715114075001023257 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for importwizard.py """ # guitest: show import pytest from guidata.env import execenv from guidata.qthelpers import exec_dialog, qt_app_context from guidata.widgets.importwizard import ImportWizard @pytest.fixture() def text(): """Return text to test""" return "17/11/1976\t1.34\n14/05/09\t3.14" def test_importwizard(text): """Test""" with qt_app_context(): dialog = ImportWizard(None, text) if exec_dialog(dialog): execenv.print(dialog.get_data()) execenv.print("OK") if __name__ == "__main__": test_importwizard("17/11/1976\t1.34\n14/05/09\t3.14") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_objecteditor.py0000644000175100017510000000275615114075001023206 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License """ Tests for objecteditor.py """ # guitest: show import datetime import numpy as np try: from PIL import Image except ImportError: # PIL is not installed Image = None from guidata.env import execenv from guidata.qthelpers import qt_app_context from guidata.widgets.objecteditor import oedit def test_objecteditor(): """Run object editor test""" with qt_app_context(): example = { "str": "kjkj kj k j j kj k jkj", "list": [1, 3, 4, "kjkj", None], "dict": {"d": 1, "a": np.random.rand(10, 10), "b": [1, 2]}, "float": 1.2233, "array": np.random.rand(10, 10), "date": datetime.date(1945, 5, 8), "datetime": datetime.datetime(1945, 5, 8), } if Image is not None: data = np.random.randint(255, size=(100, 100)).astype("uint8") image = Image.fromarray(data) example["image"] = image image = oedit(image) class Foobar: """ """ def __init__(self): self.text = "toto" foobar = Foobar() execenv.print(oedit(foobar)) execenv.print(oedit(example)) execenv.print(oedit(np.random.rand(10, 10))) execenv.print(oedit(oedit.__doc__)) execenv.print(example) execenv.print("OK") if __name__ == "__main__": test_objecteditor() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/tests/widgets/test_theme.py0000644000175100017510000001040015114075001021614 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Test dark/light theme switching """ from __future__ import annotations import os import sys from typing import Literal import pytest from qtpy import QtCore as QC from qtpy import QtWidgets as QW from guidata import qthelpers as qth from guidata.widgets.codeeditor import CodeEditor from guidata.widgets.console import Console class BaseColorModeWidget(QW.QWidget): """Base class for testing dark/light theme switching""" SIZE = (1200, 600) def __init__(self, default: Literal["light", "dark", "auto"] = qth.AUTO) -> None: super(BaseColorModeWidget, self).__init__() self.resize(*self.SIZE) self.default_theme = default self.combo: QW.QComboBox | None = None self.setWindowTitle(self.__doc__) self.grid_layout = QW.QGridLayout() self.setLayout(self.grid_layout) self.setup_widgets() if default != qth.AUTO: self.change_color_mode(default) def setup_widgets(self): """Setup widgets""" label = QW.QLabel("Select color mode:") self.combo = QW.QComboBox() self.combo.addItems(qth.COLOR_MODES) self.combo.setCurrentText(self.default_theme) self.combo.currentTextChanged.connect(self.change_color_mode) self.combo.setSizePolicy(QW.QSizePolicy.Expanding, QW.QSizePolicy.Minimum) self.combo.setToolTip( "Select color mode:" "
  • auto: follow system settings
  • " "
  • light: use light theme
  • " "
  • dark: use dark theme
" ) hlayout = QW.QHBoxLayout() hlayout.addWidget(label) hlayout.addWidget(self.combo) self.grid_layout.addLayout(hlayout, 0, 0, 1, -1) def change_color_mode(self, mode: str) -> None: """Change color mode""" qth.set_color_mode(mode) def closeEvent(self, event): """Close event""" self.console.close() event.accept() class ColorModeWidget(BaseColorModeWidget): """Testing color mode switching for guidata's widgets: code editor and console""" def __init__(self, default: Literal["light", "dark", "auto"] = qth.AUTO) -> None: self.editor: CodeEditor | None = None self.console: Console | None = None super().__init__(default) qth.win32_fix_title_bar_background(self) def setup_widgets(self): """Setup widgets""" super().setup_widgets() self.editor = CodeEditor(self) self.console = Console(self, debug=False) for widget in (self.editor, self.console): widget.setSizePolicy(QW.QSizePolicy.Expanding, QW.QSizePolicy.Expanding) self.editor.setPlainText("Change theme using the combo box above" + os.linesep) self.add_info_to_codeeditor() self.console.execute_command("print('Console output')") self.grid_layout.addWidget(self.editor, 1, 0) self.grid_layout.addWidget(self.console, 1, 1) def add_info_to_codeeditor(self): """Add current color mode and theme to the code editor, with a prefix with date and time""" self.editor.setPlainText( os.linesep.join( [ self.editor.toPlainText(), "", f"{QC.QDateTime.currentDateTime().toString()}:", f" Current color mode: {qth.get_color_mode()}", f" Current theme: {qth.get_color_theme()}", ] ) ) def change_color_mode(self, mode: str) -> None: """Change color mode""" super().change_color_mode(mode) for widget in (self.editor, self.console): widget.update_color_mode() self.add_info_to_codeeditor() @pytest.mark.skipif(reason="Not suitable for automated testing") def test_dark_light_themes( default: Literal["light", "dark", "auto"] | None = None, ) -> None: """Test dark/light theme switching""" with qth.qt_app_context(exec_loop=True): widget = ColorModeWidget(default=qth.AUTO if default is None else default) widget.show() if __name__ == "__main__": test_dark_light_themes(None if len(sys.argv) < 2 else sys.argv[1]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/userconfig.py0000644000175100017510000003716315114075001017026 0ustar00runnerrunner#!/usr/bin/env python # -*- coding: utf-8 -*- # userconfig License Agreement (MIT License) # ------------------------------------------ # # Copyright © 2009-2012 Pierre Raybaut # # Permission is hereby granted, free of charge, to any person # obtaining a copy of this software and associated documentation # files (the "Software"), to deal in the Software without # restriction, including without limitation the rights to use, # copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following # conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES # OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR # OTHER DEALINGS IN THE SOFTWARE. """ User configuration management ----------------------------- The ``guidata.userconfig`` module provides user configuration file (.ini file) management features based on ``ConfigParser`` (standard Python library). It is the exact copy of the open-source package `userconfig` (MIT license). This module provides the following functions and classes: * :py:func:`get_home_dir`: return user home directory * :py:func:`get_config_basedir`: return user configuration base directory * :py:class:`UserConfig`: user configuration file management class .. autofunction:: get_home_dir .. autofunction:: get_config_basedir .. autoclass:: UserConfig :members: """ import configparser as cp import os import os.path as osp import re import sys import time from typing import Any def try_remove_file(path: str, wait_time: float = 0.05, retries: int = 20) -> None: """Try to remove a file, waiting if necessary. Args: path: The path of the file to remove. wait_time: The time in seconds to wait between checks. retries: The number of times to retry before giving up. Raises: IOError: If the file cannot be removed. """ if os.path.isfile(path): attempt = 0 while attempt < retries: try: os.remove(path) return except IOError: time.sleep(wait_time) attempt += 1 raise IOError(f"Unable to remove file {path} due to file lock") def get_home_dir() -> str: """Return user home directory""" try: # expanduser() returns a raw byte string which needs to be # decoded with the codec that the OS is using to represent # file paths. path = os.fsdecode(osp.expanduser("~")) except Exception: path = "" if osp.isdir(path): return path else: # Get home from alternative locations for env_var in ("HOME", "USERPROFILE", "TMP"): # os.environ.get() returns a raw byte string which needs to be # decoded with the codec that the OS is using to represent # environment variables. path = os.fsdecode(os.environ.get(env_var, "")) if osp.isdir(path): return path else: path = "" if not path: raise RuntimeError( "Please set the environment variable HOME to " "your user/home directory path." ) def get_config_basedir() -> str: """Return user configuration base directory.""" if sys.platform.startswith("linux"): # Follow the XDG standard to save settings home = os.environ.get("XDG_CONFIG_HOME", "") if not home: home = osp.join(get_home_dir(), ".config") if not osp.isdir(home): os.makedirs(home) return home return get_home_dir() class NoDefault: """ Custom object used to explicitly mark that no default value were provided. """ pass class UserConfig(cp.ConfigParser): """ UserConfig class, based on ConfigParser name: name of the config options: dictionary containing options *or* list of tuples (section_name, options) Note that "get" and "set" arguments number and type differ from the overriden methods """ default_section_name = "main" def __init__(self, defaults): cp.ConfigParser.__init__(self) self.name = "none" self.raw = 0 # 0 = substitutions are enabled / 1 = raw config parser assert isinstance(defaults, dict) for _key, val in list(defaults.items()): assert isinstance(val, dict) if self.default_section_name not in defaults: defaults[self.default_section_name] = {} self.defaults = defaults self.reset_to_defaults(save=False) self.check_default_values() def update_defaults(self, defaults): """Update the default configuration :param defaults: dict section -> dict {option: default value} """ for key, sectdict in list(defaults.items()): if key not in self.defaults: self.defaults[key] = sectdict else: self.defaults[key].update(sectdict) self.reset_to_defaults(save=False) def save(self): """Save the configuration.""" # In any case, the resulting config is saved in config file: self.__save() def set_application(self, name, version, load=True, raw_mode=False): """ Set the application name and version :param name: name of the application :param version: current version in format "X.Y.Z" :param load: If True, load the configuration from dict :param raw_mode: If True, enable raw mode of ConfigParser """ self.name = name self.raw = 1 if raw_mode else 0 if (version is not None) and (re.match(r"^(\d+).(\d+).(\d+)", version) is None): raise RuntimeError( f"Version number {version!r} is incorrect - must be in X.Y.Z format" ) if load: # If config file already exists, it overrides Default options: self.__load() if version != self.get_version(version): # Version has changed -> overwriting .ini file self.reset_to_defaults(save=False) self.__remove_deprecated_options() # Set new version number self.set_version(version, save=False) if self.defaults is None: # If no defaults are defined, set .ini file settings as default self.set_as_defaults() def check_default_values(self): """Check the static options for forbidden data types""" errors = [] def _check(key, value): if value is None: return if isinstance(value, dict): for k, v in list(value.items()): _check(key + "{}", k) _check(key + "/" + k, v) elif isinstance(value, (list, tuple)): for v in value: _check(key + "[]", v) else: if not isinstance(value, (bool, int, float, str)): errors.append(f"Invalid value for {key}: {value}") for name, section in list(self.defaults.items()): assert isinstance(name, str) for key, value in list(section.items()): _check(key, value) if errors: for err in errors: print(err) raise ValueError("Invalid default values") def get_version(self, version="0.0.0"): """Return configuration (not application!) version""" return self.get(self.default_section_name, "version", version) def set_version(self, version="0.0.0", save=True): """Set configuration (not application!) version""" self.set(self.default_section_name, "version", version, save=save) def __load(self): """ Load config from the associated .ini file """ try: self.read(self.filename(), encoding="utf-8") except cp.MissingSectionHeaderError: print("Warning: File contains no section headers.") def __remove_deprecated_options(self): """ Remove options which are present in the .ini file but not in defaults """ for section in self.sections(): for option, _ in self.items(section, raw=self.raw): if self.get_default(section, option) is NoDefault: self.remove_option(section, option) if len(self.items(section, raw=self.raw)) == 0: self.remove_section(section) def __save(self): """ Save config into the associated .ini file """ fname = self.filename() if osp.isfile(fname): try_remove_file(fname, wait_time=0.05, retries=20) os.makedirs(osp.dirname(fname), mode=0o700, exist_ok=True) with open(fname, "w", encoding="utf-8") as configfile: self.write(configfile) def get_path(self, basename: str) -> str: """Return filename path inside configuration directory""" config_dir = osp.join(get_config_basedir(), f".{self.name}") if not osp.isdir(config_dir): os.makedirs(config_dir) return osp.join(config_dir, basename) def filename(self) -> str: """Return configuration file name""" return self.get_path(f"{self.name}.ini") def cleanup(self): """ Remove .ini file associated to config """ os.remove(self.filename()) def set_as_defaults(self): """ Set defaults from the current config """ self.defaults = {} for section in self.sections(): secdict = {} for option, value in self.items(section, raw=self.raw): secdict[option] = value self.defaults[section] = secdict def reset_to_defaults(self, save=True, verbose=False): """ Reset config to Default values """ for section, options in list(self.defaults.items()): for option in options: value = options[option] self.__set(section, option, value, verbose) if save: self.__save() def __check_section_option(self, section, option): """ Private method to check section and option types """ if section is None: section = self.default_section_name elif not isinstance(section, str): raise RuntimeError("Argument 'section' must be a string") if not isinstance(option, str): raise RuntimeError("Argument 'option' must be a string") return section def get_default(self, section, option): """ Get Default value for a given (section, option) Useful for type checking in 'get' method """ section = self.__check_section_option(section, option) options = self.defaults.get(section, {}) return options.get(option, NoDefault) def get(self, section, option, default: Any = NoDefault, raw=None, **kwargs): """ Get an option section=None: attribute a default section name default: default value (if not specified, an exception will be raised if option doesn't exist) """ if raw is None: raw = self.raw section = self.__check_section_option(section, option) if not self.has_section(section): if default is NoDefault: raise RuntimeError(f"Unknown section {section!r}") else: self.add_section(section) if not self.has_option(section, option): if default is NoDefault: raise RuntimeError(f"Unknown option {section!r}/{option!r}") else: self.set(section, option, default) return default value = cp.ConfigParser.get(self, section, option, raw=raw) default_value = self.get_default(section, option) if isinstance(default_value, bool): value = eval(value) elif isinstance(default_value, float): value = float(value) elif isinstance(default_value, int): value = int(value) elif isinstance(default_value, str): pass else: try: # lists, tuples, ... value = eval(value) except Exception: pass return value def get_section(self, section): """Returns configuration values of the given section. The returned dict includes unset default values. :param section: section name :return: dict option name -> value """ sect = self.defaults.get(section, {}).copy() for opt in self.options(section): sect[opt] = self.get(section, opt) return sect def __set(self, section, option, value, verbose): """ Private set method """ if not self.has_section(section): self.add_section(section) if not isinstance(value, str): value = repr(value) if verbose: print("{}[ {} ] = {}".format(section, option, value)) cp.ConfigParser.set(self, section, option, value) def set_default(self, section, option, default_value): """ Set Default value for a given (section, option) -> called when a new (section, option) is set and no default exists """ section = self.__check_section_option(section, option) options = self.defaults.setdefault(section, {}) options[option] = default_value def set(self, section, option, value, verbose=False, save=True): """ Set an option section=None: attribute a default section name """ section = self.__check_section_option(section, option) default_value = self.get_default(section, option) if default_value is NoDefault: default_value = value self.set_default(section, option, default_value) if isinstance(default_value, bool): value = bool(value) elif isinstance(default_value, float): value = float(value) elif isinstance(default_value, int): value = int(value) elif not isinstance(default_value, str): value = repr(value) self.__set(section, option, value, verbose) if save: self.__save() def remove_section(self, section): """Remove the given section and save the configuration.""" cp.ConfigParser.remove_section(self, section) self.__save() def remove_option(self, section, option): """ Remove the given option from the given section and save the configuration. """ cp.ConfigParser.remove_option(self, section, option) self.__save() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6588857 guidata-3.13.4/guidata/utils/0000755000175100017510000000000015114075015015443 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/__init__.py0000644000175100017510000000007615114075001017552 0ustar00runnerrunner# -*- coding: utf-8 -*- """Utilities package for guidata.""" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/cleanup.py0000644000175100017510000003252615114075001017447 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright (c) 2025, Codra, Pierre Raybaut. # Licensed under the terms of the BSD 3-Clause """ cleanup ======= Module for cleaning up build artifacts, cache files, and other temporary files from Python projects. This module provides functionality to clean various types of files generated during development, testing, and building processes. This module can be used programmatically or as a command-line tool. """ from __future__ import annotations import argparse import os import re import shutil import sys from pathlib import Path def get_project_root(start_path: Path | str | None = None) -> Path: """Find the project root directory by looking for pyproject.toml. Args: start_path: Starting path to search from. If None, uses current directory. Returns: The project root directory containing pyproject.toml. Raises: FileNotFoundError: If pyproject.toml is not found in the directory tree. """ if start_path is None: start_path = Path.cwd() elif isinstance(start_path, str): start_path = Path(start_path) current = start_path.resolve() # Look for pyproject.toml in current directory and parent directories while current != current.parent: if (current / "pyproject.toml").exists(): return current current = current.parent # Check root directory if (current / "pyproject.toml").exists(): return current raise FileNotFoundError( f"pyproject.toml not found in {start_path} or any parent directory" ) def get_lib_name(project_root: Path) -> str: """Extract library name from pyproject.toml. Args: project_root: The root directory of the project. Returns: The library name extracted from pyproject.toml. Raises: FileNotFoundError: If pyproject.toml is not found. ValueError: If the 'name' field cannot be found in pyproject.toml. """ pyproject_path = project_root / "pyproject.toml" if not pyproject_path.exists(): raise FileNotFoundError(f"pyproject.toml not found in {project_root}") with open(pyproject_path, "r", encoding="utf-8") as f: content = f.read() # Look for name = "..." in [project] section match = re.search( r'\[project\].*?name\s*=\s*["\']([^"\']+)["\']', content, re.DOTALL ) if match: return match.group(1) raise ValueError("Could not find 'name' field in pyproject.toml [project] section") def get_mod_name(project_root: Path) -> str: """Get the main module name by looking for directories in the project. Args: project_root: The root directory of the project. Returns: The main module name (typically the library name with hyphens replaced by '_'). """ lib_name = get_lib_name(project_root) # Convert hyphens to underscores for module name mod_name = lib_name.replace("-", "_") # Check if a directory with this name exists if (project_root / mod_name).exists(): return mod_name # Otherwise return the lib_name as is return lib_name def remove_if_exists(path: Path) -> None: """Remove a file or directory if it exists. Args: path: Path to the file or directory to remove. """ if path.exists(): if path.is_dir(): print(f" Removing directory: {path}") shutil.rmtree(path) else: print(f" Removing file: {path}") path.unlink() def remove_glob_pattern(pattern: str, search_root: Path) -> None: """Remove files matching a glob pattern. Args: pattern: Glob pattern to match files/directories. search_root: Root directory to search from. """ matches = list(search_root.glob(pattern)) if matches: print(f" Removing {len(matches)} items matching pattern: {pattern}") for match in matches: try: if match.is_dir(): if match.name == "__pycache__": print(f" Removing __pycache__ directory: {match}") shutil.rmtree(match) else: match.unlink() except (OSError, PermissionError) as e: print(f" ⚠️ Warning: Could not remove {match}: {e}") def clean_python_cache(project_root: Path) -> None: """Remove Python cache files and directories. Args: project_root: The root directory of the project. """ print(" Cleaning Python cache files...") # Remove .pyc and .pyo files remove_glob_pattern("**/*.pyc", project_root) remove_glob_pattern("**/*.pyo", project_root) # Remove __pycache__ directories for pycache_dir in project_root.rglob("__pycache__"): if pycache_dir.is_dir(): remove_if_exists(pycache_dir) def clean_build_artifacts(project_root: Path, lib_name: str, mod_name: str) -> None: """Remove build artifacts and egg-info directories. Args: project_root: The root directory of the project. lib_name: The library name from pyproject.toml. mod_name: The main module name. """ print(" Cleaning build artifacts...") # Remove egg-info directories remove_if_exists(project_root / f"{lib_name}.egg-info") remove_if_exists(project_root / f"{mod_name}.egg-info") # Remove build/dist directories and MANIFEST remove_if_exists(project_root / "MANIFEST") remove_if_exists(project_root / "build") remove_if_exists(project_root / "dist") remove_if_exists(project_root / "doc" / "_build") def clean_coverage_files(project_root: Path) -> None: """Remove coverage-related files. Args: project_root: The root directory of the project. """ print(" Cleaning coverage files...") remove_if_exists(project_root / ".coverage") remove_if_exists(project_root / "coverage.xml") remove_if_exists(project_root / "htmlcov") remove_if_exists(project_root / "sitecustomize.py") remove_if_exists(project_root / ".pytest_cache") # Remove .coverage.* files remove_glob_pattern(".coverage.*", project_root) def clean_profile_files(project_root: Path) -> None: """Remove profiling-related files. Args: project_root: The root directory of the project. """ print(" Cleaning profile files...") remove_glob_pattern("*.prof", project_root) remove_glob_pattern("*.prof.*", project_root) prof_dir = project_root / "prof" if prof_dir.exists(): remove_glob_pattern("*.prof", prof_dir) remove_glob_pattern("*.prof.*", prof_dir) remove_glob_pattern("*.svg", prof_dir) def clean_backup_files(project_root: Path) -> None: """Remove backup files and version control leftovers. Args: project_root: The root directory of the project. """ print(" Cleaning backup files and version control leftovers...") remove_glob_pattern("**/*.bak", project_root) remove_glob_pattern("**/*~", project_root) remove_glob_pattern("**/*.orig", project_root) def clean_log_files(project_root: Path) -> None: """Remove log files. Args: project_root: The root directory of the project. """ print(" Cleaning log files...") remove_glob_pattern("**/*.log", project_root) def clean_localization_files(project_root: Path, mod_name: str) -> None: """Remove localization template files. Args: project_root: The root directory of the project. mod_name: The main module name. """ print(" Cleaning localization files...") remove_if_exists(project_root / "doc" / "locale" / "pot") remove_glob_pattern(f"{mod_name}/locale/{mod_name}.pot", project_root) def clean_documentation_files(project_root: Path, lib_name: str, mod_name: str) -> None: """Remove documentation generation artifacts. Args: project_root: The root directory of the project. lib_name: The library name from pyproject.toml. mod_name: The main module name. """ print(" Cleaning documentation files...") remove_if_exists(project_root / "doc" / "changelog.md") remove_if_exists(project_root / "doc" / "auto_examples") remove_if_exists(project_root / "doc" / "sg_execution_times.rst") remove_glob_pattern(f"{mod_name}/data/doc/{lib_name}*.pdf", project_root) def clean_wix_installer_files(project_root: Path, lib_name: str) -> None: """Remove WiX installer related files. Args: project_root: The root directory of the project. lib_name: The library name from pyproject.toml. """ print(" Cleaning WiX installer files...") wix_dir = project_root / "wix" if wix_dir.exists(): remove_if_exists(wix_dir / "bin") remove_if_exists(wix_dir / "obj") remove_glob_pattern("*.bmp", wix_dir) remove_glob_pattern("*.wixpdb", wix_dir) remove_glob_pattern(f"{lib_name}*.wxs", wix_dir) def clean_empty_directories(project_root: Path) -> None: """Remove empty directories left by version control branch switching. Args: project_root: The root directory of the project. """ print(" Cleaning empty directories...") # Multiple passes to handle nested empty directories total_removed = 0 max_passes = 10 # Prevent infinite loops for pass_num in range(max_passes): removed_this_pass = 0 # Get all directories, sorted by depth (deepest first) all_dirs = [] for dir_path in project_root.rglob("*"): if ( dir_path.is_dir() and dir_path != project_root and not str(dir_path).endswith(".git") and not str(dir_path).endswith(".venv") and "site-packages" not in str(dir_path) ): all_dirs.append(dir_path) # Sort by depth (deepest first) to remove nested empty dirs all_dirs.sort(key=lambda x: len(x.parts), reverse=True) for empty_dir in all_dirs: try: if empty_dir.exists() and not any(empty_dir.iterdir()): empty_dir.rmdir() removed_this_pass += 1 total_removed += 1 except OSError: # Directory might not be empty or permission issue pass # If no directories were removed this pass, we're done if removed_this_pass == 0: break if total_removed > 0: print(f" ✓ Removed {total_removed} empty directories") def clean_public_repo_dirs(project_root: Path, lib_name: str) -> None: """Remove directories related to public repository upload. Args: project_root: The root directory of the project. lib_name: The library name from pyproject.toml. """ print(" Cleaning public repository directories...") parent_dir = project_root.parent temp_dir = parent_dir / f"{lib_name}_temp" public_dir = parent_dir / f"{lib_name}_public" remove_if_exists(temp_dir) remove_if_exists(public_dir) def run_cleanup(project_root: Path | str | None = None) -> None: """Run all cleanup operations on a project. Args: project_root: The root directory of the project. If None, will search for the project root starting from the current directory. """ try: # Get project information if project_root is None: project_root = get_project_root() elif isinstance(project_root, str): project_root = Path(project_root) print(f"🧹 Cleaning up repository: {project_root}") lib_name = get_lib_name(project_root) mod_name = get_mod_name(project_root) print(f"Library name: {lib_name}") print(f"Module name: {mod_name}") # Change to project root directory original_cwd = os.getcwd() try: os.chdir(project_root) # Perform cleanup operations clean_build_artifacts(project_root, lib_name, mod_name) clean_python_cache(project_root) clean_public_repo_dirs(project_root, lib_name) clean_coverage_files(project_root) clean_profile_files(project_root) clean_backup_files(project_root) clean_log_files(project_root) clean_localization_files(project_root, mod_name) clean_documentation_files(project_root, lib_name, mod_name) clean_wix_installer_files(project_root, lib_name) clean_empty_directories(project_root) print("🆗 Cleanup completed successfully!") finally: os.chdir(original_cwd) except Exception as e: print(f"Error during cleanup: {e}", file=sys.stderr) raise def main() -> None: """Main function for command-line interface.""" parser = argparse.ArgumentParser( description="Clean up build artifacts, cache files, " "and temporary files from Python projects." ) parser.add_argument( "path", nargs="?", default=None, help="Path to the project root directory " "(default: search from current directory)", ) parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose output" ) args = parser.parse_args() try: run_cleanup(args.path) except Exception as e: if args.verbose: raise else: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/encoding.py0000644000175100017510000001240015114075001017573 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """ Text encoding utilities, text file I/O Functions 'get_coding', 'decode', 'encode' come from Eric4 source code (Utilities/__init___.py) Copyright © 2003-2009 Detlev Offenbach """ from __future__ import annotations import os import re from codecs import BOM_UTF8, BOM_UTF16, BOM_UTF32 # ------------------------------------------------------------------------------ # Functions for encoding and decoding *text data* itself, usually originating # from or destined for the *contents* of a file. # ------------------------------------------------------------------------------ # Codecs for working with files and text. CODING_RE = re.compile(r"coding[:=]\s*([-\w_.]+)") CODECS = [ "utf-8", "iso8859-1", "iso8859-15", "ascii", "koi8-r", "cp1251", "koi8-u", "iso8859-2", "iso8859-3", "iso8859-4", "iso8859-5", "iso8859-6", "iso8859-7", "iso8859-8", "iso8859-9", "iso8859-10", "iso8859-13", "iso8859-14", "latin-1", "utf-16", ] def get_coding(text: str) -> str | None: """ Function to get the coding of a text. @param text text to inspect (string) @return coding string """ for line in text.splitlines()[:2]: try: result = CODING_RE.search(str(line)) except UnicodeDecodeError: # This could fail because str assume the text # is utf8-like and we don't know the encoding to give # it to str pass else: if result: codec = result.group(1) # sometimes we find a false encoding that can # result in errors if codec in CODECS: return codec def decode(text: bytes) -> tuple[str, str] | tuple[str, str] | tuple[str, str]: """ Function to decode a text. @param text text to decode (bytes) @return decoded text and encoding """ try: if text.startswith(BOM_UTF8): # UTF-8 with BOM return str(text[len(BOM_UTF8) :], "utf-8"), "utf-8-bom" elif text.startswith(BOM_UTF16): # UTF-16 with BOM return str(text[len(BOM_UTF16) :], "utf-16"), "utf-16" elif text.startswith(BOM_UTF32): # UTF-32 with BOM return str(text[len(BOM_UTF32) :], "utf-32"), "utf-32" coding = get_coding(text) if coding: return str(text, coding), coding except (UnicodeError, LookupError): pass # Assume UTF-8 try: return str(text, "utf-8"), "utf-8-guessed" except (UnicodeError, LookupError): pass # Assume Latin-1 (behaviour before 3.7.1) return str(text, "latin-1"), "latin-1-guessed" def encode(text: str, orig_coding: str) -> tuple[bytes, str] | tuple[bytes, str]: """ Function to encode a text. @param text text to encode (string) @param orig_coding type of the original coding (string) @return encoded text and encoding """ if orig_coding == "utf-8-bom": return BOM_UTF8 + text.encode("utf-8"), "utf-8-bom" # Try saving with original encoding if orig_coding: try: return text.encode(orig_coding), orig_coding except (UnicodeError, LookupError): pass # Try declared coding spec coding = get_coding(text) if coding: try: return text.encode(coding), coding except (UnicodeError, LookupError): raise RuntimeError("Incorrect encoding (%s)" % coding) if ( orig_coding and orig_coding.endswith("-default") or orig_coding.endswith("-guessed") ): coding = orig_coding.replace("-default", "") coding = orig_coding.replace("-guessed", "") try: return text.encode(coding), coding except (UnicodeError, LookupError): pass # Try saving as ASCII try: return text.encode("ascii"), "ascii" except UnicodeError: pass # Save as UTF-8 without BOM return text.encode("utf-8"), "utf-8" def write(text: str, filename: str, encoding: str = "utf-8", mode: str = "wb") -> str: """ Write 'text' to file ('filename') assuming 'encoding' Return (eventually new) encoding """ text, encoding = encode(text, encoding) with open(filename, mode) as textfile: textfile.write(text) return encoding def writelines( lines: list[str], filename: str, encoding: str = "utf-8", mode: str = "wb" ) -> str: """ Write 'lines' to file ('filename') assuming 'encoding' Return (eventually new) encoding """ return write(os.linesep.join(lines), filename, encoding, mode) def read(filename: str, encoding: str = "utf-8") -> tuple[str, str]: """ Read text from file ('filename') Return text and encoding """ with open(filename, "rb") as file: text, encoding = decode(file.read()) return text, encoding def readlines(filename: str, encoding: str = "utf-8") -> tuple[list[str], str]: """ Read lines from file ('filename') Return lines and encoding """ text, encoding = read(filename, encoding) return text.split(os.linesep), encoding ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/genreqs.py0000644000175100017510000002136115114075001017457 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright (c) 2023, Codra, Pierre Raybaut. # Licensed under the terms of the BSD 3-Clause """ genreqs ======= Module for generating requirements tables in documentation and requirements files from `pyproject.toml` file. """ from __future__ import annotations import argparse import os.path as osp import pathlib import re import requests try: import tomllib # type: ignore # Python 3.11+ except ImportError: import tomli as tomllib # fallback for Python <3.11 def __parse_toml_requirements(pyproject_fname: str) -> dict[str, list[str]] | None: """Extract requirements from pyproject.toml file. Args: pyproject_fname: Path to the pyproject.toml file. Returns: A tuple containing dictionary of requirements (main project and optional dependencies), or None, if pyproject.toml file does not exist """ if not osp.isfile(pyproject_fname): return None with open(pyproject_fname, "rb") as f: data = tomllib.load(f) requirements = {} # Get main project dependencies prj = data["project"] requirements["__name"] = prj.get("name", "project") requirements["main"] = ["Python" + prj["requires-python"]] + prj["dependencies"] # Get optional dependencies optional_deps = prj.get("optional-dependencies", {}) requirements.update(optional_deps) return requirements def __get_pypi_package_info(package: str) -> str: """Get package summary from PyPI. Args: package: Package name Returns: Package summary or empty string if package not found on PyPI """ if package == "Python": return "Python programming language" try: response = requests.get(f"https://pypi.org/pypi/{package}/json") except requests.exceptions.ConnectionError: return "" if response.status_code != 200: return "" return response.json()["info"]["summary"] def __convert_requirements_to_rst_table(reqs: list[str]) -> str: """Convert requirements list to RST table. Args: reqs: Requirements list Returns: RST table as a string """ requirements = [ ".. list-table::", " :header-rows: 1", " :align: left", "", " * - Name", " - Version", " - Summary", ] modlist = [] for req in reqs: try: # Split by environment marker first (semicolon) req_parts = req.split(";", 1) req_main = req_parts[0].strip() env_marker = req_parts[1].strip() if len(req_parts) > 1 else "" # Now split the main requirement by version operators mod = re.split(" ?(>=|<=|=|<|>)", req_main)[0] ver = req_main[len(mod) :].strip() # Add environment marker to version if present if env_marker: ver = f"{ver} ({env_marker})" if ver else f"({env_marker})" except ValueError: mod, ver = req, "" if mod.lower() in modlist: continue modlist.append(mod.lower()) requirements.append(" * - " + mod) requirements.append(" - " + ver) summary = __get_pypi_package_info(mod) requirements.append(" - " + summary) return "\n".join(requirements) def generate_requirements_rst( pyproject_fname: str, output_directory: str | None = None, ) -> None: """Generate install 'requirements.rst' reStructuredText text. This reStructuredText text is written in a file which is by default located in the `doc` folder of the module. Args: pyproject_fname: Path to folder containing pyproject.toml or setup.cfg file output_directory: Destination path for requirements.rst file (optional). """ requirements = __parse_toml_requirements(pyproject_fname) if requirements is None: print(f"❌ File not found: {pyproject_fname}") return name = requirements.pop("__name", "project") text = f"""The `{name}` package requires the following Python modules: {__convert_requirements_to_rst_table(requirements["main"])}""" for category, title in ( ("qt", "GUI support (Qt)"), ("dev", "development"), ("doc", "building the documentation"), ("test", "running test suite"), ): if category in requirements: text += f""" Optional modules for {title}: {__convert_requirements_to_rst_table(requirements[category])}""" if output_directory is None: output_directory = osp.join(osp.dirname(pyproject_fname), "doc") with open(osp.join(output_directory, "requirements.rst"), "w") as fdesc: fdesc.write(text) print(f"✅ Wrote requirements.rst to {output_directory}") def __extract_exact_requirements(dependencies: list[str]) -> list[str]: """Return requirements exactly as written. Args: dependencies: List of dependency strings from pyproject.toml. Returns: List of exact requirements. """ return list(dependencies) def generate_requirements_txt( pyproject_fname: str, output_fname: str, include_optional: bool = False, ) -> None: """Process the pyproject.toml file and write requirements to output file. Args: pyproject_path: Path to the pyproject.toml file. output_path: Path to the output requirements file. include_optional: If True, include optional dependencies. """ pyproject_path = pathlib.Path(pyproject_fname) output_path = pathlib.Path(output_fname) if not pyproject_path.exists(): print(f"❌ File not found: {pyproject_path}") return with pyproject_path.open("rb") as f: data = tomllib.load(f) project = data.get("project", {}) deps = project.get("dependencies", []) all_deps = __extract_exact_requirements(deps) if include_optional: opt_deps = project.get("optional-dependencies", {}) for extra, deps in opt_deps.items(): all_deps += __extract_exact_requirements(deps) # Remove duplicates and sort all_deps = sorted(set(all_deps)) with output_path.open("w", encoding="utf-8") as f: for dep in all_deps: f.write(dep + "\n") print(f"✅ Wrote {len(all_deps)} requirements to {output_path}") def main() -> None: """Main function to parse arguments and process requirements.""" parser = argparse.ArgumentParser( description="Convert pyproject.toml to documentation or requirements files", epilog="Use 'rst' to generate requirements.rst, " "'txt' for requirements.txt, " "'all' for both formats.", ) subparsers = parser.add_subparsers(dest="command", required=True) txt_parser = subparsers.add_parser("txt", help="Generate requirements.txt") txt_parser.add_argument( "--pyproject", default="pyproject.toml", help="Path to pyproject.toml (default: pyproject.toml)", ) txt_parser.add_argument( "--output", default=None, help="Output filename (default: requirements.txt)", ) txt_parser.add_argument( "--include-optional", action="store_true", default=True, help="Include optional dependencies", ) rst_parser = subparsers.add_parser("rst", help="Generate requirements.rst") rst_parser.add_argument( "--pyproject", default="pyproject.toml", help="Path to pyproject.toml (default: pyproject.toml)", ) rst_parser.add_argument( "--output", default=None, help="Output directory for requirements.rst (default: doc)", ) subparsers.add_parser( "all", help="Generate both requirements.txt and requirements.rst with defaults" ) args = parser.parse_args() if args.command == "txt": default_output = "requirements.txt" generate_requirements_txt( pyproject_fname=osp.abspath(args.pyproject), output_fname=osp.abspath(args.output or default_output), include_optional=args.include_optional, ) elif args.command == "rst": generate_requirements_rst( pyproject_fname=osp.abspath(args.pyproject), output_directory=osp.abspath(args.output) if args.output else None, ) elif args.command == "all": # Generate both requirements.txt and requirements.rst generate_requirements_txt( pyproject_fname=osp.abspath("pyproject.toml"), output_fname=osp.abspath("requirements.txt"), include_optional=True, ) generate_requirements_rst( pyproject_fname=osp.abspath("pyproject.toml"), output_directory=osp.abspath("doc"), ) else: parser.print_help() raise ValueError("Unknown command: {}".format(args.command)) if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/gitreport.py0000644000175100017510000001673015114075001020036 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright (c) 2025, DataLab Platform Developers, BSD 3-Clause license, # see LICENSE file. """ gitreport ========= Module for collecting Git repository information from Python modules. This utility is designed to be used by pytest configurations to display Git status information for the main project and its dependencies during test runs. The module provides a clean, unified way to: - Detect which Python modules are installed from Git repositories - Collect Git branch, commit hash, and commit message information - Present this information in a consistent format for test reports This is particularly useful in development environments and CI/CD pipelines where dependencies might be installed from development branches of Git repositories. """ from __future__ import annotations import os import os.path as osp import subprocess from typing import Any class GitRepositoryInfo: """Container for Git repository information.""" def __init__(self, name: str, branch: str, commit: str, message: str) -> None: """Initialize Git repository information. Args: name: Human-readable name of the repository/module branch: Git branch name commit: Short commit hash message: First line of commit message (truncated if needed) """ self.name = name self.branch = branch self.commit = commit self.message = message def __repr__(self) -> str: """Return string representation of the Git repository info.""" return ( f"GitRepositoryInfo(name={self.name!r}, " f"branch={self.branch!r}, commit={self.commit!r})" ) def get_git_info_for_modules( modules_config: list[tuple[str, Any, str | None]], ) -> dict[str, GitRepositoryInfo]: """Get Git information for specified modules that are Git repositories. Args: modules_config: List of tuples containing: - name: Human-readable name for the module - module: The imported Python module object - custom_path: Custom path to check (None for module introspection) Returns: Dictionary mapping module names to GitRepositoryInfo objects Example: >>> import mymodule >>> import othermodule >>> modules_config = [ ... ("MyProject", mymodule, "."), # Current directory ... ("OtherLib", othermodule, None), # Use module.__file__ ... ] >>> repos = get_git_info_for_modules(modules_config) >>> for name, info in repos.items(): ... print(f"{name}: {info.branch}@{info.commit}") This function: 1. Iterates through the provided module configuration 2. For each module, determines the repository path: - Uses custom_path if provided - Otherwise searches up from module.__file__ for .git directory 3. Collects Git information (branch, commit, message) if .git exists 4. Returns a clean dictionary of results """ git_repos = {} original_cwd = os.getcwd() for name, module, custom_path in modules_config: try: repo_path = _find_repository_path(module, custom_path) if repo_path is None: continue # Get Git information git_info = _extract_git_information(repo_path) if git_info is not None: git_repos[name] = GitRepositoryInfo(name, *git_info) except ( subprocess.CalledProcessError, FileNotFoundError, OSError, AttributeError, ): # Silently skip modules that can't be processed continue finally: os.chdir(original_cwd) return git_repos def _find_repository_path(module: Any, custom_path: str | None) -> str | None: """Find the Git repository path for a module. Args: module: The Python module object custom_path: Custom path to use, or None for module introspection Returns: Path to the Git repository root, or None if not found """ if custom_path: # Use custom path (typically for main project) repo_path = custom_path else: # Find module path and look for Git repo if not (hasattr(module, "__file__") and module.__file__): return None module_path = osp.dirname(module.__file__) repo_path = module_path # Walk up the directory tree looking for .git while repo_path and repo_path != osp.dirname(repo_path): if osp.exists(osp.join(repo_path, ".git")): break repo_path = osp.dirname(repo_path) else: return None # No .git directory found # Final check that .git directory exists at the determined path if not osp.exists(osp.join(repo_path, ".git")): return None return repo_path def _extract_git_information(repo_path: str) -> tuple[str, str, str] | None: """Extract Git branch, commit, and message from a repository. Args: repo_path: Path to the Git repository root Returns: Tuple of (branch, commit, message) or None if extraction fails """ os.chdir(repo_path) # Get branch name branch = subprocess.check_output( ["git", "rev-parse", "--abbrev-ref", "HEAD"], text=True, encoding="utf-8" ).strip() # Get short commit hash commit = subprocess.check_output( ["git", "rev-parse", "--short", "HEAD"], text=True, encoding="utf-8" ).strip() # Get commit message (first line only, truncated if needed) message = subprocess.check_output( ["git", "log", "-1", "--pretty=%B"], text=True, encoding="utf-8" ).strip() if len(message.splitlines()) > 1: message = message.splitlines()[0] # Truncate long messages message = message[:60] + "[…]" if len(message) > 60 else message return branch, commit, message def format_git_info_for_pytest( git_repos: dict[str, GitRepositoryInfo], main_project_name: str ) -> list[str]: """Format Git repository information for pytest report headers. Args: git_repos: Dictionary of Git repository information main_project_name: Name of the main project (to show first) Returns: List of formatted strings ready for pytest report header Example output: [ "Git information:", " MyProject - Branch: develop, Commit: abc1234", " Message: Fix important bug", " Dependencies:", " guidata - Branch: main, Commit: def5678", " Message: Update documentation" ] """ if not git_repos: return [] gitlist = ["Git information:"] # Show main project first if available if main_project_name in git_repos: info = git_repos[main_project_name] gitlist.append(f" {info.name} - Branch: {info.branch}, Commit: {info.commit}") gitlist.append(f" Message: {info.message}") # Show dependencies deps = [name for name in git_repos if name != main_project_name] if deps: if main_project_name in git_repos: gitlist.append(" Dependencies:") prefix = " " else: prefix = " " for name in sorted(deps): info = git_repos[name] gitlist.append( f"{prefix}{info.name} - Branch: {info.branch}, Commit: {info.commit}" ) if info.message: gitlist.append(f"{prefix} Message: {info.message}") return gitlist ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/misc.py0000644000175100017510000002551215114075001016750 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # pylint: disable=C0103 """ Miscellaneous utility functions ------------------------------- Running programs ^^^^^^^^^^^^^^^^ .. autofunction:: run_program .. autofunction:: is_program_installed .. autofunction:: run_shell_command Strings ^^^^^^^ .. autofunction:: to_string .. autofunction:: decode_fs_string """ from __future__ import annotations import collections.abc import ctypes import locale import os import os.path as osp import subprocess import sys from typing import Any, Type from guidata.userconfig import get_home_dir # MARK: Strings, Locale ---------------------------------------------------------------- def to_string(obj: Any) -> str: """Convert object to string. If `obj` is bytes-like, try decoding as UTF-8, falling back to Latin-1. Otherwise, use str(). """ if isinstance(obj, (bytes, bytearray, memoryview)): try: return obj.decode("utf-8") except UnicodeDecodeError: return obj.decode("latin-1") return str(obj) def decode_fs_string(string: str) -> str: """Convert string from file system charset to unicode Args: string (str): String to convert Returns: str: Converted string """ charset = sys.getfilesystemencoding() if charset is None: charset = locale.getpreferredencoding() return string.decode(charset) def get_system_lang() -> str | None: """ Retrieves the system language name. This function uses `locale.getlocale()` to obtain the locale name based on the current user's settings. If that fails on Windows (e.g. for frozen applications), it uses the Win32 API function `GetUserDefaultLocaleName` to obtain the locale name. Returns: The locale name in a format like 'en_US', or None if the function fails to retrieve the locale name. """ lang = locale.getlocale()[0] if lang is None and os.name == "nt": kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) GetUserDefaultLocaleName = kernel32.GetUserDefaultLocaleName GetUserDefaultLocaleName.argtypes = [ctypes.c_wchar_p, ctypes.c_int] GetUserDefaultLocaleName.restype = ctypes.c_int locale_name = ctypes.create_unicode_buffer(85) if GetUserDefaultLocaleName(locale_name, 85): lang = locale_name.value.replace("-", "_") return lang # MARK: Interface checking ------------------------------------------------------------- def assert_interface_supported(klass: Type, iface: Type) -> None: """Makes sure a class supports an interface Args: klass (Type): The class. iface (Type): The interface. Raises: AssertionError: If the class does not support the interface. """ for name, func in list(iface.__dict__.items()): if name == "__inherits__": continue if isinstance(func, collections.abc.Callable): assert hasattr(klass, name), "Attribute %s missing from %r" % (name, klass) imp_func = getattr(klass, name) imp_code = imp_func.__code__ code = func.__code__ imp_nargs = imp_code.co_argcount nargs = code.co_argcount if imp_code.co_varnames[:imp_nargs] != code.co_varnames[:nargs]: assert False, "Arguments of %s.%s differ from interface: %r!=%r" % ( klass.__name__, imp_func.__name__, imp_code.co_varnames[:imp_nargs], code.co_varnames[:nargs], ) else: pass # should check class attributes for consistency def assert_interfaces_valid(klass: Type) -> None: """Makes sure a class supports the interfaces it declares Args: klass (Type): The class. Raises: AssertionError: If the class does not support the interfaces it declares. """ assert hasattr(klass, "__implements__"), "Class doesn't implements anything" for iface in klass.__implements__: assert_interface_supported(klass, iface) if hasattr(iface, "__inherits__"): base = iface.__inherits__() assert issubclass(klass, base), "%s should be a subclass of %s" % ( klass, base, ) # MARK: Module, scripts, programs ------------------------------------------------------ def get_module_path(modname: str) -> str: """Return module *modname* base path. Args: modname (str): The module name. Returns: str: The module base path. """ module = sys.modules.get(modname, __import__(modname)) return osp.abspath(osp.dirname(module.__file__)) def is_program_installed(basename: str) -> str | None: """Return program absolute path if installed in PATH, otherwise None. Args: basename (str): The program base name. Returns: str | None: The program absolute path if installed in PATH, otherwise None. """ for path in os.environ["PATH"].split(os.pathsep): abspath = osp.join(path, basename) if osp.isfile(abspath): return abspath def run_program( name, args: str = "", cwd: str = None, shell: bool = True, wait: bool = False ) -> None: """Run program in a separate process. Args: name (str): The program name. args (str): The program arguments. Defaults to ""."" cwd (str): The current working directory. Defaults to None. shell (bool): If True, run program in a shell. Defaults to True. wait (bool): If True, wait for program to finish. Defaults to False. Raises: RuntimeError: If program is not installed. """ path = is_program_installed(name) if not path: raise RuntimeError("Program %s was not found" % name) command = [path] if args: command.append(args) if wait: subprocess.call(" ".join(command), cwd=cwd, shell=shell) else: subprocess.Popen(" ".join(command), cwd=cwd, shell=shell) class ProgramError(Exception): """Exception raised when a shell command failed to execute.""" pass def alter_subprocess_kwargs_by_platform(**kwargs): """ Given a dict, populate kwargs to create a generally useful default setup for running subprocess processes on different platforms. For example, `close_fds` is set on posix and creation of a new console window is disabled on Windows. This function will alter the given kwargs and return the modified dict. """ kwargs.setdefault("close_fds", os.name == "posix") if os.name == "nt": CONSOLE_CREATION_FLAGS = 0 # Default value # See: https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863%28v=vs.85%29.aspx CREATE_NO_WINDOW = 0x08000000 # We "or" them together CONSOLE_CREATION_FLAGS |= CREATE_NO_WINDOW kwargs.setdefault("creationflags", CONSOLE_CREATION_FLAGS) return kwargs def run_shell_command(cmdstr, **subprocess_kwargs): """ Execute the given shell command. Note that args and kwargs will be passed to the subprocess call. If 'shell' is given in subprocess_kwargs it must be True, otherwise ProgramError will be raised. If 'executable' is not given in subprocess_kwargs, it will be set to the value of the SHELL environment variable. Note that stdin, stdout and stderr will be set by default to PIPE unless specified in subprocess_kwargs. :str cmdstr: The string run as a shell command. :subprocess_kwargs: These will be passed to subprocess.Popen. """ if "shell" in subprocess_kwargs and not subprocess_kwargs["shell"]: raise ProgramError( 'The "shell" kwarg may be omitted, but if provided it must be True.' ) else: subprocess_kwargs["shell"] = True if "executable" not in subprocess_kwargs: subprocess_kwargs["executable"] = os.getenv("SHELL") for stream in ["stdin", "stdout", "stderr"]: subprocess_kwargs.setdefault(stream, subprocess.PIPE) subprocess_kwargs = alter_subprocess_kwargs_by_platform(**subprocess_kwargs) return subprocess.Popen(cmdstr, **subprocess_kwargs) # MARK: Path utils --------------------------------------------------------------------- def getcwd_or_home(): """Safe version of getcwd that will fallback to home user dir. This will catch the error raised when the current working directory was removed for an external program. """ try: return os.getcwd() except OSError: print( "WARNING: Current working directory was deleted, " "falling back to home directory" ) return get_home_dir() def remove_backslashes(path): """Remove backslashes in *path* For Windows platforms only. Returns the path unchanged on other platforms. This is especially useful when formatting path strings on Windows platforms for which folder paths may contain backslashes and provoke unicode decoding errors in Python 3 (or in Python 2 when future 'unicode_literals' symbol has been imported).""" if os.name == "nt": # Removing trailing single backslash if path.endswith("\\") and not path.endswith("\\\\"): path = path[:-1] # Replacing backslashes by slashes path = path.replace("\\", "/") path = path.replace("/'", "\\'") return path # MARK: Date utils --------------------------------------------------------------------- def convert_date_format(format_string: str) -> str: """ Converts a date format string in Python strftime format to QDateTime style format. Args: format_string: The date format string in Python strftime format. Returns: The converted date format string in QDateTime style. Examples: >>> format_string = '%d.%m.%Y' >>> qt_format = convert_date_format(format_string) >>> print(qt_format) dd.MM.yyyy """ format_mapping = { "%d": "dd", "%-d": "d", "%dd": "dd", "%-dd": "d", "%ddd": "ddd", "%dddd": "dddd", "%b": "MMM", "%B": "MMMM", "%m": "MM", "%-m": "M", "%mm": "MM", "%-mm": "M", "%y": "yy", "%Y": "yyyy", "%I": "h", "%H": "HH", "%-H": "H", "%M": "mm", "%-M": "m", "%S": "ss", "%-S": "s", "%z": "z", "%Z": "zzz", } qt_format = "" i = 0 while i < len(format_string): if format_string[i : i + 2] in format_mapping: qt_format += format_mapping[format_string[i : i + 2]] i += 2 elif format_string[i] in format_mapping: qt_format += format_mapping[format_string[i]] i += 1 else: qt_format += format_string[i] i += 1 return qt_format ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/qt_scraper.py0000644000175100017510000004615715114075001020170 0ustar00runnerrunner# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file. """Generic Qt scraper for Sphinx-Gallery. This module provides a scraper that can capture any Qt top-level widgets and convert them to images for use in Sphinx-Gallery documentation. The scraper automatically detects all visible top-level Qt widgets created during example execution. The scraper works by: 1. Detecting all visible Qt top-level widgets after code block execution 2. Capturing screenshots of these widgets 3. Converting the screenshots to appropriate formats for Sphinx-Gallery 4. Saving the images in the expected location for gallery generation Configuration Options: The scraper supports several configuration options for customizing behavior: - thumbnail_widget: Which widget to use as thumbnail * "first": Use the first successfully captured widget * "last": Use the last successfully captured widget (default) * None: Use default sphinx-gallery thumbnail behavior - hide_toolbars: Hide all toolbars when capturing widgets (default: False) * True: Temporarily hide QToolBar widgets during capture * False: Keep toolbars visible in screenshots - capture_inside_layout: Capture only widget content, excluding decorations * True: Capture content area only (no title bars, window borders) * False: Capture entire widget including window decorations (default) Usage: Basic usage in conf.py: sphinx_gallery_conf = { 'image_scrapers': ['guidata.utils.qt_scraper.qt_scraper'], # other configuration... } Advanced configuration with custom options: from guidata.utils.qt_scraper import set_qt_scraper_config # Configure before running examples set_qt_scraper_config( thumbnail_source="last", hide_toolbars=True, capture_inside_layout=True ) Or use the helper function for basic configuration: from guidata.utils.qt_scraper import get_sphinx_gallery_conf sphinx_gallery_conf = get_sphinx_gallery_conf() """ from __future__ import annotations import logging import os import time from pathlib import Path from typing import Any try: from qtpy.QtCore import Qt from qtpy.QtWidgets import QApplication, QDialog, QMainWindow, QToolBar, QWidget QT_AVAILABLE = True except ImportError: QT_AVAILABLE = False # Set up logging logger = logging.getLogger(__name__) # Configuration for Qt scraper (module-level) _qt_scraper_config = { "thumbnail_widget": "last", # "first", "last", or None "hide_toolbars": False, # Hide all toolbars when capturing widgets "capture_inside_layout": False, # Capture only the central widget inside layouts } def _find_qt_top_widgets() -> list[QWidget]: """Find all visible Qt top-level widgets. Returns: List of visible Qt top-level widgets. """ if not QT_AVAILABLE: return [] try: app = QApplication.instance() if app is None: return [] widgets = [] for widget in app.topLevelWidgets(): # Skip if widget is not visible if not widget.isVisible(): continue # Skip widgets that are hidden or minimized if widget.isMinimized() or widget.isHidden(): continue # Include main windows, dialogs, and other top-level widgets # Exclude tool tips, pop-ups, and other transient widgets if isinstance(widget, (QMainWindow, QDialog)) or ( isinstance(widget, QWidget) and widget.windowFlags() & Qt.Window and not widget.windowFlags() & Qt.Tool and not widget.windowFlags() & Qt.Popup ): widgets.append(widget) return widgets except (AttributeError, RuntimeError) as exc: logger.warning("Failed to find Qt widgets: %s", exc) return [] def _capture_widget(widget: QWidget, output_path: str | Path) -> bool: """Capture a screenshot of a Qt widget and save it. Args: widget: The Qt widget to capture. output_path: Path where to save the PNG screenshot. Returns: True if capture was successful, False otherwise. """ if not hasattr(widget, "grab"): logger.warning("Widget does not support grab() method") return False try: if _qt_scraper_config.get("hide_toolbars", False): # Hide all toolbars temporarily toolbars = widget.findChildren(QToolBar) for tb in toolbars: tb.setVisible(False) # Make sure the widget is visible and rendered widget.show() widget.raise_() widget.activateWindow() # Process events to ensure the widget is fully rendered app = QApplication.instance() app.processEvents() # Small delay to ensure rendering is complete time.sleep(0.1) # Capture the screenshot if _qt_scraper_config.get("capture_inside_layout", False): # Capture only the content inside the window, excluding title bar if isinstance(widget, QDialog): # For dialogs, grab the entire client area excluding decorations content_rect = widget.contentsRect() pixmap = widget.grab(content_rect) elif isinstance(widget, QMainWindow): # For main windows, grab the central widget if available central_widget = widget.centralWidget() if central_widget: pixmap = central_widget.grab() else: # Fallback to content rect content_rect = widget.contentsRect() pixmap = widget.grab(content_rect) else: # For other widgets, use content rect content_rect = widget.contentsRect() pixmap = widget.grab(content_rect) else: # Default behavior: capture the entire widget including title bar pixmap = widget.grab() if not pixmap.isNull(): success = pixmap.save(str(output_path), "PNG") if success: logger.debug("Successfully captured widget to %s", output_path) else: logger.warning("Failed to save screenshot to %s", output_path) return success logger.warning("Captured pixmap is null") return False except Exception as exc: logger.error("Failed to capture Qt widget: %s", exc) return False def _ensure_qapplication() -> QApplication | None: """Ensure a QApplication instance exists. Returns: QApplication instance or None if creation failed. """ if not QT_AVAILABLE: logger.warning("Qt not available for scraping") return None try: app = QApplication.instance() if app is None: # Create minimal QApplication for gallery building app = QApplication([]) logger.debug("Created QApplication for Qt scraper") return app except Exception as exc: logger.error("Could not initialize QApplication: %s", exc) return None def _get_example_subdirectory( gallery_conf: dict[str, Any], block_vars: dict[str, Any] | None ) -> str: """Extract subdirectory from example source file path. Args: gallery_conf: Sphinx-Gallery configuration. block_vars: Variables from the executed code block. Returns: Subdirectory name (e.g., "features", "advanced") or empty string if not found. """ if not block_vars or "src_file" not in block_vars: return "" src_file = Path(str(block_vars["src_file"])) examples_dirs = gallery_conf.get("examples_dirs", "examples") # Handle both string and list cases for examples_dirs if isinstance(examples_dirs, list): examples_dir = examples_dirs[0] if examples_dirs else "examples" else: examples_dir = examples_dirs # Try to extract subdirectory from source file path # e.g., "examples/features/convolution.py" -> "features" try: if examples_dir in src_file.parts: idx = src_file.parts.index(examples_dir) # Check if there's a subdirectory between examples_dir and the file if idx + 2 < len(src_file.parts): subdirectory = src_file.parts[idx + 1] logger.debug("Detected subdirectory: %s", subdirectory) return subdirectory except (ValueError, IndexError) as exc: logger.debug("Could not determine subdirectory: %s", exc) return "" def _get_image_directory( gallery_conf: dict[str, Any], block_vars: dict[str, Any] | None = None ) -> Path | None: """Get the image directory for saving screenshots. Args: gallery_conf: Sphinx-Gallery configuration. block_vars: Variables from the executed code block (optional). Used to determine subdirectory for examples in subsections. Returns: Path to image directory or None if cannot be determined. """ if not gallery_conf or "src_dir" not in gallery_conf: logger.warning("Invalid gallery configuration") return None src_dir = Path(gallery_conf["src_dir"]) gallery_dirs = gallery_conf.get("gallery_dirs", ["auto_examples"]) # Handle both string and list cases for gallery_dirs if isinstance(gallery_dirs, list): gallery_dir = gallery_dirs[0] if gallery_dirs else "auto_examples" else: gallery_dir = gallery_dirs # Determine subdirectory from source file if available subdirectory = _get_example_subdirectory(gallery_conf, block_vars) if subdirectory: img_dir = src_dir / gallery_dir / subdirectory / "images" else: img_dir = src_dir / gallery_dir / "images" img_dir.mkdir(parents=True, exist_ok=True) return img_dir def _generate_rst_block( image_name: str, gallery_conf: dict[str, Any], widget_index: int, block_vars: dict[str, Any] | None = None, ) -> str: """Generate RST code block for an image. Args: image_name: Name of the image file. gallery_conf: Sphinx-Gallery configuration. widget_index: Index of the widget (for alt text). block_vars: Variables from the executed code block (optional). Used to determine subdirectory for examples in subsections. Returns: RST code block. """ gallery_dirs = gallery_conf.get("gallery_dirs", ["auto_examples"]) if isinstance(gallery_dirs, list): rst_gallery_dir = gallery_dirs[0] if gallery_dirs else "auto_examples" else: rst_gallery_dir = gallery_dirs # Determine subdirectory from source file if available subdirectory = _get_example_subdirectory(gallery_conf, block_vars) if subdirectory: image_path = f"/{rst_gallery_dir}/{subdirectory}/images/{image_name}" else: image_path = f"/{rst_gallery_dir}/images/{image_name}" return f""" .. image:: {image_path} :alt: Qt widget {widget_index + 1} :class: sphx-glr-single-img """ def _save_as_thumbnail(widget: QWidget, img_dir: Path, example_name: str) -> bool: """Save a widget as the thumbnail for sphinx-gallery. Args: widget: The Qt widget to use as thumbnail. img_dir: Directory to save images. example_name: Name of the example (without extension). Returns: True if thumbnail was saved successfully, False otherwise. """ try: # Create thumbnail directory following sphinx-gallery convention thumb_dir = img_dir / "thumb" thumb_dir.mkdir(parents=True, exist_ok=True) thumb_name = f"sphx_glr_{example_name}_thumb.png" thumb_path = thumb_dir / thumb_name # Capture the widget as thumbnail success = _capture_widget(widget, thumb_path) if success: logger.info("Saved thumbnail: %s", thumb_path) return True else: logger.warning("Failed to save thumbnail: %s", thumb_path) return False except Exception as exc: logger.error("Failed to save thumbnail: %s", exc) return False def qt_scraper( block: str, block_vars: dict[str, Any], gallery_conf: dict[str, Any], **kwargs: Any ) -> str: """Scraper for Qt widgets in Sphinx-Gallery. This function is called by Sphinx-Gallery after executing each code block to capture any Qt top-level widgets that were created. Args: block: The code block that was executed (unused). block_vars: Variables from the executed code block (unused). gallery_conf: Sphinx-Gallery configuration. **kwargs: Additional arguments (unused). Returns: RST code to include the captured images. """ if not QT_AVAILABLE: logger.warning("Qt not available for scraping") return "" # Set environment variable to indicate we're building gallery os.environ["SPHINX_GALLERY_BUILDING"] = "1" # Ensure QApplication exists app = _ensure_qapplication() if app is None: return "" # Find all Qt top-level widgets widgets = _find_qt_top_widgets() logger.info("Found %d Qt top-level widgets", len(widgets)) if not widgets: return "" # Get image directory (pass block_vars to determine subdirectory) img_dir = _get_image_directory(gallery_conf, block_vars) if img_dir is None: logger.error("Cannot determine image directory") return "" timestamp = int(time.time() * 1000) # milliseconds for uniqueness # Get thumbnail configuration from module config thumbnail_config = _qt_scraper_config.get("thumbnail_widget", None) # Capture each widget and collect successful captures rst_blocks = [] successful_widgets = [] successful_indices = [] for i, widget in enumerate(widgets): try: # Generate unique image path image_name = f"sphx_glr_qt_{timestamp}_{i:03d}.png" image_path = img_dir / image_name logger.debug( "Attempting to capture widget %d/%d to %s", i + 1, len(widgets), image_path, ) # Capture the widget success = _capture_widget(widget, image_path) if success: rst_block = _generate_rst_block(image_name, gallery_conf, i, block_vars) rst_blocks.append(rst_block) successful_widgets.append(widget) successful_indices.append(i) logger.debug("Successfully captured widget %d", i + 1) else: logger.warning("Failed to capture widget %d", i + 1) except Exception as exc: logger.error("Failed to process Qt widget %d: %s", i, exc) continue # Handle thumbnail generation based on configuration if thumbnail_config and successful_widgets: if "src_file" in block_vars: example_name = Path(str(block_vars["src_file"])).stem else: raise RuntimeError( "Unable to determine example name: src_file not found in block_vars" ) logger.info("Detected example name: %s", example_name) try: if thumbnail_config == "first": thumbnail_widget = successful_widgets[0] widget_idx = successful_indices[0] logger.info("Using first widget (index %d) as thumbnail", widget_idx) elif thumbnail_config == "last": thumbnail_widget = successful_widgets[-1] widget_idx = successful_indices[-1] logger.info("Using last widget (index %d) as thumbnail", widget_idx) else: thumbnail_widget = None if thumbnail_widget: success = _save_as_thumbnail(thumbnail_widget, img_dir, example_name) if success: logger.info("Thumbnail generated successfully") else: logger.warning("Failed to generate thumbnail") except Exception as exc: logger.error("Failed to generate thumbnail: %s", exc) # Close all widgets to prevent accumulation for i, widget in enumerate(widgets): if hasattr(widget, "close") and hasattr(widget, "deleteLater"): try: widget.close() except Exception as exc: logger.warning("Failed to close widget %d: %s", i + 1, exc) return "".join(rst_blocks) def setup_qt_scraper(app: Any, config: Any) -> None: # noqa: ARG001 """Setup function to register the Qt scraper with Sphinx. Args: app: Sphinx application instance (unused). config: Sphinx configuration object. """ if hasattr(config, "sphinx_gallery_conf"): scrapers = config.sphinx_gallery_conf.get("image_scrapers", []) if qt_scraper not in scrapers: scrapers.append(qt_scraper) config.sphinx_gallery_conf["image_scrapers"] = scrapers def get_qt_scraper() -> Any: """Return the Qt scraper function for use in Sphinx-Gallery configuration. Returns: The qt_scraper function. """ return qt_scraper def set_qt_scraper_config( thumbnail_source: str | None = "last", hide_toolbars: bool = False, capture_inside_layout: bool = False, ) -> None: """Set the Qt scraper thumbnail configuration. Args: thumbnail_source: Which widget to use as thumbnail. Options are "first" (use the first successfully captured widget), "last" (use the last successfully captured widget), or None (no automatic thumbnail generation). hide_toolbars: If True, hide all toolbars when capturing widgets. capture_inside_layout: If True, capture only the central widget inside layouts. """ global _qt_scraper_config if thumbnail_source not in ("first", "last", None): raise ValueError("widget must be 'first', 'last', or None") _qt_scraper_config["thumbnail_widget"] = thumbnail_source _qt_scraper_config["hide_toolbars"] = hide_toolbars _qt_scraper_config["capture_inside_layout"] = capture_inside_layout logger.info("Qt scraper thumbnail config set to: %s", thumbnail_source) logger.info("Qt scraper hide_toolbars config set to: %s", hide_toolbars) logger.info( "Qt scraper capture_inside_layout config set to: %s", capture_inside_layout ) def get_sphinx_gallery_conf(**kwargs) -> dict[str, Any]: """Return a Sphinx-Gallery configuration dict for Qt scraper.""" config = { "image_scrapers": ["guidata.utils.qt_scraper.qt_scraper"], "examples_dirs": "examples", # Path to example scripts "gallery_dirs": "auto_examples", # Output directory for gallery "filename_pattern": "", # Pattern for example files "reset_modules": (), # Can be customized by the user "remove_config_comments": False, "expected_failing_examples": [], "capture_repr": ("_repr_html_", "__repr__"), "matplotlib_animations": False, "download_all_examples": False, "show_memory": False, "plot_gallery": True, # Enable gallery plotting "run_stale_examples": False, # Force run all examples } config.update(kwargs) return config ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/securebuild.py0000644000175100017510000001011715114075001020316 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright (c) 2025, Codra, Pierre Raybaut. # Licensed under the terms of the BSD 3-Clause """ securebuild =========== Module for securely building Python packages from a Git repository. This script ensures that only files tracked by Git are included in the build, and it creates a clean temporary directory for the build process. It uses the `git archive` command to export the current HEAD of the repository, and then builds the source distribution and wheel files using `build`. It finally copies the generated files to the `dist/` directory. """ from __future__ import annotations import shutil import subprocess import sys import tempfile from pathlib import Path def run_secure_build(root_path: str | None = None) -> None: """Run a secure build of the Python package. Args: root_path: Path to the root directory of the Git repository. If None, the function will take the current working directory as the root. This function performs the following steps: 1. Creates a temporary directory to hold the build files. 2. Creates a tar archive of the current HEAD of the Git repository. 3. Extracts the contents of the tar archive into the temporary directory. 4. Checks for the presence of `pyproject.toml` in the extracted files. 5. Runs the build process using `build` to create source distribution and wheels. 6. Copies the generated files to the `dist/` directory in repository root. Raises: RuntimeError: If the current directory is not a Git repository and no `root_path` is provided, or if the `pyproject.toml` file is not found in the extracted files. """ # Check if root_path is None if root_path is None: root_dir = Path.cwd() # Raise an error if the current directory is not a Git repository if not (root_dir / ".git").exists(): raise RuntimeError( f"Current directory '{root_dir}' is not a Git repository. " "Please provide a valid root path or run this script in a " "Git repository." ) else: root_dir = Path(root_path).resolve() dist_dir = root_dir / "dist" print(f"📁 Git root directory: {root_dir}") with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) # Step 1 : create a tar archive of the current HEAD print("📦 Creating tar archive of the current branch...") archive_path = temp_path / "source.tar" with open(archive_path, "wb") as archive_file: subprocess.run( ["git", "archive", "--format=tar", "HEAD"], check=True, cwd=root_dir, stdout=archive_file, ) # Step 2 : extract the contents of the archive print(f"📂 Extracting archive to temporary directory: {temp_path}") subprocess.run(["tar", "-xf", str(archive_path)], check=True, cwd=temp_path) # Step 3 : check for the presence of pyproject.toml print("🔍 Checking for pyproject.toml in the extracted files...") build_root = temp_path if not (build_root / "pyproject.toml").exists(): print("❌ The pyproject.toml file is missing from the extracted archive.") print(" Make sure it is committed to Git at the root.") sys.exit(1) print("📂 Extracted repository contents:") for path in sorted(build_root.iterdir()): print(f" - {path.name}") # Step 4 : run the package build print("🔨 Building the package...") subprocess.run( [sys.executable, "-m", "build", "--sdist", "--wheel"], cwd=build_root, check=True, ) # Step 5 : copy the artifacts to dist/ print(f"📦 Copying built packages to {dist_dir}...") build_dist = build_root / "dist" dist_dir.mkdir(exist_ok=True) for file in build_dist.iterdir(): shutil.copy(file, dist_dir) print(f"\n✅ Packages generated in: {dist_dir}") if __name__ == "__main__": run_secure_build() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/utils/translations.py0000644000175100017510000002535215114075001020540 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # pylint: disable=C0103 """ Translations utilities ---------------------- Description ^^^^^^^^^^^ This module provides utilities for managing translations in guidata. It is based on `Babel `_. Three functions are provided: - :func:`scan_translations`: Extracts translatable strings from Python files and generates translation files. - :func:`compile_translations`: Compiles translation files into binary format. - :func:`main`: Convenience function to call :func:`scan_translations` or :func:`compile_translations` with command-line arguments. Usage ^^^^^ To extract translatable strings and generate translation files, use: .. code-block:: console $ # Scan translations $ python -m guidata.utils.translations scan --name --directory $ # Alternatively, you can use the command-line tool: $ guidata-translations scan --name --directory To compile translation files into binary format, use: .. code-block:: console $ # Compile translations $ python -m guidata.utils.translations compile --name --directory $ # Alternatively, you can use the command-line tool: $ guidata-translations compile --name --directory """ from __future__ import annotations import argparse import locale import os import subprocess import sys import typing PYBABEL_ARGS = [sys.executable, "-m", "babel.messages.frontend"] def get_default_language_code() -> str: """Get the default language code of the system. Returns: The default language code (e.g., 'en', 'fr'). """ lang, _ = locale.getlocale() return lang.split("_")[0] if lang else "en" def scan_translations( name: str, directory: typing.Union[str, os.PathLike], copyright_holder: str | None = None, languages: typing.List[str] | None = None, ) -> None: """Extract and process translatable strings from Python files using Babel. This function performs the following steps: 1. Check the project root directory. 2. Check the Babel configuration file. 3. Set up the translation directory. 4. Extract translatable strings from the project and generate a template file. 5. For each specified language, check that the necessary directory structure exists and generate or update the corresponding translation file. Args: name: The name of the project, used for directory and domain naming. directory: The root directory of the project. copyright_holder: The name of the copyright holder for the project. languages: A list of language codes (e.g., ['fr', 'it']) for which translation files should be generated or updated. Raises: FileNotFoundError: The Babel configuration file does not exist. RuntimeError: Extraction or translation file generation failed. """ if copyright_holder is None: copyright_holder = "" if languages is None: # If no language codes are specified, use the system's default language code languages = [get_default_language_code()] # Check the project root directory if not os.path.exists(directory): print(f"Error: Project root directory {directory} does not exist.") raise FileNotFoundError(f"Project root directory {directory} does not exist.") print(f"Project root directory: {directory}") # Set the Babel configuration file path babel_cfg = os.path.join(directory, "babel.cfg") if not os.path.exists(babel_cfg): print(f"Error: Babel configuration file {babel_cfg} does not exist.") raise FileNotFoundError(f"Babel configuration file {babel_cfg} does not exist.") # Set the translation directory translation_dir = os.path.join(directory, name, "locale") if not os.path.exists(translation_dir): os.makedirs(translation_dir) print(f"Created translation directory: {translation_dir}") # Set the template file path catalog_template = os.path.join(translation_dir, f"{name}.pot") print(f"Output template file: {catalog_template}") # Run pybabel extract print("Extracting translatable strings...") try: subprocess.run( PYBABEL_ARGS + [ "extract", directory, "--mapping-file", babel_cfg, "--output-file", catalog_template, "--no-location", "--no-wrap", "--sort-by-file", "--header-comment", """\ # Translations template for PROJECT. # Copyright (C) YEAR ORGANIZATION # This file is distributed under the same license as the PROJECT project. #""", "--project", name, "--copyright-holder", copyright_holder, ], check=True, ) print(f"Extraction completed successfully. Output file: {catalog_template}") except subprocess.CalledProcessError as e: print("Error: Extraction failed.") print(e.stderr) raise RuntimeError(f"Extraction failed: {e.stderr}") from e # Initialize the folder structure if it does not exist for code in languages: locale_dir = os.path.join(translation_dir, code, "LC_MESSAGES") if not os.path.exists(locale_dir): os.makedirs(locale_dir) print(f"Created translation directory: {locale_dir}") # Generate or update the translation file print(f"Translation directory: {translation_dir}") try: for code in languages: subprocess.run( PYBABEL_ARGS + [ "update", "--input-file", catalog_template, "--output-dir", translation_dir, "--locale", code, "--domain", name, "--ignore-obsolete", "--init-missing", "--no-wrap", "--update-header-comment", ], check=True, ) except subprocess.CalledProcessError as e: print("Error: Translation file generation failed.") print(e.stderr) raise RuntimeError(f"Translation file generation failed: {e.stderr}") from e def compile_translations( name: str, directory: typing.Union[str, os.PathLike], ) -> None: """Compile translated strings using Babel. This function performs the following steps: 1. Check the project root directory. 2. Check the Babel configuration file. 3. Verify the presence of the translation directory. 4. Compile the translation files. Args: name: The name of the project. directory: The root directory of the project. Raises: FileNotFoundError: The Babel configuration file or the translation directory does not exist. RuntimeError: The compilation failed. """ # Check the project root directory if not os.path.exists(directory): print(f"Error: Project root directory {directory} does not exist.") raise FileNotFoundError(f"Project root directory {directory} does not exist.") print(f"Project root directory: {directory}") # Set the Babel configuration file path babel_cfg = os.path.join(directory, "babel.cfg") if not os.path.exists(babel_cfg): print(f"Error: Babel configuration file {babel_cfg} does not exist.") raise FileNotFoundError(f"Babel configuration file {babel_cfg} does not exist.") # Set the translation directory translation_dir = os.path.join(directory, name, "locale") if not os.path.exists(translation_dir): print(f"Error: Translation directory {translation_dir} does not exist.") raise FileNotFoundError( f"Translation directory {translation_dir} does not exist." ) print(f"Translation directory {translation_dir}") # Compile the translation files try: subprocess.run( PYBABEL_ARGS + [ "compile", "--directory", translation_dir, "--domain", name, "--statistics", ], check=True, ) print("Compilation completed successfully.") except subprocess.CalledProcessError as e: print("Error: Compilation failed.") print(e.stderr) raise RuntimeError(f"Compilation failed: {e.stderr}") from e def _get_def(option: str) -> str | None: """Get the default value from environment variables or return None.""" return os.environ.get(f"I18N_{option.upper()}") def _is_req(option: str) -> bool: """Check if the option is required based on environment variables.""" return _get_def(option) is None def main(): """Run one of the main functions based on command-line arguments.""" parser = argparse.ArgumentParser( description="Extract and process translations with Babel.", epilog="Use 'scan' to update .po files and 'compile' to generate .mo files.", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) subparsers = parser.add_subparsers(dest="command", required=True) compile_parser = subparsers.add_parser("compile", help="Compile translations") compile_parser.add_argument( "--name", required=_is_req("name"), default=_get_def("name"), help="Project name", ) compile_parser.add_argument( "--directory", required=True, help="Project root directory" ) scan_parser = subparsers.add_parser("scan", help="Scan for translatable strings") scan_parser.add_argument("--name", required=True, help="Project name") scan_parser.add_argument( "--directory", required=True, help="Project root directory" ) scan_parser.add_argument("--version", required=False, help="Project version") scan_parser.add_argument( "--copyright-holder", required=False, help="Copyright holder name" ) scan_parser.add_argument( "--languages", required=False, default=None, nargs="+", help="Language codes to translate (space-separated, e.g., 'fr it')", ) args = parser.parse_args() if args.command == "compile": compile_translations(args.name, args.directory) elif args.command == "scan": scan_translations( args.name, args.directory, args.copyright_holder, args.languages, ) else: parser.print_help() raise ValueError(f"Unknown command: {args.command}. Use 'scan' or 'compile'.") if __name__ == "__main__": main() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6608856 guidata-3.13.4/guidata/widgets/0000755000175100017510000000000015114075015015751 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/__init__.py0000644000175100017510000000127115114075001020056 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ Ready-to-use Qt widgets ----------------------- Data editors ^^^^^^^^^^^^ .. autoclass:: guidata.widgets.arrayeditor.ArrayEditor .. autoclass:: guidata.widgets.collectionseditor.CollectionsEditor .. autoclass:: guidata.widgets.dataframeeditor.DataFrameEditor .. autoclass:: guidata.widgets.texteditor.TextEditor .. autofunction:: guidata.widgets.objecteditor.oedit Console and code editor ^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: guidata.widgets.console.Console .. autoclass:: guidata.widgets.console.DockableConsole .. autoclass:: guidata.widgets.codeeditor.CodeEditor """ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/about.py0000644000175100017510000001115515114075001017433 0ustar00runnerrunner# -*- coding: utf-8 -*- """ about ===== """ from __future__ import annotations import platform import sys import qtpy from qtpy.QtCore import Qt from qtpy.QtWidgets import QMainWindow, QMessageBox import guidata from guidata.config import _ from guidata.configtools import get_icon def get_python_libs_infos(addinfo: str = "") -> str: """Get Python and libraries information Args: addinfo: additional information to be displayed Returns: str: Python and libraries information """ python_version = "{} {}".format( platform.python_version(), "64 bits" if sys.maxsize > 2**32 else "32 bits" ) if qtpy.PYQT_VERSION is None: qtb_version = qtpy.PYSIDE_VERSION qtb_name = "PySide" else: qtb_version = qtpy.PYQT_VERSION qtb_name = "PyQt" if addinfo: addinfo = ", " + addinfo return ( f"Python {python_version}, " f"Qt {qtpy.QT_VERSION}, {qtb_name} {qtb_version}" f"{addinfo} on {platform.system()}" ) def get_general_infos(addinfo: str = "") -> str: """Get general information (copyright, Qt versions, etc.) Args: addinfo: additional information to be displayed Returns: str: Qt information """ return "Copyright © 2023 CEA-Codra\n\n" + get_python_libs_infos(addinfo=addinfo) class AboutInfo: """Object to generate information about the package Args: name: package name version: package version description: package description author: package author year: package year organization: package organization project_url: package project url doc_url: package documentation url """ def __init__( self, name: str, version: str, description: str, author: str, year: int, organization: str, project_url: str = "", doc_url: str = "", ) -> None: self.name = name self.version = version self.description = description self.author = author self.year = year self.organization = organization if not project_url: project_url = f"https://github.com/PlotPyStack/{name}" self.project_url = project_url if not doc_url: doc_url = f"https://{name}.readthedocs.io" self.doc_url = doc_url def __str__(self) -> str: return self.about() def about( self, html: bool = True, copyright_only: bool = False, addinfo: str = "" ) -> str: """Return text about this package Args: html: return html text. Defaults to True. copyright_only: if True, return only copyright addinfo: additional information to be displayed Returns: Text about this package """ auth, year, org = self.author, self.year, self.organization if html: author = f"
{auth}" organization = f"{org}" shdesc = f"{self.name} {self.version}\n{self.description}" if html: shdesc += "\n\n" pname = _("Project website") dname = _("Documentation") plink = f"{pname}" dlink = f"{dname}" shdesc += _("More details about %s on %s or %s") % (self.name, plink, dlink) shdesc += "\n\n" + _("Created by %s in %d") % (author, year) + "\n" shdesc += _("Maintained by the %s organization") % organization desc = get_general_infos(addinfo) if not copyright_only: desc = f"{shdesc}{desc}" if html: desc = desc.replace("\n", "
") return desc def about(html: bool = True, copyright_only: bool = False) -> str: """Return text about this package Args: html: return html text. Defaults to True. copyright_only: if True, return only copyright Returns: Text about this package """ info = AboutInfo( "guidata", guidata.__version__, _("Automatic GUI generation for easy dataset editing and display"), "Pierre Raybaut", 2009, "PlotPyStack", ) return info.about(html=html, copyright_only=copyright_only) def show_about_dialog() -> None: """Show ``guidata`` about dialog""" win = QMainWindow(None) win.setAttribute(Qt.WA_DeleteOnClose) win.hide() win.setWindowIcon(get_icon("guidata.svg")) QMessageBox.about(win, _("About") + " guidata", about(html=True)) win.close() ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6618857 guidata-3.13.4/guidata/widgets/arrayeditor/0000755000175100017510000000000015114075015020276 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/arrayeditor/__init__.py0000644000175100017510000000107315114075001022403 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # # The array editor subpackage was derived from Spyder's arrayeditor.py module # which is licensed under the terms of the MIT License (see spyder/__init__.py # for details), copyright © Spyder Project Contributors """guidata.widgets.arrayeditor =========================== This package provides a NumPy Array Editor Dialog based on Qt. .. autoclass:: ArrayEditor :show-inheritance: :members: """ from .arrayeditor import ArrayEditor # noqa: F401 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/arrayeditor/arrayeditor.py0000644000175100017510000004164115114075001023176 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # # The array editor subpackage was derived from Spyder's arrayeditor.py module # which is licensed under the terms of the MIT License (see spyder/__init__.py # for details), copyright © Spyder Project Contributors # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 """ Module that provides array editor dialog boxes to edit various types of NumPy arrays """ from __future__ import annotations import sys from typing import Generic import numpy as np from qtpy.QtCore import QModelIndex, Qt, Slot from qtpy.QtWidgets import ( QComboBox, QDialog, QGridLayout, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSpinBox, QStackedWidget, QWidget, ) from guidata.config import _ from guidata.configtools import get_icon from guidata.qthelpers import win32_fix_title_bar_background from guidata.widgets.arrayeditor import utils from guidata.widgets.arrayeditor.arrayhandler import ( AnySupportedArray, BaseArrayHandler, MaskedArrayHandler, RecordArrayHandler, ) from guidata.widgets.arrayeditor.editorwidget import ( BaseArrayEditorWidget, DataArrayEditorWidget, MaskArrayEditorWidget, MaskedArrayEditorWidget, RecordArrayEditorWidget, ) class ArrayEditor(QDialog, Generic[AnySupportedArray]): """Array Editor Dialog Args: parent: Parent widget (default: None) """ __slots__ = ( "data", "is_record_array", "is_masked_array", "arraywidget", "arraywidgets", "stack", "layout", "btn_save_and_close", "btn_close", "dim_indexes", "last_dim", ) _data: BaseArrayHandler | MaskedArrayHandler | RecordArrayHandler arraywidget: ( BaseArrayEditorWidget | MaskArrayEditorWidget | DataArrayEditorWidget | RecordArrayEditorWidget ) layout: QGridLayout def __init__(self, parent: QWidget = None) -> None: QDialog.__init__(self, parent) win32_fix_title_bar_background(self) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.is_record_array = False self.is_masked_array = False self.arraywidgets: list[BaseArrayEditorWidget] = [] self.btn_save_and_close = None self.btn_close = None # Values for 3d array editor self.dim_indexes = [{}, {}, {}] self.last_dim = 0 # Adjust this for changing the startup dimension self.stack: QStackedWidget | None = None def setup_and_check( self, data: AnySupportedArray, title="", readonly=False, xlabels=None, ylabels=None, variable_size=False, add_title_suffix=True, ) -> bool: """ Setup the array editor dialog box and check if the array is supported. Args: data: Array to edit title: Dialog box title readonly: Flag indicating if the array is read only xlabels: List of x labels ylabels: List of y labels variable_size: Flag indicating if the array is variable size add_title_suffix: Flag indicating if the array type should be added to the title Returns: True if the array is supported, False otherwise """ readonly = readonly or not data.flags.writeable self._variable_size = ( variable_size and not readonly and xlabels is None and ylabels is None ) if readonly and variable_size: QMessageBox.warning( self, _("Conflicing edition flags"), _( "Array editor was initialized in both readonly and variable " "size mode." ) + "\n" + _("The array editor will remain in readonly mode."), ) if variable_size and (xlabels is not None or ylabels is not None): QMessageBox.warning( self, _("Unsupported array format"), _( "Array editor does not support array with x/y labels in " "variable size mode." ) + "\n" + _("You will not be able to add or remove rows/columns."), ) self.is_record_array = data.dtype.names is not None self.is_masked_array = isinstance(data, np.ma.MaskedArray) if self.is_masked_array: self._data = MaskedArrayHandler(data, self._variable_size) # type: ignore elif self.is_record_array: self._data = RecordArrayHandler(data, self._variable_size) else: self._data = BaseArrayHandler(data, self._variable_size) if data.ndim > 3: self.error(_("Arrays with more than 3 dimensions are not supported")) return False if xlabels is not None and len(xlabels) != self._data.shape[1]: self.error( _("The 'xlabels' argument length do no match array column number") ) return False if ylabels is not None and len(ylabels) != self._data.shape[0]: self.error(_("The 'ylabels' argument length do no match array row number")) return False if not self.is_record_array: dtn = data.dtype.name if ( dtn not in utils.SUPPORTED_FORMATS and not dtn.startswith("str") and not dtn.startswith("unicode") ): arr_ = _("%s arrays") % data.dtype.name self.error(_("%s are currently not supported") % arr_) return False self.layout = QGridLayout() self.setLayout(self.layout) self.setWindowIcon(get_icon("arredit.png")) if add_title_suffix: title = ( str(title) + " - " + _("NumPy array") if title else _("Array editor") ) if readonly: title += " (" + _("read only") + ")" self.setWindowTitle(title) self.resize(600, 500) # Stack widget self.stack = QStackedWidget(self) if self.is_record_array: for name in data.dtype.names: w = RecordArrayEditorWidget( self, self._data, # type: ignore name, readonly, xlabels, ylabels, variable_size, ) self.arraywidgets.append(w) self.stack.addWidget(w) elif self.is_masked_array: w1 = MaskedArrayEditorWidget( self, self._data, readonly, xlabels, ylabels, variable_size ) self.arraywidgets.append(w1) self.stack.addWidget(w1) w2 = DataArrayEditorWidget( self, self._data, # type: ignore readonly, xlabels, ylabels, variable_size, ) self.arraywidgets.append(w2) self.stack.addWidget(w2) w3 = MaskArrayEditorWidget( self, self._data, readonly, xlabels, ylabels, variable_size, ) self.arraywidgets.append(w3) self.stack.addWidget(w3) elif data.ndim == 3: pass else: w = BaseArrayEditorWidget( self, self._data, readonly, xlabels, ylabels, variable_size ) self.stack.addWidget(w) self.arraywidget = self.stack.currentWidget() if self.arraywidget: self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) for wdg in self.arraywidgets: wdg.model.SIZE_CHANGED.connect(self.update_all_tables_on_size_change) self.stack.currentChanged.connect(self.current_widget_changed) self.layout.addWidget(self.stack, 1, 0) # Buttons configuration btn_layout = QHBoxLayout() if self.is_record_array or self.is_masked_array or data.ndim == 3: if self.is_record_array: btn_layout.addWidget(QLabel(_("Record array fields:"))) names = [] for name in data.dtype.names: field = data.dtype.fields[name] text = name if len(field) >= 3: title = field[2] if not isinstance(title, str): title = repr(title) text += " - " + title names.append(text) else: names = [_("Masked data"), _("Data"), _("Mask")] if data.ndim == 3: # QSpinBox self.index_spin = QSpinBox(self, keyboardTracking=False) self.index_spin.valueChanged.connect(self.change_active_widget) # QComboBox names = [str(i) for i in range(3)] ra_combo = QComboBox(self) ra_combo.addItems(names) ra_combo.currentIndexChanged.connect(self.current_dim_changed) # Adding the widgets to layout label = QLabel(_("Axis:")) btn_layout.addWidget(label) btn_layout.addWidget(ra_combo) self.shape_label = QLabel() btn_layout.addWidget(self.shape_label) label = QLabel(_("Index:")) btn_layout.addWidget(label) btn_layout.addWidget(self.index_spin) self.slicing_label = QLabel() btn_layout.addWidget(self.slicing_label) # set the widget to display when launched self.current_dim_changed(self.last_dim) else: ra_combo = QComboBox(self) ra_combo.currentIndexChanged.connect(self.stack.setCurrentIndex) ra_combo.addItems(names) btn_layout.addWidget(ra_combo) if self.is_masked_array: label = QLabel(_("Warning: changes are applied separately")) label.setToolTip( _( "For performance reasons, changes applied " "to masked array won't be reflected in " "array's data (and vice-versa)." ) ) btn_layout.addWidget(label) btn_layout.addStretch() if not readonly: self.btn_save_and_close = QPushButton(_("Save and Close")) self.btn_save_and_close.setDisabled(True) self.btn_save_and_close.clicked.connect(self.accept) btn_layout.addWidget(self.btn_save_and_close) self.btn_close = QPushButton(_("Close")) self.btn_close.setAutoDefault(True) self.btn_close.setDefault(True) self.btn_close.clicked.connect(self.reject) btn_layout.addWidget(self.btn_close) self.layout.addLayout(btn_layout, 2, 0) self.setMinimumSize(400, 300) # Make the dialog act as a window self.setWindowFlags(Qt.WindowType.Window) return True @Slot(bool, bool) def update_all_tables_on_size_change(self, rows: bool, cols: bool) -> None: """Updates all array editor widgets when rows and/or columns count changes. Args: rows: Flag to indicate the number of rows changed cols: Flag to indicate the number of columns changed """ for wdg in self.arraywidgets: wdg.model.fetch(rows, cols) wdg.model.set_hue_values() if self._data.ndim == 3: self.current_dim_changed(self.last_dim) @Slot(QModelIndex, QModelIndex) def save_and_close_enable( self, _left_top: QModelIndex, _bottom_right: QModelIndex ) -> None: """Handle the data change event to enable the save and close button.""" if self.btn_save_and_close: self.btn_save_and_close.setEnabled(True) self.btn_save_and_close.setAutoDefault(True) self.btn_save_and_close.setDefault(True) def current_widget_changed(self, index: int) -> None: """Handle the current widget change event to connect the dataChanged signal Args: index: Index of the current widget """ if self.stack is not None: self.arraywidget = self.stack.widget(index) self.arraywidget.model.dataChanged.connect(self.save_and_close_enable) def change_active_widget(self, index: int) -> None: """This is implemented for handling negative values in index for 3d arrays, to give the same behavior as slicing Args: index: Index of the current widget """ string_index = [":"] * 3 string_index[self.last_dim] = "%i" self.slicing_label.setText( (r"Slicing: [" + ", ".join(string_index) + "]") % index ) data_index = self._data.shape[self.last_dim] + index if index < 0 else index slice_index = [slice(None)] * 3 slice_index[self.last_dim] = data_index stack_index = self.dim_indexes[self.last_dim].get(data_index) if stack_index is None and self.stack is not None: stack_index = self.stack.count() try: w = BaseArrayEditorWidget( self, self._data, variable_size=self._variable_size, current_slice=slice_index, ) self.stack.addWidget(w) except IndexError: # Handle arrays of size 0 in one axis w = BaseArrayEditorWidget( self, self._data, variable_size=self._variable_size, current_slice=slice_index, ) self.stack.addWidget(w) self.arraywidgets.append( w ) # required to fetch the new columns/rows if added/deleted w.model.SIZE_CHANGED.connect(self.update_all_tables_on_size_change) self.dim_indexes[self.last_dim][data_index] = stack_index self.stack.update() self.stack.setCurrentIndex(stack_index) def current_dim_changed(self, index: int) -> None: """This change the active axis the array editor is plotting over in 3D Args: index: Index of the current widget """ self.last_dim = index string_size = ["%i"] * 3 string_size[index] = "%i" self.shape_label.setText( ("Shape: (" + ", ".join(string_size) + ") ") % self._data.shape ) if self.index_spin.value() != 0: self.index_spin.setValue(0) else: # this is done since if the value is currently 0 it does not emit # currentIndexChanged(int) self.change_active_widget(0) self.index_spin.setRange(-self._data.shape[index], self._data.shape[index] - 1) @Slot() def accept(self) -> None: """Reimplement Qt method""" self._data.apply_changes() QDialog.accept(self) def get_value(self) -> AnySupportedArray: """Return modified array -- the returned array is a copy if variable size is True and readonly is False Returns: Modified array """ # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute self._data.reset_shape_if_changed() return self._data.get_array() def error(self, message: str) -> None: """An error occured, closing the dialog box Args: message: Error message """ if "pytest" in sys.modules: raise RuntimeError(message) QMessageBox.critical(self, _("Array editor"), message) self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self.reject() @Slot() def reject(self) -> None: """Reimplement Qt method""" self._data.clear_changes() QDialog.reject(self) class BaseArrayEditor(ArrayEditor[np.ndarray]): """Optional wrapper class to get type inferance for normal numpy arrays""" class RecordArrayEditor(ArrayEditor[np.ndarray]): """Optional wrapper class to get type inferance for record numpy arrays""" class MaskedArrayEditor(ArrayEditor[np.ma.MaskedArray]): """Optional wrapper class to get type inferance for masked numpy arrays""" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/arrayeditor/arrayhandler.py0000644000175100017510000004066615114075001023333 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # # The array editor subpackage was derived from Spyder's arrayeditor.py module # which is licensed under the terms of the MIT License (see spyder/__init__.py # for details), copyright © Spyder Project Contributors # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 """ Provide classes to wrap Numpy arrays and handle changes made to them. Array handlers acts as a pointer to the original array and allows to share the same array in multiple models/widgets and views. They handle data access and changes. """ from __future__ import annotations import copy from typing import Any, Generic, TypeVar, Union, cast import numpy as np # TODO: (When Python 3.8-3.9 support is dropped) Use `np.ndarray | np.ma.MaskedArray` # instead of `Union[np.ndarray, np.ma.MaskedArray]` AnySupportedArray = TypeVar( "AnySupportedArray", bound=Union[np.ndarray, np.ma.MaskedArray] ) AnyArrayHandler = TypeVar("AnyArrayHandler", bound="BaseArrayHandler") class BaseArrayHandler(Generic[AnySupportedArray]): """Wrapper class around a Numpy nparray that is used a pointer to share the same array in multiple models/widgets and views. It handles data access and changes. Args: array: Numpy array to wrap variable_size: Flag to indicate if the size of the array can be modified (i.e. that data can be inserted on any axis). This flag changes the underlying data management strategy. If True, the original array will be copied and changes will be done inplace in the copy, else changes are stored in a dictionnary and applied inplace when the appropriate method is called. Defaults to False. """ __slots__ = ( "_variable_size", "_backup_array", "_array", "_dtype", "current_changes", "_og_shape", ) def __init__( self, array: AnySupportedArray, variable_size: bool = False, ) -> None: self._variable_size = variable_size self._og_shape = None self._array = array self._backup_array = array self._init_arrays(array) self._dtype = array.dtype self.current_changes: dict[tuple[str | int, ...] | str, bool] = {} def _init_arrays(self, array: np.ndarray | np.ma.MaskedArray) -> None: """Small method to handle variable initializations dependent on the array. Args: array: Numpy array to use """ if self._variable_size: self._backup_array = array if array.ndim == 1: self._array = array.reshape(-1, 1) else: self._array = copy.deepcopy(array) else: if array.ndim == 1: self._og_shape = array.shape array.shape = (array.size, 1) self._array = array @property def variable_size(self) -> bool: """Returns the variable_size flag. If True, the array is resizable and changes Returns Variable size flag """ return self._variable_size @property def ndim(self) -> int: """Numpy ndim property. Returns Number of dimensions of the array """ return self._array.ndim @property def flags(self) -> np.flagsobj: """Numpy flags property. Returns Flags of the array""" return self._array.flags @property def shape(self) -> tuple[int, ...]: """Numpy shape property. Returns Shape of the array """ return self._array.shape @shape.setter def shape(self, value: tuple[int, ...]) -> None: """Numpy shape property setter. Sets the shape of the array. Args: value: new shape of the array """ self._array.shape = value @property def dtype(self) -> Any: """Numpy dtype property. Returns dtype of the array """ return self._dtype @property def data(self) -> memoryview: """Used to get the underlying data of an array. Useful for Numpy's structured arrays. Returns A memoryview to the underlying data (= a np.ndarray) """ return self._array.data def insert_on_axis( self, index: int, axis: int, insert_number: int = 1, default: Any = 0 ) -> None: """Insert new row(s) of default values on an axis. Makes a copy of the original array. Args: index: Index from which to start the insertion. axis: axis on which to make the insertion. insert_number: Number of rows to insert at once. Defaults to 1. default: default value to insert. Defaults to the "zero value" of a type (e.g. "" for str or False for Booleans). Defaults to 0. """ indexes = (index,) * insert_number self._array = np.insert(self._array, indexes, default, axis=axis) def delete_on_axis(self, index: int, axis: int, remove_number: int = 1): """Delete row(s) on an axis. Makes a copy of the original array. Args: index: index from which to start the deletion. axis: axis on which to make the deletion. remove_number: Number of rows to delete at once. Defaults to 1. """ indexes = range(index, index + remove_number) self._array = np.delete(self._array, indexes, axis=axis) def get_array(self) -> AnySupportedArray: """Returns the current wrapped array. If variable_size is False, the returned array does not contain the current modifications as they are saved separately. Returns Numpy array contained in the handler instance """ return cast(AnySupportedArray, self._array) def new_row(self, index: int, insert_number: int = 1, default: Any = 0): """Insert new row(s) on axis 0. Do not for 3D+ arrays. Prefer the method 'insert_on_axis' instead to be sure to insert on the right axis from the Model. Args: index: Index from which to start the insertion. insert_number: Number of rows to insert at once. Defaults to 1. default: default value to insert. Defaults to the "zero value" of a type (e.g. "" for str or False for Booleans). Defaults to 0. """ self.insert_on_axis(index, 0, insert_number, default) def new_col(self, index: int, insert_number: int = 1, default: Any = 0): """Insert new column(s) on axis 1. Do not for 3D+ arrays. Prefer the method 'insert_on_axis' instead to be sure to insert on the right axis from the Model. Args: index: Index from which to start the insertion. insert_number: Number of columns to insert at once. Defaults to 1. default: default value to insert. Defaults to the "zero value" of a type (e.g. "" for str or False for Booleans). Defaults to 0. """ self.insert_on_axis(index, 1, insert_number, default) def __setitem__(self, key: tuple[int, ...] | str, item: Any): """Data setter that allows to use this class like the original array. The data storage strategy changes depending on the variable_size flag. Args: key: key to set (=array index) item: value to set. Must be of the same type as the array data. """ if self._variable_size: self._array[key] = item return self.current_changes[key] = item def __getitem__(self, key: tuple[int, ...] | str) -> Any: """Data getter that allows to use this class like the original array. The data retrieval strategy changes depending on the variable_size flag. Args: key: key to get (=array index) Returns: The requested value from the array """ if not self._variable_size: return self.current_changes.get(key, self._array[key]) return self._array[key] def apply_changes(self) -> None: """Apply changes. Only useful if flag viariable_size is False as it will write the values stored in a dictionary in the original array. This operation is non-reversible as it writes inplace. """ if not self._variable_size: for coor, value in self.current_changes.items(): self._array[coor] = value self.current_changes.clear() else: self._backup_array = copy.deepcopy(self._array) def clear_changes(self) -> None: """Deletes all the changes made until that point. If the variable_size flag is True, then restores a copy of the origninal array (makes new copy needed). Else, clears the dictionnary where changes are temporarily stored. """ if not self._variable_size: self.current_changes.clear() else: self._init_arrays(self._backup_array) def reset_shape_if_changed(self) -> None: """When a numpy array is 1D, the handler changes the shape to add a second dimension of size 1 to act like a normal 2d array in the BaseArrayModel. In some instances, the shape must be reset (i.e. when getting the array when the ArrayEditor is closed). When the shape is reset, the array editor may not work properly as the awaited 2nd dimension is removed. The method _init_arrays(array) or clear_changes() must be called to avoid errors possible IndexError. """ if self._og_shape is not None: self._array.shape = self._og_shape class MaskedArrayHandler(BaseArrayHandler[np.ma.MaskedArray]): """Same as the class BaseArrayHandler but with additionnal functionnalities to handled a Numpy MaskedArray. Args: array: Numpy MaskedArray to wrap variable_size: array resizability flag, refer to BaseArrayHandler for more information. Defaults to False. """ __slots__ = ("current_mask_changes",) _array: np.ma.MaskedArray _backup_array: np.ma.MaskedArray def __init__( self, array: np.ma.MaskedArray, variable_size: bool = False, ) -> None: super().__init__(array, variable_size) self.current_mask_changes: dict[tuple[int, ...], Any] = {} @property def mask(self) -> np.ndarray: """Numpy mask property. Returns: The mask of the array """ return self._array.mask def insert_on_axis( self, index: int, axis: int, insert_number: int = 1, default: Any = 0, default_mask: bool = False, ) -> None: """Refer to BaseArrayHandler.insert_on_axis for the full details. The only difference is that with this method is that it also inserts default values for the mask. Args: index: index from which to start the insertion. axis: axis on which to make the insertion. insert_number: Number of rows to insert at once. Defaults to 1. default: default value to insert. Defaults to the "zero value" of a type (e.g. "" for str or False for Booleans). Defaults to 0. default_mask: default mask value to insert. Defaults to False. """ indexes = (index,) * insert_number new_array: np.ma.MaskedArray = np.insert( self._array, indexes, default, axis=axis ) # The check is performed at init and array type cannot change new_mask = self._array.mask new_mask = np.insert(new_mask, indexes, default_mask, axis=axis) new_array.mask = new_mask self._array = new_array def delete_on_axis(self, index: int, axis: int, remove_number: int = 1) -> None: """Refer to BaseArrayHandler.delete_on_axis for the full details. The only difference is that with this method is that it also keep the previous value of the mask. Args: index: index from which to start the deletion. axis: axis on which to make the deletion. remove_number: number of rows to delete. Defaults to 1. """ # indexes = (index,) * remove_number indexes = range(index, min(index + remove_number, self._array.shape[axis])) new_array: np.ma.MaskedArray = np.delete(self._array, indexes, axis=axis) # The check is performed at init and array type cannot change new_mask = self._array.mask new_mask = np.delete(new_mask, indexes, axis=axis) new_array.mask = new_mask self._array = new_array def set_mask_value(self, key: tuple[int, ...], value: bool) -> None: """Setter for the mask values. Identical to BaseArrayHandler.__setitem__ but for the mask. Args: key: key to set (=mask index). item: value to set. """ if not self._variable_size: self.current_mask_changes[key] = value else: self._array.mask[key] = value def get_mask_value(self, key: tuple[int, ...]) -> bool: """Getter for the mask values. Identical to BaseArrayHandler.__getitem__ but for the mask. Args: key: key to get (=mask index). Returns: The requested value from the mask """ if not self._variable_size: return self.current_mask_changes.get(key, self._array.mask[key]) return self._array.mask[key] def get_data_value(self, key: tuple[int, ...]) -> Any: """Setter for the data values (unmasked). Identical to BaseArrayHandler.__setitem__ but for the unmasked array data. Args: key: key to set (=mask index). Must be the same type as the array. item: value to set. Returns: The requested value from the array """ if not self._variable_size: return self.current_changes.get(key, self._array.data[key]) return self._array.data[key] def set_data_value(self, key: tuple[int, ...], value: bool) -> None: """Getter for the data values (unmasked). Identical to BaseArrayHandler.__getitem__ but for the unmasked array data. Args: key: key to get (=array index). """ if not self._variable_size: self.current_changes[key] = value else: self._array.data[key] = value def apply_changes(self) -> None: """Same as BaseArrayHandler.apply_changes but also applies changes to the mask.""" super().apply_changes() for coor, value in self.current_mask_changes.items(): self._array.mask[coor] = value self.current_mask_changes.clear() def clear_changes(self) -> None: """Same as BaseArrayHandler.clear_changes but also clears the changes made to the mask.""" super().clear_changes() if not self._variable_size: self.current_mask_changes.clear() class RecordArrayHandler(BaseArrayHandler[np.ndarray]): """Same as the class BaseArrayHandler but with additionnal functionnalities to handled Numpy's structured arrays. Args: array: Numpy ndarray variable_size: array resizability flag, refer to BaseArrayHandler for more information. Defaults to False. """ def __init__( self, array: np.recarray, variable_size: bool = False, ) -> None: super().__init__(array, variable_size) def get_record_value(self, name: str, key: tuple[str | int, ...]) -> Any: """Getter for the Numpy's structured array. Identical to BaseArrayHandler.__getitem__ but for the named values. Args: name: type name to get. key: key to get. Returns: The requested value from the array """ if not self._variable_size: return self.current_changes.get((name, *key), self._array[name][key]) return self._array[name][key] def set_record_value( self, name: str, key: tuple[str | int, ...], value: Any ) -> None: """Setter for the Numpy's structured array. Identical to BaseArrayHandler.__setitem__ but for the named values. Args: name: type name to get. key: key to set (i.e. array index). """ if not self._variable_size: self.current_changes[(name, *key)] = value else: self._array[name][key] = value ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/arrayeditor/datamodel.py0000644000175100017510000010267515114075001022610 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # # The array editor subpackage was derived from Spyder's arrayeditor.py module # which is licensed under the terms of the MIT License (see spyder/__init__.py # for details), copyright © Spyder Project Contributors # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 """ Data models for the array editor widget. """ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable from functools import reduce from typing import Any, Generic, Sequence, TypeVar import numpy as np from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal from qtpy.QtGui import QColor from qtpy.QtWidgets import QMessageBox from guidata.config import CONF, _ from guidata.configtools import get_font from guidata.dataset.dataitems import BoolItem, FloatItem, IntItem, StringItem from guidata.dataset.datatypes import DataItem, DataSet from guidata.widgets.arrayeditor import utils from guidata.widgets.arrayeditor.arrayhandler import ( AnyArrayHandler, AnySupportedArray, MaskedArrayHandler, RecordArrayHandler, ) ArrayModelType = TypeVar("ArrayModelType", bound="BaseArrayModel") def handle_size_change(rows=False, cols=False) -> Callable: """Wrapper to signal when the table changed dimenstions, i.e. when a row or column is inserted. This decorator emits the BaseArrayModel.SIZE_CHANGED signal and fetch/update the model. Args: rows: If rows are inserter. Defaults to False. cols: If columns are inserter. Defaults to False. Returns: The wrapped method """ def inner_handle_size_change( model_method, ): def wrapped_method(self: "BaseArrayModel", *args, **kwargs): model_method(self, *args, **kwargs) self.fetch(rows, cols) self.set_hue_values() self.SIZE_CHANGED.emit(rows, cols) qidx = QModelIndex() self.dataChanged.emit(qidx, qidx) return wrapped_method return inner_handle_size_change class BaseArrayModel(QAbstractTableModel, Generic[AnyArrayHandler, AnySupportedArray]): """Array Editor Table Model that implements all the core functionnalities Args: array_handler: instance of BaseArrayHandler or child classes. format: format of the displayed values. Defaults to "%.6g". xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag to set the data in readonly mode. Defaults to False. parent: parent QObject. Defaults to None. current_slice: slice of the same dimension as the Numpy ndarray that will return a 2d array when applied to it. Defaults to None. """ ROWS_TO_LOAD = 500 COLS_TO_LOAD = 40 SIZE_CHANGED = Signal(bool, bool) # first bool is for rows, second is for columns class InsertionDataSet(DataSet): """Abstract class to create a dataset to insert new rows/columns in a table.""" index_field: IntItem insert_number: IntItem default_value: DataItem new_label: DataItem | None @abstractmethod def get_values_to_insert(self) -> tuple[Any, ...]: """Getter for the field values to insert in the table. The values are returned as a tuple in the same order as the fields are declared in the dataset. Raises: NotImplementedError: must be implemented by every subclass of InsertionDataSet Returns: Tuple of values to insert """ raise NotImplementedError class DeletionDataSet(DataSet): """Abstract class to create a dataset to delete row/columns from a table.""" index_field: IntItem remove_number: IntItem def __init__( self, array_handler: AnyArrayHandler, format="%.6g", xlabels=None, ylabels=None, readonly=False, parent=None, current_slice: Sequence[slice | int] | None = None, ): QAbstractTableModel.__init__(self) self.dialog = parent self.xlabels = xlabels self.ylabels = ylabels self.readonly = readonly self.test_array = np.array([0], dtype=array_handler.dtype) if array_handler.dtype in (np.complex64, np.complex128): self.color_func = np.abs else: self.color_func = np.real self._array_handler = array_handler # Backgroundcolor settings new_slice = self.default_slice() if current_slice is None else current_slice self.set_slice(new_slice) self.huerange = [0.66, 0.99] # Hue self.sat = 0.7 # Saturation self.val = 1.0 # Value self.alp = 0.6 # Alpha-channel self.bgcolor_enabled = False self._format = format self.rows_loaded = 0 self.cols_loaded = 0 self.set_hue_values() self.set_row_col_counts() @property def shape(self) -> tuple[int, ...]: """Property to simplify access to the array shape Returns The shape of the array """ return self._array_handler.shape @property def ndim(self) -> int: """Property to simplify access to the array dimension Returns The number of dimensions of the array """ return self._array_handler.ndim def get_insertion_dataset(self, index: int, axis: int) -> type[InsertionDataSet]: """Wrapper around the abstract class InsertionDataSet Args: index: default insertion index used in the dataset axis: axis on which to perform the insertion (row/column) Returns: new InsertionDataSet child class """ dtype = self._array_handler.dtype max_index = self._array_handler.shape[ self.correct_ndim_axis_for_current_slice(axis) ] ptype = type(np.zeros(1, dtype=dtype)[0].item()) index_label = ( _("Insert at row") if axis == 0 else _("Insert at column") if axis == 1 else "" ) number_label = ( _("Number of rows") if axis == 0 else _("Number of columns") if axis == 1 else "" ) value_label = _("Value") class NewInsertionDataSet(self.InsertionDataSet): """InsertionDataSet child class""" index_field = IntItem( label=index_label, default=index, min=-1, max=max_index, ) insert_number = IntItem( label=number_label, default=1, min=1, ) if ptype is int: default_value = IntItem(label=value_label, default=0) elif ptype is float: default_value = FloatItem(label=value_label, default=0.0) elif ptype is bool: default_value = BoolItem(label=value_label, default=False) elif ptype is str: default_value = StringItem(label=value_label, default="") else: default_value = IntItem( label=_("Unsupported type %s, defaults to: ") % ptype.__name__, default=0, ) default_value.set_prop("display", active=False, valid=False) new_label = None def get_values_to_insert(self) -> tuple[Any, ...]: return (self.default_value,) return NewInsertionDataSet def get_deletion_dataset(self, index: int, axis: int) -> type[DeletionDataSet]: """Wrapper around the abstract class DeletionDataSet Args: index: default deletion index used in the dataset axis: axis on which to perform the deletion (row/column) Returns: new DeletionDataSet child class """ max_index = self._array_handler.shape[ self.correct_ndim_axis_for_current_slice(axis) ] index_label = ( _("Delete from row") if axis == 0 else _("Delete from column") if axis == 1 else "" ) number_label = ( _("Number of rows") if axis == 0 else _("Number of columns") if axis == 1 else "" ) class NewDeletionDataSet(self.DeletionDataSet): """InsertionDataSet child class""" index_field = IntItem( label=index_label, default=index, min=-1, max=max_index, ) remove_number = IntItem( number_label, default=1, min=1, ) return NewDeletionDataSet def get_format(self) -> str: """Return current format Returns: Current format """ # Avoid accessing the private attribute _format from outside return self._format def set_format(self, format: str) -> None: """Change display format Args: format: new format """ self._format = format self.reset() def columnCount(self, qindex=QModelIndex()) -> int: """Array column number""" if self.total_cols <= self.cols_loaded: return self.total_cols return self.cols_loaded def rowCount(self, qindex=QModelIndex()) -> int: """Array row number""" if self.total_rows <= self.rows_loaded: return self.total_rows return self.rows_loaded def can_fetch_more(self, rows=False, columns=False) -> bool: """ Is there more data than the current slice can show? Useful when variable_size is True and columns/rows are added Args: rows: Should check the rows. Defaults to False. columns: Should check the columns. Defaults to False. Returns True if new data can be fetched """ return ( rows and self.total_rows > self.rows_loaded or columns and self.total_cols > self.cols_loaded ) def can_fetch_less(self, rows=False, columns=False) -> bool: """ Is there less data than the current slice can show? Useful when variable_size is True and columns/rows are deleted Args: rows: Should check the rows. Defaults to False. columns: Should check the columns. Defaults to False. Returns: True if less data should be fetched """ return ( rows and self.total_rows < self.rows_loaded or columns and self.total_cols < self.cols_loaded ) def fetch(self, rows=False, columns=False) -> None: """ Fetch more data if necessary. Args: rows: Should fetch rows. Defaults to False. columns: Should fetch columns. Defaults to False. """ if self.can_fetch_more(rows=rows): reminder = self.total_rows - self.rows_loaded items_to_fetch = min(reminder, self.ROWS_TO_LOAD) self.beginInsertRows( QModelIndex(), self.rows_loaded, self.rows_loaded + items_to_fetch - 1 ) self.rows_loaded += items_to_fetch self.endInsertRows() elif self._array_handler.variable_size and self.can_fetch_less(rows=rows): reminder = self.rows_loaded - self.total_rows items_to_remove = min(reminder, self.ROWS_TO_LOAD) self.beginRemoveRows( QModelIndex(), self.rows_loaded - items_to_remove, self.rows_loaded - 1, ) self.rows_loaded -= items_to_remove self.endRemoveRows() if self.can_fetch_more(columns=columns): reminder = self.total_cols - self.cols_loaded items_to_fetch = min(reminder, self.COLS_TO_LOAD) self.beginInsertColumns( QModelIndex(), self.cols_loaded, self.cols_loaded + items_to_fetch - 1 ) self.cols_loaded += items_to_fetch self.endInsertColumns() elif self._array_handler.variable_size and self.can_fetch_less(columns=columns): reminder = self.cols_loaded - self.total_cols items_to_remove = min(reminder, self.ROWS_TO_LOAD) self.beginRemoveColumns( QModelIndex(), self.cols_loaded - items_to_remove, self.cols_loaded - 1, ) self.cols_loaded -= items_to_remove self.endRemoveColumns() def bgcolor(self, state: int) -> None: """Toggle backgroundcolor Args: state: 0 or 2, 0 means disabled, 2 means enabled """ self.bgcolor_enabled = state > 0 self.reset() def get_value(self, index: tuple[int, int]) -> Any: """Use a 2d index to access data in the array handler. The index is converted into the n-dim of the array (i.e. 2d -> 3d if the array is 3-dimensional). The value returned include the changes contrary to get_array() method that may not return new changes. Args: index: 2d index tuple Returns: The value requested in the ArrayHandler """ return self._array_handler[self._compute_ndim_index(index)] def set_value(self, index: tuple[int, int], value: Any) -> None: """Same as get_value() but to set a value in the array handler. The input 2d index tuple is converted to n-dim and used to set value. Use this instead of get_array()[index] because this methods handles the changes storage. Args: index: 2d index tuple value: value to set """ self._array_handler[self._compute_ndim_index(index)] = value def _compute_ndim_index(self, index: tuple[int, int]) -> tuple[int, ...]: """Transfers a 2d index tuple into n-dim using the current_sice given to the array model. Args: index: 2d index tuple Returns: New index tuple in n-dim """ ( self._nd_index_template[self._row_axis_nd], self._nd_index_template[self._col_axis_nd], ) = index return tuple(self._nd_index_template) # type: ignore def correct_ndim_axis_for_current_slice(self, d2_axis: int) -> int: """Transfer a aixs 0/1 (row/col) from the table view to the real array axis depending on the real array dimensions and current slice selection in the interface. Args: d2_axis: 2d axis (0 or 1) Returns: The corresponding axis in n-dim (1 -> 2 if current slice is [0, :, :]) """ axis_offset = reduce( lambda x, y: x + 1 if isinstance(y, int) else x, self._current_slice[: d2_axis + 1], 0, ) return d2_axis + axis_offset def data(self, index: QModelIndex, role=Qt.ItemDataRole.DisplayRole) -> Any: """Cell content""" if not index.isValid(): return None value = self.get_value((index.row(), index.column())) if isinstance(value, bytes): try: value = str(value, "utf8") except BaseException as e: print(e) if role == Qt.ItemDataRole.DisplayRole: if value is np.ma.masked: return "" try: return self._format % value except TypeError: self.readonly = True return repr(value) elif role == Qt.ItemDataRole.TextAlignmentRole: return int(Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter) elif ( role == Qt.ItemDataRole.BackgroundColorRole and self.bgcolor_enabled and value is not np.ma.masked ): try: hue = self.hue0 + self.dhue * ( float(self.vmax) - self.color_func(value) ) / (float(self.vmax) - self.vmin) hue = float(np.abs(hue)) color = QColor.fromHsvF(hue, self.sat, self.val, self.alp) return color except TypeError: return None elif role == Qt.ItemDataRole.FontRole: return get_font(CONF, "arrayeditor", "font") return None def setData(self, index, value, role=Qt.ItemDataRole.EditRole) -> bool: """Cell content change""" if not index.isValid() or self.readonly: return False i = index.row() j = index.column() dtype = self.get_array().dtype.name if dtype == "bool": try: val = bool(float(value)) except ValueError: val = value.lower() == "true" elif dtype.startswith("string") or dtype.startswith("bytes"): val = bytes(value, "utf8") elif dtype.startswith("unicode") or dtype.startswith("str"): val = str(value) else: if value.lower().startswith("e") or value.lower().endswith("e"): return False try: val = complex(value) if not val.imag: val = val.real except ValueError as e: QMessageBox.critical( self.dialog, "Error", _("Value error: %s") % str(e) ) return False try: self.test_array[0] = val # will raise an Exception eventually except OverflowError as e: print("OverflowError: " + str(e)) # spyder: test-skip QMessageBox.critical(self.dialog, "Error", _("Overflow error: %s") % str(e)) return False self.set_value((i, j), val) self.dataChanged.emit(index, index) if isinstance(val, (int, float, complex)): if val > self.vmax: self.vmax = val if val < self.vmin: self.vmin = val return True def default_slice(self) -> tuple[slice | int, ...]: """Computes a default n-dim slice to use to get a 2d array. Returns A tuple containing 0s and 2 slices of the form: (0, ..., 0, slice(None), slice(None)) """ default_slice = tuple( map( lambda ndim: slice(None) if ndim < 2 else 0, range(self._array_handler.ndim), ) ) return default_slice def set_slice(self, new_slice: Sequence[int | slice] | None) -> None: """Use this method to change the current slice handled by the model Args: new_slice: new_slice to set """ if new_slice is None: new_slice = self.default_slice() is_slice_valid = reduce( lambda x, s: x + 1 if isinstance(s, slice) else x, new_slice, 0 ) == min(2, self._array_handler.ndim) if len(new_slice) == self.get_array().ndim and is_slice_valid: self._current_slice = new_slice self._row_axis_nd = self.correct_ndim_axis_for_current_slice(0) self._col_axis_nd = self.correct_ndim_axis_for_current_slice(1) self._nd_index_template = [ None if isinstance(s, slice) else s for s in new_slice ] else: err_msg = _( "Slice %s is not valid. Expected an Iterable of " "slices and int like (slice(None), slice(None), n1, n2, ..., nX) " "with maximum two slices." ) % str(new_slice) print(err_msg) QMessageBox.critical(self.dialog, "Error", err_msg) def flags(self, index: QModelIndex) -> Qt.ItemFlags: """Set editable flag""" if not index.isValid(): return Qt.ItemFlag.ItemIsEnabled return Qt.ItemFlags( QAbstractTableModel.flags(self, index) | Qt.ItemFlag.ItemIsEditable ) def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole) -> Any: """Set header data""" if role != Qt.ItemDataRole.DisplayRole: return None labels = ( self.xlabels if orientation == Qt.Orientation.Horizontal else self.ylabels ) if labels is None: return int(section) return labels[section] @property def total_rows(self) -> int: """Total number of rows in the array""" return self._array_handler.shape[self._row_axis_nd] @property def total_cols(self) -> int: """Total number of columns in the array""" try: return self._array_handler.shape[self._col_axis_nd] except IndexError: return 1 def set_row_col_counts(self) -> None: """Set rows and columns instance variables""" size = self.total_rows * self.total_cols if size > utils.LARGE_SIZE: self.rows_loaded = self.ROWS_TO_LOAD self.cols_loaded = self.COLS_TO_LOAD else: if self.total_rows > utils.LARGE_NROWS: self.rows_loaded = self.ROWS_TO_LOAD else: self.rows_loaded = self.total_rows if self.total_cols > utils.LARGE_COLS: self.cols_loaded = self.COLS_TO_LOAD else: self.cols_loaded = self.total_cols def set_hue_values(self) -> None: """Set hue values depending on array values (min/max)""" try: self.vmin = np.nanmin(self.color_func(self.get_array())) self.vmax = np.nanmax(self.color_func(self.get_array())) if self.vmax == self.vmin: self.vmin -= 1 self.hue0 = self.huerange[0] self.dhue = self.huerange[1] - self.huerange[0] self.bgcolor_enabled = True except (TypeError, ValueError): self.vmin = None self.vmax = None self.hue0 = None self.dhue = None self.bgcolor_enabled = False @handle_size_change(rows=True) def insert_row( self, index: int, insert_number: int = 1, default_value: Any = 0 ) -> None: """Insert new rows with a default value. Args: index: row index at which start the insertion insert_number: number of rows to insert. Defaults to 1. default_value: default value to insert. Defaults to 0. """ self._array_handler.insert_on_axis( index, self._row_axis_nd, insert_number, default_value ) @handle_size_change(rows=True) def remove_row(self, index: int, remove_number: int = 1) -> None: """Removes rows from the array. Args: index: row index at which to start the deletion remove_number: number of rows to delete. Defaults to 1. """ self._array_handler.delete_on_axis(index, self._row_axis_nd, remove_number) @handle_size_change(cols=True) def insert_column( self, index: int, insert_number: int = 1, default_value: Any = 0 ) -> None: """Insert new columns with a default value. Args: index: column index at which start the insertion insert_number: number of columns to insert. Defaults to 1. default_value: default value to insert. Defaults to 0. """ self._array_handler.insert_on_axis( index, self._col_axis_nd, insert_number, default_value ) @handle_size_change(cols=True) def remove_column(self, index: int, remove_number: int = 1) -> None: """Removes columns from the array. Args: index: column index at which to start the deletion remove_number: number of columns to delete. Defaults to 1. """ self._array_handler.delete_on_axis(index, self._col_axis_nd, remove_number) def reset(self) -> None: """Reset the model.""" self.beginResetModel() self.endResetModel() def get_array(self) -> AnySupportedArray: """Return the array. Returns Array stored in the array handler. Does not include data changes if not in variable_size mode. """ return self._array_handler.get_array() def apply_changes(self) -> None: """Validates and apply changes in the array. Irreversible.""" self._array_handler.apply_changes() def clear_changes(self) -> None: """Cancel all changes in the array. Irreversible.""" self._array_handler.clear_changes() class MaskedArrayModel(BaseArrayModel[MaskedArrayHandler, np.ma.MaskedArray]): """Wrapper around a MaskedArrayHandler. More specifically, this model handles the masked data. Check BaseArrayModel for more info on core functionnalities. Args: array_handler: instance of MaskedArrayHandler. format: format of the displayed values. Defaults to "%.6g". xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag to set the data in readonly mode. Defaults to False. parent: parent QObject. Defaults to None. current_slice: slice of the same dimension as the Numpy ndarray that will return a 2d array when applied to it. Defaults to None. """ def __init__( self, array_handler: MaskedArrayHandler, format="%.6g", xlabels=None, ylabels=None, readonly=False, parent=None, current_slice: Sequence[slice | int] | None = None, ) -> None: super().__init__( array_handler, format, xlabels, ylabels, readonly, parent, current_slice ) def get_insertion_dataset( self, index: int, axis: int ) -> type[BaseArrayModel.InsertionDataSet]: """See BaseArrayModel.get_insertion_dataset(). This method modifies the DataSet to include a boolean field to set a default value to the mask. """ class NewInsertionDataSet(super().get_insertion_dataset(index, axis)): """InsertionDataSet child class to handle new mask value on insertion""" mask_value = BoolItem(label="Mask value", default=False) def get_values_to_insert(self) -> tuple[Any, ...]: """See BaseArrayModel.InsertionDataSet.get_values_to_insert()""" return (self.default_value, self.mask_value) return NewInsertionDataSet @handle_size_change(rows=True) def insert_row( self, index: int, insert_number: int, default_value: Any, default_mask_value: bool = True, ) -> None: """Same as BaseArrayModel.insert_row() but adds the capacity to add a row Args: index: index at which to insert the row insert_number: number of rows to insert default_value: default value to insert default_mask_value: default mask value to insert. Defaults to True. """ self._array_handler.insert_on_axis( index, self._row_axis_nd, insert_number, default_value, default_mask_value ) # calls MaskedArrayHandler.insert_on_axis which has a default_mask argument @handle_size_change(cols=True) def insert_column( self, index: int, insert_number: int, default_value: Any, default_mask_value: bool = False, ) -> None: """Same as BaseArrayModel.insert_column() but adds the capacity to add a column Args: index: index at which to insert the column insert_number: number of columns to insert default_value: default value to insert default_mask_value: default mask value to insert. Defaults to False. """ self._array_handler.insert_on_axis( index, self._col_axis_nd, insert_number, default_value, default_mask_value ) # calls MaskedArrayHandler.insert_on_axis which has a default_mask argument class MaskArrayModel(MaskedArrayModel): """Wrapper around a MaskedArrayHandler. More specifically, this model handles the mask data. Check BaseArrayModel and MaskedArrayHandler for more core functionnalites. Args: array_handler: instance of MaskedArrayHander. format: format of the displayed values. Defaults to "%.6g". xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag to set the data in readonly mode. Defaults to False. parent: parent QObject. Defaults to None. current_slice: slice of the same dimension as the Numpy ndarray that will return a 2d array when applied to it. Defaults to None. """ def get_array(self) -> np.ndarray: """Returns the array mask (override of the BaseArrayModel.get_array() method) Returns: Boolean mask """ return self._array_handler.mask def get_value(self, index: tuple[int, ...]) -> bool: """Get a mask value (include the changes made). Like get_array(), this is a method override. Args: index: index from which to get the value Returns: Mask boolean """ return self._array_handler.get_mask_value(index) def set_value(self, index: tuple[int, ...], value: bool) -> None: """Set mask value (override of the BaseArrayModel.set_value() method) Args: index: index at which to set the value value: mask boolean """ self._array_handler.set_mask_value(index, value) class DataArrayModel(MaskedArrayModel): """Wrapper around a MaskedArrayHandler. More specifically, this model handles the raw unmaksed, data. Check BaseArrayModel and MaskedArrayHandler for more core functionnalites. Args: array_handler: instance of MaskedArrayHander. format: format of the displayed values. Defaults to "%.6g". xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag to set the data in readonly mode. Defaults to False. parent: parent QObject. Defaults to None. current_slice: slice of the same dimension as the Numpy ndarray that will return a 2d array when applied to it. Defaults to None. """ def get_array(self) -> memoryview: """Returns a memoryview that correspond to the raw (unmasked) data in the MaskedArray. Th ememoryview can be used like a standard numpy array. Returns: Data memoryview """ return self._array_handler.data def get_value(self, index: tuple[int, ...]) -> Any: """Get a value from underlying masked array data. Args: index: index to get a value from Returns: Data value at index """ return self._array_handler.get_data_value(index) def set_value(self, index: tuple[int, ...], value: Any) -> None: """Set a value in the underlying masked array data. Args: index: index at which to set the value value: value to set """ self._array_handler.set_data_value(index, value) class RecordArrayModel(BaseArrayModel[RecordArrayHandler, AnySupportedArray]): """Array Editor Table Model made for record arrays (= Numpy's structured arrays). Args: array_handler: instance of BaseArrayHandler or child classes. dtype_name: name of the type to handle in the model format: format of the displayed values. Defaults to "%.6g". xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag to set the data in readonly mode. Defaults to False. parent: parent QObject. Defaults to None. current_slice: slice of the same dimension as the Numpy ndarray that will return a 2d array when applied to it. Defaults to None. """ __slots__ = ("_dtype_name",) def __init__( self, array_handler: RecordArrayHandler, dtype_name: str, format="%.6g", xlabels=None, ylabels=None, readonly=False, parent=None, current_slice: Sequence[slice | int] | None = None, ) -> None: self._dtype_name = dtype_name super().__init__( array_handler, format, xlabels, ylabels, readonly, parent, current_slice ) def get_array(self) -> np.ndarray: """Returns the array (override of the BaseArrayModel.get_array() method) Returns: Array stored in the array handler. Does not include data changes if not in variable_size mode. """ return self._array_handler.get_array()[self._dtype_name] def get_value(self, index: tuple[int, ...]) -> Any: """Get a value from underlying masked array data. Args: index: index to get a value from Returns: Data value at index """ return self._array_handler.get_record_value(self._dtype_name, index) def set_value(self, index: tuple[int, ...], value: Any) -> None: """Set a value in the underlying masked array data. Args: index: index at which to set the value value: value to set """ self._array_handler.set_record_value(self._dtype_name, index, value) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/arrayeditor/editorwidget.py0000644000175100017510000011314215114075001023337 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # # The array editor subpackage was derived from Spyder's arrayeditor.py module # which is licensed under the terms of the MIT License (see spyder/__init__.py # for details), copyright © Spyder Project Contributors # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 # ruff: noqa """Array editor widget""" from __future__ import annotations import io from typing import Any, Generic, Sequence, cast from qtpy.compat import getsavefilename import numpy as np from qtpy.QtCore import QItemSelection, QItemSelectionRange, QLocale, QPoint, Qt, Slot from qtpy.QtGui import QCursor, QDoubleValidator, QKeySequence from qtpy.QtWidgets import ( QAbstractItemDelegate, QApplication, QCheckBox, QHBoxLayout, QInputDialog, QItemDelegate, QLineEdit, QMenu, QMessageBox, QPushButton, QShortcut, QTableView, QVBoxLayout, QWidget, ) from guidata.config import CONF, _ from guidata.configtools import get_font, get_icon from guidata.qthelpers import add_actions, create_action, keybinding from guidata.widgets import about from guidata.widgets.arrayeditor import utils from guidata.widgets.arrayeditor.arrayhandler import ( AnySupportedArray, BaseArrayHandler, MaskedArrayHandler, RecordArrayHandler, ) from guidata.widgets.arrayeditor.datamodel import ( ArrayModelType, BaseArrayModel, DataArrayModel, MaskArrayModel, MaskedArrayModel, RecordArrayModel, ) class ArrayDelegate(QItemDelegate): """Array Editor Item Delegate Args: dtype: Numpy's dtype of the array to edit parent: parent QObject """ def __init__(self, dtype: np.dtype, parent=None) -> None: QItemDelegate.__init__(self, parent) self.dtype = dtype def createEditor(self, parent, option, index) -> QLineEdit | None: """Create editor widget""" model: BaseArrayModel = index.model() # type: ignore value = model.get_value((index.row(), index.column())) if model.get_array().dtype.name == "bool": value = not value model.setData(index, value) return None if value is not np.ma.masked: editor = QLineEdit(parent) editor.setFont(get_font(CONF, "arrayeditor", "font")) editor.setAlignment(Qt.AlignmentFlag.AlignCenter) if utils.is_number(self.dtype): validator = QDoubleValidator(editor) validator.setLocale(QLocale("C")) editor.setValidator(validator) editor.returnPressed.connect(self.commitAndCloseEditor) return editor return None def commitAndCloseEditor(self) -> None: """Commit and close editor""" editor = self.sender() # Avoid a segfault with PyQt5. Variable value won't be changed # but at least Spyder won't crash. It seems generated by a bug in sip. try: self.commitData.emit(editor) except AttributeError as e: print(e) pass self.closeEditor.emit(editor, QAbstractItemDelegate.EndEditHint.NoHint) def setEditorData(self, editor, index) -> None: """Set editor widget's data""" if (model := index.model()) is not None and editor is not None: text = model.data(index, Qt.ItemDataRole.DisplayRole) editor.setText(text) class DefaultValueDelegate(QItemDelegate): """Array Editor Item Delegate Args: dtype: Numpy's dtype of the array to edit parent: parent QObject """ def __init__(self, dtype: np.dtype, parent=None) -> None: QItemDelegate.__init__(self, parent) self.dtype = dtype (self.default_value,) = np.zeros(1, dtype=dtype) def createEditor(self, parent, option, index) -> QLineEdit: """Create editor widget""" editor = QLineEdit(parent) editor.setFont(get_font(CONF, "arrayeditor", "font")) editor.setAlignment(Qt.AlignmentFlag.AlignCenter) if utils.is_number(self.dtype): validator = QDoubleValidator(editor) validator.setLocale(QLocale("C")) editor.setValidator(validator) editor.returnPressed.connect(self.commitAndCloseEditor) return editor def commitAndCloseEditor(self): """Commit and close editor""" editor = self.sender() # Avoid a segfault with PyQt5. Variable value won't be changed # but at least Spyder won't crash. It seems generated by a bug in sip. try: self.commitData.emit(editor) except AttributeError as e: print(e) pass self.closeEditor.emit(editor, QAbstractItemDelegate.EndEditHint.NoHint) def setEditorData(self, editor, index): """Set editor widget's data""" if (model := index.model()) is not None and editor is not None: text = model.data(index, Qt.ItemDataRole.DisplayRole) editor.setText(text) # TODO: Implement "Paste" (from clipboard) feature class ArrayView(QTableView, Generic[ArrayModelType]): """Array view class Args: parent: parent QObject model: BaseArrayModel to use dtype: Numpy's dtype of the array to edit shape: Numpy's shape of the array to edit variable_size: Flag to indicate if the array dimensions can be modified. If a BaseArrayHandler is given as input, the handler should also be in readonly mode. Defaults to False. """ def __init__( self, parent: QWidget, model: ArrayModelType, dtype, shape, variable_size=False ) -> None: QTableView.__init__(self, parent) self._variable_size = variable_size self.setModel(model) self.setItemDelegate(ArrayDelegate(dtype, self)) total_width = 0 for k in range(self.model().shape[1]): total_width += self.columnWidth(k) self.viewport().resize(min(total_width, 1024), self.height()) QShortcut(QKeySequence(QKeySequence.Copy), self, self.copy) QShortcut(QKeySequence(QKeySequence.Paste), self, self.paste) self.horizontalScrollBar().valueChanged.connect( lambda val: self.load_more_data(val, columns=True) ) self.verticalScrollBar().valueChanged.connect( lambda val: self.load_more_data(val, rows=True) ) if self._variable_size: self._current_row_index = None self.vheader_menu = self.setup_header_menu(0) vheader = self.verticalHeader() vheader.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) vheader.customContextMenuRequested.connect(self.verticalHeaderContextMenu) self._current_col_index = None self.hheader_menu = self.setup_header_menu(1) hheader = self.horizontalHeader() hheader.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) hheader.customContextMenuRequested.connect(self.horizontalHeaderContextMenu) self.cell_menu = self.setup_cell_menu() self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.cellContextMenu) def model(self) -> ArrayModelType: """Returns the current BaseArrayModel or raises an TypeError Raises: ValueError: if the model is not a BaseArrayModel Returns: Current array model """ assert isinstance(model := super().model(), BaseArrayModel) return cast(ArrayModelType, model) def load_more_data(self, value: int, rows=False, columns=False) -> None: """Load more data if needed Args: value: scrollbar value rows: Flag to indicate if rows should be loaded columns: Flag to indicate if columns should be loaded """ old_selection = self.selectionModel().selection() old_rows_loaded = old_cols_loaded = None if rows and value == self.verticalScrollBar().maximum(): old_rows_loaded = self.model().rows_loaded self.model().fetch(rows=rows) if columns and value == self.horizontalScrollBar().maximum(): old_cols_loaded = self.model().cols_loaded self.model().fetch(columns=columns) if old_rows_loaded is not None or old_cols_loaded is not None: # if we've changed anything, update selection new_selection = QItemSelection() for part in old_selection: top = part.top() bottom = part.bottom() if ( old_rows_loaded is not None and top == 0 and bottom == (old_rows_loaded - 1) ): # complete column selected (so expand it to match updated range) bottom = self.model().rows_loaded - 1 left = part.left() right = part.right() if ( old_cols_loaded is not None and left == 0 and right == (old_cols_loaded - 1) ): # compete row selected (so expand it to match updated range) right = self.model().cols_loaded - 1 top_left = self.model().index(top, left) bottom_right = self.model().index(bottom, right) part = QItemSelectionRange(top_left, bottom_right) new_selection.append(part) self.selectionModel().select( new_selection, self.selectionModel().ClearAndSelect ) def insert_row(self) -> None: """Insert row(s) in the array.""" if (i := self._current_row_index) is not None: ( i, insert_number, default_values, _new_label, valid, ) = self.ask_default_inserted_value(i, 0) if valid: self.model().insert_row(i, insert_number, *default_values) self._current_row_index = None def remove_row(self) -> None: """Remove row(s) in the array""" if (i := self._current_row_index) is not None: i, remove_number, valid = self.ask_rows_cols_to_remove(i, 0) if valid: self.model().remove_row(i, remove_number) self._current_row_index = None def insert_col(self) -> None: """Insert column(s) in the array""" if (j := self._current_col_index) is not None: ( j, insert_number, default_value, _new_label, valid, ) = self.ask_default_inserted_value(j, 1) if valid: self.model().insert_column(j, insert_number, *default_value) self._current_col_index = None def remove_col(self) -> None: """Remove column(s) in the array""" if (j := self._current_col_index) is not None: j, remove_number, valid = self.ask_rows_cols_to_remove(j, 1) if valid: self.model().remove_column(j, remove_number) self._current_col_index = None def ask_default_inserted_value( self, index: int, axis: int ) -> tuple[int, int, tuple[Any, ...], Any | None, bool]: """Create and open a new dialog with a form to input insertion parameters Args: index: default insertion index (choice made when clicking) axis: insertion axis (row (0) or column (1)) Returns: User inputs """ InsertionDataSet = self.model().get_insertion_dataset(index, axis) title = ( _("Row(s) insertion") if axis == 0 else _("Column(s) insertion") if axis == 1 else "" ) insertion_dataset = InsertionDataSet( title=title, icon="insert.png", ) is_ok = insertion_dataset.edit() index_: int = insertion_dataset.index_field max_index = ( self.model() .get_array() .shape[self.model().correct_ndim_axis_for_current_slice(axis)] ) index_ = max_index if index_ == -1 else index_ return ( index_, insertion_dataset.insert_number, insertion_dataset.get_values_to_insert(), insertion_dataset.new_label, is_ok, ) # type: ignore def ask_rows_cols_to_remove(self, index: int, axis: int) -> tuple[int, int, bool]: """Create and open a new dialog with a form to input deletion parameters Args: index: default deletion index (choice made when clicking) axis: deletion axis (row (0) or column (1)) Returns: User inputs """ DeletionDataSet = self.model().get_deletion_dataset(index, axis) title = ( _("Row(s) deletion") if axis == 0 else _("Column(s) deletion") if axis == 1 else "" ) deletion_dataset = DeletionDataSet( title=title, icon="delete.png", ) is_ok = deletion_dataset.edit() index_ = deletion_dataset.index_field max_index = ( self.model() .get_array() .shape[self.model().correct_ndim_axis_for_current_slice(axis)] ) number_to_del = min(deletion_dataset.remove_number, max_index - index_) return index_, number_to_del, is_ok # type: ignore def resize_to_contents(self) -> None: """Resize cells to contents""" QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) self.resizeColumnsToContents() self.model().fetch(columns=True) self.resizeColumnsToContents() QApplication.restoreOverrideCursor() def setup_cell_menu(self) -> QMenu: """Setup context menu Returns: New QMenu object """ self.copy_action = create_action( self, _("Copy"), shortcut=keybinding("Copy"), icon=get_icon("editcopy.png"), triggered=self.copy, context=Qt.ShortcutContext.WidgetShortcut, ) self.paste_action = create_action( self, _("Paste"), shortcut=keybinding("Paste"), icon=get_icon("editpaste.png"), triggered=self.paste, context=Qt.ShortcutContext.WidgetShortcut, ) self.paste_action.setDisabled(self.model().readonly) about_action = create_action( self, _("About..."), icon=get_icon("guidata.svg"), triggered=about.show_about_dialog, ) if self._variable_size: insert_row_action = create_action( self, title=_("Insert row(s)"), icon=get_icon("insert.png"), triggered=self.insert_row, ) insert_col_action = create_action( self, title=_("Insert column(s)"), icon=get_icon("insert.png"), triggered=self.insert_col, ) remove_row_action = create_action( self, title=_("Remove row(s)"), icon=get_icon("delete.png"), triggered=self.remove_row, ) remove_col_action = create_action( self, title=_("Remove column(s)"), icon=get_icon("delete.png"), triggered=self.remove_col, ) actions = ( self.copy_action, self.paste_action, None, insert_row_action, insert_col_action, None, remove_row_action, remove_col_action, None, about_action, ) else: actions = ( self.copy_action, self.paste_action, None, about_action, ) menu = QMenu(self) add_actions(menu, actions) return menu def setup_header_menu(self, axis: int) -> QMenu: """Creates and return a contextual menu for a header depending on input axis. Args: axis: 0 for rows (vertical header), 1 for columns (horizontal header) Returns: New QMenu object for the given header """ action_args = { 0: ( (_("Insert row(s)"), self.insert_row), (_("Remove row(s)"), self.remove_row), ), 1: ( (_("Insert column(s)"), self.insert_col), (_("Remove column(s)"), self.remove_col), ), }[axis] insert_action = create_action( self, title=action_args[0][0], icon=get_icon("insert.png"), triggered=action_args[0][1], ) remove_action = create_action( self, title=action_args[1][0], icon=get_icon("delete.png"), triggered=action_args[1][1], ) actions = ( insert_action, None, remove_action, ) menu = QMenu(self) add_actions(menu, actions) return menu def verticalHeaderContextMenu(self, pos: QPoint) -> None: """Reimplement Qt method""" vheader = self.verticalHeader() self._current_row_index = vheader.logicalIndexAt(pos) self.vheader_menu.popup(vheader.mapToGlobal(pos)) def horizontalHeaderContextMenu(self, pos: QPoint) -> None: """Reimplement Qt method""" hheader = self.horizontalHeader() self._current_col_index = hheader.logicalIndexAt(pos) self.hheader_menu.popup(hheader.mapToGlobal(pos)) def cellContextMenu(self, pos: QPoint) -> None: """Reimplement Qt method""" try: selected_index = self.selectedIndexes()[0] self._current_row_index, self._current_col_index = ( selected_index.row(), selected_index.column(), ) except IndexError: # click outside of cells self._current_row_index, self._current_col_index = ( self.model().total_rows, self.model().total_cols, ) # we get the index of the last array element to insert after the last row/column self.cell_menu.popup(self.viewport().mapToGlobal(pos)) def keyPressEvent(self, event) -> None: """Reimplement Qt method""" if event == QKeySequence.Copy: self.copy() else: QTableView.keyPressEvent(self, event) def _sel_to_text(self, cell_range: list[QItemSelectionRange]) -> str | None: """Copy an array portion to a unicode string Args: cell_range: list of QItemSelectionRange objects Returns: String representation of the selected array portion, or None if the selection is empty """ if not cell_range: return None model = self.model() row_min, row_max, col_min, col_max = utils.get_idx_rect(cell_range) if col_min == 0 and col_max == (model.cols_loaded - 1): # we've selected a whole column. It isn't possible to # select only the first part of a column without loading more, # so we can treat it as intentional and copy the whole thing col_max = model.total_cols - 1 if row_min == 0 and row_max == (model.rows_loaded - 1): row_max = model.total_rows - 1 model.apply_changes() _data = model.get_array() # TODO check if this should apply changes or not output = io.BytesIO() try: np.savetxt( output, _data[row_min : row_max + 1, col_min : col_max + 1], delimiter="\t", fmt=model.get_format(), ) except BaseException: QMessageBox.warning( self, _("Warning"), _("It was not possible to copy values for this array"), ) return None contents = output.getvalue().decode("utf-8") output.close() return contents @Slot() def copy(self) -> None: """Copy text to clipboard""" cliptxt = self._sel_to_text(self.selectedIndexes()) clipboard = QApplication.clipboard() clipboard.setText(cliptxt) @Slot() def paste(self) -> None: """Paste text from clipboard""" cliptxt = QApplication.clipboard().text() if not cliptxt: return try: data = np.genfromtxt(io.StringIO(cliptxt), delimiter="\t") except ValueError: data = np.array([]) if data.size == 0: QMessageBox.warning( self, _("Warning"), _("It was not possible to paste values for this array"), ) return model = self.model() # Determine where to paste start_row = self.currentIndex().row() start_col = self.currentIndex().column() # Iterate and paste each value data = np.array(data, dtype=model.get_array().dtype) if data.ndim in (0, 1): data = data.reshape((-1, 1)) for i in range(data.shape[0]): for j in range(data.shape[1]): if (start_row + i < model.total_rows) and ( start_col + j < model.total_cols ): idx = model.index(start_row + i, start_col + j) model.setData(idx, str(data[i, j])) model.dataChanged.emit( model.index(start_row, start_col), model.index(start_row + data.shape[0] - 1, start_col + data.shape[1] - 1), ) class BaseArrayEditorWidget(QWidget): """Base ArrayEditdorWidget class. Used to wrap handle n-dimensional normal Numpy's ndarray. Args: parent: parent QObject data: Numpy's ndarray or BaseArrayHandler to use. readonly: Flag for readonly mode. Defaults to False. xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. variable_size: Flag to indicate if the array dimensions can be modified. If a BaseArrayHandler is given as input, the handler should also be in readonly mode Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. Defaults to None """ def __init__( self, parent, data: np.ndarray | BaseArrayHandler, readonly=False, xlabels=None, ylabels=None, variable_size=False, current_slice: Sequence[slice | int] | None = None, ) -> None: QWidget.__init__(self, parent) self._variable_size = variable_size and not readonly self._data: BaseArrayHandler | MaskedArrayHandler self._init_handler(data) self._init_model(xlabels, ylabels, readonly, current_slice=current_slice) format = utils.SUPPORTED_FORMATS.get(self.model.get_array().dtype.name, "%s") self.model.set_format(format) self.view = ArrayView( self, self.model, self.model.get_array().dtype, self._data.shape, self._variable_size, ) btn_layout = QHBoxLayout() btn_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) btn = QPushButton(get_icon("format.svg"), _("Format"), self) # disable format button for int type btn.setEnabled(utils.is_float(self._data.dtype)) btn_layout.addWidget(btn) btn.clicked.connect(self.change_format) btn = QPushButton(get_icon("resize.svg"), _("Resize"), self) btn_layout.addWidget(btn) btn.clicked.connect(self.view.resize_to_contents) bgcolor = QCheckBox(_("Background color")) bgcolor.setChecked(self.model.bgcolor_enabled) bgcolor.setEnabled(self.model.bgcolor_enabled) bgcolor.stateChanged.connect(self.model.bgcolor) btn_layout.addWidget(bgcolor) btn_layout.addStretch(1) btn = QPushButton(get_icon("copy_all.svg"), _("Copy all"), self) btn.setToolTip(_("Copy all array data to clipboard")) btn.clicked.connect(self.copy_all_to_clipboard) btn_layout.addWidget(btn) btn = QPushButton(get_icon("export.svg"), _("Export"), self) btn.setToolTip(_("Export array to a file")) btn_layout.addWidget(btn) btn.clicked.connect(self.export_array) layout = QVBoxLayout() layout.addWidget(self.view) layout.addLayout(btn_layout) self.setLayout(layout) def _init_handler( self, data: AnySupportedArray | BaseArrayHandler[AnySupportedArray] ) -> None: """Initializes and set the instance handler to use Args: data: Numpy's ndarray or BaseArrayHandler to use. """ if isinstance(data, np.ndarray): self._data = BaseArrayHandler[AnySupportedArray](data, self._variable_size) elif isinstance(data, BaseArrayHandler): self._data = data else: raise TypeError( "Given data must be of type np.ndarray or BaseArrayHandler, " f"not {type(data)}" ) def _init_model( self, xlabels: Sequence[str] | None, ylabels: Sequence[str] | None, readonly: bool, current_slice: Sequence[slice | int] | None = None, ) -> None: """Initializes and set the instance model to use Args: xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag for readonly mode. Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. """ self.model = BaseArrayModel( self._data, xlabels=xlabels, ylabels=ylabels, readonly=readonly, parent=self, current_slice=current_slice, ) def accept_changes(self) -> None: """Accept changes""" self.model.apply_changes() def reject_changes(self) -> None: """Reject changes""" self.model.clear_changes() def change_format(self) -> None: """Change display format""" format, valid = QInputDialog.getText( self, _("Format"), _("Float formatting"), QLineEdit.Normal, self.model.get_format(), ) if valid: format = str(format) try: format % 1.1 except BaseException: QMessageBox.critical( self, _("Error"), _("Format (%s) is incorrect") % format ) return self.model.set_format(format) def copy_all_to_clipboard(self) -> None: """Copy all array data, including headers, to clipboard""" data = self.model.get_array() xlabels = self.model.xlabels ylabels = self.model.ylabels output = io.StringIO() c0sep = "" if ylabels is None else "\t" # Write column headers if xlabels: output.write(c0sep + "\t".join(xlabels) + "\n") # Write rows with row headers for i, row in enumerate(data): label = ylabels[i] if ylabels else "" output.write(label + c0sep + "\t".join(map(str, row)) + "\n") cliptxt = output.getvalue() output.close() if cliptxt: clipboard = QApplication.clipboard() clipboard.setText(cliptxt) def export_array(self) -> None: """Export the array to a CSV file with UTF-8 BOM.""" filename, _selfilter = getsavefilename( self, _("Export array"), "", _("CSV Files") + " (*.csv)" ) if not filename: return data = self.model.get_array() xlabels = self.model.xlabels ylabels = self.model.ylabels with open(filename, "w", encoding="utf-8-sig") as file: c0sep = "" if ylabels is None else "\t" # Write column headers if xlabels: file.write(c0sep + "\t".join(xlabels) + "\n") # Write rows for i, row in enumerate(data): label = ylabels[i] if ylabels else "" file.write(label + c0sep + "\t".join(map(str, row)) + "\n") if not (xlabels or ylabels): # If no labels, overwrite file with simple array np.savetxt( filename, data, delimiter="\t", fmt=self.model.get_format(), encoding="utf-8-sig", ) class MaskedArrayEditorWidget(BaseArrayEditorWidget): """Same as BaseArrayWidgetEditorWidget but specifically handles MaskedArrayHandler and MaskedArrayModel. Specifically the masked data. Args: parent: parent QObject data: Numpy's ndarray or BaseArrayHandler to use. readonly: Flag for readonly mode. Defaults to False. xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. variable_size: Flag to indicate if the array dimensions can be modified. If a BaseArrayHandler is given as input, the handler should also be in readonly mode Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. Defaults to None """ # _data: MaskedArrayHandler def _init_handler(self, data: np.ma.MaskedArray | MaskedArrayHandler) -> None: """Initializes and set the instance handler to use Args: data: Numpy's MaskedArray or MaskedArrayHandler to use. """ if isinstance(data, np.ma.MaskedArray): self._data = MaskedArrayHandler(data, self._variable_size) elif isinstance(data, MaskedArrayHandler): self._data = data else: raise TypeError( "Given data must be of type np.ma.MaskedArray or " "MaskedArrayHandler, not {type(data)}" ) def _init_model( self, xlabels: Sequence[str] | None, ylabels: Sequence[str] | None, readonly: bool, current_slice: Sequence[slice | int] | None = None, ) -> None: """Initializes and set the instance model to use Args: xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag for readonly mode. Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. """ assert isinstance(self._data, MaskedArrayHandler) self.model = MaskedArrayModel( self._data, xlabels=xlabels, ylabels=ylabels, readonly=readonly, parent=self, current_slice=current_slice, ) class MaskArrayEditorWidget(MaskedArrayEditorWidget): """Same as BaseArrayWidgetEditorWidget but specifically handles MaskedArrayHandler and MaskArrayModel. Specifically the boolean mask. Args: parent: parent QObject data: Numpy's ndarray or BaseArrayHandler to use. readonly: Flag for readonly mode. Defaults to False. xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. variable_size: Flag to indicate if the array dimensions can be modified. If a BaseArrayHandler is given as input, the handler should also be in readonly mode Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. Defaults to None """ # _data: MaskedArrayHandler def _init_model( self, xlabels: Sequence[str] | None, ylabels: Sequence[str] | None, readonly: bool, current_slice: Sequence[slice | int] | None = None, ) -> None: """Initializes and set the instance model to use Args: xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag for readonly mode. Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. """ assert isinstance(self._data, MaskedArrayHandler) self.model = MaskArrayModel( self._data, xlabels=xlabels, ylabels=ylabels, readonly=readonly, parent=self, current_slice=current_slice, ) class DataArrayEditorWidget(MaskedArrayEditorWidget): """Same as BaseArrayWidgetEditorWidget but specifically handles MaskedArrayHandler and DataArrayModel. Specifically the raw unmasked data. Args: parent: parent QObject data: Numpy's ndarray or BaseArrayHandler to use. readonly: Flag for readonly mode. Defaults to False. xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. variable_size: Flag to indicate if the array dimensions can be modified. If a BaseArrayHandler is given as input, the handler should also be in readonly mode Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. Defaults to None """ # _data: MaskedArrayHandler def __init__( self, parent: QWidget, data: np.ma.MaskedArray | MaskedArrayHandler, readonly=False, xlabels=None, ylabels=None, variable_size=False, current_slice: Sequence[slice | int] | None = None, ) -> None: super().__init__( parent, data, readonly, xlabels, ylabels, variable_size, current_slice ) def _init_model( self, xlabels: Sequence[str] | None, ylabels: Sequence[str] | None, readonly: bool, current_slice: Sequence[slice | int] | None = None, ) -> None: """Initializes and set the instance model to use Args: xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag for readonly mode. Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. """ assert isinstance(self._data, MaskedArrayHandler) self.model = DataArrayModel( self._data, xlabels=xlabels, ylabels=ylabels, readonly=readonly, parent=self, current_slice=current_slice, ) class RecordArrayEditorWidget(BaseArrayEditorWidget): """Same as BaseArrayWidgetEditorWidget but specifically handles RecordArrayHandler and RecordArrayModel which are made to wrap Numpy's structured arrays. Args: parent: parent QObject data: Numpy's ndarray or BaseArrayHandler to use. readonly: Flag for readonly mode. Defaults to False. xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. variable_size: Flag to indicate if the array dimensions can be modified. If a BaseArrayHandler is given as input, the handler should also be in readonly mode Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. Defaults to None """ def __init__( self, parent, data: RecordArrayHandler, dtype_name: str, readonly=False, xlabels=None, ylabels=None, variable_size=False, current_slice: Sequence[slice | int] | None = None, ) -> None: self._dtype_name = dtype_name super().__init__( parent, data, readonly, xlabels, ylabels, variable_size, current_slice ) def _init_handler(self, data: np.ndarray | RecordArrayHandler) -> None: """Initializes and set the instance handler to use Args: data: Numpy's ndarray or BaseArrayHandler to use. """ if isinstance(data, np.ma.MaskedArray): self._data = RecordArrayHandler(data, self._variable_size) elif isinstance(data, RecordArrayHandler): self._data = data else: raise TypeError( f"Given data must be of type np.ndarray or RecordArrayHandler, not {type(data)}" ) def _init_model( self, xlabels: Sequence[str] | None, ylabels: Sequence[str] | None, readonly: bool, current_slice: Sequence[slice | int] | None = None, ) -> None: """Initializes and set the instance model to use Args: xlabels: labels for the columns (header). Defaults to None. ylabels: labels for the rows (header). Defaults to None. readonly: Flag for readonly mode. Defaults to False. current_slice: slice of the same dimension as the Numpy ndarray that will. """ assert isinstance(self._data, RecordArrayHandler) self.model = RecordArrayModel( self._data, self._dtype_name, xlabels=xlabels, ylabels=ylabels, readonly=readonly, parent=self, current_slice=current_slice, ) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/arrayeditor/utils.py0000644000175100017510000000500115114075001021777 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) # # The array editor subpackage was derived from Spyder's arrayeditor.py module # which is licensed under the terms of the MIT License (see spyder/__init__.py # for details), copyright © Spyder Project Contributors # Note: string and unicode data types will be formatted with '%s' (see below) """Basic utilitarian functions and variables for the various array editor classes""" import numpy as np SUPPORTED_FORMATS = { "single": "%.6g", "double": "%.6g", "float_": "%.6g", "longfloat": "%.6g", "float16": "%.6g", "float32": "%.6g", "float64": "%.6g", "float96": "%.6g", "float128": "%.6g", "csingle": "%r", "complex_": "%r", "clongfloat": "%r", "complex64": "%r", "complex128": "%r", "complex192": "%r", "complex256": "%r", "byte": "%d", "bytes8": "%s", "short": "%d", "intc": "%d", "int_": "%d", "longlong": "%d", "intp": "%d", "int8": "%d", "int16": "%d", "int32": "%d", "int64": "%d", "ubyte": "%d", "ushort": "%d", "uintc": "%d", "uint": "%d", "ulonglong": "%d", "uintp": "%d", "uint8": "%d", "uint16": "%d", "uint32": "%d", "uint64": "%d", "bool_": "%r", "bool8": "%r", "bool": "%r", } LARGE_SIZE = 5e5 LARGE_NROWS = 1e5 LARGE_COLS = 60 # ============================================================================== # Utility functions # ============================================================================== def is_float(dtype: np.dtype) -> bool: """Return True if datatype dtype is a float kind Args: dtype: numpy datatype Returns: True if dtype is a float kind """ return ("float" in dtype.name) or dtype.name in ["single", "double"] def is_number(dtype: np.dtype) -> bool: """Return True is datatype dtype is a number kind Args: dtype: numpy datatype Returns: True if dtype is a number kind """ return ( is_float(dtype) or ("int" in dtype.name) or ("long" in dtype.name) or ("short" in dtype.name) ) def get_idx_rect(index_list: list) -> tuple: """Extract the boundaries from a list of indexes Args: index_list: list of indexes Returns: tuple: (min_row, max_row, min_col, max_col) """ rows, cols = list(zip(*[(i.row(), i.column()) for i in index_list])) return (min(rows), max(rows), min(cols), max(cols)) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/codeeditor.py0000644000175100017510000004042215114075001020441 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """ guidata.widgets.codeeditor ========================== This package provides an Editor widget based on QtGui.QPlainTextEdit. .. autoclass:: CodeEditor :show-inheritance: :members: """ # %% This line is for cell execution testing # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 from __future__ import annotations from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal from qtpy.QtGui import QColor, QFont, QPainter from qtpy.QtWidgets import QPlainTextEdit, QWidget import guidata.widgets.syntaxhighlighters as sh from guidata.config import CONF, _ from guidata.configtools import get_font, get_icon from guidata.qthelpers import ( add_actions, create_action, is_dark_theme, win32_fix_title_bar_background, ) from guidata.utils import encoding from guidata.widgets import about class LineNumberArea(QWidget): """Line number area (on the left side of the text editor widget) Args: editor: CodeEditor widget """ def __init__(self, editor: CodeEditor) -> None: QWidget.__init__(self, editor) self.code_editor = editor self.setMouseTracking(True) def sizeHint(self): """Override Qt method""" return QSize(self.code_editor.compute_linenumberarea_width(), 0) def paintEvent(self, event): """Override Qt method""" self.code_editor.linenumberarea_paint_event(event) def mouseMoveEvent(self, event): """Override Qt method""" self.code_editor.linenumberarea_mousemove_event(event) def mouseDoubleClickEvent(self, event): """Override Qt method""" self.code_editor.linenumberarea_mousedoubleclick_event(event) def mousePressEvent(self, event): """Override Qt method""" self.code_editor.linenumberarea_mousepress_event(event) def mouseReleaseEvent(self, event): """Override Qt method""" self.code_editor.linenumberarea_mouserelease_event(event) def wheelEvent(self, event): """Override Qt method""" self.code_editor.wheelEvent(event) class CodeEditor(QPlainTextEdit): """Code editor widget Args: parent: Parent widget language: Language used for syntax highlighting font: Font used for the text columns: Number of columns rows: Number of rows inactivity_timeout: after this delay of inactivity (in milliseconds), the :py:attr:`CodeEditor.SIG_EDIT_STOPPED` signal is emitted """ # To have these attrs when early viewportEvent's are triggered linenumberarea = None #: Signal emitted when text changes and the user stops typing for some time SIG_EDIT_STOPPED = Signal() LANGUAGES = { "python": sh.PythonSH, "cython": sh.CythonSH, "fortran77": sh.Fortran77SH, "fortran": sh.FortranSH, "idl": sh.IdlSH, "diff": sh.DiffSH, "gettext": sh.GetTextSH, "nsis": sh.NsisSH, "html": sh.HtmlSH, "yaml": sh.YamlSH, "cpp": sh.CppSH, "opencL": sh.OpenCLSH, "enaml": sh.EnamlSH, # Every other language None: sh.TextSH, } def __init__( self, parent: QWidget = None, language: str | None = None, font: QFont | None = None, columns: int | None = None, rows: int | None = None, inactivity_timeout: int = 1000, ) -> None: QPlainTextEdit.__init__(self, parent) self.visible_blocks = [] self.highlighter = None self.normal_color = None self.sideareas_color = None self.linenumbers_color = None # Line number area management self.linenumbers_margin = True self.linenumberarea_enabled = None self.linenumberarea_pressed = None self.linenumberarea_released = None self.timer = QTimer() self.timer.setSingleShot(True) self.timer.setInterval(inactivity_timeout) # When the editor is destroyed, the timer is destroyed as well, so we do # not need to disconnect the timer from the SIG_EDIT_STOPPED signal. # But... we connect the timer to the SIG_EDIT_STOPPED signal directly: # we do not connect it to the `emit` method for two reasons. # # 1. The documented way to connect a signal to another signal is the # following: `signal1.connect(signal2)`. # # 2. When the editor is destroyed, if the timer is connected to the `emit` # method, the timer will try to call the `emit` method which is still bound # to the destroyed editor. This will cause a crash, eventually (there is a # time window between the destruction of the editor and the destruction of # the timer, so the crash is not guaranteed to happen, but it is possible). # On the other hand, if the timer is connected to the SIG_EDIT_STOPPED # signal, when the timeout is reached, the connection will be handled by # Qt and the `emit` method will not be called, so there will be no crash. self.timer.timeout.connect(self.SIG_EDIT_STOPPED) self.textChanged.connect(self.text_has_changed) self.setFocusPolicy(Qt.StrongFocus) self.setup(language=language, font=font, columns=columns, rows=rows) self.update_color_mode() def update_color_mode(self) -> None: """Update color mode according to the current theme""" win32_fix_title_bar_background(self) suffix = "dark" if is_dark_theme() else "light" color_scheme = CONF.get("color_schemes", "default/" + suffix) self.highlighter.set_color_scheme(color_scheme) self.highlighter.rehighlight() self.linenumbers_color = QColor( Qt.lightGray if is_dark_theme() else Qt.darkGray ) self.normal_color = self.highlighter.get_foreground_color() self.sideareas_color = self.highlighter.get_sideareas_color() self.linenumberarea.update() def text_has_changed(self) -> None: """Text has changed: restart the timer to emit SIG_EDIT_STOPPED after a delay""" if self.timer.isActive(): self.timer.stop() self.timer.start() def contextMenuEvent(self, event): """Override Qt method""" menu = self.createStandardContextMenu() about_action = create_action( self, _("About..."), icon=get_icon("guidata.svg"), triggered=about.show_about_dialog, ) add_actions(menu, (None, about_action)) menu.exec(event.globalPos()) def setup(self, language=None, font=None, columns=None, rows=None): """Setup widget""" if font is None: font = get_font(CONF, "codeeditor") self.setFont(font) self.setup_linenumberarea() if language is not None: language = language.lower() highlighter_class = self.LANGUAGES.get(language) if highlighter_class is None: raise ValueError("Unsupported language '%s'" % language) self.highlighter = highlighter_class(self.document(), self.font()) self.highlighter.rehighlight() self.normal_color = self.highlighter.get_foreground_color() self.sideareas_color = self.highlighter.get_sideareas_color() if columns is not None: self.set_minimum_width(columns) if rows is not None: self.set_minimum_height(rows) def set_minimum_width(self, columns): """Set widget minimum width to show the specified number of columns""" width = self.fontMetrics().width("9" * (columns + 8)) self.setMinimumWidth(width) def set_minimum_height(self, rows): """Set widget minimum height to show the specified number of rows""" height = self.fontMetrics().height() * (rows + 1) self.setMinimumHeight(height) def setup_linenumberarea(self): """Setup widget""" self.linenumberarea = LineNumberArea(self) self.blockCountChanged.connect(self.update_linenumberarea_width) self.updateRequest.connect(self.update_linenumberarea) self.linenumberarea_pressed = -1 self.linenumberarea_released = -1 self.set_linenumberarea_enabled(True) self.update_linenumberarea_width() def set_text_from_file(self, filename): """Set the text of the editor from file *fname*""" text, _enc = encoding.read(filename) self.setPlainText(text) # -----linenumberarea def set_linenumberarea_enabled(self, state): """ :param state: """ self.linenumberarea_enabled = state self.linenumberarea.setVisible(state) self.update_linenumberarea_width() def get_linenumberarea_width(self): """Return current line number area width""" return self.linenumberarea.contentsRect().width() def compute_linenumberarea_width(self): """Compute and return line number area width""" if not self.linenumberarea_enabled: return 0 digits = 1 maxb = max(1, self.blockCount()) while maxb >= 10: maxb /= 10 digits += 1 if self.linenumbers_margin: linenumbers_margin = 3 + self.fontMetrics().width("9" * digits) else: linenumbers_margin = 0 return linenumbers_margin def update_linenumberarea_width(self, new_block_count=None): """ Update line number area width. new_block_count is needed to handle blockCountChanged(int) signal """ self.setViewportMargins(self.compute_linenumberarea_width(), 0, 0, 0) def update_linenumberarea(self, qrect, dy): """Update line number area""" if dy: self.linenumberarea.scroll(0, dy) else: self.linenumberarea.update( 0, qrect.y(), self.linenumberarea.width(), qrect.height() ) if qrect.contains(self.viewport().rect()): self.update_linenumberarea_width() def linenumberarea_paint_event(self, event): """Painting line number area""" painter = QPainter(self.linenumberarea) painter.fillRect(event.rect(), self.sideareas_color) # This is needed to make that the font size of line numbers # be the same as the text one when zooming # See Issues 2296 and 4811 font = self.font() font_height = self.fontMetrics().height() active_block = self.textCursor().block() active_line_number = active_block.blockNumber() + 1 for top, line_number, block in self.visible_blocks: if self.linenumbers_margin: if line_number == active_line_number: font.setWeight(QFont.Bold) painter.setFont(font) painter.setPen(self.normal_color) else: font.setWeight(QFont.Normal) painter.setFont(font) painter.setPen(self.linenumbers_color) painter.drawText( 0, top, self.linenumberarea.width(), font_height, Qt.AlignRight | Qt.AlignBottom, str(line_number), ) def __get_linenumber_from_mouse_event(self, event): """Return line number from mouse event""" block = self.firstVisibleBlock() line_number = block.blockNumber() top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() bottom = top + self.blockBoundingRect(block).height() while block.isValid() and top < event.pos().y(): block = block.next() top = bottom bottom = top + self.blockBoundingRect(block).height() line_number += 1 return line_number def linenumberarea_mousemove_event(self, event): """Handling line number area mouse move event""" line_number = self.__get_linenumber_from_mouse_event(event) block = self.document().findBlockByNumber(line_number - 1) data = block.userData() # this disables pyflakes messages if there is an active drag/selection # operation check = self.linenumberarea_released == -1 if data and data.code_analysis and check: self.__show_code_analysis_results(line_number, data.code_analysis) if event.buttons() == Qt.LeftButton: self.linenumberarea_released = line_number self.linenumberarea_select_lines( self.linenumberarea_pressed, self.linenumberarea_released ) def linenumberarea_mousedoubleclick_event(self, event): """Handling line number area mouse double-click event""" line_number = self.__get_linenumber_from_mouse_event(event) shift = event.modifiers() & Qt.ShiftModifier def linenumberarea_mousepress_event(self, event): """Handling line number area mouse double press event""" line_number = self.__get_linenumber_from_mouse_event(event) self.linenumberarea_pressed = line_number self.linenumberarea_released = line_number self.linenumberarea_select_lines( self.linenumberarea_pressed, self.linenumberarea_released ) def linenumberarea_mouserelease_event(self, event): """Handling line number area mouse release event""" self.linenumberarea_released = -1 self.linenumberarea_pressed = -1 def linenumberarea_select_lines(self, linenumber_pressed, linenumber_released): """Select line(s) after a mouse press/mouse press drag event""" find_block_by_line_number = self.document().findBlockByLineNumber move_n_blocks = linenumber_released - linenumber_pressed start_line = linenumber_pressed start_block = find_block_by_line_number(start_line - 1) cursor = self.textCursor() cursor.setPosition(start_block.position()) # Select/drag downwards if move_n_blocks > 0: for n in range(abs(move_n_blocks) + 1): cursor.movePosition(cursor.NextBlock, cursor.KeepAnchor) # Select/drag upwards or select single line else: cursor.movePosition(cursor.NextBlock) for n in range(abs(move_n_blocks) + 1): cursor.movePosition(cursor.PreviousBlock, cursor.KeepAnchor) # Account for last line case if linenumber_released == self.blockCount(): cursor.movePosition(cursor.EndOfBlock, cursor.KeepAnchor) else: cursor.movePosition(cursor.StartOfBlock, cursor.KeepAnchor) self.setTextCursor(cursor) def resizeEvent(self, event): """Reimplemented Qt method to handle line number area resizing""" QPlainTextEdit.resizeEvent(self, event) cr = self.contentsRect() self.linenumberarea.setGeometry( QRect(cr.left(), cr.top(), self.compute_linenumberarea_width(), cr.height()) ) def paintEvent(self, event): """Overrides paint event to update the list of visible blocks""" self.update_visible_blocks(event) QPlainTextEdit.paintEvent(self, event) def update_visible_blocks(self, event): """Update the list of visible blocks/lines position""" self.visible_blocks[:] = [] block = self.firstVisibleBlock() blockNumber = block.blockNumber() top = int( self.blockBoundingGeometry(block).translated(self.contentOffset()).top() ) bottom = top + int(self.blockBoundingRect(block).height()) ebottom_bottom = self.height() while block.isValid(): visible = bottom <= ebottom_bottom if not visible: break if block.isVisible(): self.visible_blocks.append((top, blockNumber + 1, block)) block = block.next() top = bottom bottom = top + int(self.blockBoundingRect(block).height()) blockNumber = block.blockNumber() if __name__ == "__main__": from guidata.qthelpers import qt_app_context with qt_app_context(exec_loop=True): widget = CodeEditor(columns=80, rows=40) widget.set_text_from_file(__file__) widget.show() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/collectionseditor.py0000644000175100017510000015075315114075001022056 0ustar00runnerrunner# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright © Spyder Project Contributors # # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ---------------------------------------------------------------------------- # ruff: noqa """ guidata.widgets.collectionseditor ================================= This package provides a Collections (i.e. dictionary, list and tuple) editor widget and dialog. .. autoclass:: CollectionsEditor :show-inheritance: :members: """ # TODO: Multiple selection: open as many editors (array/dict/...) as necessary, # at the same time # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 import datetime import io import re import sys import warnings try: from PIL import Image as PILImage except ImportError: PILImage = None from qtpy.compat import getsavefilename from qtpy.QtCore import QAbstractTableModel, QDateTime, QModelIndex, Qt, Signal, Slot from qtpy.QtGui import QColor, QKeySequence from qtpy.QtWidgets import ( QAbstractItemDelegate, QApplication, QDateEdit, QDateTimeEdit, QDialog, QHBoxLayout, QInputDialog, QItemDelegate, QLineEdit, QMenu, QMessageBox, QPushButton, QTableView, QVBoxLayout, QWidget, ) from guidata.config import CONF, _ from guidata.configtools import get_font, get_icon from guidata.qthelpers import ( add_actions, create_action, mimedata2url, win32_fix_title_bar_background, ) from guidata.utils.misc import getcwd_or_home from guidata.widgets.importwizard import ImportWizard from guidata.widgets.nsview import ( DataFrame, DatetimeIndex, FakeObject, Image, MaskedArray, Series, array, display_to_value, get_color_name, get_human_readable_type, get_object_attrs, get_size, get_type_string, is_editable_type, is_known_type, ndarray, np_savetxt, sort_against, try_to_eval, unsorted_unique, value_to_display, ) from guidata.widgets.texteditor import TextEditor if ndarray is not FakeObject: from guidata.widgets.arrayeditor import ArrayEditor if DataFrame is not FakeObject: from guidata.widgets.dataframeeditor import DataFrameEditor LARGE_NROWS = 100 ROWS_TO_LOAD = 50 def fix_reference_name(name, blacklist=None): """Return a syntax-valid Python reference name from an arbitrary name""" name = "".join(re.split(r"[^0-9a-zA-Z_]", name)) while name and not re.match(r"([a-zA-Z]+[0-9a-zA-Z_]*)$", name): if not re.match(r"[a-zA-Z]", name[0]): name = name[1:] continue name = str(name) if not name: name = "data" if blacklist is not None and name in blacklist: def get_new_name(index): """Generate new name""" return name + ("%03d" % index) index = 0 while get_new_name(index) in blacklist: index += 1 name = get_new_name(index) return name class ProxyObject: """Dictionary proxy to an unknown object.""" def __init__(self, obj): """Constructor.""" self.__obj__ = obj def __len__(self): """Get len according to detected attributes.""" return len(get_object_attrs(self.__obj__)) def __getitem__(self, key): """Get the attribute corresponding to the given key.""" # Catch NotImplementedError to fix #6284 in pandas MultiIndex # due to NA checking not being supported on a multiindex. # Catch AttributeError to fix #5642 in certain special classes like xml # when this method is called on certain attributes. # Catch TypeError to prevent fatal Python crash to desktop after # modifying certain pandas objects. Fix issue #6727 . # Catch ValueError to allow viewing and editing of pandas offsets. # Fix issue #6728 . try: attribute_toreturn = getattr(self.__obj__, key) except (NotImplementedError, AttributeError, TypeError, ValueError): attribute_toreturn = None return attribute_toreturn def __setitem__(self, key, value): """Set attribute corresponding to key with value.""" # Catch AttributeError to gracefully handle inability to set an # attribute due to it not being writeable or set-table. # Fix issue #6728 . Also, catch NotImplementedError for safety. try: setattr(self.__obj__, key, value) except (TypeError, AttributeError, NotImplementedError): pass except Exception as e: if "cannot set values for" not in str(e): raise class ReadOnlyCollectionsModel(QAbstractTableModel): """CollectionsEditor Read-Only Table Model""" sig_setting_data = Signal() def __init__( self, parent, data, title="", names=False, minmax=False, dataframe_format=None ): QAbstractTableModel.__init__(self, parent) if data is None: data = {} self.names = names self.minmax = minmax self.dataframe_format = dataframe_format self.header0 = None self._data = None self.total_rows = None self.showndata = None self.keys = None self.title = str(title) # in case title is not a string if self.title: self.title = self.title + " - " self.sizes = [] self.types = [] self.set_data(data) def get_data(self): """Return model data""" return self._data def set_data(self, data, coll_filter=None): """Set model data""" self._data = data data_type = get_type_string(data) if coll_filter is not None and isinstance(data, (tuple, list, dict)): data = coll_filter(data) self.showndata = data self.header0 = _("Index") if self.names: self.header0 = _("Name") if isinstance(data, tuple): self.keys = list(range(len(data))) self.title += _("Tuple") elif isinstance(data, list): self.keys = list(range(len(data))) self.title += _("List") elif isinstance(data, dict): self.keys = list(data.keys()) self.title += _("Dictionary") if not self.names: self.header0 = _("Key") else: self.keys = get_object_attrs(data) self._data = data = self.showndata = ProxyObject(data) if not self.names: self.header0 = _("Attribute") if not isinstance(self._data, ProxyObject): self.title += " (" + str(len(self.keys)) + " " + _("elements") + ")" else: self.title += data_type self.total_rows = len(self.keys) if self.total_rows > LARGE_NROWS: self.rows_loaded = ROWS_TO_LOAD else: self.rows_loaded = self.total_rows self.sig_setting_data.emit() self.set_size_and_type() self.reset() def set_size_and_type(self, start=None, stop=None): """ :param start: :param stop: """ data = self._data if start is None and stop is None: start = 0 stop = self.rows_loaded fetch_more = False else: fetch_more = True # Ignore pandas warnings that certain attributes are deprecated # and will be removed, since they will only be accessed if they exist. with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message=( r"^\w+\.\w+ is deprecated and " "will be removed in a future version" ), ) sizes = [get_size(data[self.keys[index]]) for index in range(start, stop)] types = [ get_human_readable_type(data[self.keys[index]]) for index in range(start, stop) ] if fetch_more: self.sizes = self.sizes + sizes self.types = self.types + types else: self.sizes = sizes self.types = types def sort(self, column, order=Qt.AscendingOrder): """Overriding sort method""" reverse = order == Qt.DescendingOrder if column == 0: self.sizes = sort_against(self.sizes, self.keys, reverse) self.types = sort_against(self.types, self.keys, reverse) try: self.keys.sort(reverse=reverse) except Exception: # pylint: disable=broad-except pass elif column == 1: self.keys[: self.rows_loaded] = sort_against(self.keys, self.types, reverse) self.sizes = sort_against(self.sizes, self.types, reverse) try: self.types.sort(reverse=reverse) except Exception: # pylint: disable=broad-except pass elif column == 2: self.keys[: self.rows_loaded] = sort_against(self.keys, self.sizes, reverse) self.types = sort_against(self.types, self.sizes, reverse) try: self.sizes.sort(reverse=reverse) except Exception: # pylint: disable=broad-except pass elif column == 3: values = [self._data[key] for key in self.keys] self.keys = sort_against(self.keys, values, reverse) self.sizes = sort_against(self.sizes, values, reverse) self.types = sort_against(self.types, values, reverse) self.beginResetModel() self.endResetModel() def columnCount(self, qindex=QModelIndex()): """Array column number""" return 4 def rowCount(self, index=QModelIndex()): """Array row number""" if self.total_rows <= self.rows_loaded: return self.total_rows else: return self.rows_loaded def canFetchMore(self, index=QModelIndex()): """ :param index: :return: """ if self.total_rows > self.rows_loaded: return True else: return False def fetchMore(self, index=QModelIndex()): """ :param index: """ reminder = self.total_rows - self.rows_loaded items_to_fetch = min(reminder, ROWS_TO_LOAD) self.set_size_and_type(self.rows_loaded, self.rows_loaded + items_to_fetch) self.beginInsertRows( QModelIndex(), self.rows_loaded, self.rows_loaded + items_to_fetch - 1 ) self.rows_loaded += items_to_fetch self.endInsertRows() def get_index_from_key(self, key): """ :param key: :return: """ try: return self.createIndex(self.keys.index(key), 0) except (RuntimeError, ValueError): return QModelIndex() def get_key(self, index): """Return current key""" return self.keys[index.row()] def get_value(self, index): """Return current value""" if index.column() == 0: return self.keys[index.row()] elif index.column() == 1: return self.types[index.row()] elif index.column() == 2: return self.sizes[index.row()] else: return self._data[self.keys[index.row()]] def get_bgcolor(self, index): """Background color depending on value""" if index.column() == 0: color = QColor(Qt.lightGray) color.setAlphaF(0.05) elif index.column() < 3: color = QColor(Qt.lightGray) color.setAlphaF(0.2) else: color = QColor(Qt.lightGray) color.setAlphaF(0.3) return color def data(self, index, role=Qt.DisplayRole): """Cell content""" if not index.isValid(): return None value = self.get_value(index) if index.column() == 3: display = value_to_display(value, minmax=self.minmax) else: display = str(value) if role == Qt.DisplayRole: return display elif role == Qt.EditRole: return value_to_display(value) elif role == Qt.TextAlignmentRole: if index.column() == 3: if len(display.splitlines()) < 3: return int(Qt.AlignLeft | Qt.AlignVCenter) else: return int(Qt.AlignLeft | Qt.AlignTop) else: return int(Qt.AlignLeft | Qt.AlignVCenter) elif role == Qt.BackgroundColorRole: return self.get_bgcolor(index) elif role == Qt.FontRole: return get_font(CONF, "dicteditor", "font") return None def headerData(self, section, orientation, role=Qt.DisplayRole): """Overriding method headerData""" if role != Qt.DisplayRole: return None i_column = int(section) if orientation == Qt.Horizontal: headers = (self.header0, _("Type"), _("Size"), _("Value")) return headers[i_column] else: return None def flags(self, index): """Overriding method flags""" # This method was implemented in CollectionsModel only, but to enable # tuple exploration (even without editing), this method was moved here if not index.isValid(): return Qt.ItemIsEnabled return Qt.ItemFlags(QAbstractTableModel.flags(self, index) | Qt.ItemIsEditable) def reset(self): """ """ self.beginResetModel() self.endResetModel() class CollectionsModel(ReadOnlyCollectionsModel): """Collections Table Model""" def set_value(self, index, value): """Set value""" self._data[self.keys[index.row()]] = value self.showndata[self.keys[index.row()]] = value self.sizes[index.row()] = get_size(value) self.types[index.row()] = get_human_readable_type(value) self.sig_setting_data.emit() def get_bgcolor(self, index): """Background color depending on value""" value = self.get_value(index) if index.column() < 3: color = ReadOnlyCollectionsModel.get_bgcolor(self, index) else: color_name = get_color_name(value) color = QColor(color_name) color.setAlphaF(0.2) return color def setData(self, index, value, role=Qt.EditRole): """Cell content change""" if not index.isValid(): return False if index.column() < 3: return False value = display_to_value(value, self.get_value(index), ignore_errors=True) self.set_value(index, value) self.dataChanged.emit(index, index) return True class CollectionsDelegate(QItemDelegate): """CollectionsEditor Item Delegate""" def __init__(self, parent=None): QItemDelegate.__init__(self, parent) self._editors = {} # keep references on opened editors def get_value(self, index): """ :param index: :return: """ if index.isValid(): return index.model().get_value(index) def set_value(self, index, value): """ :param index: :param value: """ if index.isValid(): index.model().set_value(index, value) def show_warning(self, index): """ Decide if showing a warning when the user is trying to view a big variable associated to a Tablemodel index This avoids getting the variables' value to know its size and type, using instead those already computed by the TableModel. The problem is when a variable is too big, it can take a lot of time just to get its value """ try: val_size = index.model().sizes[index.row()] val_type = index.model().types[index.row()] except Exception: # pylint: disable=broad-except return False if val_type in ["list", "tuple", "dict"] and int(val_size) > 1e5: return True else: return False def createEditor(self, parent, option, index): """Overriding method createEditor""" if index.column() < 3: return None if self.show_warning(index): answer = QMessageBox.warning( self.parent(), _("Warning"), _( "Opening this variable can be slow\n\n" "Do you want to continue anyway?" ), QMessageBox.Yes | QMessageBox.No, ) if answer == QMessageBox.No: return None try: value = self.get_value(index) if value is None: return None except Exception as msg: QMessageBox.critical( self.parent(), _("Error"), _( "Unable to retrieve the value of " "this variable from the console.

" "The error mesage was:
" "%s" ) % str(msg), ) return key = index.model().get_key(index) readonly = ( isinstance(value, tuple) or self.parent().readonly or not is_known_type(value) ) # CollectionsEditor for a list, tuple, dict, etc. if isinstance(value, (list, tuple, dict)): editor = CollectionsEditor(parent=parent) editor.setup(value, key, icon=self.parent().windowIcon(), readonly=readonly) self.create_dialog( editor, dict(model=index.model(), editor=editor, key=key, readonly=readonly), ) return None # ArrayEditor for a Numpy array elif isinstance(value, (ndarray, MaskedArray)) and ndarray is not FakeObject: editor = ArrayEditor(parent=parent) if not editor.setup_and_check(value, title=key, readonly=readonly): return self.create_dialog( editor, dict(model=index.model(), editor=editor, key=key, readonly=readonly), ) return None # ArrayEditor for an images elif ( isinstance(value, Image) and ndarray is not FakeObject and Image is not FakeObject ): arr = array(value) editor = ArrayEditor(parent=parent) if not editor.setup_and_check(arr, title=key, readonly=readonly): return def conv_func(arr): """Conversion function""" return PILImage.fromarray(arr, mode=value.mode) self.create_dialog( editor, dict( model=index.model(), editor=editor, key=key, readonly=readonly, conv=conv_func, ), ) return None # DataFrameEditor for a pandas dataframe, series or index elif ( isinstance(value, (DataFrame, DatetimeIndex, Series)) and DataFrame is not FakeObject ): editor = DataFrameEditor(parent=parent) if not editor.setup_and_check(value, title=key): return editor.dataModel.set_format(index.model().dataframe_format) editor.sig_option_changed.connect(self.change_option) self.create_dialog( editor, dict(model=index.model(), editor=editor, key=key, readonly=readonly), ) return None # QDateEdit and QDateTimeEdit for a dates or datetime respectively elif isinstance(value, datetime.date): if readonly: return None else: if isinstance(value, datetime.datetime): editor = QDateTimeEdit(value, parent=parent) else: editor = QDateEdit(value, parent=parent) editor.setCalendarPopup(True) editor.setFont(get_font(CONF, "dicteditor", "font")) return editor # TextEditor for a long string elif isinstance(value, str) and len(value) > 40: te = TextEditor(None, parent=parent) if te.setup_and_check(value): editor = TextEditor(value, key, readonly=readonly, parent=parent) self.create_dialog( editor, dict( model=index.model(), editor=editor, key=key, readonly=readonly ), ) return None # QLineEdit for an individual value (int, float, short string, etc) elif is_editable_type(value): if readonly: return None else: editor = QLineEdit(parent=parent) editor.setFont(get_font(CONF, "dicteditor", "font")) editor.setAlignment(Qt.AlignLeft) # This is making Spyder crash because the QLineEdit that it's # been modified is removed and a new one is created after # evaluation. So the object on which this method is trying to # act doesn't exist anymore. # editor.returnPressed.connect(self.commitAndCloseEditor) return editor # CollectionsEditor for an arbitrary Python object else: editor = CollectionsEditor(parent=parent) editor.setup(value, key, icon=self.parent().windowIcon(), readonly=readonly) self.create_dialog( editor, dict(model=index.model(), editor=editor, key=key, readonly=readonly), ) return None def create_dialog(self, editor, data): """ :param editor: :param data: """ self._editors[id(editor)] = data editor.accepted.connect(lambda eid=id(editor): self.editor_accepted(eid)) editor.rejected.connect(lambda eid=id(editor): self.editor_rejected(eid)) editor.show() @Slot(str, object) def change_option(self, option_name, new_value): """ Change configuration option. This function is called when a `sig_option_changed` signal is received. At the moment, this signal can only come from a DataFrameEditor. """ if option_name == "dataframe_format": self.parent().set_dataframe_format(new_value) def editor_accepted(self, editor_id): """ :param editor_id: """ data = self._editors[editor_id] if not data["readonly"]: index = data["model"].get_index_from_key(data["key"]) value = data["editor"].get_value() conv_func = data.get("conv", lambda v: v) self.set_value(index, conv_func(value)) self._editors.pop(editor_id) editor = self.sender() editor.deleteLater() def editor_rejected(self, editor_id): """ :param editor_id: """ self._editors.pop(editor_id) editor = self.sender() editor.deleteLater() def commitAndCloseEditor(self): """Overriding method commitAndCloseEditor""" editor = self.sender() # Avoid a segfault with PyQt5. Variable value won't be changed # but at least Spyder won't crash. It seems generated by a bug in sip. try: self.commitData.emit(editor) except AttributeError: pass self.closeEditor.emit(editor, QAbstractItemDelegate.NoHint) def setEditorData(self, editor, index): """ Overriding method setEditorData Model --> Editor """ value = self.get_value(index) if isinstance(editor, QLineEdit): if isinstance(value, bytes): try: value = str(value, "utf8") except Exception: # pylint: disable=broad-except pass if not isinstance(value, str): value = repr(value) editor.setText(value) elif isinstance(editor, QDateEdit): editor.setDate(value) elif isinstance(editor, QDateTimeEdit): editor.setDateTime(QDateTime(value.date(), value.time())) def setModelData(self, editor, model, index): """ Overriding method setModelData Editor --> Model """ if not hasattr(model, "set_value"): # Read-only mode return if isinstance(editor, QLineEdit): value = editor.text() try: value = display_to_value( value, self.get_value(index), ignore_errors=False ) except Exception as msg: raise QMessageBox.critical( editor, _("Edit item"), _( "Unable to assign data to item." "

Error message:
%s" ) % str(msg), ) return elif isinstance(editor, QDateEdit): qdate = editor.date() value = datetime.date(qdate.year(), qdate.month(), qdate.day()) elif isinstance(editor, QDateTimeEdit): qdatetime = editor.dateTime() qdate = qdatetime.date() qtime = qdatetime.time() value = datetime.datetime( qdate.year(), qdate.month(), qdate.day(), qtime.hour(), qtime.minute(), qtime.second(), ) else: # Should not happen... raise RuntimeError("Unsupported editor widget") self.set_value(index, value) class BaseTableView(QTableView): """Base collection editor table view""" sig_option_changed = Signal(str, object) sig_files_dropped = Signal(list) redirect_stdio = Signal(bool) def __init__(self, parent): QTableView.__init__(self, parent) self.array_filename = None self.menu = None self.empty_ws_menu = None self.paste_action = None self.copy_action = None self.edit_action = None self.plot_action = None self.hist_action = None self.imshow_action = None self.save_array_action = None self.insert_action = None self.remove_action = None self.minmax_action = None self.rename_action = None self.duplicate_action = None self.delegate = None self.setAcceptDrops(True) def setup_table(self): """Setup table""" self.horizontalHeader().setStretchLastSection(True) self.adjust_columns() # Sorting columns self.setSortingEnabled(True) self.sortByColumn(0, Qt.AscendingOrder) def setup_menu(self, minmax): """Setup context menu""" if self.minmax_action is not None: self.minmax_action.setChecked(minmax) return resize_action = create_action( self, _("Resize rows to contents"), triggered=self.resizeRowsToContents ) self.paste_action = create_action( self, _("Paste"), icon=get_icon("editpaste.png"), triggered=self.paste ) self.copy_action = create_action( self, _("Copy"), icon=get_icon("editcopy.png"), triggered=self.copy ) self.edit_action = create_action( self, _("Edit"), icon=get_icon("edit.png"), triggered=self.edit_item ) self.plot_action = create_action( self, _("Plot"), icon=get_icon("plot.png"), triggered=lambda: self.plot_item("plot"), ) self.plot_action.setVisible(False) self.hist_action = create_action( self, _("Histogram"), icon=get_icon("hist.png"), triggered=lambda: self.plot_item("hist"), ) self.hist_action.setVisible(False) self.imshow_action = create_action( self, _("Show image"), icon=get_icon("imshow.png"), triggered=self.imshow_item, ) self.imshow_action.setVisible(False) self.save_array_action = create_action( self, _("Save array"), icon=get_icon("filesave.png"), triggered=self.save_array, ) self.save_array_action.setVisible(False) self.insert_action = create_action( self, _("Insert"), icon=get_icon("insert.png"), triggered=self.insert_item ) self.remove_action = create_action( self, _("Remove"), icon=get_icon("editdelete.png"), triggered=self.remove_item, ) self.minmax_action = create_action( self, _("Show arrays min/max"), toggled=self.toggle_minmax ) self.minmax_action.setChecked(minmax) self.toggle_minmax(minmax) self.rename_action = create_action( self, _("Rename"), icon=get_icon("rename.png"), triggered=self.rename_item ) self.duplicate_action = create_action( self, _("Duplicate"), icon=get_icon("edit_add.png"), triggered=self.duplicate_item, ) menu = QMenu(self) menu_actions = [ self.edit_action, self.plot_action, self.hist_action, self.imshow_action, self.save_array_action, self.insert_action, self.remove_action, self.copy_action, self.paste_action, None, self.rename_action, self.duplicate_action, None, resize_action, ] if ndarray is not FakeObject: menu_actions.append(self.minmax_action) add_actions(menu, menu_actions) self.empty_ws_menu = QMenu(self) add_actions( self.empty_ws_menu, [self.insert_action, self.paste_action, None, resize_action], ) return menu # ------ Remote/local API --------------------------------------------------- def remove_values(self, keys): """Remove values from data""" raise NotImplementedError def copy_value(self, orig_key, new_key): """Copy value""" raise NotImplementedError def new_value(self, key, value): """Create new value in data""" raise NotImplementedError def is_list(self, key): """Return True if variable is a list or a tuple""" raise NotImplementedError def get_len(self, key): """Return sequence length""" raise NotImplementedError def is_array(self, key): """Return True if variable is a numpy array""" raise NotImplementedError def is_image(self, key): """Return True if variable is a PIL.Image image""" raise NotImplementedError def is_dict(self, key): """Return True if variable is a dictionary""" raise NotImplementedError def get_array_shape(self, key): """Return array's shape""" raise NotImplementedError def get_array_ndim(self, key): """Return array's ndim""" raise NotImplementedError def plot(self, key, funcname): """Plot item""" raise NotImplementedError def imshow(self, key): """Show item's image""" raise NotImplementedError def show_image(self, key): """Show image (item is a PIL image)""" raise NotImplementedError # --------------------------------------------------------------------------- def refresh_menu(self): """Refresh context menu""" index = self.currentIndex() condition = index.isValid() self.edit_action.setEnabled(condition) self.remove_action.setEnabled(condition) self.refresh_plot_entries(index) def refresh_plot_entries(self, index): """ :param index: """ if index.isValid(): key = self.model.get_key(index) is_list = self.is_list(key) is_array = self.is_array(key) and self.get_len(key) != 0 condition_plot = is_array and len(self.get_array_shape(key)) <= 2 condition_hist = is_array and self.get_array_ndim(key) == 1 condition_imshow = condition_plot and self.get_array_ndim(key) == 2 condition_imshow = condition_imshow or self.is_image(key) else: is_array = condition_plot = condition_imshow = is_list = condition_hist = ( False ) self.plot_action.setVisible(condition_plot or is_list) self.hist_action.setVisible(condition_hist or is_list) self.imshow_action.setVisible(condition_imshow) self.save_array_action.setVisible(is_array) def adjust_columns(self): """Resize two first columns to contents""" for col in range(3): self.resizeColumnToContents(col) def set_data(self, data): """Set table data""" if data is not None: self.model.set_data(data, self.dictfilter) self.sortByColumn(0, Qt.AscendingOrder) def mousePressEvent(self, event): """Reimplement Qt method""" if event.button() != Qt.LeftButton: QTableView.mousePressEvent(self, event) return index_clicked = self.indexAt(event.pos()) if index_clicked.isValid(): if ( index_clicked == self.currentIndex() and index_clicked in self.selectedIndexes() ): self.clearSelection() else: QTableView.mousePressEvent(self, event) else: self.clearSelection() event.accept() def mouseDoubleClickEvent(self, event): """Reimplement Qt method""" index_clicked = self.indexAt(event.pos()) if index_clicked.isValid(): row = index_clicked.row() # TODO: Remove hard coded "Value" column number (3 here) index_clicked = index_clicked.child(row, 3) self.edit(index_clicked) else: event.accept() def keyPressEvent(self, event): """Reimplement Qt methods""" if event.key() == Qt.Key_Delete: self.remove_item() elif event.key() == Qt.Key_F2: self.rename_item() elif event == QKeySequence.Copy: self.copy() elif event == QKeySequence.Paste: self.paste() else: QTableView.keyPressEvent(self, event) def contextMenuEvent(self, event): """Reimplement Qt method""" if self.model.showndata: self.refresh_menu() self.menu.popup(event.globalPos()) event.accept() else: self.empty_ws_menu.popup(event.globalPos()) event.accept() def dragEnterEvent(self, event): """Allow user to drag files""" if mimedata2url(event.mimeData()): event.accept() else: event.ignore() def dragMoveEvent(self, event): """Allow user to move files""" if mimedata2url(event.mimeData()): event.setDropAction(Qt.CopyAction) event.accept() else: event.ignore() def dropEvent(self, event): """Allow user to drop supported files""" urls = mimedata2url(event.mimeData()) if urls: event.setDropAction(Qt.CopyAction) event.accept() self.sig_files_dropped.emit(urls) else: event.ignore() @Slot(bool) def toggle_minmax(self, state): """Toggle min/max display for numpy arrays""" self.sig_option_changed.emit("minmax", state) self.model.minmax = state @Slot(str) def set_dataframe_format(self, new_format): """ Set format to use in DataframeEditor. Args: new_format (string): e.g. "%.3f" """ self.sig_option_changed.emit("dataframe_format", new_format) self.model.dataframe_format = new_format @Slot() def edit_item(self): """Edit item""" index = self.currentIndex() if not index.isValid(): return # TODO: Remove hard coded "Value" column number (3 here) self.edit(index.child(index.row(), 3)) @Slot() def remove_item(self): """Remove item""" indexes = self.selectedIndexes() if not indexes: return for index in indexes: if not index.isValid(): return one = _("Do you want to remove the selected item?") more = _("Do you want to remove all selected items?") answer = QMessageBox.question( self, _("Remove"), one if len(indexes) == 1 else more, QMessageBox.Yes | QMessageBox.No, ) if answer == QMessageBox.Yes: idx_rows = unsorted_unique([idx.row() for idx in indexes]) keys = [self.model.keys[idx_row] for idx_row in idx_rows] self.remove_values(keys) def copy_item(self, erase_original=False): """Copy item""" indexes = self.selectedIndexes() if not indexes: return idx_rows = unsorted_unique([idx.row() for idx in indexes]) if len(idx_rows) > 1 or not indexes[0].isValid(): return orig_key = self.model.keys[idx_rows[0]] if erase_original: title = _("Rename") field_text = _("New variable name:") else: title = _("Duplicate") field_text = _("Variable name:") data = self.model.get_data() if isinstance(data, (list, set)): new_key, valid = len(data), True else: new_key, valid = QInputDialog.getText( self, title, field_text, QLineEdit.Normal, orig_key ) if valid and str(new_key): new_key = try_to_eval(str(new_key)) if new_key == orig_key: return self.copy_value(orig_key, new_key) if erase_original: self.remove_values([orig_key]) @Slot() def duplicate_item(self): """Duplicate item""" self.copy_item() @Slot() def rename_item(self): """Rename item""" self.copy_item(True) @Slot() def insert_item(self): """Insert item""" index = self.currentIndex() if not index.isValid(): row = self.model.rowCount() else: row = index.row() data = self.model.get_data() if isinstance(data, list): key = row data.insert(row, "") elif isinstance(data, dict): key, valid = QInputDialog.getText( self, _("Insert"), _("Key:"), QLineEdit.Normal ) if valid and str(key): key = try_to_eval(str(key)) else: return else: return value, valid = QInputDialog.getText( self, _("Insert"), _("Value:"), QLineEdit.Normal ) if valid and str(value): self.new_value(key, try_to_eval(str(value))) def __prepare_plot(self): try: import plotpy.pyplot # analysis:ignore return True except Exception: # pylint: disable=broad-except try: if "matplotlib" not in sys.modules: import matplotlib matplotlib.use("Qt5Agg") return True except Exception: # pylint: disable=broad-except QMessageBox.warning( self, _("Import error"), _("Please install PlotPy or matplotlib."), ) def plot_item(self, funcname): """Plot item""" index = self.currentIndex() if self.__prepare_plot(): key = self.model.get_key(index) try: self.plot(key, funcname) except (ValueError, TypeError) as error: QMessageBox.critical( self, _("Plot"), _("Unable to plot data.

Error message:
%s") % str(error), ) @Slot() def imshow_item(self): """Imshow item""" index = self.currentIndex() if self.__prepare_plot(): key = self.model.get_key(index) try: if self.is_image(key): self.show_image(key) else: self.imshow(key) except (ValueError, TypeError) as error: QMessageBox.critical( self, _("Plot"), _("Unable to show image.

Error message:
%s") % str(error), ) @Slot() def save_array(self): """Save array""" title = _("Save array") if self.array_filename is None: self.array_filename = getcwd_or_home() self.redirect_stdio.emit(False) filename, _selfilter = getsavefilename( self, title, self.array_filename, _("NumPy arrays") + " (*.npy)" ) self.redirect_stdio.emit(True) if filename: self.array_filename = filename data = self.delegate.get_value(self.currentIndex()) try: import numpy as np np.save(self.array_filename, data) except Exception as error: QMessageBox.critical( self, title, _("Unable to save array

Error message:
%s") % str(error), ) @Slot() def copy(self): """Copy text to clipboard""" clipboard = QApplication.clipboard() clipl = [] for idx in self.selectedIndexes(): if not idx.isValid(): continue obj = self.delegate.get_value(idx) # Check if we are trying to copy a numpy array, and if so make sure # to copy the whole thing in a tab separated format if isinstance(obj, (ndarray, MaskedArray)) and ndarray is not FakeObject: output = io.BytesIO() try: np_savetxt(output, obj, delimiter="\t") except Exception: # pylint: disable=broad-except QMessageBox.warning( self, _("Warning"), _("It was not possible to copy this array"), ) return obj = output.getvalue().decode("utf-8") output.close() elif isinstance(obj, (DataFrame, Series)) and DataFrame is not FakeObject: output = io.StringIO() try: obj.to_csv(output, sep="\t", index=True, header=True) except Exception: # pylint: disable=broad-except QMessageBox.warning( self, _("Warning"), _("It was not possible to copy this dataframe"), ) return obj = output.getvalue() output.close() elif isinstance(obj, bytes): obj = str(obj, "utf8") else: obj = str(obj) clipl.append(obj) clipboard.setText("\n".join(clipl)) def import_from_string(self, text, title=None): """Import data from string""" data = self.model.get_data() # Check if data is a dict if not hasattr(data, "keys"): return editor = ImportWizard( self, text, title=title, contents_title=_("Clipboard contents"), varname=fix_reference_name("data", blacklist=list(data.keys())), ) if editor.exec(): var_name, clip_data = editor.get_data() self.new_value(var_name, clip_data) @Slot() def paste(self): """Import text/data/code from clipboard""" clipboard = QApplication.clipboard() cliptext = "" if clipboard.mimeData().hasText(): cliptext = str(clipboard.text()) if cliptext.strip(): self.import_from_string(cliptext, title=_("Import from clipboard")) else: QMessageBox.warning( self, _("Empty clipboard"), _("Nothing to be imported from clipboard.") ) class CollectionsEditorTableView(BaseTableView): """CollectionsEditor table view""" def __init__( self, parent, data, readonly=False, title="", names=False, minmax=False ): BaseTableView.__init__(self, parent) self.dictfilter = None self.readonly = readonly or isinstance(data, tuple) CollectionsModelClass = ( ReadOnlyCollectionsModel if self.readonly else CollectionsModel ) self.model = CollectionsModelClass( self, data, title, names=names, minmax=minmax ) self.setModel(self.model) self.delegate = CollectionsDelegate(self) self.setItemDelegate(self.delegate) self.setup_table() self.menu = self.setup_menu(minmax) # ------ Remote/local API --------------------------------------------------- def remove_values(self, keys): """Remove values from data""" data = self.model.get_data() for key in sorted(keys, reverse=True): data.pop(key) self.set_data(data) def copy_value(self, orig_key, new_key): """Copy value""" data = self.model.get_data() if isinstance(data, list): data.append(data[orig_key]) if isinstance(data, set): data.add(data[orig_key]) else: data[new_key] = data[orig_key] self.set_data(data) def new_value(self, key, value): """Create new value in data""" data = self.model.get_data() data[key] = value self.set_data(data) def is_list(self, key): """Return True if variable is a list or a tuple""" data = self.model.get_data() return isinstance(data[key], (tuple, list)) def get_len(self, key): """Return sequence length""" data = self.model.get_data() return len(data[key]) def is_array(self, key): """Return True if variable is a numpy array""" data = self.model.get_data() return isinstance(data[key], (ndarray, MaskedArray)) def is_image(self, key): """Return True if variable is a PIL.Image image""" data = self.model.get_data() return isinstance(data[key], Image) def is_dict(self, key): """Return True if variable is a dictionary""" data = self.model.get_data() return isinstance(data[key], dict) def get_array_shape(self, key): """Return array's shape""" data = self.model.get_data() return data[key].shape def get_array_ndim(self, key): """Return array's ndim""" data = self.model.get_data() return data[key].ndim def plot(self, key, funcname): """Plot item""" data = self.model.get_data() import plotpy.pyplot as plt plt.figure() getattr(plt, funcname)(data[key]) plt.show() def imshow(self, key): """Show item's image""" data = self.model.get_data() import plotpy.pyplot as plt plt.figure() plt.imshow(data[key]) plt.show() def show_image(self, key): """Show image (item is a PIL image)""" data = self.model.get_data() data[key].show() # --------------------------------------------------------------------------- def refresh_menu(self): """Refresh context menu""" data = self.model.get_data() index = self.currentIndex() condition = ( (not isinstance(data, tuple)) and index.isValid() and not self.readonly ) self.edit_action.setEnabled(condition) self.remove_action.setEnabled(condition) self.insert_action.setEnabled(not self.readonly) self.duplicate_action.setEnabled(condition) condition_rename = not isinstance(data, (tuple, list, set)) self.rename_action.setEnabled(condition_rename) self.refresh_plot_entries(index) def set_filter(self, dictfilter=None): """Set table dict filter""" self.dictfilter = dictfilter class CollectionsEditorWidget(QWidget): """Dictionary Editor Widget""" def __init__(self, parent, data, readonly=False, title=""): QWidget.__init__(self, parent) self.editor = CollectionsEditorTableView(self, data, readonly, title) layout = QVBoxLayout() layout.addWidget(self.editor) self.setLayout(layout) def set_data(self, data): """Set DictEditor data""" self.editor.set_data(data) def get_title(self): """Get model title""" return self.editor.model.title class CollectionsEditor(QDialog): """Collections Editor Dialog""" def __init__(self, parent=None): QDialog.__init__(self, parent) win32_fix_title_bar_background(self) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) self.data_copy = None self.widget = None self.btn_save_and_close = None self.btn_close = None def setup(self, data, title="", readonly=False, width=650, icon=None, parent=None): """Setup editor.""" if isinstance(data, dict): # dictionary self.data_copy = data.copy() datalen = len(data) elif isinstance(data, (tuple, list)): # list, tuple self.data_copy = data[:] datalen = len(data) else: # unknown object import copy try: self.data_copy = copy.deepcopy(data) except NotImplementedError: self.data_copy = copy.copy(data) except (TypeError, AttributeError): readonly = True self.data_copy = data datalen = len(get_object_attrs(data)) # If the copy has a different type, then do not allow editing, because # this would change the type after saving; cf. issue #6936 if type(self.data_copy) != type(data): readonly = True self.widget = CollectionsEditorWidget( self, self.data_copy, title=title, readonly=readonly ) self.widget.editor.model.sig_setting_data.connect(self.save_and_close_enable) layout = QVBoxLayout() layout.addWidget(self.widget) self.setLayout(layout) # Buttons configuration btn_layout = QHBoxLayout() btn_layout.addStretch() if not readonly: self.btn_save_and_close = QPushButton(_("Save and Close")) self.btn_save_and_close.setDisabled(True) self.btn_save_and_close.clicked.connect(self.accept) btn_layout.addWidget(self.btn_save_and_close) self.btn_close = QPushButton(_("Close")) self.btn_close.setAutoDefault(True) self.btn_close.setDefault(True) self.btn_close.clicked.connect(self.reject) btn_layout.addWidget(self.btn_close) layout.addLayout(btn_layout) constant = 121 row_height = 30 error_margin = 10 height = constant + row_height * min([10, datalen]) + error_margin self.resize(width, height) self.setWindowTitle(self.widget.get_title()) if icon is None: self.setWindowIcon(get_icon("dictedit.png")) # Make the dialog act as a window self.setWindowFlags(Qt.Window) @Slot() def save_and_close_enable(self): """Handle the data change event to enable the save and close button.""" if self.btn_save_and_close: self.btn_save_and_close.setEnabled(True) self.btn_save_and_close.setAutoDefault(True) self.btn_save_and_close.setDefault(True) def get_value(self): """Return modified copy of dictionary or list""" # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute return self.data_copy ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6638856 guidata-3.13.4/guidata/widgets/console/0000755000175100017510000000000015114075015017413 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/__init__.py0000644000175100017510000000622215114075001021521 0ustar00runnerrunner# -*- coding: utf-8 -*- """ guidata.widgets.console ======================= This package provides a Python console widget. .. autoclass:: Console :show-inheritance: :members: .. autoclass:: DockableConsole :show-inheritance: :members: """ from qtpy.QtCore import Qt from guidata.config import CONF from guidata.configtools import get_font from guidata.qthelpers import win32_fix_title_bar_background from guidata.widgets.console.internalshell import InternalShell from guidata.widgets.dockable import DockableWidgetMixin class Console(InternalShell): """ Python console that run an interactive shell linked to the running process. :param parent: parent Qt widget :param namespace: available python namespace when the console start :type namespace: dict :param message: banner displayed before the first prompt :param commands: commands run when the interpreter starts :param type commands: list of string :param multithreaded: multithreaded support """ def __init__( self, parent=None, namespace=None, message=None, commands=None, multithreaded=True, debug=False, ): InternalShell.__init__( self, parent=parent, namespace=namespace, message=message, commands=commands or [], multithreaded=multithreaded, debug=debug, ) win32_fix_title_bar_background(self) self.setup() def setup(self): """Setup the calltip widget and show the console once all internal handler are ready.""" font = get_font(CONF, "console") font.setPointSize(10) self.set_font(font) self.set_codecompletion_auto(True) self.set_calltips(True) self.setup_completion(size=(300, 180), font=font) try: self.exception_occurred.connect(self.show_console) except AttributeError: pass def closeEvent(self, event): """Reimplement Qt base method""" InternalShell.closeEvent(self, event) self.exit_interpreter() event.accept() class DockableConsole(Console, DockableWidgetMixin): """ Dockable Python console that run an interactive shell linked to the running process. :param parent: parent Qt widget :param namespace: available python namespace when the console start :type namespace: dict :param message: banner displayed before the first prompt :param commands: commands run when the interpreter starts :param type commands: list of string """ LOCATION = Qt.BottomDockWidgetArea def __init__( self, parent, namespace, message, commands=None, multithreaded=True, debug=False ): DockableWidgetMixin.__init__(self) Console.__init__( self, parent=parent, namespace=namespace, message=message, commands=commands or [], multithreaded=multithreaded, debug=debug, ) def show_console(self): """Show the console widget.""" self.dockwidget.raise_() self.dockwidget.show() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/base.py0000644000175100017510000016077715114075001020714 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """QPlainTextEdit base class""" # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 import os import re import sys from collections import OrderedDict from qtpy.QtCore import QEvent, QEventLoop, QPoint, Qt, Signal, Slot from qtpy.QtGui import ( QClipboard, QColor, QMouseEvent, QPalette, QTextCharFormat, QTextCursor, QTextFormat, QTextOption, ) from qtpy.QtWidgets import ( QAbstractItemView, QApplication, QListWidget, QListWidgetItem, QMainWindow, QPlainTextEdit, QTextEdit, QToolTip, ) from guidata import qthelpers as qth from guidata.config import CONF from guidata.configtools import get_font, get_icon from guidata.widgets.console.calltip import CallTipWidget from guidata.widgets.console.mixins import BaseEditMixin from guidata.widgets.console.terminal import ANSIEscapeCodeHandler def insert_text_to(cursor, text, fmt): """Helper to print text, taking into account backspaces""" while True: index = text.find(chr(8)) # backspace if index == -1: break cursor.insertText(text[:index], fmt) if cursor.positionInBlock() > 0: cursor.deletePreviousChar() text = text[index + 1 :] cursor.insertText(text, fmt) class CompletionWidget(QListWidget): """Completion list widget""" sig_show_completions = Signal(object) def __init__(self, parent, ancestor): QListWidget.__init__(self, ancestor) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setWindowFlags(Qt.SubWindow | Qt.FramelessWindowHint) self.textedit = parent self.completion_list = None self.case_sensitive = False self.enter_select = None self.hide() self.itemActivated.connect(self.item_selected) def setup_appearance(self, size, font): """ :param size: :param font: """ self.resize(*size) self.setFont(font) def show_list(self, completion_list, automatic=True): """ :param completion_list: :param automatic: :return: """ types = [c[1] for c in completion_list] completion_list = [c[0] for c in completion_list] if len(completion_list) == 1 and not automatic: self.textedit.insert_completion(completion_list[0]) return self.completion_list = completion_list self.clear() icons_map = { "instance": "attribute", "statement": "attribute", "method": "method", "function": "function", "class": "class", "module": "module", } self.type_list = types if any(types): for c, t in zip(completion_list, types): icon = icons_map.get(t, "no_match") self.addItem(QListWidgetItem(get_icon(icon + ".png"), c)) else: self.addItems(completion_list) self.setCurrentRow(0) QApplication.processEvents(QEventLoop.ExcludeUserInputEvents) self.show() self.setFocus() self.raise_() # Retrieving current screen height srect = self.textedit.screen().availableGeometry() screen_right = srect.right() screen_bottom = srect.bottom() point = self.textedit.cursorRect().bottomRight() point.setX(point.x() + self.textedit.get_linenumberarea_width()) point = self.textedit.mapToGlobal(point) # Computing completion widget and its parent right positions comp_right = point.x() + self.width() ancestor = self.parentWidget() if ancestor is None: anc_right = screen_right else: anc_right = min([ancestor.x() + ancestor.width(), screen_right]) # Moving completion widget to the left # if there is not enough space to the right if comp_right > anc_right: point.setX(point.x() - self.width()) # Computing completion widget and its parent bottom positions comp_bottom = point.y() + self.height() ancestor = self.parentWidget() if ancestor is None: anc_bottom = screen_bottom else: anc_bottom = min([ancestor.y() + ancestor.height(), screen_bottom]) # Moving completion widget above if there is not enough space below x_position = point.x() if comp_bottom > anc_bottom: point = self.textedit.cursorRect().topRight() point = self.textedit.mapToGlobal(point) point.setX(x_position) point.setY(point.y() - self.height()) if ancestor is not None: # Useful only if we set parent to 'ancestor' in __init__ point = ancestor.mapFromGlobal(point) self.move(point) if str(self.textedit.completion_text): # When initialized, if completion text is not empty, we need # to update the displayed list: self.update_current() # signal used for testing self.sig_show_completions.emit(completion_list) def hide(self): """ """ QListWidget.hide(self) self.textedit.setFocus() def keyPressEvent(self, event): """ :param event: """ text, key = event.text(), event.key() alt = event.modifiers() & Qt.AltModifier shift = event.modifiers() & Qt.ShiftModifier ctrl = event.modifiers() & Qt.ControlModifier modifier = shift or ctrl or alt if ( key in (Qt.Key_Return, Qt.Key_Enter) and self.enter_select ) or key == Qt.Key_Tab: self.item_selected() elif key in ( Qt.Key_Return, Qt.Key_Enter, Qt.Key_Left, Qt.Key_Right, ) or text in (".", ":"): self.hide() self.textedit.keyPressEvent(event) elif ( key in ( Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown, Qt.Key_Home, Qt.Key_End, Qt.Key_CapsLock, ) and not modifier ): QListWidget.keyPressEvent(self, event) elif len(text) or key == Qt.Key_Backspace: self.textedit.keyPressEvent(event) self.update_current() elif modifier: self.textedit.keyPressEvent(event) else: self.hide() QListWidget.keyPressEvent(self, event) def update_current(self): """ """ completion_text = str(self.textedit.completion_text) if completion_text: for row, completion in enumerate(self.completion_list): if not self.case_sensitive: print(completion_text) # spyder: test-skip completion = completion.lower() completion_text = completion_text.lower() if completion.startswith(completion_text): self.setCurrentRow(row) self.scrollTo(self.currentIndex(), QAbstractItemView.PositionAtTop) break else: self.hide() else: self.hide() def focusOutEvent(self, event): """ :param event: """ event.ignore() # Don't hide it on Mac when main window loses focus because # keyboard input is lost # Fixes Issue 1318 if sys.platform == "darwin": if event.reason() != Qt.ActiveWindowFocusReason: self.hide() else: self.hide() def item_selected(self, item=None): """ :param item: """ if item is None: item = self.currentItem() self.textedit.insert_completion(str(item.text())) self.hide() class TextEditBaseWidget(QPlainTextEdit, BaseEditMixin): """Text edit base widget""" BRACE_MATCHING_SCOPE = ("sof", "eof") cell_separators = None focus_in = Signal() zoom_in = Signal() zoom_out = Signal() zoom_reset = Signal() focus_changed = Signal() sig_eol_chars_changed = Signal(str) def __init__(self, parent=None): QPlainTextEdit.__init__(self, parent) BaseEditMixin.__init__(self) self.setAttribute(Qt.WA_DeleteOnClose) self.extra_selections_dict = OrderedDict() self.textChanged.connect(self.changed) self.cursorPositionChanged.connect(self.cursor_position_changed) self.indent_chars = " " * 4 self.tab_stop_width_spaces = 4 # Code completion / calltips if parent is not None: mainwin = parent while not isinstance(mainwin, QMainWindow): mainwin = mainwin.parent() if mainwin is None: break if mainwin is not None: parent = mainwin self.completion_widget = CompletionWidget(self, parent) self.codecompletion_auto = False self.codecompletion_case = True self.codecompletion_enter = False self.completion_text = "" self.setup_completion() self.calltip_widget = CallTipWidget(self, hide_timer_on=False) self.calltips = True self.calltip_position = None self.has_cell_separators = False self.highlight_current_cell_enabled = False # The color values may be overridden by the syntax highlighter # Highlight current line color self.currentline_color = QColor(Qt.red).lighter(190) self.currentcell_color = QColor(Qt.red).lighter(194) # Brace matching self.bracepos = None self.matched_p_color = QColor(Qt.green) self.unmatched_p_color = QColor(Qt.red) self.last_cursor_cell = None def setup_completion(self, size=None, font=None): """ :param size: :param font: """ size = size or CONF.get("console", "codecompletion/size") font = font or get_font(CONF, "texteditor", "font") self.completion_widget.setup_appearance(size, font) def set_indent_chars(self, indent_chars): """ :param indent_chars: """ self.indent_chars = indent_chars def set_tab_stop_width_spaces(self, tab_stop_width_spaces): """ :param tab_stop_width_spaces: """ self.tab_stop_width_spaces = tab_stop_width_spaces self.update_tab_stop_width_spaces() def update_tab_stop_width_spaces(self): """ """ self.setTabStopWidth(self.fontMetrics().width(" " * self.tab_stop_width_spaces)) def set_palette(self, background, foreground): """ Set text editor palette colors: background color and caret (text cursor) color """ palette = QPalette() # palette.setColor(QPalette.Base, background) palette.setColor(QPalette.Text, foreground) palette.setColor(QPalette.Window, background) self.setPalette(palette) # Set the right background color when changing color schemes # or creating new Editor windows. This seems to be a Qt bug. # Fixes Issue 2028 if sys.platform == "darwin": if self.objectName(): style = "QPlainTextEdit#%s {background: %s; color: %s;}" % ( self.objectName(), background.name(), foreground.name(), ) self.setStyleSheet(style) # ------Extra selections def extra_selection_length(self, key): """ :param key: :return: """ selection = self.get_extra_selections(key) if selection: cursor = self.extra_selections_dict[key][0].cursor selection_length = cursor.selectionEnd() - cursor.selectionStart() return selection_length else: return 0 def get_extra_selections(self, key): """ :param key: :return: """ return self.extra_selections_dict.get(key, []) def set_extra_selections(self, key, extra_selections): """ :param key: :param extra_selections: """ self.extra_selections_dict[key] = extra_selections self.extra_selections_dict = OrderedDict( sorted( self.extra_selections_dict.items(), key=lambda s: self.extra_selection_length(s[0]), reverse=True, ) ) def update_extra_selections(self): """ """ extra_selections = [] # Python 3 compatibility (weird): current line has to be # highlighted first if "current_cell" in self.extra_selections_dict: extra_selections.extend(self.extra_selections_dict["current_cell"]) if "current_line" in self.extra_selections_dict: extra_selections.extend(self.extra_selections_dict["current_line"]) for key, extra in list(self.extra_selections_dict.items()): if not (key == "current_line" or key == "current_cell"): extra_selections.extend(extra) self.setExtraSelections(extra_selections) def clear_extra_selections(self, key): """ :param key: """ self.extra_selections_dict[key] = [] self.update_extra_selections() def changed(self): """Emit changed signal""" self.modificationChanged.emit(self.document().isModified()) # ------Highlight current line def highlight_current_line(self): """Highlight current line""" selection = QTextEdit.ExtraSelection() selection.format.setProperty(QTextFormat.FullWidthSelection, True) selection.format.setBackground(self.currentline_color) selection.cursor = self.textCursor() selection.cursor.clearSelection() self.set_extra_selections("current_line", [selection]) self.update_extra_selections() def unhighlight_current_line(self): """Unhighlight current line""" self.clear_extra_selections("current_line") # ------Highlight current cell def highlight_current_cell(self): """Highlight current cell""" if self.cell_separators is None or not self.highlight_current_cell_enabled: return selection = QTextEdit.ExtraSelection() selection.format.setProperty(QTextFormat.FullWidthSelection, True) selection.format.setBackground(self.currentcell_color) ( selection.cursor, whole_file_selected, whole_screen_selected, ) = self.select_current_cell_in_visible_portion() if whole_file_selected: self.clear_extra_selections("current_cell") elif whole_screen_selected: if self.has_cell_separators: self.set_extra_selections("current_cell", [selection]) self.update_extra_selections() else: self.clear_extra_selections("current_cell") else: self.set_extra_selections("current_cell", [selection]) self.update_extra_selections() def unhighlight_current_cell(self): """Unhighlight current cell""" self.clear_extra_selections("current_cell") # ------Brace matching def find_brace_match(self, position, brace, forward): """ :param position: :param brace: :param forward: :return: """ start_pos, end_pos = self.BRACE_MATCHING_SCOPE if forward: bracemap = {"(": ")", "[": "]", "{": "}"} text = self.get_text(position, end_pos) i_start_open = 1 i_start_close = 1 else: bracemap = {")": "(", "]": "[", "}": "{"} text = self.get_text(start_pos, position) i_start_open = len(text) - 1 i_start_close = len(text) - 1 while True: if forward: i_close = text.find(bracemap[brace], i_start_close) else: i_close = text.rfind(bracemap[brace], 0, i_start_close + 1) if i_close > -1: if forward: i_start_close = i_close + 1 i_open = text.find(brace, i_start_open, i_close) else: i_start_close = i_close - 1 i_open = text.rfind(brace, i_close, i_start_open + 1) if i_open > -1: if forward: i_start_open = i_open + 1 else: i_start_open = i_open - 1 else: # found matching brace if forward: return position + i_close else: return position - (len(text) - i_close) else: # no matching brace return def __highlight(self, positions, color=None, cancel=False): if cancel: self.clear_extra_selections("brace_matching") return extra_selections = [] for position in positions: if position > self.get_position("eof"): return selection = QTextEdit.ExtraSelection() selection.format.setBackground(color) selection.cursor = self.textCursor() selection.cursor.clearSelection() selection.cursor.setPosition(position) selection.cursor.movePosition( QTextCursor.NextCharacter, QTextCursor.KeepAnchor ) extra_selections.append(selection) self.set_extra_selections("brace_matching", extra_selections) self.update_extra_selections() def cursor_position_changed(self): """Brace matching""" if self.bracepos is not None: self.__highlight(self.bracepos, cancel=True) self.bracepos = None cursor = self.textCursor() if cursor.position() == 0: return cursor.movePosition(QTextCursor.PreviousCharacter, QTextCursor.KeepAnchor) text = str(cursor.selectedText()) pos1 = cursor.position() if text in (")", "]", "}"): pos2 = self.find_brace_match(pos1, text, forward=False) elif text in ("(", "[", "{"): pos2 = self.find_brace_match(pos1, text, forward=True) else: return if pos2 is not None: self.bracepos = (pos1, pos2) self.__highlight(self.bracepos, color=self.matched_p_color) else: self.bracepos = (pos1,) self.__highlight(self.bracepos, color=self.unmatched_p_color) # -----Widget setup and options def set_codecompletion_auto(self, state): """Set code completion state""" self.codecompletion_auto = state def set_codecompletion_case(self, state): """Case sensitive completion""" self.codecompletion_case = state self.completion_widget.case_sensitive = state def set_codecompletion_enter(self, state): """Enable Enter key to select completion""" self.codecompletion_enter = state self.completion_widget.enter_select = state def set_calltips(self, state): """Set calltips state""" self.calltips = state def set_wrap_mode(self, mode=None): """ Set wrap mode Valid *mode* values: None, 'word', 'character' """ if mode == "word": wrap_mode = QTextOption.WrapAtWordBoundaryOrAnywhere elif mode == "character": wrap_mode = QTextOption.WrapAnywhere else: wrap_mode = QTextOption.NoWrap self.setWordWrapMode(wrap_mode) # ------Reimplementing Qt methods @Slot() def copy(self): """ Reimplement Qt method Copy text to clipboard with correct EOL chars """ if self.get_selected_text(): QApplication.clipboard().setText(self.get_selected_text()) def toPlainText(self): """ Reimplement Qt method Fix PyQt4 bug on Windows and Python 3 """ # Fix what appears to be a PyQt4 bug when getting file # contents under Windows and PY3. This bug leads to # corruptions when saving files with certain combinations # of unicode chars on them (like the one attached on # Issue 1546) if os.name == "nt": text = self.get_text("sof", "eof") return ( text.replace("\u2028", "\n") .replace("\u2029", "\n") .replace("\u0085", "\n") ) else: return super().toPlainText() def keyPressEvent(self, event): """ :param event: """ _text, key = event.text(), event.key() ctrl = event.modifiers() & Qt.ControlModifier meta = event.modifiers() & Qt.MetaModifier # Use our own copy method for {Ctrl,Cmd}+C to avoid Qt # copying text in HTML (See Issue 2285) if (ctrl or meta) and key == Qt.Key_C: self.copy() else: super().keyPressEvent(event) # ------Text: get, set, ... def get_selection_as_executable_code(self): """Return selected text as a processed text, to be executable in a Python/IPython interpreter""" ls = self.get_line_separator() _indent = lambda line: len(line) - len(line.lstrip()) line_from, line_to = self.get_selection_bounds() text = self.get_selected_text() if not text: return lines = text.split(ls) if len(lines) > 1: # Multiline selection -> eventually fixing indentation original_indent = _indent(self.get_text_line(line_from)) text = (" " * (original_indent - _indent(lines[0]))) + text # If there is a common indent to all lines, find it. # Moving from bottom line to top line ensures that blank # lines inherit the indent of the line *below* it, # which is the desired behavior. min_indent = 999 current_indent = 0 lines = text.split(ls) for i in range(len(lines) - 1, -1, -1): line = lines[i] if line.strip(): current_indent = _indent(line) min_indent = min(current_indent, min_indent) else: lines[i] = " " * current_indent if min_indent: lines = [line[min_indent:] for line in lines] # Remove any leading whitespace or comment lines # since they confuse the reserved word detector that follows below while lines: first_line = lines[0].lstrip() if first_line == "" or first_line[0] == "#": lines.pop(0) else: break # Add an EOL character after indentation blocks that start with some # Python reserved words, so that it gets evaluated automatically # by the console varname = re.compile(r"[a-zA-Z0-9_]*") # Matches valid variable names. maybe = False nextexcept = () for n, line in enumerate(lines): if not _indent(line): word = varname.match(line).group() if maybe and word not in nextexcept: lines[n - 1] += ls maybe = False if word: if word in ("def", "for", "while", "with", "class"): maybe = True nextexcept = () elif word == "if": maybe = True nextexcept = ("elif", "else") elif word == "try": maybe = True nextexcept = ("except", "finally") if maybe: if lines[-1].strip() == "": lines[-1] += ls else: lines.append(ls) return ls.join(lines) def __exec_cell(self): init_cursor = QTextCursor(self.textCursor()) start_pos, end_pos = self.__save_selection() cursor, whole_file_selected = self.select_current_cell() if not whole_file_selected: self.setTextCursor(cursor) text = self.get_selection_as_executable_code() self.last_cursor_cell = init_cursor self.__restore_selection(start_pos, end_pos) if text is not None: text = text.rstrip() return text def get_cell_as_executable_code(self): """Return cell contents as executable code""" return self.__exec_cell() def get_last_cell_as_executable_code(self): """ :return: """ text = None if self.last_cursor_cell: self.setTextCursor(self.last_cursor_cell) self.highlight_current_cell() text = self.__exec_cell() return text def is_cell_separator(self, cursor=None, block=None): """Return True if cursor (or text block) is on a block separator""" assert cursor is not None or block is not None if cursor is not None: cursor0 = QTextCursor(cursor) cursor0.select(QTextCursor.BlockUnderCursor) text = str(cursor0.selectedText()) else: text = str(block.text()) if self.cell_separators is None: return False else: return text.lstrip().startswith(self.cell_separators) def select_current_cell(self): """Select cell under cursor cell = group of lines separated by CELL_SEPARATORS returns the textCursor and a boolean indicating if the entire file is selected""" cursor = self.textCursor() cursor.movePosition(QTextCursor.StartOfBlock) cur_pos = prev_pos = cursor.position() # Moving to the next line that is not a separator, if we are # exactly at one of them while self.is_cell_separator(cursor): cursor.movePosition(QTextCursor.NextBlock) prev_pos = cur_pos cur_pos = cursor.position() if cur_pos == prev_pos: return cursor, False prev_pos = cur_pos # If not, move backwards to find the previous separator while not self.is_cell_separator(cursor): cursor.movePosition(QTextCursor.PreviousBlock) prev_pos = cur_pos cur_pos = cursor.position() if cur_pos == prev_pos: if self.is_cell_separator(cursor): return cursor, False else: break cursor.setPosition(prev_pos) cell_at_file_start = cursor.atStart() # Once we find it (or reach the beginning of the file) # move to the next separator (or the end of the file) # so we can grab the cell contents while not self.is_cell_separator(cursor): cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) cur_pos = cursor.position() if cur_pos == prev_pos: cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) break prev_pos = cur_pos cell_at_file_end = cursor.atEnd() return cursor, cell_at_file_start and cell_at_file_end def select_current_cell_in_visible_portion(self): """Select cell under cursor in the visible portion of the file cell = group of lines separated by CELL_SEPARATORS Returns: - the textCursor - a boolean indicating if the entire file is selected - a boolean indicating if the entire visible portion of the file is selected""" cursor = self.textCursor() cursor.movePosition(QTextCursor.StartOfBlock) cur_pos = prev_pos = cursor.position() beg_pos = self.cursorForPosition(QPoint(0, 0)).position() bottom_right = QPoint(self.viewport().width() - 1, self.viewport().height() - 1) end_pos = self.cursorForPosition(bottom_right).position() # Moving to the next line that is not a separator, if we are # exactly at one of them while self.is_cell_separator(cursor): cursor.movePosition(QTextCursor.NextBlock) prev_pos = cur_pos cur_pos = cursor.position() if cur_pos == prev_pos: return cursor, False, False prev_pos = cur_pos # If not, move backwards to find the previous separator while not self.is_cell_separator(cursor) and cursor.position() >= beg_pos: cursor.movePosition(QTextCursor.PreviousBlock) prev_pos = cur_pos cur_pos = cursor.position() if cur_pos == prev_pos: if self.is_cell_separator(cursor): return cursor, False, False else: break cell_at_screen_start = cursor.position() <= beg_pos cursor.setPosition(prev_pos) cell_at_file_start = cursor.atStart() # Selecting cell header if not cell_at_file_start: cursor.movePosition(QTextCursor.PreviousBlock) cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) # Once we find it (or reach the beginning of the file) # move to the next separator (or the end of the file) # so we can grab the cell contents while not self.is_cell_separator(cursor) and cursor.position() <= end_pos: cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) cur_pos = cursor.position() if cur_pos == prev_pos: cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) break prev_pos = cur_pos cell_at_file_end = cursor.atEnd() cell_at_screen_end = cursor.position() >= end_pos return ( cursor, cell_at_file_start and cell_at_file_end, cell_at_screen_start and cell_at_screen_end, ) def go_to_next_cell(self): """Go to the next cell of lines""" cursor = self.textCursor() cursor.movePosition(QTextCursor.NextBlock) cur_pos = prev_pos = cursor.position() while not self.is_cell_separator(cursor): # Moving to the next code cell cursor.movePosition(QTextCursor.NextBlock) prev_pos = cur_pos cur_pos = cursor.position() if cur_pos == prev_pos: return self.setTextCursor(cursor) def go_to_previous_cell(self): """Go to the previous cell of lines""" cursor = self.textCursor() cur_pos = prev_pos = cursor.position() if self.is_cell_separator(cursor): # Move to the previous cell cursor.movePosition(QTextCursor.PreviousBlock) cur_pos = prev_pos = cursor.position() while not self.is_cell_separator(cursor): # Move to the previous cell or the beginning of the current cell cursor.movePosition(QTextCursor.PreviousBlock) prev_pos = cur_pos cur_pos = cursor.position() if cur_pos == prev_pos: return self.setTextCursor(cursor) def get_line_count(self): """Return document total line number""" return self.blockCount() def __save_selection(self): """Save current cursor selection and return position bounds""" cursor = self.textCursor() return cursor.selectionStart(), cursor.selectionEnd() def __restore_selection(self, start_pos, end_pos): """Restore cursor selection from position bounds""" cursor = self.textCursor() cursor.setPosition(start_pos) cursor.setPosition(end_pos, QTextCursor.KeepAnchor) self.setTextCursor(cursor) def __duplicate_line_or_selection(self, after_current_line=True): """Duplicate current line or selected text""" cursor = self.textCursor() cursor.beginEditBlock() start_pos, end_pos = self.__save_selection() if str(cursor.selectedText()): cursor.setPosition(end_pos) # Check if end_pos is at the start of a block: if so, starting # changes from the previous block cursor.movePosition(QTextCursor.StartOfBlock, QTextCursor.KeepAnchor) if not str(cursor.selectedText()): cursor.movePosition(QTextCursor.PreviousBlock) end_pos = cursor.position() cursor.setPosition(start_pos) cursor.movePosition(QTextCursor.StartOfBlock) while cursor.position() <= end_pos: cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) if cursor.atEnd(): cursor_temp = QTextCursor(cursor) cursor_temp.clearSelection() cursor_temp.insertText(self.get_line_separator()) break cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) text = cursor.selectedText() cursor.clearSelection() if not after_current_line: # Moving cursor before current line/selected text cursor.setPosition(start_pos) cursor.movePosition(QTextCursor.StartOfBlock) start_pos += len(text) end_pos += len(text) cursor.insertText(text) cursor.endEditBlock() self.setTextCursor(cursor) self.__restore_selection(start_pos, end_pos) def duplicate_line(self): """ Duplicate current line or selected text Paste the duplicated text *after* the current line/selected text """ self.__duplicate_line_or_selection(after_current_line=True) def copy_line(self): """ Copy current line or selected text Paste the duplicated text *before* the current line/selected text """ self.__duplicate_line_or_selection(after_current_line=False) def __move_line_or_selection(self, after_current_line=True): """Move current line or selected text""" cursor = self.textCursor() cursor.beginEditBlock() start_pos, end_pos = self.__save_selection() last_line = False # ------ Select text # Get selection start location cursor.setPosition(start_pos) cursor.movePosition(QTextCursor.StartOfBlock) start_pos = cursor.position() # Get selection end location cursor.setPosition(end_pos) if not cursor.atBlockStart() or end_pos == start_pos: cursor.movePosition(QTextCursor.EndOfBlock) cursor.movePosition(QTextCursor.NextBlock) end_pos = cursor.position() # Check if selection ends on the last line of the document if cursor.atEnd(): if not cursor.atBlockStart() or end_pos == start_pos: last_line = True # ------ Stop if at document boundary cursor.setPosition(start_pos) if cursor.atStart() and not after_current_line: # Stop if selection is already at top of the file while moving up cursor.endEditBlock() self.setTextCursor(cursor) self.__restore_selection(start_pos, end_pos) return cursor.setPosition(end_pos, QTextCursor.KeepAnchor) if last_line and after_current_line: # Stop if selection is already at end of the file while moving down cursor.endEditBlock() self.setTextCursor(cursor) self.__restore_selection(start_pos, end_pos) return # ------ Move text sel_text = str(cursor.selectedText()) cursor.removeSelectedText() if after_current_line: # Shift selection down text = str(cursor.block().text()) sel_text = os.linesep + sel_text[0:-1] # Move linesep at the start cursor.movePosition(QTextCursor.EndOfBlock) start_pos += len(text) + 1 end_pos += len(text) if not cursor.atEnd(): end_pos += 1 else: # Shift selection up if last_line: # Remove the last linesep and add it to the selected text cursor.deletePreviousChar() sel_text = sel_text + os.linesep cursor.movePosition(QTextCursor.StartOfBlock) end_pos += 1 else: cursor.movePosition(QTextCursor.PreviousBlock) text = str(cursor.block().text()) start_pos -= len(text) + 1 end_pos -= len(text) + 1 cursor.insertText(sel_text) cursor.endEditBlock() self.setTextCursor(cursor) self.__restore_selection(start_pos, end_pos) def move_line_up(self): """Move up current line or selected text""" self.__move_line_or_selection(after_current_line=False) def move_line_down(self): """Move down current line or selected text""" self.__move_line_or_selection(after_current_line=True) def go_to_new_line(self): """Go to the end of the current line and create a new line""" self.stdkey_end(False, False) self.insert_text(self.get_line_separator()) def extend_selection_to_complete_lines(self): """Extend current selection to complete lines""" cursor = self.textCursor() start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() cursor.setPosition(start_pos) cursor.setPosition(end_pos, QTextCursor.KeepAnchor) if cursor.atBlockStart(): cursor.movePosition(QTextCursor.PreviousBlock, QTextCursor.KeepAnchor) cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) self.setTextCursor(cursor) def delete_line(self): """Delete current line""" cursor = self.textCursor() if self.has_selected_text(): self.extend_selection_to_complete_lines() start_pos, end_pos = cursor.selectionStart(), cursor.selectionEnd() cursor.setPosition(start_pos) else: start_pos = end_pos = cursor.position() cursor.beginEditBlock() cursor.setPosition(start_pos) cursor.movePosition(QTextCursor.StartOfBlock) while cursor.position() <= end_pos: cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) if cursor.atEnd(): break cursor.movePosition(QTextCursor.NextBlock, QTextCursor.KeepAnchor) cursor.removeSelectedText() cursor.endEditBlock() self.ensureCursorVisible() def set_selection(self, start, end): """ :param start: :param end: """ cursor = self.textCursor() cursor.setPosition(start) cursor.setPosition(end, QTextCursor.KeepAnchor) self.setTextCursor(cursor) def truncate_selection(self, position_from): """Unselect read-only parts in shell, like prompt""" position_from = self.get_position(position_from) cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() if start < end: start = max([position_from, start]) else: end = max([position_from, end]) self.set_selection(start, end) def restrict_cursor_position(self, position_from, position_to): """In shell, avoid editing text except between prompt and EOF""" position_from = self.get_position(position_from) position_to = self.get_position(position_to) cursor = self.textCursor() cursor_position = cursor.position() if cursor_position < position_from or cursor_position > position_to: self.set_cursor_position(position_to) # ------Code completion / Calltips def hide_tooltip_if_necessary(self, key): """Hide calltip when necessary""" try: calltip_char = self.get_character(self.calltip_position) before = self.is_cursor_before(self.calltip_position, char_offset=1) other = key in (Qt.Key_ParenRight, Qt.Key_Period, Qt.Key_Tab) if calltip_char not in ("?", "(") or before or other: QToolTip.hideText() except (IndexError, TypeError): QToolTip.hideText() def show_completion_widget(self, textlist, automatic=True): """Show completion widget""" self.completion_widget.show_list(textlist, automatic=automatic) def hide_completion_widget(self): """Hide completion widget""" self.completion_widget.hide() def show_completion_list(self, completions, completion_text="", automatic=True): """Display the possible completions""" if not completions: return if not isinstance(completions[0], tuple): completions = [(c, "") for c in completions] if len(completions) == 1 and completions[0][0] == completion_text: return self.completion_text = completion_text # Sorting completion list (entries starting with underscore are # put at the end of the list): underscore = set( [(comp, t) for (comp, t) in completions if comp.startswith("_")] ) completions = sorted(set(completions) - underscore, key=lambda x: x[0].lower()) completions += sorted(underscore, key=lambda x: x[0].lower()) self.show_completion_widget(completions, automatic=automatic) def select_completion_list(self): """Completion list is active, Enter was just pressed""" self.completion_widget.item_selected() def insert_completion(self, text): """ :param text: """ if text: cursor = self.textCursor() cursor.movePosition( QTextCursor.PreviousCharacter, QTextCursor.KeepAnchor, len(self.completion_text), ) cursor.removeSelectedText() self.insert_text(text) def is_completion_widget_visible(self): """Return True is completion list widget is visible""" return self.completion_widget.isVisible() # ------Standard keys def stdkey_clear(self): """ """ if not self.has_selected_text(): self.moveCursor(QTextCursor.NextCharacter, QTextCursor.KeepAnchor) self.remove_selected_text() def stdkey_backspace(self): """ """ if not self.has_selected_text(): self.moveCursor(QTextCursor.PreviousCharacter, QTextCursor.KeepAnchor) self.remove_selected_text() def __get_move_mode(self, shift): return QTextCursor.KeepAnchor if shift else QTextCursor.MoveAnchor def stdkey_up(self, shift): """ :param shift: """ self.moveCursor(QTextCursor.Up, self.__get_move_mode(shift)) def stdkey_down(self, shift): """ :param shift: """ self.moveCursor(QTextCursor.Down, self.__get_move_mode(shift)) def stdkey_tab(self): """ """ self.insert_text(self.indent_chars) def stdkey_home(self, shift, ctrl, prompt_pos=None): """Smart HOME feature: cursor is first moved at indentation position, then at the start of the line""" move_mode = self.__get_move_mode(shift) if ctrl: self.moveCursor(QTextCursor.Start, move_mode) else: cursor = self.textCursor() if prompt_pos is None: start_position = self.get_position("sol") else: start_position = self.get_position(prompt_pos) text = self.get_text(start_position, "eol") indent_pos = start_position + len(text) - len(text.lstrip()) if cursor.position() != indent_pos: cursor.setPosition(indent_pos, move_mode) else: cursor.setPosition(start_position, move_mode) self.setTextCursor(cursor) def stdkey_end(self, shift, ctrl): """ :param shift: :param ctrl: """ move_mode = self.__get_move_mode(shift) if ctrl: self.moveCursor(QTextCursor.End, move_mode) else: self.moveCursor(QTextCursor.EndOfBlock, move_mode) def stdkey_pageup(self): """ """ pass def stdkey_pagedown(self): """ """ pass def stdkey_escape(self): """ """ pass # ----Qt Events def mousePressEvent(self, event): """Reimplement Qt method""" if sys.platform.startswith("linux") and event.button() == Qt.MidButton: self.calltip_widget.hide() self.setFocus() event = QMouseEvent( QEvent.MouseButtonPress, event.pos(), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier, ) QPlainTextEdit.mousePressEvent(self, event) QPlainTextEdit.mouseReleaseEvent(self, event) # Send selection text to clipboard to be able to use # the paste method and avoid the strange Issue 1445 # NOTE: This issue seems a focusing problem but it # seems really hard to track mode_clip = QClipboard.Clipboard mode_sel = QClipboard.Selection text_clip = QApplication.clipboard().text(mode=mode_clip) text_sel = QApplication.clipboard().text(mode=mode_sel) QApplication.clipboard().setText(text_sel, mode=mode_clip) self.paste() QApplication.clipboard().setText(text_clip, mode=mode_clip) else: self.calltip_widget.hide() QPlainTextEdit.mousePressEvent(self, event) def focusInEvent(self, event): """Reimplemented to handle focus""" self.focus_changed.emit() self.focus_in.emit() self.highlight_current_cell() QPlainTextEdit.focusInEvent(self, event) def focusOutEvent(self, event): """Reimplemented to handle focus""" self.focus_changed.emit() QPlainTextEdit.focusOutEvent(self, event) def wheelEvent(self, event): """Reimplemented to emit zoom in/out signals when Ctrl is pressed""" # This feature is disabled on MacOS, see Issue 1510 if sys.platform != "darwin": if event.modifiers() & Qt.ControlModifier: if hasattr(event, "angleDelta"): if event.angleDelta().y() < 0: self.zoom_out.emit() elif event.angleDelta().y() > 0: self.zoom_in.emit() elif hasattr(event, "delta"): if event.delta() < 0: self.zoom_out.emit() elif event.delta() > 0: self.zoom_in.emit() return QPlainTextEdit.wheelEvent(self, event) self.highlight_current_cell() class QtANSIEscapeCodeHandler(ANSIEscapeCodeHandler): """ """ def __init__(self): ANSIEscapeCodeHandler.__init__(self) self.base_format = None self.current_format = None def set_light_background(self, state): """ :param state: """ if state: self.default_foreground_color = 30 self.default_background_color = 47 else: self.default_foreground_color = 37 self.default_background_color = 40 def set_base_format(self, base_format): """ :param base_format: """ self.base_format = base_format def get_format(self): """ :return: """ return self.current_format def set_style(self): """ Set font style with the following attributes: 'foreground_color', 'background_color', 'italic', 'bold' and 'underline' """ if self.current_format is None: assert self.base_format is not None self.current_format = QTextCharFormat(self.base_format) # Foreground color if self.foreground_color is None: qcolor = self.base_format.foreground() else: cstr = self.ANSI_COLORS[self.foreground_color - 30][self.intensity] qcolor = QColor(cstr) self.current_format.setForeground(qcolor) # Background color if self.background_color is None: qcolor = self.base_format.background() else: cstr = self.ANSI_COLORS[self.background_color - 40][self.intensity] qcolor = QColor(cstr) self.current_format.setBackground(qcolor) font = self.current_format.font() # Italic if self.italic is None: italic = self.base_format.fontItalic() else: italic = self.italic font.setItalic(italic) # Bold if self.bold is None: bold = self.base_format.font().bold() else: bold = self.bold font.setBold(bold) # Underline if self.underline is None: underline = self.base_format.font().underline() else: underline = self.underline font.setUnderline(underline) self.current_format.setFont(font) def inverse_color(color): """Inverse color""" inv_color = QColor() inv_color.setHsv(color.hue(), color.saturation(), 255 - color.value()) return inv_color class ConsoleFontStyle(object): """Console font style management""" def __init__(self, foregroundcolor, backgroundcolor, bold, italic, underline): self.foregroundcolor = foregroundcolor self.backgroundcolor = backgroundcolor self.bold = bold self.italic = italic self.underline = underline self.format = None def apply_style(self, font, is_default): """Apply style to font Args: font: QFont is_default: Default is standard text (not error, link, etc.) """ self.format = QTextCharFormat() self.format.setFont(font) fg_color = QColor(self.foregroundcolor) if is_default: self.format.setForeground( inverse_color(fg_color) if qth.is_dark_theme() else fg_color ) else: self.format.setForeground(fg_color) self.format.setBackground(qth.get_background_color()) font = self.format.font() font.setBold(self.bold) font.setItalic(self.italic) font.setUnderline(self.underline) self.format.setFont(font) class ConsoleBaseWidget(TextEditBaseWidget): """Console base widget""" BRACE_MATCHING_SCOPE = ("sol", "eol") COLOR_PATTERN = re.compile(r"\x01?\x1b\[(.*?)m\x02?") exception_occurred = Signal(str, bool) userListActivated = Signal(int, str) completion_widget_activated = Signal(str) def __init__(self, parent=None): TextEditBaseWidget.__init__(self, parent) self.setMaximumBlockCount(300) # ANSI escape code handler self.ansi_handler = QtANSIEscapeCodeHandler() # Disable undo/redo (nonsense for a console widget...): self.setUndoRedoEnabled(False) self.userListActivated.connect( lambda user_id, text: self.completion_widget_activated.emit(text) ) self.default_style = ConsoleFontStyle( foregroundcolor=0x000000, backgroundcolor=0xFFFFFF, bold=False, italic=False, underline=False, ) self.error_style = ConsoleFontStyle( foregroundcolor=0xFF0000, backgroundcolor=0xFFFFFF, bold=False, italic=False, underline=False, ) self.traceback_link_style = ConsoleFontStyle( foregroundcolor=0x0000FF, backgroundcolor=0xFFFFFF, bold=True, italic=False, underline=True, ) self.prompt_style = ConsoleFontStyle( foregroundcolor=0x00AA00, backgroundcolor=0xFFFFFF, bold=True, italic=False, underline=False, ) self.font_styles = ( self.default_style, self.error_style, self.traceback_link_style, self.prompt_style, ) self.update_color_mode() self.setMouseTracking(True) def update_color_mode(self): """Update color mode according to the current theme""" self.set_light_background(not qth.is_dark_theme()) def set_light_background(self, state): """Set light background state Args: state: bool """ default_fg_color = QColor(self.default_style.foregroundcolor) if not state: default_fg_color = inverse_color(default_fg_color) bg_color = qth.get_background_color() cursor = self.textCursor() cursor.movePosition(QTextCursor.Start) while not cursor.atEnd(): cursor.setPosition(cursor.block().position()) cursor.movePosition(QTextCursor.EndOfBlock, QTextCursor.KeepAnchor) charformat = cursor.charFormat() if charformat.background() == bg_color: break charformat.setBackground(bg_color) col = charformat.foreground().color() if col.red() == col.green() and col.green() == col.blue(): # Default style charformat.setForeground(default_fg_color) cursor.setCharFormat(charformat) cursor.movePosition(QTextCursor.NextBlock) self.set_palette(background=bg_color, foreground=qth.get_foreground_color()) self.ansi_handler.set_light_background(state) self.set_pythonshell_font() # ------Python shell def insert_text(self, text): """Reimplement TextEditBaseWidget method""" # Eventually this maybe should wrap to insert_text_to if # backspace-handling is required self.textCursor().insertText(text, self.default_style.format) def paste(self): """Reimplement Qt method""" if self.has_selected_text(): self.remove_selected_text() self.insert_text(QApplication.clipboard().text()) def append_text_to_shell(self, text, error, prompt): """ Append text to Python shell In a way, this method overrides the method 'insert_text' when text is inserted at the end of the text widget for a Python shell Handles error messages and show blue underlined links Handles ANSI color sequences Handles ANSI FF sequence """ cursor = self.textCursor() cursor.movePosition(QTextCursor.End) if "\r" in text: # replace \r\n with \n text = text.replace("\r\n", "\n") text = text.replace("\r", "\n") while True: index = text.find(chr(12)) if index == -1: break text = text[index + 1 :] self.clear() if error: is_traceback = False for text in text.splitlines(True): if text.startswith(" File") and not text.startswith(' File "<'): is_traceback = True # Show error links in blue underlined text cursor.insertText(" ", self.default_style.format) cursor.insertText(text[2:], self.traceback_link_style.format) else: # Show error/warning messages in red cursor.insertText(text, self.error_style.format) self.exception_occurred.emit(text, is_traceback) elif prompt: # Show prompt in green insert_text_to(cursor, text, self.prompt_style.format) else: # Show other outputs in black last_end = 0 for match in self.COLOR_PATTERN.finditer(text): insert_text_to( cursor, text[last_end : match.start()], self.default_style.format ) last_end = match.end() try: for code in [int(_c) for _c in match.group(1).split(";")]: self.ansi_handler.set_code(code) except ValueError: pass self.default_style.format = self.ansi_handler.get_format() insert_text_to(cursor, text[last_end:], self.default_style.format) self.set_cursor_position("eof") self.setCurrentCharFormat(self.default_style.format) def set_pythonshell_font(self, font=None): """Python Shell only""" if font is None: if self.default_style.format is None: font = self.font() else: font = self.default_style.format.font() for style in self.font_styles: style.apply_style(font=font, is_default=style is self.default_style) self.ansi_handler.set_base_format(self.default_style.format) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/calltip.py0000644000175100017510000002761715114075001021425 0ustar00runnerrunner# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2010 IPython Development Team # Copyright (c) 2013- Spyder Project Contributors # # Distributed under the terms of the Modified BSD License # (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). # ----------------------------------------------------------------------------- """ Calltip widget used only to show signatures. Adapted from IPython/frontend/qt/console/call_tip_widget.py of the `IPython Project `_. Now located at qtconsole/call_tip_widget.py as part of the `Jupyter QtConsole Project `_. """ from unicodedata import category from qtpy.QtCore import QBasicTimer, QCoreApplication, QEvent, Qt from qtpy.QtGui import QCursor, QPalette from qtpy.QtWidgets import ( QFrame, QLabel, QPlainTextEdit, QStyle, QStyleOptionFrame, QStylePainter, QTextEdit, QToolTip, ) class CallTipWidget(QLabel): """Shows call tips by parsing the current text of Q[Plain]TextEdit.""" # -------------------------------------------------------------------------- # 'QObject' interface # -------------------------------------------------------------------------- def __init__(self, text_edit, hide_timer_on=False): """Create a call tip manager that is attached to the specified Qt text edit widget. """ assert isinstance(text_edit, (QTextEdit, QPlainTextEdit)) super().__init__(None, Qt.ToolTip) self.app = QCoreApplication.instance() self.hide_timer_on = hide_timer_on self.tip = None self._hide_timer = QBasicTimer() self._text_edit = text_edit self.setFont(text_edit.document().defaultFont()) self.setForegroundRole(QPalette.ToolTipText) self.setBackgroundRole(QPalette.ToolTipBase) self.setPalette(QToolTip.palette()) self.setAlignment(Qt.AlignLeft) self.setIndent(1) self.setFrameStyle(QFrame.NoFrame) self.setMargin( 1 + self.style().pixelMetric(QStyle.PM_ToolTipLabelFrameWidth, None, self) ) def eventFilter(self, obj, event): """Reimplemented to hide on certain key presses and on text edit focus changes. """ if obj == self._text_edit: etype = event.type() if etype == QEvent.KeyPress: key = event.key() cursor = self._text_edit.textCursor() prev_char = self._text_edit.get_character(cursor.position(), offset=-1) if key in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Down, Qt.Key_Up): self.hide() elif key == Qt.Key_Escape: self.hide() return True elif prev_char == ")": self.hide() elif etype == QEvent.FocusOut: self.hide() elif etype == QEvent.Enter: if ( self._hide_timer.isActive() and self.app.topLevelAt(QCursor.pos()) == self ): self._hide_timer.stop() elif etype == QEvent.Leave: self._leave_event_hide() return super().eventFilter(obj, event) def timerEvent(self, event): """Reimplemented to hide the widget when the hide timer fires.""" if event.timerId() == self._hide_timer.timerId(): self._hide_timer.stop() self.hide() # -------------------------------------------------------------------------- # 'QWidget' interface # -------------------------------------------------------------------------- def enterEvent(self, event): """Reimplemented to cancel the hide timer.""" super().enterEvent(event) if self._hide_timer.isActive() and self.app.topLevelAt(QCursor.pos()) == self: self._hide_timer.stop() def hideEvent(self, event): """Reimplemented to disconnect signal handlers and event filter.""" super().hideEvent(event) self._text_edit.cursorPositionChanged.disconnect(self._cursor_position_changed) self._text_edit.removeEventFilter(self) def leaveEvent(self, event): """Reimplemented to start the hide timer.""" super().leaveEvent(event) self._leave_event_hide() def mousePressEvent(self, event): """ :param event: """ super().mousePressEvent(event) self.hide() def paintEvent(self, event): """Reimplemented to paint the background panel.""" painter = QStylePainter(self) option = QStyleOptionFrame() option.initFrom(self) painter.drawPrimitive(QStyle.PE_PanelTipLabel, option) painter.end() super().paintEvent(event) def setFont(self, font): """Reimplemented to allow use of this method as a slot.""" super().setFont(font) def showEvent(self, event): """Reimplemented to connect signal handlers and event filter.""" super().showEvent(event) self._text_edit.cursorPositionChanged.connect(self._cursor_position_changed) self._text_edit.installEventFilter(self) def focusOutEvent(self, event): """Reimplemented to hide it when focus goes out of the main window. """ self.hide() # -------------------------------------------------------------------------- # 'CallTipWidget' interface # -------------------------------------------------------------------------- def show_tip(self, point, tip, wrapped_tiplines): """Attempts to show the specified tip at the current cursor location.""" # Don't attempt to show it if it's already visible and the text # to be displayed is the same as the one displayed before. if self.isVisible(): if self.tip == tip: return True else: self.hide() # Attempt to find the cursor position at which to show the call tip. text_edit = self._text_edit cursor = text_edit.textCursor() search_pos = cursor.position() - 1 self._start_position, _ = self._find_parenthesis(search_pos, forward=False) if self._start_position == -1: return False if self.hide_timer_on: self._hide_timer.stop() # Logic to decide how much time to show the calltip depending # on the amount of text present if len(wrapped_tiplines) == 1: args = wrapped_tiplines[0].split("(")[1] nargs = len(args.split(",")) if nargs == 1: hide_time = 1400 elif nargs == 2: hide_time = 1600 else: hide_time = 1800 elif len(wrapped_tiplines) == 2: args1 = wrapped_tiplines[1].strip() nargs1 = len(args1.split(",")) if nargs1 == 1: hide_time = 2500 else: hide_time = 2800 else: hide_time = 3500 self._hide_timer.start(hide_time, self) # Set the text and resize the widget accordingly. self.tip = tip self.setText(tip) self.resize(self.sizeHint()) # Locate and show the widget. Place the tip below the current line # unless it would be off the screen. In that case, decide the best # location based trying to minimize the area that goes off-screen. padding = 3 # Distance in pixels between cursor bounds and tip box. cursor_rect = text_edit.cursorRect(cursor) screen_rect = text_edit.screen().geometry() point.setY(point.y() + padding) tip_height = self.size().height() tip_width = self.size().width() vertical = "bottom" horizontal = "Right" if point.y() + tip_height > screen_rect.height() + screen_rect.y(): point_ = text_edit.mapToGlobal(cursor_rect.topRight()) # If tip is still off screen, check if point is in top or bottom # half of screen. if point_.y() - tip_height < padding: # If point is in upper half of screen, show tip below it. # otherwise above it. if 2 * point.y() < screen_rect.height(): vertical = "bottom" else: vertical = "top" else: vertical = "top" if point.x() + tip_width > screen_rect.width() + screen_rect.x(): point_ = text_edit.mapToGlobal(cursor_rect.topRight()) # If tip is still off-screen, check if point is in the right or # left half of the screen. if point_.x() - tip_width < padding: if 2 * point.x() < screen_rect.width(): horizontal = "Right" else: horizontal = "Left" else: horizontal = "Left" pos = getattr(cursor_rect, "%s%s" % (vertical, horizontal)) adjusted_point = text_edit.mapToGlobal(pos()) if vertical == "top": point.setY(adjusted_point.y() - tip_height - padding) if horizontal == "Left": point.setX(adjusted_point.x() - tip_width - padding) self.move(point) self.show() return True # -------------------------------------------------------------------------- # Protected interface # -------------------------------------------------------------------------- def _find_parenthesis(self, position, forward=True): """If 'forward' is True (resp. False), proceed forwards (resp. backwards) through the line that contains 'position' until an unmatched closing (resp. opening) parenthesis is found. Returns a tuple containing the position of this parenthesis (or -1 if it is not found) and the number commas (at depth 0) found along the way. """ commas = depth = 0 document = self._text_edit.document() char = str(document.characterAt(position)) # Search until a match is found or a non-printable character is # encountered. while category(char) != "Cc" and position > 0: if char == "," and depth == 0: commas += 1 elif char == ")": if forward and depth == 0: break depth += 1 elif char == "(": if not forward and depth == 0: break depth -= 1 position += 1 if forward else -1 char = str(document.characterAt(position)) else: position = -1 return position, commas def _leave_event_hide(self): """Hides the tooltip after some time has passed (assuming the cursor is not over the tooltip). """ if ( self.hide_timer_on and not self._hide_timer.isActive() and # If Enter events always came after Leave events, we wouldn't need # this check. But on Mac OS, it sometimes happens the other way # around when the tooltip is created. self.app.topLevelAt(QCursor.pos()) != self ): self._hide_timer.start(800, self) # ------ Signal handlers ---------------------------------------------------- def _cursor_position_changed(self): """Updates the tip based on user cursor movement.""" cursor = self._text_edit.textCursor() position = cursor.position() document = self._text_edit.document() char = str(document.characterAt(position - 1)) if position <= self._start_position: self.hide() elif char == ")": pos, _ = self._find_parenthesis(position - 1, forward=False) if pos == -1: self.hide() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/dochelpers.py0000644000175100017510000002603115114075001022112 0ustar00runnerrunner# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2009- Spyder Kernels Contributors # # Licensed under the terms of the MIT License # (see spyder_kernels/__init__.py for details) # ----------------------------------------------------------------------------- # ruff: noqa """Utilities and wrappers around inspect module""" import builtins import inspect import re SYMBOLS = r"[^\'\"a-zA-Z0-9_.]" def getobj(txt, last=False): """Return the last valid object name in string""" txt_end = "" for startchar, endchar in ["[]", "()"]: if txt.endswith(endchar): pos = txt.rfind(startchar) if pos: txt_end = txt[pos:] txt = txt[:pos] tokens = re.split(SYMBOLS, txt) token = None try: while token is None or re.match(SYMBOLS, token): token = tokens.pop() if token.endswith("."): token = token[:-1] if token.startswith("."): # Invalid object name return None if last: # XXX: remove this statement as well as the "last" argument token += txt[txt.rfind(token) + len(token)] token += txt_end if token: return token except IndexError: return None def getobjdir(obj): """ For standard objects, will simply return dir(obj) In special cases (e.g. WrapITK package), will return only string elements of result returned by dir(obj) """ return [item for item in dir(obj) if isinstance(item, str)] def getdoc(obj): """ Return text documentation from an object. This comes in a form of dictionary with four keys: name: The name of the inspected object argspec: It's argspec note: A phrase describing the type of object (function or method) we are inspecting, and the module it belongs to. docstring: It's docstring """ docstring = inspect.getdoc(obj) or inspect.getcomments(obj) or "" # Most of the time doc will only contain ascii characters, but there are # some docstrings that contain non-ascii characters. Not all source files # declare their encoding in the first line, so querying for that might not # yield anything, either. So assume the most commonly used # multi-byte file encoding (which also covers ascii). try: docstring = str(docstring) except: # pylint: disable=bare-except pass # Doc dict keys doc = {"name": "", "argspec": "", "note": "", "docstring": docstring} if callable(obj): try: name = obj.__name__ except AttributeError: doc["docstring"] = docstring return doc if inspect.ismethod(obj): imclass = obj.__self__.__class__ if obj.__self__ is not None: doc["note"] = "Method of %s instance" % obj.__self__.__class__.__name__ else: doc["note"] = "Unbound %s method" % imclass.__name__ obj = obj.__func__ elif hasattr(obj, "__module__"): doc["note"] = "Function of %s module" % obj.__module__ else: doc["note"] = "Function" doc["name"] = obj.__name__ if inspect.isfunction(obj): sig = inspect.signature(obj) doc["argspec"] = str(sig) if name == "": doc["name"] = name + " lambda " doc["argspec"] = doc["argspec"][1:-1] # remove parentheses else: argspec = getargspecfromtext(doc["docstring"]) if argspec: doc["argspec"] = argspec # Many scipy and numpy docstrings begin with a function # signature on the first line. This ends up begin redundant # when we are using title and argspec to create the # rich text "Definition:" field. We'll carefully remove this # redundancy but only under a strict set of conditions: # Remove the starting charaters of the 'doc' portion *iff* # the non-whitespace characters on the first line # match *exactly* the combined function title # and argspec we determined above. signature = doc["name"] + doc["argspec"] docstring_blocks = doc["docstring"].split("\n\n") first_block = docstring_blocks[0].strip() if first_block == signature: doc["docstring"] = ( doc["docstring"].replace(signature, "", 1).lstrip() ) else: doc["argspec"] = "(...)" # Remove self from argspec argspec = doc["argspec"] doc["argspec"] = argspec.replace("(self)", "()").replace("(self, ", "(") return doc def getsource(obj): """Wrapper around inspect.getsource""" try: try: src = str(inspect.getsource(obj)) except TypeError: if hasattr(obj, "__class__"): src = str(inspect.getsource(obj.__class__)) else: # Bindings like VTK or ITK require this case src = getdoc(obj) return src except (TypeError, IOError): return def getsignaturefromtext(text, objname): """Get object signatures from text (object documentation) Return a list containing a single string in most cases Example of multiple signatures: PyQt5 objects""" if isinstance(text, dict): text = text.get("docstring", "") # Regexps oneline_re = objname + r'\([^\)].+?(?<=[\w\]\}\'"])\)(?!,)' multiline_re = objname + r'\([^\)]+(?<=[\w\]\}\'"])\)(?!,)' multiline_end_parenleft_re = r'(%s\([^\)]+(\),\n.+)+(?<=[\w\]\}\'"])\))' # Grabbing signatures if not text: text = "" sigs_1 = re.findall(oneline_re + "|" + multiline_re, text) sigs_2 = [g[0] for g in re.findall(multiline_end_parenleft_re % objname, text)] all_sigs = sigs_1 + sigs_2 # The most relevant signature is usually the first one. There could be # others in doctests but those are not so important if all_sigs: return all_sigs[0] else: return "" # Fix for Issue 1953 # TODO: Add more signatures and remove this hack in 2.4 getsignaturesfromtext = getsignaturefromtext def getargspecfromtext(text): """ Try to get the formatted argspec of a callable from the first block of its docstring This will return something like '(foo, bar, k=1)' """ blocks = text.split("\n\n") first_block = blocks[0].strip() return getsignaturefromtext(first_block, "") def getargsfromtext(text, objname): """Get arguments from text (object documentation)""" signature = getsignaturefromtext(text, objname) if signature: argtxt = signature[signature.find("(") + 1 : -1] return argtxt.split(",") def getargsfromdoc(obj): """Get arguments from object doc""" if obj.__doc__ is not None: return getargsfromtext(obj.__doc__, obj.__name__) def getargs(obj): """Get the names and default values of a function's arguments""" if inspect.isfunction(obj) or inspect.isbuiltin(obj): func_obj = obj elif inspect.ismethod(obj): func_obj = obj.__func__ elif inspect.isclass(obj) and hasattr(obj, "__init__"): func_obj = getattr(obj, "__init__") else: return [] if not hasattr(func_obj, "func_code"): # Builtin: try to extract info from doc args = getargsfromdoc(func_obj) if args is not None: return args else: # Example: PyQt5 return getargsfromdoc(obj) args, _, _ = inspect.getargs(func_obj.func_code) if not args: return getargsfromdoc(obj) # Supporting tuple arguments in def statement: for i_arg, arg in enumerate(args): if isinstance(arg, list): args[i_arg] = "(%s)" % ", ".join(arg) defaults = func_obj.__defaults__ if defaults is not None: for index, default in enumerate(defaults): args[index + len(args) - len(defaults)] += "=" + repr(default) if inspect.isclass(obj) or inspect.ismethod(obj): if len(args) == 1: return None if "self" in args: args.remove("self") return args def getargtxt(obj, one_arg_per_line=True): """ Get the names and default values of a function's arguments Return list with separators (', ') formatted for calltips """ args = getargs(obj) if args: sep = ", " textlist = None for i_arg, arg in enumerate(args): if textlist is None: textlist = [""] textlist[-1] += arg if i_arg < len(args) - 1: textlist[-1] += sep if len(textlist[-1]) >= 32 or one_arg_per_line: textlist.append("") if inspect.isclass(obj) or inspect.ismethod(obj): if len(textlist) == 1: return None if "self" + sep in textlist: textlist.remove("self" + sep) return textlist def isdefined(obj, force_import=False, namespace=None): """Return True if object is defined in namespace If namespace is None --> namespace = locals()""" if namespace is None: namespace = locals() attr_list = obj.split(".") base = attr_list.pop(0) if len(base) == 0: return False if base not in builtins.__dict__ and base not in namespace: if force_import: try: module = __import__(base, globals(), namespace) if base not in globals(): globals()[base] = module namespace[base] = module except Exception: return False else: return False for attr in attr_list: try: attr_not_found = not hasattr(eval(base, namespace), attr) except (SyntaxError, AttributeError): return False if attr_not_found: if force_import: try: __import__(base + "." + attr, globals(), namespace) except (ImportError, SyntaxError): return False else: return False base += "." + attr return True if __name__ == "__main__": class Test(object): def method(self, x, y=2): """ :param x: :param y: """ pass print(getargtxt(Test.__init__)) # spyder: test-skip print(getargtxt(Test.method)) # spyder: test-skip print(isdefined("numpy.take", force_import=True)) # spyder: test-skip print(isdefined("__import__")) # spyder: test-skip print(isdefined(".keys", force_import=True)) # spyder: test-skip print(getobj("globals")) # spyder: test-skip print(getobj("globals().keys")) # spyder: test-skip print(getobj("+scipy.signal.")) # spyder: test-skip print(getobj("4.")) # spyder: test-skip print(getdoc(sorted)) # spyder: test-skip print(getargtxt(sorted)) # spyder: test-skip ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/internalshell.py0000644000175100017510000003555615114075001022642 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """Internal shell widget : PythonShellWidget + Interpreter""" # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 # FIXME: Internal shell MT: for i in range(100000): print i -> bug import builtins import os import platform import sys import threading from time import time from qtpy.QtCore import QEventLoop, QObject, Signal, Slot from qtpy.QtWidgets import QMessageBox from guidata.config import CONF, _ from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action, get_std_icon, is_dark_theme from guidata.utils.misc import getcwd_or_home, run_program from guidata.widgets import about from guidata.widgets.console.dochelpers import getargtxt, getdoc, getobjdir, getsource from guidata.widgets.console.interpreter import Interpreter from guidata.widgets.console.shell import PythonShellWidget def create_banner(message): """Create internal shell banner""" system = platform.system() if message is None: bitness = 64 if sys.maxsize > 2**32 else 32 return "Python %s %dbits [%s]" % (platform.python_version(), bitness, system) else: return message class SysOutput(QObject): """Handle standard I/O queue""" data_avail = Signal() def __init__(self): QObject.__init__(self) self.queue = [] self.lock = threading.Lock() def write(self, val): """ :param val: """ self.lock.acquire() self.queue.append(val) self.lock.release() self.data_avail.emit() def empty_queue(self): """ :return: """ self.lock.acquire() s = "".join(self.queue) self.queue = [] self.lock.release() return s # We need to add this method to fix Issue 1789 def flush(self): """ """ pass # This is needed to fix Issue 2984 @property def closed(self): """ :return: """ return False class WidgetProxyData(object): pass class WidgetProxy(QObject): """Handle Shell widget refresh signal""" sig_new_prompt = Signal(str) sig_set_readonly = Signal(bool) sig_edit = Signal(str, bool) sig_wait_input = Signal(str) def __init__(self, input_condition): QObject.__init__(self) self.input_data = None self.input_condition = input_condition def new_prompt(self, prompt): """ :param prompt: """ self.sig_new_prompt.emit(prompt) def set_readonly(self, state): """ :param state: """ self.sig_set_readonly.emit(state) def edit(self, filename, external_editor=False): """ :param filename: :param external_editor: """ self.sig_edit.emit(filename, external_editor) def data_available(self): """Return True if input data is available""" return self.input_data is not WidgetProxyData def wait_input(self, prompt=""): """ :param prompt: """ self.input_data = WidgetProxyData self.sig_wait_input.emit(prompt) def end_input(self, cmd): """ :param cmd: """ self.input_condition.acquire() self.input_data = cmd self.input_condition.notify() self.input_condition.release() class InternalShell(PythonShellWidget): """Shell base widget: link between PythonShellWidget and Interpreter""" status = Signal(str) refresh = Signal() go_to_error = Signal(str) focus_changed = Signal() def __init__( self, parent=None, namespace=None, commands=[], message=None, max_line_count=300, font=None, exitfunc=None, profile=False, multithreaded=True, light_background=not is_dark_theme(), debug=False, ): PythonShellWidget.__init__(self, parent, profile) self.debug = debug self.set_light_background(light_background) self.multithreaded = multithreaded self.setMaximumBlockCount(max_line_count) if font is not None: self.set_font(font) # Allow raw_input support: self.input_loop = None self.input_mode = False # KeyboardInterrupt support self.interrupted = False # used only for not-multithreaded mode self.sig_keyboard_interrupt.connect(self.keyboard_interrupt) # Code completion / calltips getcfg = lambda option: CONF.get("console", option) case_sensitive = getcfg("codecompletion/case_sensitive") self.set_codecompletion_case(case_sensitive) # keyboard events management self.eventqueue = [] # Init interpreter self.exitfunc = exitfunc self.commands = commands self.message = message self.interpreter = None self.start_interpreter(namespace) # Clear status bar self.status.emit("") # ------ Interpreter def start_interpreter(self, namespace): """Start Python interpreter""" self.clear() if self.interpreter is not None: self.interpreter.closing() self.interpreter = Interpreter( namespace, self.exitfunc, SysOutput, WidgetProxy, self.debug ) self.interpreter.stdout_write.data_avail.connect(self.stdout_avail) self.interpreter.stderr_write.data_avail.connect(self.stderr_avail) self.interpreter.widget_proxy.sig_set_readonly.connect(self.setReadOnly) self.interpreter.widget_proxy.sig_new_prompt.connect(self.new_prompt) self.interpreter.widget_proxy.sig_edit.connect(self.edit_script) self.interpreter.widget_proxy.sig_wait_input.connect(self.wait_input) if self.multithreaded: self.interpreter.start() # Interpreter banner banner = create_banner(self.message) self.write(banner, prompt=True) # Initial commands for cmd in self.commands: self.run_command(cmd, history=False, new_prompt=False) # First prompt self.new_prompt(self.interpreter.p1) self.refresh.emit() return self.interpreter def exit_interpreter(self): """Exit interpreter""" self.interpreter.exit_flag = True if self.multithreaded: self.interpreter.stdin_write.write(b"\n") self.interpreter.restore_stds() def edit_script(self, filename, external_editor): """ :param filename: :param external_editor: """ filename = str(filename) if external_editor: self.external_editor(filename) else: self.parentWidget().edit_script(filename) def stdout_avail(self): """Data is available in stdout, let's empty the queue and write it!""" data = self.interpreter.stdout_write.empty_queue() if data: self.write(data) def stderr_avail(self): """Data is available in stderr, let's empty the queue and write it!""" data = self.interpreter.stderr_write.empty_queue() if data: self.write(data, error=True) self.flush(error=True) # ------Raw input support def wait_input(self, prompt=""): """Wait for input (raw_input support)""" self.new_prompt(prompt) self.setFocus() self.input_mode = True self.input_loop = QEventLoop() self.input_loop.exec() self.input_loop = None def end_input(self, cmd): """End of wait_input mode""" self.input_mode = False self.input_loop.exit() self.interpreter.widget_proxy.end_input(cmd) # ----- Menus, actions, ... def setup_context_menu(self): """Reimplement PythonShellWidget method""" PythonShellWidget.setup_context_menu(self) help_action = create_action( self, _("Help..."), icon=get_std_icon("DialogHelpButton"), triggered=self.help, ) about_action = create_action( self, _("About..."), icon=get_icon("guidata.svg"), triggered=about.show_about_dialog, ) add_actions(self.menu, (None, help_action, about_action)) @Slot() def help(self): """Help on Spyder console""" QMessageBox.information( self, _("Help"), """%s

%s
edit foobar.py

%s
xedit foobar.py

%s
run foobar.py

%s
clear x, y

%s
!ls

%s
object? """ % ( _("Shell special commands:"), _("Internal editor:"), _("External editor:"), _("Run script:"), _("Remove references:"), _("System commands:"), _("Python help:"), ), ) def external_editor(self, filename, goto=-1): """Edit in an external editor Recommended: SciTE (e.g. to go to line where an error did occur)""" editor_path = CONF.get("console", "external_editor/path") goto_option = CONF.get("console", "external_editor/gotoline") try: args = [filename] if goto > 0 and goto_option: args.append("%s%d".format(goto_option, goto)) run_program(editor_path, args) except OSError: self.write_error("External editor was not found: %s\n" % editor_path) # ------ I/O def flush(self, error=False, prompt=False): """Reimplement ShellBaseWidget method""" PythonShellWidget.flush(self, error=error, prompt=prompt) if self.interrupted: self.interrupted = False raise KeyboardInterrupt # ------ Clear terminal def clear_terminal(self): """Reimplement ShellBaseWidget method""" self.clear() self.new_prompt( self.interpreter.p2 if self.interpreter.more else self.interpreter.p1 ) # ------ Keyboard events def on_enter(self, command): """on_enter""" if self.profile: # Simple profiling test t0 = time() for _ in range(10): self.execute_command(command) self.insert_text("\n<Δt>=%dms\n" % (1e2 * (time() - t0))) self.new_prompt(self.interpreter.p1) else: self.execute_command(command) self.__flush_eventqueue() def keyPressEvent(self, event): """ Reimplement Qt Method Enhanced keypress event handler """ if self.preprocess_keyevent(event): # Event was accepted in self.preprocess_keyevent return self.postprocess_keyevent(event) def __flush_eventqueue(self): """Flush keyboard event queue""" while self.eventqueue: past_event = self.eventqueue.pop(0) self.postprocess_keyevent(past_event) # ------ Command execution def keyboard_interrupt(self): """Simulate keyboard interrupt""" if self.multithreaded: self.interpreter.raise_keyboard_interrupt() else: if self.interpreter.more: self.write_error("\nKeyboardInterrupt\n") self.interpreter.more = False self.new_prompt(self.interpreter.p1) self.interpreter.resetbuffer() else: self.interrupted = True def execute_lines(self, lines): """ Execute a set of lines as multiple command lines: multiple lines of text to be executed as single commands """ for line in lines.splitlines(): stripped_line = line.strip() if stripped_line.startswith("#"): continue self.write(line + os.linesep, flush=True) self.execute_command(line + "\n") self.flush() def execute_command(self, cmd): """ Execute a command :param cmd: one-line command only, with ``'\n'`` at the end """ if self.input_mode: self.end_input(cmd) return if cmd.endswith("\n"): cmd = cmd[:-1] # cls command if cmd == "cls": self.clear_terminal() return self.run_command(cmd) def run_command(self, cmd, history=True, new_prompt=True): """Run command in interpreter""" if not cmd: cmd = "" else: if history: self.add_to_history(cmd) if not self.multithreaded: if "input" not in cmd: self.interpreter.stdin_write.write(bytes(cmd + "\n", "utf-8")) self.interpreter.run_line() self.refresh.emit() else: self.write( _( 'In order to use commands like "input" run console with the multithread option' ), error=True, ) else: self.interpreter.stdin_write.write(bytes(cmd + "\n", "utf-8")) # ------ Code completion / Calltips def _eval(self, text): """Is text a valid object?""" return self.interpreter.eval(text) def get_dir(self, objtxt): """Return dir(object)""" obj, valid = self._eval(objtxt) if valid: return getobjdir(obj) def get_globals_keys(self): """Return shell globals() keys""" return list(self.interpreter.namespace.keys()) def get_cdlistdir(self): """Return shell current directory list dir""" return os.listdir(getcwd_or_home()) def iscallable(self, objtxt): """Is object callable?""" obj, valid = self._eval(objtxt) if valid: return callable(obj) def get_arglist(self, objtxt): """Get func/method argument list""" obj, valid = self._eval(objtxt) if valid: return getargtxt(obj) def get__doc__(self, objtxt): """Get object __doc__""" obj, valid = self._eval(objtxt) if valid: return obj.__doc__ def get_doc(self, objtxt): """Get object documentation dictionary""" obj, valid = self._eval(objtxt) if valid: return getdoc(obj) def get_source(self, objtxt): """Get object source""" obj, valid = self._eval(objtxt) if valid: return getsource(obj) def is_defined(self, objtxt, force_import=False): """Return True if object is defined""" return self.interpreter.is_defined(objtxt, force_import) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/interpreter.py0000644000175100017510000002773015114075001022334 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """Shell Interpreter""" import atexit import ctypes import os import os.path as osp import pydoc import re import sys import threading from code import InteractiveConsole from guidata.utils.misc import getcwd_or_home, remove_backslashes, run_shell_command from guidata.widgets.console.dochelpers import isdefined sys.path.insert(0, "") def guess_filename(filename): """Guess filename""" if osp.isfile(filename): return filename if not filename.endswith(".py"): filename += ".py" for path in [getcwd_or_home()] + sys.path: fname = osp.join(path, filename) if osp.isfile(fname): return fname elif osp.isfile(fname + ".py"): return fname + ".py" elif osp.isfile(fname + ".pyw"): return fname + ".pyw" return filename class Interpreter(InteractiveConsole, threading.Thread): """Interpreter, executed in a separate thread""" p1 = ">>> " p2 = "... " def __init__( self, namespace=None, exitfunc=None, Output=None, WidgetProxy=None, debug=False ): """ namespace: locals send to InteractiveConsole object commands: list of commands executed at startup """ InteractiveConsole.__init__(self, namespace) threading.Thread.__init__(self) self._id = None self.exit_flag = False self.debug = debug # Execution Status self.more = False if exitfunc is not None: atexit.register(exitfunc) self.namespace = self.locals self.namespace["__name__"] = "__main__" self.namespace["execfile"] = self.execfile self.namespace["runfile"] = self.runfile self.namespace["raw_input"] = self.raw_input_replacement self.namespace["help"] = self.help_replacement # Capture all interactive input/output self.initial_stdout = sys.stdout self.initial_stderr = sys.stderr self.initial_stdin = sys.stdin # Create communication pipes pr, pw = os.pipe() self.stdin_read = os.fdopen(pr, "r") self.stdin_write = os.fdopen(pw, "wb", 0) self.stdout_write = Output() self.stderr_write = Output() self.input_condition = threading.Condition() self.widget_proxy = WidgetProxy(self.input_condition) self.redirect_stds() # ------ Standard input/output def redirect_stds(self): """Redirects stds""" if not self.debug: sys.stdout = self.stdout_write sys.stderr = self.stderr_write sys.stdin = self.stdin_read def restore_stds(self): """Restore stds""" if not self.debug: sys.stdout = self.initial_stdout sys.stderr = self.initial_stderr sys.stdin = self.initial_stdin def raw_input_replacement(self, prompt=""): """For raw_input builtin function emulation""" self.widget_proxy.wait_input(prompt) self.input_condition.acquire() while not self.widget_proxy.data_available(): self.input_condition.wait() inp = self.widget_proxy.input_data self.input_condition.release() return inp def help_replacement(self, text=None, interactive=False): """For help builtin function emulation""" if text is not None and not interactive: return pydoc.help(text) elif text is None: pyver = "%d.%d" % (sys.version_info[0], sys.version_info[1]) self.write( """ Welcome to Python %s! This is the online help utility. If this is your first time using Python, you should definitely check out the tutorial on the Internet at https://www.python.org/about/gettingstarted/ Enter the name of any module, keyword, or topic to get help on writing Python programs and using Python modules. To quit this help utility and return to the interpreter, just type "quit". To get a list of available modules, keywords, or topics, type "modules", "keywords", or "topics". Each module also comes with a one-line summary of what it does; to list the modules whose summaries contain a given word such as "spam", type "modules spam". """ % pyver ) else: text = text.strip() try: eval("pydoc.help(%s)" % text) except (NameError, SyntaxError): print( "no Python documentation found for '%r'" % text ) # spyder: test-skip self.write(os.linesep) self.widget_proxy.new_prompt("help> ") inp = self.raw_input_replacement() if inp.strip(): self.help_replacement(inp, interactive=True) else: self.write( """ You are now leaving help and returning to the Python interpreter. If you want to ask for help on a particular object directly from the interpreter, you can type "help(object)". Executing "help('string')" has the same effect as typing a particular string at the help> prompt. """ ) def run_command(self, cmd, new_prompt=True): """Run command in interpreter""" if cmd == "exit()": self.exit_flag = True self.write("\n") return # -- Special commands type I # (transformed into commands executed in the interpreter) # ? command special_pattern = r"^%s (?:r\')?(?:u\')?\"?\'?([a-zA-Z0-9_\.]+)" run_match = re.match(special_pattern % "run", cmd) help_match = re.match(r"^([a-zA-Z0-9_\.]+)\?$", cmd) cd_match = re.match(r"^\!cd \"?\'?([a-zA-Z0-9_ \.]+)", cmd) if help_match: cmd = "help(%s)" % help_match.group(1) # run command elif run_match: filename = guess_filename(run_match.groups()[0]) cmd = "runfile('%s', args=None)" % remove_backslashes(filename) # !cd system command elif cd_match: cmd = 'import os; os.chdir(r"%s")' % cd_match.groups()[0].strip() # -- End of Special commands type I # -- Special commands type II # (don't need code execution in interpreter) xedit_match = re.match(special_pattern % "xedit", cmd) edit_match = re.match(special_pattern % "edit", cmd) clear_match = re.match(r"^clear ([a-zA-Z0-9_, ]+)", cmd) # (external) edit command if xedit_match: filename = guess_filename(xedit_match.groups()[0]) self.widget_proxy.edit(filename, external_editor=True) # local edit command elif edit_match: filename = guess_filename(edit_match.groups()[0]) if osp.isfile(filename): self.widget_proxy.edit(filename) else: self.stderr_write.write("No such file or directory: %s\n" % filename) # remove reference (equivalent to MATLAB's clear command) elif clear_match: varnames = clear_match.groups()[0].replace(" ", "").split(",") for varname in varnames: try: self.namespace.pop(varname) except KeyError: pass # Execute command elif cmd.startswith("!"): # System ! command pipe = run_shell_command(cmd[1:]) out = pipe.stdout.read() err = pipe.stderr.read() try: out = out.decode("cp437") except UnicodeDecodeError: out = out.decode(erros="ignore") try: err = err.decode("cp437") except UnicodeDecodeError: err = err.decode(erros="ignore") if err: self.stderr_write.write(err) if out: self.stdout_write.write(out) self.stdout_write.write("\n") self.more = False # -- End of Special commands type II else: # Command executed in the interpreter # self.widget_proxy.set_readonly(True) self.more = self.push(cmd) # self.widget_proxy.set_readonly(False) if new_prompt: self.widget_proxy.new_prompt(self.p2 if self.more else self.p1) if not self.more: self.resetbuffer() def run(self): """Wait for input and run it""" while not self.exit_flag: self.run_line() def run_line(self): """ :return: """ line = self.stdin_read.readline() if self.exit_flag: return # Remove last character which is always '\n': self.run_command(line[:-1]) def get_thread_id(self): """Return thread id""" if self._id is None: for thread_id, obj in list(threading._active.items()): if obj is self: self._id = thread_id return self._id def raise_keyboard_interrupt(self): """ :return: """ if self.is_alive(): ctypes.pythonapi.PyThreadState_SetAsyncExc( self.get_thread_id(), ctypes.py_object(KeyboardInterrupt) ) return True else: return False def closing(self): """Actions to be done before restarting this interpreter""" pass def execfile(self, filename): """Exec filename""" source = open(filename, "r").read() try: try: name = filename.encode("ascii") except UnicodeEncodeError: name = "" code = compile(source, name, "exec") except (OverflowError, SyntaxError): InteractiveConsole.showsyntaxerror(self, filename) else: self.runcode(code) def runfile(self, filename, args=None): """ Run filename args: command line arguments (string) """ if args is not None and not isinstance(args, str): raise TypeError("expected a character buffer object") self.namespace["__file__"] = filename sys.argv = [filename] if args is not None: for arg in args.split(): sys.argv.append(arg) self.execfile(filename) sys.argv = [""] self.namespace.pop("__file__") def eval(self, text): """ Evaluate text and return (obj, valid) where *obj* is the object represented by *text* and *valid* is True if object evaluation did not raise any exception """ assert isinstance(text, str) try: return eval(text, self.locals), True except: return None, False def is_defined(self, objtxt, force_import=False): """Return True if object is defined""" return isdefined(objtxt, force_import=force_import, namespace=self.locals) # =========================================================================== # InteractiveConsole API # =========================================================================== def push(self, line): """ Push a line of source text to the interpreter The line should not have a trailing newline; it may have internal newlines. The line is appended to a buffer and the interpreter’s runsource() method is called with the concatenated contents of the buffer as source. If this indicates that the command was executed or invalid, the buffer is reset; otherwise, the command is incomplete, and the buffer is left as it was after the line was appended. The return value is True if more input is required, False if the line was dealt with in some way (this is the same as runsource()). """ return InteractiveConsole.push(self, "#coding=utf-8\n" + line) def resetbuffer(self): """Remove any unhandled source text from the input buffer""" InteractiveConsole.resetbuffer(self) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/mixins.py0000644000175100017510000007265015114075001021301 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """Mix-in classes These classes were created to be able to provide Spyder's regular text and console widget features to an independant widget based on QTextEdit for the IPython console plugin. """ import os import os.path as osp import re import textwrap from xml.sax.saxutils import escape from qtpy.QtCore import QPoint, QRegularExpression, Qt from qtpy.QtGui import QCursor, QTextCursor, QTextDocument from qtpy.QtWidgets import QApplication, QToolTip from guidata.config import _ from guidata.utils import encoding from guidata.widgets.console.dochelpers import ( getargspecfromtext, getobj, getsignaturefromtext, ) # Order is important: EOL_CHARS = (("\r\n", "nt"), ("\n", "posix"), ("\r", "mac")) def get_eol_chars(text): """Get text EOL characters""" for eol_chars, _os_name in EOL_CHARS: if text.find(eol_chars) > -1: return eol_chars def get_error_match(text): """Return error match""" import re return re.match(r' File "(.*)", line (\d*)', text) class BaseEditMixin(object): """ """ def __init__(self): self.eol_chars = None self.calltip_size = 600 # ------Line number area def get_linenumberarea_width(self): """Return line number area width""" # Implemented in CodeEditor, but needed for calltip/completion widgets return 0 # ------Calltips def _format_signature(self, text): formatted_lines = [] name = text.split("(")[0] rows = textwrap.wrap(text, width=50, subsequent_indent=" " * (len(name) + 1)) for r in rows: r = escape(r) # Escape most common html chars r = r.replace(" ", " ") for char in ["=", ",", "(", ")", "*", "**"]: r = r.replace( char, "" + char + "", ) formatted_lines.append(r) signature = "
".join(formatted_lines) return signature, rows def show_calltip( self, title, text, signature=False, color="#2D62FF", at_line=None, at_position=None, ): """Show calltip""" if text is None or len(text) == 0: return # Saving cursor position: if at_position is None: at_position = self.get_position("cursor") self.calltip_position = at_position # Preparing text: if signature: text, wrapped_textlines = self._format_signature(text) else: if isinstance(text, list): text = "\n ".join(text) text = text.replace("\n", "
") if len(text) > self.calltip_size: text = text[: self.calltip_size] + " ..." # Formatting text font = self.font() size = font.pointSize() family = font.family() format1 = "

" % ( family, size, color, ) format2 = "
" % ( family, size - 1 if size > 9 else size, ) tiptext = ( format1 + ("%s
" % title) + "
" + format2 + text + "
" ) # Showing tooltip at cursor position: cx, cy = self.get_coordinates("cursor") if at_line is not None: cx = 5 cursor = QTextCursor(self.document().findBlockByNumber(at_line - 1)) cy = self.cursorRect(cursor).top() point = self.mapToGlobal(QPoint(cx, cy)) point.setX(point.x() + self.get_linenumberarea_width()) point.setY(point.y() + font.pointSize() + 5) if signature: self.calltip_widget.show_tip(point, tiptext, wrapped_textlines) else: QToolTip.showText(point, tiptext) # ------EOL characters def set_eol_chars(self, text): """Set widget end-of-line (EOL) characters from text (analyzes text)""" eol_chars = get_eol_chars(text) is_document_modified = eol_chars is not None and self.eol_chars is not None self.eol_chars = eol_chars if is_document_modified: self.document().setModified(True) if self.sig_eol_chars_changed is not None: self.sig_eol_chars_changed.emit(eol_chars) def get_line_separator(self): """Return line separator based on current EOL mode""" if self.eol_chars is not None: return self.eol_chars else: return os.linesep def get_text_with_eol(self): """Same as 'toPlainText', replace '\n' by correct end-of-line characters""" utext = str(self.toPlainText()) lines = utext.splitlines() linesep = self.get_line_separator() txt = linesep.join(lines) if utext.endswith("\n"): txt += linesep return txt # ------Positions, coordinates (cursor, EOF, ...) def get_position(self, subject): """Get offset in character for the given subject from the start of text edit area""" cursor = self.textCursor() if subject == "cursor": pass elif subject == "sol": cursor.movePosition(QTextCursor.StartOfBlock) elif subject == "eol": cursor.movePosition(QTextCursor.EndOfBlock) elif subject == "eof": cursor.movePosition(QTextCursor.End) elif subject == "sof": cursor.movePosition(QTextCursor.Start) else: # Assuming that input argument was already a position return subject return cursor.position() def get_coordinates(self, position): """ :param position: :return: """ position = self.get_position(position) cursor = self.textCursor() cursor.setPosition(position) point = self.cursorRect(cursor).center() return point.x(), point.y() def get_cursor_line_column(self): """Return cursor (line, column) numbers""" cursor = self.textCursor() return cursor.blockNumber(), cursor.columnNumber() def get_cursor_line_number(self): """Return cursor line number""" return self.textCursor().blockNumber() + 1 def set_cursor_position(self, position): """Set cursor position""" position = self.get_position(position) cursor = self.textCursor() cursor.setPosition(position) self.setTextCursor(cursor) self.ensureCursorVisible() def move_cursor(self, chars=0): """Move cursor to left or right (unit: characters)""" direction = QTextCursor.Right if chars > 0 else QTextCursor.Left for _i in range(abs(chars)): self.moveCursor(direction, QTextCursor.MoveAnchor) def is_cursor_on_first_line(self): """Return True if cursor is on the first line""" cursor = self.textCursor() cursor.movePosition(QTextCursor.StartOfBlock) return cursor.atStart() def is_cursor_on_last_line(self): """Return True if cursor is on the last line""" cursor = self.textCursor() cursor.movePosition(QTextCursor.EndOfBlock) return cursor.atEnd() def is_cursor_at_end(self): """Return True if cursor is at the end of the text""" return self.textCursor().atEnd() def is_cursor_before(self, position, char_offset=0): """Return True if cursor is before *position*""" position = self.get_position(position) + char_offset cursor = self.textCursor() cursor.movePosition(QTextCursor.End) if position < cursor.position(): cursor.setPosition(position) return self.textCursor() < cursor def __move_cursor_anchor(self, what, direction, move_mode): assert what in ("character", "word", "line") if what == "character": if direction == "left": self.moveCursor(QTextCursor.PreviousCharacter, move_mode) elif direction == "right": self.moveCursor(QTextCursor.NextCharacter, move_mode) elif what == "word": if direction == "left": self.moveCursor(QTextCursor.PreviousWord, move_mode) elif direction == "right": self.moveCursor(QTextCursor.NextWord, move_mode) elif what == "line": if direction == "down": self.moveCursor(QTextCursor.NextBlock, move_mode) elif direction == "up": self.moveCursor(QTextCursor.PreviousBlock, move_mode) def move_cursor_to_next(self, what="word", direction="left"): """ Move cursor to next *what* ('word' or 'character') toward *direction* ('left' or 'right') """ self.__move_cursor_anchor(what, direction, QTextCursor.MoveAnchor) # ------Selection def clear_selection(self): """Clear current selection""" cursor = self.textCursor() cursor.clearSelection() self.setTextCursor(cursor) def extend_selection_to_next(self, what="word", direction="left"): """ Extend selection to next *what* ('word' or 'character') toward *direction* ('left' or 'right') """ self.__move_cursor_anchor(what, direction, QTextCursor.KeepAnchor) # ------Text: get, set, ... def __select_text(self, position_from, position_to): position_from = self.get_position(position_from) position_to = self.get_position(position_to) cursor = self.textCursor() cursor.setPosition(position_from) cursor.setPosition(position_to, QTextCursor.KeepAnchor) return cursor def get_text_line(self, line_nb): """Return text line at line number *line_nb*""" # Taking into account the case when a file ends in an empty line, # since splitlines doesn't return that line as the last element # TODO: Make this function more efficient try: return str(self.toPlainText()).splitlines()[line_nb] except IndexError: return self.get_line_separator() def get_text(self, position_from, position_to): """ Return text between *position_from* and *position_to* Positions may be positions or 'sol', 'eol', 'sof', 'eof' or 'cursor' """ cursor = self.__select_text(position_from, position_to) text = str(cursor.selectedText()) all_text = position_from == "sof" and position_to == "eof" if text and not all_text: while text.endswith("\n"): text = text[:-1] while text.endswith("\u2029"): text = text[:-1] return text def get_character(self, position, offset=0): """Return character at *position* with the given offset.""" position = self.get_position(position) + offset cursor = self.textCursor() cursor.movePosition(QTextCursor.End) if position < cursor.position(): cursor.setPosition(position) cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor) return str(cursor.selectedText()) else: return "" def insert_text(self, text): """Insert text at cursor position""" if not self.isReadOnly(): self.textCursor().insertText(text) def replace_text(self, position_from, position_to, text): """ :param position_from: :param position_to: :param text: """ cursor = self.__select_text(position_from, position_to) cursor.removeSelectedText() cursor.insertText(text) def remove_text(self, position_from, position_to): """ :param position_from: :param position_to: """ cursor = self.__select_text(position_from, position_to) cursor.removeSelectedText() def get_current_word(self): """Return current word, i.e. word at cursor position""" cursor = self.textCursor() if cursor.hasSelection(): # Removes the selection and moves the cursor to the left side # of the selection: this is required to be able to properly # select the whole word under cursor (otherwise, the same word is # not selected when the cursor is at the right side of it): cursor.setPosition(min([cursor.selectionStart(), cursor.selectionEnd()])) else: # Checks if the first character to the right is a white space # and if not, moves the cursor one word to the left (otherwise, # if the character to the left do not match the "word regexp" # (see below), the word to the left of the cursor won't be # selected), but only if the first character to the left is not a # white space too. def is_space(move): """ :param move: :return: """ curs = self.textCursor() curs.movePosition(move, QTextCursor.KeepAnchor) return not str(curs.selectedText()).strip() if is_space(QTextCursor.NextCharacter): if is_space(QTextCursor.PreviousCharacter): return cursor.movePosition(QTextCursor.WordLeft) cursor.select(QTextCursor.WordUnderCursor) text = str(cursor.selectedText()) # find a valid python variable name match = re.findall(r"([^\d\W]\w*)", text, re.UNICODE) if match: return match[0] def get_current_line(self): """Return current line's text""" cursor = self.textCursor() cursor.select(QTextCursor.BlockUnderCursor) return str(cursor.selectedText()) def get_current_line_to_cursor(self): """Return text from prompt to cursor""" return self.get_text(self.current_prompt_pos, "cursor") def get_line_number_at(self, coordinates): """Return line number at *coordinates* (QPoint)""" cursor = self.cursorForPosition(coordinates) return cursor.blockNumber() - 1 def get_line_at(self, coordinates): """Return line at *coordinates* (QPoint)""" cursor = self.cursorForPosition(coordinates) cursor.select(QTextCursor.BlockUnderCursor) return str(cursor.selectedText()).replace("\u2029", "") def get_word_at(self, coordinates): """Return word at *coordinates* (QPoint)""" cursor = self.cursorForPosition(coordinates) cursor.select(QTextCursor.WordUnderCursor) return str(cursor.selectedText()) def get_block_indentation(self, block_nb): """Return line indentation (character number)""" text = str(self.document().findBlockByNumber(block_nb).text()) text = text.replace("\t", " " * self.tab_stop_width_spaces) return len(text) - len(text.lstrip()) def get_selection_bounds(self): """Return selection bounds (block numbers)""" cursor = self.textCursor() start, end = cursor.selectionStart(), cursor.selectionEnd() block_start = self.document().findBlock(start) block_end = self.document().findBlock(end) return sorted([block_start.blockNumber(), block_end.blockNumber()]) # ------Text selection def has_selected_text(self): """Returns True if some text is selected""" return bool(str(self.textCursor().selectedText())) def get_selected_text(self): """ Return text selected by current text cursor, converted in unicode Replace the unicode line separator character ``\u2029`` by the line separator characters returned by :py:meth:`get_line_separator` """ return str(self.textCursor().selectedText()).replace( "\u2029", self.get_line_separator() ) def remove_selected_text(self): """Delete selected text""" self.textCursor().removeSelectedText() def replace(self, text, pattern=None): """Replace selected text by *text* If *pattern* is not None, replacing selected text using regular expression text substitution""" cursor = self.textCursor() cursor.beginEditBlock() if pattern is not None: seltxt = str(cursor.selectedText()) cursor.removeSelectedText() if pattern is not None: text = re.sub(str(pattern), str(text), str(seltxt)) cursor.insertText(text) cursor.endEditBlock() # ------Find/replace def find_multiline_pattern(self, regexp, cursor, findflag): """Reimplement QTextDocument's find method Add support for *multiline* regular expressions""" pattern = str(regexp.pattern()) text = str(self.toPlainText()) try: regobj = re.compile(pattern) except re.error: return if findflag & QTextDocument.FindBackward: # Find backward offset = min([cursor.selectionEnd(), cursor.selectionStart()]) text = text[:offset] matches = [_m for _m in regobj.finditer(text, 0, offset)] if matches: match = matches[-1] else: return else: # Find forward offset = max([cursor.selectionEnd(), cursor.selectionStart()]) match = regobj.search(text, offset) if match: pos1, pos2 = match.span() fcursor = self.textCursor() fcursor.setPosition(pos1) fcursor.setPosition(pos2, QTextCursor.KeepAnchor) return fcursor def find_text( self, text, changed=True, forward=True, case=False, words=False, regexp=False ): """Find text""" cursor = self.textCursor() findflag = QTextDocument.FindFlag() if not forward: findflag = findflag | QTextDocument.FindBackward if case: findflag = findflag | QTextDocument.FindCaseSensitively moves = [QTextCursor.NoMove] if forward: moves += [QTextCursor.NextWord, QTextCursor.Start] if changed: if str(cursor.selectedText()): new_position = min([cursor.selectionStart(), cursor.selectionEnd()]) cursor.setPosition(new_position) else: cursor.movePosition(QTextCursor.PreviousWord) else: moves += [QTextCursor.End] if regexp: text = str(text) else: text = re.escape(str(text)) pattern = QRegularExpression("\\b{}\\b".format(text) if words else text) if case: pattern.setPatternOptions(QRegularExpression.CaseInsensitiveOption) for move in moves: cursor.movePosition(move) if regexp and "\\n" in text: # Multiline regular expression found_cursor = self.find_multiline_pattern(pattern, cursor, findflag) else: # Single line find: using the QTextDocument's find function, # probably much more efficient than ours found_cursor = self.document().find(pattern, cursor, findflag) if found_cursor is not None and not found_cursor.isNull(): self.setTextCursor(found_cursor) return True return False def is_editor(self): """Needs to be overloaded in the codeeditor where it will be True""" return False def get_number_matches(self, pattern, source_text="", case=False, regexp=False): """Get the number of matches for the searched text.""" pattern = str(pattern) if not pattern: return 0 if not regexp: pattern = re.escape(pattern) if not source_text: source_text = str(self.toPlainText()) try: if case: regobj = re.compile(pattern) else: regobj = re.compile(pattern, re.IGNORECASE) except re.error: return None number_matches = 0 for match in regobj.finditer(source_text): number_matches += 1 return number_matches def get_match_number(self, pattern, case=False, regexp=False): """Get number of the match for the searched text.""" position = self.textCursor().position() source_text = self.get_text(position_from="sof", position_to=position) match_number = self.get_number_matches( pattern, source_text=source_text, case=case, regexp=regexp ) return match_number class TracebackLinksMixin(object): """ """ QT_CLASS = None go_to_error = None def __init__(self): self.__cursor_changed = False self.setMouseTracking(True) # ------Mouse events def mouseReleaseEvent(self, event): """Go to error""" self.QT_CLASS.mouseReleaseEvent(self, event) text = self.get_line_at(event.pos()) if get_error_match(text) and not self.has_selected_text(): if self.go_to_error is not None: self.go_to_error.emit(text) def mouseMoveEvent(self, event): """Show Pointing Hand Cursor on error messages""" text = self.get_line_at(event.pos()) if get_error_match(text): if not self.__cursor_changed: QApplication.setOverrideCursor(QCursor(Qt.PointingHandCursor)) self.__cursor_changed = True event.accept() return if self.__cursor_changed: QApplication.restoreOverrideCursor() self.__cursor_changed = False self.QT_CLASS.mouseMoveEvent(self, event) def leaveEvent(self, event): """If cursor has not been restored yet, do it now""" if self.__cursor_changed: QApplication.restoreOverrideCursor() self.__cursor_changed = False self.QT_CLASS.leaveEvent(self, event) class GetHelpMixin(object): """ """ def __init__(self): self.help = None self.help_enabled = False def set_help(self, help_plugin): """Set Help DockWidget reference""" self.help = help_plugin def set_help_enabled(self, state): """ :param state: """ self.help_enabled = state def inspect_current_object(self): """ """ text = "" text1 = self.get_text("sol", "cursor") tl1 = re.findall(r"([a-zA-Z_]+[0-9a-zA-Z_\.]*)", text1) if tl1 and text1.endswith(tl1[-1]): text += tl1[-1] text2 = self.get_text("cursor", "eol") tl2 = re.findall(r"([0-9a-zA-Z_\.]+[0-9a-zA-Z_\.]*)", text2) if tl2 and text2.startswith(tl2[0]): text += tl2[0] if text: self.show_object_info(text, force=True) def show_object_info(self, text, call=False, force=False): """Show signature calltip and/or docstring in the Help plugin""" text = str(text) # Show docstring help_enabled = self.help_enabled or force if force and self.help is not None: self.help.dockwidget.setVisible(True) self.help.dockwidget.raise_() if ( help_enabled and (self.help is not None) and (self.help.dockwidget.isVisible()) ): # Help widget exists and is visible if hasattr(self, "get_doc"): self.help.set_shell(self) else: self.help.set_shell(self.parent()) self.help.set_object_text(text, ignore_unknown=False) self.setFocus() # if help was not at top level, raising it to # top will automatically give it focus because of # the visibility_changed signal, so we must give # focus back to shell # Show calltip if call and self.calltips: # Display argument list if this is a function call iscallable = self.iscallable(text) if iscallable is not None: if iscallable: arglist = self.get_arglist(text) name = text.split(".")[-1] argspec = signature = "" is_signature = True if isinstance(arglist, bool): arglist = [] if arglist: argspec = "(" + "".join(arglist) + ")" else: doc = self.get__doc__(text) if doc is not None: # This covers cases like np.abs, whose docstring is # the same as np.absolute and because of that a # proper signature can't be obtained correctly argspec = getargspecfromtext(doc) if not argspec: signature = getsignaturefromtext(doc, name) if not signature: signature = doc is_signature = False if argspec or signature: if argspec: tiptext = name + argspec else: tiptext = signature self.show_calltip( _("Arguments"), tiptext, signature=is_signature, color="#2D62FF", ) def get_last_obj(self, last=False): """ Return the last valid object on the current line """ return getobj(self.get_current_line_to_cursor(), last=last) class SaveHistoryMixin(object): """ """ INITHISTORY = None SEPARATOR = None HISTORY_FILENAMES = [] append_to_history = None def __init__(self, history_filename=""): self.history_filename = history_filename self.create_history_filename() def create_history_filename(self): """Create history_filename with INITHISTORY if it doesn't exist.""" if self.history_filename and not osp.isfile(self.history_filename): try: encoding.writelines(self.INITHISTORY, self.history_filename) except EnvironmentError: pass def add_to_history(self, command): """Add command to history""" command = str(command) if command in ["", "\n"] or command.startswith("Traceback"): return if command.endswith("\n"): command = command[:-1] self.histidx = None if len(self.history) > 0 and self.history[-1] == command: return self.history.append(command) text = os.linesep + command # When the first entry will be written in history file, # the separator will be append first: if self.history_filename not in self.HISTORY_FILENAMES: self.HISTORY_FILENAMES.append(self.history_filename) text = self.SEPARATOR + text # Needed to prevent errors when writing history to disk # See issue 6431 try: encoding.write(text, self.history_filename, mode="ab") except EnvironmentError: pass if self.append_to_history is not None: self.append_to_history.emit(self.history_filename, text) class BrowseHistoryMixin(object): """ """ def __init__(self): self.history = [] self.histidx = None self.hist_wholeline = False def clear_line(self): """Clear current line (without clearing console prompt)""" self.remove_text(self.current_prompt_pos, "eof") def browse_history(self, backward): """Browse history""" if self.is_cursor_before("eol") and self.hist_wholeline: self.hist_wholeline = False tocursor = self.get_current_line_to_cursor() text, self.histidx = self.find_in_history(tocursor, self.histidx, backward) if text is not None: if self.hist_wholeline: self.clear_line() self.insert_text(text) else: cursor_position = self.get_position("cursor") # Removing text from cursor to the end of the line self.remove_text("cursor", "eol") # Inserting history text self.insert_text(text) self.set_cursor_position(cursor_position) def find_in_history(self, tocursor, start_idx, backward): """Find text 'tocursor' in history, from index 'start_idx'""" if start_idx is None: start_idx = len(self.history) # Finding text in history step = -1 if backward else 1 idx = start_idx if len(tocursor) == 0 or self.hist_wholeline: idx += step if idx >= len(self.history) or len(self.history) == 0: return "", len(self.history) elif idx < 0: idx = 0 self.hist_wholeline = True return self.history[idx], idx else: for index in range(len(self.history)): idx = (start_idx + step * (index + 1)) % len(self.history) entry = self.history[idx] if entry.startswith(tocursor): return entry[len(tocursor) :], idx else: return None, start_idx def reset_search_pos(self): """Reset the position from which to search the history""" self.histidx = None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/shell.py0000644000175100017510000010003115114075001021062 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """Shell widgets: base, python and terminal""" # pylint: disable=C0103 # pylint: disable=R0903 # pylint: disable=R0911 # pylint: disable=R0201 import builtins import keyword import locale import os import os.path as osp import re import sys import time from qtpy.compat import getsavefilename from qtpy.QtCore import Property, QCoreApplication, Qt, QTimer, Signal, Slot from qtpy.QtGui import QKeyEvent, QTextCharFormat, QTextCursor from qtpy.QtWidgets import QApplication, QMenu, QToolTip from guidata.config import CONF, _ from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action, keybinding from guidata.utils import encoding from guidata.widgets.console.base import ConsoleBaseWidget from guidata.widgets.console.mixins import ( BrowseHistoryMixin, GetHelpMixin, SaveHistoryMixin, TracebackLinksMixin, ) DEBUG = False STDERR = sys.stderr def tuple2keyevent(past_event): """Convert tuple into a QKeyEvent instance""" return QKeyEvent(*past_event) def restore_keyevent(event): """ :param event: :return: """ if isinstance(event, tuple): _, key, modifiers, text, _, _ = event event = tuple2keyevent(event) else: text = event.text() modifiers = event.modifiers() key = event.key() ctrl = modifiers & Qt.ControlModifier shift = modifiers & Qt.ShiftModifier return event, text, key, ctrl, shift class ShellBaseWidget(ConsoleBaseWidget, SaveHistoryMixin, BrowseHistoryMixin): """ Shell base widget """ redirect_stdio = Signal(bool) sig_keyboard_interrupt = Signal() execute = Signal(str) append_to_history = Signal(str, str) def __init__(self, parent, profile=False, initial_message=None, read_only=False): """ `parent` : specifies the parent widget """ ConsoleBaseWidget.__init__(self, parent) SaveHistoryMixin.__init__(self) BrowseHistoryMixin.__init__(self) # Read-only console is not the more used case, but it may be useful in # some cases (e.g. when using the console as a log window) self.setReadOnly(read_only) self.historylog_filename = "history.log" # Prompt position: tuple (line, index) self.current_prompt_pos = None self.new_input_line = True # Context menu self.menu = None self.setup_context_menu() # Simple profiling test self.profile = profile # Buffer to increase performance of write/flush operations self.__buffer = [] if initial_message: self.__buffer.append(initial_message) self.__timestamp = 0.0 self.__flushtimer = QTimer(self) self.__flushtimer.setSingleShot(True) self.__flushtimer.timeout.connect(self.flush) # Give focus to widget self.setFocus() # Cursor width self.setCursorWidth(CONF.get("console", "cursor/width")) def toggle_wrap_mode(self, enable): """Enable/disable wrap mode""" self.set_wrap_mode("character" if enable else None) def set_font(self, font): """Set shell styles font""" self.setFont(font) self.set_pythonshell_font(font) cursor = self.textCursor() cursor.select(QTextCursor.Document) charformat = QTextCharFormat() charformat.setFontFamilies([font.family()]) charformat.setFontPointSize(font.pointSize()) cursor.mergeCharFormat(charformat) # ------ Context menu def setup_context_menu(self): """Setup shell context menu""" self.menu = QMenu(self) self.cut_action = create_action( self, _("Cut"), shortcut=keybinding("Cut"), icon=get_icon("editcut.png"), triggered=self.cut, ) self.copy_action = create_action( self, _("Copy"), shortcut=keybinding("Copy"), icon=get_icon("editcopy.png"), triggered=self.copy, ) paste_action = create_action( self, _("Paste"), shortcut=keybinding("Paste"), icon=get_icon("editpaste.png"), triggered=self.paste, ) save_action = create_action( self, _("Save history log..."), icon=get_icon("filesave.png"), tip=_( "Save current history log (i.e. all inputs and outputs) in a text file" ), triggered=self.save_historylog, ) self.delete_action = create_action( self, _("Delete"), shortcut=keybinding("Delete"), icon=get_icon("editdelete.png"), triggered=self.delete, ) selectall_action = create_action( self, _("Select All"), shortcut=keybinding("SelectAll"), icon=get_icon("selectall.png"), triggered=self.selectAll, ) add_actions( self.menu, ( self.cut_action, self.copy_action, paste_action, self.delete_action, None, selectall_action, None, save_action, ), ) def contextMenuEvent(self, event): """Reimplement Qt method""" state = self.has_selected_text() self.copy_action.setEnabled(state) self.cut_action.setEnabled(state) self.delete_action.setEnabled(state) self.menu.popup(event.globalPos()) event.accept() # ------ Input buffer def get_current_line_from_cursor(self): """ :return: """ return self.get_text("cursor", "eof") def _select_input(self): """Select current line (without selecting console prompt)""" line, index = self.get_position("eof") if self.current_prompt_pos is None: pline, pindex = line, index else: pline, pindex = self.current_prompt_pos self.setSelection(pline, pindex, line, index) @Slot() def clear_terminal(self): """ Clear terminal window Child classes reimplement this method to write prompt """ self.clear() # The buffer being edited def _set_input_buffer(self, text): """Set input buffer""" if self.current_prompt_pos is not None: self.replace_text(self.current_prompt_pos, "eol", text) else: self.insert(text) self.set_cursor_position("eof") def _get_input_buffer(self): """Return input buffer""" input_buffer = "" if self.current_prompt_pos is not None: input_buffer = self.get_text(self.current_prompt_pos, "eol") input_buffer = input_buffer.replace(os.linesep, "\n") return input_buffer input_buffer = Property("QString", _get_input_buffer, _set_input_buffer) # ------ Prompt def new_prompt(self, prompt): """ Print a new prompt and save its (line, index) position """ if self.get_cursor_line_column()[1] != 0: self.write("\n") self.write(prompt, prompt=True) # now we update our cursor giving end of prompt self.current_prompt_pos = self.get_position("cursor") self.ensureCursorVisible() self.new_input_line = False def check_selection(self): """ Check if selected text is r/w, otherwise remove read-only parts of selection """ if self.current_prompt_pos is None: self.set_cursor_position("eof") else: self.truncate_selection(self.current_prompt_pos) # ------ Copy / Keyboard interrupt @Slot() def copy(self): """Copy text to clipboard... or keyboard interrupt""" if self.has_selected_text(): ConsoleBaseWidget.copy(self) elif not sys.platform == "darwin": self.interrupt() def interrupt(self): """Keyboard interrupt""" self.sig_keyboard_interrupt.emit() @Slot() def cut(self): """Cut text""" self.check_selection() if self.has_selected_text(): ConsoleBaseWidget.cut(self) @Slot() def delete(self): """Remove selected text""" self.check_selection() if self.has_selected_text(): ConsoleBaseWidget.remove_selected_text(self) @Slot() def save_historylog(self): """Save current history log (all text in console)""" title = _("Save history log") self.redirect_stdio.emit(False) filename, _selfilter = getsavefilename( self, title, self.historylog_filename, "%s (*.log)" % _("History logs") ) self.redirect_stdio.emit(True) if filename: filename = osp.normpath(filename) try: encoding.write(str(self.get_text_with_eol()), filename) self.historylog_filename = filename except EnvironmentError: pass # ------ Basic keypress event handler def on_enter(self, command): """on_enter""" self.execute_command(command) def execute_command(self, command): """ :param command: """ self.execute.emit(command) self.add_to_history(command) self.new_input_line = True def on_new_line(self): """On new input line""" self.set_cursor_position("eof") self.current_prompt_pos = self.get_position("cursor") self.new_input_line = False @Slot() def paste(self): """Reimplemented slot to handle multiline paste action""" if self.new_input_line: self.on_new_line() ConsoleBaseWidget.paste(self) def keyPressEvent(self, event): """ Reimplement Qt Method Basic keypress event handler (reimplemented in InternalShell to add more sophisticated features) """ if self.preprocess_keyevent(event): # Event was accepted in self.preprocess_keyevent return self.postprocess_keyevent(event) def preprocess_keyevent(self, event): """Pre-process keypress event: return True if event is accepted, false otherwise""" # Copy must be done first to be able to copy read-only text parts # (otherwise, right below, we would remove selection # if not on current line) ctrl = event.modifiers() & Qt.ControlModifier meta = event.modifiers() & Qt.MetaModifier # meta=ctrl in OSX if event.key() == Qt.Key_C and ( (Qt.MetaModifier | Qt.ControlModifier) & event.modifiers() ): if meta and sys.platform == "darwin": self.interrupt() elif ctrl: self.copy() event.accept() return True if self.new_input_line and ( len(event.text()) or event.key() in (Qt.Key_Up, Qt.Key_Down, Qt.Key_Left, Qt.Key_Right) ): self.on_new_line() return False def postprocess_keyevent(self, event): """Post-process keypress event: in InternalShell, this is method is called when shell is ready""" event, text, key, ctrl, shift = restore_keyevent(event) # Is cursor on the last line? and after prompt? if len(text): # XXX: Shouldn't it be: `if len(unicode(text).strip(os.linesep))` ? if self.has_selected_text(): self.check_selection() self.restrict_cursor_position(self.current_prompt_pos, "eof") cursor_position = self.get_position("cursor") if key in (Qt.Key_Return, Qt.Key_Enter): if self.is_cursor_on_last_line(): self._key_enter() # add and run selection else: self.insert_text(self.get_selected_text(), at_end=True) elif key == Qt.Key_Insert and not shift and not ctrl: self.setOverwriteMode(not self.overwriteMode()) elif key == Qt.Key_Delete: if self.has_selected_text(): self.check_selection() self.remove_selected_text() elif self.is_cursor_on_last_line(): self.stdkey_clear() elif key == Qt.Key_Backspace: self._key_backspace(cursor_position) elif key == Qt.Key_Tab: self._key_tab() elif key == Qt.Key_Space and ctrl: self._key_ctrl_space() elif key == Qt.Key_Left: if self.current_prompt_pos == cursor_position: # Avoid moving cursor on prompt return method = ( self.extend_selection_to_next if shift else self.move_cursor_to_next ) method("word" if ctrl else "character", direction="left") elif key == Qt.Key_Right: if self.is_cursor_at_end(): return method = ( self.extend_selection_to_next if shift else self.move_cursor_to_next ) method("word" if ctrl else "character", direction="right") elif (key == Qt.Key_Home) or ((key == Qt.Key_Up) and ctrl): self._key_home(shift, ctrl) elif (key == Qt.Key_End) or ((key == Qt.Key_Down) and ctrl): self._key_end(shift, ctrl) elif key == Qt.Key_Up: if not self.is_cursor_on_last_line(): self.set_cursor_position("eof") y_cursor = self.get_coordinates(cursor_position)[1] y_prompt = self.get_coordinates(self.current_prompt_pos)[1] if y_cursor > y_prompt: self.stdkey_up(shift) else: self.browse_history(backward=True) elif key == Qt.Key_Down: if not self.is_cursor_on_last_line(): self.set_cursor_position("eof") y_cursor = self.get_coordinates(cursor_position)[1] y_end = self.get_coordinates("eol")[1] if y_cursor < y_end: self.stdkey_down(shift) else: self.browse_history(backward=False) elif key in (Qt.Key_PageUp, Qt.Key_PageDown): # XXX: Find a way to do this programmatically instead of calling # widget keyhandler (this won't work if the *event* is coming from # the event queue - i.e. if the busy buffer is ever implemented) ConsoleBaseWidget.keyPressEvent(self, event) elif key == Qt.Key_Escape and shift: self.clear_line() elif key == Qt.Key_Escape: self._key_escape() elif key == Qt.Key_L and ctrl: self.clear_terminal() elif key == Qt.Key_V and ctrl: self.paste() elif key == Qt.Key_X and ctrl: self.cut() elif key == Qt.Key_Z and ctrl: self.undo() elif key == Qt.Key_Y and ctrl: self.redo() elif key == Qt.Key_A and ctrl: self.selectAll() elif key == Qt.Key_Question and not self.has_selected_text(): self._key_question(text) elif key == Qt.Key_ParenLeft and not self.has_selected_text(): self._key_parenleft(text) elif key == Qt.Key_Period and not self.has_selected_text(): self._key_period(text) elif len(text) and not self.isReadOnly(): self.hist_wholeline = False self.insert_text(text) self._key_other(text) else: # Let the parent widget handle the key press event ConsoleBaseWidget.keyPressEvent(self, event) # ------ Key handlers def _key_enter(self): command = self.input_buffer self.insert_text("\n", at_end=True) self.on_enter(command) self.flush() def _key_other(self, text): raise NotImplementedError def _key_backspace(self, cursor_position): raise NotImplementedError def _key_tab(self): raise NotImplementedError def _key_ctrl_space(self): raise NotImplementedError def _key_home(self, shift, ctrl): if self.is_cursor_on_last_line(): self.stdkey_home(shift, ctrl, self.current_prompt_pos) def _key_end(self, shift, ctrl): if self.is_cursor_on_last_line(): self.stdkey_end(shift, ctrl) def _key_pageup(self): raise NotImplementedError def _key_pagedown(self): raise NotImplementedError def _key_escape(self): raise NotImplementedError def _key_question(self, text): raise NotImplementedError def _key_parenleft(self, text): raise NotImplementedError def _key_period(self, text): raise NotImplementedError # ------ History Management def load_history(self): """Load history from a .py file in user home directory""" if osp.isfile(self.history_filename): rawhistory, _ = encoding.readlines(self.history_filename) rawhistory = [line.replace("\n", "") for line in rawhistory] if rawhistory[1] != self.INITHISTORY[1]: rawhistory[1] = self.INITHISTORY[1] else: rawhistory = self.INITHISTORY history = [line for line in rawhistory if line and not line.startswith("#")] # Truncating history to X entries: while len(history) >= CONF.get("historylog", "max_entries"): del history[0] while rawhistory[0].startswith("#"): del rawhistory[0] del rawhistory[0] # Saving truncated history: try: encoding.writelines(rawhistory, self.history_filename) except EnvironmentError: pass return history # ------ Simulation standards input/output def write_error(self, text): """Simulate stderr""" self.flush() self.write(text, flush=True, error=True) if DEBUG: STDERR.write(text) def write(self, text, flush=False, error=False, prompt=False): """Simulate stdout and stderr""" if prompt: self.flush() if not isinstance(text, str): # This test is useful to discriminate QStrings from decoded str text = str(text) self.__buffer.append(text) ts = time.time() if flush or prompt: self.flush(error=error, prompt=prompt) elif ts - self.__timestamp > 0.05: self.flush(error=error) self.__timestamp = ts # Timer to flush strings cached by last write() operation in series self.__flushtimer.start(50) def flush(self, error=False, prompt=False): """Flush buffer, write text to console""" # Fix for Spyder Issue #2452 try: text = "".join(self.__buffer) except TypeError: text = b"".join(self.__buffer) try: text = text.decode(locale.getpreferredencoding()) except UnicodeDecodeError: pass self.__buffer = [] try: self.insert_text(text, at_end=True, error=error, prompt=prompt) QCoreApplication.processEvents() self.repaint() # Clear input buffer: self.new_input_line = True except RuntimeError: # RuntimeError: wrapped C/C++ object of type QTextCursor has been deleted # # (This happens when the shell is closed while a thread is writing. # For example, when closing host application with an active logging # connected to the shell) pass # ------ Text Insertion def insert_text(self, text, at_end=False, error=False, prompt=False): """ Insert text at the current cursor position or at the end of the command line """ if at_end: # Insert text at the end of the command line self.append_text_to_shell(text, error, prompt) else: # Insert text at current cursor position ConsoleBaseWidget.insert_text(self, text) # ------ Re-implemented Qt Methods def focusNextPrevChild(self, next): """ Reimplemented to stop Tab moving to the next window """ if next: return False return ConsoleBaseWidget.focusNextPrevChild(self, next) # ------ Drag and drop def dragEnterEvent(self, event): """Drag and Drop - Enter event""" event.setAccepted(event.mimeData().hasFormat("text/plain")) def dragMoveEvent(self, event): """Drag and Drop - Move event""" if event.mimeData().hasFormat("text/plain"): event.setDropAction(Qt.MoveAction) event.accept() else: event.ignore() def dropEvent(self, event): """Drag and Drop - Drop event""" if event.mimeData().hasFormat("text/plain"): text = str(event.mimeData().text()) if self.new_input_line: self.on_new_line() self.insert_text(text, at_end=True) self.setFocus() event.setDropAction(Qt.MoveAction) event.accept() else: event.ignore() def drop_pathlist(self, pathlist): """Drop path list""" raise NotImplementedError # Example how to debug complex interclass call chains: # # from spyder.utils.debug import log_methods_calls # log_methods_calls('log.log', ShellBaseWidget) class PythonShellWidget(TracebackLinksMixin, ShellBaseWidget, GetHelpMixin): """Python shell widget""" QT_CLASS = ShellBaseWidget INITHISTORY = [ "# -*- coding: utf-8 -*-", "# *** Spyder Python Console History Log ***", ] SEPARATOR = "%s##---(%s)---" % (os.linesep * 2, time.ctime()) go_to_error = Signal(str) def __init__(self, parent, profile=False, initial_message=None, read_only=False): ShellBaseWidget.__init__(self, parent, profile, initial_message, read_only) TracebackLinksMixin.__init__(self) GetHelpMixin.__init__(self) # ------ Context menu def setup_context_menu(self): """Reimplements ShellBaseWidget method""" ShellBaseWidget.setup_context_menu(self) self.copy_without_prompts_action = create_action( self, _("Copy without prompts"), icon=get_icon("copywop.png"), triggered=self.copy_without_prompts, ) actions = [self.copy_without_prompts_action] if not self.isReadOnly(): clear_line_action = create_action( self, _("Clear line"), icon=get_icon("editdelete.png"), tip=_("Clear line"), triggered=self.clear_line, ) clear_action = create_action( self, _("Clear shell"), icon=get_icon("editclear.png"), tip=_("Clear shell contents ('cls' command)"), triggered=self.clear_terminal, ) actions += [clear_line_action, clear_action] add_actions(self.menu, actions) def contextMenuEvent(self, event): """Reimplements ShellBaseWidget method""" state = self.has_selected_text() self.copy_without_prompts_action.setEnabled(state) ShellBaseWidget.contextMenuEvent(self, event) @Slot() def copy_without_prompts(self): """Copy text to clipboard without prompts""" text = self.get_selected_text() lines = text.split(os.linesep) for index, line in enumerate(lines): if line.startswith(">>> ") or line.startswith("... "): lines[index] = line[4:] text = os.linesep.join(lines) QApplication.clipboard().setText(text) # ------ Key handlers def postprocess_keyevent(self, event): """Process keypress event""" ShellBaseWidget.postprocess_keyevent(self, event) if QToolTip.isVisible(): _event, _text, key, _ctrl, _shift = restore_keyevent(event) self.hide_tooltip_if_necessary(key) def _key_other(self, text): """1 character key""" if self.is_completion_widget_visible(): self.completion_text += text def _key_backspace(self, cursor_position): """Action for Backspace key""" if self.has_selected_text(): self.check_selection() self.remove_selected_text() elif self.current_prompt_pos == cursor_position: # Avoid deleting prompt return elif self.is_cursor_on_last_line(): self.stdkey_backspace() if self.is_completion_widget_visible(): # Removing only last character because if there was a selection # the completion widget would have been canceled self.completion_text = self.completion_text[:-1] def _key_tab(self): """Action for TAB key""" if self.is_cursor_on_last_line(): empty_line = not self.get_current_line_to_cursor().strip() if empty_line: self.stdkey_tab() else: self.show_code_completion(automatic=False) def _key_ctrl_space(self): """Action for Ctrl+Space""" if not self.is_completion_widget_visible(): self.show_code_completion(automatic=False) def _key_pageup(self): """Action for PageUp key""" pass def _key_pagedown(self): """Action for PageDown key""" pass def _key_escape(self): """Action for ESCAPE key""" if self.is_completion_widget_visible(): self.hide_completion_widget() def _key_question(self, text): """Action for '?'""" if self.get_current_line_to_cursor(): last_obj = self.get_last_obj() if last_obj and not last_obj.isdigit(): self.show_object_info(last_obj) self.insert_text(text) # In case calltip and completion are shown at the same time: if self.is_completion_widget_visible(): self.completion_text += "?" def _key_parenleft(self, text): """Action for '('""" self.hide_completion_widget() if self.get_current_line_to_cursor(): last_obj = self.get_last_obj() if last_obj and not last_obj.isdigit(): self.insert_text(text) self.show_object_info(last_obj, call=True) return self.insert_text(text) def _key_period(self, text): """Action for '.'""" self.insert_text(text) if self.codecompletion_auto: # Enable auto-completion only if last token isn't a float last_obj = self.get_last_obj() if last_obj and not last_obj.isdigit(): self.show_code_completion(automatic=True) # ------ Paste def paste(self): """Reimplemented slot to handle multiline paste action""" text = str(QApplication.clipboard().text()) if len(text.splitlines()) > 1: # Multiline paste if self.new_input_line: self.on_new_line() self.remove_selected_text() # Remove selection, eventually end = self.get_current_line_from_cursor() lines = self.get_current_line_to_cursor() + text + end self.clear_line() self.execute_lines(lines) self.move_cursor(-len(end)) else: # Standard paste ShellBaseWidget.paste(self) # ------ Code Completion / Calltips # Methods implemented in child class: # (e.g. InternalShell) def get_dir(self, objtxt): """Return dir(object)""" raise NotImplementedError def get_module_completion(self, objtxt): """Return module completion list associated to object name""" pass def get_globals_keys(self): """Return shell globals() keys""" raise NotImplementedError def get_cdlistdir(self): """Return shell current directory list dir""" raise NotImplementedError def iscallable(self, objtxt): """Is object callable?""" raise NotImplementedError def get_arglist(self, objtxt): """Get func/method argument list""" raise NotImplementedError def get__doc__(self, objtxt): """Get object __doc__""" raise NotImplementedError def get_doc(self, objtxt): """Get object documentation dictionary""" raise NotImplementedError def get_source(self, objtxt): """Get object source""" raise NotImplementedError def is_defined(self, objtxt, force_import=False): """Return True if object is defined""" raise NotImplementedError def show_code_completion(self, automatic): """Display a completion list based on the current line""" # Note: unicode conversion is needed only for ExternalShellBase text = str(self.get_current_line_to_cursor()) last_obj = self.get_last_obj() if not text: return if text.startswith("import "): # pylint: disable=assignment-from-no-return obj_list = self.get_module_completion(text) words = text.split(" ") if "," in words[-1]: words = words[-1].split(",") self.show_completion_list( obj_list, completion_text=words[-1], automatic=automatic ) return elif text.startswith("from "): # pylint: disable=assignment-from-no-return obj_list = self.get_module_completion(text) if obj_list is None: return words = text.split(" ") if "(" in words[-1]: words = words[:-2] + words[-1].split("(") if "," in words[-1]: words = words[:-2] + words[-1].split(",") self.show_completion_list( obj_list, completion_text=words[-1], automatic=automatic ) return obj_dir = self.get_dir(last_obj) if last_obj and obj_dir and text.endswith("."): self.show_completion_list(obj_dir, automatic=automatic) return # Builtins and globals if ( not text.endswith(".") and last_obj and re.match(r"[a-zA-Z_0-9]*$", last_obj) ): b_k_g = dir(builtins) + self.get_globals_keys() + keyword.kwlist for objname in b_k_g: if objname.startswith(last_obj) and objname != last_obj: self.show_completion_list( b_k_g, completion_text=last_obj, automatic=automatic ) return else: return # Looking for an incomplete completion if last_obj is None: last_obj = text dot_pos = last_obj.rfind(".") if dot_pos != -1: if dot_pos == len(last_obj) - 1: completion_text = "" else: completion_text = last_obj[dot_pos + 1 :] last_obj = last_obj[:dot_pos] completions = self.get_dir(last_obj) if completions is not None: self.show_completion_list( completions, completion_text=completion_text, automatic=automatic ) return # Looking for ' or ": filename completion q_pos = max([text.rfind("'"), text.rfind('"')]) if q_pos != -1: completions = self.get_cdlistdir() if completions: self.show_completion_list( completions, completion_text=text[q_pos + 1 :], automatic=automatic ) return # ------ Drag'n Drop def drop_pathlist(self, pathlist): """Drop path list""" if pathlist: files = ["r'%s'" % path for path in pathlist] if len(files) == 1: text = files[0] else: text = "[" + ", ".join(files) + "]" if self.new_input_line: self.on_new_line() self.insert_text(text) self.setFocus() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/console/terminal.py0000644000175100017510000000757515114075001021611 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """Terminal emulation tools""" import os class ANSIEscapeCodeHandler(object): """ANSI Escape sequences handler""" if os.name == "nt": # Windows terminal colors: ANSI_COLORS = ( # Normal, Bright/Light ("#000000", "#808080"), # 0: black ("#800000", "#ff0000"), # 1: red ("#008000", "#00ff00"), # 2: green ("#808000", "#ffff00"), # 3: yellow ("#000080", "#0000ff"), # 4: blue ("#800080", "#ff00ff"), # 5: magenta ("#008080", "#00ffff"), # 6: cyan ("#c0c0c0", "#ffffff"), # 7: white ) elif os.name == "mac": # Terminal.app colors: ANSI_COLORS = ( # Normal, Bright/Light ("#000000", "#818383"), # 0: black ("#C23621", "#FC391F"), # 1: red ("#25BC24", "#25BC24"), # 2: green ("#ADAD27", "#EAEC23"), # 3: yellow ("#492EE1", "#5833FF"), # 4: blue ("#D338D3", "#F935F8"), # 5: magenta ("#33BBC8", "#14F0F0"), # 6: cyan ("#CBCCCD", "#E9EBEB"), # 7: white ) else: # xterm colors: ANSI_COLORS = ( # Normal, Bright/Light ("#000000", "#7F7F7F"), # 0: black ("#CD0000", "#ff0000"), # 1: red ("#00CD00", "#00ff00"), # 2: green ("#CDCD00", "#ffff00"), # 3: yellow ("#0000EE", "#5C5CFF"), # 4: blue ("#CD00CD", "#ff00ff"), # 5: magenta ("#00CDCD", "#00ffff"), # 6: cyan ("#E5E5E5", "#ffffff"), # 7: white ) def __init__(self): self.intensity = 0 self.italic = None self.bold = None self.underline = None self.foreground_color = None self.background_color = None self.default_foreground_color = 30 self.default_background_color = 47 def set_code(self, code): """ :param code: """ assert isinstance(code, int) if code == 0: # Reset all settings self.reset() elif code == 1: # Text color intensity self.intensity = 1 # The following line is commented because most terminals won't # change the font weight, against ANSI standard recommendation: # self.bold = True elif code == 3: # Italic on self.italic = True elif code == 4: # Underline simple self.underline = True elif code == 22: # Normal text color intensity self.intensity = 0 self.bold = False elif code == 23: # No italic self.italic = False elif code == 24: # No underline self.underline = False elif code >= 30 and code <= 37: # Text color self.foreground_color = code elif code == 39: # Default text color self.foreground_color = self.default_foreground_color elif code >= 40 and code <= 47: # Background color self.background_color = code elif code == 49: # Default background color self.background_color = self.default_background_color self.set_style() def set_style(self): """ Set font style with the following attributes: 'foreground_color', 'background_color', 'italic', 'bold' and 'underline' """ raise NotImplementedError def reset(self): """ """ self.current_format = None self.intensity = 0 self.italic = False self.bold = False self.underline = False self.foreground_color = None self.background_color = None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/dataframeeditor.py0000644000175100017510000010204415114075001021452 0ustar00runnerrunner# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2011-2012 Lambda Foundry, Inc. and PyData Development Team # Copyright (c) 2013 Jev Kuznetsov and contributors # Copyright (c) 2014- Spyder Project Contributors # # Distributed under the terms of the New BSD License # (BSD 3-clause; see NOTICE.txt in the Spyder root directory for details). # ----------------------------------------------------------------------------- # ruff: noqa """ guidata.widgets.dataframeeditor =============================== This package provides a DataFrameModel based on the class ArrayModel from array editor and the class DataFrameModel from the pandas project. Present in pandas.sandbox.qtpandas in v0.13.1. .. autoclass:: DataFrameEditor :show-inheritance: :members: Originally based on pandas/sandbox/qtpandas.py of the `pandas project `_. The current version is qtpandas/models/DataFrameModel.py of the `QtPandas project `_. """ import io from qtpy.compat import getsavefilename import numpy as np from pandas import DataFrame, DatetimeIndex, Series from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot from qtpy.QtGui import QColor, QCursor, QKeySequence from qtpy.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QDialog, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLineEdit, QMenu, QMessageBox, QPushButton, QShortcut, QTableView, ) from guidata.config import CONF, _ from guidata.configtools import get_font, get_icon from guidata.qthelpers import ( add_actions, create_action, keybinding, win32_fix_title_bar_background, ) from guidata.widgets.arrayeditor.utils import get_idx_rect try: from pandas._libs.tslib import OutOfBoundsDatetime except ImportError: # For pandas version < 0.20 from pandas.tslib import OutOfBoundsDatetime # Supported Numbers and complex numbers REAL_NUMBER_TYPES = (float, int, np.int64, np.int32) COMPLEX_NUMBER_TYPES = (complex, np.complex64, np.complex128) # Used to convert bool intrance to false since bool('False') will return True _bool_false = ["false", "f", "0", "0.", "0.0", " "] # Default format for data frames with floats DEFAULT_FORMAT = "%.6g" # Limit at which dataframe is considered so large that it is loaded on demand LARGE_SIZE = 5e5 LARGE_NROWS = 1e5 LARGE_COLS = 60 # Background colours BACKGROUND_NUMBER_MINHUE = 0.66 # hue for largest number BACKGROUND_NUMBER_HUERANGE = 0.33 # (hue for smallest) minus (hue for largest) BACKGROUND_NUMBER_SATURATION = 0.7 BACKGROUND_NUMBER_VALUE = 1.0 BACKGROUND_NUMBER_ALPHA = 0.6 BACKGROUND_NONNUMBER_COLOR = Qt.lightGray BACKGROUND_INDEX_ALPHA = 0.8 BACKGROUND_STRING_ALPHA = 0.05 BACKGROUND_MISC_ALPHA = 0.3 def bool_false_check(value): """ Used to convert bool intrance to false since any string in bool('') will return True """ if value.lower() in _bool_false: value = "" return value def global_max(col_vals, index): """Returns the global maximum and minimum""" col_vals_without_None = [x for x in col_vals if x is not None] max_col, min_col = zip(*col_vals_without_None) return max(max_col), min(min_col) class DataFrameModel(QAbstractTableModel): """DataFrame Table Model""" ROWS_TO_LOAD = 500 COLS_TO_LOAD = 40 def __init__(self, dataFrame, format=DEFAULT_FORMAT, parent=None, readonly=False): QAbstractTableModel.__init__(self) self.dialog = parent self.readonly = readonly self.df = dataFrame self.df_index = dataFrame.index.tolist() self.df_header = dataFrame.columns.tolist() self._format = format self.complex_intran = None self.display_error_idxs = [] self.total_rows = self.df.shape[0] self.total_cols = self.df.shape[1] size = self.total_rows * self.total_cols self.max_min_col = None if size < LARGE_SIZE: self.max_min_col_update() self.colum_avg_enabled = True self.bgcolor_enabled = True self.colum_avg(1) else: self.colum_avg_enabled = False self.bgcolor_enabled = False self.colum_avg(0) # Use paging when the total size, number of rows or number of # columns is too large if size > LARGE_SIZE: self.rows_loaded = self.ROWS_TO_LOAD self.cols_loaded = self.COLS_TO_LOAD else: if self.total_rows > LARGE_NROWS: self.rows_loaded = self.ROWS_TO_LOAD else: self.rows_loaded = self.total_rows if self.total_cols > LARGE_COLS: self.cols_loaded = self.COLS_TO_LOAD else: self.cols_loaded = self.total_cols def max_min_col_update(self): """ Determines the maximum and minimum number in each column. The result is a list whose k-th entry is [vmax, vmin], where vmax and vmin denote the maximum and minimum of the k-th column (ignoring NaN). This list is stored in self.max_min_col. If the k-th column has a non-numerical dtype, then the k-th entry is set to None. If the dtype is complex, then compute the maximum and minimum of the absolute values. If vmax equals vmin, then vmin is decreased by one. """ if self.df.shape[0] == 0: # If no rows to compute max/min then return return self.max_min_col = [] for dummy, col in self.df.items(): if col.dtype in REAL_NUMBER_TYPES + COMPLEX_NUMBER_TYPES: if col.dtype in REAL_NUMBER_TYPES: vmax = col.max(skipna=True) vmin = col.min(skipna=True) else: vmax = col.abs().max(skipna=True) vmin = col.abs().min(skipna=True) if vmax != vmin: max_min = [vmax, vmin] else: max_min = [vmax, vmin - 1] else: max_min = None self.max_min_col.append(max_min) def get_format(self): """Return current format""" # Avoid accessing the private attribute _format from outside return self._format def set_format(self, format): """Change display format""" self._format = format self.reset() def bgcolor(self, state): """Toggle backgroundcolor""" self.bgcolor_enabled = state > 0 self.reset() def colum_avg(self, state): """Toggle backgroundcolor""" self.colum_avg_enabled = state > 0 if self.colum_avg_enabled: self.return_max = lambda col_vals, index: col_vals[index] else: self.return_max = global_max self.reset() def headerData(self, section, orientation, role=Qt.DisplayRole): """Set header data""" if role != Qt.DisplayRole: return None if orientation == Qt.Horizontal: if section == 0: return "Index" elif type(self.df_header[section - 1]) in (bytes, str): # Don't perform any conversion on strings because it # leads to differences between the data present in # the dataframe and what is shown by Spyder return self.df_header[section - 1] else: return str(self.df_header[section - 1]) else: return None def get_bgcolor(self, index): """Background color depending on value""" column = index.column() if column == 0: color = QColor(BACKGROUND_NONNUMBER_COLOR) color.setAlphaF(BACKGROUND_INDEX_ALPHA) return color if not self.bgcolor_enabled: return value = self.get_value(index.row(), column - 1) if self.max_min_col[column - 1] is None: color = QColor(BACKGROUND_NONNUMBER_COLOR) if isinstance(value, str): color.setAlphaF(BACKGROUND_STRING_ALPHA) else: color.setAlphaF(BACKGROUND_MISC_ALPHA) else: if isinstance(value, COMPLEX_NUMBER_TYPES): color_func = abs else: color_func = float vmax, vmin = self.return_max(self.max_min_col, column - 1) hue = BACKGROUND_NUMBER_MINHUE + BACKGROUND_NUMBER_HUERANGE * ( vmax - color_func(value) ) / (vmax - vmin) hue = float(abs(hue)) if hue > 1: hue = 1 color = QColor.fromHsvF( hue, BACKGROUND_NUMBER_SATURATION, BACKGROUND_NUMBER_VALUE, BACKGROUND_NUMBER_ALPHA, ) return color def get_value(self, row, column): """Returns the value of the DataFrame""" # To increase the performance iat is used but that requires error # handling, so fallback uses iloc try: value = self.df.iat[row, column] except OutOfBoundsDatetime: value = self.df.iloc[:, column].astype(str).iat[row] except: value = self.df.iloc[row, column] return value def update_df_index(self): """ "Update the DataFrame index""" self.df_index = self.df.index.tolist() def data(self, index, role=Qt.DisplayRole): """Cell content""" if not index.isValid(): return None if role == Qt.DisplayRole or role == Qt.EditRole: column = index.column() row = index.row() if column == 0: df_idx = self.df_index[row] if type(df_idx) in (bytes, str): # Don't perform any conversion on strings # because it leads to differences between # the data present in the dataframe and # what is shown by Spyder return df_idx else: return str(df_idx) else: value = self.get_value(row, column - 1) if isinstance(value, float): try: return self._format % value except (ValueError, TypeError): # may happen if format = '%d' and value = NaN; # see issue 4139 return DEFAULT_FORMAT % value elif type(value) in (bytes, str): # Don't perform any conversion on strings # because it leads to differences between # the data present in the dataframe and # what is shown by Spyder return value else: try: return str(value) except Exception: self.display_error_idxs.append(index) return "Display Error!" elif role == Qt.BackgroundColorRole: return self.get_bgcolor(index) elif role == Qt.FontRole: return get_font(CONF, "arrayeditor", "font") elif role == Qt.ToolTipRole: if index in self.display_error_idxs: return _( "It is not possible to display this value because\n" "an error ocurred while trying to do it" ) return None def sort(self, column, order=Qt.AscendingOrder): """Overriding sort method""" if self.complex_intran is not None: if self.complex_intran.any(axis=0).iloc[column - 1]: QMessageBox.critical( self.dialog, "Error", "TypeError error: no ordering " "relation is defined for complex numbers", ) return False try: ascending = order == Qt.AscendingOrder if column > 0: try: self.df.sort_values( by=self.df.columns[column - 1], ascending=ascending, inplace=True, kind="mergesort", ) except AttributeError: # for pandas version < 0.17 self.df.sort( columns=self.df.columns[column - 1], ascending=ascending, inplace=True, kind="mergesort", ) except ValueError as e: # Not possible to sort on duplicate columns #5225 QMessageBox.critical( self.dialog, "Error", "ValueError: %s" % str(e) ) except SystemError as e: # Not possible to sort on category dtypes #5361 QMessageBox.critical( self.dialog, "Error", "SystemError: %s" % str(e) ) self.update_df_index() else: self.df.sort_index(inplace=True, ascending=ascending) self.update_df_index() except TypeError as e: QMessageBox.critical(self.dialog, "Error", "TypeError error: %s" % str(e)) return False self.reset() return True def flags(self, index): """Set flags""" if index.column() == 0: return Qt.ItemIsEnabled | Qt.ItemIsSelectable return Qt.ItemFlags(QAbstractTableModel.flags(self, index) | Qt.ItemIsEditable) def setData(self, index, value, role=Qt.EditRole, change_type=None): """Cell content change""" column = index.column() row = index.row() if index in self.display_error_idxs or self.readonly: return False if change_type is not None: try: val = self.data(index, role=Qt.DisplayRole) if change_type is bool: val = bool_false_check(val) self.df.iloc[row, column - 1] = change_type(val) except ValueError: self.df.iloc[row, column - 1] = change_type("0") else: val = value current_value = self.get_value(row, column - 1) if isinstance(current_value, (bool, np.bool_)): val = bool_false_check(val) supported_types = (bool, np.bool_) + REAL_NUMBER_TYPES if isinstance(current_value, supported_types) or isinstance( current_value, str ): try: self.df.iloc[row, column - 1] = current_value.__class__(val) except (ValueError, OverflowError) as e: QMessageBox.critical( self.dialog, "Error", str(type(e).__name__) + ": " + str(e) ) return False else: QMessageBox.critical( self.dialog, "Error", "Editing dtype {0!s} not yet supported.".format( type(current_value).__name__ ), ) return False self.max_min_col_update() self.dataChanged.emit(index, index) return True def get_data(self): """Return data""" return self.df def rowCount(self, index=QModelIndex()): """DataFrame row number""" if self.total_rows <= self.rows_loaded: return self.total_rows else: return self.rows_loaded def can_fetch_more(self, rows=False, columns=False): """ :param rows: :param columns: :return: """ if rows: if self.total_rows > self.rows_loaded: return True else: return False if columns: if self.total_cols > self.cols_loaded: return True else: return False def fetch_more(self, rows=False, columns=False): """ :param rows: :param columns: """ if self.can_fetch_more(rows=rows): reminder = self.total_rows - self.rows_loaded items_to_fetch = min(reminder, self.ROWS_TO_LOAD) self.beginInsertRows( QModelIndex(), self.rows_loaded, self.rows_loaded + items_to_fetch - 1 ) self.rows_loaded += items_to_fetch self.endInsertRows() if self.can_fetch_more(columns=columns): reminder = self.total_cols - self.cols_loaded items_to_fetch = min(reminder, self.COLS_TO_LOAD) self.beginInsertColumns( QModelIndex(), self.cols_loaded, self.cols_loaded + items_to_fetch - 1 ) self.cols_loaded += items_to_fetch self.endInsertColumns() def columnCount(self, index=QModelIndex()): """DataFrame column number""" # This is done to implement series if len(self.df.shape) == 1: return 2 elif self.total_cols <= self.cols_loaded: return self.total_cols + 1 else: return self.cols_loaded + 1 def reset(self): """ """ self.beginResetModel() self.endResetModel() class FrozenTableView(QTableView): """This class implements a table with its first column frozen For more information please see: https://doc.qt.io/qt-5/qtwidgets-itemviews-frozencolumn-example.html""" def __init__(self, parent): """Constructor.""" QTableView.__init__(self, parent) self.parent = parent self.setModel(parent.model()) self.setFocusPolicy(Qt.NoFocus) self.verticalHeader().hide() try: self.horizontalHeader().setSectionResizeMode(QHeaderView.Fixed) except: # support for qtpy<1.2.0 self.horizontalHeader().setResizeMode(QHeaderView.Fixed) parent.viewport().stackUnder(self) self.setSelectionModel(parent.selectionModel()) for col in range(1, parent.model().columnCount()): self.setColumnHidden(col, True) self.setColumnWidth(0, parent.columnWidth(0)) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.show() self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) def update_geometry(self): """Update the frozen column size when an update occurs in its parent table""" self.setGeometry( self.parent.verticalHeader().width() + self.parent.frameWidth(), self.parent.frameWidth(), self.parent.columnWidth(0), self.parent.viewport().height() + self.parent.horizontalHeader().height(), ) class DataFrameView(QTableView): """Data Frame view class""" def __init__(self, parent, model): QTableView.__init__(self, parent) self.setModel(model) self.frozen_table_view = FrozenTableView(self) self.frozen_table_view.update_geometry() self.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel) self.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel) self.horizontalHeader().sectionResized.connect(self.update_section_width) self.verticalHeader().sectionResized.connect(self.update_section_height) self.frozen_table_view.verticalScrollBar().valueChanged.connect( self.verticalScrollBar().setValue ) self.sort_old = [None] self.header_class = self.horizontalHeader() self.header_class.sectionClicked.connect(self.sortByColumn) self.menu = self.setup_menu() QShortcut(QKeySequence(QKeySequence.Copy), self, self.copy) self.horizontalScrollBar().valueChanged.connect( lambda val: self.load_more_data(val, columns=True) ) self.verticalScrollBar().valueChanged.connect( lambda val: self.load_more_data(val, rows=True) ) self.verticalScrollBar().valueChanged.connect( self.frozen_table_view.verticalScrollBar().setValue ) def update_section_width(self, logical_index, old_size, new_size): """Update the horizontal width of the frozen column when a change takes place in the first column of the table""" if logical_index == 0: self.frozen_table_view.setColumnWidth(0, new_size) self.frozen_table_view.update_geometry() def update_section_height(self, logical_index, old_size, new_size): """Update the vertical width of the frozen column when a change takes place on any of the rows""" self.frozen_table_view.setRowHeight(logical_index, new_size) def resizeEvent(self, event): """Update the frozen column dimensions. Updates takes place when the enclosing window of this table reports a dimension change """ QTableView.resizeEvent(self, event) self.frozen_table_view.update_geometry() def moveCursor(self, cursor_action, modifiers): """Update the table position. Updates the position along with the frozen column when the cursor (selector) changes its position """ current = QTableView.moveCursor(self, cursor_action, modifiers) col_width = self.frozen_table_view.columnWidth( 0 ) + self.frozen_table_view.columnWidth(1) topleft_x = self.visualRect(current).topLeft().x() overflow = cursor_action == QAbstractItemView.MoveLeft and current.column() > 1 overflow = overflow and topleft_x < col_width if cursor_action == overflow: new_value = self.horizontalScrollBar().value() + topleft_x - col_width self.horizontalScrollBar().setValue(new_value) return current def scrollTo(self, index, hint): """Scroll the table. It is necessary to ensure that the item at index is visible. The view will try to position the item according to the given hint. This method does not takes effect only if the frozen column is scrolled. """ if index.column() > 1: QTableView.scrollTo(self, index, hint) def load_more_data(self, value, rows=False, columns=False): """ :param value: :param rows: :param columns: """ if rows and value == self.verticalScrollBar().maximum(): self.model().fetch_more(rows=rows) if columns and value == self.horizontalScrollBar().maximum(): self.model().fetch_more(columns=columns) def sortByColumn(self, index): """Implement a Column sort""" if self.sort_old == [None]: self.header_class.setSortIndicatorShown(True) sort_order = self.header_class.sortIndicatorOrder() if not self.model().sort(index, sort_order): if len(self.sort_old) != 2: self.header_class.setSortIndicatorShown(False) else: self.header_class.setSortIndicator(self.sort_old[0], self.sort_old[1]) return self.sort_old = [index, self.header_class.sortIndicatorOrder()] def contextMenuEvent(self, event): """Reimplement Qt method""" self.menu.popup(event.globalPos()) event.accept() def setup_menu(self): """Setup context menu""" copy_action = create_action( self, _("Copy"), shortcut=keybinding("Copy"), icon=get_icon("editcopy.png"), triggered=self.copy, context=Qt.WidgetShortcut, ) actions = [copy_action] if not self.model().readonly: functions = ( (_("To bool"), bool), (_("To complex"), complex), (_("To int"), int), (_("To float"), float), (_("To str"), str), ) for name, func in functions: # QAction.triggered works differently for PySide and PyQt slot = lambda _checked, func=func: self.change_type(func) actions += [ create_action(self, name, triggered=slot, context=Qt.WidgetShortcut) ] menu = QMenu(self) add_actions(menu, actions) return menu def change_type(self, func): """A function that changes types of cells""" model = self.model() index_list = self.selectedIndexes() [model.setData(i, "", change_type=func) for i in index_list] @Slot() def copy(self): """Copy text to clipboard""" if not self.selectedIndexes(): return (row_min, row_max, col_min, col_max) = get_idx_rect(self.selectedIndexes()) index = header = False if col_min == 0: col_min = 1 index = True df = self.model().df if col_max == 0: # To copy indices contents = "\n".join( map(str, df.index.tolist()[slice(row_min, row_max + 1)]) ) else: # To copy DataFrame if (col_min == 0 or col_min == 1) and (df.shape[1] == col_max): header = True obj = df.iloc[slice(row_min, row_max + 1), slice(col_min - 1, col_max)] output = io.StringIO() obj.to_csv(output, sep="\t", index=index, header=header) contents = output.getvalue() output.close() clipboard = QApplication.clipboard() clipboard.setText(contents) class DataFrameEditor(QDialog): """ Dialog for displaying and editing DataFrame and related objects. Signals ------- sig_option_changed(str, object): Raised if an option is changed. Arguments are name of option and its new value. """ sig_option_changed = Signal(str, object) def __init__(self, parent=None): QDialog.__init__(self, parent) win32_fix_title_bar_background(self) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) self.is_series = False self.layout = None def setup_and_check(self, data, title="", readonly=False, add_title_suffix=True): """Setup DataFrameEditor. Args: data: The DataFrame or Series to edit. title: The dialog title. readonly: If True, the DataFrame is not editable. add_title_suffix: If True, the data type is added to the title. Returns: True if the editor could be set up, False otherwise. """ self.layout = QGridLayout() self.setLayout(self.layout) self.setWindowIcon(get_icon("arredit.png")) if add_title_suffix: if title: title = str(title) + " - %s" % data.__class__.__name__ else: title = _("%s editor") % data.__class__.__name__ if readonly: title += " (" + _("read only") + ")" if isinstance(data, Series): self.is_series = True data = data.to_frame() elif isinstance(data, DatetimeIndex): data = DataFrame(data) self.setWindowTitle(title) self.resize(600, 500) self.dataModel = DataFrameModel(data, parent=self, readonly=readonly) self.dataModel.dataChanged.connect(self.save_and_close_enable) self.dataTable = DataFrameView(self, self.dataModel) self.layout.addWidget(self.dataTable) self.setLayout(self.layout) self.setMinimumSize(400, 300) # Make the dialog act as a window self.setWindowFlags(Qt.Window) btn_layout = QHBoxLayout() btn = QPushButton(get_icon("format.svg"), _("Format"), self) # disable format button for int type btn_layout.addWidget(btn) btn.clicked.connect(self.change_format) btn = QPushButton(get_icon("resize.svg"), _("Resize"), self) btn_layout.addWidget(btn) btn.clicked.connect(self.resize_to_contents) bgcolor = QCheckBox(_("Background color")) bgcolor.setChecked(self.dataModel.bgcolor_enabled) bgcolor.setEnabled(self.dataModel.bgcolor_enabled) bgcolor.stateChanged.connect(self.change_bgcolor_enable) btn_layout.addWidget(bgcolor) self.bgcolor_global = QCheckBox(_("Column min/max")) self.bgcolor_global.setChecked(self.dataModel.colum_avg_enabled) self.bgcolor_global.setEnabled( not self.is_series and self.dataModel.bgcolor_enabled ) self.bgcolor_global.stateChanged.connect(self.dataModel.colum_avg) btn_layout.addWidget(self.bgcolor_global) btn_layout.addStretch(1) btn = QPushButton(get_icon("copy_all.svg"), _("Copy all"), self) btn.setToolTip(_("Copy all array data to clipboard")) btn.clicked.connect(self.copy_all_to_clipboard) btn_layout.addWidget(btn) btn = QPushButton(get_icon("export.svg"), _("Export"), self) btn.setToolTip(_("Export array to a file")) btn_layout.addWidget(btn) btn.clicked.connect(self.export_dataframe) btn_layout.addStretch() if not readonly: self.btn_save_and_close = QPushButton(_("Save and Close")) self.btn_save_and_close.setDisabled(True) self.btn_save_and_close.clicked.connect(self.accept) btn_layout.addWidget(self.btn_save_and_close) self.btn_close = QPushButton(_("Close")) self.btn_close.setAutoDefault(True) self.btn_close.setDefault(True) self.btn_close.clicked.connect(self.reject) btn_layout.addWidget(self.btn_close) self.layout.addLayout(btn_layout, 2, 0) # Resize column 0 (index column) to contents self.dataTable.resizeColumnToContents(0) return True @Slot(QModelIndex, QModelIndex) def save_and_close_enable(self, top_left, bottom_right): """Handle the data change event to enable the save and close button.""" self.btn_save_and_close.setEnabled(True) self.btn_save_and_close.setAutoDefault(True) self.btn_save_and_close.setDefault(True) def change_bgcolor_enable(self, state): """ This is implementet so column min/max is only active when bgcolor is """ self.dataModel.bgcolor(state) self.bgcolor_global.setEnabled(not self.is_series and state > 0) def change_format(self): """ Ask user for display format for floats and use it. This function also checks whether the format is valid and emits `sig_option_changed`. """ format, valid = QInputDialog.getText( self, _("Format"), _("Float formatting"), QLineEdit.Normal, self.dataModel.get_format(), ) if valid: format = str(format) try: format % 1.1 except: msg = _("Format ({}) is incorrect").format(format) QMessageBox.critical(self, _("Error"), msg) return if not format.startswith("%"): msg = _("Format ({}) should start with '%'").format(format) QMessageBox.critical(self, _("Error"), msg) return self.dataModel.set_format(format) self.sig_option_changed.emit("dataframe_format", format) def copy_all_to_clipboard(self) -> None: """Copy all array data, including headers, to clipboard""" dataframe = self.dataModel.get_data() if self.is_series: dataframe = dataframe.iloc[:, 0] output = io.StringIO() dataframe.to_csv(output, sep="\t", index=True, header=True) cliptxt = output.getvalue() output.close() if cliptxt: clipboard = QApplication.clipboard() clipboard.setText(cliptxt) def export_dataframe(self) -> None: """Export the dataframe to a CSV file with UTF-8 BOM.""" filename, _selfilter = getsavefilename( self, _("Export dataframe"), "", _("CSV Files") + " (*.csv)" ) if not filename: return dataframe = self.dataModel.get_data() dataframe.to_csv(filename, sep="\t", index=True, header=True) def get_value(self): """Return modified Dataframe -- this is *not* a copy""" # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute df = self.dataModel.get_data() if self.is_series: return df.iloc[:, 0] else: return df def resize_to_contents(self): """ """ QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) self.dataTable.resizeColumnsToContents() self.dataModel.fetch_more(columns=True) self.dataTable.resizeColumnsToContents() QApplication.restoreOverrideCursor() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/dockable.py0000644000175100017510000000672315114075001020072 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ dockable -------- The `dockable` module provides a mixin class for widgets that can be docked into a QMainWindow. """ from __future__ import annotations from qtpy.QtCore import Qt from qtpy.QtWidgets import QDockWidget, QWidget class DockableWidgetMixin: """Mixin class for widgets that can be docked into a QMainWindow""" ALLOWED_AREAS = Qt.AllDockWidgetAreas LOCATION = Qt.TopDockWidgetArea FEATURES = ( QDockWidget.DockWidgetClosable | QDockWidget.DockWidgetFloatable | QDockWidget.DockWidgetMovable ) def __init__(self): self._isvisible = False self.dockwidget: QDockWidget | None = None self._allowed_areas = self.ALLOWED_AREAS self._location = self.LOCATION self._features = self.FEATURES @property def parent_widget(self) -> QWidget | None: """Return associated QWidget parent""" return self.parent() def setup_dockwidget( self, location: Qt.DockWidgetArea | None = None, features: QDockWidget.DockWidgetFeatures | None = None, allowed_areas: Qt.DockWidgetAreas | None = None, ) -> None: """Setup dockwidget parameters Args: location (Qt.DockWidgetArea): Dockwidget location features (QDockWidget.DockWidgetFeatures): Dockwidget features allowed_areas (Qt.DockWidgetAreas): Dockwidget allowed areas """ assert self.dockwidget is None, ( "Dockwidget must be setup before calling 'create_dockwidget'" ) if location is not None: self._location = location if features is not None: self._features = features if allowed_areas is not None: self._allowed_areas = allowed_areas def get_focus_widget(self) -> QWidget | None: """Return widget to focus when dockwidget is visible""" return None def create_dockwidget(self, title: str) -> tuple[QDockWidget, Qt.DockWidgetArea]: """Add to parent QMainWindow as a dock widget Args: title (str): Dockwidget title Returns: tuple[QDockWidget, Qt.DockWidgetArea]: Dockwidget and location """ dock = QDockWidget(title, self.parent_widget) dock.setObjectName(self.__class__.__name__ + "_dw") dock.setAllowedAreas(self._allowed_areas) dock.setFeatures(self._features) dock.setWidget(self) dock.visibilityChanged.connect(self.visibility_changed) self.dockwidget = dock return (dock, self._location) def is_visible(self) -> bool: """Return dockwidget visibility state""" return self._isvisible def visibility_changed(self, enable: bool) -> None: """DockWidget visibility has changed Args: enable (bool): Dockwidget visibility state """ if enable: self.dockwidget.raise_() widget = self.get_focus_widget() # pylint: disable=assignment-from-none if widget is not None: widget.setFocus() self._isvisible = enable and self.dockwidget.isVisible() class DockableWidget(QWidget, DockableWidgetMixin): """Dockable widget Args: parent (QWidget): Parent widget """ def __init__(self, parent: QWidget): QWidget.__init__(self, parent) DockableWidgetMixin.__init__(self) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/importwizard.py0000644000175100017510000005555515114075001021070 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """ Text data Importing Wizard based on Qt """ # ----date and datetime objects support import datetime import io from functools import partial as ft_partial from itertools import zip_longest from numpy import nan from qtpy.QtCore import QAbstractTableModel, QModelIndex, Qt, Signal, Slot from qtpy.QtGui import QColor, QIntValidator from qtpy.QtWidgets import ( QCheckBox, QDialog, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMenu, QMessageBox, QPushButton, QRadioButton, QSizePolicy, QSpacerItem, QTableView, QTabWidget, QTextEdit, QVBoxLayout, QWidget, ) from guidata.config import _ from guidata.configtools import get_icon from guidata.qthelpers import add_actions, create_action, win32_fix_title_bar_background try: import pandas as pd except: pd = None def try_to_parse(value): """ :param value: :return: """ _types = ("int", "float") for _t in _types: try: _val = eval("%s('%s')" % (_t, value)) return _val except (ValueError, SyntaxError): pass return value def try_to_eval(value): """ :param value: :return: """ try: return eval(value) except (NameError, SyntaxError, ImportError): return value # ----Numpy arrays support class FakeObject: """Fake class used in replacement of missing modules""" pass try: from numpy import array, ndarray except: class ndarray(FakeObject): # analysis:ignore """Fake ndarray""" pass try: from dateutil.parser import parse as dateparse except: def dateparse(datestr, dayfirst=True): # analysis:ignore """Just for 'day/month/year' strings""" _a, _b, _c = list(map(int, datestr.split("/"))) if dayfirst: return datetime.datetime(_c, _b, _a) return datetime.datetime(_c, _a, _b) def datestr_to_datetime(value, dayfirst=True): """ :param value: :param dayfirst: :return: """ return dateparse(value, dayfirst=dayfirst) # ----Background colors for supported types COLORS = { bool: Qt.magenta, (float, int): Qt.blue, list: Qt.yellow, dict: Qt.cyan, tuple: Qt.lightGray, (str,): Qt.darkRed, ndarray: Qt.green, datetime.date: Qt.darkYellow, } def get_color(value, alpha): """Return color depending on value type""" color = QColor() for typ in COLORS: if isinstance(value, typ): color = QColor(COLORS[typ]) color.setAlphaF(alpha) return color class ContentsWidget(QWidget): """Import wizard contents widget""" asDataChanged = Signal(bool) def __init__(self, parent, text): QWidget.__init__(self, parent) self.text_editor = QTextEdit(self) self.text_editor.setText(text) self.text_editor.setReadOnly(True) # Type frame type_layout = QHBoxLayout() type_label = QLabel(_("Import as")) type_layout.addWidget(type_label) data_btn = QRadioButton(_("data")) data_btn.setChecked(True) self._as_data = True type_layout.addWidget(data_btn) code_btn = QRadioButton(_("code")) self._as_code = False type_layout.addWidget(code_btn) txt_btn = QRadioButton(_("text")) type_layout.addWidget(txt_btn) h_spacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) type_layout.addItem(h_spacer) type_frame = QFrame() type_frame.setLayout(type_layout) # Opts frame grid_layout = QGridLayout() grid_layout.setSpacing(0) col_label = QLabel(_("Column separator:")) grid_layout.addWidget(col_label, 0, 0) col_w = QWidget() col_btn_layout = QHBoxLayout() self.tab_btn = QRadioButton(_("Tab")) self.tab_btn.setChecked(False) col_btn_layout.addWidget(self.tab_btn) self.ws_btn = QRadioButton(_("Whitespace")) self.ws_btn.setChecked(False) col_btn_layout.addWidget(self.ws_btn) other_btn_col = QRadioButton(_("other")) other_btn_col.setChecked(True) col_btn_layout.addWidget(other_btn_col) col_w.setLayout(col_btn_layout) grid_layout.addWidget(col_w, 0, 1) self.line_edt = QLineEdit(",") self.line_edt.setMaximumWidth(30) self.line_edt.setEnabled(True) other_btn_col.toggled.connect(self.line_edt.setEnabled) grid_layout.addWidget(self.line_edt, 0, 2) row_label = QLabel(_("Row separator:")) grid_layout.addWidget(row_label, 1, 0) row_w = QWidget() row_btn_layout = QHBoxLayout() self.eol_btn = QRadioButton(_("EOL")) self.eol_btn.setChecked(True) row_btn_layout.addWidget(self.eol_btn) other_btn_row = QRadioButton(_("other")) row_btn_layout.addWidget(other_btn_row) row_w.setLayout(row_btn_layout) grid_layout.addWidget(row_w, 1, 1) self.line_edt_row = QLineEdit(";") self.line_edt_row.setMaximumWidth(30) self.line_edt_row.setEnabled(False) other_btn_row.toggled.connect(self.line_edt_row.setEnabled) grid_layout.addWidget(self.line_edt_row, 1, 2) grid_layout.setRowMinimumHeight(2, 15) other_group = QGroupBox(_("Additional options")) other_layout = QGridLayout() other_group.setLayout(other_layout) skiprows_label = QLabel(_("Skip rows:")) other_layout.addWidget(skiprows_label, 0, 0) self.skiprows_edt = QLineEdit("0") self.skiprows_edt.setMaximumWidth(30) intvalid = QIntValidator(0, len(str(text).splitlines()), self.skiprows_edt) self.skiprows_edt.setValidator(intvalid) other_layout.addWidget(self.skiprows_edt, 0, 1) other_layout.setColumnMinimumWidth(2, 5) comments_label = QLabel(_("Comments:")) other_layout.addWidget(comments_label, 0, 3) self.comments_edt = QLineEdit("#") self.comments_edt.setMaximumWidth(30) other_layout.addWidget(self.comments_edt, 0, 4) self.trnsp_box = QCheckBox(_("Transpose")) # self.trnsp_box.setEnabled(False) other_layout.addWidget(self.trnsp_box, 1, 0, 2, 0) grid_layout.addWidget(other_group, 3, 0, 2, 0) opts_frame = QFrame() opts_frame.setLayout(grid_layout) data_btn.toggled.connect(opts_frame.setEnabled) data_btn.toggled.connect(self.set_as_data) code_btn.toggled.connect(self.set_as_code) # self.connect(txt_btn, SIGNAL("toggled(bool)"), # self, SLOT("is_text(bool)")) # Final layout layout = QVBoxLayout() layout.addWidget(type_frame) layout.addWidget(self.text_editor) layout.addWidget(opts_frame) self.setLayout(layout) def get_as_data(self): """Return if data type conversion""" return self._as_data def get_as_code(self): """Return if code type conversion""" return self._as_code def get_as_num(self): """Return if numeric type conversion""" return self._as_num def get_col_sep(self): """Return the column separator""" if self.tab_btn.isChecked(): return "\t" elif self.ws_btn.isChecked(): return None return str(self.line_edt.text()) def get_row_sep(self): """Return the row separator""" if self.eol_btn.isChecked(): return "\n" return str(self.line_edt_row.text()) def get_skiprows(self): """Return number of lines to be skipped""" return int(str(self.skiprows_edt.text())) def get_comments(self): """Return comment string""" return str(self.comments_edt.text()) @Slot(bool) def set_as_data(self, as_data): """Set if data type conversion""" self._as_data = as_data self.asDataChanged.emit(as_data) @Slot(bool) def set_as_code(self, as_code): """Set if code type conversion""" self._as_code = as_code class PreviewTableModel(QAbstractTableModel): """Import wizard preview table model""" def __init__(self, data=[], parent=None): QAbstractTableModel.__init__(self, parent) self._data = data def rowCount(self, parent=QModelIndex()): """Return row count""" return len(self._data) def columnCount(self, parent=QModelIndex()): """Return column count""" return len(self._data[0]) def _display_data(self, index): """Return a data element""" return self._data[index.row()][index.column()] def data(self, index, role=Qt.DisplayRole): """Return a model data element""" if not index.isValid(): return None if role == Qt.DisplayRole: return self._display_data(index) elif role == Qt.BackgroundColorRole: return get_color(self._data[index.row()][index.column()], 0.2) elif role == Qt.TextAlignmentRole: return int(Qt.AlignRight | Qt.AlignVCenter) return None def setData(self, index, value, role=Qt.EditRole): """Set model data""" return False def get_data(self): """Return a copy of model data""" return self._data[:][:] def parse_data_type(self, index, **kwargs): """Parse a type to an other type""" if not index.isValid(): return False try: if kwargs["atype"] == "date": self._data[index.row()][index.column()] = datestr_to_datetime( self._data[index.row()][index.column()], kwargs["dayfirst"] ).date() elif kwargs["atype"] == "perc": _tmp = self._data[index.row()][index.column()].replace("%", "") self._data[index.row()][index.column()] = eval(_tmp) / 100.0 elif kwargs["atype"] == "account": _tmp = self._data[index.row()][index.column()].replace(",", "") self._data[index.row()][index.column()] = eval(_tmp) elif kwargs["atype"] == "unicode": self._data[index.row()][index.column()] = str( self._data[index.row()][index.column()] ) elif kwargs["atype"] == "int": self._data[index.row()][index.column()] = int( self._data[index.row()][index.column()] ) elif kwargs["atype"] == "float": self._data[index.row()][index.column()] = float( self._data[index.row()][index.column()] ) self.dataChanged.emit(index, index) except Exception as instance: print(instance) # spyder: test-skip def reset(self): """ """ self.beginResetModel() self.endResetModel() class PreviewTable(QTableView): """Import wizard preview widget""" def __init__(self, parent): QTableView.__init__(self, parent) self._model = None # Setting up actions self.date_dayfirst_action = create_action( self, "dayfirst", triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=True), ) self.date_monthfirst_action = create_action( self, "monthfirst", triggered=ft_partial(self.parse_to_type, atype="date", dayfirst=False), ) self.perc_action = create_action( self, "perc", triggered=ft_partial(self.parse_to_type, atype="perc") ) self.acc_action = create_action( self, "account", triggered=ft_partial(self.parse_to_type, atype="account") ) self.str_action = create_action( self, "unicode", triggered=ft_partial(self.parse_to_type, atype="unicode") ) self.int_action = create_action( self, "int", triggered=ft_partial(self.parse_to_type, atype="int") ) self.float_action = create_action( self, "float", triggered=ft_partial(self.parse_to_type, atype="float") ) # Setting up menus self.date_menu = QMenu() self.date_menu.setTitle("Date") add_actions( self.date_menu, (self.date_dayfirst_action, self.date_monthfirst_action) ) self.parse_menu = QMenu(self) self.parse_menu.addMenu(self.date_menu) add_actions(self.parse_menu, (self.perc_action, self.acc_action)) self.parse_menu.setTitle("String to") self.opt_menu = QMenu(self) self.opt_menu.addMenu(self.parse_menu) add_actions( self.opt_menu, (self.str_action, self.int_action, self.float_action) ) def _shape_text( self, text, colsep="\t", rowsep="\n", transpose=False, skiprows=0, comments="#", ): """Decode the shape of the given text""" assert colsep != rowsep out = [] text_rows = text.split(rowsep)[skiprows:] for row in text_rows: stripped = str(row).strip() if len(stripped) == 0 or stripped.startswith(comments): continue line = str(row).split(colsep) line = [try_to_parse(str(x)) for x in line] out.append(line) # Replace missing elements with np.nan's or None's out = list(zip_longest(*out, fillvalue=nan)) # Tranpose the last result to get the expected one out = [[r[col] for r in out] for col in range(len(out[0]))] if transpose: return [[r[col] for r in out] for col in range(len(out[0]))] return out def get_data(self): """Return model data""" if self._model is None: return None return self._model.get_data() def process_data( self, text, colsep="\t", rowsep="\n", transpose=False, skiprows=0, comments="#", ): """Put data into table model""" data = self._shape_text(text, colsep, rowsep, transpose, skiprows, comments) self._model = PreviewTableModel(data) self.setModel(self._model) @Slot() def parse_to_type(self, **kwargs): """Parse to a given type""" indexes = self.selectedIndexes() if not indexes: return for index in indexes: self.model().parse_data_type(index, **kwargs) def contextMenuEvent(self, event): """Reimplement Qt method""" self.opt_menu.popup(event.globalPos()) event.accept() class PreviewWidget(QWidget): """Import wizard preview widget""" def __init__(self, parent): QWidget.__init__(self, parent) vert_layout = QVBoxLayout() # Type frame type_layout = QHBoxLayout() type_label = QLabel(_("Import as")) type_layout.addWidget(type_label) self.array_btn = array_btn = QRadioButton(_("array")) array_btn.setEnabled(ndarray is not FakeObject) array_btn.setChecked(ndarray is not FakeObject) type_layout.addWidget(array_btn) list_btn = QRadioButton(_("list")) list_btn.setChecked(not array_btn.isChecked()) type_layout.addWidget(list_btn) if pd: self.df_btn = df_btn = QRadioButton(_("DataFrame")) df_btn.setChecked(False) type_layout.addWidget(df_btn) h_spacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) type_layout.addItem(h_spacer) type_frame = QFrame() type_frame.setLayout(type_layout) self._table_view = PreviewTable(self) vert_layout.addWidget(type_frame) vert_layout.addWidget(self._table_view) self.setLayout(vert_layout) def open_data( self, text, colsep="\t", rowsep="\n", transpose=False, skiprows=0, comments="#", ): """Open clipboard text as table""" if pd: self.pd_text = text self.pd_info = dict( sep=colsep, lineterminator=rowsep, skiprows=skiprows, comment=comments ) if colsep is None: self.pd_info = dict( lineterminator=rowsep, skiprows=skiprows, comment=comments, delim_whitespace=True, ) self._table_view.process_data( text, colsep, rowsep, transpose, skiprows, comments ) def get_data(self): """Return table data""" return self._table_view.get_data() class ImportWizard(QDialog): """Text data import wizard""" def __init__( self, parent, text, title=None, icon=None, contents_title=None, varname=None ): QDialog.__init__(self, parent) win32_fix_title_bar_background(self) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) if title is None: title = _("Import wizard") self.setWindowTitle(title) if icon is None: self.setWindowIcon(get_icon("fileimport.png")) if contents_title is None: contents_title = _("Raw text") if varname is None: varname = _("variable_name") self.var_name, self.clip_data = None, None # Setting GUI self.tab_widget = QTabWidget(self) self.text_widget = ContentsWidget(self, text) self.table_widget = PreviewWidget(self) self.tab_widget.addTab(self.text_widget, _("text")) self.tab_widget.setTabText(0, contents_title) self.tab_widget.addTab(self.table_widget, _("table")) self.tab_widget.setTabText(1, _("Preview")) self.tab_widget.setTabEnabled(1, False) name_layout = QHBoxLayout() name_label = QLabel(_("Variable Name")) name_layout.addWidget(name_label) self.name_edt = QLineEdit() self.name_edt.setText(varname) name_layout.addWidget(self.name_edt) btns_layout = QHBoxLayout() cancel_btn = QPushButton(_("Cancel")) btns_layout.addWidget(cancel_btn) cancel_btn.clicked.connect(self.reject) h_spacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum) btns_layout.addItem(h_spacer) self.back_btn = QPushButton(_("Previous")) self.back_btn.setEnabled(False) btns_layout.addWidget(self.back_btn) self.back_btn.clicked.connect(ft_partial(self._set_step, step=-1)) self.fwd_btn = QPushButton(_("Next")) if not text: self.fwd_btn.setEnabled(False) btns_layout.addWidget(self.fwd_btn) self.fwd_btn.clicked.connect(ft_partial(self._set_step, step=1)) self.done_btn = QPushButton(_("Done")) self.done_btn.setEnabled(False) btns_layout.addWidget(self.done_btn) self.done_btn.clicked.connect(self.process) self.text_widget.asDataChanged.connect(self.fwd_btn.setEnabled) self.text_widget.asDataChanged.connect(self.done_btn.setDisabled) layout = QVBoxLayout() layout.addLayout(name_layout) layout.addWidget(self.tab_widget) layout.addLayout(btns_layout) self.setLayout(layout) def _focus_tab(self, tab_idx): """Change tab focus""" for i in range(self.tab_widget.count()): self.tab_widget.setTabEnabled(i, False) self.tab_widget.setTabEnabled(tab_idx, True) self.tab_widget.setCurrentIndex(tab_idx) def _set_step(self, step): """Proceed to a given step""" new_tab = self.tab_widget.currentIndex() + step assert new_tab < self.tab_widget.count() and new_tab >= 0 if new_tab == self.tab_widget.count() - 1: try: self.table_widget.open_data( self._get_plain_text(), self.text_widget.get_col_sep(), self.text_widget.get_row_sep(), self.text_widget.trnsp_box.isChecked(), self.text_widget.get_skiprows(), self.text_widget.get_comments(), ) self.done_btn.setEnabled(True) self.done_btn.setDefault(True) self.fwd_btn.setEnabled(False) self.back_btn.setEnabled(True) except (SyntaxError, AssertionError) as error: QMessageBox.critical( self, _("Import wizard"), _( "Unable to proceed to next step" "

Please check your entries." "

Error message:
%s" ) % str(error), ) return elif new_tab == 0: self.done_btn.setEnabled(False) self.fwd_btn.setEnabled(True) self.back_btn.setEnabled(False) self._focus_tab(new_tab) def get_data(self): """Return processed data""" # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute return self.var_name, self.clip_data def _simplify_shape(self, alist, rec=0): """Reduce the alist dimension if needed""" if rec != 0: if len(alist) == 1: return alist[-1] return alist if len(alist) == 1: return self._simplify_shape(alist[-1], 1) return [self._simplify_shape(al, 1) for al in alist] def _get_table_data(self): """Return clipboard processed as data""" data = self._simplify_shape(self.table_widget.get_data()) if self.table_widget.array_btn.isChecked(): return array(data) elif pd and self.table_widget.df_btn.isChecked(): info = self.table_widget.pd_info buf = io.StringIO(self.table_widget.pd_text) return pd.read_csv(buf, **info) return data def _get_plain_text(self): """Return clipboard as text""" return self.text_widget.text_editor.toPlainText() @Slot() def process(self): """Process the data from clipboard""" var_name = self.name_edt.text() try: self.var_name = str(var_name) except UnicodeEncodeError: self.var_name = str(var_name) if self.text_widget.get_as_data(): self.clip_data = self._get_table_data() elif self.text_widget.get_as_code(): self.clip_data = try_to_eval(str(self._get_plain_text())) else: self.clip_data = str(self._get_plain_text()) self.accept() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/nsview.py0000644000175100017510000005400415114075001017634 0ustar00runnerrunner# -*- coding: utf-8 -*- # ----------------------------------------------------------------------------- # Copyright (c) 2009- Spyder Kernels Contributors # # Licensed under the terms of the MIT License # (see spyder_kernels/__init__.py for details) # ----------------------------------------------------------------------------- # ruff: noqa """ Utilities """ # ============================================================================== # Date and datetime objects support # ============================================================================== import datetime import re from itertools import islice NUMERIC_TYPES = (int, float, complex) # ============================================================================== # FakeObject # ============================================================================== class FakeObject(object): """Fake class used in replacement of missing modules""" pass # ============================================================================== # Numpy arrays and numeric types support # ============================================================================== try: from numpy import ( array, bool_, complex64, complex128, float16, float32, float64, get_printoptions, int8, int16, int32, int64, matrix, ndarray, recarray, ) from numpy import savetxt as np_savetxt from numpy import set_printoptions, uint8, uint16, uint32, uint64 from numpy.ma import MaskedArray except: ndarray = array = matrix = recarray = MaskedArray = np_savetxt = int64 = int32 = ( int16 ) = int8 = uint64 = uint32 = uint16 = uint8 = float64 = float32 = float16 = ( complex64 ) = complex128 = bool_ = FakeObject def get_numpy_dtype(obj): """Return NumPy data type associated to obj Return None if NumPy is not available or if obj is not a NumPy array or scalar""" if ndarray is not FakeObject: # NumPy is available import numpy as np if isinstance(obj, np.generic) or isinstance(obj, np.ndarray): # Numpy scalars all inherit from np.generic. # Numpy arrays all inherit from np.ndarray. # If we check that we are certain we have one of these # types then we are less likely to generate an exception below. try: return obj.dtype.type except (AttributeError, RuntimeError): # AttributeError: some NumPy objects have no dtype attribute # RuntimeError: happens with NetCDF objects (Issue 998) return # ============================================================================== # Pandas support # ============================================================================== try: from pandas import DataFrame, DatetimeIndex, Series except: DataFrame = DatetimeIndex = Series = FakeObject # ============================================================================== # PIL Images support # ============================================================================== try: import PIL.Image Image = PIL.Image.Image except: Image = FakeObject # analysis:ignore # ============================================================================== # BeautifulSoup support (see Issue 2448) # ============================================================================== try: import bs4 NavigableString = bs4.element.NavigableString except: NavigableString = FakeObject # analysis:ignore # ============================================================================== # Misc. # ============================================================================== def address(obj): """Return object address as a string: ''""" return "<%s @ %s>" % ( obj.__class__.__name__, hex(id(obj)).upper().replace("X", "x"), ) def try_to_eval(value): """Try to eval value""" try: return eval(value) except (NameError, SyntaxError, ImportError): return value def get_size(item): """Return size of an item of arbitrary type""" if isinstance(item, (list, tuple, dict)): return len(item) elif isinstance(item, (ndarray, MaskedArray)): return item.shape elif isinstance(item, Image): return item.size if isinstance(item, (DataFrame, DatetimeIndex, Series)): return item.shape else: return 1 def get_object_attrs(obj): """ Get the attributes of an object using dir. This filters protected attributes """ attrs = [k for k in dir(obj) if not k.startswith("__")] if not attrs: attrs = dir(obj) return attrs try: from dateutil.parser import parse as dateparse except: def dateparse(datestr): # analysis:ignore """Just for 'year, month, day' strings""" return datetime.datetime(*list(map(int, datestr.split(",")))) def datestr_to_datetime(value): """ :param value: :return: """ rp = value.rfind("(") + 1 v = dateparse(value[rp:-1]) print(value, "-->", v) # spyder: test-skip return v def str_to_timedelta(value): """Convert a string to a datetime.timedelta value. The following strings are accepted: - 'datetime.timedelta(1, 5, 12345)' - 'timedelta(1, 5, 12345)' - '(1, 5, 12345)' - '1, 5, 12345' - '1' if there are less then three parameters, the missing parameters are assumed to be 0. Variations in the spacing of the parameters are allowed. Raises: ValueError for strings not matching the above criterion. """ m = re.match(r"^(?:(?:datetime\.)?timedelta)?" r"\(?" r"([^)]*)" r"\)?$", value) if not m: raise ValueError("Invalid string for datetime.timedelta") args = [int(a.strip()) for a in m.group(1).split(",")] return datetime.timedelta(*args) # ============================================================================== # Background colors for supported types # ============================================================================== ARRAY_COLOR = "#00ff00" SCALAR_COLOR = "#0000ff" COLORS = { bool: "#ff00ff", NUMERIC_TYPES: SCALAR_COLOR, list: "#ffff00", dict: "#00ffff", tuple: "#c0c0c0", (str,): "#800000", (ndarray, MaskedArray, matrix, DataFrame, Series, DatetimeIndex): ARRAY_COLOR, Image: "#008000", datetime.date: "#808000", datetime.timedelta: "#808000", } CUSTOM_TYPE_COLOR = "#7755aa" UNSUPPORTED_COLOR = "#ffffff" def get_color_name(value): """Return color name depending on value type""" if not is_known_type(value): return CUSTOM_TYPE_COLOR for typ, name in list(COLORS.items()): if isinstance(value, typ): return name else: np_dtype = get_numpy_dtype(value) if np_dtype is None or not hasattr(value, "size"): return UNSUPPORTED_COLOR elif value.size == 1: return SCALAR_COLOR else: return ARRAY_COLOR def is_editable_type(value): """Return True if data type is editable with a standard GUI-based editor, like CollectionsEditor, ArrayEditor, QDateEdit or a simple QLineEdit""" return get_color_name(value) not in (UNSUPPORTED_COLOR, CUSTOM_TYPE_COLOR) # ============================================================================== # Sorting # ============================================================================== def sort_against(list1, list2, reverse=False): """ Arrange items of list1 in the same order as sorted(list2). In other words, apply to list1 the permutation which takes list2 to sorted(list2, reverse). """ try: return [ item for _, item in sorted( zip(list2, list1), key=lambda x: x[0], reverse=reverse ) ] except: return list1 def unsorted_unique(lista): """Removes duplicates from lista neglecting its initial ordering""" return list(set(lista)) # ============================================================================== # Display <--> Value # ============================================================================== def default_display(value, with_module=True): """Default display for unknown objects.""" object_type = type(value) try: name = object_type.__name__ module = object_type.__module__ if with_module: return name + " object of " + module + " module" else: return name except: type_str = str(object_type) return type_str[1:-1] def collections_display(value, level): """Display for collections (i.e. list, tuple and dict).""" is_dict = isinstance(value, dict) # Get elements if is_dict: elements = value.items() else: elements = value # Truncate values truncate = False if level == 1 and len(value) > 10: elements = islice(elements, 10) if is_dict else value[:10] truncate = True elif level == 2 and len(value) > 5: elements = islice(elements, 5) if is_dict else value[:5] truncate = True # Get display of each element if level <= 2: if is_dict: displays = [ value_to_display(k, level=level) + ":" + value_to_display(v, level=level) for (k, v) in list(elements) ] else: displays = [value_to_display(e, level=level) for e in elements] if truncate: displays.append("...") display = ", ".join(displays) else: display = "..." # Return display if is_dict: display = "{" + display + "}" elif isinstance(value, list): display = "[" + display + "]" else: display = "(" + display + ")" return display def value_to_display(value, minmax=False, level=0): """Convert value for display purpose""" # To save current Numpy threshold np_threshold = FakeObject try: numeric_numpy_types = ( int64, int32, int16, int8, uint64, uint32, uint16, uint8, float64, float32, float16, complex128, complex64, bool_, ) if ndarray is not FakeObject: # Save threshold np_threshold = get_printoptions().get("threshold") # Set max number of elements to show for Numpy arrays # in our display set_printoptions(threshold=10) if isinstance(value, recarray): if level == 0: fields = value.names display = "Field names: " + ", ".join(fields) else: display = "Recarray" elif isinstance(value, MaskedArray): display = "Masked array" elif isinstance(value, ndarray): if level == 0: if minmax: try: display = "Min: %r\nMax: %r" % (value.min(), value.max()) except (TypeError, ValueError): if value.dtype.type in numeric_numpy_types: display = str(value) else: display = default_display(value) elif value.dtype.type in numeric_numpy_types: display = str(value) else: display = default_display(value) else: display = "Numpy array" elif any([type(value) == t for t in [list, tuple, dict]]): display = collections_display(value, level + 1) elif isinstance(value, Image): if level == 0: display = "%s Mode: %s" % (address(value), value.mode) else: display = "Image" elif isinstance(value, DataFrame): if level == 0: cols = value.columns cols = [str(c) for c in cols] display = "Column names: " + ", ".join(list(cols)) else: display = "Dataframe" elif isinstance(value, NavigableString): # Fixes Issue 2448 display = str(value) if level > 0: display = "'" + display + "'" elif isinstance(value, DatetimeIndex): if level == 0: try: display = value._summary() except AttributeError: display = value.summary() else: display = "DatetimeIndex" elif isinstance(value, bytes): # We don't apply this to classes that extend string types # See issue 5636 if type(value) is bytes: try: display = str(value, "utf8") if level > 0: display = "'" + display + "'" except: display = value if level > 0: display = b"'" + display + b"'" else: display = default_display(value) elif isinstance(value, str): # We don't apply this to classes that extend string types # See issue 5636 if type(value) is str: display = value if level > 0: display = "'" + display + "'" else: display = default_display(value) elif isinstance(value, datetime.date) or isinstance(value, datetime.timedelta): display = str(value) elif ( isinstance(value, NUMERIC_TYPES) or isinstance(value, bool) or isinstance(value, numeric_numpy_types) ): display = repr(value) else: if level == 0: display = default_display(value) else: display = default_display(value, with_module=False) except: display = default_display(value) # Truncate display at 70 chars to avoid freezing Spyder # because of large displays if len(display) > 70: if isinstance(display, bytes): ellipses = b" ..." else: ellipses = " ..." display = display[:70].rstrip() + ellipses # Restore Numpy threshold if np_threshold is not FakeObject: set_printoptions(threshold=np_threshold) return display def display_to_value(value, default_value, ignore_errors=True): """Convert back to value""" try: np_dtype = get_numpy_dtype(default_value) if isinstance(default_value, bool): # We must test for boolean before NumPy data types # because `bool` class derives from `int` class try: value = bool(float(value)) except ValueError: value = value.lower() == "true" elif np_dtype is not None: if "complex" in str(type(default_value)): value = np_dtype(complex(value)) else: value = np_dtype(value) elif isinstance(default_value, bytes): value = bytes(value, "utf8") elif isinstance(default_value, str): value = str(value) elif isinstance(default_value, complex): value = complex(value) elif isinstance(default_value, float): value = float(value) elif isinstance(default_value, int): try: value = int(value) except ValueError: value = float(value) elif isinstance(default_value, datetime.datetime): value = datestr_to_datetime(value) elif isinstance(default_value, datetime.date): value = datestr_to_datetime(value).date() elif isinstance(default_value, datetime.timedelta): value = str_to_timedelta(value) elif ignore_errors: value = try_to_eval(value) else: value = eval(value) except (ValueError, SyntaxError): if ignore_errors: value = try_to_eval(value) else: return default_value return value # ============================================================================= # Types # ============================================================================= def get_type_string(item): """Return type string of an object.""" if isinstance(item, DataFrame): return "DataFrame" if isinstance(item, DatetimeIndex): return "DatetimeIndex" if isinstance(item, Series): return "Series" found = re.findall(r"<(?:type|class) '(\S*)'>", str(type(item))) if found: return found[0] def is_known_type(item): """Return True if object has a known type""" # Unfortunately, the masked array case is specific return isinstance(item, MaskedArray) or get_type_string(item) is not None def get_human_readable_type(item): """Return human-readable type string of an item""" if isinstance(item, (ndarray, MaskedArray)): return item.dtype.name elif isinstance(item, Image): return "Image" else: text = get_type_string(item) if text is None: return "unknown" else: return text[text.find(".") + 1 :] # ============================================================================== # Globals filter: filter namespace dictionaries (to be edited in # CollectionsEditor) # ============================================================================== def is_supported(value, check_all=False, filters=None, iterate=False): """Return True if the value is supported, False otherwise""" assert filters is not None if value is None: return True if not is_editable_type(value): return False elif not isinstance(value, filters): return False elif iterate: if isinstance(value, (list, tuple, set)): valid_count = 0 for val in value: if is_supported(val, filters=filters, iterate=check_all): valid_count += 1 if not check_all: break return valid_count > 0 elif isinstance(value, dict): for key, val in list(value.items()): if not is_supported( key, filters=filters, iterate=check_all ) or not is_supported(val, filters=filters, iterate=check_all): return False if not check_all: break return True def globalsfilter( input_dict, check_all=False, filters=None, exclude_private=None, exclude_capitalized=None, exclude_uppercase=None, exclude_unsupported=None, excluded_names=None, ): """Keep only objects that can be pickled""" output_dict = {} for key, value in list(input_dict.items()): excluded = ( (exclude_private and key.startswith("_")) or (exclude_capitalized and key[0].isupper()) or ( exclude_uppercase and key.isupper() and len(key) > 1 and not key[1:].isdigit() ) or (key in excluded_names) or ( exclude_unsupported and not is_supported(value, check_all=check_all, filters=filters) ) ) if not excluded: output_dict[key] = value return output_dict # ============================================================================== # Create view to be displayed by NamespaceBrowser # ============================================================================== REMOTE_SETTINGS = ( "check_all", "exclude_private", "exclude_uppercase", "exclude_capitalized", "exclude_unsupported", "excluded_names", "minmax", ) def get_supported_types(): """ Return a dictionary containing types lists supported by the namespace browser. """ from datetime import date, timedelta editable_types = [int, float, complex, list, dict, tuple, date, timedelta, str] try: from numpy import generic, matrix, ndarray editable_types += [ndarray, matrix, generic] except: pass try: from pandas import DataFrame, DatetimeIndex, Series editable_types += [DataFrame, Series, DatetimeIndex] except: pass picklable_types = editable_types[:] if Image is not FakeObject: editable_types.append(Image) return dict(picklable=picklable_types, editable=editable_types) def get_remote_data(data, settings, mode, more_excluded_names=None): """ Return globals according to filter described in *settings*: * data: data to be filtered (dictionary) * settings: variable explorer settings (dictionary) * mode (string): 'editable' or 'picklable' * more_excluded_names: additional excluded names (list) """ supported_types = get_supported_types() assert mode in list(supported_types.keys()) excluded_names = settings["excluded_names"] if more_excluded_names is not None: excluded_names += more_excluded_names return globalsfilter( data, check_all=settings["check_all"], filters=tuple(supported_types[mode]), exclude_private=settings["exclude_private"], exclude_uppercase=settings["exclude_uppercase"], exclude_capitalized=settings["exclude_capitalized"], exclude_unsupported=settings["exclude_unsupported"], excluded_names=excluded_names, ) def make_remote_view(data, settings, more_excluded_names=None): """ Make a remote view of dictionary *data* -> globals explorer """ data = get_remote_data( data, settings, mode="editable", more_excluded_names=more_excluded_names ) remote = {} for key, value in list(data.items()): view = value_to_display(value, minmax=settings["minmax"]) remote[key] = { "type": get_human_readable_type(value), "size": get_size(value), "color": get_color_name(value), "view": view, } return remote ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/objecteditor.py0000644000175100017510000000646715114075001021010 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) """ guidata.widgets.objecteditor ============================ This package provides a generic object editor widget. .. autofunction:: oedit """ from __future__ import annotations from typing import TYPE_CHECKING import numpy as np try: from PIL import Image as PILImage except ImportError: PILImage = None from guidata.qthelpers import exec_dialog from guidata.widgets.arrayeditor import ArrayEditor from guidata.widgets.collectionseditor import CollectionsEditor from guidata.widgets.nsview import DataFrame, FakeObject, Series, is_known_type from guidata.widgets.texteditor import TextEditor try: from guidata.widgets.dataframeeditor import DataFrameEditor except ImportError: DataFrameEditor = FakeObject() if TYPE_CHECKING: from qtpy import QtWidgets as QW def create_dialog(obj, title, parent=None): """Creates the editor dialog and returns a tuple (dialog, func) where func is the function to be called with the dialog instance as argument, after quitting the dialog box The role of this intermediate function is to allow easy monkey-patching. (uschmitt suggested this indirection here so that he can monkey patch oedit to show eMZed related data) """ def conv_func(data): """Conversion function""" return data readonly = not is_known_type(obj) if isinstance(obj, np.ndarray): dialog = ArrayEditor(parent) if not dialog.setup_and_check(obj, title=title, readonly=readonly): return elif PILImage is not None and isinstance(obj, PILImage.Image): dialog = ArrayEditor(parent) data = np.array(obj) if not dialog.setup_and_check(data, title=title, readonly=readonly): return def conv_func(data): # pylint: disable=function-redefined """Conversion function""" return PILImage.fromarray(data, mode=obj.mode) elif isinstance(obj, (DataFrame, Series)) and DataFrame is not FakeObject: dialog = DataFrameEditor(parent) if not dialog.setup_and_check(obj): return elif isinstance(obj, str): dialog = TextEditor(obj, title=title, readonly=readonly, parent=parent) else: dialog = CollectionsEditor(parent) dialog.setup(obj, title=title, readonly=readonly) def end_func(dialog): """ :param dialog: :return: """ return conv_func(dialog.get_value()) return dialog, end_func def oedit( obj: dict | list | tuple | str | np.ndarray, title: str = None, parent: QW.QWidget = None, ) -> dict | list | tuple | str | np.ndarray: """Edit the object 'obj' in a GUI-based editor and return the edited copy (if Cancel is pressed, return None) Args: obj (dict | list | tuple | str | np.ndarray): object to edit title (str): dialog title parent (QW.QWidget): parent widget Returns: dict | list | tuple | str | np.ndarray: edited object """ title = "" if title is None else title result = create_dialog(obj, title, parent) if result is None: return dialog, end_func = result if exec_dialog(dialog): return end_func(dialog) return None ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/rotatedlabel.py0000644000175100017510000000407515114075001020766 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) """ rotatedlabel ------------ The ``guidata.widgets.rotatedlabel`` module provides a widget for displaying rotated text. """ from math import cos, pi, sin from qtpy.QtCore import QSize, Qt from qtpy.QtGui import QFont, QPainter, QPen from qtpy.QtWidgets import QLabel from guidata.configtools import get_family class RotatedLabel(QLabel): """ Rotated QLabel (rich text is not supported) Arguments: * parent: parent widget * angle=270 (int): rotation angle in degrees * family (string): font family * bold (bool): font weight * italic (bool): font italic style * color (QColor): font color """ def __init__( self, text, parent=None, angle=270, family=None, bold=False, italic=False, color=None, ): QLabel.__init__(self, text, parent) font = self.font() if family is not None: font.setFamily(get_family(family)) font.setBold(bold) font.setItalic(italic) font.setHintingPreference(QFont.PreferNoHinting) self.setFont(font) self.color = color self.angle = angle self.setAlignment(Qt.AlignCenter) def paintEvent(self, evt): painter = QPainter(self) if self.color is not None: painter.setPen(QPen(self.color)) painter.rotate(self.angle) transform = painter.transform().inverted()[0] rct = transform.mapRect(self.rect()) painter.drawText(rct, self.alignment(), self.text()) def sizeHint(self): hint = QLabel.sizeHint(self) width, height = hint.width(), hint.height() angle = self.angle * pi / 180 rotated_width = int(abs(width * cos(angle)) + abs(height * sin(angle))) rotated_height = int(abs(width * sin(angle)) + abs(height * cos(angle))) return QSize(rotated_width, rotated_height) def minimumSizeHint(self): return self.sizeHint() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/syntaxhighlighters.py0000644000175100017510000017014215114075001022253 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """ Editor widget syntax highlighters based on QtGui.QSyntaxHighlighter (Python syntax highlighting rules are inspired from idlelib) """ import builtins import keyword import re from qtpy.QtCore import Qt from qtpy.QtGui import ( QColor, QCursor, QFont, QSyntaxHighlighter, QTextCharFormat, QTextOption, ) from qtpy.QtWidgets import ( QApplication, ) from guidata.config import CONF, _ from guidata.qthelpers import is_dark_theme # ============================================================================= # Constants # ============================================================================= COLOR_SCHEME_KEYS = { "background": _("Background:"), "currentline": _("Current line:"), "currentcell": _("Current cell:"), "occurrence": _("Occurrence:"), "ctrlclick": _("Link:"), "sideareas": _("Side areas:"), "matched_p": _("Matched
parens:"), "unmatched_p": _("Unmatched
parens:"), "normal": _("Normal text:"), "keyword": _("Keyword:"), "builtin": _("Builtin:"), "definition": _("Definition:"), "comment": _("Comment:"), "string": _("String:"), "number": _("Number:"), "instance": _("Instance:"), } COLOR_SCHEME_NAMES = CONF.get("color_schemes", "names") # Mapping for file extensions that use Pygments highlighting but should use # different lexers than Pygments' autodetection suggests. Keys are file # extensions or tuples of extensions, values are Pygments lexer names. # ============================================================================== # Auxiliary functions # ============================================================================== def get_color_scheme(name): """Get a color scheme from config using its name""" name = name.lower() scheme = {} for key in COLOR_SCHEME_KEYS: try: scheme[key] = CONF.get("color_schemes", name + "/" + key) except: scheme[key] = CONF.get("color_schemes", "spyder/" + key) return scheme # ============================================================================== # Syntax highlighting color schemes # ============================================================================== class BaseSH(QSyntaxHighlighter): """Base Syntax Highlighter Class""" # Syntax highlighting rules: PROG = None BLANKPROG = re.compile(r"\s+") # Syntax highlighting states (from one text block to another): NORMAL = 0 # Syntax highlighting parameters. BLANK_ALPHA_FACTOR = 0.31 def __init__(self, parent, font=None, color_scheme=None): QSyntaxHighlighter.__init__(self, parent) self.font = font if color_scheme is None: suffix = "dark" if is_dark_theme() else "light" color_scheme = CONF.get("color_schemes", "default/" + suffix) if isinstance(color_scheme, str): self.color_scheme = get_color_scheme(color_scheme) else: self.color_scheme = color_scheme self.background_color = None self.currentline_color = None self.currentcell_color = None self.occurrence_color = None self.ctrlclick_color = None self.sideareas_color = None self.matched_p_color = None self.unmatched_p_color = None self.formats = None self.setup_formats(font) self.cell_separators = None def get_background_color(self): """ :return: """ return QColor(self.background_color) def get_foreground_color(self): """Return foreground ('normal' text) color""" return self.formats["normal"].foreground().color() def get_currentline_color(self): """ :return: """ return QColor(self.currentline_color) def get_currentcell_color(self): """ :return: """ return QColor(self.currentcell_color) def get_occurrence_color(self): """ :return: """ return QColor(self.occurrence_color) def get_ctrlclick_color(self): """ :return: """ return QColor(self.ctrlclick_color) def get_sideareas_color(self): """ :return: """ return QColor(self.sideareas_color) def get_matched_p_color(self): """ :return: """ return QColor(self.matched_p_color) def get_unmatched_p_color(self): """ :return: """ return QColor(self.unmatched_p_color) def get_comment_color(self): """Return color for the comments""" return self.formats["comment"].foreground().color() def get_color_name(self, fmt): """Return color name assigned to a given format""" return self.formats[fmt].foreground().color().name() def setup_formats(self, font=None): """ :param font: """ base_format = QTextCharFormat() if font is not None: self.font = font if self.font is not None: base_format.setFont(self.font) self.formats = {} colors = self.color_scheme.copy() self.background_color = colors.pop("background") self.currentline_color = colors.pop("currentline") self.currentcell_color = colors.pop("currentcell") self.occurrence_color = colors.pop("occurrence") self.ctrlclick_color = colors.pop("ctrlclick") self.sideareas_color = colors.pop("sideareas") self.matched_p_color = colors.pop("matched_p") self.unmatched_p_color = colors.pop("unmatched_p") for name, (color, bold, italic) in list(colors.items()): format = QTextCharFormat(base_format) format.setForeground(QColor(color)) format.setBackground(QColor(self.background_color)) if bold: format.setFontWeight(QFont.Bold) format.setFontItalic(italic) self.formats[name] = format def set_color_scheme(self, color_scheme): """ :param color_scheme: """ if isinstance(color_scheme, str): self.color_scheme = get_color_scheme(color_scheme) else: self.color_scheme = color_scheme self.setup_formats() self.rehighlight() def highlightBlock(self, text): """ :param text: """ raise NotImplementedError def highlight_spaces(self, text, offset=0): """ Make blank space less apparent by setting the foreground alpha. This only has an effect when 'Show blank space' is turned on. Derived classes could call this function at the end of highlightBlock(). """ flags_text = self.document().defaultTextOption().flags() show_blanks = flags_text & QTextOption.ShowTabsAndSpaces if show_blanks: format_leading = self.formats.get("leading", None) format_trailing = self.formats.get("trailing", None) match = self.BLANKPROG.search(text, offset) while match: start, end = match.span() start = max([0, start + offset]) end = max([0, end + offset]) # Format trailing spaces at the end of the line. if end == len(text) and format_trailing is not None: self.setFormat(start, end, format_trailing) # Format leading spaces, e.g. indentation. if start == 0 and format_leading is not None: self.setFormat(start, end, format_leading) format = self.format(start) color_foreground = format.foreground().color() alpha_new = self.BLANK_ALPHA_FACTOR * color_foreground.alphaF() color_foreground.setAlphaF(alpha_new) self.setFormat(start, end - start, color_foreground) match = self.BLANKPROG.search(text, match.end()) def rehighlight(self): """ """ QApplication.setOverrideCursor(QCursor(Qt.WaitCursor)) QSyntaxHighlighter.rehighlight(self) QApplication.restoreOverrideCursor() class TextSH(BaseSH): """Simple Text Syntax Highlighter Class (do nothing)""" def highlightBlock(self, text): pass class GenericSH(BaseSH): """Generic Syntax Highlighter""" # Syntax highlighting rules: PROG = None # to be redefined in child classes def highlightBlock(self, text): text = str(text) self.setFormat(0, len(text), self.formats["normal"]) match = self.PROG.search(text) index = 0 while match: for key, value in match.groupdict().items(): if value: start, end = match.span(key) index += end - start self.setFormat(start, end - start, self.formats[key]) match = self.PROG.search(text, match.end()) # ============================================================================== # Python syntax highlighter # ============================================================================== def any(name, alternates): "Return a named group pattern matching list of alternates." return "(?P<%s>" % name + "|".join(alternates) + ")" def make_python_patterns(additional_keywords=[], additional_builtins=[]): "Strongly inspired from idlelib.ColorDelegator.make_pat" kwlist = keyword.kwlist + additional_keywords builtinlist = [ str(name) for name in dir(builtins) if not name.startswith("_") ] + additional_builtins repeated = set(kwlist) & set(builtinlist) for repeated_element in repeated: kwlist.remove(repeated_element) kw = r"\b" + any("keyword", kwlist) + r"\b" builtin = r"([^.'\"\\#]\b|^)" + any("builtin", builtinlist) + r"\b" comment = any("comment", [r"#[^\n]*"]) instance = any( "instance", [ r"\bself\b", r"\bcls\b", (r"^\s*@([a-zA-Z_][a-zA-Z0-9_]*)" r"(\.[a-zA-Z_][a-zA-Z0-9_]*)*"), ], ) number_regex = [ r"\b[+-]?[0-9]+[lLjJ]?\b", r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", r"\b[+-]?0[oO][0-7]+[lL]?\b", r"\b[+-]?0[bB][01]+[lL]?\b", r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?[jJ]?\b", ] # Based on # https://github.com/python/cpython/blob/ # 81950495ba2c36056e0ce48fd37d514816c26747/Lib/tokenize.py#L117 # In order: Hexnumber, Binnumber, Octnumber, Decnumber, # Pointfloat + Exponent, Expfloat, Imagnumber number_regex = [ r"\b[+-]?0[xX](?:_?[0-9A-Fa-f])+[lL]?\b", r"\b[+-]?0[bB](?:_?[01])+[lL]?\b", r"\b[+-]?0[oO](?:_?[0-7])+[lL]?\b", r"\b[+-]?(?:0(?:_?0)*|[1-9](?:_?[0-9])*)[lL]?\b", r"\b((\.[0-9](?:_?[0-9])*')|\.[0-9](?:_?[0-9])*)" "([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", r"\b[0-9](?:_?[0-9])*([eE][+-]?[0-9](?:_?[0-9])*)?[jJ]?\b", r"\b[0-9](?:_?[0-9])*[jJ]\b", ] number = any("number", number_regex) sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' uf_sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*(\\)$(?!')$" uf_dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*(\\)$(?!")$' sq3string = r"(\b[rRuU])?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" dq3string = r'(\b[rRuU])?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' uf_sq3string = r"(\b[rRuU])?'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(\\)?(?!''')$" uf_dq3string = r'(\b[rRuU])?"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(\\)?(?!""")$' string = any("string", [sq3string, dq3string, sqstring, dqstring]) ufstring1 = any("uf_sqstring", [uf_sqstring]) ufstring2 = any("uf_dqstring", [uf_dqstring]) ufstring3 = any("uf_sq3string", [uf_sq3string]) ufstring4 = any("uf_dq3string", [uf_dq3string]) return "|".join( [ instance, kw, builtin, comment, ufstring1, ufstring2, ufstring3, ufstring4, string, number, any("SYNC", [r"\n"]), ] ) class PythonSH(BaseSH): """Python Syntax Highlighter""" # Syntax highlighting rules: add_kw = ["async", "await"] PROG = re.compile(make_python_patterns(additional_keywords=add_kw), re.S) IDPROG = re.compile(r"\s+(\w+)", re.S) ASPROG = re.compile(r".*?\b(as)\b") # Syntax highlighting states (from one text block to another): ( NORMAL, INSIDE_SQ3STRING, INSIDE_DQ3STRING, INSIDE_SQSTRING, INSIDE_DQSTRING, ) = list(range(5)) # Comments suitable for Outline Explorer OECOMMENT = re.compile(r"^(# ?--[-]+|##[#]+ )[ -]*[^- ]+") def __init__(self, parent, font=None, color_scheme=None): BaseSH.__init__(self, parent, font, color_scheme) self.import_statements = {} self.found_cell_separators = False def highlightBlock(self, text): """ :param text: """ text = str(text) prev_state = self.previousBlockState() if prev_state == self.INSIDE_DQ3STRING: offset = -4 text = r'""" ' + text elif prev_state == self.INSIDE_SQ3STRING: offset = -4 text = r"''' " + text elif prev_state == self.INSIDE_DQSTRING: offset = -2 text = r'" ' + text elif prev_state == self.INSIDE_SQSTRING: offset = -2 text = r"' " + text else: offset = 0 prev_state = self.NORMAL import_stmt = None self.setFormat(0, len(text), self.formats["normal"]) state = self.NORMAL match = self.PROG.search(text) while match: for key, value in list(match.groupdict().items()): if value: start, end = match.span(key) start = max([0, start + offset]) end = max([0, end + offset]) if key == "uf_sq3string": self.setFormat(start, end - start, self.formats["string"]) state = self.INSIDE_SQ3STRING elif key == "uf_dq3string": self.setFormat(start, end - start, self.formats["string"]) state = self.INSIDE_DQ3STRING elif key == "uf_sqstring": self.setFormat(start, end - start, self.formats["string"]) state = self.INSIDE_SQSTRING elif key == "uf_dqstring": self.setFormat(start, end - start, self.formats["string"]) state = self.INSIDE_DQSTRING else: self.setFormat(start, end - start, self.formats[key]) if key == "keyword": if value in ("def", "class"): match1 = self.IDPROG.match(text, end) if match1: start1, end1 = match1.span(1) self.setFormat( start1, end1 - start1, self.formats["definition"], ) elif value == "import": import_stmt = text.strip() # color all the "as" words on same line, except # if in a comment; cheap approximation to the # truth if "#" in text: endpos = text.index("#") else: endpos = len(text) while True: match1 = self.ASPROG.match(text, end, endpos) if not match1: break start, end = match1.span(1) self.setFormat( start, end - start, self.formats["keyword"] ) match = self.PROG.search(text, match.end()) self.setCurrentBlockState(state) # Use normal format for indentation and trailing spaces. self.formats["leading"] = self.formats["normal"] self.formats["trailing"] = self.formats["normal"] self.highlight_spaces(text, offset) if import_stmt is not None: block_nb = self.currentBlock().blockNumber() self.import_statements[block_nb] = import_stmt def get_import_statements(self): """ :return: """ return list(self.import_statements.values()) def rehighlight(self): """ """ self.import_statements = {} self.found_cell_separators = False BaseSH.rehighlight(self) # ============================================================================== # Cython syntax highlighter # ============================================================================== C_TYPES = "bool char double enum float int long mutable short signed struct unsigned void NULL" class CythonSH(PythonSH): """Cython Syntax Highlighter""" ADDITIONAL_KEYWORDS = [ "cdef", "ctypedef", "cpdef", "inline", "cimport", "extern", "include", "begin", "end", "by", "gil", "nogil", "const", "public", "readonly", "fused", "static", "api", "DEF", "IF", "ELIF", "ELSE", ] ADDITIONAL_BUILTINS = C_TYPES.split() + [ "array", "bint", "Py_ssize_t", "intern", "reload", "sizeof", "NULL", ] PROG = re.compile( make_python_patterns(ADDITIONAL_KEYWORDS, ADDITIONAL_BUILTINS), re.S ) IDPROG = re.compile(r"\s+([\w\.]+)", re.S) # ============================================================================== # Enaml syntax highlighter # ============================================================================== class EnamlSH(PythonSH): """Enaml Syntax Highlighter""" ADDITIONAL_KEYWORDS = [ "enamldef", "template", "attr", "event", "const", "alias", "func", ] ADDITIONAL_BUILTINS = [] PROG = re.compile( make_python_patterns(ADDITIONAL_KEYWORDS, ADDITIONAL_BUILTINS), re.S ) IDPROG = re.compile(r"\s+([\w\.]+)", re.S) # ============================================================================== # C/C++ syntax highlighter # ============================================================================== C_KEYWORDS1 = "and and_eq bitand bitor break case catch const const_cast continue default delete do dynamic_cast else explicit export extern for friend goto if inline namespace new not not_eq operator or or_eq private protected public register reinterpret_cast return sizeof static static_cast switch template throw try typedef typeid typename union using virtual while xor xor_eq" C_KEYWORDS2 = "a addindex addtogroup anchor arg attention author b brief bug c class code date def defgroup deprecated dontinclude e em endcode endhtmlonly ifdef endif endlatexonly endlink endverbatim enum example exception f$ file fn hideinitializer htmlinclude htmlonly if image include ingroup internal invariant interface latexonly li line link mainpage name namespace nosubgrouping note overload p page par param post pre ref relates remarks return retval sa section see showinitializer since skip skipline subsection test throw todo typedef union until var verbatim verbinclude version warning weakgroup" C_KEYWORDS3 = "asm auto class compl false true volatile wchar_t" def make_generic_c_patterns( keywords, builtins, instance=None, define=None, comment=None ): "Strongly inspired from idlelib.ColorDelegator.make_pat" kw = r"\b" + any("keyword", keywords.split()) + r"\b" builtin = r"\b" + any("builtin", builtins.split() + C_TYPES.split()) + r"\b" if comment is None: comment = any("comment", [r"//[^\n]*", r"\/\*(.*?)\*\/"]) comment_start = any("comment_start", [r"\/\*"]) comment_end = any("comment_end", [r"\*\/"]) if instance is None: instance = any("instance", [r"\bthis\b"]) number = any( "number", [ r"\b[+-]?[0-9]+[lL]?\b", r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", ], ) sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' string = any("string", [sqstring, dqstring]) if define is None: define = any("define", [r"#[^\n]*"]) return "|".join( [ instance, kw, comment, string, number, comment_start, comment_end, builtin, define, any("SYNC", [r"\n"]), ] ) def make_cpp_patterns(): return make_generic_c_patterns(C_KEYWORDS1 + " " + C_KEYWORDS2, C_KEYWORDS3) class CppSH(BaseSH): """C/C++ Syntax Highlighter""" # Syntax highlighting rules: PROG = re.compile(make_cpp_patterns(), re.S) # Syntax highlighting states (from one text block to another): NORMAL = 0 INSIDE_COMMENT = 1 def __init__(self, parent, font=None, color_scheme=None): BaseSH.__init__(self, parent, font, color_scheme) def highlightBlock(self, text): """Implement highlight specific for C/C++.""" text = str(text) inside_comment = self.previousBlockState() == self.INSIDE_COMMENT self.setFormat( 0, len(text), self.formats["comment" if inside_comment else "normal"], ) match = self.PROG.search(text) index = 0 while match: for key, value in list(match.groupdict().items()): if value: start, end = match.span(key) index += end - start if key == "comment_start": inside_comment = True self.setFormat( start, len(text) - start, self.formats["comment"] ) elif key == "comment_end": inside_comment = False self.setFormat(start, end - start, self.formats["comment"]) elif inside_comment: self.setFormat(start, end - start, self.formats["comment"]) elif key == "define": self.setFormat(start, end - start, self.formats["number"]) else: self.setFormat(start, end - start, self.formats[key]) match = self.PROG.search(text, match.end()) last_state = self.INSIDE_COMMENT if inside_comment else self.NORMAL self.setCurrentBlockState(last_state) def make_opencl_patterns(): # Keywords: kwstr1 = "cl_char cl_uchar cl_short cl_ushort cl_int cl_uint cl_long cl_ulong cl_half cl_float cl_double cl_platform_id cl_device_id cl_context cl_command_queue cl_mem cl_program cl_kernel cl_event cl_sampler cl_bool cl_bitfield cl_device_type cl_platform_info cl_device_info cl_device_address_info cl_device_fp_config cl_device_mem_cache_type cl_device_local_mem_type cl_device_exec_capabilities cl_command_queue_properties cl_context_properties cl_context_info cl_command_queue_info cl_channel_order cl_channel_type cl_mem_flags cl_mem_object_type cl_mem_info cl_image_info cl_addressing_mode cl_filter_mode cl_sampler_info cl_map_flags cl_program_info cl_program_build_info cl_build_status cl_kernel_info cl_kernel_work_group_info cl_event_info cl_command_type cl_profiling_info cl_image_format" # Constants: kwstr2 = "CL_FALSE, CL_TRUE, CL_PLATFORM_PROFILE, CL_PLATFORM_VERSION, CL_PLATFORM_NAME, CL_PLATFORM_VENDOR, CL_PLATFORM_EXTENSIONS, CL_DEVICE_TYPE_DEFAULT , CL_DEVICE_TYPE_CPU, CL_DEVICE_TYPE_GPU, CL_DEVICE_TYPE_ACCELERATOR, CL_DEVICE_TYPE_ALL, CL_DEVICE_TYPE, CL_DEVICE_VENDOR_ID, CL_DEVICE_MAX_COMPUTE_UNITS, CL_DEVICE_MAX_WORK_ITEM_DIMENSIONS, CL_DEVICE_MAX_WORK_GROUP_SIZE, CL_DEVICE_MAX_WORK_ITEM_SIZES, CL_DEVICE_PREFERRED_VECTOR_WIDTH_CHAR, CL_DEVICE_PREFERRED_VECTOR_WIDTH_SHORT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_INT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_LONG, CL_DEVICE_PREFERRED_VECTOR_WIDTH_FLOAT, CL_DEVICE_PREFERRED_VECTOR_WIDTH_DOUBLE, CL_DEVICE_MAX_CLOCK_FREQUENCY, CL_DEVICE_ADDRESS_BITS, CL_DEVICE_MAX_READ_IMAGE_ARGS, CL_DEVICE_MAX_WRITE_IMAGE_ARGS, CL_DEVICE_MAX_MEM_ALLOC_SIZE, CL_DEVICE_IMAGE2D_MAX_WIDTH, CL_DEVICE_IMAGE2D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_WIDTH, CL_DEVICE_IMAGE3D_MAX_HEIGHT, CL_DEVICE_IMAGE3D_MAX_DEPTH, CL_DEVICE_IMAGE_SUPPORT, CL_DEVICE_MAX_PARAMETER_SIZE, CL_DEVICE_MAX_SAMPLERS, CL_DEVICE_MEM_BASE_ADDR_ALIGN, CL_DEVICE_MIN_DATA_TYPE_ALIGN_SIZE, CL_DEVICE_SINGLE_FP_CONFIG, CL_DEVICE_GLOBAL_MEM_CACHE_TYPE, CL_DEVICE_GLOBAL_MEM_CACHELINE_SIZE, CL_DEVICE_GLOBAL_MEM_CACHE_SIZE, CL_DEVICE_GLOBAL_MEM_SIZE, CL_DEVICE_MAX_CONSTANT_BUFFER_SIZE, CL_DEVICE_MAX_CONSTANT_ARGS, CL_DEVICE_LOCAL_MEM_TYPE, CL_DEVICE_LOCAL_MEM_SIZE, CL_DEVICE_ERROR_CORRECTION_SUPPORT, CL_DEVICE_PROFILING_TIMER_RESOLUTION, CL_DEVICE_ENDIAN_LITTLE, CL_DEVICE_AVAILABLE, CL_DEVICE_COMPILER_AVAILABLE, CL_DEVICE_EXECUTION_CAPABILITIES, CL_DEVICE_QUEUE_PROPERTIES, CL_DEVICE_NAME, CL_DEVICE_VENDOR, CL_DRIVER_VERSION, CL_DEVICE_PROFILE, CL_DEVICE_VERSION, CL_DEVICE_EXTENSIONS, CL_DEVICE_PLATFORM, CL_FP_DENORM, CL_FP_INF_NAN, CL_FP_ROUND_TO_NEAREST, CL_FP_ROUND_TO_ZERO, CL_FP_ROUND_TO_INF, CL_FP_FMA, CL_NONE, CL_READ_ONLY_CACHE, CL_READ_WRITE_CACHE, CL_LOCAL, CL_GLOBAL, CL_EXEC_KERNEL, CL_EXEC_NATIVE_KERNEL, CL_QUEUE_OUT_OF_ORDER_EXEC_MODE_ENABLE, CL_QUEUE_PROFILING_ENABLE, CL_CONTEXT_REFERENCE_COUNT, CL_CONTEXT_DEVICES, CL_CONTEXT_PROPERTIES, CL_CONTEXT_PLATFORM, CL_QUEUE_CONTEXT, CL_QUEUE_DEVICE, CL_QUEUE_REFERENCE_COUNT, CL_QUEUE_PROPERTIES, CL_MEM_READ_WRITE, CL_MEM_WRITE_ONLY, CL_MEM_READ_ONLY, CL_MEM_USE_HOST_PTR, CL_MEM_ALLOC_HOST_PTR, CL_MEM_COPY_HOST_PTR, CL_R, CL_A, CL_RG, CL_RA, CL_RGB, CL_RGBA, CL_BGRA, CL_ARGB, CL_INTENSITY, CL_LUMINANCE, CL_SNORM_INT8, CL_SNORM_INT16, CL_UNORM_INT8, CL_UNORM_INT16, CL_UNORM_SHORT_565, CL_UNORM_SHORT_555, CL_UNORM_INT_101010, CL_SIGNED_INT8, CL_SIGNED_INT16, CL_SIGNED_INT32, CL_UNSIGNED_INT8, CL_UNSIGNED_INT16, CL_UNSIGNED_INT32, CL_HALF_FLOAT, CL_FLOAT, CL_MEM_OBJECT_BUFFER, CL_MEM_OBJECT_IMAGE2D, CL_MEM_OBJECT_IMAGE3D, CL_MEM_TYPE, CL_MEM_FLAGS, CL_MEM_SIZECL_MEM_HOST_PTR, CL_MEM_HOST_PTR, CL_MEM_MAP_COUNT, CL_MEM_REFERENCE_COUNT, CL_MEM_CONTEXT, CL_IMAGE_FORMAT, CL_IMAGE_ELEMENT_SIZE, CL_IMAGE_ROW_PITCH, CL_IMAGE_SLICE_PITCH, CL_IMAGE_WIDTH, CL_IMAGE_HEIGHT, CL_IMAGE_DEPTH, CL_ADDRESS_NONE, CL_ADDRESS_CLAMP_TO_EDGE, CL_ADDRESS_CLAMP, CL_ADDRESS_REPEAT, CL_FILTER_NEAREST, CL_FILTER_LINEAR, CL_SAMPLER_REFERENCE_COUNT, CL_SAMPLER_CONTEXT, CL_SAMPLER_NORMALIZED_COORDS, CL_SAMPLER_ADDRESSING_MODE, CL_SAMPLER_FILTER_MODE, CL_MAP_READ, CL_MAP_WRITE, CL_PROGRAM_REFERENCE_COUNT, CL_PROGRAM_CONTEXT, CL_PROGRAM_NUM_DEVICES, CL_PROGRAM_DEVICES, CL_PROGRAM_SOURCE, CL_PROGRAM_BINARY_SIZES, CL_PROGRAM_BINARIES, CL_PROGRAM_BUILD_STATUS, CL_PROGRAM_BUILD_OPTIONS, CL_PROGRAM_BUILD_LOG, CL_BUILD_SUCCESS, CL_BUILD_NONE, CL_BUILD_ERROR, CL_BUILD_IN_PROGRESS, CL_KERNEL_FUNCTION_NAME, CL_KERNEL_NUM_ARGS, CL_KERNEL_REFERENCE_COUNT, CL_KERNEL_CONTEXT, CL_KERNEL_PROGRAM, CL_KERNEL_WORK_GROUP_SIZE, CL_KERNEL_COMPILE_WORK_GROUP_SIZE, CL_KERNEL_LOCAL_MEM_SIZE, CL_EVENT_COMMAND_QUEUE, CL_EVENT_COMMAND_TYPE, CL_EVENT_REFERENCE_COUNT, CL_EVENT_COMMAND_EXECUTION_STATUS, CL_COMMAND_NDRANGE_KERNEL, CL_COMMAND_TASK, CL_COMMAND_NATIVE_KERNEL, CL_COMMAND_READ_BUFFER, CL_COMMAND_WRITE_BUFFER, CL_COMMAND_COPY_BUFFER, CL_COMMAND_READ_IMAGE, CL_COMMAND_WRITE_IMAGE, CL_COMMAND_COPY_IMAGE, CL_COMMAND_COPY_IMAGE_TO_BUFFER, CL_COMMAND_COPY_BUFFER_TO_IMAGE, CL_COMMAND_MAP_BUFFER, CL_COMMAND_MAP_IMAGE, CL_COMMAND_UNMAP_MEM_OBJECT, CL_COMMAND_MARKER, CL_COMMAND_ACQUIRE_GL_OBJECTS, CL_COMMAND_RELEASE_GL_OBJECTS, command execution status, CL_COMPLETE, CL_RUNNING, CL_SUBMITTED, CL_QUEUED, CL_PROFILING_COMMAND_QUEUED, CL_PROFILING_COMMAND_SUBMIT, CL_PROFILING_COMMAND_START, CL_PROFILING_COMMAND_END, CL_CHAR_BIT, CL_SCHAR_MAX, CL_SCHAR_MIN, CL_CHAR_MAX, CL_CHAR_MIN, CL_UCHAR_MAX, CL_SHRT_MAX, CL_SHRT_MIN, CL_USHRT_MAX, CL_INT_MAX, CL_INT_MIN, CL_UINT_MAX, CL_LONG_MAX, CL_LONG_MIN, CL_ULONG_MAX, CL_FLT_DIG, CL_FLT_MANT_DIG, CL_FLT_MAX_10_EXP, CL_FLT_MAX_EXP, CL_FLT_MIN_10_EXP, CL_FLT_MIN_EXP, CL_FLT_RADIX, CL_FLT_MAX, CL_FLT_MIN, CL_FLT_EPSILON, CL_DBL_DIG, CL_DBL_MANT_DIG, CL_DBL_MAX_10_EXP, CL_DBL_MAX_EXP, CL_DBL_MIN_10_EXP, CL_DBL_MIN_EXP, CL_DBL_RADIX, CL_DBL_MAX, CL_DBL_MIN, CL_DBL_EPSILON, CL_SUCCESS, CL_DEVICE_NOT_FOUND, CL_DEVICE_NOT_AVAILABLE, CL_COMPILER_NOT_AVAILABLE, CL_MEM_OBJECT_ALLOCATION_FAILURE, CL_OUT_OF_RESOURCES, CL_OUT_OF_HOST_MEMORY, CL_PROFILING_INFO_NOT_AVAILABLE, CL_MEM_COPY_OVERLAP, CL_IMAGE_FORMAT_MISMATCH, CL_IMAGE_FORMAT_NOT_SUPPORTED, CL_BUILD_PROGRAM_FAILURE, CL_MAP_FAILURE, CL_INVALID_VALUE, CL_INVALID_DEVICE_TYPE, CL_INVALID_PLATFORM, CL_INVALID_DEVICE, CL_INVALID_CONTEXT, CL_INVALID_QUEUE_PROPERTIES, CL_INVALID_COMMAND_QUEUE, CL_INVALID_HOST_PTR, CL_INVALID_MEM_OBJECT, CL_INVALID_IMAGE_FORMAT_DESCRIPTOR, CL_INVALID_IMAGE_SIZE, CL_INVALID_SAMPLER, CL_INVALID_BINARY, CL_INVALID_BUILD_OPTIONS, CL_INVALID_PROGRAM, CL_INVALID_PROGRAM_EXECUTABLE, CL_INVALID_KERNEL_NAME, CL_INVALID_KERNEL_DEFINITION, CL_INVALID_KERNEL, CL_INVALID_ARG_INDEX, CL_INVALID_ARG_VALUE, CL_INVALID_ARG_SIZE, CL_INVALID_KERNEL_ARGS, CL_INVALID_WORK_DIMENSION, CL_INVALID_WORK_GROUP_SIZE, CL_INVALID_WORK_ITEM_SIZE, CL_INVALID_GLOBAL_OFFSET, CL_INVALID_EVENT_WAIT_LIST, CL_INVALID_EVENT, CL_INVALID_OPERATION, CL_INVALID_GL_OBJECT, CL_INVALID_BUFFER_SIZE, CL_INVALID_MIP_LEVEL, CL_INVALID_GLOBAL_WORK_SIZE" # Functions: builtins = "clGetPlatformIDs, clGetPlatformInfo, clGetDeviceIDs, clGetDeviceInfo, clCreateContext, clCreateContextFromType, clReleaseContext, clGetContextInfo, clCreateCommandQueue, clRetainCommandQueue, clReleaseCommandQueue, clGetCommandQueueInfo, clSetCommandQueueProperty, clCreateBuffer, clCreateImage2D, clCreateImage3D, clRetainMemObject, clReleaseMemObject, clGetSupportedImageFormats, clGetMemObjectInfo, clGetImageInfo, clCreateSampler, clRetainSampler, clReleaseSampler, clGetSamplerInfo, clCreateProgramWithSource, clCreateProgramWithBinary, clRetainProgram, clReleaseProgram, clBuildProgram, clUnloadCompiler, clGetProgramInfo, clGetProgramBuildInfo, clCreateKernel, clCreateKernelsInProgram, clRetainKernel, clReleaseKernel, clSetKernelArg, clGetKernelInfo, clGetKernelWorkGroupInfo, clWaitForEvents, clGetEventInfo, clRetainEvent, clReleaseEvent, clGetEventProfilingInfo, clFlush, clFinish, clEnqueueReadBuffer, clEnqueueWriteBuffer, clEnqueueCopyBuffer, clEnqueueReadImage, clEnqueueWriteImage, clEnqueueCopyImage, clEnqueueCopyImageToBuffer, clEnqueueCopyBufferToImage, clEnqueueMapBuffer, clEnqueueMapImage, clEnqueueUnmapMemObject, clEnqueueNDRangeKernel, clEnqueueTask, clEnqueueNativeKernel, clEnqueueMarker, clEnqueueWaitForEvents, clEnqueueBarrier" # Qualifiers: qualifiers = "__global __local __constant __private __kernel" keyword_list = C_KEYWORDS1 + " " + C_KEYWORDS2 + " " + kwstr1 + " " + kwstr2 builtin_list = C_KEYWORDS3 + " " + builtins + " " + qualifiers return make_generic_c_patterns(keyword_list, builtin_list) class OpenCLSH(CppSH): """OpenCL Syntax Highlighter""" PROG = re.compile(make_opencl_patterns(), re.S) # ============================================================================== # Fortran Syntax Highlighter # ============================================================================== def make_fortran_patterns(): "Strongly inspired from idlelib.ColorDelegator.make_pat" kwstr = "access action advance allocatable allocate apostrophe assign assignment associate asynchronous backspace bind blank blockdata call case character class close common complex contains continue cycle data deallocate decimal delim default dimension direct do dowhile double doubleprecision else elseif elsewhere encoding end endassociate endblockdata enddo endfile endforall endfunction endif endinterface endmodule endprogram endselect endsubroutine endtype endwhere entry eor equivalence err errmsg exist exit external file flush fmt forall form format formatted function go goto id if implicit in include inout integer inquire intent interface intrinsic iomsg iolength iostat kind len logical module name named namelist nextrec nml none nullify number only open opened operator optional out pad parameter pass pause pending pointer pos position precision print private program protected public quote read readwrite real rec recl recursive result return rewind save select selectcase selecttype sequential sign size stat status stop stream subroutine target then to type unformatted unit use value volatile wait where while write" bistr1 = "abs achar acos acosd adjustl adjustr aimag aimax0 aimin0 aint ajmax0 ajmin0 akmax0 akmin0 all allocated alog alog10 amax0 amax1 amin0 amin1 amod anint any asin asind associated atan atan2 atan2d atand bitest bitl bitlr bitrl bjtest bit_size bktest break btest cabs ccos cdabs cdcos cdexp cdlog cdsin cdsqrt ceiling cexp char clog cmplx conjg cos cosd cosh count cpu_time cshift csin csqrt dabs dacos dacosd dasin dasind datan datan2 datan2d datand date date_and_time dble dcmplx dconjg dcos dcosd dcosh dcotan ddim dexp dfloat dflotk dfloti dflotj digits dim dimag dint dlog dlog10 dmax1 dmin1 dmod dnint dot_product dprod dreal dsign dsin dsind dsinh dsqrt dtan dtand dtanh eoshift epsilon errsns exp exponent float floati floatj floatk floor fraction free huge iabs iachar iand ibclr ibits ibset ichar idate idim idint idnint ieor ifix iiabs iiand iibclr iibits iibset iidim iidint iidnnt iieor iifix iint iior iiqint iiqnnt iishft iishftc iisign ilen imax0 imax1 imin0 imin1 imod index inint inot int int1 int2 int4 int8 iqint iqnint ior ishft ishftc isign isnan izext jiand jibclr jibits jibset jidim jidint jidnnt jieor jifix jint jior jiqint jiqnnt jishft jishftc jisign jmax0 jmax1 jmin0 jmin1 jmod jnint jnot jzext kiabs kiand kibclr kibits kibset kidim kidint kidnnt kieor kifix kind kint kior kishft kishftc kisign kmax0 kmax1 kmin0 kmin1 kmod knint knot kzext lbound leadz len len_trim lenlge lge lgt lle llt log log10 logical lshift malloc matmul max max0 max1 maxexponent maxloc maxval merge min min0 min1 minexponent minloc minval mod modulo mvbits nearest nint not nworkers number_of_processors pack popcnt poppar precision present product radix random random_number random_seed range real repeat reshape rrspacing rshift scale scan secnds selected_int_kind selected_real_kind set_exponent shape sign sin sind sinh size sizeof sngl snglq spacing spread sqrt sum system_clock tan tand tanh tiny transfer transpose trim ubound unpack verify" bistr2 = "cdabs cdcos cdexp cdlog cdsin cdsqrt cotan cotand dcmplx dconjg dcotan dcotand decode dimag dll_export dll_import doublecomplex dreal dvchk encode find flen flush getarg getcharqq getcl getdat getenv gettim hfix ibchng identifier imag int1 int2 int4 intc intrup invalop iostat_msg isha ishc ishl jfix lacfar locking locnear map nargs nbreak ndperr ndpexc offset ovefl peekcharqq precfill prompt qabs qacos qacosd qasin qasind qatan qatand qatan2 qcmplx qconjg qcos qcosd qcosh qdim qexp qext qextd qfloat qimag qlog qlog10 qmax1 qmin1 qmod qreal qsign qsin qsind qsinh qsqrt qtan qtand qtanh ran rand randu rewrite segment setdat settim system timer undfl unlock union val virtual volatile zabs zcos zexp zlog zsin zsqrt" kw = r"\b" + any("keyword", kwstr.split()) + r"\b" builtin = r"\b" + any("builtin", bistr1.split() + bistr2.split()) + r"\b" comment = any("comment", [r"\![^\n]*"]) number = any( "number", [ r"\b[+-]?[0-9]+[lL]?\b", r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", ], ) sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' string = any("string", [sqstring, dqstring]) return "|".join([kw, comment, string, number, builtin, any("SYNC", [r"\n"])]) class FortranSH(BaseSH): """Fortran Syntax Highlighter""" # Syntax highlighting rules: PROG = re.compile(make_fortran_patterns(), re.S | re.I) IDPROG = re.compile(r"\s+(\w+)", re.S) # Syntax highlighting states (from one text block to another): NORMAL = 0 def __init__(self, parent, font=None, color_scheme=None): BaseSH.__init__(self, parent, font, color_scheme) def highlightBlock(self, text): """Implement highlight specific for Fortran.""" text = str(text) self.setFormat(0, len(text), self.formats["normal"]) match = self.PROG.search(text) index = 0 while match: for key, value in list(match.groupdict().items()): if value: start, end = match.span(key) index += end - start self.setFormat(start, end - start, self.formats[key]) if value.lower() in ("subroutine", "module", "function"): match1 = self.IDPROG.match(text, end) if match1: start1, end1 = match1.span(1) self.setFormat( start1, end1 - start1, self.formats["definition"] ) match = self.PROG.search(text, match.end()) class Fortran77SH(FortranSH): """Fortran 77 Syntax Highlighter""" def highlightBlock(self, text): """Implement highlight specific for Fortran77.""" text = str(text) if text.startswith(("c", "C")): self.setFormat(0, len(text), self.formats["comment"]) else: FortranSH.highlightBlock(self, text) self.setFormat(0, 5, self.formats["comment"]) self.setFormat(73, max([73, len(text)]), self.formats["comment"]) # ============================================================================== # IDL highlighter # # Contribution from Stuart Mumford (Littlemumford) - 2012-02-02 # See spyder-ide/spyder#850. # ============================================================================== def make_idl_patterns(): """Strongly inspired by idlelib.ColorDelegator.make_pat.""" kwstr = "begin of pro function endfor endif endwhile endrep endcase endswitch end if then else for do while repeat until break case switch common continue exit return goto help message print read retall stop" bistr1 = "a_correlate abs acos adapt_hist_equal alog alog10 amoeba arg_present arra_equal array_indices ascii_template asin assoc atan beseli beselj besel k besely beta bilinear bin_date binary_template dinfgen dinomial blk_con broyden bytarr byte bytscl c_correlate call_external call_function ceil chebyshev check_math chisqr_cvf chisqr_pdf choldc cholsol cindgen clust_wts cluster color_quan colormap_applicable comfit complex complexarr complexround compute_mesh_normals cond congrid conj convert_coord convol coord2to3 correlate cos cosh cramer create_struct crossp crvlength ct_luminance cti_test curvefit cv_coord cvttobm cw_animate cw_arcball cw_bgroup cw_clr_index cw_colorsel cw_defroi cw_field cw_filesel cw_form cw_fslider cw_light_editor cw_orient cw_palette_editor cw_pdmenu cw_rgbslider cw_tmpl cw_zoom dblarr dcindgen dcomplexarr defroi deriv derivsig determ diag_matrix dialog_message dialog_pickfile pialog_printersetup dialog_printjob dialog_read_image dialog_write_image digital_filter dilate dindgen dist double eigenql eigenvec elmhes eof erode erf erfc erfcx execute exp expand_path expint extrac extract_slice f_cvf f_pdf factorial fft file_basename file_dirname file_expand_path file_info file_same file_search file_test file_which filepath findfile findgen finite fix float floor fltarr format_axis_values fstat fulstr fv_test fx_root fz_roots gamma gauss_cvf gauss_pdf gauss2dfit gaussfit gaussint get_drive_list get_kbrd get_screen_size getenv grid_tps grid3 griddata gs_iter hanning hdf_browser hdf_read hilbert hist_2d hist_equal histogram hough hqr ibeta identity idl_validname idlitsys_createtool igamma imaginary indgen int_2d int_3d int_tabulated intarr interpol interpolate invert ioctl ishft julday keword_set krig2d kurtosis kw_test l64indgen label_date label_region ladfit laguerre la_cholmprove la_cholsol la_Determ la_eigenproblem la_eigenql la_eigenvec la_elmhes la_gm_linear_model la_hqr la_invert la_least_square_equality la_least_squares la_linear_equation la_lumprove la_lusol la_trimprove la_trisol leefit legendre linbcg lindgen linfit ll_arc_distance lmfit lmgr lngamma lnp_test locale_get logical_and logical_or logical_true lon64arr lonarr long long64 lsode lu_complex lumprove lusol m_correlate machar make_array map_2points map_image map_patch map_proj_forward map_proj_init map_proj_inverse matrix_multiply matrix_power max md_test mean meanabsdev median memory mesh_clip mesh_decimate mesh_issolid mesh_merge mesh_numtriangles mesh_smooth mesh_surfacearea mesh_validate mesh_volume min min_curve_surf moment morph_close morph_distance morph_gradient morph_histormiss morph_open morph_thin morph_tophat mpeg_open msg_cat_open n_elements n_params n_tags newton norm obj_class obj_isa obj_new obj_valid objarr p_correlate path_sep pcomp pnt_line polar_surface poly poly_2d poly_area poly_fit polyfillv ployshade primes product profile profiles project_vol ptr_new ptr_valid ptrarr qgrid3 qromb qromo qsimp query_bmp query_dicom query_image query_jpeg query_mrsid query_pict query_png query_ppm query_srf query_tiff query_wav r_correlate r_test radon randomn randomu ranks read_ascii read_binary read_bmp read_dicom read_image read_mrsid read_png read_spr read_sylk read_tiff read_wav read_xwd real_part rebin recall_commands recon3 reform region_grow regress replicate reverse rk4 roberts rot rotate round routine_info rs_test s_test savgol search2d search3d sfit shift shmdebug shmvar simplex sin sindgen sinh size skewness smooth sobel sort sph_scat spher_harm spl_init spl_interp spline spline_p sprsab sprsax sprsin sprstp sqrt standardize stddev strarr strcmp strcompress stregex string strjoin strlen strlowcase strmatch strmessage strmid strpos strsplit strtrim strupcase svdfit svsol swap_endian systime t_cvf t_pdf tag_names tan tanh temporary tetra_clip tetra_surface tetra_volume thin timegen tm_test total trace transpose tri_surf trigrid trisol ts_coef ts_diff ts_fcast ts_smooth tvrd uindgen unit uintarr ul64indgen ulindgen ulon64arr ulonarr ulong ulong64 uniq value_locate variance vert_t3d voigt voxel_proj warp_tri watershed where widget_actevix widget_base widget_button widget_combobox widget_draw widget_droplist widget_event widget_info widget_label widget_list widget_propertsheet widget_slider widget_tab widget_table widget_text widget_tree write_sylk wtn xfont xregistered xsq_test" bistr2 = "annotate arrow axis bar_plot blas_axpy box_cursor breakpoint byteorder caldata calendar call_method call_procedure catch cd cir_3pnt close color_convert compile_opt constrained_min contour copy_lun cpu create_view cursor cw_animate_getp cw_animate_load cw_animate_run cw_light_editor_get cw_light_editor_set cw_palette_editor_get cw_palette_editor_set define_key define_msgblk define_msgblk_from_file defsysv delvar device dfpmin dissolve dlm_load doc_librar draw_roi efont empty enable_sysrtn erase errplot expand file_chmod file_copy file_delete file_lines file_link file_mkdir file_move file_readlink flick flow3 flush forward_function free_lun funct gamma_ct get_lun grid_input h_eq_ct h_eq_int heap_free heap_gc hls hsv icontour iimage image_cont image_statistics internal_volume iplot isocontour isosurface isurface itcurrent itdelete itgetcurrent itregister itreset ivolume journal la_choldc la_ludc la_svd la_tridc la_triql la_trired linkimage loadct ludc make_dll map_continents map_grid map_proj_info map_set mesh_obj mk_html_help modifyct mpeg_close mpeg_put mpeg_save msg_cat_close msg_cat_compile multi obj_destroy on_error on_ioerror online_help openr openw openu oplot oploterr particle_trace path_cache plot plot_3dbox plot_field ploterr plots point_lun polar_contour polyfill polywarp popd powell printf printd ps_show_fonts psafm pseudo ptr_free pushd qhull rdpix readf read_interfile read_jpeg read_pict read_ppm read_srf read_wave read_x11_bitmap reads readu reduce_colors register_cursor replicate_inplace resolve_all resolve_routine restore save scale3 scale3d set_plot set_shading setenv setup_keys shade_surf shade_surf_irr shade_volume shmmap show3 showfont skip_lun slicer3 slide_image socket spawn sph_4pnt streamline stretch strput struct_assign struct_hide surface surfr svdc swap_enian_inplace t3d tek_color threed time_test2 triangulate triql trired truncate_lun tv tvcrs tvlct tvscl usersym vector_field vel velovect voronoi wait wdelete wf_draw widget_control widget_displaycontextmenu window write_bmp write_image write_jpeg write_nrif write_pict write_png write_ppm write_spr write_srf write_tiff write_wav write_wave writeu wset wshow xbm_edit xdisplayfile xdxf xinteranimate xloadct xmanager xmng_tmpl xmtool xobjview xobjview_rotate xobjview_write_image xpalette xpcolo xplot3d xroi xsurface xvaredit xvolume xyouts zoom zoom_24" kw = r"\b" + any("keyword", kwstr.split()) + r"\b" builtin = r"\b" + any("builtin", bistr1.split() + bistr2.split()) + r"\b" comment = any("comment", [r"\;[^\n]*"]) number = any( "number", [ r"\b[+-]?[0-9]+[lL]?\b", r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", r"\b\.[0-9]d0|\.d0+[lL]?\b", r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", ], ) sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' string = any("string", [sqstring, dqstring]) return "|".join([kw, comment, string, number, builtin, any("SYNC", [r"\n"])]) class IdlSH(GenericSH): """IDL Syntax Highlighter""" PROG = re.compile(make_idl_patterns(), re.S | re.I) # ============================================================================== # Diff/Patch highlighter # ============================================================================== class DiffSH(BaseSH): """Simple Diff/Patch Syntax Highlighter Class""" def highlightBlock(self, text): """Implement highlight specific Diff/Patch files.""" text = str(text) if text.startswith("+++"): self.setFormat(0, len(text), self.formats["keyword"]) elif text.startswith("---"): self.setFormat(0, len(text), self.formats["keyword"]) elif text.startswith("+"): self.setFormat(0, len(text), self.formats["string"]) elif text.startswith("-"): self.setFormat(0, len(text), self.formats["number"]) elif text.startswith("@"): self.setFormat(0, len(text), self.formats["builtin"]) # ============================================================================== # NSIS highlighter # ============================================================================== def make_nsis_patterns(): "Strongly inspired from idlelib.ColorDelegator.make_pat" kwstr1 = "Abort AddBrandingImage AddSize AllowRootDirInstall AllowSkipFiles AutoCloseWindow BGFont BGGradient BrandingText BringToFront Call CallInstDLL Caption ClearErrors CompletedText ComponentText CopyFiles CRCCheck CreateDirectory CreateFont CreateShortCut Delete DeleteINISec DeleteINIStr DeleteRegKey DeleteRegValue DetailPrint DetailsButtonText DirText DirVar DirVerify EnableWindow EnumRegKey EnumRegValue Exec ExecShell ExecWait Exch ExpandEnvStrings File FileBufSize FileClose FileErrorText FileOpen FileRead FileReadByte FileSeek FileWrite FileWriteByte FindClose FindFirst FindNext FindWindow FlushINI Function FunctionEnd GetCurInstType GetCurrentAddress GetDlgItem GetDLLVersion GetDLLVersionLocal GetErrorLevel GetFileTime GetFileTimeLocal GetFullPathName GetFunctionAddress GetInstDirError GetLabelAddress GetTempFileName Goto HideWindow ChangeUI CheckBitmap Icon IfAbort IfErrors IfFileExists IfRebootFlag IfSilent InitPluginsDir InstallButtonText InstallColors InstallDir InstallDirRegKey InstProgressFlags InstType InstTypeGetText InstTypeSetText IntCmp IntCmpU IntFmt IntOp IsWindow LangString LicenseBkColor LicenseData LicenseForceSelection LicenseLangString LicenseText LoadLanguageFile LogSet LogText MessageBox MiscButtonText Name OutFile Page PageCallbacks PageEx PageExEnd Pop Push Quit ReadEnvStr ReadINIStr ReadRegDWORD ReadRegStr Reboot RegDLL Rename ReserveFile Return RMDir SearchPath Section SectionEnd SectionGetFlags SectionGetInstTypes SectionGetSize SectionGetText SectionIn SectionSetFlags SectionSetInstTypes SectionSetSize SectionSetText SendMessage SetAutoClose SetBrandingImage SetCompress SetCompressor SetCompressorDictSize SetCtlColors SetCurInstType SetDatablockOptimize SetDateSave SetDetailsPrint SetDetailsView SetErrorLevel SetErrors SetFileAttributes SetFont SetOutPath SetOverwrite SetPluginUnload SetRebootFlag SetShellVarContext SetSilent ShowInstDetails ShowUninstDetails ShowWindow SilentInstall SilentUnInstall Sleep SpaceTexts StrCmp StrCpy StrLen SubCaption SubSection SubSectionEnd UninstallButtonText UninstallCaption UninstallIcon UninstallSubCaption UninstallText UninstPage UnRegDLL Var VIAddVersionKey VIProductVersion WindowIcon WriteINIStr WriteRegBin WriteRegDWORD WriteRegExpandStr WriteRegStr WriteUninstaller XPStyle" kwstr2 = "all alwaysoff ARCHIVE auto both bzip2 components current custom details directory false FILE_ATTRIBUTE_ARCHIVE FILE_ATTRIBUTE_HIDDEN FILE_ATTRIBUTE_NORMAL FILE_ATTRIBUTE_OFFLINE FILE_ATTRIBUTE_READONLY FILE_ATTRIBUTE_SYSTEM FILE_ATTRIBUTE_TEMPORARY force grey HIDDEN hide IDABORT IDCANCEL IDIGNORE IDNO IDOK IDRETRY IDYES ifdiff ifnewer instfiles instfiles lastused leave left level license listonly lzma manual MB_ABORTRETRYIGNORE MB_DEFBUTTON1 MB_DEFBUTTON2 MB_DEFBUTTON3 MB_DEFBUTTON4 MB_ICONEXCLAMATION MB_ICONINFORMATION MB_ICONQUESTION MB_ICONSTOP MB_OK MB_OKCANCEL MB_RETRYCANCEL MB_RIGHT MB_SETFOREGROUND MB_TOPMOST MB_YESNO MB_YESNOCANCEL nevershow none NORMAL off OFFLINE on READONLY right RO show silent silentlog SYSTEM TEMPORARY text textonly true try uninstConfirm windows zlib" kwstr3 = "MUI_ABORTWARNING MUI_ABORTWARNING_CANCEL_DEFAULT MUI_ABORTWARNING_TEXT MUI_BGCOLOR MUI_COMPONENTSPAGE_CHECKBITMAP MUI_COMPONENTSPAGE_NODESC MUI_COMPONENTSPAGE_SMALLDESC MUI_COMPONENTSPAGE_TEXT_COMPLIST MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_INFO MUI_COMPONENTSPAGE_TEXT_DESCRIPTION_TITLE MUI_COMPONENTSPAGE_TEXT_INSTTYPE MUI_COMPONENTSPAGE_TEXT_TOP MUI_CUSTOMFUNCTION_ABORT MUI_CUSTOMFUNCTION_GUIINIT MUI_CUSTOMFUNCTION_UNABORT MUI_CUSTOMFUNCTION_UNGUIINIT MUI_DESCRIPTION_TEXT MUI_DIRECTORYPAGE_BGCOLOR MUI_DIRECTORYPAGE_TEXT_DESTINATION MUI_DIRECTORYPAGE_TEXT_TOP MUI_DIRECTORYPAGE_VARIABLE MUI_DIRECTORYPAGE_VERIFYONLEAVE MUI_FINISHPAGE_BUTTON MUI_FINISHPAGE_CANCEL_ENABLED MUI_FINISHPAGE_LINK MUI_FINISHPAGE_LINK_COLOR MUI_FINISHPAGE_LINK_LOCATION MUI_FINISHPAGE_NOAUTOCLOSE MUI_FINISHPAGE_NOREBOOTSUPPORT MUI_FINISHPAGE_REBOOTLATER_DEFAULT MUI_FINISHPAGE_RUN MUI_FINISHPAGE_RUN_FUNCTION MUI_FINISHPAGE_RUN_NOTCHECKED MUI_FINISHPAGE_RUN_PARAMETERS MUI_FINISHPAGE_RUN_TEXT MUI_FINISHPAGE_SHOWREADME MUI_FINISHPAGE_SHOWREADME_FUNCTION MUI_FINISHPAGE_SHOWREADME_NOTCHECKED MUI_FINISHPAGE_SHOWREADME_TEXT MUI_FINISHPAGE_TEXT MUI_FINISHPAGE_TEXT_LARGE MUI_FINISHPAGE_TEXT_REBOOT MUI_FINISHPAGE_TEXT_REBOOTLATER MUI_FINISHPAGE_TEXT_REBOOTNOW MUI_FINISHPAGE_TITLE MUI_FINISHPAGE_TITLE_3LINES MUI_FUNCTION_DESCRIPTION_BEGIN MUI_FUNCTION_DESCRIPTION_END MUI_HEADER_TEXT MUI_HEADER_TRANSPARENT_TEXT MUI_HEADERIMAGE MUI_HEADERIMAGE_BITMAP MUI_HEADERIMAGE_BITMAP_NOSTRETCH MUI_HEADERIMAGE_BITMAP_RTL MUI_HEADERIMAGE_BITMAP_RTL_NOSTRETCH MUI_HEADERIMAGE_RIGHT MUI_HEADERIMAGE_UNBITMAP MUI_HEADERIMAGE_UNBITMAP_NOSTRETCH MUI_HEADERIMAGE_UNBITMAP_RTL MUI_HEADERIMAGE_UNBITMAP_RTL_NOSTRETCH MUI_HWND MUI_ICON MUI_INSTALLCOLORS MUI_INSTALLOPTIONS_DISPLAY MUI_INSTALLOPTIONS_DISPLAY_RETURN MUI_INSTALLOPTIONS_EXTRACT MUI_INSTALLOPTIONS_EXTRACT_AS MUI_INSTALLOPTIONS_INITDIALOG MUI_INSTALLOPTIONS_READ MUI_INSTALLOPTIONS_SHOW MUI_INSTALLOPTIONS_SHOW_RETURN MUI_INSTALLOPTIONS_WRITE MUI_INSTFILESPAGE_ABORTHEADER_SUBTEXT MUI_INSTFILESPAGE_ABORTHEADER_TEXT MUI_INSTFILESPAGE_COLORS MUI_INSTFILESPAGE_FINISHHEADER_SUBTEXT MUI_INSTFILESPAGE_FINISHHEADER_TEXT MUI_INSTFILESPAGE_PROGRESSBAR MUI_LANGDLL_ALLLANGUAGES MUI_LANGDLL_ALWAYSSHOW MUI_LANGDLL_DISPLAY MUI_LANGDLL_INFO MUI_LANGDLL_REGISTRY_KEY MUI_LANGDLL_REGISTRY_ROOT MUI_LANGDLL_REGISTRY_VALUENAME MUI_LANGDLL_WINDOWTITLE MUI_LANGUAGE MUI_LICENSEPAGE_BGCOLOR MUI_LICENSEPAGE_BUTTON MUI_LICENSEPAGE_CHECKBOX MUI_LICENSEPAGE_CHECKBOX_TEXT MUI_LICENSEPAGE_RADIOBUTTONS MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_ACCEPT MUI_LICENSEPAGE_RADIOBUTTONS_TEXT_DECLINE MUI_LICENSEPAGE_TEXT_BOTTOM MUI_LICENSEPAGE_TEXT_TOP MUI_PAGE_COMPONENTS MUI_PAGE_CUSTOMFUNCTION_LEAVE MUI_PAGE_CUSTOMFUNCTION_PRE MUI_PAGE_CUSTOMFUNCTION_SHOW MUI_PAGE_DIRECTORY MUI_PAGE_FINISH MUI_PAGE_HEADER_SUBTEXT MUI_PAGE_HEADER_TEXT MUI_PAGE_INSTFILES MUI_PAGE_LICENSE MUI_PAGE_STARTMENU MUI_PAGE_WELCOME MUI_RESERVEFILE_INSTALLOPTIONS MUI_RESERVEFILE_LANGDLL MUI_SPECIALINI MUI_STARTMENU_GETFOLDER MUI_STARTMENU_WRITE_BEGIN MUI_STARTMENU_WRITE_END MUI_STARTMENUPAGE_BGCOLOR MUI_STARTMENUPAGE_DEFAULTFOLDER MUI_STARTMENUPAGE_NODISABLE MUI_STARTMENUPAGE_REGISTRY_KEY MUI_STARTMENUPAGE_REGISTRY_ROOT MUI_STARTMENUPAGE_REGISTRY_VALUENAME MUI_STARTMENUPAGE_TEXT_CHECKBOX MUI_STARTMENUPAGE_TEXT_TOP MUI_UI MUI_UI_COMPONENTSPAGE_NODESC MUI_UI_COMPONENTSPAGE_SMALLDESC MUI_UI_HEADERIMAGE MUI_UI_HEADERIMAGE_RIGHT MUI_UNABORTWARNING MUI_UNABORTWARNING_CANCEL_DEFAULT MUI_UNABORTWARNING_TEXT MUI_UNCONFIRMPAGE_TEXT_LOCATION MUI_UNCONFIRMPAGE_TEXT_TOP MUI_UNFINISHPAGE_NOAUTOCLOSE MUI_UNFUNCTION_DESCRIPTION_BEGIN MUI_UNFUNCTION_DESCRIPTION_END MUI_UNGETLANGUAGE MUI_UNICON MUI_UNPAGE_COMPONENTS MUI_UNPAGE_CONFIRM MUI_UNPAGE_DIRECTORY MUI_UNPAGE_FINISH MUI_UNPAGE_INSTFILES MUI_UNPAGE_LICENSE MUI_UNPAGE_WELCOME MUI_UNWELCOMEFINISHPAGE_BITMAP MUI_UNWELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_UNWELCOMEFINISHPAGE_INI MUI_WELCOMEFINISHPAGE_BITMAP MUI_WELCOMEFINISHPAGE_BITMAP_NOSTRETCH MUI_WELCOMEFINISHPAGE_CUSTOMFUNCTION_INIT MUI_WELCOMEFINISHPAGE_INI MUI_WELCOMEPAGE_TEXT MUI_WELCOMEPAGE_TITLE MUI_WELCOMEPAGE_TITLE_3LINES" bistr = "addincludedir addplugindir AndIf cd define echo else endif error execute If ifdef ifmacrodef ifmacrondef ifndef include insertmacro macro macroend onGUIEnd onGUIInit onInit onInstFailed onInstSuccess onMouseOverSection onRebootFailed onSelChange onUserAbort onVerifyInstDir OrIf packhdr system undef verbose warning" instance = any("instance", [r"\$\{.*?\}", r"\$[A-Za-z0-9\_]*"]) define = any("define", [r"\![^\n]*"]) comment = any("comment", [r"\;[^\n]*", r"\#[^\n]*", r"\/\*(.*?)\*\/"]) return make_generic_c_patterns( kwstr1 + " " + kwstr2 + " " + kwstr3, bistr, instance=instance, define=define, comment=comment, ) class NsisSH(CppSH): """NSIS Syntax Highlighter""" # Syntax highlighting rules: PROG = re.compile(make_nsis_patterns(), re.S) # ============================================================================== # gettext highlighter # ============================================================================== def make_gettext_patterns(): "Strongly inspired from idlelib.ColorDelegator.make_pat" kwstr = "msgid msgstr" kw = r"\b" + any("keyword", kwstr.split()) + r"\b" fuzzy = any("builtin", [r"#,[^\n]*"]) links = any("normal", [r"#:[^\n]*"]) comment = any("comment", [r"#[^\n]*"]) number = any( "number", [ r"\b[+-]?[0-9]+[lL]?\b", r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", ], ) sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' string = any("string", [sqstring, dqstring]) return "|".join([kw, string, number, fuzzy, links, comment, any("SYNC", [r"\n"])]) class GetTextSH(GenericSH): """gettext Syntax Highlighter""" # Syntax highlighting rules: PROG = re.compile(make_gettext_patterns(), re.S) # ============================================================================== # yaml highlighter # ============================================================================== def make_yaml_patterns(): "Strongly inspired from sublime highlighter" kw = any("keyword", [r":|>|-|\||\[|\]|[A-Za-z][\w\s\-\_ ]+(?=:)"]) links = any("normal", [r"#:[^\n]*"]) comment = any("comment", [r"#[^\n]*"]) number = any( "number", [ r"\b[+-]?[0-9]+[lL]?\b", r"\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b", r"\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", ], ) sqstring = r"(\b[rRuU])?'[^'\\\n]*(\\.[^'\\\n]*)*'?" dqstring = r'(\b[rRuU])?"[^"\\\n]*(\\.[^"\\\n]*)*"?' string = any("string", [sqstring, dqstring]) return "|".join([kw, string, number, links, comment, any("SYNC", [r"\n"])]) class YamlSH(GenericSH): """yaml Syntax Highlighter""" # Syntax highlighting rules: PROG = re.compile(make_yaml_patterns(), re.S) # ============================================================================== # HTML highlighter # ============================================================================== class BaseWebSH(BaseSH): """Base class for CSS and HTML syntax highlighters""" NORMAL = 0 COMMENT = 1 def __init__(self, parent, font=None, color_scheme=None): BaseSH.__init__(self, parent, font, color_scheme) def highlightBlock(self, text): """Implement highlight specific for CSS and HTML.""" text = str(text) previous_state = self.previousBlockState() if previous_state == self.COMMENT: self.setFormat(0, len(text), self.formats["comment"]) else: previous_state = self.NORMAL self.setFormat(0, len(text), self.formats["normal"]) self.setCurrentBlockState(previous_state) match = self.PROG.search(text) match_count = 0 n_characters = len(text) # There should never be more matches than characters in the text. while match and match_count < n_characters: match_dict = match.groupdict() for key, value in list(match_dict.items()): if value: start, end = match.span(key) if previous_state == self.COMMENT: if key == "multiline_comment_end": self.setCurrentBlockState(self.NORMAL) self.setFormat(end, len(text), self.formats["normal"]) else: self.setCurrentBlockState(self.COMMENT) self.setFormat(0, len(text), self.formats["comment"]) else: if key == "multiline_comment_start": self.setCurrentBlockState(self.COMMENT) self.setFormat(start, len(text), self.formats["comment"]) else: self.setCurrentBlockState(self.NORMAL) try: self.setFormat(start, end - start, self.formats[key]) except KeyError: # Happens with unmatched end-of-comment. # See spyder-ide/spyder#1462. pass match = self.PROG.search(text, match.end()) match_count += 1 def make_html_patterns(): """Strongly inspired from idlelib.ColorDelegator.make_pat""" tags = any("builtin", [r"<", r"[\?/]?>", r"(?<=<).*?(?=[ >])"]) keywords = any("keyword", [r" [\w:-]*?(?==)"]) string = any("string", [r'".*?"']) comment = any("comment", [r""]) multiline_comment_start = any("multiline_comment_start", [r""]) return "|".join( [ comment, multiline_comment_start, multiline_comment_end, tags, keywords, string, ] ) class HtmlSH(BaseWebSH): """HTML Syntax Highlighter""" PROG = re.compile(make_html_patterns(), re.S) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata/widgets/texteditor.py0000644000175100017510000000750215114075001020515 0ustar00runnerrunner# -*- coding: utf-8 -*- # # Copyright © Spyder Project Contributors # Licensed under the terms of the MIT License # (see spyder/__init__.py for details) # ruff: noqa """ guidata.widgets.texteditor ========================== This package provides a text editor widget based on QtGui.QPlainTextEdit. .. autoclass:: TextEditor :show-inheritance: :members: """ from qtpy.QtCore import Qt, Slot from qtpy.QtWidgets import QDialog, QHBoxLayout, QPushButton, QTextEdit, QVBoxLayout from guidata.config import CONF, _ from guidata.configtools import get_font, get_icon from guidata.qthelpers import win32_fix_title_bar_background class TextEditor(QDialog): """Array Editor Dialog""" def __init__( self, text, title="", font=None, parent=None, readonly=False, size=(400, 300) ): QDialog.__init__(self, parent) win32_fix_title_bar_background(self) # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to # a segmentation fault on UNIX or an application crash on Windows self.setAttribute(Qt.WA_DeleteOnClose) self.text = None self.btn_save_and_close = None # Display text as unicode if it comes as bytes, so users see # its right representation if isinstance(text, bytes): self.is_binary = True text = str(text, "utf8") else: self.is_binary = False self.layout = QVBoxLayout() self.setLayout(self.layout) # Text edit self.edit = QTextEdit(parent) self.edit.setReadOnly(readonly) self.edit.textChanged.connect(self.text_changed) self.edit.setPlainText(text) if font is None: font = get_font(CONF, "texteditor") self.edit.setFont(font) self.layout.addWidget(self.edit) # Buttons configuration btn_layout = QHBoxLayout() btn_layout.addStretch() if not readonly: self.btn_save_and_close = QPushButton(_("Save and Close")) self.btn_save_and_close.setDisabled(True) self.btn_save_and_close.clicked.connect(self.accept) btn_layout.addWidget(self.btn_save_and_close) self.btn_close = QPushButton(_("Close")) self.btn_close.setAutoDefault(True) self.btn_close.setDefault(True) self.btn_close.clicked.connect(self.reject) btn_layout.addWidget(self.btn_close) self.layout.addLayout(btn_layout) # Make the dialog act as a window self.setWindowFlags(Qt.Window) self.setWindowIcon(get_icon("edit.png")) self.setWindowTitle( _("Text editor") + "%s" % (" - " + str(title) if str(title) else "") ) self.resize(size[0], size[1]) @Slot() def text_changed(self): """Text has changed""" # Save text as bytes, if it was initially bytes if self.is_binary: self.text = bytes(self.edit.toPlainText(), "utf8") else: self.text = str(self.edit.toPlainText()) if self.btn_save_and_close: self.btn_save_and_close.setEnabled(True) self.btn_save_and_close.setAutoDefault(True) self.btn_save_and_close.setDefault(True) def get_value(self): """Return modified text""" # It is import to avoid accessing Qt C++ object as it has probably # already been destroyed, due to the Qt.WA_DeleteOnClose attribute return self.text def setup_and_check(self, value): """Verify if TextEditor is able to display strings passed to it.""" if isinstance(value, str): return True try: str(value, "utf8") return True except: return False ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/guidata-tests.desktop0000644000175100017510000000043015114075001017026 0ustar00runnerrunner[Desktop Entry] Version=1.0 Type=Application Name=guidata-tests GenericName=guidata Test launcher Comment=The guidata Python library provides GUIs for easy dataset editing and display TryExec=guidata-tests Exec=guidata-tests Icon=guidata.svg Categories=Education;Science;Physics; ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6638856 guidata-3.13.4/guidata.egg-info/0000755000175100017510000000000015114075015015775 5ustar00runnerrunner././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784652.0 guidata-3.13.4/guidata.egg-info/PKG-INFO0000644000175100017510000001626015114075014017076 0ustar00runnerrunnerMetadata-Version: 2.4 Name: guidata Version: 3.13.4 Summary: Automatic GUI generation for easy dataset editing and display Author-email: Codra License: BSD 3-Clause License Copyright (c) 2023, CEA-Codra, Pierre Raybaut. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Project-URL: Homepage, https://github.com/PlotPyStack/guidata/ Project-URL: Documentation, https://guidata.readthedocs.io/en/latest/ Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Education Classifier: Intended Audience :: Science/Research Classifier: Operating System :: MacOS :: MacOS X Classifier: Operating System :: Microsoft :: Windows :: Windows 7 Classifier: Operating System :: Microsoft :: Windows :: Windows 8 Classifier: Operating System :: Microsoft :: Windows :: Windows 10 Classifier: Operating System :: Microsoft :: Windows :: Windows 11 Classifier: Operating System :: POSIX :: Linux Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Topic :: Scientific/Engineering Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Software Development :: User Interfaces Classifier: Topic :: Software Development :: Widget Sets Classifier: Topic :: Utilities Requires-Python: <4,>=3.9 Description-Content-Type: text/markdown License-File: LICENSE Requires-Dist: h5py>=3.6 Requires-Dist: NumPy>=1.22 Requires-Dist: QtPy>=1.9 Requires-Dist: requests Requires-Dist: tomli; python_version < "3.11" Provides-Extra: qt Requires-Dist: PyQt5>5.15.5; extra == "qt" Provides-Extra: dev Requires-Dist: build; extra == "dev" Requires-Dist: babel; extra == "dev" Requires-Dist: Coverage; extra == "dev" Requires-Dist: pylint; extra == "dev" Requires-Dist: ruff; extra == "dev" Requires-Dist: pre-commit; extra == "dev" Provides-Extra: doc Requires-Dist: PyQt5; extra == "doc" Requires-Dist: pillow; extra == "doc" Requires-Dist: pandas; extra == "doc" Requires-Dist: sphinx; extra == "doc" Requires-Dist: myst_parser; extra == "doc" Requires-Dist: sphinx-copybutton; extra == "doc" Requires-Dist: sphinx_qt_documentation; extra == "doc" Requires-Dist: python-docs-theme; extra == "doc" Provides-Extra: test Requires-Dist: pytest; extra == "test" Requires-Dist: pytest-xvfb; extra == "test" Dynamic: license-file # guidata: Automatic GUI generation for easy dataset editing and display with Python [![pypi version](https://img.shields.io/pypi/v/guidata.svg)](https://pypi.org/project/guidata/) [![PyPI status](https://img.shields.io/pypi/status/guidata.svg)](https://github.com/PlotPyStack/guidata/) [![PyPI pyversions](https://img.shields.io/pypi/pyversions/guidata.svg)](https://pypi.python.org/pypi/guidata/) [![download count](https://img.shields.io/conda/dn/conda-forge/guidata.svg)](https://www.anaconda.com/download/) ℹ️ Created in 2009 by [Pierre Raybaut](https://github.com/PierreRaybaut) and maintained by the [PlotPyStack](https://github.com/PlotPyStack) organization. ## Overview The `guidata` package is a Python library generating Qt graphical user interfaces. It is part of the [PlotPyStack](https://github.com/PlotPyStack) project, aiming at providing a unified framework for creating scientific GUIs with Python and Qt. Simple example of `guidata` datasets embedded in an application window: ![Example](https://raw.githubusercontent.com/PlotPyStack/guidata/master/doc/images/screenshots/editgroupbox.png) See [documentation](https://guidata.readthedocs.io/en/latest/) for more details on the library. Copyrights and licensing: * Copyright © 2023 [CEA](https://www.cea.fr), [Codra](https://codra.net/), [Pierre Raybaut](https://github.com/PierreRaybaut). * Licensed under the terms of the BSD 3-Clause (see [LICENSE](https://github.com/PlotPyStack/guidata/blob/master/LICENSE)). ## Features Based on the Qt library, `guidata` is a Python library generating graphical user interfaces for easy dataset editing and display. It also provides helpers and application development tools for Qt (PyQt5, PySide2, PyQt6, PySide6). Generate GUIs to edit and display all kind of objects regrouped in datasets: * Integers, floats, strings * Lists (single/multiple choices) * Dictionaries * `ndarrays` (NumPy's N-dimensional arrays) * Etc. Save and load datasets to/from HDF5, JSON or INI files. Application development tools: * Data model (internal data structure, serialization, etc.) * Configuration management * Internationalization (`gettext`) * Deployment tools * HDF5, JSON and INI I/O helpers * Qt helpers * Ready-to-use Qt widgets: Python console, source code editor, array editor, etc. ## Dependencies and installation ### Supported Qt versions and bindings The whole PlotPyStack set of libraries relies on the [Qt](https://doc.qt.io/) GUI toolkit, thanks to [QtPy](https://pypi.org/project/QtPy/), an abstraction layer which allows to use the same API to interact with different Python-to-Qt bindings (PyQt5, PyQt6, PySide2, PySide6). Compatibility table: | guidata version | PyQt5 | PyQt6 | PySide2 | PySide6 | |----------------|-------|-------|---------|---------| | 3.0-3.5 | ✅ | ⚠️ | ❌ | ⚠️ | | Latest | ✅ | ✅ | ❌ | ✅ | ### Other dependencies and installation See [Installation](https://guidata.readthedocs.io/en/latest/installation.html) section in the documentation for more details. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784652.0 guidata-3.13.4/guidata.egg-info/SOURCES.txt0000644000175100017510000002075715114075014017673 0ustar00runnerrunnerLICENSE MANIFEST.in README.md guidata-tests.desktop pyproject.toml requirements.txt doc/basic_example.py doc/conf.py doc/examples.rst doc/index.rst doc/installation.rst doc/overview.rst doc/requirements.rst doc/widgets.rst doc/_static/favicon.ico doc/autodoc/autodoc_example.py doc/autodoc/index.rst doc/dev/contribute.rst doc/dev/howto.rst doc/dev/index.rst doc/dev/platform.rst doc/dev/v2_to_v3.csv doc/dev/v2_to_v3.rst doc/images/basic_example.png doc/images/guidata-banner.png doc/images/guidata-vertical.png doc/images/layout_example.png doc/images/screenshots/__init__.png doc/images/screenshots/activable_dataset.png doc/images/screenshots/all_features.png doc/images/screenshots/all_items.png doc/images/screenshots/arrayeditor.png doc/images/screenshots/bool_selector.png doc/images/screenshots/codeeditor.png doc/images/screenshots/collectioneditor.png doc/images/screenshots/console.png doc/images/screenshots/dataframeeditor.png doc/images/screenshots/datasetgroup.png doc/images/screenshots/editgroupbox.png doc/images/screenshots/importwizard.png doc/reference/guitest.rst doc/reference/index.rst doc/reference/userconfig.rst doc/reference/utils.rst doc/reference/widgets.rst doc/reference/dataset/conv.rst doc/reference/dataset/dataitems.rst doc/reference/dataset/datatypes.rst doc/reference/dataset/index.rst doc/reference/dataset/io.rst doc/reference/dataset/qtwidgets.rst doc/release_notes/index.rst doc/release_notes/release_1.md doc/release_notes/release_2.md doc/release_notes/release_3.00.md doc/release_notes/release_3.01.md doc/release_notes/release_3.02.md doc/release_notes/release_3.03.md doc/release_notes/release_3.04.md doc/release_notes/release_3.05.md doc/release_notes/release_3.06.md doc/release_notes/release_3.07.md doc/release_notes/release_3.08.md doc/release_notes/release_3.09.md doc/release_notes/release_3.10.md doc/release_notes/release_3.11.md doc/release_notes/release_3.12.md doc/release_notes/release_3.13.md guidata/__init__.py guidata/config.py guidata/configtools.py guidata/env.py guidata/guitest.py guidata/qthelpers.py guidata/userconfig.py guidata.egg-info/PKG-INFO guidata.egg-info/SOURCES.txt guidata.egg-info/dependency_links.txt guidata.egg-info/entry_points.txt guidata.egg-info/requires.txt guidata.egg-info/top_level.txt guidata/data/icons/apply.png guidata/data/icons/arredit.png guidata/data/icons/busy.png guidata/data/icons/cell_edit.png guidata/data/icons/copy.png guidata/data/icons/copy_all.svg guidata/data/icons/delete.png guidata/data/icons/dictedit.png guidata/data/icons/dtype.png guidata/data/icons/edit.png guidata/data/icons/exit.png guidata/data/icons/expander_down.png guidata/data/icons/expander_right.png guidata/data/icons/export.svg guidata/data/icons/file.png guidata/data/icons/fileclose.png guidata/data/icons/fileimport.png guidata/data/icons/filenew.png guidata/data/icons/fileopen.png guidata/data/icons/filesave.png guidata/data/icons/filesaveas.png guidata/data/icons/format.svg guidata/data/icons/guidata-banner.svg guidata/data/icons/guidata-vertical.svg guidata/data/icons/guidata.svg guidata/data/icons/hist.png guidata/data/icons/max.png guidata/data/icons/min.png guidata/data/icons/none.png guidata/data/icons/not_found.png guidata/data/icons/python.png guidata/data/icons/quickview.png guidata/data/icons/resize.svg guidata/data/icons/save_all.png guidata/data/icons/selection.png guidata/data/icons/settings.png guidata/data/icons/shape.png guidata/data/icons/xmax.png guidata/data/icons/xmin.png guidata/data/icons/editors/copywop.png guidata/data/icons/editors/edit.png guidata/data/icons/editors/edit_add.png guidata/data/icons/editors/editclear.png guidata/data/icons/editors/editcopy.png guidata/data/icons/editors/editcut.png guidata/data/icons/editors/editdelete.png guidata/data/icons/editors/editpaste.png guidata/data/icons/editors/fileimport.png guidata/data/icons/editors/filesave.png guidata/data/icons/editors/imshow.png guidata/data/icons/editors/insert.png guidata/data/icons/editors/plot.png guidata/data/icons/editors/rename.png guidata/data/icons/editors/selectall.png guidata/data/icons/filetypes/doc.png guidata/data/icons/filetypes/gif.png guidata/data/icons/filetypes/html.png guidata/data/icons/filetypes/jpg.png guidata/data/icons/filetypes/pdf.png guidata/data/icons/filetypes/png.png guidata/data/icons/filetypes/pps.png guidata/data/icons/filetypes/ps.png guidata/data/icons/filetypes/tar.png guidata/data/icons/filetypes/tgz.png guidata/data/icons/filetypes/tif.png guidata/data/icons/filetypes/txt.png guidata/data/icons/filetypes/xls.png guidata/data/icons/filetypes/zip.png guidata/dataset/__init__.py guidata/dataset/autodoc.py guidata/dataset/conv.py guidata/dataset/dataitems.py guidata/dataset/datatypes.py guidata/dataset/io.py guidata/dataset/note_directive.py guidata/dataset/qtitemwidgets.py guidata/dataset/qtwidgets.py guidata/dataset/textedit.py guidata/external/__init__.py guidata/external/darkdetect/__init__.py guidata/external/darkdetect/_dummy.py guidata/external/darkdetect/_linux_detect.py guidata/external/darkdetect/_mac_detect.py guidata/external/darkdetect/_windows_detect.py guidata/io/__init__.py guidata/io/base.py guidata/io/h5fmt.py guidata/io/inifmt.py guidata/io/jsonfmt.py guidata/locale/fr/LC_MESSAGES/guidata.mo guidata/tests/__init__.py guidata/tests/conftest.py guidata/tests/data/genreqs/pyproject.toml guidata/tests/data/genreqs/setup.cfg guidata/tests/dataset/__init__.py guidata/tests/dataset/test_activable_dataset.py guidata/tests/dataset/test_activable_items.py guidata/tests/dataset/test_all_features.py guidata/tests/dataset/test_all_items.py guidata/tests/dataset/test_all_items_readonly.py guidata/tests/dataset/test_auto_apply.py guidata/tests/dataset/test_bool_selector.py guidata/tests/dataset/test_button_item.py guidata/tests/dataset/test_callbacks.py guidata/tests/dataset/test_computed_items.py guidata/tests/dataset/test_datasetgroup.py guidata/tests/dataset/test_editgroupbox.py guidata/tests/dataset/test_inheritance.py guidata/tests/dataset/test_item_order.py guidata/tests/dataset/test_loadsave_hdf5.py guidata/tests/dataset/test_loadsave_json.py guidata/tests/dataset/test_rotatedlabel.py guidata/tests/dataset/test_separator_item_trailing.py guidata/tests/unit/__init__.py guidata/tests/unit/test_assert_datasets_equal.py guidata/tests/unit/test_boolitem_numpy.py guidata/tests/unit/test_choice_tuple_serialization.py guidata/tests/unit/test_config.py guidata/tests/unit/test_data.py guidata/tests/unit/test_dataset_class_config.py guidata/tests/unit/test_dataset_from_dict.py guidata/tests/unit/test_dataset_from_func.py guidata/tests/unit/test_dataset_to_html.py guidata/tests/unit/test_force_allow_none.py guidata/tests/unit/test_h5fmt.py guidata/tests/unit/test_h5fmt_datetime.py guidata/tests/unit/test_item_order.py guidata/tests/unit/test_jsonfmt.py guidata/tests/unit/test_no_qt.py guidata/tests/unit/test_text.py guidata/tests/unit/test_translations.py guidata/tests/unit/test_updaterestoredataset.py guidata/tests/unit/test_userconfig_app.py guidata/tests/unit/test_validationmodes.py guidata/tests/widgets/__init__.py guidata/tests/widgets/test_arrayeditor.py guidata/tests/widgets/test_arrayeditor_unit.py guidata/tests/widgets/test_codeeditor.py guidata/tests/widgets/test_collectionseditor.py guidata/tests/widgets/test_console.py guidata/tests/widgets/test_dataframeeditor.py guidata/tests/widgets/test_importwizard.py guidata/tests/widgets/test_objecteditor.py guidata/tests/widgets/test_theme.py guidata/utils/__init__.py guidata/utils/cleanup.py guidata/utils/encoding.py guidata/utils/genreqs.py guidata/utils/gitreport.py guidata/utils/misc.py guidata/utils/qt_scraper.py guidata/utils/securebuild.py guidata/utils/translations.py guidata/widgets/__init__.py guidata/widgets/about.py guidata/widgets/codeeditor.py guidata/widgets/collectionseditor.py guidata/widgets/dataframeeditor.py guidata/widgets/dockable.py guidata/widgets/importwizard.py guidata/widgets/nsview.py guidata/widgets/objecteditor.py guidata/widgets/rotatedlabel.py guidata/widgets/syntaxhighlighters.py guidata/widgets/texteditor.py guidata/widgets/arrayeditor/__init__.py guidata/widgets/arrayeditor/arrayeditor.py guidata/widgets/arrayeditor/arrayhandler.py guidata/widgets/arrayeditor/datamodel.py guidata/widgets/arrayeditor/editorwidget.py guidata/widgets/arrayeditor/utils.py guidata/widgets/console/__init__.py guidata/widgets/console/base.py guidata/widgets/console/calltip.py guidata/widgets/console/dochelpers.py guidata/widgets/console/internalshell.py guidata/widgets/console/interpreter.py guidata/widgets/console/mixins.py guidata/widgets/console/shell.py guidata/widgets/console/terminal.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784652.0 guidata-3.13.4/guidata.egg-info/dependency_links.txt0000644000175100017510000000000115114075014022042 0ustar00runnerrunner ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784652.0 guidata-3.13.4/guidata.egg-info/entry_points.txt0000644000175100017510000000033315114075014021271 0ustar00runnerrunner[console_scripts] gbuild = guidata.utils.securebuild:main gclean = guidata.utils.cleanup:main greqs = guidata.utils.genreqs:main gtrans = guidata.utils.translations:main [gui_scripts] guidata-tests = guidata.tests:run ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784652.0 guidata-3.13.4/guidata.egg-info/requires.txt0000644000175100017510000000042615114075014020376 0ustar00runnerrunnerh5py>=3.6 NumPy>=1.22 QtPy>=1.9 requests [:python_version < "3.11"] tomli [dev] build babel Coverage pylint ruff pre-commit [doc] PyQt5 pillow pandas sphinx myst_parser sphinx-copybutton sphinx_qt_documentation python-docs-theme [qt] PyQt5>5.15.5 [test] pytest pytest-xvfb ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784652.0 guidata-3.13.4/guidata.egg-info/top_level.txt0000644000175100017510000000001015114075014020515 0ustar00runnerrunnerguidata ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/pyproject.toml0000644000175100017510000000765315114075001015607 0ustar00runnerrunner# guidata setup configuration file # Important note: # Requirements are parsed by utils\genreqs.py to generate documentation [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project] name = "guidata" authors = [{ name = "Codra", email = "p.raybaut@codra.fr" }] description = "Automatic GUI generation for easy dataset editing and display" readme = "README.md" license = { file = "LICENSE" } classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: Education", "Intended Audience :: Science/Research", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows :: Windows 7", "Operating System :: Microsoft :: Windows :: Windows 8", "Operating System :: Microsoft :: Windows :: Windows 10", "Operating System :: Microsoft :: Windows :: Windows 11", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Human Machine Interfaces", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: User Interfaces", "Topic :: Software Development :: Widget Sets", "Topic :: Utilities", ] requires-python = ">=3.9, <4" dependencies = ["h5py >= 3.6", "NumPy >= 1.22", "QtPy >= 1.9", "requests", "tomli; python_version < '3.11'"] dynamic = ["version"] [project.gui-scripts] guidata-tests = "guidata.tests:run" [project.scripts] gtrans = "guidata.utils.translations:main" greqs = "guidata.utils.genreqs:main" gbuild = "guidata.utils.securebuild:main" gclean = "guidata.utils.cleanup:main" [project.optional-dependencies] qt = ["PyQt5 > 5.15.5"] dev = ["build", "babel", "Coverage", "pylint", "ruff", "pre-commit"] doc = [ "PyQt5", "pillow", "pandas", "sphinx", "myst_parser", "sphinx-copybutton", "sphinx_qt_documentation", "python-docs-theme", ] test = ["pytest", "pytest-xvfb"] [project.urls] Homepage = "https://github.com/PlotPyStack/guidata/" Documentation = "https://guidata.readthedocs.io/en/latest/" [tool.setuptools.packages.find] include = ["guidata*"] [tool.setuptools.package-data] "*" = ["*.png", "*.svg", "*.mo", "*.cfg", "*.toml"] [tool.setuptools.dynamic] version = { attr = "guidata.__version__" } [tool.pytest.ini_options] addopts = "guidata --import-mode=importlib" # addopts = "guidata --import-mode=importlib --show-windows" # Disable offscreen mode [tool.ruff] exclude = [".git", ".vscode", "build", "dist", "guidata/external"] line-length = 88 # Same as Black. indent-width = 4 # Same as Black. target-version = "py39" # Assume Python 3.9. [tool.ruff.lint] # all rules can be found here: https://beta.ruff.rs/docs/rules/ select = [ "D202", # No blank lines allowed after function docstring. "D403", # First word of docstring should be properly capitalized. "E", # Pycodestyle error "F", # Pyflakes "I", # Isort "NPY201", # Numpy-specific checks "RUF022", # Unsorted __all__ "W" # Pycodestyle warning ] ignore = [ "E203", # space before : (needed for how black formats slicing) ] [tool.ruff.format] quote-style = "double" # Like Black, use double quotes for strings. indent-style = "space" # Like Black, indent with spaces, rather than tabs. skip-magic-trailing-comma = false # Like Black, respect magic trailing commas. line-ending = "auto" # Like Black, automatically detect the appropriate line ending. [tool.ruff.lint.per-file-ignores] "doc/*" = ["E402"] [tool.ruff.lint.pydocstyle] convention = "google" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1764784641.0 guidata-3.13.4/requirements.txt0000644000175100017510000000037115114075001016145 0ustar00runnerrunnerCoverage NumPy >= 1.22 PyQt5 > 5.15.5 QtPy >= 1.9 babel build h5py >= 3.6 myst_parser pandas pillow pre-commit pylint pytest pytest-xvfb python-docs-theme requests ruff sphinx sphinx-copybutton sphinx_qt_documentation tomli; python_version < '3.11' ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1764784652.6658857 guidata-3.13.4/setup.cfg0000644000175100017510000000004615114075015014506 0ustar00runnerrunner[egg_info] tag_build = tag_date = 0