diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9c7b384 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +* text=auto + +*.py text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.svg text eol=lf +*.ps1 text eol=crlf + +*.ico binary +*.png binary +*.zip binary +*.xlsx binary +*.sav binary +*.zsav binary diff --git a/.gitignore b/.gitignore index f4be677..531e5b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,45 @@ +# Python bytecode and package metadata __pycache__/ *.py[cod] *.egg-info/ -.pytest_cache/ + +# Local environments and secrets .venv/ +venv/ +.env +.env.* +!.env.example + +# Test, lint, type-check, and coverage caches +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ + +# Packaging and generated release assets build/ dist/ *.spec +/assets/app-icon.ico +/assets/app-icon.png + +# Research data and generated codebooks +*.sav +*.zsav +*_codebook.xlsx +*_variables.csv +*_value_labels.csv +*_missing_values.csv +*_warnings.csv + +# Editors, operating systems, and temporary files +.idea/ +.vscode/ +.DS_Store +Thumbs.db +Desktop.ini +*.log +*.tmp +*~ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index c2fe40e..baeecf6 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,65 @@ -# SPSS Codebook Generator +# SPSS Codebook Rescue -Portable Windows/macOS app and CLI for generating R-friendly codebooks from SPSS -`.sav` and `.zsav` files. +A small, local-first desktop app for turning the metadata embedded in SPSS +`.sav` and `.zsav` files into readable Excel and CSV codebooks. -SPSS datasets often contain the metadata analysts need, but that information is -stored in the file's variable labels, value labels, and user-missing definitions -instead of in a readable codebook. This project extracts those metadata fields -and writes them as explicit Excel and CSV tables that are easy to inspect and -straightforward to import into R. +![SPSS Codebook Rescue desktop interface](docs/assets/application.png) -## Features +## Why this exists -- Read SPSS `.sav` and `.zsav` files without SPSS. -- Export a human-readable Excel codebook. -- Export UTF-8 CSV tables with stable English column names for R workflows. -- Preserve raw SPSS codes while attaching variable labels and value labels. -- Mark SPSS user-missing values. -- Calculate optional observed frequencies. -- Show warnings for missing variable labels and partially labelled variables. -- Provide a basic desktop viewer for data preview and codebook tables. +Every now and then, a project hands me an SPSS file to work with but I prefer to work in R. Those files often arrive with a patchy codebook, or no codebook at all, which turns even simple data work into an avoidable guessing game and a lot of back and forth. -## Output files +So I built this little app: give it an SPSS file and it turns the metadata still +embedded in that file into a readable codebook. It does not magically restore +documentation that was never there, but it makes the most of what is available +and lets me get back to the actual analysis, using the tidy data format, a little faster. -For an output name such as `study_codebook`, the app can write: +All processing happens on your computer and the app does not upload anything. -- `study_codebook_codebook.xlsx` -- `study_codebook_variables.csv` -- `study_codebook_value_labels.csv` -- `study_codebook_missing_values.csv` -- `study_codebook_warnings.csv` +## Highlights -The Excel workbook contains the same four codebook sheets as the CSV exports: +- Reads SPSS `.sav` and compressed `.zsav` files. +- Extracts variable labels, value labels, formats, measurement levels, and + user-defined missing values. +- Flags variables without labels and observed codes without value labels. +- Optionally calculates observed counts and percentages. +- Shows a labelled preview of the first 500 rows. +- Exports a formatted Excel workbook and/or analysis-friendly UTF-8 CSV files. +- Runs as a portable Windows app; Python and SPSS are not required. +- Includes a command-line interface for reproducible workflows. -- `variables` -- `value_labels` -- `missing_values` -- `warnings` +## Quick start on Windows -## Output schema +1. Download `SPSS-Codebook-Rescue-Windows.zip` from the + [latest release](https://github.com/drdiscipulus/spss-codebook-rescue/releases/latest). +2. Extract the ZIP file to a folder you can write to. +3. Open `SPSS Codebook Rescue.exe`. +4. Choose or drag in a `.sav` or `.zsav` file. +5. Review the extracted tables and select **Export codebook**. + +The build is unsigned. Windows may show a Microsoft +Defender SmartScreen prompt even when the downloaded checksum matches the +release. + +A macOS version is not planned. + +## Generated files + +For an output name such as `study`, the app can create: + +| File | Contents | +| --- | --- | +| `study_codebook.xlsx` | All codebook tables in one formatted workbook | +| `study_variables.csv` | One row per variable | +| `study_value_labels.csv` | Value labels in long format | +| `study_missing_values.csv` | Discrete and range-based user-missing definitions | +| `study_warnings.csv` | Potential documentation gaps | + +The Excel workbook contains `variables`, `value_labels`, `missing_values`, and +`warnings` sheets. Headers are filterable, the first row is frozen, and column +widths are adjusted automatically. + +### Output schema `variables` @@ -65,74 +87,85 @@ variable_name, missing_type, value, lower, upper, label severity, variable_name, value, message ``` -## GUI usage +Raw SPSS codes are preserved. In `value_labels`, `observed_percent` is the share +among non-system-missing rows, including SPSS user-missing codes. In +`variables`, `n_observed` excludes both system-missing and user-missing values. -Start the desktop app: +## Install from source + +Python 3.12 is required. ```powershell -spss-codebook-gui +py -3.12 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -e ".[dev]" ``` -Then: - -1. Select an input `.sav` or `.zsav` file. -2. Enter an output name. -3. Select an output folder. -4. Choose Excel and/or CSV export. -5. Optionally disable frequency calculation for very large files. -6. Load the preview or export the codebook. +Start the desktop app: -The data preview shows the first 500 rows. If a value label exists, cells are -displayed as `code - label`; otherwise the raw value is shown. +```powershell +spss-codebook-rescue-gui +``` -## CLI usage +Or generate a codebook from the command line: ```powershell -spss-codebook input.sav --output-dir . --output-name study_codebook +spss-codebook-rescue survey.sav --output-dir . --output-name survey ``` -Useful flags: - -- `--no-excel` -- `--no-csv` -- `--no-frequencies` -- `--overwrite` -- `--preview-rows 500` +Useful CLI options include `--no-excel`, `--no-csv`, `--no-frequencies`, +`--overwrite`, and `--preview-rows`. ## Development -Use Python 3.12. +The code follows a small, explicit pipeline: -```powershell -py -3.12 -m pip install -e ".[dev]" -py -3.12 -m pytest +```text +SPSS file -> metadata extraction -> normalized pandas tables -> GUI / CLI -> Excel / CSV ``` -The test suite creates a small synthetic `.sav` fixture, so no private SPSS data -is required for automated tests. +- `src/spss_codebook/core.py` contains SPSS extraction and normalization. +- `src/spss_codebook/exporters.py` owns all output formatting and file naming. +- `src/spss_codebook/workflow.py` exposes the shared GUI/CLI workflow. +- `src/spss_codebook/gui.py` contains the Qt desktop interface. +- `tests/` uses synthetic SPSS fixtures; no private research data is required. + +Run the quality checks with: + +```powershell +python -m ruff check . +python -m pytest -q +``` -## Windows build +## Build a portable Windows app ```powershell .\scripts\build_windows.ps1 ``` -The Windows build creates `dist/SPSS-Codebook-Generator-Windows.zip`. +The script runs linting and tests before creating: -## macOS build +- `dist/SPSS-Codebook-Rescue-Windows.zip` +- `dist/SPSS-Codebook-Rescue-Windows.zip.sha256` -```bash -./scripts/build_macos.sh -``` +The ZIP file and its checksum can then be attached to a GitHub release manually. + +## Current limitations + +- Multiple-response sets and some advanced SPSS metadata are not extracted yet. +- Frequency calculation reads the complete dataset and can take time for large + files. +- The data preview is intentionally limited to 500 rows. + +## Project status + +This is a personal side project that I built in my spare time. I maintain it +when time allows, so updates will likely be sporadic. -The macOS build creates `dist/SPSS Codebook Generator.app`. Signing, -notarization, and DMG packaging are intentionally left for a later release step. +## License and attribution -## Limitations +SPSS Codebook Rescue is free and open-source software licensed under the +[GNU General Public License version 3](LICENSE), specifically `GPL-3.0-only`. -- Raw dataset export is intentionally out of scope for V1. -- Multiple-response sets and advanced SPSS metadata are not handled yet. -- Frequency calculation reads the dataset and can take time for large files. -- CodeRabbit CLI review requires WSL on Windows; native Windows PowerShell is - not enough for the official CodeRabbit CLI installer. -- No project license has been selected yet. +SPSS is a trademark of IBM. This independent project is not affiliated with or +endorsed by IBM. diff --git a/assets/app-icon.svg b/assets/app-icon.svg new file mode 100644 index 0000000..47c6d89 --- /dev/null +++ b/assets/app-icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/checkbox-checked.svg b/assets/checkbox-checked.svg new file mode 100644 index 0000000..9b47e2e --- /dev/null +++ b/assets/checkbox-checked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/checkbox-unchecked.svg b/assets/checkbox-unchecked.svg new file mode 100644 index 0000000..1712f0d --- /dev/null +++ b/assets/checkbox-unchecked.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/assets/application.png b/docs/assets/application.png new file mode 100644 index 0000000..0e4ea05 Binary files /dev/null and b/docs/assets/application.png differ diff --git a/pyproject.toml b/pyproject.toml index bf9ba88..f6c1c7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,13 @@ [build-system] -requires = ["setuptools>=69", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "spss-codebook-generator" +name = "spss-codebook-rescue" version = "0.1.0" -description = "Portable SPSS codebook generator for R-friendly Excel and CSV exports." +description = "Portable Windows app for extracting SPSS metadata into R-friendly codebooks." readme = "README.md" +license = "GPL-3.0-only" requires-python = ">=3.12,<3.13" authors = [ { name = "drdiscipulus" }, @@ -14,7 +15,6 @@ authors = [ keywords = ["spss", "codebook", "sav", "zsav", "r", "metadata"] classifiers = [ "Development Status :: 3 - Alpha", - "Environment :: MacOS X", "Environment :: Win32 (MS Windows)", "Intended Audience :: Science/Research", "Programming Language :: Python :: 3.12", @@ -31,16 +31,19 @@ dependencies = [ dev = [ "pyinstaller>=6.0", "pytest>=8.0", + "ruff>=0.12", ] [project.scripts] -spss-codebook = "spss_codebook.cli:main" -spss-codebook-gui = "spss_codebook.gui:main" +spss-codebook-rescue = "spss_codebook.cli:main" + +[project.gui-scripts] +spss-codebook-rescue-gui = "spss_codebook.gui:main" [project.urls] -Homepage = "https://github.com/drdiscipulus/spss-codebook-generator" -Repository = "https://github.com/drdiscipulus/spss-codebook-generator" -Issues = "https://github.com/drdiscipulus/spss-codebook-generator/issues" +Homepage = "https://github.com/drdiscipulus/spss-codebook-rescue" +Repository = "https://github.com/drdiscipulus/spss-codebook-rescue" +Issues = "https://github.com/drdiscipulus/spss-codebook-rescue/issues" [tool.setuptools.packages.find] where = ["src"] @@ -48,3 +51,10 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src"] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh deleted file mode 100644 index f2781d3..0000000 --- a/scripts/build_macos.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -python3.12 -m pip install -e ".[dev]" -python3.12 -m PyInstaller \ - --noconfirm \ - --clean \ - --onedir \ - --windowed \ - --name "SPSS Codebook Generator" \ - --specpath build/pyinstaller \ - --collect-all pyreadstat \ - --exclude-module pytest \ - --exclude-module py \ - --exclude-module pygments \ - src/spss_codebook/gui_entry.py - -echo "Created dist/SPSS Codebook Generator.app" diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index ff06447..39add56 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -1,22 +1,76 @@ +param( + [string]$PythonExecutable = "" +) + $ErrorActionPreference = "Stop" +$repoRoot = Split-Path -Parent $PSScriptRoot +$localPython = Join-Path $repoRoot ".venv\Scripts\python.exe" +$assetsPath = Join-Path $repoRoot "assets" + +function Assert-NativeSuccess { + param([string]$Step) + if ($LASTEXITCODE -ne 0) { + throw "$Step failed with exit code $LASTEXITCODE." + } +} + +Push-Location $repoRoot +try { + if (-not $PythonExecutable) { + if (-not (Test-Path -LiteralPath $localPython)) { + py -3.12 -m venv .venv + Assert-NativeSuccess "Creating the virtual environment" + } + $PythonExecutable = $localPython + } -py -3.12 -m pip install -e ".[dev]" -py -3.12 -m PyInstaller ` - --noconfirm ` - --clean ` - --onedir ` - --windowed ` - --name "SPSS Codebook Generator" ` - --specpath "build/pyinstaller" ` - --collect-all pyreadstat ` - --exclude-module pytest ` - --exclude-module py ` - --exclude-module pygments ` - "src/spss_codebook/gui_entry.py" + & $PythonExecutable -m pip install -e ".[dev]" + Assert-NativeSuccess "Installing dependencies" + & $PythonExecutable -m pytest -q + Assert-NativeSuccess "Running tests" + & $PythonExecutable -m ruff check . + Assert-NativeSuccess "Running Ruff" + & $PythonExecutable "scripts/generate_icon.py" + Assert-NativeSuccess "Generating the Windows icon" + & $PythonExecutable -m PyInstaller ` + --noconfirm ` + --clean ` + --onedir ` + --windowed ` + --name "SPSS Codebook Rescue" ` + --icon "$assetsPath\app-icon.ico" ` + --add-data "$assetsPath\app-icon.svg;assets" ` + --add-data "$assetsPath\checkbox-checked.svg;assets" ` + --add-data "$assetsPath\checkbox-unchecked.svg;assets" ` + --specpath "build/pyinstaller" ` + --collect-all pyreadstat ` + --exclude-module pytest ` + --exclude-module py ` + --exclude-module pygments ` + "src/spss_codebook/gui_entry.py" + Assert-NativeSuccess "Building the Windows application" -$zipPath = "dist/SPSS-Codebook-Generator-Windows.zip" -if (Test-Path $zipPath) { - Remove-Item $zipPath -Force + $bundlePath = Join-Path $repoRoot "dist\SPSS Codebook Rescue" + $zipPath = Join-Path $repoRoot "dist\SPSS-Codebook-Rescue-Windows.zip" + $hashPath = "$zipPath.sha256" + Copy-Item -LiteralPath (Join-Path $repoRoot "LICENSE") ` + -Destination (Join-Path $bundlePath "LICENSE.txt") -Force + Copy-Item -LiteralPath (Join-Path $repoRoot "README.md") ` + -Destination (Join-Path $bundlePath "README.md") -Force + if (Test-Path -LiteralPath $zipPath) { + Remove-Item -LiteralPath $zipPath -Force + } + if (Test-Path -LiteralPath $hashPath) { + Remove-Item -LiteralPath $hashPath -Force + } + + Compress-Archive -LiteralPath $bundlePath -DestinationPath $zipPath + $hash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $(Split-Path -Leaf $zipPath)" | Set-Content -LiteralPath $hashPath -Encoding ascii + + Write-Host "Created $zipPath" + Write-Host "Created $hashPath" +} +finally { + Pop-Location } -Compress-Archive -Path "dist/SPSS Codebook Generator" -DestinationPath $zipPath -Write-Host "Created $zipPath" diff --git a/scripts/capture_screenshot.py b/scripts/capture_screenshot.py new file mode 100644 index 0000000..48a7945 --- /dev/null +++ b/scripts/capture_screenshot.py @@ -0,0 +1,69 @@ +"""Create the README screenshot with representative, synthetic data.""" + +from pathlib import Path + +import pandas as pd +from PySide6.QtWidgets import QApplication + +from spss_codebook.core import CodebookResult +from spss_codebook.gui import MainWindow +from spss_codebook.theme import app_stylesheet + + +def main() -> None: + app = QApplication([]) + app.setStyle("Fusion") + app.setStyleSheet(app_stylesheet()) + + window = MainWindow() + window.input_path.setText("C:/Research/example_survey.sav") + window.output_name.setText("example_survey") + window.output_dir.setText("C:/Research/codebook") + window._populate_tables( # noqa: SLF001 - dedicated visual documentation helper + CodebookResult( + preview=pd.DataFrame( + { + "founder_id": [101, 102, 103], + "gender": ["1 - Woman", "2 - Man", "1 - Woman"], + "employees": [4, 12, 7], + } + ), + variables=pd.DataFrame( + { + "variable_name": ["founder_id", "gender", "employees"], + "variable_label": ["Founder ID", "Gender", "Number of employees"], + "storage_type": ["double", "double", "double"], + } + ), + value_labels=pd.DataFrame( + { + "variable_name": ["gender", "gender"], + "value": ["1", "2"], + "value_label": ["Woman", "Man"], + } + ), + missing_values=pd.DataFrame( + columns=["variable_name", "missing_type", "value", "lower", "upper"] + ), + warnings=pd.DataFrame( + { + "severity": ["info"], + "variable_name": ["employees"], + "value": [""], + "message": ["Variable has observed values but no value labels."], + } + ), + ) + ) + window.resize(1180, 780) + window.show() + app.processEvents() + + output = Path("docs/assets/application.png") + output.parent.mkdir(parents=True, exist_ok=True) + if not window.grab().save(str(output)): + raise RuntimeError(f"Could not save screenshot to {output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/generate_icon.py b/scripts/generate_icon.py new file mode 100644 index 0000000..2fc66e4 --- /dev/null +++ b/scripts/generate_icon.py @@ -0,0 +1,30 @@ +"""Render the vector application icon to the Windows ICO build asset.""" + +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication, QImage, QPainter +from PySide6.QtSvg import QSvgRenderer + + +def main() -> None: + _app = QGuiApplication.instance() or QGuiApplication([]) + + source = Path("assets/app-icon.svg") + ico_destination = Path("assets/app-icon.ico") + image = QImage(256, 256, QImage.Format_ARGB32) + image.fill(Qt.transparent) + + renderer = QSvgRenderer(str(source)) + if not renderer.isValid(): + raise RuntimeError(f"Could not load {source}") + painter = QPainter(image) + renderer.render(painter) + painter.end() + + if not image.save(str(ico_destination), "ICO"): + raise RuntimeError(f"Could not write {ico_destination}") + + +if __name__ == "__main__": + main() diff --git a/src/spss_codebook/__init__.py b/src/spss_codebook/__init__.py index fe1c9ae..0bed107 100644 --- a/src/spss_codebook/__init__.py +++ b/src/spss_codebook/__init__.py @@ -1,4 +1,4 @@ -"""SPSS Codebook Generator.""" +"""SPSS Codebook Rescue.""" __all__ = ["__version__"] diff --git a/src/spss_codebook/cli.py b/src/spss_codebook/cli.py index 36b5e3c..84f6141 100644 --- a/src/spss_codebook/cli.py +++ b/src/spss_codebook/cli.py @@ -11,7 +11,7 @@ def build_parser() -> argparse.ArgumentParser: """Create the CLI parser used by tests and the installed console script.""" parser = argparse.ArgumentParser( - prog="spss-codebook", + prog="spss-codebook-rescue", description="Generate Excel and CSV codebooks from SPSS .sav/.zsav files.", ) parser.add_argument("input_file", type=Path) @@ -19,7 +19,9 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--output-name", required=True) parser.add_argument("--no-excel", action="store_true", help="Do not write the Excel codebook.") parser.add_argument("--no-csv", action="store_true", help="Do not write CSV codebook tables.") - parser.add_argument("--no-frequencies", action="store_true", help="Leave frequency columns empty.") + parser.add_argument( + "--no-frequencies", action="store_true", help="Leave frequency columns empty." + ) parser.add_argument("--overwrite", action="store_true", help="Overwrite existing output files.") parser.add_argument("--preview-rows", type=int, default=500) return parser diff --git a/src/spss_codebook/core.py b/src/spss_codebook/core.py index e8a463c..9926b0b 100644 --- a/src/spss_codebook/core.py +++ b/src/spss_codebook/core.py @@ -1,13 +1,14 @@ from __future__ import annotations +from contextlib import suppress from dataclasses import dataclass +from functools import partial from pathlib import Path from typing import Any import pandas as pd import pyreadstat - SUPPORTED_SUFFIXES = {".sav", ".zsav"} VARIABLE_COLUMNS = [ @@ -75,10 +76,7 @@ def build_codebook( into long-form tables that are easy to inspect in Excel or import into R. """ - path = Path(input_path) - if path.suffix.lower() not in SUPPORTED_SUFFIXES: - supported = ", ".join(sorted(SUPPORTED_SUFFIXES)) - raise ValueError(f"Unsupported input format '{path.suffix}'. Supported formats: {supported}.") + path = _validate_input_path(input_path) if preview_rows < 0: raise ValueError("preview_rows must be zero or greater.") @@ -106,14 +104,19 @@ def build_codebook( series = df[variable_name] variable_label = variable_labels.get(variable_name, "") label_map = value_label_maps.get(variable_name, {}) or {} - normalized_labels = {_normal_key(value): (value, label) for value, label in label_map.items()} + normalized_labels = { + _normal_key(value): (value, label) for value, label in label_map.items() + } missing_specs = _missing_specs(variable_name, missing_ranges, missing_user_values) - missing_keys = _missing_keys(missing_specs) - non_system_missing = series.dropna() observed_counts = non_system_missing.value_counts(dropna=True) + observed_counts_by_key = { + _normal_key(value): int(count) for value, count in observed_counts.items() + } observed_keys = {_normal_key(value) for value in observed_counts.index} - user_missing_mask = non_system_missing.map(lambda value: _is_user_missing(value, missing_specs)) + user_missing_mask = non_system_missing.map( + partial(_is_user_missing, missing_specs=missing_specs) + ) n_user_missing = int(user_missing_mask.sum()) if len(non_system_missing) else 0 n_system_missing = int(series.isna().sum()) n_missing = n_system_missing + n_user_missing @@ -170,16 +173,18 @@ def build_codebook( denominator = int(len(non_system_missing)) for label_value, value_label in label_map.items(): - count = _observed_count_for_key(observed_counts, _normal_key(label_value)) + count = observed_counts_by_key.get(_normal_key(label_value), 0) value_label_rows.append( { "variable_name": variable_name, "variable_label": variable_label, "value": _display_value(label_value), "value_label": value_label, - "is_user_missing": _normal_key(label_value) in missing_keys, + "is_user_missing": _is_user_missing(label_value, missing_specs), "observed_count": count if calculate_frequencies else "", - "observed_percent": _percent(count, denominator) if calculate_frequencies else "", + "observed_percent": _percent(count, denominator) + if calculate_frequencies + else "", "source": "spss_value_label", } ) @@ -204,9 +209,11 @@ def build_codebook( "variable_label": variable_label, "value": _display_value(observed_value), "value_label": "", - "is_user_missing": observed_key in missing_keys, + "is_user_missing": _is_user_missing(observed_value, missing_specs), "observed_count": count if calculate_frequencies else "", - "observed_percent": _percent(count, denominator) if calculate_frequencies else "", + "observed_percent": _percent(count, denominator) + if calculate_frequencies + else "", "source": "observed_unlabelled", } ) @@ -220,6 +227,21 @@ def build_codebook( ) +def _validate_input_path(input_path: str | Path) -> Path: + """Return a validated SPSS input path with user-facing error messages.""" + + path = Path(input_path).expanduser() + if path.suffix.lower() not in SUPPORTED_SUFFIXES: + supported = ", ".join(sorted(SUPPORTED_SUFFIXES)) + suffix = path.suffix or "(none)" + raise ValueError(f"Unsupported input format '{suffix}'. Supported formats: {supported}.") + if not path.exists(): + raise FileNotFoundError(f"SPSS file not found: {path}") + if not path.is_file(): + raise ValueError(f"Input path is not a file: {path}") + return path + + def _column_labels(meta: Any) -> dict[str, str]: labels = getattr(meta, "column_names_to_labels", None) if labels: @@ -227,7 +249,10 @@ def _column_labels(meta: Any) -> dict[str, str]: names = getattr(meta, "column_names", []) or [] raw_labels = getattr(meta, "column_labels", []) or [] - return {name: (raw_labels[index] if index < len(raw_labels) and raw_labels[index] else "") for index, name in enumerate(names)} + return { + name: (raw_labels[index] if index < len(raw_labels) and raw_labels[index] else "") + for index, name in enumerate(names) + } def _missing_specs( @@ -256,14 +281,6 @@ def _missing_specs( return specs -def _missing_keys(missing_specs: list[dict[str, Any]]) -> set[tuple[str, Any]]: - keys: set[tuple[str, Any]] = set() - for spec in missing_specs: - if spec["missing_type"] == "discrete": - keys.add(_normal_key(spec.get("value"))) - return keys - - def _is_user_missing(value: Any, missing_specs: list[dict[str, Any]]) -> bool: value_key = _normal_key(value) for spec in missing_specs: @@ -280,21 +297,18 @@ def _is_user_missing(value: Any, missing_specs: list[dict[str, Any]]) -> bool: return False -def _label_for_missing(spec: dict[str, Any], normalized_labels: dict[tuple[str, Any], tuple[Any, str]]) -> str: +def _label_for_missing( + spec: dict[str, Any], normalized_labels: dict[tuple[str, Any], tuple[Any, str]] +) -> str: if spec["missing_type"] != "discrete": return "" label = normalized_labels.get(_normal_key(spec.get("value"))) return label[1] if label else "" -def _observed_count_for_key(observed_counts: pd.Series, key: tuple[str, Any]) -> int: - for observed_value, count in observed_counts.items(): - if _normal_key(observed_value) == key: - return int(count) - return 0 - - -def _format_preview(preview: pd.DataFrame, value_label_maps: dict[str, dict[Any, str]]) -> pd.DataFrame: +def _format_preview( + preview: pd.DataFrame, value_label_maps: dict[str, dict[Any, str]] +) -> pd.DataFrame: formatted = preview.copy() for column in formatted.columns: label_map = value_label_maps.get(column, {}) or {} @@ -302,7 +316,7 @@ def _format_preview(preview: pd.DataFrame, value_label_maps: dict[str, dict[Any, continue normalized_labels = {_normal_key(value): label for value, label in label_map.items()} formatted[column] = formatted[column].map( - lambda value: _preview_value(value, normalized_labels), + partial(_preview_value, normalized_labels=normalized_labels), na_action=None, ) return formatted @@ -323,10 +337,8 @@ def _normal_key(value: Any) -> tuple[str, Any]: if pd.isna(value): return ("missing", "") if hasattr(value, "item"): - try: + with suppress(ValueError): value = value.item() - except ValueError: - pass if isinstance(value, float) and value.is_integer(): return ("number", int(value)) if isinstance(value, (int, float)): @@ -340,10 +352,8 @@ def _display_value(value: Any) -> str: if pd.isna(value): return "" if hasattr(value, "item"): - try: + with suppress(ValueError): value = value.item() - except ValueError: - pass if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value) diff --git a/src/spss_codebook/exporters.py b/src/spss_codebook/exporters.py index b09cb82..4bd4ad0 100644 --- a/src/spss_codebook/exporters.py +++ b/src/spss_codebook/exporters.py @@ -2,9 +2,27 @@ from dataclasses import dataclass from pathlib import Path +from typing import TYPE_CHECKING + +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter from .core import CodebookResult +if TYPE_CHECKING: + import pandas as pd + + +_TABLES = ("variables", "value_labels", "missing_values", "warnings") +_WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{number}" for number in range(1, 10)), + *(f"LPT{number}" for number in range(1, 10)), +} + @dataclass(frozen=True) class ExportOptions: @@ -27,7 +45,12 @@ class ExportResult: def export_codebook(result: CodebookResult, options: ExportOptions) -> ExportResult: """Write codebook tables to the selected Excel and/or CSV outputs.""" + if not options.export_excel and not options.export_csv: + raise ValueError("Select at least one export format.") + output_dir = Path(options.output_dir) + if output_dir.exists() and not output_dir.is_dir(): + raise NotADirectoryError(f"Output location is not a directory: {output_dir}") output_dir.mkdir(parents=True, exist_ok=True) output_name = _clean_output_name(options.output_name) paths = expected_output_paths(output_dir, output_name, options.export_excel, options.export_csv) @@ -42,20 +65,15 @@ def export_codebook(result: CodebookResult, options: ExportOptions) -> ExportRes if options.export_excel: excel_path = output_dir / f"{output_name}_codebook.xlsx" with result_to_excel_writer(excel_path) as writer: - result.variables.to_excel(writer, sheet_name="variables", index=False) - result.value_labels.to_excel(writer, sheet_name="value_labels", index=False) - result.missing_values.to_excel(writer, sheet_name="missing_values", index=False) - result.warnings.to_excel(writer, sheet_name="warnings", index=False) + for table_name in _TABLES: + table = getattr(result, table_name) + table.to_excel(writer, sheet_name=table_name, index=False) + _format_worksheet(writer.book[table_name]) written_files.append(excel_path) if options.export_csv: - csv_tables = { - "variables": result.variables, - "value_labels": result.value_labels, - "missing_values": result.missing_values, - "warnings": result.warnings, - } - for suffix, table in csv_tables.items(): + for suffix in _TABLES: + table = getattr(result, suffix) csv_path = output_dir / f"{output_name}_{suffix}.csv" table.to_csv(csv_path, index=False, encoding="utf-8") written_files.append(csv_path) @@ -87,19 +105,43 @@ def expected_output_paths( return paths -def result_to_excel_writer(path: Path): +def result_to_excel_writer(path: Path) -> pd.ExcelWriter: import pandas as pd return pd.ExcelWriter(path, engine="openpyxl") def _clean_output_name(output_name: str) -> str: - """Make a user-supplied output stem safe for Windows and macOS file names.""" + """Make a user-supplied output stem safe for common file systems.""" - cleaned = output_name.strip() + cleaned = output_name.strip().rstrip(". ") if not cleaned: raise ValueError("output_name must not be empty.") illegal = '<>:"/\\|?*' for char in illegal: cleaned = cleaned.replace(char, "_") + if not cleaned or cleaned in {".", ".."}: + raise ValueError("output_name must contain at least one valid character.") + if cleaned.split(".", maxsplit=1)[0].upper() in _WINDOWS_RESERVED_NAMES: + cleaned = f"_{cleaned}" return cleaned + + +def _format_worksheet(worksheet) -> None: + """Apply restrained formatting that keeps generated workbooks readable.""" + + header_fill = PatternFill("solid", fgColor="17324D") + header_font = Font(color="FFFFFF", bold=True) + for cell in worksheet[1]: + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(vertical="center") + + worksheet.freeze_panes = "A2" + worksheet.auto_filter.ref = worksheet.dimensions + worksheet.sheet_view.showGridLines = False + + for index, column_cells in enumerate(worksheet.columns, start=1): + values = ("" if cell.value is None else str(cell.value) for cell in column_cells) + width = min(max(max((len(value) for value in values), default=0) + 2, 10), 60) + worksheet.column_dimensions[get_column_letter(index)].width = width diff --git a/src/spss_codebook/gui.py b/src/spss_codebook/gui.py index cce8767..2b12bf3 100644 --- a/src/spss_codebook/gui.py +++ b/src/spss_codebook/gui.py @@ -1,20 +1,34 @@ from __future__ import annotations import sys +from dataclasses import replace from pathlib import Path -from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt +import pandas as pd +from PySide6.QtCore import ( + QAbstractTableModel, + QModelIndex, + QObject, + QRunnable, + Qt, + QThreadPool, + QUrl, + Signal, +) +from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QIcon from PySide6.QtWidgets import ( QApplication, QCheckBox, QFileDialog, + QFrame, QGridLayout, - QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QMainWindow, QMessageBox, + QProgressBar, QPushButton, QTableView, QTabWidget, @@ -22,239 +36,499 @@ QWidget, ) -from .core import CodebookResult +from . import __version__ +from .core import SUPPORTED_SUFFIXES, CodebookResult from .exporters import ExportOptions, expected_output_paths +from .theme import app_stylesheet, resource_path from .workflow import load_codebook, write_codebook +_INVALID_INDEX = QModelIndex() + class DataFrameModel(QAbstractTableModel): - """Small Qt table model for displaying pandas DataFrames in preview tabs.""" + """Read-only Qt model with sorting support for a pandas DataFrame.""" - def __init__(self): + def __init__(self) -> None: super().__init__() - self._dataframe = None + self._dataframe = pd.DataFrame() - def set_dataframe(self, dataframe): + def set_dataframe(self, dataframe: pd.DataFrame) -> None: self.beginResetModel() - self._dataframe = dataframe + self._dataframe = dataframe.copy() self.endResetModel() - def rowCount(self, parent=QModelIndex()) -> int: - if parent.isValid() or self._dataframe is None: - return 0 - return len(self._dataframe) + def rowCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802 + return 0 if parent.isValid() else len(self._dataframe) - def columnCount(self, parent=QModelIndex()) -> int: - if parent.isValid() or self._dataframe is None: - return 0 - return len(self._dataframe.columns) + def columnCount(self, parent: QModelIndex = _INVALID_INDEX) -> int: # noqa: N802 + return 0 if parent.isValid() else len(self._dataframe.columns) - def data(self, index: QModelIndex, role=Qt.DisplayRole): - if not index.isValid() or self._dataframe is None or role != Qt.DisplayRole: + def data(self, index: QModelIndex, role: int = Qt.DisplayRole): + if not index.isValid(): return None - value = self._dataframe.iat[index.row(), index.column()] - return "" if value is None else str(value) - def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole): - if self._dataframe is None or role != Qt.DisplayRole: + value = self._dataframe.iat[index.row(), index.column()] + if role == Qt.DisplayRole: + return "" if pd.isna(value) else str(value) + if role == Qt.TextAlignmentRole and isinstance(value, (int, float)): + return Qt.AlignRight | Qt.AlignVCenter + return None + + def headerData( # noqa: N802 + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.DisplayRole, + ): + if role != Qt.DisplayRole: return None if orientation == Qt.Horizontal: return str(self._dataframe.columns[section]) return str(section + 1) + def sort(self, column: int, order: Qt.SortOrder = Qt.AscendingOrder) -> None: + if self._dataframe.empty or column >= len(self._dataframe.columns): + return + self.layoutAboutToBeChanged.emit() + column_name = self._dataframe.columns[column] + self._dataframe = self._dataframe.sort_values( + column_name, + ascending=order == Qt.AscendingOrder, + kind="stable", + na_position="last", + ).reset_index(drop=True) + self.layoutChanged.emit() + + +class _WorkerSignals(QObject): + succeeded = Signal(object) + failed = Signal(str) + + +class _CodebookWorker(QRunnable): + """Load an SPSS file away from the UI thread.""" + + def __init__(self, path: Path, calculate_frequencies: bool) -> None: + super().__init__() + self.path = path + self.calculate_frequencies = calculate_frequencies + self.signals = _WorkerSignals() + + def run(self) -> None: + try: + result = load_codebook( + self.path, + calculate_frequencies=self.calculate_frequencies, + preview_rows=500, + ) + except Exception as exc: # Convert library failures into a GUI signal. + self.signals.failed.emit(str(exc)) + return + self.signals.succeeded.emit(result) + class MainWindow(QMainWindow): - """Main desktop window for loading, previewing, and exporting codebooks.""" + """Desktop workflow for inspecting SPSS metadata and exporting a codebook.""" - def __init__(self): + def __init__(self) -> None: super().__init__() - self.setWindowTitle("SPSS Codebook Generator") - self.resize(1100, 720) + self.setWindowTitle("SPSS Codebook Rescue") + self.setMinimumSize(940, 680) + self.resize(1180, 780) + self.setAcceptDrops(True) + self._result: CodebookResult | None = None + self._loaded_signature: tuple[Path, bool] | None = None + self._pending_export = False + self._last_output_dir: Path | None = None + self._worker: _CodebookWorker | None = None self.input_path = QLineEdit() + self.input_path.setPlaceholderText("Choose a .sav or .zsav file") self.output_name = QLineEdit() + self.output_name.setPlaceholderText("e.g. survey_wave_1") self.output_dir = QLineEdit() - self.excel_checkbox = QCheckBox("Export Excel") - self.csv_checkbox = QCheckBox("Export CSV") - self.frequencies_checkbox = QCheckBox("Calculate frequencies") - self.overwrite_checkbox = QCheckBox("Overwrite existing files") - self.status_label = QLabel("Select an SPSS file to begin.") + self.output_dir.setPlaceholderText("Folder for the generated codebook") + self.excel_checkbox = QCheckBox("Excel workbook") + self.csv_checkbox = QCheckBox("CSV tables") + self.frequencies_checkbox = QCheckBox("Calculate observed frequencies") + self.overwrite_checkbox = QCheckBox("Overwrite existing files") self.excel_checkbox.setChecked(True) self.csv_checkbox.setChecked(True) self.frequencies_checkbox.setChecked(True) - self.tabs = QTabWidget() - self.models = { - "Data Preview": DataFrameModel(), - "Variables": DataFrameModel(), - "Value Labels": DataFrameModel(), - "Missing Values": DataFrameModel(), - "Warnings": DataFrameModel(), + self.analyse_button = QPushButton("Analyse file") + self.analyse_button.setObjectName("secondaryButton") + self.analyse_button.clicked.connect(self._load_preview) + self.export_button = QPushButton("Export codebook") + self.export_button.setObjectName("primaryButton") + self.export_button.clicked.connect(self._export) + self.open_folder_button = QPushButton("Open output folder") + self.open_folder_button.setObjectName("linkButton") + self.open_folder_button.clicked.connect(self._open_output_folder) + self.open_folder_button.hide() + + self.progress = QProgressBar() + self.progress.setRange(0, 0) + self.progress.setFixedWidth(120) + self.progress.hide() + self.status_label = QLabel("Choose an SPSS file or drop it onto this window.") + self.status_label.setObjectName("statusLabel") + + self.summary_value_labels: dict[str, QLabel] = {} + self.summary_cards = { + "variables": self._summary_card("variables", "Variables", "—"), + "labels": self._summary_card("labels", "Value labels", "—"), + "warnings": self._summary_card("warnings", "Warnings", "—"), } - for title, model in self.models.items(): - view = QTableView() - view.setModel(model) - view.setSortingEnabled(True) - view.setAlternatingRowColors(True) - self.tabs.addTab(view, title) - - root = QWidget() - root_layout = QVBoxLayout(root) - root_layout.addWidget(self._build_form()) - root_layout.addWidget(self.tabs) - root_layout.addWidget(self.status_label) - self.setCentralWidget(root) - - def _build_form(self) -> QWidget: - group = QGroupBox("Export settings") - layout = QGridLayout(group) - - input_button = QPushButton("Browse") + + self.tabs = QTabWidget() + self.models: dict[str, DataFrameModel] = {} + for key, title in ( + ("preview", "Data preview"), + ("variables", "Variables"), + ("value_labels", "Value labels"), + ("missing_values", "Missing values"), + ("warnings", "Warnings"), + ): + model = DataFrameModel() + self.models[key] = model + self.tabs.addTab(self._table_view(model), title) + + self.form_panel = self._build_form_panel() + central = QWidget() + layout = QVBoxLayout(central) + layout.setContentsMargins(28, 24, 28, 20) + layout.setSpacing(16) + layout.addLayout(self._build_header()) + layout.addWidget(self.form_panel) + layout.addLayout(self._build_summary()) + layout.addWidget(self.tabs, 1) + layout.addLayout(self._build_status_bar()) + self.setCentralWidget(central) + + self.input_path.textChanged.connect(self._invalidate_result) + self.frequencies_checkbox.toggled.connect(self._invalidate_result) + + def _build_header(self) -> QVBoxLayout: + layout = QVBoxLayout() + layout.setSpacing(4) + title = QLabel("SPSS Codebook Rescue") + title.setObjectName("pageTitle") + subtitle = QLabel("Turn embedded SPSS metadata into a transparent, reusable codebook.") + subtitle.setObjectName("pageSubtitle") + layout.addWidget(title) + layout.addWidget(subtitle) + return layout + + def _build_form_panel(self) -> QFrame: + panel = QFrame() + panel.setObjectName("panel") + layout = QGridLayout(panel) + layout.setContentsMargins(20, 18, 20, 18) + layout.setHorizontalSpacing(12) + layout.setVerticalSpacing(12) + layout.setColumnStretch(1, 1) + + input_button = QPushButton("Browse…") input_button.clicked.connect(self._select_input) - output_dir_button = QPushButton("Browse") + output_dir_button = QPushButton("Browse…") output_dir_button.clicked.connect(self._select_output_dir) - load_button = QPushButton("Load Preview") - load_button.clicked.connect(self._load_preview) - export_button = QPushButton("Export Codebook") - export_button.clicked.connect(self._export) - layout.addWidget(QLabel("Input SPSS file"), 0, 0) + layout.addWidget(QLabel("SPSS file"), 0, 0) layout.addWidget(self.input_path, 0, 1) layout.addWidget(input_button, 0, 2) layout.addWidget(QLabel("Output name"), 1, 0) layout.addWidget(self.output_name, 1, 1, 1, 2) - layout.addWidget(QLabel("Output location"), 2, 0) + layout.addWidget(QLabel("Output folder"), 2, 0) layout.addWidget(self.output_dir, 2, 1) layout.addWidget(output_dir_button, 2, 2) options = QHBoxLayout() - options.addWidget(self.excel_checkbox) - options.addWidget(self.csv_checkbox) - options.addWidget(self.frequencies_checkbox) - options.addWidget(self.overwrite_checkbox) + options.setSpacing(18) + for checkbox in ( + self.excel_checkbox, + self.csv_checkbox, + self.frequencies_checkbox, + self.overwrite_checkbox, + ): + options.addWidget(checkbox) options.addStretch() - options.addWidget(load_button) - options.addWidget(export_button) - layout.addLayout(options, 3, 0, 1, 3) - return group - def _select_input(self): + actions = QHBoxLayout() + actions.setSpacing(10) + actions.addStretch() + actions.addWidget(self.analyse_button) + actions.addWidget(self.export_button) + layout.addLayout(options, 3, 0, 1, 3) + layout.addLayout(actions, 4, 0, 1, 3) + return panel + + def _build_summary(self) -> QHBoxLayout: + layout = QHBoxLayout() + layout.setSpacing(12) + for card in self.summary_cards.values(): + layout.addWidget(card) + return layout + + def _summary_card(self, key: str, label: str, value: str) -> QFrame: + card = QFrame() + card.setObjectName("summaryCard") + layout = QVBoxLayout(card) + layout.setContentsMargins(14, 10, 14, 10) + layout.setSpacing(1) + value_label = QLabel(value) + value_label.setObjectName("summaryValue") + caption = QLabel(label) + caption.setObjectName("summaryCaption") + layout.addWidget(value_label) + layout.addWidget(caption) + self.summary_value_labels[key] = value_label + return card + + def _build_status_bar(self) -> QHBoxLayout: + layout = QHBoxLayout() + layout.addWidget(self.status_label, 1) + layout.addWidget(self.progress) + layout.addWidget(self.open_folder_button) + version = QLabel(f"v{__version__}") + version.setObjectName("versionLabel") + layout.addWidget(version) + return layout + + @staticmethod + def _table_view(model: DataFrameModel) -> QTableView: + view = QTableView() + view.setModel(model) + view.setSortingEnabled(True) + view.setAlternatingRowColors(True) + view.setEditTriggers(QTableView.NoEditTriggers) + view.setSelectionBehavior(QTableView.SelectRows) + view.setShowGrid(False) + view.verticalHeader().setDefaultSectionSize(30) + view.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + view.horizontalHeader().setMinimumSectionSize(90) + view.horizontalHeader().setStretchLastSection(True) + return view + + def _select_input(self) -> None: path, _ = QFileDialog.getOpenFileName( self, - "Select SPSS file", - "", - "SPSS files (*.sav *.zsav)", + "Choose an SPSS file", + self.input_path.text(), + "SPSS data files (*.sav *.zsav)", ) - if not path: - return - self.input_path.setText(path) - input_path = Path(path) + if path: + self._set_input_path(Path(path)) + + def _set_input_path(self, path: Path) -> None: + self.input_path.setText(str(path)) if not self.output_name.text().strip(): - self.output_name.setText(input_path.stem) + self.output_name.setText(path.stem) if not self.output_dir.text().strip(): - self.output_dir.setText(str(input_path.parent)) + self.output_dir.setText(str(path.parent)) - def _select_output_dir(self): - path = QFileDialog.getExistingDirectory(self, "Select output location") + def _select_output_dir(self) -> None: + path = QFileDialog.getExistingDirectory( + self, + "Choose an output folder", + self.output_dir.text(), + ) if path: self.output_dir.setText(path) - def _load_preview(self): + def _invalidate_result(self) -> None: + if self._loaded_signature != self._current_signature(silent=True): + self._result = None + self.open_folder_button.hide() + + def _load_preview(self) -> None: + self._pending_export = False + self._start_load() + + def _start_load(self) -> None: try: - self._result = load_codebook( - self._input_path(), - calculate_frequencies=self.frequencies_checkbox.isChecked(), - preview_rows=500, + path = self._input_file() + except (ValueError, FileNotFoundError) as exc: + QMessageBox.warning(self, "Choose an SPSS file", str(exc)) + return + + self._set_busy(True, f"Reading {path.name}…") + worker = _CodebookWorker(path, self.frequencies_checkbox.isChecked()) + worker.signals.succeeded.connect(self._load_succeeded) + worker.signals.failed.connect(self._load_failed) + self._worker = worker + QThreadPool.globalInstance().start(worker) + + def _load_succeeded(self, result: CodebookResult) -> None: + self._result = result + self._loaded_signature = self._current_signature() + self._populate_tables(result) + self._set_busy(False, "Analysis complete. Review the tables or export the codebook.") + self._worker = None + if self._pending_export: + self._pending_export = False + self._write_export() + + def _load_failed(self, message: str) -> None: + self._result = None + self._pending_export = False + self._worker = None + self._set_busy(False, "The file could not be analysed.") + QMessageBox.critical(self, "Could not analyse file", message) + + def _export(self) -> None: + if not self.excel_checkbox.isChecked() and not self.csv_checkbox.isChecked(): + QMessageBox.warning( + self, + "Choose an export format", + "Select Excel, CSV, or both.", ) - except Exception as exc: - QMessageBox.critical(self, "Could not load file", str(exc)) return - self._populate_tables(self._result) - self.status_label.setText("Preview loaded.") + try: + self._output_folder() + self._output_stem() + except ValueError as exc: + QMessageBox.warning(self, "Check export settings", str(exc)) + return - def _export(self): - if self._result is None: - self._load_preview() - if self._result is None: - return + if self._result is None or self._loaded_signature != self._current_signature(): + self._pending_export = True + self._start_load() + return + self._write_export() - export_excel = self.excel_checkbox.isChecked() - export_csv = self.csv_checkbox.isChecked() - if not export_excel and not export_csv: - QMessageBox.warning(self, "No export selected", "Select at least one export format.") + def _write_export(self) -> None: + if self._result is None: return options = ExportOptions( - output_dir=self._output_dir(), - output_name=self._output_name(), - export_excel=export_excel, - export_csv=export_csv, + output_dir=self._output_folder(), + output_name=self._output_stem(), + export_excel=self.excel_checkbox.isChecked(), + export_csv=self.csv_checkbox.isChecked(), overwrite=self.overwrite_checkbox.isChecked(), ) - - if not options.overwrite: - output_paths = expected_output_paths( - options.output_dir, - options.output_name, - export_excel, - export_csv, + output_paths = expected_output_paths( + options.output_dir, + options.output_name, + options.export_excel, + options.export_csv, + ) + existing = [path for path in output_paths if path.exists()] + if existing and not options.overwrite: + names = "\n".join(f"• {path.name}" for path in existing) + answer = QMessageBox.question( + self, + "Replace existing files?", + f"These files already exist:\n\n{names}\n\nReplace them?", ) - existing = [path for path in output_paths if path.exists()] - if existing: - names = "\n".join(path.name for path in existing) - answer = QMessageBox.question( - self, - "Overwrite files?", - f"The following files already exist:\n\n{names}\n\nOverwrite them?", - ) - if answer != QMessageBox.Yes: - return - options = ExportOptions( - output_dir=options.output_dir, - output_name=options.output_name, - export_excel=options.export_excel, - export_csv=options.export_csv, - overwrite=True, - ) + if answer != QMessageBox.Yes: + self.status_label.setText("Export cancelled; no files were changed.") + return + options = replace(options, overwrite=True) try: export_result = write_codebook(self._result, options) except Exception as exc: QMessageBox.critical(self, "Export failed", str(exc)) return - files = ", ".join(path.name for path in export_result.written_files) - self.status_label.setText(f"Export complete: {files}") - def _populate_tables(self, result: CodebookResult): - self.models["Data Preview"].set_dataframe(result.preview) - self.models["Variables"].set_dataframe(result.variables) - self.models["Value Labels"].set_dataframe(result.value_labels) - self.models["Missing Values"].set_dataframe(result.missing_values) - self.models["Warnings"].set_dataframe(result.warnings) + self._last_output_dir = options.output_dir + self.open_folder_button.show() + count = len(export_result.written_files) + self.status_label.setText( + f"Export complete — {count} file{'s' if count != 1 else ''} written to " + f"{options.output_dir}." + ) + + def _populate_tables(self, result: CodebookResult) -> None: + tables = { + "preview": result.preview, + "variables": result.variables, + "value_labels": result.value_labels, + "missing_values": result.missing_values, + "warnings": result.warnings, + } + for index, (key, table) in enumerate(tables.items()): + self.models[key].set_dataframe(table) + base_title = self.tabs.tabText(index).split(" (", maxsplit=1)[0] + self.tabs.setTabText(index, f"{base_title} ({len(table):,})") + + self._set_summary_value("variables", len(result.variables)) + self._set_summary_value("labels", len(result.value_labels)) + self._set_summary_value("warnings", len(result.warnings)) + + def _set_summary_value(self, key: str, value: int) -> None: + self.summary_value_labels[key].setText(f"{value:,}") + + def _set_busy(self, busy: bool, message: str) -> None: + self.form_panel.setEnabled(not busy) + self.progress.setVisible(busy) + self.status_label.setText(message) + + def _open_output_folder(self) -> None: + if self._last_output_dir: + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self._last_output_dir))) - def _input_path(self) -> Path: + def _input_file(self) -> Path: value = self.input_path.text().strip() if not value: - raise ValueError("Select an input SPSS file.") - return Path(value) - - def _output_dir(self) -> Path: + raise ValueError("Choose a .sav or .zsav file first.") + path = Path(value) + if path.suffix.lower() not in SUPPORTED_SUFFIXES: + raise ValueError("The selected file must use the .sav or .zsav extension.") + if not path.is_file(): + raise FileNotFoundError(f"File not found: {path}") + return path + + def _output_folder(self) -> Path: value = self.output_dir.text().strip() if not value: - raise ValueError("Select an output location.") + raise ValueError("Choose an output folder.") return Path(value) - def _output_name(self) -> str: + def _output_stem(self) -> str: value = self.output_name.text().strip() if not value: raise ValueError("Enter an output name.") return value + def _current_signature(self, *, silent: bool = False) -> tuple[Path, bool] | None: + try: + raw_path = self.input_path.text().strip() + if not raw_path: + return None + path = Path(raw_path) + if not silent and not path.is_file(): + return None + return (path.resolve(), self.frequencies_checkbox.isChecked()) + except OSError: + return None + + def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 + urls = event.mimeData().urls() + if any(Path(url.toLocalFile()).suffix.lower() in SUPPORTED_SUFFIXES for url in urls): + event.acceptProposedAction() + + def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 + for url in event.mimeData().urls(): + path = Path(url.toLocalFile()) + if path.suffix.lower() in SUPPORTED_SUFFIXES: + self._set_input_path(path) + event.acceptProposedAction() + return + def main() -> int: app = QApplication(sys.argv) + app.setApplicationName("SPSS Codebook Rescue") + app.setApplicationVersion(__version__) + app.setOrganizationName("SPSS Codebook Rescue") + app.setStyle("Fusion") + app.setWindowIcon(QIcon(str(resource_path("assets/app-icon.svg")))) + app.setStyleSheet(app_stylesheet()) window = MainWindow() window.show() return app.exec() diff --git a/src/spss_codebook/theme.py b/src/spss_codebook/theme.py new file mode 100644 index 0000000..ab5e2d8 --- /dev/null +++ b/src/spss_codebook/theme.py @@ -0,0 +1,139 @@ +"""Visual theme and packaged-resource lookup for the desktop application. + +Keeping the stylesheet separate keeps GUI behavior easy to review and avoids a +third-party theme dependency in the portable build. +""" + +import sys +from pathlib import Path + + +def resource_path(relative_path: str) -> Path: + """Resolve an asset in both source checkouts and PyInstaller bundles.""" + + bundle_root = getattr(sys, "_MEIPASS", None) + root = Path(bundle_root) if bundle_root else Path(__file__).resolve().parents[2] + return root / relative_path + + +def app_stylesheet() -> str: + """Return the stylesheet with absolute paths for checkbox assets.""" + + unchecked = resource_path("assets/checkbox-unchecked.svg").as_posix() + checked = resource_path("assets/checkbox-checked.svg").as_posix() + return _APP_STYLESHEET.replace("__UNCHECKED__", unchecked).replace("__CHECKED__", checked) + + +_APP_STYLESHEET = """ +QWidget { + color: #17202a; + background: #f4f7fa; + font-family: "Segoe UI"; + font-size: 10pt; +} +QLabel#pageTitle { + color: #102a43; + font-size: 22pt; + font-weight: 700; +} +QLabel { background: transparent; } +QLabel#pageSubtitle, QLabel#summaryCaption, QLabel#versionLabel { + color: #627d98; +} +QFrame#panel, QFrame#summaryCard { + background: #ffffff; + border: 1px solid #d9e2ec; + border-radius: 9px; +} +QFrame#summaryCard { min-width: 130px; } +QLabel#summaryValue { + background: transparent; + color: #102a43; + font-size: 17pt; + font-weight: 650; +} +QLabel#summaryCaption, QLabel#statusLabel, QLabel#versionLabel { + background: transparent; +} +QLineEdit { + min-height: 35px; + padding: 0 10px; + background: #ffffff; + border: 1px solid #bcccdc; + border-radius: 6px; + selection-background-color: #2f6fed; +} +QLineEdit:focus { border: 2px solid #2f6fed; } +QPushButton { + min-height: 35px; + padding: 0 15px; + background: #ffffff; + border: 1px solid #bcccdc; + border-radius: 6px; + font-weight: 600; +} +QPushButton:hover { background: #eef4fb; } +QPushButton:pressed { background: #d9e8f7; } +QPushButton:disabled { color: #9fb3c8; background: #f0f4f8; } +QPushButton#primaryButton { + color: #ffffff; + background: #2457c5; + border-color: #2457c5; +} +QPushButton#primaryButton:hover { background: #1d4aa9; } +QPushButton#secondaryButton { color: #2457c5; border-color: #7b9ed9; } +QPushButton#linkButton { + color: #2457c5; + background: transparent; + border: none; + padding: 0 8px; +} +QCheckBox { spacing: 7px; background: transparent; } +QCheckBox::indicator { + width: 18px; + height: 18px; +} +QCheckBox::indicator:unchecked { image: url("__UNCHECKED__"); } +QCheckBox::indicator:checked { image: url("__CHECKED__"); } +QTabWidget::pane { + background: #ffffff; + border: 1px solid #d9e2ec; + border-radius: 7px; + top: -1px; +} +QTabBar::tab { + color: #486581; + background: transparent; + border: none; + padding: 9px 14px; +} +QTabBar::tab:selected { + color: #2457c5; + font-weight: 700; + border-bottom: 2px solid #2457c5; +} +QTableView { + background: #ffffff; + alternate-background-color: #f7f9fc; + border: none; + selection-background-color: #dce8fb; + selection-color: #102a43; +} +QHeaderView::section { + color: #334e68; + background: #edf2f7; + border: none; + border-right: 1px solid #d9e2ec; + border-bottom: 1px solid #d9e2ec; + padding: 8px; + font-weight: 650; +} +QProgressBar { + max-height: 6px; + border: none; + border-radius: 3px; + background: #d9e2ec; +} +QProgressBar::chunk { background: #2f6fed; border-radius: 3px; } +QMessageBox { background: #f4f7fa; } +""" diff --git a/tests/test_codebook.py b/tests/test_codebook.py index 079b776..0bd84ea 100644 --- a/tests/test_codebook.py +++ b/tests/test_codebook.py @@ -76,8 +76,7 @@ def test_codebook_extracts_labels_missing_values_and_warnings(tmp_path): assert age_missing["label"] == "No answer" gender_three = result.value_labels[ - (result.value_labels["variable_name"] == "gender") - & (result.value_labels["value"] == "3") + (result.value_labels["variable_name"] == "gender") & (result.value_labels["value"] == "3") ].iloc[0] assert gender_three["source"] == "observed_unlabelled" assert gender_three["value_label"] == "" @@ -131,10 +130,16 @@ def test_exports_excel_and_csv_with_expected_files_and_sheets(tmp_path): } assert {path.name for path in export_result.written_files} == expected_names - workbook = openpyxl.load_workbook(tmp_path / "study_codebook.xlsx", read_only=True) + workbook = openpyxl.load_workbook(tmp_path / "study_codebook.xlsx") assert workbook.sheetnames == ["variables", "value_labels", "missing_values", "warnings"] + variables_sheet = workbook["variables"] + assert variables_sheet.freeze_panes == "A2" + assert variables_sheet.auto_filter.ref == variables_sheet.dimensions + assert variables_sheet["A1"].font.bold is True - variables_header = (tmp_path / "study_variables.csv").read_text(encoding="utf-8").splitlines()[0] + variables_header = ( + (tmp_path / "study_variables.csv").read_text(encoding="utf-8").splitlines()[0] + ) assert variables_header == ",".join(VARIABLE_COLUMNS) @@ -165,3 +170,42 @@ def test_gui_and_cli_share_workflow_pipeline(tmp_path): ) assert (tmp_path / "workflow_codebook.xlsx") in export_result.written_files + + +def test_rejects_missing_input_file(tmp_path): + with pytest.raises(FileNotFoundError, match="SPSS file not found"): + build_codebook(tmp_path / "missing.sav") + + +def test_rejects_export_without_a_format(tmp_path): + sav_path = write_fixture(tmp_path / "fixture.sav") + result = build_codebook(sav_path) + + with pytest.raises(ValueError, match="at least one export format"): + export_codebook( + result, + ExportOptions( + output_dir=tmp_path, + output_name="study", + export_excel=False, + export_csv=False, + ), + ) + + +def test_range_missing_values_are_marked_in_value_labels(tmp_path): + sav_path = tmp_path / "range_missing.sav" + df = pd.DataFrame({"score": [1, 95, 99]}) + pyreadstat.write_sav( + df, + str(sav_path), + variable_value_labels={"score": {1: "Valid", 95: "Refused", 99: "Unknown"}}, + missing_ranges={"score": [{"lo": 90, "hi": 99}]}, + ) + + result = build_codebook(sav_path) + + labelled = result.value_labels.set_index("value") + assert bool(labelled.loc["1", "is_user_missing"]) is False + assert bool(labelled.loc["95", "is_user_missing"]) is True + assert bool(labelled.loc["99", "is_user_missing"]) is True