[
  {
    "path": ".gitignore",
    "content": "__pycache__/\n\nbuild/\n*.egg-info/\n\nvenv/\n.venv/\ndist/\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "content": "# Commands used in development\n\nBefore committing ensure all tests pass and format files.\n\n## Running\n\nRun decman as root to test all changes:\n\n```sh\nsudo uv run --all-packages decman\n```\n\n## Python shell\n\nRunning a python shell with all the packages.\n\n```sh\nsudo uv run --all-packages python\nsudo uv run --exact --package decman python\n```\n\n## Testing\n\nRun all unit tests (`-s` disables output capturing, needed for PTY test):\n\n```sh\nuv run --package decman pytest -s tests/\nuv run --package decman-pacman pytest plugins/decman-pacman/tests/\nuv run --package decman-systemd pytest plugins/decman-systemd/tests/\nuv run --package decman-flatpak pytest plugins/decman-flatpak/tests/\n```\n\n## Formatting\n\nFormat all files:\n\n```sh\nuv run ruff format\n```\n\n## Linting\n\nRun lints:\n\n```sh\nuv run ruff check\n```\n\nApply fixes:\n\n```sh\nuv run ruff check --fix\n```\n\n## Installing the example plugin\n\n```sh\nuv pip install -e example/plugin/\n```\n\nUninstalling:\n\n```sh\nuv pip uninstall decman-plugin-example\n```\n\nMaking the plugin available/unavailable:\n\n```sh\ntouch /tmp/example_plugin_available\nrm /tmp/example_plugin_available\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nthe GNU General Public License is intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.  We, the Free Software Foundation, use the\nGNU General Public License for most of our software; it applies also to\nany other work released this way by its authors.  You can apply it to\nyour programs, too.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  To protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights.  Therefore, you have\ncertain responsibilities if you distribute copies of the software, or if\nyou modify it: responsibilities to respect the freedom of others.\n\n  For example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received.  You must make sure that they, too, receive\nor can get the source code.  And you must show them these terms so they\nknow their rights.\n\n  Developers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\n  For the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software.  For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\n  Some devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the manufacturer\ncan do so.  This is fundamentally incompatible with the aim of\nprotecting users' freedom to change the software.  The systematic\npattern of such abuse occurs in the area of products for individuals to\nuse, which is precisely where it is most unacceptable.  Therefore, we\nhave designed this version of the GPL to prohibit the practice for those\nproducts.  If such problems arise substantially in other domains, we\nstand ready to extend this provision to those domains in future versions\nof the GPL, as needed to protect the freedom of users.\n\n  Finally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish to\navoid the special danger that patents applied to a free program could\nmake it effectively proprietary.  To prevent this, the GPL assures that\npatents cannot be used to render the program non-free.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Use with the GNU Affero General Public License.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU General Public License from time to time.  Such new versions will\nbe similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU General Public License for more details.\n\n    You should have received a copy of the GNU General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n    <program>  Copyright (C) <year>  <name of author>\n    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n    This is free software, and you are welcome to redistribute it\n    under certain conditions; type `show c' for details.\n\nThe hypothetical commands `show w' and `show c' should show the appropriate\nparts of the General Public License.  Of course, your program's commands\nmight be different; for a GUI interface, you would use an \"about box\".\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU GPL, see\n<https://www.gnu.org/licenses/>.\n\n  The GNU General Public License does not permit incorporating your program\ninto proprietary programs.  If your program is a subroutine library, you\nmay consider it more useful to permit linking proprietary applications with\nthe library.  If this is what you want to do, use the GNU Lesser General\nPublic License instead of this License.  But first, please read\n<https://www.gnu.org/licenses/why-not-lgpl.html>.\n"
  },
  {
    "path": "README.md",
    "content": "# Decman\n\n> Decman has breaking changes!\n> Decman has undergone an architecture rewrite. The new architecture makes decman more expandable and maintainable.\n>\n> Migration guide is [here](/docs/migrate-to-v1.md).\n\nDecman is a declarative package & configuration manager for Arch Linux. It allows you to manage installed packages, your dotfiles, enabled systemd units, and run commands automatically. Your system is configured using Python so your configuration can be very adaptive.\n\n## Overview\n\n[See the example for a quick tutorial.](/example/README.md)\n\nTo use decman, you need a source file that declares your system installation. I recommend you put this file in source control, for example in a git repository.\n\n`/home/user/config/source.py`:\n\n```py\nimport decman\n\nfrom decman import File, Directory\n\n# Declare installed pacman packages\ndecman.pacman.packages |= {\"base\", \"linux\", \"linux-firmware\", \"networkmanager\", \"ufw\", \"neovim\"}\n\n# Declare installed aur packages\ndecman.aur.packages |= {\"decman\"}\n\n# Declare configuration files\n# Inline\ndecman.files[\"/etc/vconsole.conf\"] = File(content=\"KEYMAP=us\")\n\n# From files within your source repository\n# (full path here would be /home/user/config/dotfiles/pacman.conf)\ndecman.files[\"/etc/pacman.conf\"] = File(source_file=\"./dotfiles/pacman.conf\")\n\n# Declare a whole directory\ndecman.directories[\"/home/user/.config/nvim\"] = Directory(source_directory=\"./dotfiles/nvim\",\n                                                          owner=\"user\")\n# Ensure that a systemd unit is enabled.\ndecman.systemd.enabled_units |= {\"NetworkManager.service\"}\n```\n\nTo better organize your system configuration, you can create modules.\n\n`/home/user/config/syncthing.py`:\n\n```py\nfrom decman import Module, Store, prg\nfrom decman.plugins import pacman, systemd\n\n# Your custom modules are child classes of the module class.\n# They can override methods of the Module-class.\nclass Syncthing(Module):\n\n    def __init__(self):\n        super().__init__(name=\"syncthing\")\n\n    # Run code when a module is first enabled\n    def on_enable(self, store: Store):\n        # Note: store is a key-value store that will persist between decman runs.\n        # You can use it to store your own data as well. Here it is not needed.\n\n        # Call a program\n        prg([\"ufw\", \"allow\", \"syncthing\"])\n\n        # Run any python code\n        print(\"Remember to setup syncthing with the browser UI!\")\n\n    # On disable is a special method, it will get executed when this module no longer exists.\n    # Therefore it must be static, take no parameters, and inline all imports.\n    # Imported modules should be available everywhere.\n    @staticmethod\n    def on_disable():\n        # Run code when a module is disabled\n        import decman\n        decman.prg([\"ufw\", \"deny\", \"syncthing\"])\n\n    # Decorate a function with @pacman.packages to indicate it returns a set of pacman packages\n    # to be installed\n    @pacman.packages\n    def pacman_packages(self) -> set[str]:\n        return {\"syncthing\"}\n\n    # Systemd units are declared in a similiar fashion\n    @systemd.user_units\n    def systemd_user_units(self) -> dict[str, set[str]]:\n        # Systemd user units part of this module\n        return {\"user\": {\"syncthing.service\"}}\n```\n\nThen import your module in your main source file.\n\n`/home/user/config/source.py`:\n\n```py\nimport decman\nfrom syncthing import Syncthing\n\ndecman.modules += [Syncthing()]\n```\n\nThen run decman.\n\n> [!WARNING]\n> Decman runs as root. This means that your `source.py` will be executed as root as well.\n\n```sh\nsudo decman --source /home/user/config/source.py\n```\n\nWhen you first run decman, you must define the source file, but subsequent runs remember the previous value.\n\n```sh\nsudo decman\n```\n\nDecman has some CLI options, to see them all run:\n\n```sh\ndecman --help\n```\n\nFor troubleshooting and submitting issues, you should use the `--debug` option.\n\n```sh\nsudo decman --debug\n```\n\n[See the complete documentation for using decman.](/docs/README.md)\n\n## Installation\n\nClone the decman PKGBUILD:\n\n```sh\ngit clone https://aur.archlinux.org/decman.git\n```\n\nReview the PKGBUILD and install it.\n\n```sh\ncd decman\nmakepkg -si\n```\n\nRemember to add decman to its own configuration.\n\n```py\nimport decman\ndecman.aur.packages |= {\"decman\"}\n```\n\n## What decman manages?\n\nDecman has built-in functionality for managing files and directories. Additionally decman manages system state using plugins. By default decman ships with the following plugins:\n\n- [pacman](/docs/pacman.md)\n- [systemd](/docs/systemd.md)\n- [aur](/docs/aur.md)\n- [flatpak](/docs/flatpak.md)\n\nAdditionally management of [users, groups and PGP keys](/docs/extras.md) is provided by built-in modules.\n\nPlugins can be disabled if desired and flatpaks are disabled by default.\n\nPlease read the documentation to understand the functionality of those plugins in detail. Here are quick examples to show what the default plugins are capable of.\n\n### Pacman\n\nPacman plugins manages native packages. Native packages can be installed from the pacman repositories. This plugin will never touch AUR packages.\n\n```py\nimport decman\n\n# Packages that decman ensures are installed to the system\ndecman.pacman.packages |= {\"firefox\", \"reflector\"}\n\n# These packages will never get installed or removed by decman.\ndecman.pacman.ignored_packages |= {\"opendoas\"}\n```\n\n### AUR\n\n> [!NOTE]\n> Building of AUR or custom packages is not the primary function of decman. There are some issues that I may or may not fix.\n> If you can't build a package using decman, consider adding it to `decman.aur.ignored_packages` and building it yourself.\n\nAUR plugins manages foreign packages. Foreign packages are installed from the AUR or other sources. This plugin will never touch native packages.\n\n```py\nimport decman\nfrom decman.plugins.aur import CustomPackage\n\n# AUR Packages that decman ensures are installed to the system\ndecman.aur.packages |= {\"android-studio\", \"fnm-bin\"}\n\n# These foreign packages will never get installed or removed by decman.\ndecman.aur.ignored_packages |= {\"yay\"}\n\n# You can add packages from custom sources.\n# Just add a package name and repository / directory containing a PKGBUILD\ndecman.aur.custom_packages |= {\n    CustomPackage(\"decman\", git_url=\"https://github.com/kiviktnm/decman-pkgbuild.git\"),\n    CustomPackage(\"my-own-package\", pkgbuild_directory=\"/path/to/directory/\"),\n}\n```\n\n### Systemd units\n\n> [!NOTE]\n> Decman will only enable and disable systemd services. It will not start or stop them.\n\nDecman can enable systemd services, system wide or for a specific user. Decman will enable all units defined in the source, and disable them when they are removed from the source. If a unit is not defined in the source, decman will not touch it.\n\n```py\nimport decman\n\n# System-wide units\ndecman.systemd.enabled_units |= {\"NetworkManager.service\"}\n\n# User specific units\ndecman.systemd.enabled_user_units.setdefault(\"user\", set()).update({\"syncthing.service\"})\n```\n\n### Flatpak\n\n```py\nimport decman\n\n# Flatpaks that decman ensures are installed to the system\ndecman.flatpak.packages |= {\"org.mozilla.firefox\", \"org.signal.Signal\"}\n\n# Flatpaks can be installed to specific users only\ndecman.flatpak.user_packages.setdefault(\"user\", {}).update({\"com.valvesoftware.Steam\"})\n\n# These flatpaks will never get installed or removed by decman.\ndecman.flatpak.ignored_packages |= {\"dev.zed.Zed\"}\n```\n\n### Users and PGP keys\n\nDecman ships with built-in modules for managing users, groups and PGP keys. The modules don't support all features. In particular the PGP module is inteded only for AUR packages. However, they still allow managing users declaratively. Read more about them [here](/docs/extras.md).\n\nHere these modules are used to create a `builduser` for AUR packages.\n\n```python\nimport decman\nimport os\nfrom decman.extras.gpg import GPGReceiver\nfrom decman.extras.users import User, UserManager\n\num = UserManager()\ngpg = GPGReceiver()\n\n# Add a normal user\num.add_user(User(\n    username=\"alice\",\n    groups=(\"libvirt\"),\n    shell=\"/usr/bin/fish\",\n))\n\n# Create builduser\num.add_user(User(\n    username=\"builduser\",\n    home=\"/var/lib/builduser\",\n    system=True,\n))\n\n# Receive desired PGP keys to that account (Spotify as an example)\ngpg.fetch_key(\n    user=\"builduser\",\n    gpg_home=\"/var/lib/builduser/gnupg\",\n    fingerprint=\"E1096BCBFF6D418796DE78515384CE82BA52C83A\",\n    uri=\"https://download.spotify.com/debian/pubkey_5384CE82BA52C83A.gpg\",\n)\n\n# Configure aur to use builduser and the GNUPGHOME.\nos.environ[\"GNUPGHOME\"] = \"/var/lib/builduser/gnupg\"\ndecman.aur.makepkg_user = \"builduser\"\n\n# Add version control systems required by the packages\ndecman.pacman.packages |= {\"fossil\"}\n\n# Add AUR packages that require PGP keys or builduser setup\ndecman.aur.packages |= {\"spotify\", \"pikchr-fossil\"}\n\n# Order matters here, users should be added before gpg keys\ndecman.modules += [um, gpg]\n```\n\n## Managing plugins and the order of operations\n\nThe order of operations is managed by setting `decman.execution_order`. This is also the default.\n\n```py\nimport decman\ndecman.execution_order = [\n    \"files\",\n    \"pacman\",\n    \"aur\",\n    \"systemd\",\n]\n```\n\nThis variable also manages which plugins are enabled. To enable flatpaks, simply add the plugin to the execution order.\n\n```py\nimport decman\ndecman.execution_order = [\n    \"files\",\n    \"pacman\",\n    \"aur\",\n    \"flatpak\",\n    \"systemd\",\n]\n```\n\nNote that `files` is not a plugin, but is defined here anyways.\n\nBefore the core execution order, decman will run hook methods from `Module`s.\n\n1. `before_update`\n2. `on_disable`\n\nAfter the plugin execution, decman will run the following hook methods.\n\n1. `on_enable`\n2. `on_change`\n3. `atfer_update`\n\nOperations and hooks may be skipped with command line options.\n\n```sh\n# Skip the aur plugin\nsudo decman --skip aur\n\n# Only apply file operations\nsudo decman --no-hooks --only files\n```\n\n## Why use decman?\n\nHere are some reasons why I created decman for myself.\n\n### Configuration as documentation\n\nYou can consult your config to see what packages are installed and what config files are created. If you organize your config into modules, you also see what files, systemd units and packages are related.\n\n### Modular config\n\nIn a modular config, you can also change parts of your system eg. switch shells without it affecting your other setups at all. If you create a module called `Shell` that exposes a function `add_alias`, you can call that function from other modules. Then later if you decide to switch from bash to fish, you can change the internals of your `Shell`-module without modifying your other modules at all.\n\n```py\nfrom decman import Module\n\n# Look below for an example of a theme module\nimport theme\n\nclass Shell(Module):\n    def __init__(self):\n        super().__init__(\"shell\")\n        self._aliases_text = \"\"\n\n    def add_alias(self, alias: str, cmd: str):\n        self._aliases_text += f\"alias {alias}='{cmd}'\\n\"\n\n    def files(self) -> dict[str, File]:\n        return {\n            \"/home/user/.config/fish/config.fish\":\n            File(source_file=\"./files/shell/config.fish\", owner=\"user\")\n        }\n\n    def file_variables(self) -> dict[str, str]:\n        fvars = {\n            \"%aliases%\": self._aliases_text,\n        }\n        # Remember this line when looking at the next point\n        fvars.update(theme.COLORS)\n        return fvars\n```\n\n### Consistency between applications\n\nDecman's file variables are a great way to make sure different tools are in sync. For example, you can create a theme file in your config and then use that theme in modules. The previous `Shell`-module imports a theme from a theme file.\n\n`theme.py`:\n\n```py\nCOLORS = {\n    \"%PRIMARY_COLOR%\": \"#b121ff\",\n    \"%SECONDARY_COLOR%\": \"#ff5577\",\n    \"%BACKGROUND_COLOR%\": \"#6a30d5\",\n    # etc\n}\n```\n\n### Reproducibility\n\nYou can easily reinstall your system using your decman config.\n\n### Dynamic configuration\n\nUsing python you can use the same config for different computers and only change some things between them.\n\n```py\nimport socket\n\nimport decman\n\nif socket.gethostname() == \"laptop\":\n    # add brightness controls to your laptop\n    decman.pacman.packages |= {\"brightnessctl\"}\n```\n\n## Alternatives\n\nThere are some alternatives you may want to consider instead of using decman.\n\n- [Ansible](https://docs.ansible.com/)\n- [aconfmgr](https://github.com/CyberShadow/aconfmgr)\n- [NixOS](https://nixos.org/)\n\n### Why not use NixOS?\n\nNixOS is a Linux disto built around the idea of declarative system management, so why create a more limited alternative?\n\nI tried NixOS in the past, but it had some issues that caused me to create decman for Arch Linux instead. In my opinion:\n\n- NixOS forces you to do everything the Nix way.\n- NixOS requires learning a new domain specific language.\n- NixOS is extreme when it comes to declaration. Sometimes you don't want _everything_ to be managed declaratively.\n\n## License\n\nCopyright (C) 2024-2025 Kivi Kaitaniemi\n\nDecman is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as\npublished by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.\n\nDecman is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of\nMERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.\n\nYou should have received a copy of the GNU General Public License along with this program. If not,\nsee <https://www.gnu.org/licenses/>.\n\nSee [license](LICENSE).\n"
  },
  {
    "path": "completions/_decman",
    "content": "#compdef decman\n# zsh completion for decman\n\n_arguments -s \\\n  '--source=[python file containing configuration]:config file:_files' \\\n  '--dry-run[print what would happen as a result of running decman]' \\\n  '--print[print what would happen as a result of running decman]' \\\n  '--debug[show debug output]' \\\n  '--skip[skip the following execution steps]:step(s):_message -r \"step\"' \\\n  '--only[run only the following execution steps]:step(s):_message -r \"step\"' \\\n  '--no-hooks[don'\\''t run hook methods for modules]' \\\n  '--no-color[don'\\''t print messages with color]' \\\n  '--params[additional parameters passed to plugins]:param(s):_message -r \"param\"' \\\n  '--help[show help]'\n"
  },
  {
    "path": "completions/decman.bash",
    "content": "# bash completion for decman\n\n_decman() {\n    local cur prev opts\n    COMPREPLY=()\n    cur=\"${COMP_WORDS[COMP_CWORD]}\"\n    prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\n    opts=\"--source --dry-run --print --debug --skip --only --no-hooks --no-color --params --help\"\n\n    case \"$prev\" in\n        --source)\n            # file completion\n            COMPREPLY=( $(compgen -f -- \"$cur\") )\n            return 0\n            ;;\n        --skip|--only|--params)\n            # free-form list\n            return 0\n            ;;\n    esac\n\n    if [[ \"$cur\" == --* ]]; then\n        COMPREPLY=( $(compgen -W \"$opts\" -- \"$cur\") )\n        return 0\n    fi\n\n    return 0\n}\ncomplete -F _decman decman\n"
  },
  {
    "path": "completions/decman.fish",
    "content": "# fish completion for decman\n\ncomplete -c decman -l source   -r -d \"python file containing configuration\" -a \"(__fish_complete_path)\"\ncomplete -c decman -l dry-run     -d \"print what would happen as a result of running decman\"\ncomplete -c decman -l print       -d \"print what would happen as a result of running decman\"\ncomplete -c decman -l debug       -d \"show debug output\"\ncomplete -c decman -l skip     -r -d \"skip the following execution steps\"\ncomplete -c decman -l only     -r -d \"run only the following execution steps\"\ncomplete -c decman -l no-hooks    -d \"don't run hook methods for modules\"\ncomplete -c decman -l no-color    -d \"don't print messages with color\"\ncomplete -c decman -l params   -r -d \"additional parameters passed to plugins\"\n"
  },
  {
    "path": "docs/README.md",
    "content": "# Decman documentation\n\nThis contains the documentation for decman. Each plugin has its own documentation. For a quick overview of decman, see the [README](/README.md). For a tutorial, see the [example](/example/README.md).\n\n- [pacman](/docs/pacman.md)\n- [systemd](/docs/systemd.md)\n- [aur](/docs/aur.md)\n- [flatpak](/docs/flatpak.md)\n\nCheck out [extras](/docs/extras.md) for documentation for built-in modules.\n\n## Quick notes\n\n\"Decman source\" or \"source\" refers to your system configuration. It is set using the `--source` command line argument with decman.\n\n```sh\nsudo decman --source /this/is/your/source.py\n```\n\nDecman and decman plugins use sets for most collections to avoid duplicates. Remember to add values to sets instead of reassigning it.\n\n```py\nimport decman\n\n# GOOD\ndecman.pacman.packages |= {\"vim\"}\n\n# BAD, now there is only \"vim\" in the packages, all previous operations were overridden.\ndecman.pacman.packages = {\"vim\"}\n```\n\nIn Python you should not use from imports with global variables. It can lead to issues.\n\n```py\n# DO THIS:\nimport decman\ndecman.pacman.packages |= {\"vim\"}\n\n# THIS MAY NOT WORK\nfrom decman import pacman\npacman.packages |= {\"vim\"}\n```\n\nYou can still import classes and functions with from imports safely.\n\n```py\nfrom decman import File\n# pacman here refers to the pacman module containing the plugin\n# not the plugin instance\nfrom decman.plugins import pacman\n```\n\n## Decman Store\n\nDecman stores data in the file `/var/lib/decman/store.json`. This file should not be modified manually. However, if encountering bugs with decman, manual modification may be desirable. The file is JSON so editing it should be easy enough.\n\nUsing the store:\n\n```py\n# The store is always given as a parameter to a method call.\n# You don't need to create new instances.\nstore[\"key\"] = value\n\n# To ensure that a key exists (with a default value if it doesn't)\nstore.ensure(\"my_dict\", {})\nstore[\"my_dict\"][\"dict_key\"] = 3\n```\n\nThis store is also available to plugins and modules. The following keys are used by decman:\n\n- `allow_running_source_without_prompt`\n- `source_file`\n- `enabled_modules`\n- `module_on_disable_scripts`\n- `all_files`\n\nDetails about the keys used by each plugin are provided in the plugin’s documentation.\n\n## Configuring decman\n\nDecman has a small number of configuration options. They are set in your source file with python. These values are prioritized over command line options.\n\nImport the config to modify it.\n\n```py\nimport decman.config\n```\n\nEnable debug messages\n\n```py\ndecman.config.debug_output = False\n```\n\nDisable info messages\n\n```py\ndecman.config.quiet_output = False\n```\n\nSet colored output. This setting should not be used. It should be passed as a command line argument or an environment variable instead.\n\n- Command line argument: `--no-color`\n- Environment variables:\n  - `NO_COLOR`: disables color\n  - `FORCE_COLOR`: enables color\n\n```py\n  decman.config.color_output = True\n```\n\nDirectory for scripts containing Modules' on_disable code\n\n```py\ndecman.config.module_on_disable_scripts_dir = \"/var/lib/decman/scripts/\"\n```\n\nCache directory. Plugins like the AUR plugin use this directory as their own cache.\n\n```py\ndecman.config.cache_dir = \"/var/cache/decman\"\n```\n\nThe architecture of the computer's CPU. Currently, this is only used by the AUR plugin, but it may be useful for some other plugins.\n\n```py\ndecman.config.arch = \"x86_64\"\n```\n\n## Files, directories and symlink\n\nDecman functions as a dotfile manager. It will install the defined files, directories and symlinks to their destinations. You can set file permissions, owners as well as define variables that will be substituted in the installed files. Decman keeps track of all files it creates and when a file is no longer present in your source, it will be also removed from its destination. This helps with keeping your system clean. However, decman won't remove directories as they might contain files that weren't created by decman.\n\nSymlinks management is simpler and more limited than for files since symlinks cannot have file permissions or ownership.\n\nVariables can only be defined for files within modules. See the module example for using file variables.\n\nFiles and directories are updated during the `files` execution order step.\n\n### File\n\nDeclarative file specification describing how a file should be materialized at a target path.\n\n```py\nfrom decman import File\nimport decman\n\n# To declare a file, add it's target path and create a File object\ndecman.files[\"/home/me/.config/nvim/init.lua\"] = File(\n    source_file=\"./dotfiles/nvim/init.lua\",\n    bin_file=False,\n    encoding=\"utf-8\",\n    owner=\"me\",\n    group=\"users\",\n    permissions=0o700,\n)\n```\n\nExactly one of `source_file` or `content` must be provided.\n\nThe file can be created by copying an existing source file or by writing provided content. For text files, optional variable substitution is applied at copy time. Binary files are copied or written verbatim and never undergo substitution.\n\nOwnership, permissions, and parent directories are enforced on creation. Missing parent directories are created recursively and assigned the same ownership as the file when specified.\n\n#### Parameters:\n\n- `source_file: str`: Path to an existing file to copy from. Mutually exclusive with `content`.\n- `content: str`: In-memory file contents to write. Mutually exclusive with `source_file`.\n- `bin_file: bool`: If `True`, treat the file as binary. Disables variable substitution and writes bytes verbatim.\n- `encoding: str`: Text encoding used when reading or writing non-binary files.\n- `owner: str`: System user name to own the file and created parent directories.\n- `group: str`: System group name to own the file and created parent directories. By default the `owner`'s group is used.\n- `permissions: int`: File mode applied to the target file (e.g. `0o644`).\n\nNote: Variable substitution is a simple string replacement where each key in variables is replaced by its corresponding value. No escaping or templating semantics are applied.\n\n### Directory\n\nDeclarative specification for copying the contents of a source directory into a target directory.\n\n```py\nfrom decman import Directory\nimport decman\n\n# To declare a directory, add it's target path and create a Directory object\ndecman.directories[\"/home/me/.config/nvim\"] = Directory(\n    source_directory=\"./dotfiles/nvim\",\n    bin_files=False,\n    encoding=\"utf-8\",\n    owner=\"me\",\n    group=\"users\",\n    permissions=0o600,\n)\n```\n\nFor text files in the directory, optional variable substitution is applied at copy time. Binary files are copied or written verbatim and never undergo substitution.\n\nOwnership, permissions, and parent directories are enforced on creation. Missing parent directories are created recursively and assigned the same ownership as the target directory when specified.\n\n#### Parameters:\n\n- `source_directory: str`: Path to the directory whose contents will be mirrored into the target.\n- `bin_files: bool`: If `True`, treat all files as binary. Disables variable substitution and copies bytes verbatim.\n- `encoding: str`: Text encoding used when reading or writing non-binary files.\n- `owner: str`: System user name to own the files and directories.\n- `group: str`: System group name to own the files and directories. By default the `owner`'s group is used.\n- `permissions: int`: File mode applied to the created or updated files (e.g. `0o644`).\n\n### Symlink\n\nDeclare a link to a target. Missing directories are created. If you need to configure parent folder permissions, use `Symlink` objects.\n\n```py\nimport decman\n\n# Replaces sudo with doas\n# /usr/bin/sudo -> /usr/bin/doas\ndecman.symlinks[\"/usr/bin/sudo\"] = \"/usr/bin/doas\"\n\n# I don't know why would you ever do this but as an example\ndecman.symlinks[\"/home/me/.bin/mydoas\"] = decman.Symlink(\"/usr/bin/doas\", owner=\"me\", group=\"users\")\n```\n\n## Modules\n\nModules allow grouping related functionality together.\n\nA **Module** is the primary unit for grouping related files, directories, packages, and executable logic in decman. Create your own modules by subclassing `Module`. Then override the methods documented below.\n\nEach module is uniquely identified by its `name`.\n\nRemember to add modules to decman. Modules are added to a list to preserve deterministic execution order for hooks. Modules added first will be executed first.\n\n```py\nimport decman\ndecman.modules += [MyModule()]\n```\n\n### Basic Structure\n\n```python\nfrom decman import Module\n\nclass MyModule(Module):\n    def __init__(self) -> None:\n        super().__init__(\"my-module\")\n```\n\n### Lifecycle Hooks\n\nModules can hook into specific phases of a decman run by overriding methods.\n\n#### Before update\n\nExecuted **before** any updates are applied.\n\n```python\ndef before_update(self, store):\n    ...\n```\n\n#### After update\n\nExecuted **after** all updates are applied.\n\n```python\ndef after_update(self, store):\n    ...\n```\n\n#### On enable\n\nExecuted **once**, when the module transitions from disabled to enabled.\n\n```python\ndef on_enable(self, store):\n    ...\n```\n\n#### On change\n\nExecuted when the module’s **content changes** between runs. Module's content is deemed changed if:\n\n- If files or directories defined within the module have their content updated\n- A plugin marks the module as changed\n  - For example, the pacman plugin marks a module as changed if the packages defined within that module change\n\n```python\ndef on_change(self, store):\n    ...\n```\n\n#### On disable\n\nExecuted when the module is disabled. A module is disabled when it's removed from the modules set.\n\n**Must be declared as `@staticmethod`.**\nValidated at class creation time.\n\n```python\n@staticmethod\ndef on_disable():\n    import os\n    os.remove(\"/some/file\")\n```\n\n**Important constraints:**\n\n- Code is copied verbatim into a temporary file\n- No external variables\n- Imports must be inside the function\n- Signature must be exactly `on_disable()`\n\n### Filesystem Declarations\n\nModules can declaratively define files and directories to be installed.\n\n#### Files\n\nReturns a mapping of target paths to `File` objects.\n\n```python\ndef files(self) -> dict[str, File]:\n    return {\n        \"/etc/myapp/config.conf\": File(source_file=\"./dotfiles/config.conf\"),\n    }\n```\n\n#### Directories\n\nReturns a mapping of target paths to `Directory` objects.\n\n```python\ndef directories(self) -> dict[str, Directory]:\n    return {\n        \"/var/lib/myapp\": Directory(source_directory=\"./dotfiles/myapp\"),\n    }\n```\n\n#### File Variable Substitution\n\nDefines variables that are substituted inside **text files** belonging to the module.\n\n```python\ndef file_variables(self) -> dict[str, str]:\n    return {\n        \"HOSTNAME\": \"example.com\",\n        \"PORT\": \"8080\",\n    }\n```\n\n#### Symlinks\n\nDefines symlinks fro the module.\n\n```py\ndef symlinks(self) -> dict[str, str | Symlink]:\n    return {\n        \"/etc/resolv.conf\": \"/run/systemd/resolve/resolv.conf\",\n        \"/home/me/.config/app/file.conf\": Symlink(\"/home/me/.file.conf\", owner=\"me\"),\n    }\n```\n\n### Extending with plugins\n\nTo include plugin functionality inside a module, create a new method and mark it with the plugin's decorator. During the execution of decman, the plugin will call the marked method and use its result. Here is an example with the pacman plugin.\n\n```py\nfrom decman.plugins import pacman\n\n@pacman.packages\ndef pacman_packages(self) -> set[str]:\n    return {\"wget\", \"zip\"}\n```\n\n## Plugins\n\nPlugins are used to manage a single aspect of a system declaratively. Decman ships with some default plugins useful with Arch Linux but it is possible to add custom plugins.\n\nTo manage the execution order of plugins set `decman.execution_order`.\n\n```py\nimport decman\ndecman.execution_order = [\n    \"files\", # not a plugin but included here\n    \"pacman\",\n    \"aur\",\n    \"flatpak\",\n    \"systemd\",\n]\n```\n\nAvailable plugins are found in `decman.plugins`. You can add your own plugins to that dictionary.\n\n```py\nimport decman\nmy_plugin = MyPlugin()\ndecman.plugins[\"my-plugin\"] = my_plugin\n\n# Remember to include your plugin in the execution order\ndecman.execution_order += [\"my-plugin\"]\n```\n\nFor conveniance, decman provides some plugins with quick access.\n\n```py\nimport decman\n\nassert decman.pacman == decman.plugins.get(\"pacman\")\nassert decman.aur == decman.plugins.get(\"aur\")\nassert decman.systemd == decman.plugins.get(\"systemd\")\nassert decman.flatpak == decman.plugins.get(\"flatpak\")\n```\n\n### Creating custom plugins\n\nCreate your own modules by subclassing `Plugin`. Then override the methods documented below.\n\n#### Basic Structure\n\n```python\nfrom decman.plugins import Plugin\n\nclass MyPlugin(Plugin):\n    # Plugins should be singletons. (Only one instance exists ever.)\n    # This name should be the same as the key used in decman.plugins dict.\n    NAME = \"my-plugin\"\n```\n\n#### Availability check\n\nChecks if this plugin can be enabled. For example, this could check if a required command is available. Returns `True` if this plugin can be enabled.\n\nThis is not useful if the plugin is directly added to `decman.plugins`. However, if using the Python package method for installing plugins, this check is used before adding the plugin automatically to `decman.plugins`.\n\nPlease note that this availibility check is executed before **any** decman steps. If a plugin depends on a pacman package, and that package is defined in the source but not yet installed, the plugin will not be available during the first run of decman.\n\n```py\ndef available(self) -> bool:\n    return True\n```\n\n#### Process modules\n\nThis method gathers state information from modules. If the module's state has changed since the last time running this plugin, set the module to changed. For example, the pacman plugin uses this method to find which modules have methods marked with `@pacman.packages` and calls them.\n\nThis method only gathers information. It doesn't apply it.\n\n```py\nfrom decman import Store, Module\n\ndef process_modules(self, store: Store, modules: list[Module]):\n    ...\n\n    # Toy example for setting modules as changed\n    for module in modules:\n        module._changed = True\n```\n\n#### Apply\n\nEnsures that the state managed by this plugin is present on the system.\n\n`dry_run` indicates that changes should only be printed, not yet applied.\n\n`params` is a list of strings passed as command line arguments. For example running `decman --params abc def` would cause `params = [\"abc\", \"def\"]`.\n\nThis method must not raise exceptions. Instead it should return `False` to indicate a\nfailure. The method should handle it's exceptions and print them to the user.\n\n```py\nfrom decman import Store\n\ndef apply(\n    self, store: Store, dry_run: bool = False, params: list[str] | None = None\n) -> bool:\n    return True\n```\n\n### Installing plugins as Python packages\n\nYou can have decman automatically detect plugins by creating a Python package with entry points in `decman.plugins`. Decman also does this with its own plugins.\n\nIn `pyproject.toml` set:\n\n```toml\n[project.entry-points.\"decman.plugins\"]\npacman = \"decman.plugins.pacman:Pacman\"\naur = \"decman.plugins.aur:AUR\"\n```\n\n## Useful utilities\n\nDecman ships with some useful utilites that can help with modules and plugins.\n\n### Run commands\n\nRuns a command and returns its output.\n\n```py\nimport decman\ndecman.prg(\n    [\"nvim\", \"--headless\", \"+Lazy! sync\", \"+qa\"],\n    user = \"user\",\n    env_overrides = {\"EXAMPLE\": \"value\"},\n    pass_environment = True,\n    mimic_login = True,\n    pty = True,\n    check = True,\n)\n```\n\n#### Parameters\n\n- `cmd: list[str]`: Command to execute.\n- `user: str`: User name to run the command as. If set, the command is executed after dropping privileges to this user.\n- `pass_environment: bool`: Copy decman's execution environment variables and pass them to the subprocess.\n- `env_overrides: dict[str, str]`: Environment variables to override or add for the command execution. These values are merged on top of the current process environment.\n- `mimic_login: bool`: If mimic_login is True, will set the following environment variables according to the given user's passwd file details. This only happens when user is set.\n  - `HOME`\n  - `USER`\n  - `LOGNAME`\n  - `SHELL`\n- `pty: bool`: If `True`, run the command inside a pseudo-terminal (PTY). This enables interactive behavior and terminal-dependent programs. If `False`, run the command without a PTY using standard subprocess execution.\n- `check`: If `True`, raise `decman.core.error.CommandFailedError` when the command exits with a non-zero status. If `False`, print a warning when encountering a non-zero exit code.\n\n### Run a command in a shell\n\nRuns a command in a shell and returns its output. Almost same as `decman.prg` but takes a string argument instead of a list and for example shell redirects are allowed.\n\n```py\nimport decman\ndecman.sh(\n    \"echo $EXAMPLE | less\",\n    user = \"user\",\n    env_overrides = {\"EXAMPLE\": \"value\"},\n    mimic_login = True,\n    pty = True,\n    check = True,\n)\n```\n\n#### Parameters\n\n- `sh_cmd: str`: Shell command to execute.\n- `user: str`: User name to run the command as. If set, the command is executed after dropping privileges to this user.\n- `env_overrides dict[str, str]`: Environment variables to override or add for the command execution. These values are merged on top of the current process environment.\n- `mimic_login: bool`: If mimic_login is True, will set the following environment variables according to the given user's passwd file details. This only happens when user is set.\n  - `HOME`\n  - `USER`\n  - `LOGNAME`\n  - `SHELL`\n- `pty: bool`: If `True`, run the command inside a pseudo-terminal (PTY). This enables interactive behavior and terminal-dependent programs. If `False`, run the command without a PTY using standard subprocess execution.\n- `check`: If `True`, raise `decman.core.error.CommandFailedError` when the command exits with a non-zero status. If `False`, print a warning when encountering a non-zero exit code.\n\n### Errors\n\nWhen your source needs to raise an error, decman provides `SourceError`s. Running commands with `prg` and `sh` may raise `decman.core.error.CommandFailedError`s if `check` is set to `True`. These are the errors that should be raised when decman runs your `source.py` file.\n\n```py\nimport decman\nraise decman.SourceError(\"boom\")\n```\n\n### Decman Core\n\nAdditionally, you can import the modules used by decman. They should be relatively stable and not change too much between decman versions. The module `decman.core.output` is probably the most relevant one, as it provides methods for printing output.\n"
  },
  {
    "path": "docs/aur.md",
    "content": "# AUR\n\n> [!NOTE]\n> While this plugin exists with the sole purpose of installing foreing packages, this functionality is not the primary purpose of decman. Issues regarding this plugin are not a priority.\n> If you can't build a package with this plugin, consider adding it to `ignored_packages` and building it yourself.\n\nAUR plugin can be used to manage AUR and custom packages. The pacman plugin manages only foreing packages installed from the AUR or elsewhere. All native (pacman repositories) packages are ignored by this plugin with the exception that this plugin will install native dependencies of foreign packages.\n\nIt manages packages exactly the same way as the pacman plugin.\n\n> This plugin will ensure that explicitly installed packages match those defined in the decman source. If your system has explicitly installed package A, but it is not included in the source, it will be uninstalled. You don't need to list dependencies in your source as those will be handeled by pacman automatically. However, if you have inluded package B in your source and that package depends on A, this plugin will not remove A. Instead it will demote A to a dependency. This plugin will also remove all orphaned packages automatically. **Packages that are only optionally required by other packages are considered orphans.** This way this plugin can ensure that your system truly matches your source. You cannot install an optional dependency, and forget about it later.\n\nBuilding of foreign packages happens in a chroot. This creates some overhead, but ensures clean builds. By default the chroot is created to `/tmp/decman/build`. If `/tmp` is a in-memory filesystem like tmpfs, make sure that the tmpfs-partition is large enough. I recommend at least 6 GB. You can also change the build directory if memory is an issue.\n\nBuild packages are by default stored in a cache `/var/cache/decman/aur`. This plugin keeps 3 most recent versions of all packages.\n\nWhen installing packages from other version control systems than git, you'll need to install the package for that VCS.\n\n## Usage\n\nDefine AUR packages. These will be installed from the AUR.\n\n```py\nimport decman\ndecman.aur.packages |= {\"android-studio\", \"fnm-bin\"}\n```\n\nDefine ignored foreing packages. These can be AUR packages or other foreign packages. These packages will never get installed or removed by the plugin.\n\n```py\ndecman.aur.ignored_packages |= {\"yay\"}\n```\n\nDefine packages from custom sources. Add a package name and repository / directory containing a PKGBUILD. This plugin will fetch the PKGBUILD, generate .SRCINFO and parse that to find the package details.\n\n```py\nfrom decman.plugins.aur import CustomPackage\ndecman.aur.custom_packages |= {\n    CustomPackage(\"decman\", git_url=\"https://github.com/kiviktnm/decman-pkgbuild.git\"),\n    CustomPackage(\"my-own-package\", pkgbuild_directory=\"/path/to/directory/\"),\n}\n```\n\nThis plugin's execution order step name is `aur`.\n\n### Command line\n\nThis plugin accepts params via the command line.\n\n```sh\nsudo decman --params aur-upgrade-devel aur-force\n```\n\n`aur-upgrade-devel` causes devel packages (packages from version control, such as `*-git` packages) to be upgraded.\n\n`aur-force` causes decman to rebuild packages that were already cached.\n\n### Within modules\n\nModules can also define AUR packages and custom packages. Decorate a module's method with `@decman.plugins.aur.packages` or `@decman.plugins.aur.custom_packages`. For AUR packages return a `set[str]` of package names from that module. Custom packages should return a `set[CustomPackage]`.\n\n```py\nimport decman\nfrom decman.plugins import aur\n\nclass MyModule(decman.Module):\n    ...\n\n    @aur.packages\n    def aur_packages_defined_in_this_module(self) -> set[str]:\n        return {\"android-studio\", \"fnm-bin\"}\n\n    @aur.custom_packages\n    def custom_packages_defined_in_this_module(self) -> set[aur.CustomPackage]:\n        return {\n            CustomPackage(\"decman\", git_url=\"https://github.com/kiviktnm/decman-pkgbuild.git\"),\n        }\n```\n\nIf these sets change, this plugin will flag the module as changed. The module's `on_change` method will be executed.\n\n## Recommended setup\n\nI recommend setting up a build user for AUR packages. Then you can import PGP keys to that user's keyring that will be used for verifying AUR packages. The build user setup might help with some version control systems such as fossil packages.\n\n```python\nimport decman\nimport os\nfrom decman.extras.gpg import GPGReceiver\nfrom decman.extras.users import User, UserManager\n\num = UserManager()\ngpg = GPGReceiver()\n\n# Create builduser\num.add_user(User(\n    username=\"builduser\",\n    home=\"/var/lib/builduser\",\n    system=True,\n))\n\n# Receive desired PGP keys to that account (Spotify as an example)\ngpg.fetch_key(\n    user=\"builduser\",\n    gpg_home=\"/var/lib/builduser/gnupg\",\n    fingerprint=\"E1096BCBFF6D418796DE78515384CE82BA52C83A\",\n    uri=\"https://download.spotify.com/debian/pubkey_5384CE82BA52C83A.gpg\",\n)\n\n# Configure aur to use builduser and the GNUPGHOME.\nos.environ[\"GNUPGHOME\"] = \"/var/lib/builduser/gnupg\"\ndecman.aur.makepkg_user = \"builduser\"\n\n# Add version control systems required by the packages\ndecman.pacman.packages |= {\"fossil\"}\n\n# Add AUR packages that require PGP keys or builduser setup\ndecman.aur.packages |= {\"spotify\", \"pikchr-fossil\"}\n\ndecman.modules += [um, gpg]\n```\n\n## Keys used in the decman store\n\n- `aur_packages_for_module`\n- `custom_packages_for_module`\n\n## Configuration\n\nThis module has partially the same configuration with pacman. You'll have to define pacman output keywords and database options again.\n\n```py\nimport decman\n# set keywords\ndecman.aur.keywords = {\"pacsave\", \"pacnew\", \"warning\"}\n# disable the feature\ndecman.aur.print_highlights = False\n\n# signature level for querying existing databases\ndecman.aur.database_signature_level = 2048 # pyalpm.SIG_DATABASE_OPTIONAL\n\n# path to databases\ndecman.aur.database_path = \"/var/lib/pacman/\"\n```\n\nThere are some options related to building packages.\n\n```py\n# Timeout for fetching information from AUR\ndecman.aur.aur_rpc_timeout = 30\n# User which builds AUR packages\ndecman.aur.makepkg_user = \"nobody\"\n# Directory used for building packages\ndecman.aur.build_dir = \"/tmp/decman/build\"\n```\n\nSome AUR packages must be verified with GPG keys. In that case set the `GNUPGHOME` environment variable to the keystore containing imported keys. Set `makepkg_user` user to the owner of that directory.\n\n```py\nimport os\nos.environ[\"GNUPGHOME\"] = \"/home/kk/.gnupg/\"\ndecman.aur.makepkg_user = \"kk\"\n```\n\nAdditionally it's possible to override the commands this plugin uses. Create your own `AurCommands` class and override methods returning commands. Since this plugin and the pacman plugin have many overlapping commands, `AurCommands` is actually a subclass of `PacmanCommands`. This means that you can use a single override class for both of them. These are the defaults.\n\n```py\nfrom decman.plugins import aur\nimport decman\n\nclass MyAurAndPacmanCommands(aur.AurCommands):\n    def install_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command installs the given packages from pacman repositories.\n        The packages are installed as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-S\", \"--needed\", \"--asdeps\"] + list(pkgs)\n\n    def install_files_as_dependencies(self, pkg_files: list[str]) -> list[str]:\n        \"\"\"\n        Running this command installs the given packages files as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-U\", \"--asdeps\"] + pkg_files\n\n    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:\n        \"\"\"\n        Running this command outputs -1 when the installed version is older than the new version.\n        \"\"\"\n        return [\"vercmp\", installed_version, new_version]\n\n    def git_clone(self, repo: str, dest: str) -> list[str]:\n        \"\"\"\n        Running this command clones a git repository to the the given destination.\n        \"\"\"\n        return [\"git\", \"clone\", repo, dest]\n\n    def git_diff(self, from_commit: str) -> list[str]:\n        \"\"\"\n        Running this command outputs the difference between the given commit and\n        the current state of the repository.\n        \"\"\"\n        return [\"git\", \"diff\", from_commit]\n\n    def git_get_commit_id(self) -> list[str]:\n        \"\"\"\n        Running this command outputs the current commit id.\n        \"\"\"\n        return [\"git\", \"rev-parse\", \"HEAD\"]\n\n    def git_log_commit_ids(self) -> list[str]:\n        \"\"\"\n        Running this command outputs commit hashes of the repository.\n        \"\"\"\n        return [\"git\", \"log\", \"--format=format:%H\"]\n\n    def review_file(self, file: str) -> list[str]:\n        \"\"\"\n        Running this command outputs a file for the user to see.\n        \"\"\"\n        return [\"less\", file]\n\n    def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command creates a new arch chroot to the chroot directory and installs the\n        given packages there.\n        \"\"\"\n        return [\"mkarchroot\", chroot_dir] + list(with_pkgs)\n\n    def install_chroot(self, chroot_dir: str, packages: list[str]):\n        \"\"\"\n        Running this command installs the given packages to the given chroot.\n        \"\"\"\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-S\",\n            \"--needed\",\n            \"--noconfirm\",\n        ] + packages\n\n    def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[str]:\n        \"\"\"\n        This command prints a real name of a package.\n        For example, it prints the package which provides a virtual package.\n        \"\"\"\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-Sddp\",\n            \"--print-format=%n\",\n            pkg,\n        ]\n\n    def remove_chroot(self, chroot_dir: str, packages: set[str]):\n        \"\"\"\n        Running this command removes the given packages from the given chroot.\n        \"\"\"\n        return [\"arch-nspawn\", chroot_dir, \"pacman\", \"-Rsu\", \"--noconfirm\"] + list(packages)\n\n    def make_chroot_pkg(\n        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]\n    ) -> list[str]:\n        \"\"\"\n        Running this command creates a package file using the given chroot.\n        The package is created as the user and the pkg_files_to_install are installed\n        in the chroot before the package is created.\n        \"\"\"\n        makechrootpkg_cmd = [\"makechrootpkg\", \"-c\", \"-r\", chroot_wd_dir, \"-U\", user]\n\n        for pkgfile in pkgfiles_to_install:\n            makechrootpkg_cmd += [\"-I\", pkgfile]\n\n        return makechrootpkg_cmd\n\n    def print_srcinfo(self) -> list[str]:\n        \"\"\"\n        Running this command prints SRCINFO generated from the package in the current\n        working directory.\n        \"\"\"\n        return [\"makepkg\", \"--printsrcinfo\"]\n\n    # -------------------------------------------\n    # Here I override some PacmanCommand methods.\n    # -------------------------------------------\n\n    def set_as_explicit(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command sets the given as explicitly installed.\n        \"\"\"\n        return [\"pacman\", \"-D\", \"--asexplicit\"] + list(pkgs)\n\n    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command sets the given packages as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-D\", \"--asdeps\"] + list(pkgs)\n```\n\nApplying the commands is easy.\n\n```py\nimport decman\ndecman.pacman.commands = MyAurAndPacmanCommands()\ndecman.aur.commands = MyAurAndPacmanCommands()\n```\n"
  },
  {
    "path": "docs/extras.md",
    "content": "# Extras\n\nDecman ships with some built in modules. They implement functionality that is probably useful for declarative management, but for one reason or another don't make sense as plugins.\n\n## User and group management module\n\n```python\nimport decman.extras.users\n```\n\nA decman module for managing system users, groups, and supplementary group membership and subordinate UID/GID ranges for existing users.\n\nThe module is **additive**: it only manages users/groups you explicitly register, and it only manages additional groups/subids you explicitly define. Anything created manually and not tracked by this module is left alone.\n\n### Provided types\n\n#### `Group`\n\nRepresents a managed group.\n\n```python\n@dataclass(frozen=True)\nclass Group:\n    groupname: str\n    gid: Optional[int] = None\n    system: bool = False\n```\n\nFields:\n\n- `groupname`: Group name.\n- `gid`: Desired numeric GID. If omitted, system assigns one.\n- `system`: Only affects _creation_ (`groupadd --system`). Changing this after creation does nothing.\n\n#### `User`\n\nRepresents a managed user.\n\n```python\n@dataclass(frozen=True)\nclass User:\n    username: str\n    uid: Optional[int] = None\n    group: Optional[str] = None\n    home: Optional[str] = None\n    shell: Optional[str] = None\n    groups: tuple[str, ...] = ()\n    system: bool = False\n```\n\nFields:\n\n- `username`: Login name.\n- `uid`: Desired numeric UID. If omitted, system assigns one.\n- `group`: Primary group name.\n- `home`: Home directory.\n- `shell`: Login shell.\n- `groups`: Supplementary groups set.\n- `system`: Only affects _creation_ (`useradd --system`). Changing this after creation does nothing.\n\n### `UserManager` module\n\n```python\nclass UserManager(Module):\n```\n\n#### Lifecycle\n\n- Before update\n  - Create/modify managed groups.\n  - Create/modify managed users.\n  - Delete previously-managed users/groups that are no longer listed.\n- After update\n  - Apply **additional** supplementary group membership and **subuid/subgid** ranges (including removals).\n\n#### Store keys\n\nThe module persists state in decman store under these keys:\n\n- `usermanager_users`\n- `usermanager_groups`\n- `usermanager_user_additional_groups`\n- `usermanager_user_subuids`\n- `usermanager_user_subgids`\n\nThe module does **not** parse `/etc/subuid` or `/etc/subgid`; it relies on these store keys to compute additions/removals.\n\n#### Methods\n\n##### `add_user(user: User)`\n\nEnsure a user exists with the configured attributes.\n\nNotes:\n\n- If `uid` is provided and an existing user matches by UID but has a different name, the module will rename the user (`usermod --login`) and apply other changes.\n\n##### `add_group(group: Group)`\n\nEnsure a group exists with the configured attributes.\n\n##### `add_user_to_group(user: str, group: str)`\n\nEnsure `user` is a member of `group`.\n\n- This is applied in `after_update`.\n- Both `user` and `group` are expected to exist\n\nYou should not use this method for users added with `add_user`.\n\n##### `add_subuids(user: str, first: int, last: int)`\n\nEnsure subordinate UID range `first-last` is present for `user`.\n\n##### `add_subgids(user: str, first: int, last: int)`\n\nEnsure subordinate GID range `first-last` is present for `user`.\n\n### Example usage\n\n```python\nfrom decman.extras.users import UserManager, User, Group\n\num = UserManager()\n\num.add_group(Group(\"containers\", system=True))\num.add_user(User(\n    username=\"alice\",\n    uid=1001,\n    group=\"users\",\n    home=\"/home/alice\",\n    groups=(),\n    shell=\"/bin/zsh\",\n))\n\num.add_user_to_group(\"bob\", \"containers\")\n\num.add_subuids(\"alice\", 100000, 165535)\num.add_subgids(\"alice\", 100000, 165535)\n\nimport decman\ndecman.modules += [um]\n```\n\n## GPG receiver module\n\n```python\nimport decman.extras.gpg\n```\n\nManages importing OpenPGP public keys into per-user GnuPG homes. Tracks imported keys in the decman store and removes keys that were previously managed but are no longer configured.\n\nThis module is intentionally limited since it's main usage is for AUR build users. You probably shouldn't manage your primary user’s keyring with it.\n\n### Types\n\n#### `OwnerTrust`\n\nValid ownertrust levels:\n\n- `never`\n- `marginal`\n- `full`\n- `ultimate`\n\nThese map to GnuPG `--import-ownertrust` numeric levels `1..4`.\n\n#### `SourceKind`\n\nHow a key is imported:\n\n- `fingerprint`: fetch from keyserver via `--recv-keys`\n- `uri`: fetch from URI via `--fetch-key`\n- `file`: import from local file via `--import`\n\n#### `Key`\n\nRepresents one managed key entry.\n\nFields:\n\n- `fingerprint`: OpenPGP fingerprint, validated to be exactly 40 hex chars (spaces allowed in input; normalized by removing spaces and uppercasing).\n- `source_kind`: one of `fingerprint | uri | file`.\n- `source`: keyserver (for `fingerprint`), URI (for `uri`), or filepath (for `file`).\n- `trust`: optional `OwnerTrust` to set via ownertrust import.\n\nValidation behavior:\n\n- Fingerprint is normalized: `replace(\" \", \"\").upper()`.\n- Fingerprint must match `^[0-9A-F]{40}$`; otherwise `ValueError`.\n\n### `GPGReceiver` module\n\n```python\nclass GPGReceiver(module.Module):\n```\n\n#### Store keys\n\nThe module persists state in decman store under these keys:\n\n- `gpgreceiver_userhome_keys`\n\nIt relies on the store to keep track which keys were added by it.\n\n#### Public API\n\n##### `receive_key(user: str, gpg_home: str, fingerprint: str, keyserver: str, trust: OwnerTrust | None = None)`\n\nReceives a key with a `fingerprint` from a `keyserver` to a `gpg_home` owned by `user`.\n\nIf `trust` is provided, ownertrust is set after import.\n\n##### `fetch_key(user: str, gpg_home: str, fingerprint: str, uri: str, trust: OwnerTrust | None=None)`\n\nReceives a key with a `fingerprint` from a `uri` to a `gpg_home` owned by `user`.\n\nIf `trust` is provided, ownertrust is set after import.\n\n##### `import_key(user: str, gpg_home: str, fingerprint: str, file: str, trust: OwnerTrust | None =None)`\n\nReceives a key with a `fingerprint` from a local `file` to a `gpg_home` owned by `user`.\n\nIf `trust` is provided, ownertrust is set after import.\n\n### Example usage\n\n```python\nfrom decman.modules.gpg import GPGReceiver\nimport decman\n\ngpg = GPGReceiver()\n\n# Receive a key from a keyserver\ngpg.receive_key(\n    user=\"builduser\",\n    gpg_home=\"/var/lib/builduser/gnupg\",\n    fingerprint=\"AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA\",\n    keyserver=\"hkps://keyserver.ubuntu.com\",\n    trust=\"marginal\",\n)\n\n# Fetch a key from a URI\ngpg.fetch_key(\n    user=\"alice\",\n    gpg_home=\"/home/alice/.gnupg\",\n    fingerprint=\"BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB\",\n    uri=\"https://example.org/signing-key.asc\",\n)\n\n# Import a key from a local file\ngpg.import_key(\n    user=\"bob\",\n    gpg_home=\"/home/bob/.gnupg\",\n    fingerprint=\"CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC\",\n    file=\"/etc/decman/keys/custom.asc\",\n)\n\ndecman.modules += [gpg]\n```\n"
  },
  {
    "path": "docs/flatpak.md",
    "content": "# Flatpak\n\nThe flatpak plugin is used to manage flatpak apps. It manages both systemd-wide and user-specific flatpaks. Flatpaks are still a new addition to decman, so they might not work as well as pacman packages. Flatpak management is disabled by default.\n\nThis plugin will ensure that installed flatpak apps match those defined in the decman source. If your system has installed a package, but it is not included in the source, it will be uninstalled. You don't need to list dependencies and runtimes in your source as those will be handeled by flatpak automatically. This plugin will remove unneeded runtimes.\n\n## Usage\n\nDefine systemd-wide flatpaks.\n\n```py\nimport decman\ndecman.flatpak.packages |= {\"org.mozilla.firefox\", \"org.signal.Signal\"}\n```\n\nDefine user-specific flatpaks.\n\n```py\ndecman.flatpak.user_packages.setdefault(\"user\", {}).update({\"com.valvesoftware.Steam\"})\n```\n\nDefine ignored flatpaks. This plugin won't install them nor remove them. This list affects user and system flatpaks.\n\n```py\ndecman.flatpak.ignored_packages |= {\"dev.zed.Zed\"}\n```\n\n### Within modules\n\nModules can also define flatpaks units. Decorate a module's method with `@decman.plugins.flatpaks.packages` and return a `set[str]` of flatpak names from that module. For user flatpaks decorate with `@decman.plugins.flatpak.user_packages` and return a `dict[str, set[str]]` of usernames and flatpaks for that user.\n\n```py\nimport decman\nfrom decman.plugins import flatpak\n\nclass MyModule(decman.Module):\n    ...\n\n    @flatpak.packages\n    def units_defined_in_this_module(self) -> set[str]:\n        return {\"org.signal.Signal\", \"org.mozilla.firefox\"}\n\n    @flatpak.user_packages\n    def user_units_defined_in_this_module(self) -> dict[str, set[str]]:\n        return {\"user\": {\"com.valvesoftware.Steam\"}}\n```\n\nIf packages or user packages change, this plugin will flag the module as changed. The module's `on_change` method will be executed.\n\n## Keys used in the decman store\n\n- `flatpaks_for_module`\n- `user_flatpaks_for_module`\n\n## Configuration\n\nIt's possible to override the commands this plugin uses. Create your own `FlatpakCommands` class and override methods returning commands. These are the defaults.\n\n```py\nfrom decman.plugins import flatpak\n\nclass MyCommands(flatpak.FlatpakCommands):\n    def list_apps(self, as_user: bool) -> list[str]:\n        \"\"\"\n        Running this command outputs a newline separated list of installed flatpak application IDs.\n\n        If ``as_user`` is ``True``, run the command as the user whose packages should be listed.\n\n        NOTE: The first line says 'Application ID' and should be ignored.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"list\",\n            \"--app\",\n            \"--user\" if as_user else \"--system\",\n            \"--columns\",\n            \"application\",\n        ]\n\n    def install(self, pkgs: set[str], as_user: bool) -> list[str]:\n        \"\"\"\n        Running this command installs all listed packages, and their dependencies/runtimes\n        automatically.\n\n        If ``as_user`` is ``True``, run the command as the user for whom packages are installed.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"install\",\n            \"--user\" if as_user else \"--system\",\n        ] + sorted(pkgs)\n\n    def upgrade(self, as_user: bool) -> list[str]:\n        \"\"\"\n        Updates all installed flatpaks including runtimes and dependencies.\n\n        If ``as_user`` is ``True``, run the command as the user whose flatpaks are updated.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"update\",\n            \"--user\" if as_user else \"--system\",\n        ]\n\n    def remove(self, pkgs: set[str], as_user: bool) -> list[str]:\n        \"\"\"\n        Running this command will remove the listed packages.\n\n        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--user\" if as_user else \"--system\",\n        ] + sorted(pkgs)\n\n    def remove_unused(self, as_user: bool) -> list[str]:\n        \"\"\"\n        This will remove all unused flatpak dependencies and runtimes.\n\n        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--unused\",\n            \"--user\" if as_user else \"--system\",\n        ]\n```\n\nThen set the commands.\n\n```py\nimport decman\ndecman.flatpak.commands = MyCommands()\n```\n"
  },
  {
    "path": "docs/migrate-to-v1.md",
    "content": "# Migrating to the new architecture\n\nI recommend reading decman's new documentation. This document is supposed to be a quick reference on what will you have to modify in your current source to make it work with the new decman. This will not document new features.\n\n## Few notes about changed behavior\n\nThis change is mostly architectural and doesn't change decman's behavior, but there are a few exceptions.\n\n- The pacman plugin will now remove orphan packages.\n  - **Packages that are only optionally required by other packages are considered orphans.**\n- Explicitly installed packages that are required by other explicitly installed packages are no longer uninstalled when removed from the source.\n- Module's `on_disable` will now be executed when the module is no longer present in `decman.modules`.\n  - It will be executed even when the module is removed completely from the source\n- Order of operations has changed. While the order of operations is now configurable, returning to the previous way is not possible.\n- I will no longer provide any examples for using decman with other languages than Python. It would be possible to write an adapter, but I don't see it as worth the effort.\n\nSince there are changes in the internal logic, it is possible that there are more breaking changes, but I haven't thought about them yet.\n\n## After upgrading decman\n\nAfter upgrading decman, the store and cache must be deleted. This will cause some `on_enable` -hooks to run again, but the store has had many internal changes, and should be recreated.\n\n```sh\nsudo rm /var/lib/decman/store.json\nsudo rm -r /var/cache/decman/\n```\n\n## Changes\n\nOne notable change is replacing lists with sets. Sets make more sense for most things decman manages since duplicates and order are meaningless. With Python you'll want to use `|=` when adding two sets together instead of `+=` which is for lists.\n\n### Files and directories\n\nFiles and directories are still managed the same way.\n\n```py\nimport decman\ndecman.files[\"/etc/pacman.conf\"] = File(source_file=\"./dotfiles/pacman.conf\")\ndecman.directories[\"/home/user/.config/nvim\"] = Directory(source_directory=\"./dotfiles/nvim\",\n```\n\n### Pacman packages\n\n#### Old\n\n```py\ndecman.packages += [\"devtools\", \"git\", \"networkmanager\"]\ndecman.ignored_packages += [\"rustup\", \"yay\"]\n```\n\n#### New\n\nPackages are now defined with the plugin. Sets are used instead of lists. Ignored packages contains only native packages found in the pacman repositories. Ignored AUR packages is a seperate setting.\n\n```py\ndecman.pacman.packages |= {\"devtools\", \"git\", \"networkmanager\"}\ndecman.pacman.ignored_packages |= {\"rustup\"}\n```\n\n### AUR packages\n\n#### Old\n\n```py\ndecman.aur_packages += [\"decman\", \"android-studio\"]\ndecman.ignored_packages += [\"rustup\", \"yay\"]\n```\n\n#### New\n\nPackages are now defined with the plugin. Sets are used instead of lists. Ignored packages contains only foreign packages for example AUR packages.\n\n```py\ndecman.aur.packages |= {\"decman\", \"android-studio\"}\ndecman.aur.ignored_packages |= {\"yay\"}\n```\n\n### User Packages\n\n#### Old\n\n```py\ndecman.user_packages.append(\n    UserPackage(\n        pkgname=\"decman\",\n        version=\"0.4.2\",\n        provides=[\"decman\"],\n        dependencies=[\n            \"python\",\n            \"python-requests\",\n            \"devtools\",\n            \"pacman\",\n            \"systemd\",\n            \"git\",\n            \"less\",\n        ],\n        make_dependencies=[\n            \"python-setuptools\",\n            \"python-build\",\n            \"python-installer\",\n            \"python-wheel\",\n        ],\n        git_url=\"https://github.com/kiviktnm/decman-pkgbuild.git\",\n    )\n)\n```\n\n#### New\n\nUser packages were renamed to custom packages. Sets are used instead of lists. PKGBUILDs are now parsed by decman so defining them is simpler. They are managed by the aur plugin.\n\n```py\nfrom decman.plugins import aur\ndecman.aur.custom_packages |= {aur.CustomPackage(\"decman\", git_url=\"https://github.com/kiviktnm/decman-pkgbuild.git\")}\n```\n\n### Systemd services\n\n#### Old\n\n```py\ndecman.enabled_systemd_units += [\"NetworkManager.service\"]\ndecman.enabled_systemd_user_units.setdefault(\"kk\", []).append(\"syncthing.service\")\n```\n\n#### New\n\nUnits are now defined with the plugin. Sets are used instead of lists.\n\n```py\ndecman.systemd.enabled_units |= {\"NetworkManager.service\"}\ndecman.systemd.enabled_user_units.setdefault(\"user\", set()).add(\"syncthing.service\")\n```\n\n### Flatpaks\n\n#### Old\n\n```py\ndecman.flatpak_packages += [\"org.mozilla.firefox\"]\ndecman.ignored_flatpak_packages += [\"org.signal.Signal\"]\ndecman.flatpak_user_packages.setdefault(\"kk\", []).append(\"com.valvesoftware.Steam\")\n```\n\n#### New\n\nPackages are now defined with the plugin. Sets are used instead of lists.\n\n```py\ndecman.flatpak.packages |= {\"org.mozilla.firefox\"}\ndecman.flatpak.ignored_packages |= {\"org.signal.Signal\"}\ndecman.flatpak.user_packages.setdefault(\"kk\", {}).update({\"com.valvesoftware.Steam\"})\n```\n\n### Changes to modules\n\n#### Old\n\n```py\nimport decman\nfrom decman import Module, prg, sh\n\ndecman.modules += [MyModule()]\n\nclass MyModule(Module):\n    def __init__(self):\n        self.pkgs = [\"rust\"]\n        self.update_rustup = False\n        super().__init__(name=\"Example module\", enabled=True, version=\"1\")\n\n    def enable_my_custom_feature(self, b: bool):\n        if b:\n            self.pkgs = [\"rustup\"]\n            self.update_rustup = True\n\n    def on_enable(self):\n        sh(\"groupadd mygroup\")\n        prg([\"usermod\", \"--append\", \"--groups\", \"mygroup\", \"kk\"])\n\n    def on_disable(self):\n        sh(\"whoami\", user=\"kk\")\n        sh(\"echo $HI\", env_overrides={\"HI\": \"Hello!\"})\n\n    def after_update(self):\n        if self.update_rustup:\n            prg([\"rustup\", \"update\"], user=\"kk\")\n\n    def after_version_change(self):\n        prg([\"mkinitcpio\", \"-P\"])\n\n    def file_variables(self) -> dict[str, str]:\n        return {\"%msg%\": \"Hello, world!\"}\n\n    def files(self) -> dict[str, File]:\n        return {\n            \"/usr/local/bin/say-hello\": File(\n                content=\"#!/usr/bin/env bash\\necho %msg%\", permissions=0o755\n            ),\n            \"/usr/local/share/say-hello/image.png\": File(\n                source_file=\"files/i-dont-exist.png\", bin_file=True\n            ),\n        }\n\n    def directories(self) -> dict[str, Directory]:\n        return {\n            \"/home/kk/.config/mod-app/\": Directory(\n                source_directory=\"files/app-config\", owner=\"kk\"\n            )\n        }\n\n    def pacman_packages(self) -> list[str]:\n        return self.pkgs\n\n    def user_packages(self) -> list[UserPackage]:\n        return [UserPackage(...)]\n\n    def aur_packages(self) -> list[str]:\n        return [\"protonvpn\"]\n\n    def flatpak_packages(self) -> list[str]:\n        return [\"org.mozilla.firefox\"]\n\n    def flatpak_user_packages(self) -> dict[str, list[str]]:\n        return {\"username\": [\"io.github.kolunmi.Bazaar\"]}\n\n    def systemd_units(self) -> list[str]:\n        return [\"reflector.timer\"]\n\n    def systemd_user_units(self) -> dict[str, list[str]]:\n        return {\"kk\": [\"syncthing.service\"]}\n```\n\n#### New\n\nModules no longer have `version`s or `enabled` values. A module is enabled when it gets added to `decman.modules` and disabled when it gets removed from `decman.modules`. Versions are no longer needed because `after_version_change` has been removed and `on_change` has been added. `on_change` is executed automatically after the content of the module changes. `on_disable` will be executed automatically when the module is removed from `decman.modules`. It is no longer a instance method. Instead it must be a self-contained method with no references outside it. Not even imports.\n\nModule methods will get a `Store` instance passed to them as an argument. It can be used to store key-value pairs between decman runs.\n\nFiles and directories work the same way as before. Pacman, aur and flatpak packages as well as systemd units have been changed. You'll no longer override methods on the `Module`-class. Instead you'll decorate any method with the appropriate decorator and return desired values from that method.\n\n```py\nimport decman\nfrom decman import Module, Store, prg, sh\nfrom decman.plugins import pacman, aur, systemd, flatpak\n\ndecman.modules += [MyModule()]\n\nclass MyModule(Module):\n    def __init__(self):\n        self.pkgs = {\"rust\"}\n        self.update_rustup = False\n        super().__init__(\"Example module\")\n\n    def enable_my_custom_feature(self, b: bool):\n        if b:\n            self.pkgs = {\"rustup\"}\n            self.update_rustup = True\n\n    def on_enable(self, store: Store):\n        sh(\"groupadd mygroup\")\n        prg([\"usermod\", \"--append\", \"--groups\", \"mygroup\", \"kk\"])\n        store[\"value\"] = True\n\n    @staticmethod\n    def on_disable():\n        from decman import sh\n        sh(\"whoami\", user=\"kk\")\n        sh(\"echo $HI\", env_overrides={\"HI\": \"Hello!\"})\n\n    def after_update(self, store: Store):\n        if self.update_rustup:\n            prg([\"rustup\", \"update\"], user=\"kk\")\n\n    def on_change(self, store: Store):\n        prg([\"mkinitcpio\", \"-P\"])\n\n    def file_variables(self) -> dict[str, str]:\n        return {\"%msg%\": \"Hello, world!\"}\n\n    def files(self) -> dict[str, File]:\n        return {\n            \"/usr/local/bin/say-hello\": File(\n                content=\"#!/usr/bin/env bash\\necho %msg%\", permissions=0o755\n            ),\n            \"/usr/local/share/say-hello/image.png\": File(\n                source_file=\"files/i-dont-exist.png\", bin_file=True\n            ),\n        }\n\n    def directories(self) -> dict[str, Directory]:\n        return {\n            \"/home/kk/.config/mod-app/\": Directory(\n                source_directory=\"files/app-config\", owner=\"kk\"\n            )\n        }\n\n    @pacman.packages\n    def my_pacman_packages(self) -> set[str]:\n        return self.pkgs\n\n    @aur.custom_packages\n    def my_user_packages(self) -> set[aur.CustomPackage]:\n        return [aur.CustomPackage(...)]\n\n    @aur.packages\n    def my_aur_packages(self) -> set[str]:\n        return {\"protonvpn-cli\"}\n\n    @flatpak.packages\n    def my_flatpak_packages(self) -> set[str]:\n        return {\"org.mozilla.firefox\"}\n\n    @flatpak.user_packages\n    def my_flatpak_user_packages(self) -> dict[str, set[str]]:\n        return {\"username\": {\"io.github.kolunmi.Bazaar\"}}\n\n    @systemd.units\n    def my_systemd_units(self) -> set[str]:\n        return {\"reflector.timer\"}\n\n    @systemd.user_units\n    def my_systemd_user_units(self) -> dict[str, set[str]]:\n        return {\"kk\": {\"syncthing.service\"}}\n```\n\n## Configuration changes\n\nWith the plugin architecture, plugins now contain their own configuration instead of a global `decman.config`. The global `decman.config` still exists but the options available there are much more limited.\n\n### Global options\n\n#### Old\n\n```py\nimport decman.config\n\ndecman.config.debug_output = False\ndecman.config.suppress_command_output = True\ndecman.config.quiet_output = False\n```\n\n#### New\n\n`suppress_command_output` got removed. Commands that this option affected will now print their output only when encountering errors. In future releases the debug output option will be used to make it available even when not encountering errors.\n\nOther options stayed the same. These will now override values passed as CLI arguments.\n\n```py\ndecman.config.debug_output = False\ndecman.config.quiet_output = False\n```\n\n### Seperately enabled features\n\n#### Old\n\n```py\ndecman.config.enable_fpm = True\ndecman.config.enable_flatpak = False\n```\n\n#### New\n\nThese options are managed by setting `decman.execution_order`. Add or remove steps as needed.\n\n```py\nimport decman\ndecman.execution_order = [\n    \"files\",\n    \"pacman\",\n    \"aur\", # AUR/fpm enabled\n    \"systemd\",\n    # \"flatpak\", # Flatpak disabled\n]\n```\n\n### Pacman options\n\n#### Old\n\n```py\ndecman.config.pacman_output_keywords = [\n    \"pacsave\",\n    \"pacnew\",\n]\ndecman.config.print_pacman_output_highlights = True\n```\n\n#### New\n\nThese are now moved under the pacman plugin and renamed. Keywords is no longer a `list`. It is now a `set`.\n\n```py\nimport decman\ndecman.pacman.keywords = {\"pacsave\", \"pacnew\"}\ndecman.pacman.print_highlights = True\n```\n\nYou'll have to set them for the aur plugin seperately. I recommend sharing the values between the plugins.\n\n```py\nimport decman\ndecman.aur.keywords = {\"pacsave\", \"pacnew\"}\ndecman.aur.print_highlights = False\n```\n\n### Foreign package management related options\n\n#### Old\n\n```py\ndecman.config.aur_rpc_timeout = 30\ndecman.config.makepkg_user = \"kk\"\ndecman.config.build_dir = \"/tmp/decman/build\"\ndecman.config.pkg_cache_dir = \"/var/cache/decman\"\ndecman.config.number_of_packages_stored_in_cache = 3\ndecman.config.valid_pkgexts = [\n    \".pkg.tar\",\n    \".pkg.tar.gz\",\n    \".pkg.tar.bz2\",\n    \".pkg.tar.xz\",\n    \".pkg.tar.zst\",\n    \".pkg.tar.lzo\",\n    \".pkg.tar.lrz\",\n    \".pkg.tar.lz4\",\n    \".pkg.tar.lz\",\n    \".pkg.tar.Z\",\n]\n```\n\n#### New\n\nOptions `number_of_packages_stored_in_cache` and `valid_pkgexts` got removed. The default values are no longer configurable. I deemed these settings unnecessary.\n\n`pkg_cache_dir` is now a global setting and is used more generally for all cached things. Package cache is the directory `aur/` in this directory.\n\n```py\ndecman.config.cache_dir = \"/var/cache/decman\"\n```\n\nOther options are now moved under the aur plugin.\n\n```py\ndecman.aur.aur_rpc_timeout = 30\ndecman.aur.makepkg_user = \"nobody\"\ndecman.aur.build_dir = \"/tmp/decman/build\"\n```\n\n### Commands\n\nCommand management has now also been split up. Instead of a single commands class. Commands have to be overridden seperately for each plugin (except for AUR and pacman).\n\n#### Old\n\nHere are the old defaults.\n\n```py\ndecman.config.commands = MyCommands()\n\nclass MyCommands(decman.config.Commands):\n    def list_pkgs(self) -> list[str]:\n        return [\"pacman\", \"-Qeq\", \"--color=never\"]\n\n    def list_flatpak_pkgs(self, as_user: bool = False) -> list[str]:\n        return [\n            \"flatpak\",\n            \"list\",\n            \"--app\",\n            \"--user\" if as_user else \"--system\",\n            \"--columns\",\n            \"application\",\n        ]\n\n    def list_foreign_pkgs_versioned(self) -> list[str]:\n        return [\"pacman\", \"-Qm\", \"--color=never\"]\n\n    def install_pkgs(self, pkgs: list[str]) -> list[str]:\n        return [\"pacman\", \"-S\", \"--color=always\", \"--needed\"] + pkgs\n\n    def install_flatpak_pkgs(self, pkgs: list[str], as_user: bool = False) -> list[str]:\n        return [\"flatpak\", \"install\", \"-y\", \"--user\" if as_user else \"--system\"] + pkgs\n\n    def install_files(self, pkg_files: list[str]) -> list[str]:\n        return [\"pacman\", \"-U\", \"--color=always\", \"--asdeps\"] + pkg_files\n\n    def set_as_explicitly_installed(self, pkgs: list[str]) -> list[str]:\n        return [\"pacman\", \"-D\", \"--color=always\", \"--asexplicit\"] + pkgs\n\n    def install_deps(self, deps: list[str]) -> list[str]:\n        return [\"pacman\", \"-S\", \"--color=always\", \"--needed\", \"--asdeps\"] + deps\n\n    def is_installable(self, pkg: str) -> list[str]:\n        return [\"pacman\", \"-Sddp\", pkg]\n\n    def upgrade(self) -> list[str]:\n        return [\"pacman\", \"-Syu\", \"--color=always\"]\n\n    def upgrade_flatpak(self, as_user: bool = False) -> list[str]:\n        return [\n            \"flatpak\",\n            \"update\",\n            \"--noninteractive\",\n            \"-y\",\n            \"--user\" if as_user else \"--system\",\n        ]\n\n    def remove(self, pkgs: list[str]) -> list[str]:\n        return [\"pacman\", \"-Rs\", \"--color=always\"] + pkgs\n\n    def remove_flatpak(self, pkgs: list[str], as_user: bool = False) -> list[str]:\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--noninteractive\",\n            \"-y\",\n            \"--user\" if as_user else \"--system\",\n        ] + pkgs\n\n    def remove_unused_flatpak(self, as_user: bool = False) -> list[str]:\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--noninteractive\",\n            \"-y\",\n            \"--unused\",\n            \"--user\" if as_user else \"--system\",\n        ]\n\n    def enable_units(self, units: list[str]) -> list[str]:\n        return [\"systemctl\", \"enable\"] + units\n\n    def disable_units(self, units: list[str]) -> list[str]:\n        return [\"systemctl\", \"disable\"] + units\n\n    def enable_user_units(self, units: list[str], user: str) -> list[str]:\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"enable\"] + units\n\n    def disable_user_units(self, units: list[str], user: str) -> list[str]:\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"disable\"] + units\n\n    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:\n        return [\"vercmp\", installed_version, new_version]\n\n    def git_clone(self, repo: str, dest: str) -> list[str]:\n        return [\"git\", \"clone\", repo, dest]\n\n    def git_diff(self, from_commit: str) -> list[str]:\n        return [\"git\", \"diff\", from_commit]\n\n    def git_get_commit_id(self) -> list[str]:\n        return [\"git\", \"rev-parse\", \"HEAD\"]\n\n    def git_log_commit_ids(self) -> list[str]:\n        return [\"git\", \"log\", \"--format=format:%H\"]\n\n    def review_file(self, file: str) -> list[str]:\n        return [\"less\", file]\n\n    def make_chroot(self, chroot_dir: str, with_pkgs: list[str]) -> list[str]:\n        return [\"mkarchroot\", chroot_dir] + with_pkgs\n\n    def install_chroot_packages(self, chroot_dir: str, packages: list[str]):\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-S\",\n            \"--needed\",\n            \"--noconfirm\",\n        ] + packages\n\n    def resolve_real_name(self, chroot_dir: str, pkg: str) -> list[str]:\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-Sddp\",\n            \"--print-format=%n\",\n            pkg,\n        ]\n\n    def remove_chroot_packages(self, chroot_dir: str, packages: list[str]):\n        return [\"arch-nspawn\", chroot_dir, \"pacman\", \"-Rsu\", \"--noconfirm\"] + packages\n\n    def make_chroot_pkg(\n        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]\n    ) -> list[str]:\n        makechrootpkg_cmd = [\"makechrootpkg\", \"-c\", \"-r\", chroot_wd_dir, \"-U\", user]\n\n        for pkgfile in pkgfiles_to_install:\n            makechrootpkg_cmd += [\"-I\", pkgfile]\n\n        return makechrootpkg_cmd\n```\n\n#### New\n\nAUR and pacman commands are a seperate setting, but they share the same subclass, so it's possible to set them in a one place. Many pacman query commands have been deleted since pyalpm is used now. New commands have also been added but it is better to look at the plugin documentation for those options.\n\nThese values are the new defaults.\n\n```py\nimport decman\nfrom decman.plugins import aur\n\ndecman.aur.commands = MyAurAndPacmanCommands()\ndecman.pacman.commands = MyAurAndPacmanCommands()\n\nclass MyAurAndPacmanCommands(aur.AurCommands):\n    def install(self, pkgs: set[str]) -> list[str]:\n        return [\"pacman\", \"-S\", \"--needed\"] + list(pkgs)\n\n    def upgrade(self) -> list[str]:\n        return [\"pacman\", \"-Syu\"]\n\n    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        return [\"pacman\", \"-D\", \"--asdeps\"] + list(pkgs)\n\n    def set_as_explicit(self, pkgs: set[str]) -> list[str]:\n        return [\"pacman\", \"-D\", \"--asexplicit\"] + list(pkgs)\n\n    def remove(self, pkgs: set[str]) -> list[str]:\n        return [\"pacman\", \"-Rs\"] + list(pkgs)\n\n    def install_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        return [\"pacman\", \"-S\", \"--needed\", \"--asdeps\"] + list(pkgs)\n\n    def install_files_as_dependencies(self, pkg_files: list[str]) -> list[str]:\n        return [\"pacman\", \"-U\", \"--asdeps\"] + pkg_files\n\n    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:\n        return [\"vercmp\", installed_version, new_version]\n\n    def git_clone(self, repo: str, dest: str) -> list[str]:\n        return [\"git\", \"clone\", repo, dest]\n\n    def git_diff(self, from_commit: str) -> list[str]:\n        return [\"git\", \"diff\", from_commit]\n\n    def git_get_commit_id(self) -> list[str]:\n        return [\"git\", \"rev-parse\", \"HEAD\"]\n\n    def git_log_commit_ids(self) -> list[str]:\n        return [\"git\", \"log\", \"--format=format:%H\"]\n\n    def review_file(self, file: str) -> list[str]:\n        return [\"less\", file]\n\n    def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:\n        return [\"mkarchroot\", chroot_dir] + list(with_pkgs)\n\n    def install_chroot(self, chroot_dir: str, packages: list[str]):\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-S\",\n            \"--needed\",\n            \"--noconfirm\",\n        ] + packages\n\n    def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[str]:\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-Sddp\",\n            \"--print-format=%n\",\n            pkg,\n        ]\n\n    def remove_chroot(self, chroot_dir: str, packages: set[str]):\n        return [\"arch-nspawn\", chroot_dir, \"pacman\", \"-Rsu\", \"--noconfirm\"] + list(packages)\n\n    def make_chroot_pkg(\n        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]\n    ) -> list[str]:\n        makechrootpkg_cmd = [\"makechrootpkg\", \"-c\", \"-r\", chroot_wd_dir, \"-U\", user]\n\n        for pkgfile in pkgfiles_to_install:\n            makechrootpkg_cmd += [\"-I\", pkgfile]\n\n        return makechrootpkg_cmd\n\n    def print_srcinfo(self) -> list[str]:\n        return [\"makepkg\", \"--printsrcinfo\"]\n```\n\nSystemd commands:\n\n```py\nimport decman\nfrom decman.plugins import systemd\n\ndecman.systemd.commands = MyCommands()\n\nclass MyCommands(SystemdCommands):\n\n    def enable_units(self, units: set[str]) -> list[str]:\n        return [\"systemctl\", \"enable\"] + list(units)\n\n    def disable_units(self, units: set[str]) -> list[str]:\n        return [\"systemctl\", \"disable\"] + list(units)\n\n    def enable_user_units(self, units: set[str], user: str) -> list[str]:\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"enable\"] + list(units)\n\n    def disable_user_units(self, units: set[str], user: str) -> list[str]:\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"disable\"] + list(units)\n\n    def daemon_reload(self) -> list[str]:\n        return [\"systemctl\", \"daemon-reload\"]\n\n    def user_daemon_reload(self, user: str) -> list[str]:\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"daemon-reload\"]\n```\n\nFlatpak commands:\n\n```py\nimport decman\nfrom decman.plugins import flatpak\n\ndecman.flatpak.commands = MyCommands()\n\nclass MyCommands(FlatpakCommands):\n    def list_apps(self, as_user: bool) -> list[str]:\n        return [\n            \"flatpak\",\n            \"list\",\n            \"--app\",\n            \"--user\" if as_user else \"--system\",\n            \"--columns\",\n            \"application\",\n        ]\n\n    def install(self, pkgs: set[str], as_user: bool) -> list[str]:\n        return [\n            \"flatpak\",\n            \"install\",\n            \"--user\" if as_user else \"--system\",\n        ] + sorted(pkgs)\n\n    def upgrade(self, as_user: bool) -> list[str]:\n        return [\n            \"flatpak\",\n            \"update\",\n            \"--user\" if as_user else \"--system\",\n        ]\n\n    def remove(self, pkgs: set[str], as_user: bool) -> list[str]:\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--user\" if as_user else \"--system\",\n        ] + sorted(pkgs)\n\n    def remove_unused(self, as_user: bool) -> list[str]:\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--unused\",\n            \"--user\" if as_user else \"--system\",\n        ]\n```\n"
  },
  {
    "path": "docs/pacman.md",
    "content": "# Pacman\n\nPacman plugin can be used to manage pacman packages. The pacman plugin manages only native packages found in arch repositories. All foreign (AUR) packages are ignored by this plugin.\n\nThis plugin will ensure that explicitly installed packages match those defined in the decman source. If your system has explicitly installed package A, but it is not included in the source, it will be uninstalled. You don't need to list dependencies in your source as those will be handeled by pacman automatically. However, if you have inluded package B in your source and that package depends on A, this plugin will not remove A. Instead it will demote A to a dependency. This plugin will also remove all orphaned packages automatically. **Packages that are only optionally required by other packages are considered orphans.** This way this plugin can ensure that your system truly matches your source. You cannot install an optional dependency, and forget about it later.\n\nPlease keep in mind that decman doesn't play well with package groups, since all packages part of that group will be installed explicitly. After the initial run decman will now try to remove those packages since it only knows that the group itself should be explicitly installed. Instead of package groups, use meta packages.\n\n## Usage\n\nDefine system packages.\n\n```py\nimport decman\ndecman.pacman.packages |= {\"sudo\", \"vim\"}\n```\n\nDefine ignored packages. This plugin won't install them nor remove them.\n\n```py\n# Include only packages found in the pacman repositories in here.\ndecman.pacman.ignored_packages |= {\"opendoas\"}\n```\n\nThis plugin's execution order step name is `pacman`.\n\n### Within modules\n\nModules can also define pacman packages. Decorate a module's method with `@decman.plugins.pacman.packages` and return a `set[str]` of package names from that module.\n\n```py\nimport decman\nfrom decman.plugins import pacman\n\nclass MyModule(decman.Module):\n    ...\n\n    @pacman.packages\n    def packages_defined_in_this_module(self) -> set[str]:\n        return {\"tmux\", \"kitty\"}\n```\n\nIf this set changes, this plugin will flag the module as changed. The module's `on_change` method will be executed.\n\n## Keys used in the decman store\n\n- `packages_for_module`\n\n## Configuration\n\nThis plugin has a pacman output highlight function. If pacman output contains some keywords, it will be highlighted. You can disable this feature or set the keywords.\n\n```py\nimport decman\n\n# set keywords\ndecman.pacman.keywords = {\"pacsave\", \"pacnew\", \"warning\"}\n\n# disable the feature\ndecman.pacman.print_highlights = False\n\n# signature level for querying existing databases\ndecman.pacman.database_signature_level = 2048 # pyalpm.SIG_DATABASE_OPTIONAL\n\n# path to databases\ndecman.pacman.database_path = \"/var/lib/pacman/\"\n```\n\nAdditionally it's possible to override the commands this plugin uses. Create your own `PacmanCommands` class and override methods returning commands. These are the defaults.\n\n```py\nfrom decman.plugins import pacman\n\nclass MyCommands(pacman.PacmanCommands):\n    def list_pacman_repos(self) -> list[str]:\n        \"\"\"\n        Running this command prints a newline seperated list of pacman repositories.\n        \"\"\"\n        return [\"pacman-conf\", \"--repo-list\"]\n\n    def install(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command installs the given packages from pacman repositories.\n        \"\"\"\n        return [\"pacman\", \"-S\", \"--needed\"] + list(pkgs)\n\n    def upgrade(self) -> list[str]:\n        \"\"\"\n        Running this command upgrades all pacman packages from pacman repositories.\n        \"\"\"\n        return [\"pacman\", \"-Syu\"]\n\n    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command sets the given packages as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-D\", \"--asdeps\"] + list(pkgs)\n\n    def set_as_explicit(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command sets the given as explicitly installed.\n        \"\"\"\n        return [\"pacman\", \"-D\", \"--asexplicit\"] + list(pkgs)\n\n    def remove(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command removes the given packages and their dependencies\n        (that aren't required by other packages).\n        \"\"\"\n        return [\"pacman\", \"-Rs\"] + list(pkgs)\n```\n\nThen set the commands.\n\n```py\nimport decman\ndecman.pacman.commands = MyCommands()\n```\n"
  },
  {
    "path": "docs/systemd.md",
    "content": "# Systemd\n\nThe systemd plugin can enable systemd services, system wide or for a specific user. It will enable all units defined in the source, and disable them when they are removed from the source.\n\nThis plugin manages systemd units \"softly\". It only touches units included in the source. So if you install a package that automatically enables a systemd unit, you don't have to include it in the source. If a unit is not defined in the source, the plugin will not touch it.\n\nDecman will only enable and disable systemd services. It will not start or stop them. Starting or stopping them automatically can cause issues.\n\n## Usage\n\nDeclare system-wide units.\n\n```py\nimport decman\ndecman.systemd.enabled_units |= {\"NetworkManager.service\", \"ufw.service\"}\n```\n\nDeclare user units. Here this `setdefault` method is used to ensure that the key `user` exists. In this case you cannot use the `|=` syntax and instead must call the `update`-method.\n\n```py\ndecman.systemd.enabled_user_units.setdefault(\"user\", set()).update({\"syncthing.service\"})\n```\n\nThis plugin's execution order step name is `systemd`.\n\n### Within modules\n\nModules can also define systemd units. Decorate a module's method with `@decman.plugins.systemd.units` and return a `set[str]` of package names from that module. For user units decorate with `@decman.plugins.systemd.user_units` and return a `dict[str, set[str]]` of usernames and user units.\n\n```py\nimport decman\nfrom decman.plugins import systemd\n\nclass MyModule(decman.Module):\n    ...\n\n    @systemd.units\n    def units_defined_in_this_module(self) -> set[str]:\n        return {\"NetworkManager.service\", \"ufw.service\"}\n\n    @systemd.user_units\n    def user_units_defined_in_this_module(self) -> dict[str, set[str]]:\n        return {\"user\": {\"syncthing.service\"}}\n```\n\nIf units or user units change, this plugin will flag the module as changed. The module's `on_change` method will be executed.\n\n## Keys used in the decman store\n\n- `systemd_units_for_module`\n- `systemd_user_units_for_module`\n\n## Configuration\n\nIt's possible to override the commands this plugin uses. Create your own `SystemdCommands` class and override methods returning commands. These are the defaults.\n\n```py\nfrom decman.plugins import systemd\n\nclass MyCommands(systemd.SystemdCommands):\n    \"\"\"\n    Default commands for the Systemd plugin.\n    \"\"\"\n\n    def enable_units(self, units: set[str]) -> list[str]:\n        \"\"\"\n        Running this command enables the given systemd units.\n        \"\"\"\n        return [\"systemctl\", \"enable\"] + list(units)\n\n    def disable_units(self, units: set[str]) -> list[str]:\n        \"\"\"\n        Running this command disables the given systemd units.\n        \"\"\"\n        return [\"systemctl\", \"disable\"] + list(units)\n\n    def enable_user_units(self, units: set[str], user: str) -> list[str]:\n        \"\"\"\n        Running this command enables the given systemd units for the user.\n        \"\"\"\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"enable\"] + list(units)\n\n    def disable_user_units(self, units: set[str], user: str) -> list[str]:\n        \"\"\"\n        Running this command disables the given systemd units for the user.\n        \"\"\"\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"disable\"] + list(units)\n\n    def daemon_reload(self) -> list[str]:\n        \"\"\"\n        Running this command reloads the systemd daemon.\n        \"\"\"\n        return [\"systemctl\", \"daemon-reload\"]\n\n    def user_daemon_reload(self, user: str) -> list[str]:\n        \"\"\"\n        Running this command reloads the systemd daemon for the given user.\n        \"\"\"\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"daemon-reload\"]\n```\n\nThen set the commands.\n\n```py\nimport decman\ndecman.systemd.commands = MyCommands()\n```\n"
  },
  {
    "path": "example/README.md",
    "content": "# Example\n\nThis directory contains an example of a minimal decman configuration. This also functions as a tutorial for starting out with decman. I recommend looking at the [docs](/docs/README.md) after this.\n\n## Tutorial\n\n### Installing decman\n\nI will first install git and base-devel. Then I'll clone the PKGBUILD and install decman.\n\n```sh\nsudo pacman -S git base-devel\ngit clone https://aur.archlinux.org/decman.git\ncd decman/\nmakepkg -sic\n```\n\n### Starting out\n\nI will create a source directory for the system's configuration.\n\n```sh\nmkdir ~/source\ncd ~/source\n```\n\nDecman will remove all explicitly installed packages not found in the source. Let's find all explicitly installed packages.\n\n```sh\n$ pacman -Qeq\nbase\nbase-devel\nbtrfs-progs\ndecman\ndosfstools\nefibootmgr\ngit\ngrub\nlinux\nopenssh\nqemu-guest-agent\nsudo\nvim\n```\n\nFirst thing to note: `decman` is not a native package. I remember this, but if you don't, you can find only native packages with `pacman -Qeqn` and foreign packages with `pacman -Qeqm`. Since decman is not a native package, the pacman plugin cannot handle it. I'll add decman to AUR packages.\n\nInstead of adding all of these packages to `decman.pacman.packages`, I will first create a module for base system packages in `~/source/base.py`.\n\n```py\nimport decman\nfrom decman.plugins import pacman, aur\n\nclass BaseModule(decman.Module):\n\n    def __init__(self):\n        # I'll intend this module to be a singleton (only one instance ever),\n        # so I'll inline the module name\n        super().__init__(\"base\")\n\n    @pacman.packages\n    def pkgs(self) -> set[str]:\n        return {\n            \"base\",\n            \"btrfs-progs\",\n            \"dosfstools\",\n            \"efibootmgr\",\n            \"grub\",\n            \"linux\",\n\n            # I'll also include git and base-devel here, they are essential to this system\n            \"git\",\n            \"base-devel\",\n        }\n\n    @aur.packages\n    def aurpkgs(self) -> set[str]:\n        return {\"decman\"}\n```\n\nThen I'll create the main source file with the rest of the packages. I'll import `BaseModule` and add it to `decman.modules`. The main file is `~/source/source.py`.\n\n```py\nimport decman\nfrom base import BaseModule\n\ndecman.pacman.packages |= {\"openssh\", \"qemu-guest-agent\", \"sudo\", \"vim\"}\ndecman.modules += [BaseModule()]\n```\n\nThis config is already enough to run decman for the first time.\n\n```sh\nsudo decman --source /home/arch/source/source.py\n```\n\nThis will run a system upgrade, but otherwise nothing else happens, since my system already matches the desired configuration.\n\n### Extending my config with files and commands\n\nNow I'll want to gradually add more stuff to my config. As an example, I'll add my custom `mkinitcpio.conf`. I'll create the file `~/source/files/mkinitcpio.conf` with the desired content. Then I'll add the file to my `BaseModule`. Since I want to run the command `mkinitcpio -P` every time I update my config, I'll add a on change hook as well. I'll update the file `~/source/base.py`.\n\n```py\nclass BaseModule(decman.Module):\n    ...\n\n    def files(self) -> dict[str, decman.File]:\n        return {\"/etc/mkinitcpio.conf\": decman.File(source_file=\"./files/mkinitcpio.conf\")}\n\n    def on_change(self, store):\n        decman.prg([\"mkinitcpio\", \"-P\"])\n```\n\nI'll also add my vim config to decman. I could now create a Vim module, but since my config is simple, I feel that is not needed. I'll update the main source file `~/source/source.py`.\n\n```py\nimport decman\n\n...\n\ndecman.files[\"/home/arch/.vimrc\"] = decman.File(source_file=\"./files/vimrc\", owner=\"arch\", permissions=0o600)\n```\n\nThen I'll apply my changes. Decman will remember my source, so no need to give it as an argument anymore. I don't want to waste time checking for aur updates, so I'll skip them.\n\n```sh\nsudo decman --skip aur\n```\n\n### Systemd services and flatpaks\n\nI want add a desktop environment. I'll create a module for that in the file `~/source/kde.py`. I'll use SDDM as the login manager. SDDM service needs to be enabled, so I'll use the systemd plugin for that.\n\n```py\nimport decman\nfrom decman.plugins import pacman, systemd\n\nclass KDE(decman.Module):\n\n    def __init__(self):\n        super().__init__(\"kde\")\n\n    @pacman.packages\n    def pkgs(self) -> set[str]:\n        return {\n            \"plasma-desktop\",\n            \"konsole\",\n            \"sddm\",\n        }\n\n    @systemd.units\n    def units(self) -> set[str]:\n        return {\"sddm.service\"}\n```\n\nI'll add the module to enabled modules in `~/source/source.py`.\n\n```py\nimport decman\nfrom base import BaseModule\nfrom kde import KDE\n\n...\n\ndecman.modules += [BaseModule(), KDE()]\n```\n\nI'll run decman once again. I'll also start SDDM manually, since decman can't autostart it.\n\n```sh\nsudo decman\nsudo systemctl start sddm\n```\n\nLastly I want to install some packages with flatpak. I'll first have to install flatpak to make the plugin available. I'll do it manually since it's quicker.\n\n```sh\nsudo pacman -S flatpak\n```\n\nThen I'll modify `~/source/source.py`. I must add `flatpak` to execution steps to run the plugin.\n\n```py\nimport decman\n\n...\n\ndecman.execution_order = [\n    \"files\",\n    \"pacman\",\n    \"aur\",\n    \"flatpak\",\n    \"systemd\",\n]\n\ndecman.pacman.packages.add(\"flatpak\")\ndecman.flatpak.packages |= {\"org.mozilla.firefox\", \"org.signal.Signal\"}\n```\n\nThen run decman.\n\n```sh\nsudo decman\n```\n\n### Maintaining a system with decman\n\nDecman is intended to replace your upgrade procedures. Instead of running `yay -Syu` for example, you would run `sudo decman`. With `after_update` hooks you can chain other update commands such as `rustup update`. This way you'll only have to remember to run decman. All other update steps are defined in your source.\n\n## Plugins\n\nIt is possible to create your own plugins for decman. However, you probably won't need to do that, as modules are already very capable. This example directory also contains a **very** minimal plugin. To learn more about plugins, look at [the docs](/docs/README.md).\n"
  },
  {
    "path": "example/base.py",
    "content": "import decman\nfrom decman.plugins import aur, pacman\n\n\nclass BaseModule(decman.Module):\n    def __init__(self):\n        # I'll intend this module to be a singleton (only one instance ever),\n        # so I'll inline the module name\n        super().__init__(\"base\")\n\n    @pacman.packages\n    def pkgs(self) -> set[str]:\n        return {\n            \"base\",\n            \"btrfs-progs\",\n            \"dosfstools\",\n            \"efibootmgr\",\n            \"grub\",\n            \"linux\",\n            # I'll also include git and base-devel here, they are essential to this system\n            \"git\",\n            \"base-devel\",\n        }\n\n    @aur.packages\n    def aurpkgs(self) -> set[str]:\n        return {\"decman\"}\n\n    def files(self) -> dict[str, decman.File]:\n        return {\"/etc/mkinitcpio.conf\": decman.File(source_file=\"./files/mkinitcpio.conf\")}\n\n    def on_change(self, store):\n        decman.prg([\"mkinitcpio\", \"-P\"])\n"
  },
  {
    "path": "example/files/mkinitcpio.conf",
    "content": "MODULES=()\nBINARIES=()\nFILES=()\nHOOKS=(base systemd autodetect microcode modconf kms keyboard keymap sd-vconsole block filesystems fsck)\n"
  },
  {
    "path": "example/files/vimrc",
    "content": "set number\nsyntax on\n"
  },
  {
    "path": "example/kde.py",
    "content": "import decman\nfrom decman.plugins import pacman, systemd\n\n\nclass KDE(decman.Module):\n    def __init__(self):\n        super().__init__(\"kde\")\n\n    @pacman.packages\n    def pkgs(self) -> set[str]:\n        return {\n            \"plasma-desktop\",\n            \"konsole\",\n            \"sddm\",\n        }\n\n    @systemd.units\n    def units(self) -> set[str]:\n        return {\"sddm.service\"}\n"
  },
  {
    "path": "example/plugin/decman_plugin_example.py",
    "content": "import os\n\nimport decman\n\n\nclass Example(decman.Plugin):\n    NAME = \"example\"\n\n    def available(self) -> bool:\n        return os.path.exists(\"/tmp/example_plugin_available\")\n\n    def process_modules(self, store: decman.Store, modules: list[decman.Module]):\n        # Toy example for setting modules as changed\n        for module in modules:\n            module._changed = True\n\n    def apply(\n        self, store: decman.Store, dry_run: bool = False, params: list[str] | None = None\n    ) -> bool:\n        return True\n"
  },
  {
    "path": "example/plugin/pyproject.toml",
    "content": "[project]\nname = \"decman-plugin-example\"\nversion = \"0.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\n    \"decman\",\n]\n\n[project.entry-points.\"decman.plugins\"]\nexample = \"decman_plugin_example:Example\"\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n"
  },
  {
    "path": "example/source.py",
    "content": "from base import BaseModule\nfrom kde import KDE\n\nimport decman\n\ndecman.pacman.packages |= {\"openssh\", \"qemu-guest-agent\", \"sudo\", \"vim\"}\n\ndecman.modules += [BaseModule(), KDE()]\n\ndecman.files[\"/home/arch/.vimrc\"] = decman.File(\n    source_file=\"./files/vimrc\", owner=\"arch\", permissions=0o600\n)\n\ndecman.execution_order = [\n    \"files\",\n    \"pacman\",\n    \"aur\",\n    \"flatpak\",\n    \"systemd\",\n]\n\ndecman.pacman.packages.add(\"flatpak\")\ndecman.flatpak.packages |= {\"org.mozilla.firefox\", \"org.signal.Signal\"}\n"
  },
  {
    "path": "plugins/decman-flatpak/pyproject.toml",
    "content": "[project]\nname = \"decman-flatpak\"\nversion = \"1.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\"decman==1.2.1\"]\n\n[project.entry-points.\"decman.plugins\"]\nflatpak = \"decman.plugins.flatpak:Flatpak\"\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools.packages.find]\nwhere = [\"src\"]\nnamespaces = true\ninclude = [\"decman.plugins*\"]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n"
  },
  {
    "path": "plugins/decman-flatpak/src/decman/plugins/flatpak.py",
    "content": "import shutil\n\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\nimport decman.plugins as plugins\n\n\ndef packages(fn):\n    \"\"\"\n    Annotate that this function returns a set of flatpak package names that should be installed.\n\n    Return type of ``fn``: ``set[str]``\n    \"\"\"\n    fn.__flatpak__packages__ = True\n    return fn\n\n\ndef user_packages(fn):\n    \"\"\"\n    Annotate that this function returns a dict of users and flatpak packages that should be\n    installed.\n\n    Return type of ``fn``: ``dict[str, set[str]]``\n    \"\"\"\n    fn.__flatpak__user__packages__ = True\n    return fn\n\n\nclass Flatpak(plugins.Plugin):\n    \"\"\"\n    Plugin that manages flatpak packages added directly to ``packages`` or declared by modules via\n    ``@flatpak.packages``. User packages are managed as well.\n    \"\"\"\n\n    NAME = \"flatpak\"\n\n    def __init__(self) -> None:\n        self.packages: set[str] = set()\n        self.user_packages: dict[str, set[str]] = {}\n        self.ignored_packages: set[str] = set()\n        self.commands = FlatpakCommands()\n\n    def available(self) -> bool:\n        return shutil.which(\"flatpak\") is not None\n\n    def process_modules(self, store: _store.Store, modules: list[module.Module]):\n        # These store keys are used to track changes in modules.\n        # This way when these change, module can be marked as changed\n        store.ensure(\"flatpaks_for_module\", {})\n        store.ensure(\"user_flatpaks_for_module\", {})\n\n        for mod in modules:\n            store[\"flatpaks_for_module\"].setdefault(mod.name, set())\n            store[\"user_flatpaks_for_module\"].setdefault(mod.name, {})\n\n            packages = set().union(\n                *plugins.run_methods_with_attribute(mod, \"__flatpak__packages__\")\n            )\n            user_packages = {\n                k: v\n                for d in plugins.run_methods_with_attribute(mod, \"__flatpak__user__packages__\")\n                for k, v in d.items()\n            }\n\n            if store[\"flatpaks_for_module\"][mod.name] != packages:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified system flatpaks.\"\n                )\n\n            if store[\"user_flatpaks_for_module\"][mod.name] != user_packages:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified user flatpaks.\"\n                )\n\n            self.packages |= packages\n            for user, flatpaks in user_packages.items():\n                self.user_packages.setdefault(user, set()).update(flatpaks)\n\n            store[\"flatpaks_for_module\"][mod.name] = packages\n            store[\"user_flatpaks_for_module\"][mod.name] = user_packages\n\n    def apply(\n        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None\n    ) -> bool:\n        pm = FlatpakInterface(self.commands)\n\n        try:\n            self.apply_packages(pm, None, self.packages, self.ignored_packages, dry_run)\n\n            for user, packages in self.user_packages.items():\n                self.apply_packages(pm, user, packages, self.ignored_packages, dry_run)\n        except errors.CommandFailedError as error:\n            output.print_error(\"Running a flatpak command failed.\")\n            output.print_error(\n                \"Flatpak command exited with an unexpected return code. You may have cancelled a \"\n                \"flatpak operation.\"\n            )\n            output.print_error(str(error))\n            if error.output:\n                output.print_command_output(error.output)\n            output.print_traceback()\n            return False\n        return True\n\n    def apply_packages(\n        self,\n        flatpak: \"FlatpakInterface\",\n        user: str | None,\n        packages: set[str],\n        ignored_packages: set[str],\n        dry_run: bool,\n    ):\n        currently_installed = flatpak.get_apps(user)\n        to_remove = currently_installed - packages - ignored_packages\n        to_install = packages - currently_installed - ignored_packages\n\n        for_user_msg = f\" for {user}\" if user else \"\"\n\n        if to_remove:\n            output.print_list(f\"Removing flatpak packages{for_user_msg}:\", sorted(to_remove))\n            if not dry_run:\n                flatpak.remove(to_remove, user)\n\n        output.print_summary(f\"Upgrading packages{for_user_msg}.\")\n        if not dry_run:\n            flatpak.upgrade(user)\n\n        if to_install:\n            output.print_list(f\"Installing flatpak packages{for_user_msg}:\", sorted(to_install))\n            if not dry_run:\n                flatpak.install(to_install, user)\n\n\nclass FlatpakCommands:\n    def list_apps(self, as_user: bool) -> list[str]:\n        \"\"\"\n        Running this command outputs a newline separated list of installed flatpak application IDs.\n\n        If ``as_user`` is ``True``, run the command as the user whose packages should be listed.\n\n        NOTE: The first line says 'Application ID' and should be ignored.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"list\",\n            \"--app\",\n            \"--user\" if as_user else \"--system\",\n            \"--columns\",\n            \"application\",\n        ]\n\n    def install(self, pkgs: set[str], as_user: bool) -> list[str]:\n        \"\"\"\n        Running this command installs all listed packages, and their dependencies/runtimes\n        automatically.\n\n        If ``as_user`` is ``True``, run the command as the user for whom packages are installed.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"install\",\n            \"--user\" if as_user else \"--system\",\n        ] + sorted(pkgs)\n\n    def upgrade(self, as_user: bool) -> list[str]:\n        \"\"\"\n        Updates all installed flatpaks including runtimes and dependencies.\n\n        If ``as_user`` is ``True``, run the command as the user whose flatpaks are updated.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"update\",\n            \"--user\" if as_user else \"--system\",\n        ]\n\n    def remove(self, pkgs: set[str], as_user: bool) -> list[str]:\n        \"\"\"\n        Running this command will remove the listed packages.\n\n        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.\n        \"\"\"\n\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--user\" if as_user else \"--system\",\n        ] + sorted(pkgs)\n\n    def remove_unused(self, as_user: bool) -> list[str]:\n        \"\"\"\n        This will remove all unused flatpak dependencies and runtimes.\n\n        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.\n        \"\"\"\n        return [\n            \"flatpak\",\n            \"remove\",\n            \"--unused\",\n            \"--user\" if as_user else \"--system\",\n        ]\n\n\nclass FlatpakInterface:\n    \"\"\"\n    High level interface for running pacman commands.\n\n    On failure methods raise a ``CommandFailedError``.\n    \"\"\"\n\n    def __init__(self, commands: FlatpakCommands) -> None:\n        self._commands = commands\n\n    def get_apps(self, user: str | None = None) -> set[str]:\n        \"\"\"\n        Returns a set of installed flatpak apps.\n\n        If ``user`` is set, returns flatpak apps for that user.\n        \"\"\"\n        as_user = user is not None\n\n        cmd = self._commands.list_apps(as_user=as_user)\n        _, packages_text = command.check_run_result(\n            cmd, command.run(cmd, user=user, mimic_login=as_user)\n        )\n        packages = packages_text.strip().split(\"\\n\")\n\n        # In case no apps are installed, the list contains this\n        if \"\" in packages:\n            packages.remove(\"\")\n\n        return set(packages)\n\n    def install(self, packages: set[str], user: str | None = None):\n        \"\"\"\n        Installs the given packages.\n\n        If ``user`` is set, installs packages for that user.\n        \"\"\"\n        if not packages:\n            return\n\n        as_user = user is not None\n\n        cmd = self._commands.install(packages, as_user)\n        command.prg(cmd, user=user, mimic_login=as_user)\n\n    def upgrade(self, user: str | None = None):\n        \"\"\"\n        Upgrades all packages.\n\n        If ``user`` is set, upgrades packages for that user.\n        \"\"\"\n        as_user = user is not None\n        cmd = self._commands.upgrade(as_user)\n        command.prg(cmd, user=user, mimic_login=as_user)\n\n    def remove(self, packages: set[str], user: str | None = None):\n        \"\"\"\n        Removes the given packages as well as unused dependencies.\n\n        If ``user`` is set, removes packages for that user.\n        \"\"\"\n        if not packages:\n            return\n\n        as_user = user is not None\n        cmd = self._commands.remove(packages, as_user)\n        command.prg(cmd, user=user, mimic_login=as_user)\n\n        cmd = self._commands.remove_unused(as_user)\n        command.prg(cmd, user=user, mimic_login=as_user)\n"
  },
  {
    "path": "plugins/decman-pacman/pyproject.toml",
    "content": "[project]\nname = \"decman-pacman\"\nversion = \"1.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\n  \"decman==1.2.1\",\n  \"pyalpm\",\n  \"requests\",\n]\n\n[dependency-groups]\ndev = [\n    \"pytest>=8.4.2\",\n    \"pytest-mock>=3.15.1\",\n]\n\n[project.entry-points.\"decman.plugins\"]\npacman = \"decman.plugins.pacman:Pacman\"\naur = \"decman.plugins.aur:AUR\"\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools.packages.find]\nwhere = [\"src\"]\nnamespaces = true\ninclude = [\"decman.plugins*\"]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/__init__.py",
    "content": "import os\nimport shutil\n\nimport pyalpm\nfrom decman.plugins.aur.commands import AurCommands, AurPacmanInterface\nfrom decman.plugins.aur.error import (\n    AurRPCError,\n    DependencyCycleError,\n    ForeignPackageManagerError,\n    PKGBUILDParseError,\n)\nfrom decman.plugins.aur.fpm import ForeignPackageManager\nfrom decman.plugins.aur.package import CustomPackage, PackageSearch\n\nimport decman.config as config\nimport decman.core.error as errors\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\nimport decman.plugins as plugins\n\n# Re-exports\n__all__ = [\n    \"AUR\",\n    \"AurCommands\",\n    \"CustomPackage\",\n    \"packages\",\n    \"custom_packages\",\n]\n\n\ndef packages(fn):\n    \"\"\"\n    Annotate that this function returns a set of AUR package names that should be installed.\n\n    Return type of ``fn``: ``set[str]``\n    \"\"\"\n    fn.__aur__packages__ = True\n    return fn\n\n\ndef custom_packages(fn):\n    \"\"\"\n    Annotate that this function returns a set of ``CustomPackage``s that should be installed.\n\n    Return type of ``fn``: ``set[CustomPackage]``\n    \"\"\"\n    fn.__custom__packages__ = True\n    return fn\n\n\nclass AUR(plugins.Plugin):\n    \"\"\"\n    Plugin that manages additional pacman packages installed outside the pacman repos.\n\n    AUR packages are added directly to ``packages`` or declared by modules via ``@aur.packages``.\n\n    Custom packages are added directly to ``custom_packages`` or declared by modules via\n    ``@aur.custom_packages``.\n    \"\"\"\n\n    NAME = \"aur\"\n\n    def __init__(self) -> None:\n        self.packages: set[str] = set()\n        self.custom_packages: set[CustomPackage] = set()\n        self.ignored_packages: set[str] = set()\n        self.commands: AurCommands = AurCommands()\n\n        self.database_signature_level = pyalpm.SIG_DATABASE_OPTIONAL\n        self.database_path = \"/var/lib/pacman/\"\n\n        self.aur_rpc_timeout: int = 30\n        self.print_highlights: bool = True\n        self.keywords: set[str] = {\n            \"pacsave\",\n            \"pacnew\",\n            # These cause too many false positives IMO\n            # \"warning\",\n            # \"error\",\n            # \"note\",\n        }\n        self.build_dir: str = \"/tmp/decman/build\"\n        self.makepkg_user: str = \"nobody\"\n\n    def available(self) -> bool:\n        return (\n            shutil.which(\"pacman\") is not None\n            and shutil.which(\"git\") is not None\n            and shutil.which(\"mkarchroot\") is not None\n        )\n\n    def process_modules(self, store: _store.Store, modules: list[module.Module]):\n        # This is used to track changes in modules.\n        store.ensure(\"aur_packages_for_module\", {})\n        store.ensure(\"custom_packages_for_module\", {})\n\n        for mod in modules:\n            store[\"aur_packages_for_module\"].setdefault(mod.name, set())\n            store[\"custom_packages_for_module\"].setdefault(mod.name, set())\n\n            aur_packages = set().union(\n                *plugins.run_methods_with_attribute(mod, \"__aur__packages__\")\n            )\n            custom_packages = set().union(\n                *plugins.run_methods_with_attribute(mod, \"__custom__packages__\")\n            )\n            custom_package_strs = set(map(str, custom_packages))\n\n            if store[\"aur_packages_for_module\"][mod.name] != aur_packages:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified aur packages.\"\n                )\n\n            if store[\"custom_packages_for_module\"][mod.name] != custom_package_strs:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified custom packages.\"\n                )\n\n            self.packages |= aur_packages\n            self.custom_packages |= custom_packages\n\n            store[\"aur_packages_for_module\"][mod.name] = aur_packages\n            store[\"custom_packages_for_module\"][mod.name] = custom_package_strs\n\n    def apply(\n        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None\n    ) -> bool:\n        params = params or []\n        upgrade_devel = \"aur-upgrade-devel\" in params\n        force = \"aur-force\" in params\n        pkg_cache_dir = os.path.join(config.cache_dir, \"aur/\")\n\n        if not dry_run:\n            try:\n                os.makedirs(pkg_cache_dir, exist_ok=True)\n            except OSError as error:\n                output.print_error(\n                    \"Failed to ensure AUR package cache directory exists: \"\n                    f\"{error.strerror or error}\"\n                )\n                output.print_traceback()\n\n                return False\n\n        try:\n            package_search = PackageSearch(self.aur_rpc_timeout)\n            for custom_package in self.custom_packages:\n                package_search.add_custom_pkg(custom_package.parse(self.commands))\n            pm = AurPacmanInterface(\n                self.commands,\n                self.print_highlights,\n                self.keywords,\n                self.database_signature_level,\n                self.database_path,\n            )\n            fpm = ForeignPackageManager(\n                store,\n                pm,\n                package_search,\n                self.commands,\n                pkg_cache_dir,\n                self.build_dir,\n                self.makepkg_user,\n            )\n\n            custom_package_names = {p.pkgname for p in self.custom_packages}\n            currently_installed_native = pm.get_native_explicit()\n            currently_installed_foreign = pm.get_foreign_explicit()\n            orphans = pm.get_foreign_orphans()\n\n            to_remove = (\n                (currently_installed_foreign | orphans)\n                - self.packages\n                - custom_package_names\n                - self.ignored_packages\n            )\n\n            actually_to_remove = set()\n            to_set_as_dependencies = set()\n\n            dependants_to_keep = (\n                self.packages\n                | custom_package_names\n                | currently_installed_native\n                # don't remove ignored packages' dependencies\n                | (self.ignored_packages & currently_installed_foreign)\n            )\n            for package in to_remove:\n                dependants = pm.get_dependants(package)\n                if dependants & dependants_to_keep:\n                    to_set_as_dependencies.add(package)\n                else:\n                    actually_to_remove.add(package)\n\n            if actually_to_remove:\n                output.print_list(\"Removing foreign packages:\", sorted(actually_to_remove))\n                if not dry_run:\n                    pm.remove(actually_to_remove)\n\n            if to_set_as_dependencies:\n                output.print_list(\n                    \"Setting previously explicitly installed foreign packages as dependencies:\",\n                    sorted(to_set_as_dependencies),\n                )\n                if not dry_run:\n                    pm.set_as_dependencies(to_set_as_dependencies)\n\n            output.print_summary(\"Upgrading foreign packages.\")\n            if not dry_run:\n                # don't try to upgrade removed packages\n                fpm.upgrade(upgrade_devel, force, self.ignored_packages | actually_to_remove)\n\n            to_install = (\n                (self.packages | custom_package_names)\n                - currently_installed_foreign\n                - self.ignored_packages\n            )\n            output.print_list(\"Installing foreign packages:\", sorted(to_install))\n\n            if not dry_run:\n                fpm.install(list(to_install), force=force)\n        except AurRPCError as error:\n            output.print_error(\"Failed to fetch data from AUR RPC.\")\n            output.print_error(str(error))\n            output.print_traceback()\n            return False\n        except DependencyCycleError as error:\n            output.print_error(\"Foreign package dependency cycle detected.\")\n            output.print_error(str(error))\n            output.print_traceback()\n            return False\n        except PKGBUILDParseError as error:\n            output.print_error(\"Failed to parse a CustomPackage PKGBUILD.\")\n            output.print_error(str(error))\n            output.print_traceback()\n            return False\n        except ForeignPackageManagerError as error:\n            output.print_error(\"Foreign package manager failed.\")\n            output.print_error(str(error))\n            output.print_traceback()\n            return False\n        except pyalpm.error as error:\n            output.print_error(\"Failed to query pacman databases with pyalpm.\")\n            output.print_error(str(error))\n            output.print_traceback()\n            return False\n        except errors.CommandFailedError as error:\n            output.print_error(\n                \"AUR command exited with an unexpected return code. You may have cancelled a \"\n                \"pacman operation.\"\n            )\n            output.print_error(str(error))\n            if error.output:\n                output.print_command_output(error.output)\n            output.print_traceback()\n            return False\n\n        return True\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/commands.py",
    "content": "import decman.plugins.pacman as pacman\n\nimport decman.config as config\nimport decman.core.command as command\n\n\nclass AurCommands(pacman.PacmanCommands):\n    def install_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command installs the given packages from pacman repositories.\n        The packages are installed as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-S\", \"--needed\", \"--asdeps\"] + list(pkgs)\n\n    def install_files_as_dependencies(self, pkg_files: list[str]) -> list[str]:\n        \"\"\"\n        Running this command installs the given packages files as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-U\", \"--asdeps\"] + pkg_files\n\n    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:\n        \"\"\"\n        Running this command outputs -1 when the installed version is older than the new version.\n        \"\"\"\n        return [\"vercmp\", installed_version, new_version]\n\n    def git_clone(self, repo: str, dest: str) -> list[str]:\n        \"\"\"\n        Running this command clones a git repository to the the given destination.\n        \"\"\"\n        return [\"git\", \"clone\", repo, dest]\n\n    def git_diff(self, from_commit: str) -> list[str]:\n        \"\"\"\n        Running this command outputs the difference between the given commit and\n        the current state of the repository.\n        \"\"\"\n        return [\"git\", \"diff\", from_commit]\n\n    def git_get_commit_id(self) -> list[str]:\n        \"\"\"\n        Running this command outputs the current commit id.\n        \"\"\"\n        return [\"git\", \"rev-parse\", \"HEAD\"]\n\n    def git_log_commit_ids(self) -> list[str]:\n        \"\"\"\n        Running this command outputs commit hashes of the repository.\n        \"\"\"\n        return [\"git\", \"log\", \"--format=format:%H\"]\n\n    def review_file(self, file: str) -> list[str]:\n        \"\"\"\n        Running this command outputs a file for the user to see.\n        \"\"\"\n        return [\"less\", file]\n\n    def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command creates a new arch chroot to the chroot directory and installs the\n        given packages there.\n        \"\"\"\n        return [\"mkarchroot\", chroot_dir] + list(with_pkgs)\n\n    def install_chroot(self, chroot_dir: str, packages: list[str]):\n        \"\"\"\n        Running this command installs the given packages to the given chroot.\n        \"\"\"\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-S\",\n            \"--needed\",\n            \"--noconfirm\",\n        ] + packages\n\n    def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[str]:\n        \"\"\"\n        This command prints a real name of a package.\n        For example, it prints the package which provides a virtual package.\n        \"\"\"\n        return [\n            \"arch-nspawn\",\n            chroot_dir,\n            \"pacman\",\n            \"-Sddp\",\n            \"--print-format=%n\",\n            pkg,\n        ]\n\n    def remove_chroot(self, chroot_dir: str, packages: set[str]):\n        \"\"\"\n        Running this command removes the given packages from the given chroot.\n        \"\"\"\n        return [\"arch-nspawn\", chroot_dir, \"pacman\", \"-Rsu\", \"--noconfirm\"] + list(packages)\n\n    def make_chroot_pkg(\n        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]\n    ) -> list[str]:\n        \"\"\"\n        Running this command creates a package file using the given chroot.\n        The package is created as the user and the pkg_files_to_install are installed\n        in the chroot before the package is created.\n        \"\"\"\n        makechrootpkg_cmd = [\"makechrootpkg\", \"-c\", \"-r\", chroot_wd_dir, \"-U\", user]\n\n        for pkgfile in pkgfiles_to_install:\n            makechrootpkg_cmd += [\"-I\", pkgfile]\n\n        return makechrootpkg_cmd\n\n    def print_srcinfo(self) -> list[str]:\n        \"\"\"\n        Running this command prints SRCINFO generated from the package in the current\n        working directory.\n        \"\"\"\n        return [\"makepkg\", \"--printsrcinfo\"]\n\n\nclass AurPacmanInterface(pacman.PacmanInterface):\n    \"\"\"\n    High level interface for running pacman commands.\n\n    On failure methods raise a ``CommandFailedError``.\n    \"\"\"\n\n    def __init__(\n        self,\n        commands: AurCommands,\n        print_highlights: bool,\n        keywords: set[str],\n        dbsiglevel: int,\n        dbpath: str,\n    ) -> None:\n        super().__init__(commands, print_highlights, keywords, dbsiglevel, dbpath)\n        self._installable: dict[str, bool] = {}\n        self._aur_commands = commands\n\n    def get_foreign_orphans(self) -> set[str]:\n        \"\"\"\n        Returns a set of orphaned foreign packages.\n        \"\"\"\n        return self._get_orphans(pacman.PacmanInterface._is_foreign)\n\n    def is_provided_by_installed(self, dependency: str) -> bool:\n        return pacman.strip_dependency(dependency) in self._local_provides_index\n\n    def filter_installed_packages(self, deps: set[str]) -> set[str]:\n        out = set()\n        for d in deps:\n            if not self.is_provided_by_installed(d) and d not in self.get_all_packages():\n                out.add(d)\n        return out\n\n    def is_installable(self, pkg: str) -> bool:\n        \"\"\"\n        Returns True if a package can be installed using pacman.\n        \"\"\"\n        return (\n            pacman.strip_dependency(pkg) in self._name_index\n            or pacman.strip_dependency(pkg) in self._provides_index\n        )\n\n    def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:\n        \"\"\"\n        Returns a list of installed packages and their versions that aren't from pacman databases,\n        basically AUR packages.\n        \"\"\"\n        out: list[tuple[str, str]] = []\n        for pkg in self._handle.get_localdb().pkgcache:\n            if not self._is_native(pkg.name):\n                out.append((pkg.name, pkg.version))\n        return out\n\n    def install_dependencies(self, deps: set[str]):\n        \"\"\"\n        Installs the given dependencies.\n        \"\"\"\n        if not deps:\n            return\n\n        cmd = self._aur_commands.install_as_dependencies(deps)\n        pacman_output = command.prg(cmd)\n        self.print_highlighted_pacman_messages(pacman_output)\n\n    def install_files(self, files: list[str], as_explicit: set[str]):\n        \"\"\"\n        Installs the given files first as dependencies. Then the packages listed in as_explicit are\n        installed explicitly.\n        \"\"\"\n        if not files:\n            return\n\n        cmd = self._aur_commands.install_files_as_dependencies(files)\n        pacman_output = command.prg(cmd)\n        self.print_highlighted_pacman_messages(pacman_output)\n\n        if not as_explicit:\n            return\n\n        cmd = self._commands.set_as_explicit(as_explicit)\n        command.prg(cmd, pty=config.debug_output)\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/error.py",
    "content": "class ForeignPackageManagerError(Exception):\n    \"\"\"\n    Error raised from the ForeignPackageManager\n    \"\"\"\n\n\nclass DependencyCycleError(Exception):\n    \"\"\"\n    Error raised when a dependency cycle is detected involving foreign packages.\n    \"\"\"\n\n    def __init__(self, package1: str, package2: str):\n        super().__init__(\n            f\"Foreign package dependency cycle detected involving '{package1}' \"\n            f\"and '{package2}'. Foreign package dependencies are also required \"\n            \"during package building and therefore dependency cycles cannot be handled.\"\n        )\n\n\nclass PKGBUILDParseError(Exception):\n    \"\"\"\n    Error raised when parsing a PKGBUILD fails.\n    \"\"\"\n\n    def __init__(self, git_url: str | None, pkgbuild_directory: str | None, message: str) -> None:\n        # Only one of these should be set\n        self.pkgbuild_source = git_url or pkgbuild_directory\n        self.message = message\n        super().__init__(f\"Failed to parse PKGBUILD from '{self.pkgbuild_source}': {message}\")\n\n\nclass AurRPCError(Exception):\n    \"\"\"\n    Error raised when accessing AUR RPC fails.\n    \"\"\"\n\n    def __init__(self, message: str, url: str):\n        self.message = message\n        self.url = url\n        super().__init__(f\"Failed to complete AUR RPC request to '{url}': {message}\")\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/fpm.py",
    "content": "import os\nimport shutil\nimport time\nimport typing\n\nfrom decman.plugins.aur.commands import AurCommands\nfrom decman.plugins.aur.error import ForeignPackageManagerError\nfrom decman.plugins.aur.package import AurPacmanInterface, PackageSearch\nfrom decman.plugins.aur.resolver import DepGraph, ForeignPackage\n\nimport decman.config as config\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.output as output\nimport decman.core.store as _store\n\n\ndef find_latest_cached_package(store: _store.Store, package: str) -> tuple[str, str] | None:\n    \"\"\"\n    Returns the latest version and path of a package stored in the built packages cache as a\n    tuple (version, path).\n    \"\"\"\n    store.ensure(\"package_file_cache\", {})\n    entries = store[\"package_file_cache\"].get(package)\n\n    if entries is None:\n        return None\n\n    latest_version = None\n    latest_path = None\n    latest_timestamp = 0\n\n    for version, path, timestamp in entries:\n        if latest_timestamp < timestamp and os.path.exists(path):\n            latest_timestamp = timestamp\n            latest_version = version\n            latest_path = path\n\n    output.print_debug(f\"Latest file for {package} is '{latest_path}'.\")\n\n    if latest_path is None:\n        return None\n\n    assert latest_version is not None, \"If latest_path is set, then latest_version is set.\"\n    return (latest_version, latest_path)\n\n\ndef add_package_to_cache(store: _store.Store, package: str, version: str, path_to_built_pkg: str):\n    \"\"\"\n    Adds a built package to the package file cache. Tries to remove excess cached packages.\n    \"\"\"\n    store.ensure(\"package_file_cache\", {})\n\n    new_entry = (version, path_to_built_pkg, int(time.time()))\n    entries = store[\"package_file_cache\"].get(package, [])\n    for _, already_cached_path, __ in entries:\n        if already_cached_path == path_to_built_pkg:\n            output.print_debug(\n                f\"Trying to cache {package} version {version}, but the version is already cached: \"\n                f\"{already_cached_path}\"\n            )\n            return\n    entries.append(new_entry)\n\n    store[\"package_file_cache\"][package] = entries\n    clean_package_cache(store, package)\n\n\ndef clean_package_cache(store: _store.Store, package: str):\n    oldest_path = None\n    oldest_timestamp = None\n    index_of_oldest = None\n\n    entries = store[\"package_file_cache\"][package]\n    output.print_debug(f\"Package cache has {len(entries)} entries.\")\n\n    number_of_packages_stored_in_cache = 3\n\n    if len(entries) <= number_of_packages_stored_in_cache:\n        output.print_debug(\"Old files will not be removed.\")\n        return\n\n    for index, entry in enumerate(entries):\n        _, path, timestamp = entry\n        if oldest_timestamp is None or oldest_timestamp > timestamp:\n            oldest_timestamp = timestamp\n            oldest_path = path\n            index_of_oldest = index\n\n    output.print_debug(f\"Oldest cached file for {package} is '{oldest_path}'.\")\n    if oldest_path is None:\n        return\n    assert index_of_oldest is not None\n\n    entries.pop(index_of_oldest)\n    if os.path.exists(oldest_path):\n        output.print_debug(f\"Removing '{oldest_path}' from the package cache.\")\n        try:\n            os.remove(oldest_path)\n        except OSError as e:\n            output.print_error(f\"Failed to remove file '{oldest_path}' from the package cache.\")\n            output.print_error(e.strerror or str(e))\n            output.print_error(\"You'll have to remove the file manually.\")\n\n    store[\"package_file_cache\"][package] = entries\n\n\ndef is_devel(package: str) -> bool:\n    \"\"\"\n    Returns True if the given package is a devel package.\n    \"\"\"\n    devel_suffixes = [\n        \"-git\",\n        \"-hg\",\n        \"-bzr\",\n        \"-svn\",\n        \"-cvs\",\n        \"-darcs\",\n    ]\n    for suffix in devel_suffixes:\n        if package.endswith(suffix):\n            return True\n    return False\n\n\nclass ResolvedDependencies:\n    \"\"\"\n    Result of dependency resolution.\n    \"\"\"\n\n    def __init__(self) -> None:\n        self.pacman_deps: set[str] = set()\n        self.foreign_pkgs: set[str] = set()\n        self.foreign_dep_pkgs: set[str] = set()\n        self.foreign_build_dep_pkgs: set[str] = set()\n        self.build_order: list[str] = []\n        self.packages: dict[str, ForeignPackage] = {}\n        # maps dependency names to package names\n        self.providers: dict[str, list[str]] = {}\n        self.all_provided: set[str] = set()\n        self._pkgbases_to_pkgs: dict[str, set[str]] = {}\n        self._pkgs_to_pkgbases: dict[str, str] = {}\n\n    def add_pkgbase_info(self, pkgname: str, pkgbase: str):\n        \"\"\"\n        Adds information about a which package belongs in which package base.\n        \"\"\"\n        pkgs = self._pkgbases_to_pkgs.get(pkgbase, set())\n        pkgs.add(pkgname)\n        self._pkgbases_to_pkgs[pkgbase] = pkgs\n        self._pkgs_to_pkgbases[pkgname] = pkgbase\n\n    def get_pkgbase(self, pkgname: str) -> str:\n        \"\"\"\n        Returns the package base of an package.\n        \"\"\"\n        return self._pkgs_to_pkgbases[pkgname]\n\n    def get_pkgs_with_common_pkgbase(self, pkgname: str) -> set[str]:\n        \"\"\"\n        Returns all packages that have the same package base as the given package.\n        \"\"\"\n        pkgbase = self._pkgs_to_pkgbases[pkgname]\n        return self._pkgbases_to_pkgs[pkgbase]\n\n    def all_pkgbases(self) -> list[str]:\n        \"\"\"\n        Returns all pkgbases.\n        \"\"\"\n        return list(self._pkgbases_to_pkgs)\n\n    def get_some_pkgname(self, pkgbase: str) -> str:\n        \"\"\"\n        Returns some package name that the given pkgbase has.\n        \"\"\"\n        return list(self._pkgbases_to_pkgs[pkgbase])[0]\n\n\nclass ForeignPackageManager:\n    \"\"\"\n    Class for dealing with foreign packages.\n    \"\"\"\n\n    def __init__(\n        self,\n        store: _store.Store,\n        pacman: AurPacmanInterface,\n        search: PackageSearch,\n        commands: AurCommands,\n        pkg_cache_dir: str,\n        build_dir: str,\n        makepkg_user: str,\n    ):\n        self._store = store\n        self._pacman = pacman\n        self._search = search\n        self._commands = commands\n        self._pkg_cache_dir = pkg_cache_dir\n        self._build_dir = build_dir\n        self._makepkg_user = makepkg_user\n\n    def upgrade(\n        self,\n        upgrade_devel: bool = False,\n        force: bool = False,\n        ignored_pkgs: typing.Optional[set[str]] = None,\n    ):\n        \"\"\"\n        Upgrades all foreign packages.\n        \"\"\"\n        if ignored_pkgs is None:\n            ignored_pkgs = set()\n\n        output.print_info(\"Determining foreign packages to upgrade.\")\n\n        all_foreign_pkgs = self._pacman.get_versioned_foreign_packages()\n        all_explicit_foreign_pkgs = set(self._pacman.get_foreign_explicit())\n        output.print_debug(f\"Foreign packages to check for upgrades: {all_foreign_pkgs}\")\n\n        self._search.try_caching_packages(list(map(lambda p: p[0], all_foreign_pkgs)))\n\n        as_explicit = []\n        as_deps = []\n        for pkg, ver in all_foreign_pkgs:\n            if pkg in ignored_pkgs:\n                continue\n\n            info = self._search.get_package_info(pkg)\n            if info is None:\n                raise ForeignPackageManagerError(\n                    f\"Failed to find '{pkg}' from AUR or user provided packages.\"\n                )\n\n            if self.should_upgrade_package(pkg, ver, info.version, upgrade_devel):\n                if pkg in all_explicit_foreign_pkgs:\n                    as_explicit.append(pkg)\n                else:\n                    as_deps.append(pkg)\n\n        output.print_debug(\n            f\"The following foreign packages will be upgraded: {' '.join(as_explicit)}\"\n        )\n\n        self.install(as_explicit, as_deps, force)\n\n    def install(\n        self,\n        foreign_pkgs: list[str],\n        foreign_dep_pkgs: typing.Optional[list[str]] = None,\n        force: bool = False,\n    ):\n        \"\"\"\n        Installs the given foreign packages and their dependencies (both pacman/AUR).\n        \"\"\"\n\n        if foreign_dep_pkgs is None:\n            foreign_dep_pkgs = []\n\n        if len(foreign_pkgs) == 0 and len(foreign_dep_pkgs) == 0:\n            return\n\n        resolved_dependencies = self.resolve_dependencies(foreign_pkgs, foreign_dep_pkgs)\n\n        output.print_list(\n            \"The following foreign packages will be installed explicitly:\",\n            sorted(resolved_dependencies.foreign_pkgs),\n        )\n\n        output.print_list(\n            \"The following foreign packages will be installed as dependencies:\",\n            sorted(resolved_dependencies.foreign_dep_pkgs),\n        )\n\n        output.print_list(\n            \"The following foreign packages will be built in order to install other packages. \"\n            \"They will not be installed:\",\n            sorted(resolved_dependencies.foreign_build_dep_pkgs),\n        )\n\n        if not output.prompt_confirm(\"Proceed?\", default=True):\n            raise ForeignPackageManagerError(\"Installing aborted by the user.\")\n\n        needed_pacman_deps = self._pacman.filter_installed_packages(\n            resolved_dependencies.pacman_deps - resolved_dependencies.all_provided\n        )\n        output.print_summary(\"Installing foreign package dependencies from pacman.\")\n        self._pacman.install_dependencies(needed_pacman_deps)\n\n        try:\n            with PackageBuilder(\n                self._search,\n                self._store,\n                self._pacman,\n                resolved_dependencies,\n                self._commands,\n                self._pkg_cache_dir,\n                self._build_dir,\n                self._makepkg_user,\n            ) as builder:\n                while resolved_dependencies.build_order:\n                    to_build = resolved_dependencies.build_order.pop(0)\n\n                    pkgbase = resolved_dependencies.get_pkgbase(to_build)\n                    package_names = resolved_dependencies.get_pkgs_with_common_pkgbase(to_build)\n\n                    packages = [\n                        resolved_dependencies.packages[pkgname] for pkgname in package_names\n                    ]\n\n                    builder.build_packages(pkgbase, packages, force)\n        except OSError as e:\n            raise ForeignPackageManagerError(\"Failed to build packages.\") from e\n\n        packages_to_install = resolved_dependencies.foreign_pkgs\n        packages_to_install |= resolved_dependencies.foreign_dep_pkgs\n\n        package_files_to_install = []\n        for pkg in packages_to_install:\n            built_pkg = find_latest_cached_package(self._store, pkg)\n            assert built_pkg is not None\n            _, path = built_pkg\n            package_files_to_install.append(path)\n\n        if package_files_to_install or force:\n            output.print_summary(\"Installing foreign packages.\")\n            self._pacman.install_files(\n                package_files_to_install,\n                as_explicit=resolved_dependencies.foreign_pkgs\n                - resolved_dependencies.foreign_dep_pkgs,\n            )\n        else:\n            output.print_summary(\"No packages to install.\")\n\n    def resolve_dependencies(\n        self,\n        foreign_pkgs: list[str],\n        foreign_dep_pkgs: typing.Optional[list[str]] = None,\n    ) -> ResolvedDependencies:\n        \"\"\"\n        Resolves foreign dependencies of foreign packages.\n        \"\"\"\n\n        output.print_info(\"Resolving foreign package dependencies.\")\n        output.print_debug(f\"Packages: {foreign_pkgs}\")\n\n        if foreign_dep_pkgs is None:\n            foreign_dep_pkgs = []\n\n        result = ResolvedDependencies()\n        result.foreign_pkgs = set(foreign_pkgs)\n        result.foreign_dep_pkgs = set(foreign_dep_pkgs)\n\n        graph = DepGraph()\n\n        for name in foreign_pkgs + foreign_dep_pkgs:\n            graph.add_requirement(name, None)\n\n        seen_packages = set(foreign_pkgs + foreign_dep_pkgs)\n        to_process = foreign_pkgs + foreign_dep_pkgs\n        total_processed = 0\n\n        self._search.try_caching_packages(to_process)\n\n        def process_dep(pkgname: str, depname: str, add_to: set[str]):\n            dep_info = self._search.find_provider(depname)\n\n            if dep_info is None:\n                raise ForeignPackageManagerError(\n                    f\"Failed to find '{depname}' from AUR or user provided packages.\"\n                )\n\n            add_to.add(dep_info.pkgname)\n\n            output.print_debug(f\"Adding dependency {dep_info.pkgname} to package {pkgname}.\")\n            graph.add_requirement(dep_info.pkgname, pkgname)\n            if dep_info.pkgname not in seen_packages:\n                to_process.append(dep_info.pkgname)\n                seen_packages.add(dep_info.pkgname)\n\n        while to_process:\n            pkgname = to_process.pop()\n\n            info = self._search.get_package_info(pkgname)\n            if info is None:\n                raise ForeignPackageManagerError(\n                    f\"Failed to find '{pkgname}' from AUR or user provided packages.\"\n                )\n\n            for provided in info.provides:\n                result.providers.setdefault(provided, []).append(pkgname)\n                result.all_provided.add(provided)\n\n            result.pacman_deps.update(info.native_dependencies(self._pacman))\n            result.add_pkgbase_info(pkgname, info.pkgbase)\n\n            build_deps = info.foreign_make_dependencies(\n                self._pacman\n            ) + info.foreign_check_dependencies(self._pacman)\n\n            self._search.try_caching_packages(info.foreign_dependencies(self._pacman) + build_deps)\n\n            for depname in info.foreign_dependencies(self._pacman):\n                process_dep(pkgname, depname, result.foreign_dep_pkgs)\n\n            for depname in build_deps:\n                process_dep(pkgname, depname, result.foreign_build_dep_pkgs)\n\n            total_processed += 1\n            output.print_info(f\"Progress: {total_processed}/{len(seen_packages)}.\")\n\n        output.print_info(\"Determining build order.\")\n\n        while True:\n            to_add = graph.get_and_remove_outer_dep_pkgs()\n\n            if len(to_add) == 0:\n                break\n\n            for pkg in to_add:\n                if pkg not in result.packages:\n                    output.print_debug(f\"Adding {pkg} to build_order.\")\n                    result.build_order.append(pkg.name)\n                    result.packages[pkg.name] = pkg\n\n        return result\n\n    def should_upgrade_package(\n        self,\n        package: str,\n        installed_version: str,\n        fetched_version: str,\n        upgrade_devel=False,\n    ) -> bool:\n        \"\"\"\n        Returns True if a package should be upgraded.\n        \"\"\"\n\n        if upgrade_devel and is_devel(package):\n            output.print_debug(f\"Package {package} is devel package. It should be upgraded.\")\n            return True\n\n        try:\n            cmd = self._commands.compare_versions(installed_version, fetched_version)\n            vercmp_output = command.prg(cmd, pty=False)\n            should_upgrade = int(vercmp_output) < 0\n\n            output.print_debug(\n                f\"Installed version is: {installed_version}. \"\n                f\"Available version is {fetched_version}. Should upgrade: {should_upgrade}.\"\n            )\n            return should_upgrade\n        except (ValueError, errors.CommandFailedError) as error:\n            output.print_error(f\"{error}\")\n            raise ForeignPackageManagerError(\"Failed to compare versions using vercmp.\") from error\n\n\nclass PackageBuilder:\n    \"\"\"\n    Used for building packages in a chroot.\n    \"\"\"\n\n    always_included_packages = [\"base-devel\", \"git\"]\n\n    def __init__(\n        self,\n        search: PackageSearch,\n        store: _store.Store,\n        pacman: AurPacmanInterface,\n        resolved_deps: ResolvedDependencies,\n        commands: AurCommands,\n        pkg_cache_dir: str,\n        build_dir: str,\n        makepkg_user: str,\n    ):\n        self._search = search\n        self._store = store\n        self._pacman = pacman\n        self._resolved_deps = resolved_deps\n        self._commands = commands\n        self.pkg_cache_dir = pkg_cache_dir\n        self.build_dir = build_dir\n        self.makepkg_user = makepkg_user\n        self.valid_pkgexts = [\n            \".pkg.tar\",\n            \".pkg.tar.gz\",\n            \".pkg.tar.bz2\",\n            \".pkg.tar.xz\",\n            \".pkg.tar.zst\",\n            \".pkg.tar.lzo\",\n            \".pkg.tar.lrz\",\n            \".pkg.tar.lz4\",\n            \".pkg.tar.lz\",\n            \".pkg.tar.Z\",\n        ]\n        self.chroot_wd_dir = os.path.join(build_dir, \"chroot\")\n        self.chroot_dir = os.path.join(self.chroot_wd_dir, \"root\")\n        self.pkgbase_dir_map: dict[str, str] = {}\n        self.original_wd = \"\"\n        self._pkgs_in_chroot = set(PackageBuilder.always_included_packages)\n        self._pkgs_in_chroot.update(resolved_deps.pacman_deps)\n\n    def __enter__(self):\n        self.store_wd()\n        self.create_build_environment()\n\n        return self\n\n    def __exit__(self, exc_type, exc_value, traceback):\n        self.restore_wd()\n        self.remove_build_environment()\n\n    def store_wd(self):\n        \"\"\"\n        Remembers the current working directory as the original working directory.\n        \"\"\"\n        self.original_wd = os.getcwd()\n\n    def restore_wd(self):\n        \"\"\"\n        Returns to the original working directory.\n        \"\"\"\n        os.chdir(self.original_wd)\n\n    def create_build_environment(self):\n        \"\"\"\n        Creates a new chroot and clones all PKGBUILDS.\n        \"\"\"\n        output.print_info(\"Creating a build environment..\")\n\n        if os.path.exists(self.build_dir):\n            output.print_info(\"Removing previous build directory.\")\n            self.remove_build_environment()\n\n        output.print_info(\"Getting all PKGBUILDS.\")\n\n        # Set up PKGBUILDS\n        for pkgbase in self._resolved_deps.all_pkgbases():\n            pkgbuild_dir = os.path.join(self.build_dir, pkgbase)\n            self.pkgbase_dir_map[pkgbase] = pkgbuild_dir\n            os.makedirs(pkgbuild_dir)\n            os.chdir(pkgbuild_dir)\n\n            pkgbase_info = self._search.get_package_info(\n                self._resolved_deps.get_some_pkgname(pkgbase)\n            )\n\n            assert pkgbase_info is not None, (\n                \"All dependencies and packages should be resolved \"\n                \"during the creation of ResolvedDependencies.\"\n            )\n\n            output.print_debug(f\"Git URL for '{pkgbase}' is '{pkgbase_info.git_url}'\")\n            output.print_debug(\n                f\"PKGBUILD directory for '{pkgbase}' is '{pkgbase_info.pkgbuild_directory}'\"\n            )\n            self._fetch_and_review_pkgbuild(\n                pkgbase, pkgbase_info.git_url, pkgbase_info.pkgbuild_directory\n            )\n            shutil.chown(pkgbuild_dir, user=self.makepkg_user)\n\n        output.print_info(\"Creating a new chroot.\")\n        os.makedirs(self.chroot_wd_dir)\n\n        # Remove GNUPGHOME from mkarchroot environment variables since it may interfere with\n        # the chroot creation\n        mkarchroot_env_vars = os.environ.copy()\n        try:\n            del mkarchroot_env_vars[\"GNUPGHOME\"]\n            output.print_debug(\"Removed GNUPGHOME variable from mkarchroot environment.\")\n        except KeyError:\n            pass\n\n        cmd = self._commands.make_chroot(self.chroot_dir, self._pkgs_in_chroot)\n        command.prg(\n            cmd, env_overrides=mkarchroot_env_vars, pass_environment=False, pty=config.debug_output\n        )\n\n    def remove_build_environment(self):\n        \"\"\"\n        Deletes the build environment.\n        \"\"\"\n        shutil.rmtree(self.build_dir)\n\n    def build_packages(self, package_base: str, packages: list[ForeignPackage], force: bool):\n        \"\"\"\n        Builds package(s) with the same package base.\n\n        Set force to true to force rebuilds of packages that are already cached\n        \"\"\"\n\n        package_names = list(map(lambda p: p.name, packages))\n\n        # Rebuild is only needed if at least one package is not in the cache.\n\n        if self._are_all_pkgs_cached(packages) and not force:\n            output.print_info(f\"Skipped building '{' '.join(package_names)}'. Already up to date.\")\n            return\n\n        output.print_info(f\"Building '{' '.join(package_names)}'.\")\n\n        chroot_new_pacman_pkgs, chroot_pkg_files = self._get_chroot_packages(packages)\n\n        pkgbuild_dir = self.pkgbase_dir_map[package_base]\n        os.chdir(pkgbuild_dir)\n\n        output.print_debug(f\"Chroot dir is: '{self.chroot_dir}', pkgbuild dir is '{pkgbuild_dir}'.\")\n\n        output.print_info(\"Installing build dependencies to chroot.\")\n\n        cmd = self._commands.install_chroot(\n            self.chroot_dir, chroot_new_pacman_pkgs + PackageBuilder.always_included_packages\n        )\n        command.prg(cmd, pty=config.debug_output)\n        output.print_info(\"Making package.\")\n\n        cmd = self._commands.make_chroot_pkg(\n            self.chroot_wd_dir, self.makepkg_user, chroot_pkg_files\n        )\n        command.prg(cmd)\n\n        for pkgname in package_names:\n            file = self._find_pkgfile(pkgname, pkgbuild_dir)\n\n            dest = shutil.copy(file, self.pkg_cache_dir)\n\n            pkg_info = self._search.get_package_info(pkgname)\n\n            # Because all dependencies and packages should be resolved during the creation\n            # of ResolvedDependencies.\n            assert pkg_info is not None\n            version = pkg_info.version\n\n            output.print_debug(\n                f\"Adding '{pkgname}', version: '{version}' to cache as file '{dest}'.\"\n            )\n\n            add_package_to_cache(self._store, pkgname, version, dest)\n\n        if len(chroot_new_pacman_pkgs) != 0:\n            to_remove = set()\n            for p in chroot_new_pacman_pkgs:\n                if p not in self._pkgs_in_chroot:\n                    cmd = self._commands.resolve_real_name_chroot(self.chroot_dir, p)\n                    _, cmd_output = command.check_run_result(cmd, command.run(cmd))\n                    real_pkgname = cmd_output.strip()\n                    to_remove.add(real_pkgname)\n\n            if to_remove:\n                output.print_info(\"Removing build dependencies from chroot.\")\n                cmd = self._commands.remove_chroot(self.chroot_dir, to_remove)\n                command.prg(cmd, pty=config.debug_output)\n            else:\n                output.print_debug(\"No build dependencies to remove from chroot.\")\n\n        output.print_info(f\"Finished building: '{' '.join(package_names)}'.\")\n\n    def _are_all_pkgs_cached(self, pkgs: list[ForeignPackage]) -> bool:\n        for pkg in pkgs:\n            cache_entry = find_latest_cached_package(self._store, pkg.name)\n            if cache_entry is None:\n                return False\n            cached_version, _ = cache_entry\n\n            pkg_info = self._search.get_package_info(pkg.name)\n\n            # Because all dependencies and packages should be resolved during the creation\n            # of ResolvedDependencies. git_url should not be None.\n            assert pkg_info is not None\n            fetched_version = pkg_info.version\n\n            if cached_version != fetched_version or is_devel(pkg.name):\n                return False\n        return True\n\n    def _get_chroot_packages(\n        self, pkgs_to_build: list[ForeignPackage]\n    ) -> tuple[list[str], list[str]]:\n        \"\"\"\n        Returns a tuple of pacman build dependencies and built foreign pkgs files that are needed\n        in the chroot before building. pkgs_to_build share the same pkgbase.\n        \"\"\"\n        chroot_pacman_build_deps = set()\n        chroot_foreign_pkgs = set()\n\n        def add_to_pacman_build_deps(deps: list[str]):\n            for dep in deps:\n                if dep not in self._resolved_deps.pacman_deps:\n                    chroot_pacman_build_deps.add(dep)\n\n        for pkg in pkgs_to_build:\n            info = self._search.get_package_info(pkg.name)\n            # Because all dependencies and packages should be resolved during the creation\n            # of ResolvedDependencies. git_url should not be None.\n            assert info is not None\n\n            add_to_pacman_build_deps(info.native_make_dependencies(self._pacman))\n            add_to_pacman_build_deps(info.native_check_dependencies(self._pacman))\n\n            foreign_deps = pkg.get_all_recursive_foreign_dep_pkgs()\n            chroot_foreign_pkgs.update(foreign_deps)\n\n            # Add pacman deps of foreign packages\n            for dep in foreign_deps:\n                dep_info = self._search.get_package_info(dep)\n                # Because all dependencies and packages should be resolved during the creation\n                # of ResolvedDependencies. git_url should not be None.\n                assert dep_info is not None\n\n                add_to_pacman_build_deps(dep_info.native_make_dependencies(self._pacman))\n                add_to_pacman_build_deps(dep_info.native_check_dependencies(self._pacman))\n\n        # Packages with the same pkgbase might depend on each other,\n        # but they don't need to be installed for the build to succeed.\n        for pkg in pkgs_to_build:\n            if pkg.name in chroot_foreign_pkgs:\n                chroot_foreign_pkgs.remove(pkg.name)\n\n        chroot_foreign_pkg_files = []\n\n        for foreign_pkg in chroot_foreign_pkgs:\n            entry = find_latest_cached_package(self._store, foreign_pkg)\n            assert entry is not None, (\n                \"Build order determines that the dependencies are built \"\n                \"before and thus are found in the cache.\"\n            )\n\n            _, file = entry\n\n            chroot_foreign_pkg_files.append(file)\n\n        return (list(chroot_pacman_build_deps), chroot_foreign_pkg_files)\n\n    def _find_pkgfile(self, pkgname: str, pkgbuild_dir: str) -> str:\n        # HACK: Because we don't know the pkgarch we can't be sure what is the build result.\n        # Instead: we just try with pre- and postfixes.\n\n        matches = []\n\n        info = self._search.get_package_info(pkgname)\n        assert info is not None\n        prefix = info.pkg_file_prefix()\n\n        for file in os.scandir(pkgbuild_dir):\n            if file.is_file() and file.name.startswith(prefix):\n                for ext in self.valid_pkgexts:\n                    if file.name.endswith(ext):\n                        matches.append(file.path)\n                        continue\n\n        if len(matches) != 1:\n            raise ForeignPackageManagerError(\n                f\"Failed to build package '{pkgname}', because the pkg file cannot be determined. \"\n                f\"Possible files are: {matches}\"\n            )\n\n        return matches[0]\n\n    def _fetch_and_review_pkgbuild(\n        self, pkgbase: str, git_url: str | None, pkgbuild_directory: str | None\n    ):\n        \"\"\"\n        Fetches a PKGBUILD to the current directory.\n\n        PKGBUILD will be cloned using git if ``git_url`` is set.\n        PKGBUILD will be copied from ``pkgbuild_directory`` if it is set.\n\n        The user is prompted to review the PKGBUILD and confirm if the package should be built.\n        \"\"\"\n\n        self._store.ensure(\"pkgbuild_latest_reviewed_commits\", {})\n\n        if git_url:\n            cmd = self._commands.git_clone(git_url, \".\")\n            command.prg(cmd, pty=config.debug_output)\n\n        if pkgbuild_directory:\n            try:\n                shutil.copytree(pkgbuild_directory, \".\", dirs_exist_ok=True)\n\n                # Chmod to 755 to allow reading files\n                mode = 0o755\n                for root, dirs, files in os.walk(\".\"):\n                    for name in dirs + files:\n                        os.chmod(os.path.join(root, name), mode)\n                os.chmod(\".\", mode)\n            except OSError as error:\n                raise ForeignPackageManagerError(f\"Failed to copy {pkgbuild_directory}.\") from error\n\n        if output.prompt_confirm(f\"Review PKGBUILD or show diff for {pkgbase}?\", default=True):\n            latest_reviewed_commit = None\n            git_commit_ids = []\n\n            if git_url:\n                latest_reviewed_commit = self._store[\"pkgbuild_latest_reviewed_commits\"].get(\n                    pkgbase\n                )\n\n                cmd = self._commands.git_log_commit_ids()\n                git_output = command.prg(cmd, pty=False)\n                git_commit_ids = git_output.strip().split(\"\\n\")\n\n            if latest_reviewed_commit is None or latest_reviewed_commit not in git_commit_ids:\n                try:\n                    for file in os.scandir(\".\"):\n                        if file.is_file() and not file.name.startswith(\".\"):\n                            cmd = self._commands.review_file(file.path)\n                            command.prg(cmd)\n                except OSError as error:\n                    raise ForeignPackageManagerError(\n                        f\"Failed to review files in directory for {pkgbase}.\"\n                    ) from error\n\n            else:\n                cmd = self._commands.git_diff(latest_reviewed_commit)\n                command.prg(cmd)\n\n        if output.prompt_confirm(\"Build this package?\", default=True):\n            cmd = self._commands.git_get_commit_id()\n            rc, git_output = command.run(cmd)\n            if rc == 0:\n                commit_id = git_output.strip()\n                self._store[\"pkgbuild_latest_reviewed_commits\"][pkgbase] = commit_id\n            else:\n                output.print_debug(\n                    f\"{pkgbase} is not in a git repository. Commit ID cannot be saved.\"\n                )\n        else:\n            raise ForeignPackageManagerError(\"Building aborted.\")\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/package.py",
    "content": "import dataclasses\nimport os\nimport pathlib\nimport shutil\nimport tempfile\n\nimport decman.plugins.pacman as pacman_module\nimport requests  # type: ignore\nfrom decman.plugins.aur.commands import AurCommands, AurPacmanInterface\nfrom decman.plugins.aur.error import AurRPCError, PKGBUILDParseError\n\nimport decman.config as config\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.output as output\n\n\n@dataclasses.dataclass(frozen=True, slots=True)\nclass PackageInfo:\n    \"\"\"\n    Immutable description of a package to be built or installed.\n\n    This class represents *resolved* package metadata and is intended to be\n    passed around as pure data.\n\n    Exactly one source must be specified:\n    - ``git_url`` for VCS-based (e.g. AUR) packages\n    - ``pkgbuild_directory`` for local PKGBUILD-based packages\n\n    Invariants:\n    - ``pkgname`` uniquely identifies the package.\n    - ``pkgbase`` groups split packages.\n    - Exactly one of ``git_url`` or ``pkgbuild_directory`` is set.\n    - All dependency containers are immutable.\n\n    This object is safe for hashing, set membership, and reuse across runs.\n    \"\"\"\n\n    pkgname: str\n    pkgbase: str\n    version: str\n\n    git_url: str | None = None\n    pkgbuild_directory: str | None = None\n    provides: tuple[str, ...] = dataclasses.field(default_factory=tuple)\n    dependencies: tuple[str, ...] = dataclasses.field(default_factory=tuple)\n    make_dependencies: tuple[str, ...] = dataclasses.field(default_factory=tuple)\n    check_dependencies: tuple[str, ...] = dataclasses.field(default_factory=tuple)\n\n    # Caches (excluded from eq/hash)\n    _native_dependencies: tuple[str, ...] | None = dataclasses.field(\n        default=None, init=False, repr=False, compare=False\n    )\n    _foreign_dependencies: tuple[str, ...] | None = dataclasses.field(\n        default=None, init=False, repr=False, compare=False\n    )\n\n    _native_make_dependencies: tuple[str, ...] | None = dataclasses.field(\n        default=None, init=False, repr=False, compare=False\n    )\n    _foreign_make_dependencies: tuple[str, ...] | None = dataclasses.field(\n        default=None, init=False, repr=False, compare=False\n    )\n\n    _native_check_dependencies: tuple[str, ...] | None = dataclasses.field(\n        default=None, init=False, repr=False, compare=False\n    )\n    _foreign_check_dependencies: tuple[str, ...] | None = dataclasses.field(\n        default=None, init=False, repr=False, compare=False\n    )\n\n    def __post_init__(self) -> None:\n        if self.git_url is None and self.pkgbuild_directory is None:\n            raise ValueError(\"Both git_url and pkgbuild_directory cannot be None.\")\n\n        if self.git_url is not None and self.pkgbuild_directory is not None:\n            raise ValueError(\"Both git_url and pkgbuild_directory cannot be set.\")\n\n    def pkg_file_prefix(self) -> str:\n        \"\"\"\n        Returns the beginning of the file created from building this package.\n        \"\"\"\n        return f\"{self.pkgname}-{self.version}\"\n\n    # --- public API ---------------------------------------------------------\n\n    def foreign_dependencies(self, pacman: AurPacmanInterface) -> list[str]:\n        \"\"\"\n        Returns a list of foreign dependencies of this package.\n\n        The dependencies are stripped of their version constraints if there are any.\n        \"\"\"\n        self._ensure_dependencies_cached(pacman)\n        assert self._foreign_dependencies is not None\n        return list(self._foreign_dependencies)\n\n    def foreign_make_dependencies(self, pacman: AurPacmanInterface) -> list[str]:\n        \"\"\"\n        Returns a list of foreign make dependencies of this package.\n\n        The dependencies are stripped of their version constraints if there are any.\n        \"\"\"\n        self._ensure_make_dependencies_cached(pacman)\n        assert self._foreign_make_dependencies is not None\n        return list(self._foreign_make_dependencies)\n\n    def foreign_check_dependencies(self, pacman: AurPacmanInterface) -> list[str]:\n        \"\"\"\n        Returns a list of foreign check dependencies of this package.\n\n        The dependencies are stripped of their version constraints if there are any.\n        \"\"\"\n        self._ensure_check_dependencies_cached(pacman)\n        assert self._foreign_check_dependencies is not None\n        return list(self._foreign_check_dependencies)\n\n    def native_dependencies(self, pacman: AurPacmanInterface) -> list[str]:\n        \"\"\"\n        Returns a list of native dependencies of this package.\n\n        The dependencies are stripped of their version constraints if there are any.\n        \"\"\"\n        self._ensure_dependencies_cached(pacman)\n        assert self._native_dependencies is not None\n        return list(self._native_dependencies)\n\n    def native_make_dependencies(self, pacman: AurPacmanInterface) -> list[str]:\n        \"\"\"\n        Returns a list of native make dependencies of this package.\n\n        The dependencies are stripped of their version constraints if there are any.\n        \"\"\"\n        self._ensure_make_dependencies_cached(pacman)\n        assert self._native_make_dependencies is not None\n        return list(self._native_make_dependencies)\n\n    def native_check_dependencies(self, pacman: AurPacmanInterface) -> list[str]:\n        \"\"\"\n        Returns a list of native check dependencies of this package.\n\n        The dependencies are stripped of their version constraints if there are any.\n        \"\"\"\n        self._ensure_check_dependencies_cached(pacman)\n        assert self._native_check_dependencies is not None\n        return list(self._native_check_dependencies)\n\n    # --- internal helpers ---------------------------------------------------\n\n    @staticmethod\n    def _classify_dependencies(\n        deps: tuple[str, ...], pacman: AurPacmanInterface\n    ) -> tuple[tuple[str, ...], tuple[str, ...]]:\n        native: list[str] = []\n        foreign: list[str] = []\n\n        for dependency in deps:\n            stripped = pacman_module.strip_dependency(dependency)\n            if pacman.is_installable(dependency):\n                native.append(stripped)\n            else:\n                foreign.append(stripped)\n\n        return tuple(native), tuple(foreign)\n\n    def _ensure_dependencies_cached(self, pacman: AurPacmanInterface) -> None:\n        if self._native_dependencies is not None:\n            return\n\n        native, foreign = self._classify_dependencies(self.dependencies, pacman)\n        object.__setattr__(self, \"_native_dependencies\", native)\n        object.__setattr__(self, \"_foreign_dependencies\", foreign)\n\n    def _ensure_make_dependencies_cached(self, pacman: AurPacmanInterface) -> None:\n        if self._native_make_dependencies is not None:\n            return\n\n        native, foreign = self._classify_dependencies(self.make_dependencies, pacman)\n        object.__setattr__(self, \"_native_make_dependencies\", native)\n        object.__setattr__(self, \"_foreign_make_dependencies\", foreign)\n\n    def _ensure_check_dependencies_cached(self, pacman: AurPacmanInterface) -> None:\n        if self._native_check_dependencies is not None:\n            return\n\n        native, foreign = self._classify_dependencies(self.check_dependencies, pacman)\n        object.__setattr__(self, \"_native_check_dependencies\", native)\n        object.__setattr__(self, \"_foreign_check_dependencies\", foreign)\n\n\nclass CustomPackage:\n    \"\"\"\n    Custom package installed from some other location than the official repos or the AUR.\n\n    ``pkgname`` is required because the PKGBUILD might be for split packages.\n\n    Exactly one of ``git_url`` or ``pkgbuild_directory`` must be provided.\n\n    Parameters:\n        ``pkgname``:\n            Name of the package.\n\n        ``git_url``:\n            URL to a git repository containing the PKGBUILD.\n\n        ``pkgbuild_directory``:\n            Path to the directory containing the PKGBUILD.\n    \"\"\"\n\n    def __init__(\n        self, pkgname: str, git_url: str | None = None, pkgbuild_directory: str | None = None\n    ) -> None:\n        if git_url is None and pkgbuild_directory is None:\n            raise ValueError(\"Both git_url and pkgbuild_directory cannot be None.\")\n\n        if git_url is not None and pkgbuild_directory is not None:\n            raise ValueError(\"Both git_url and pkgbuild_directory cannot be set.\")\n\n        self.pkgname = pkgname\n        self.git_url = git_url\n        self.pkgbuild_directory = pkgbuild_directory\n\n    def parse(self, commands: AurCommands) -> PackageInfo:\n        \"\"\"\n        Parses this package's PKGBUILD to ``PackageInfo``.\n\n        If this fails, raises a ``PKGBUILDParseError``.\n        \"\"\"\n        if self.pkgbuild_directory is not None:\n            srcinfo = self._srcinfo_from_pkgbuild_directory(commands)\n        else:\n            srcinfo = self._srcinfo_from_git(commands)\n\n        return self._parse_srcinfo(srcinfo)\n\n    def __eq__(self, other: object) -> bool:\n        if not isinstance(other, CustomPackage):\n            return False\n        return (\n            self.git_url == other.git_url\n            and self.pkgbuild_directory == other.pkgbuild_directory\n            and self.pkgname == other.pkgname\n        )\n\n    def __hash__(self) -> int:\n        return hash((self.pkgname, self.git_url, self.pkgbuild_directory))\n\n    def __str__(self) -> str:\n        if self.git_url is not None:\n            return f\"CustomPackage(pkgname={self.pkgname}, git_url={self.git_url})\"\n        return (\n            f\"CustomPackage(pkgname={self.pkgname}, pkgbuild_directory={self.pkgbuild_directory})\"\n        )\n\n    def _srcinfo_from_pkgbuild_directory(self, commands: AurCommands) -> str:\n        assert self.pkgbuild_directory is not None, (\n            \"This will not get called if pkgbuild_directory is unset.\"\n        )\n\n        path = pathlib.Path(self.pkgbuild_directory)\n        if not path.is_dir():\n            raise PKGBUILDParseError(\n                self.git_url,\n                self.pkgbuild_directory,\n                f\"pkgbuild_directory '{path}' does not exist or is not a directory.\",\n            )\n\n        if not (path / \"PKGBUILD\").exists():\n            raise PKGBUILDParseError(\n                self.git_url, self.pkgbuild_directory, f\"No PKGBUILD found in '{path}'.\"\n            )\n\n        # Since makepkg cannot run as root even when just printing the SRCINFO,\n        # use a tmpdir and the user 'nobody'\n        try:\n            with tempfile.TemporaryDirectory(prefix=\"decman-pkgbuild-\") as tmpdir:\n                shutil.copytree(path, tmpdir, dirs_exist_ok=True)\n\n                # Allow the user 'nobody' to use this directory\n                mode = 0o777\n                for root, dirs, files in os.walk(tmpdir):\n                    for name in dirs + files:\n                        os.chmod(os.path.join(root, name), mode)\n                os.chmod(tmpdir, 0o777)\n\n                return self._run_makepkg_printsrcinfo(pathlib.Path(tmpdir), commands)\n        except OSError as error:\n            raise PKGBUILDParseError(\n                self.git_url,\n                self.pkgbuild_directory,\n                \"Failed to create temporary directory for the PKGBUILD.\",\n            ) from error\n\n    def _srcinfo_from_git(self, commands: AurCommands) -> str:\n        assert self.git_url is not None, \"This will not get called if git_url is unset.\"\n        try:\n            with tempfile.TemporaryDirectory(prefix=\"decman-pkgbuild-\") as tmpdir:\n                tmp_path = pathlib.Path(tmpdir)\n                # Allow the user 'nobody' to use this directory\n                os.chmod(tmpdir, 0o777)\n                try:\n                    cmd = commands.git_clone(self.git_url, tmpdir)\n                    # Use the user nobody, since that will be used later to generate SRCINFO\n                    command.prg(cmd, user=\"nobody\", pty=config.debug_output)\n                except errors.CommandFailedError as error:\n                    raise PKGBUILDParseError(\n                        self.git_url,\n                        self.pkgbuild_directory,\n                        \"Failed to clone PKGBUILD repository.\",\n                    ) from error\n\n                if not (tmp_path / \"PKGBUILD\").exists():\n                    raise PKGBUILDParseError(\n                        self.git_url,\n                        self.pkgbuild_directory,\n                        f\"Cloned repository '{self.git_url}' does not contain a PKGBUILD.\",\n                    )\n\n                return self._run_makepkg_printsrcinfo(tmp_path, commands)\n        except OSError as error:\n            raise PKGBUILDParseError(\n                self.git_url,\n                self.pkgbuild_directory,\n                \"Failed to create temporary directory for the PKGBUILD.\",\n            ) from error\n\n    def _run_makepkg_printsrcinfo(self, path: pathlib.Path, commands: AurCommands) -> str:\n        orig_wd = os.getcwd()\n        try:\n            os.chdir(path)\n            cmd = commands.print_srcinfo()\n            # No need to use the makepkg_user config option here.\n            # For just printing the SRCINFO, hardcoded 'nobody' works\n            srcinfo = command.prg(cmd, user=\"nobody\", pty=False)\n        except errors.CommandFailedError as error:\n            raise PKGBUILDParseError(\n                self.git_url, self.pkgbuild_directory, \"Failed to generate SRCINFO using makepkg.\"\n            ) from error\n        finally:\n            os.chdir(orig_wd)\n\n        return srcinfo\n\n    def _parse_srcinfo(self, srcinfo: str) -> PackageInfo:\n        pkgbase: str | None = None\n        pkgver: str | None = None\n        pkgrel: str | None = None\n        epoch: str | None = None\n        provides: list[str] = []\n\n        # I'm not sure if split packages can have dependencies listed in the base.\n        # Easy to handle regardless\n        base_depends: list[str] = []\n        base_makedepends: list[str] = []\n        base_checkdepends: list[str] = []\n\n        pkg_depends: list[str] = []\n        pkg_makedepends: list[str] = []\n        pkg_checkdepends: list[str] = []\n\n        current_pkg: str | None = None\n        found_pkgnames = set()\n\n        for raw in srcinfo.splitlines():\n            line = raw.strip()\n            if not line or line.startswith(\"#\") or \"=\" not in line:\n                continue\n\n            key, value = (part.strip() for part in line.split(\"=\", 1))\n\n            is_base = current_pkg is None\n            is_target_pkg = current_pkg == self.pkgname\n\n            match key:\n                case \"pkgbase\":\n                    pkgbase = value\n                    current_pkg = None\n\n                case \"pkgname\":\n                    current_pkg = value\n                    found_pkgnames.add(value)\n\n                case \"pkgver\":\n                    if pkgver is None or current_pkg == self.pkgname:\n                        pkgver = value\n\n                case \"pkgrel\":\n                    if pkgrel is None or current_pkg == self.pkgname:\n                        pkgrel = value\n\n                case \"epoch\":\n                    if epoch is None or current_pkg == self.pkgname:\n                        epoch = value\n\n                case \"provides\":\n                    if is_target_pkg:\n                        provides.append(value)\n\n                case \"depends\":\n                    if is_base:\n                        base_depends.append(value)\n                    elif is_target_pkg:\n                        pkg_depends.append(value)\n\n                case \"makedepends\":\n                    if is_base:\n                        base_makedepends.append(value)\n                    elif is_target_pkg:\n                        pkg_makedepends.append(value)\n\n                case \"checkdepends\":\n                    if is_base:\n                        base_checkdepends.append(value)\n                    elif is_target_pkg:\n                        pkg_checkdepends.append(value)\n\n                case _ if key.startswith(\"depends\") and key.removeprefix(\"depends_\") == config.arch:\n                    if is_base:\n                        base_depends.append(value)\n                    elif is_target_pkg:\n                        pkg_depends.append(value)\n\n                case _ if (\n                    key.startswith(\"makedepends\")\n                    and key.removeprefix(\"makedepends_\") == config.arch\n                ):\n                    if is_base:\n                        base_makedepends.append(value)\n                    elif is_target_pkg:\n                        pkg_makedepends.append(value)\n\n                case _ if (\n                    key.startswith(\"checkdepends\")\n                    and key.removeprefix(\"checkdepends_\") == config.arch\n                ):\n                    if is_base:\n                        base_checkdepends.append(value)\n                    elif is_target_pkg:\n                        pkg_checkdepends.append(value)\n\n        if pkgbase is None or pkgver is None:\n            raise PKGBUILDParseError(\n                self.git_url,\n                self.pkgbuild_directory,\n                \"Missing required fields (pkgbase/pkgver) in SRCINFO.\",\n            )\n\n        if self.pkgname not in found_pkgnames:\n            raise PKGBUILDParseError(\n                self.git_url,\n                self.pkgbuild_directory,\n                f\"Package {self.pkgname} not found in SRCINFO. \"\n                f\"Packages present: {' '.join(found_pkgnames)}.\",\n            )\n\n        version_core = pkgver\n        if pkgrel is not None:\n            version_core = f\"{version_core}-{pkgrel}\"\n\n        if epoch is not None:\n            version = f\"{epoch}:{version_core}\"\n        else:\n            version = version_core\n\n        return PackageInfo(\n            pkgname=self.pkgname,\n            pkgbase=pkgbase,\n            version=version,\n            git_url=self.git_url,\n            pkgbuild_directory=self.pkgbuild_directory,\n            provides=tuple(provides),\n            dependencies=tuple(base_depends + pkg_depends),\n            make_dependencies=tuple(base_makedepends + pkg_makedepends),\n            check_dependencies=tuple(base_checkdepends + pkg_checkdepends),\n        )\n\n\nclass PackageSearch:\n    \"\"\"\n    Allows searcing for packages / providers from the AUR as well as user defined sources.\n\n    Results are cached and custom packages are preferred.\n    \"\"\"\n\n    def __init__(self, aur_rpc_timeout: int = 30) -> None:\n        self._package_cache: dict[str, PackageInfo] = {}\n        self._selected_providers_cache: dict[str, PackageInfo] = {}\n        self._all_providers_cache: dict[str, list[str]] = {}\n        self._custom_packages: list[PackageInfo] = []\n        self._timeout = aur_rpc_timeout\n\n    def add_custom_pkg(self, user_pkg: PackageInfo):\n        \"\"\"\n        Adds the given package to custom packages.\n        \"\"\"\n        self._custom_packages.append(user_pkg)\n        self._cache_pkg(user_pkg)\n\n    def _cache_pkg(self, pkg: PackageInfo):\n        for provided_pkg in pkg.provides:\n            self._all_providers_cache.setdefault(provided_pkg, []).append(pkg.pkgname)\n\n        self._package_cache[pkg.pkgname] = pkg\n\n    def try_caching_packages(self, packages: list[str]):\n        \"\"\"\n        Tries caching the given packages. Virtual packages may not be cached.\n\n        This can be used before calling get_package_info or find_provider multiple individual\n        times, because then those methods don't have to make new AUR RPC requests.\n        \"\"\"\n\n        uncached_packages = list(filter(lambda p: p not in self._package_cache, packages))\n\n        if len(uncached_packages) == 0:\n            return\n\n        output.print_debug(f\"Trying to cache {uncached_packages}.\")\n\n        max_pkgs_per_request = 200\n\n        while uncached_packages:\n            to_request = map(lambda p: f\"arg[]={p}\", uncached_packages[:max_pkgs_per_request])\n            uncached_packages = uncached_packages[max_pkgs_per_request:]\n\n            url = f\"https://aur.archlinux.org/rpc/v5/info?{'&'.join(to_request)}\"\n            output.print_debug(f\"Request URL = {url}\")\n\n            try:\n                request = requests.get(url, timeout=self._timeout)\n                d = request.json()\n\n                if d[\"type\"] == \"error\":\n                    raise AurRPCError(f\"AUR RPC returned error: {d['error']}\", url)\n\n                for result in d[\"results\"]:\n                    pkgname = result[\"Name\"]\n\n                    if pkgname in self._package_cache:\n                        continue\n\n                    for user_package in self._custom_packages:\n                        if user_package.pkgname == pkgname:\n                            output.print_debug(f\"'{pkgname}' found in custom packages.\")\n                            self._cache_pkg(user_package)\n                            break\n                    else:  # if not in user_packages then:\n                        info = PackageInfo(\n                            pkgname=result[\"Name\"],\n                            pkgbase=result[\"PackageBase\"],\n                            version=result[\"Version\"],\n                            dependencies=result.get(\"Depends\", []),\n                            make_dependencies=result.get(\"MakeDepends\", []),\n                            check_dependencies=result.get(\"CheckDepends\", []),\n                            provides=result.get(\"Provides\", []),\n                            git_url=f\"https://aur.archlinux.org/{result['PackageBase']}.git\",\n                        )\n                        self._cache_pkg(info)\n\n                output.print_debug(\"Request completed.\")\n            except (requests.RequestException, KeyError) as e:\n                raise AurRPCError(\n                    f\"Failed to fetch package information for {uncached_packages} from AUR RPC.\",\n                    url,\n                ) from e\n\n    def get_package_info(self, package: str) -> PackageInfo | None:\n        \"\"\"\n        Returns information about a package.\n\n        If the package is not custom, fetches information from the AUR.\n        Returns None if no such AUR package exists.\n        \"\"\"\n        output.print_debug(f\"Getting info for package '{package}'.\")\n\n        if package in self._package_cache:\n            output.print_debug(f\"'{package}' found in cache.\")\n            return self._package_cache[package]\n\n        # This code is probably not needed since all user packages should be cached\n        for user_package in self._custom_packages:\n            if user_package.pkgname == package:\n                output.print_debug(f\"'{package}' found in custom packages.\")\n                self._cache_pkg(user_package)\n                return user_package\n\n        url = f\"https://aur.archlinux.org/rpc/v5/info/{package}\"\n        output.print_debug(f\"Requesting info for '{package}' from AUR. URL = {url}\")\n        try:\n            request = requests.get(url, timeout=self._timeout)\n            d = request.json()\n\n            if d[\"type\"] == \"error\":\n                raise AurRPCError(f\"AUR RPC returned error: {d['error']}\", url)\n\n            if d[\"resultcount\"] == 0:\n                output.print_debug(f\"'{package}' not found.\")\n                return None\n\n            output.print_debug(f\"'{package}' found from AUR.\")\n\n            result = d[\"results\"][0]\n            info = PackageInfo(\n                pkgname=result[\"Name\"],\n                pkgbase=result[\"PackageBase\"],\n                version=result[\"Version\"],\n                dependencies=result.get(\"Depends\", []),\n                make_dependencies=result.get(\"MakeDepends\", []),\n                check_dependencies=result.get(\"CheckDepends\", []),\n                provides=result.get(\"Provides\", []),\n                git_url=f\"https://aur.archlinux.org/{result['PackageBase']}.git\",\n            )\n\n            self._cache_pkg(info)\n\n            return info\n        except (requests.RequestException, KeyError) as e:\n            raise AurRPCError(\n                f\"Failed to fetch package information for {package} from AUR RPC.\",\n                url,\n            ) from e\n\n    def find_provider(self, stripped_dependency: str) -> PackageInfo | None:\n        \"\"\"\n        Finds a provider for a dependency. The dependency should not contain version constraints.\n\n        May prompt the user to select if multiple are available.\n        \"\"\"\n        output.print_debug(f\"Finding provider for '{stripped_dependency}'.\")\n\n        if stripped_dependency in self._selected_providers_cache:\n            output.print_debug(f\"'{stripped_dependency}' found in cache.\")\n            return self._selected_providers_cache[stripped_dependency]\n\n        output.print_debug(\"Are there exact name matches?\")\n\n        exact_name_match = self.get_package_info(stripped_dependency)\n\n        if exact_name_match is not None:\n            output.print_debug(\"Exact name match found.\")\n            self._selected_providers_cache[stripped_dependency] = exact_name_match\n            return exact_name_match\n\n        output.print_debug(\"No exact name matches found. Finding providers.\")\n\n        known_pkg_results = self._all_providers_cache.get(stripped_dependency, [])\n        for user_package in self._custom_packages:\n            if (\n                stripped_dependency in user_package.provides\n                and stripped_dependency not in known_pkg_results\n            ):\n                known_pkg_results.append(user_package.pkgname)\n\n        if len(known_pkg_results) == 1:\n            pkg = self.get_package_info(known_pkg_results[0])\n            assert pkg is not None\n            output.print_debug(\n                f\"Single provider for '{stripped_dependency}' found in known packages: '{pkg}'.\"\n            )\n            self._selected_providers_cache[stripped_dependency] = pkg\n            return pkg\n\n        if len(known_pkg_results) > 1:\n            return self._choose_provider(stripped_dependency, known_pkg_results, \"user packages\")\n\n        url = f\"https://aur.archlinux.org/rpc/v5/search/{stripped_dependency}?by=provides\"\n        output.print_debug(\n            f\"Requesting providers for '{stripped_dependency}' from AUR. URL = {url}\"\n        )\n        try:\n            request = requests.get(url, timeout=self._timeout)\n            d = request.json()\n\n            if d[\"type\"] == \"error\":\n                raise AurRPCError(f\"AUR RPC returned error: {d['error']}\", url)\n\n            if d[\"resultcount\"] == 0:\n                output.print_debug(f\"'{stripped_dependency}' not found.\")\n                return None\n\n            results = list(map(lambda r: r[\"Name\"], d[\"results\"]))\n\n            if len(results) == 1:\n                pkgname = results[0]\n                output.print_debug(\n                    f\"Single provider for '{stripped_dependency}' found from AUR: '{pkgname}'\"\n                )\n                info = self.get_package_info(pkgname)\n                return info\n\n            return self._choose_provider(stripped_dependency, results, \"AUR\")\n        except (requests.RequestException, KeyError) as e:\n            raise AurRPCError(\n                f\"Failed to search for {stripped_dependency} from AUR RPC.\",\n                url,\n            ) from e\n\n    def _choose_provider(\n        self, dep: str, possible_providers: list[str], where: str\n    ) -> PackageInfo | None:\n        min_selection = 1\n        max_selection = len(possible_providers)\n        output.print_summary(f\"Found {len(possible_providers)} providers for {dep} from {where}.\")\n\n        providers = \"Providers: \"\n        for index, name in enumerate(possible_providers):\n            providers += f\"{index + 1}:{name} \"\n        output.print_summary(providers)\n\n        selection = output.prompt_number(\n            f\"Select a provider [{min_selection}-{max_selection}] (default: {min_selection}): \",\n            min_selection,\n            max_selection,\n            default=min_selection,\n        )\n\n        info = self.get_package_info(possible_providers[selection - 1])\n        if info is not None:\n            self._selected_providers_cache[dep] = info\n        return info\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/resolver.py",
    "content": "import typing\n\nfrom decman.plugins.aur.error import DependencyCycleError\n\n\nclass ForeignPackage:\n    \"\"\"\n    Class used to keep track of foreign recursive dependency packages of an foreign package.\n    \"\"\"\n\n    def __init__(self, name: str):\n        self.name = name\n        self._all_recursive_foreign_deps: set[str] = set()\n\n    def __eq__(self, value: object, /) -> bool:\n        if isinstance(value, self.__class__):\n            return (\n                self.name == value.name\n                and self._all_recursive_foreign_deps == value._all_recursive_foreign_deps\n            )\n        return False\n\n    def __hash__(self) -> int:\n        return self.name.__hash__()\n\n    def __repr__(self) -> str:\n        return f\"{self.name}: {{{' '.join(self._all_recursive_foreign_deps)}}}\"\n\n    def __str__(self) -> str:\n        return f\"{self.name}\"\n\n    def add_foreign_dependency_packages(self, package_names: typing.Iterable[str]):\n        \"\"\"\n        Adds dependencies to the package.\n        \"\"\"\n        self._all_recursive_foreign_deps.update(package_names)\n\n    def get_all_recursive_foreign_dep_pkgs(self) -> set[str]:\n        \"\"\"\n        Returns all dependencies and sub-dependencies of the package.\n        \"\"\"\n        return set(self._all_recursive_foreign_deps)\n\n\nclass DepNode:\n    \"\"\"\n    A Node of the DepGraph\n    \"\"\"\n\n    def __init__(self, package: ForeignPackage) -> None:\n        self.parents: dict[str, DepNode] = {}\n        self.children: dict[str, DepNode] = {}\n        self.pkg = package\n\n    def is_pkgname_in_parents_recursive(self, pkgname: str) -> bool:\n        \"\"\"\n        Returns True if the given package name is in the parents of this DepNode.\n        \"\"\"\n        for name, parent in self.parents.items():\n            if name == pkgname or parent.is_pkgname_in_parents_recursive(pkgname):\n                return True\n        return False\n\n\nclass DepGraph:\n    \"\"\"\n    Represents a graph between foreign packages\n    \"\"\"\n\n    def __init__(self) -> None:\n        self.package_nodes: dict[str, DepNode] = {}\n        self._childless_node_names: set[str] = set()\n\n    def add_requirement(self, child_pkgname: str, parent_pkgname: typing.Optional[str]):\n        \"\"\"\n        Adds a connection between two packages, creating the child package if it doesn't exist.\n\n        The parent is the package that requires the child package.\n        \"\"\"\n        child_node = self.package_nodes.get(child_pkgname, DepNode(ForeignPackage(child_pkgname)))\n        self.package_nodes[child_pkgname] = child_node\n\n        if len(child_node.children) == 0:\n            self._childless_node_names.add(child_pkgname)\n\n        if parent_pkgname is None:\n            return\n\n        parent_node = self.package_nodes[parent_pkgname]\n\n        if parent_node.is_pkgname_in_parents_recursive(child_pkgname):\n            raise DependencyCycleError(child_pkgname, parent_pkgname)\n\n        parent_node.children[child_pkgname] = child_node\n        child_node.parents[parent_pkgname] = parent_node\n\n        if parent_pkgname in self._childless_node_names:\n            self._childless_node_names.remove(parent_pkgname)\n\n    def get_and_remove_outer_dep_pkgs(self) -> list[ForeignPackage]:\n        \"\"\"\n        Returns all childless nodes of the dependency package graph and removes them.\n        \"\"\"\n        new_childless_node_names = set()\n        result = []\n        for childless_node_name in self._childless_node_names:\n            childless_node = self.package_nodes[childless_node_name]\n\n            for parent in childless_node.parents.values():\n                new_deps = childless_node.pkg.get_all_recursive_foreign_dep_pkgs()\n                new_deps.add(childless_node.pkg.name)\n                parent.pkg.add_foreign_dependency_packages(new_deps)\n                del parent.children[childless_node_name]\n                if len(parent.children) == 0:\n                    new_childless_node_names.add(parent.pkg.name)\n\n            result.append(childless_node.pkg)\n        self._childless_node_names = new_childless_node_names\n        return result\n"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/pacman.py",
    "content": "import re\nimport shutil\nfrom typing import Callable\n\nimport pyalpm\n\nimport decman.config as config\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\nimport decman.plugins as plugins\n\n\ndef packages(fn):\n    \"\"\"\n    Annotate that this function returns a set of pacman package names that should be installed.\n\n    Return type of ``fn``: ``set[str]``\n    \"\"\"\n    fn.__pacman__packages__ = True\n    return fn\n\n\ndef strip_dependency(dep: str) -> str:\n    \"\"\"\n    Removes version spefications from a dependency name.\n    \"\"\"\n    rx = re.compile(\"(=.*|>.*|<.*)\")\n    return rx.sub(\"\", dep)\n\n\nclass Pacman(plugins.Plugin):\n    \"\"\"\n    Plugin that manages pacman packages added directly to ``packages`` or declared by modules via\n    ``@packages``.\n    \"\"\"\n\n    NAME = \"pacman\"\n\n    def __init__(self) -> None:\n        self.packages: set[str] = set()\n        self.ignored_packages: set[str] = set()\n        self.commands = PacmanCommands()\n        self.print_highlights = True\n        self.keywords = {\n            \"pacsave\",\n            \"pacnew\",\n            # These cause too many false positives IMO\n            # \"warning\",\n            # \"error\",\n            # \"note\",\n        }\n        self.database_signature_level = pyalpm.SIG_DATABASE_OPTIONAL\n        self.database_path = \"/var/lib/pacman/\"\n\n    def available(self) -> bool:\n        return shutil.which(\"pacman\") is not None\n\n    def process_modules(self, store: _store.Store, modules: list[module.Module]):\n        # This is used to track changes in modules.\n        store.ensure(\"packages_for_module\", {})\n\n        for mod in modules:\n            store[\"packages_for_module\"].setdefault(mod.name, set())\n\n            packages = set().union(*plugins.run_methods_with_attribute(mod, \"__pacman__packages__\"))\n\n            if store[\"packages_for_module\"][mod.name] != packages:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified pacman packages.\"\n                )\n\n            self.packages |= packages\n\n            store[\"packages_for_module\"][mod.name] = packages\n\n    def apply(\n        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None\n    ) -> bool:\n        try:\n            pm = PacmanInterface(\n                self.commands,\n                self.print_highlights,\n                self.keywords,\n                self.database_signature_level,\n                self.database_path,\n            )\n\n            currently_installed_native = pm.get_native_explicit()\n            currently_installed_foreign = pm.get_foreign_explicit()\n            orphans = pm.get_native_orphans()\n            to_remove = (\n                (currently_installed_native | orphans) - self.packages - self.ignored_packages\n            )\n\n            actually_to_remove = set()\n            to_set_as_dependencies = set()\n\n            dependants_to_keep = self.packages | currently_installed_foreign\n            for package in to_remove:\n                dependants = pm.get_dependants(package)\n                if dependants & dependants_to_keep:\n                    to_set_as_dependencies.add(package)\n                else:\n                    actually_to_remove.add(package)\n\n            if actually_to_remove:\n                output.print_list(\"Removing pacman packages:\", sorted(actually_to_remove))\n                if not dry_run:\n                    pm.remove(actually_to_remove)\n\n            if to_set_as_dependencies:\n                output.print_list(\n                    \"Setting previously explicitly installed packages as dependencies:\",\n                    sorted(to_set_as_dependencies),\n                )\n                if not dry_run:\n                    pm.set_as_dependencies(to_set_as_dependencies)\n\n            output.print_summary(\"Upgrading packages.\")\n            if not dry_run:\n                pm.upgrade()\n\n            to_install = self.packages - currently_installed_native - self.ignored_packages\n            output.print_list(\"Installing pacman packages:\", sorted(to_install))\n\n            if not dry_run:\n                pm.install(to_install)\n        except pyalpm.error as error:\n            output.print_error(\"Failed to query pacman databases with pyalpm.\")\n            output.print_error(str(error))\n            output.print_traceback()\n            return False\n        except errors.CommandFailedError as error:\n            output.print_error(\n                \"Pacman command exited with an unexpected return code. You may have cancelled a \"\n                \"pacman operation.\"\n            )\n            output.print_error(str(error))\n            if error.output:\n                output.print_command_output(error.output)\n            output.print_traceback()\n            return False\n        return True\n\n\nclass PacmanCommands:\n    def list_pacman_repos(self) -> list[str]:\n        \"\"\"\n        Running this command prints a newline seperated list of pacman repositories.\n        \"\"\"\n        return [\"pacman-conf\", \"--repo-list\"]\n\n    def install(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command installs the given packages from pacman repositories.\n        \"\"\"\n        return [\"pacman\", \"-S\", \"--needed\"] + list(pkgs)\n\n    def upgrade(self) -> list[str]:\n        \"\"\"\n        Running this command upgrades all pacman packages from pacman repositories.\n        \"\"\"\n        return [\"pacman\", \"-Syu\"]\n\n    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command sets the given packages as dependencies.\n        \"\"\"\n        return [\"pacman\", \"-D\", \"--asdeps\"] + list(pkgs)\n\n    def set_as_explicit(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command sets the given as explicitly installed.\n        \"\"\"\n        return [\"pacman\", \"-D\", \"--asexplicit\"] + list(pkgs)\n\n    def remove(self, pkgs: set[str]) -> list[str]:\n        \"\"\"\n        Running this command removes the given packages and their dependencies\n        (that aren't required by other packages).\n        \"\"\"\n        return [\"pacman\", \"-Rs\"] + list(pkgs)\n\n\nclass PacmanInterface:\n    \"\"\"\n    High level interface for running pacman commands.\n\n    On failure methods raise a ``CommandFailedError`` or ``pyalpm.error``.\n    \"\"\"\n\n    def __init__(\n        self,\n        commands: PacmanCommands,\n        print_highlights: bool,\n        keywords: set[str],\n        dbsiglevel: int,\n        dbpath: str,\n    ) -> None:\n        self._commands = commands\n        self._print_highlights = print_highlights\n        self._keywords = keywords\n        self._dbsiglevel = dbsiglevel\n        self._dbpath = dbpath\n        self._handle = self._create_pyalpm_handle()\n        self._name_index = self._create_name_index()\n        self._local_provides_index = self._create_local_provides_index()\n        self._provides_index = self._create_provides_index()\n        self._requiredby_index = self._create_requiredby_index()\n\n    def _create_pyalpm_handle(self):\n        root = \"/\"\n\n        h = pyalpm.Handle(root, self._dbpath)\n\n        cmd = self._commands.list_pacman_repos()\n        repos = command.prg(cmd, pty=False).strip().split(\"\\n\")\n\n        # Empty string means no DBs\n        if \"\" in repos and len(repos) == 1:\n            return\n\n        for repo in repos:\n            h.register_syncdb(repo, self._dbsiglevel)\n\n        return h\n\n    def _create_name_index(self) -> dict[str, pyalpm.Package]:\n        return {pkg.name: pkg for db in self._handle.get_syncdbs() for pkg in db.pkgcache}\n\n    def _create_local_provides_index(self) -> dict[str, set[str]]:\n        out: dict[str, set[str]] = {}\n        for pkg in self._handle.get_localdb().pkgcache:\n            for p in pkg.provides:\n                out.setdefault(strip_dependency(p), set()).add(pkg.name)\n                out.setdefault(p, set()).add(pkg.name)\n        return out\n\n    def _create_provides_index(self) -> dict[str, set[str]]:\n        out: dict[str, set[str]] = {}\n        for db in self._handle.get_syncdbs():\n            for pkg in db.pkgcache:\n                for p in pkg.provides:\n                    out.setdefault(strip_dependency(p), set()).add(pkg.name)\n                    out.setdefault(p, set()).add(pkg.name)\n        return out\n\n    def _create_requiredby_index(self) -> dict[str, set[str]]:\n        return {p.name: set(p.compute_requiredby()) for p in self._handle.get_localdb().pkgcache}\n\n    def _is_native(self, package: str) -> bool:\n        return package in self._name_index\n\n    def _is_foreign(self, package: str) -> bool:\n        return not self._is_native(package)\n\n    def get_all_packages(self) -> set[str]:\n        \"\"\"\n        Returns a set of all installed packages.\n        \"\"\"\n        return {pkg for pkg in self._handle.get_localdb().pkgcache}\n\n    def get_native_explicit(self) -> set[str]:\n        \"\"\"\n        Returns a set of explicitly installed native packages.\n        \"\"\"\n        out: set[str] = set()\n        for pkg in self._handle.get_localdb().pkgcache:\n            if pkg.reason == pyalpm.PKG_REASON_EXPLICIT and self._is_native(pkg.name):\n                out.add(pkg.name)\n        return out\n\n        return packages\n\n    def _get_orphans(self, filter_fn: Callable[[\"PacmanInterface\", str], bool]) -> set[str]:\n        orphans: set[str] = {\n            p.name\n            for p in self._handle.get_localdb().pkgcache\n            if p.reason == pyalpm.PKG_REASON_DEPEND and filter_fn(self, p.name)\n        }\n\n        # Prune orphans until there are only packages that are requiredby other orphans\n        changed = True\n        while changed:\n            changed = False\n            for name in tuple(orphans):\n                if self._requiredby_index.get(name, set()) - orphans:\n                    orphans.remove(name)\n                    changed = True\n        return orphans\n\n    def get_native_orphans(self) -> set[str]:\n        \"\"\"\n        Returns a set of orphaned native packages.\n        \"\"\"\n        return self._get_orphans(PacmanInterface._is_native)\n\n    def get_foreign_explicit(self) -> set[str]:\n        \"\"\"\n        Returns a set of explicitly installed foreign packages.\n        \"\"\"\n        out: set[str] = set()\n        for pkg in self._handle.get_localdb().pkgcache:\n            if pkg.reason == pyalpm.PKG_REASON_EXPLICIT and not self._is_native(pkg.name):\n                out.add(pkg.name)\n        return out\n\n    def get_dependants(self, package: str) -> set[str]:\n        \"\"\"\n        Returns a set of installed packages that depend on the given package.\n        Includes the package itself.\n        \"\"\"\n        local = self._handle.get_localdb()\n\n        seen: set[str] = set()\n        stack = [package]\n\n        while stack:\n            name = stack.pop()\n            if name in seen:\n                continue\n            seen.add(name)\n\n            pkg = local.get_pkg(name)\n            if pkg is None:\n                continue\n\n            for dep in pkg.compute_requiredby():\n                if dep not in seen:\n                    stack.append(dep)\n\n        return seen\n\n    def set_as_dependencies(self, packages: set[str]):\n        \"\"\"\n        Marks the given packages as dependency packages.\n        \"\"\"\n        if not packages:\n            return\n\n        cmd = self._commands.set_as_dependencies(packages)\n        command.prg(cmd, pty=config.debug_output)\n\n    def install(self, packages: set[str]):\n        \"\"\"\n        Installs the given packages. If the packages are already installed, marks them as\n        explicitly installed.\n        \"\"\"\n        if not packages:\n            return\n\n        cmd = self._commands.install(packages)\n\n        pacman_output = command.prg(cmd)\n        self.print_highlighted_pacman_messages(pacman_output)\n\n        cmd = self._commands.set_as_explicit(packages)\n        command.prg(cmd, pty=config.debug_output)\n\n    def upgrade(self):\n        \"\"\"\n        Upgrades all packages.\n        \"\"\"\n        cmd = self._commands.upgrade()\n        pacman_output = command.prg(cmd)\n        self.print_highlighted_pacman_messages(pacman_output)\n\n    def remove(self, packages: set[str]):\n        \"\"\"\n        Removes the given packages.\n        \"\"\"\n        if not packages:\n            return\n\n        cmd = self._commands.remove(packages)\n        pacman_output = command.prg(cmd)\n        self.print_highlighted_pacman_messages(pacman_output)\n\n    def print_highlighted_pacman_messages(self, pacman_output: str):\n        \"\"\"\n        Prints lines that contain pacman output keywords.\n        \"\"\"\n        if not self._print_highlights:\n            return\n\n        lines = pacman_output.split(\"\\n\")\n        highlight_lines = []\n        for index, line in enumerate(lines):\n            for keyword in self._keywords:\n                if keyword.lower() in line.lower():\n                    highlight_lines.append(f\"lines: {index}-{index + 2}\")\n                    if index >= 1:\n                        highlight_lines.append(lines[index - 1])\n                    highlight_lines.append(line)\n                    if index + 1 < len(lines):\n                        highlight_lines.append(lines[index + 1])\n                    highlight_lines.append(\"\")\n\n                    # Break, as to not print the same line again if it contains multiple keywords\n                    break\n\n        if highlight_lines:\n            output.print_summary(\"Pacman output highlights:\")\n            for line in highlight_lines:\n                if line.startswith(\"lines:\"):\n                    output.print_summary(line)\n                else:\n                    output.print_continuation(line)\n"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_aur.py",
    "content": "from typing import Any\n\nimport pytest\n\nfrom decman.plugins import aur as aur_plugin\n\n\nclass FakeStore(dict):\n    def ensure(self, key: str, default: Any) -> None:\n        if key not in self:\n            self[key] = default\n\n\nclass FakeModule:\n    def __init__(self, name: str, aur_pkgs: set[str], custom_pkgs: set[Any]) -> None:\n        self.name = name\n        self._changed = False\n        self._aur_pkgs = aur_pkgs\n        self._custom_pkgs = custom_pkgs\n\n\nclass FakeCustomPackage:\n    def __init__(self, pkgname: str) -> None:\n        self.pkgname = pkgname\n\n    def __hash__(self) -> int:  # needed because instances go into sets\n        return hash(self.pkgname)\n\n    def __eq__(self, other: object) -> bool:\n        return isinstance(other, FakeCustomPackage) and self.pkgname == other.pkgname\n\n    def parse(self, commands: Any) -> str:\n        # Whatever ForeignPackageManager expects; we just need something to feed into add_custom_pkg\n        return f\"parsed-{self.pkgname}\"\n\n\ndef test_process_modules_collects_aur_and_custom_packages_and_marks_changed(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    aur = aur_plugin.AUR()\n    store = FakeStore()\n\n    cp1 = FakeCustomPackage(\"custom1\")\n    cp2 = FakeCustomPackage(\"custom2\")\n\n    mod1 = FakeModule(\"mod1\", {\"aur1\", \"aur2\"}, {cp1})\n    mod2 = FakeModule(\"mod2\", {\"aur3\"}, {cp2})\n\n    def fake_run_methods_with_attribute(mod: FakeModule, attr: str):\n        if attr == \"__aur__packages__\":\n            return [mod._aur_pkgs]\n        if attr == \"__custom__packages__\":\n            return [mod._custom_pkgs]\n        return []\n\n    monkeypatch.setattr(\n        aur_plugin.plugins, \"run_methods_with_attribute\", fake_run_methods_with_attribute\n    )\n\n    aur.process_modules(store, {mod1, mod2})\n\n    # union of all aur/custom packages collected\n    assert aur.packages == {\"aur1\", \"aur2\", \"aur3\"}\n    assert aur.custom_packages == {cp1, cp2}\n\n    # stored per-module\n    assert store[\"aur_packages_for_module\"][\"mod1\"] == {\"aur1\", \"aur2\"}\n    assert store[\"aur_packages_for_module\"][\"mod2\"] == {\"aur3\"}\n    assert store[\"custom_packages_for_module\"][\"mod1\"] == {str(cp1)}\n    assert store[\"custom_packages_for_module\"][\"mod2\"] == {str(cp2)}\n\n    # first run: modules marked changed\n    assert mod1._changed is True\n    assert mod2._changed is True\n\n\ndef test_apply_respects_ignored_packages_and_protects_their_dependencies(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    aur = aur_plugin.AUR()\n    store = FakeStore()\n\n    # Desired AUR/custom state\n    aur.packages = {\"desired-aur\"}\n    cp = FakeCustomPackage(\"custom-aur\")\n    aur.custom_packages = {cp}\n\n    # Ignored foreign package (installed) and an ignored but *uninstalled* package\n    aur.ignored_packages = {\"ignored-aur\", \"ignored-not-installed\"}\n\n    # Fake PackageSearch\n    class FakePackageSearch:\n        def __init__(self, timeout: int) -> None:\n            self.timeout = timeout\n            self.added: list[Any] = []\n\n        def add_custom_pkg(self, parsed: Any) -> None:\n            self.added.append(parsed)\n\n    monkeypatch.setattr(aur_plugin, \"PackageSearch\", FakePackageSearch)\n    monkeypatch.setattr(aur_plugin.os, \"makedirs\", lambda *x, **kw: None)\n\n    # Fake pacman interface for foreign/native info\n    class FakePM:\n        def __init__(self, commands, print_highlights, keywords, dbsiglevel, dbpath) -> None:\n            self.commands = commands\n            self.print_highlights = print_highlights\n            self.keywords = keywords\n\n            self.remove_called_with: set[str] | None = None\n            self.set_as_deps_called_with: set[str] | None = None\n\n        def get_native_explicit(self) -> set[str]:\n            # no natives needed for this scenario\n            return set()\n\n        def get_foreign_explicit(self) -> set[str]:\n            # All explicitly installed foreign packages:\n            # - ignored-aur           (ignored, must stay and protect deps)\n            # - dep-of-ignored        (candidate; has ignored dependant)\n            # - orphan-foreign        (candidate; no dependants)\n            return {\"ignored-aur\", \"dep-of-ignored\", \"orphan-foreign\"}\n\n        def get_foreign_orphans(self) -> set[str]:\n            # orphan-foreign also considered orphan\n            return {\"orphan-foreign\"}\n\n        def get_dependants(self, pkg: str) -> set[str]:\n            if pkg == \"dep-of-ignored\":\n                # ignored-aur depends on dep-of-ignored -> must demote, not remove\n                return {\"ignored-aur\"}\n            if pkg == \"orphan-foreign\":\n                return set()\n            return set()\n\n        def remove(self, pkgs: set[str]) -> None:\n            self.remove_called_with = pkgs\n\n        def set_as_dependencies(self, pkgs: set[str]) -> None:\n            self.set_as_deps_called_with = pkgs\n\n    fake_pm = FakePM(None, None, None, None, None)\n\n    def fake_pm_ctor(\n        commands,\n        print_highlights,\n        keywords,\n        dbsiglevel,\n        dbpath,\n    ) -> FakePM:\n        fake_pm.commands = commands\n        fake_pm.print_highlights = print_highlights\n        fake_pm.keywords = keywords\n        return fake_pm\n\n    monkeypatch.setattr(aur_plugin, \"AurPacmanInterface\", fake_pm_ctor)\n\n    # Fake ForeignPackageManager\n    class FakeFPM:\n        def __init__(\n            self,\n            store_arg,\n            pm_arg,\n            package_search_arg,\n            commands_arg,\n            cache_dir,\n            build_dir,\n            makepkg_user,\n        ) -> None:\n            self.store = store_arg\n            self.pm = pm_arg\n            self.package_search = package_search_arg\n            self.commands = commands_arg\n            self.cache_dir = cache_dir\n            self.build_dir = build_dir\n            self.makepkg_user = makepkg_user\n\n            self.upgrade_args: tuple[bool, bool, set[str]] | None = None\n            self.install_called_with: list[str] | None = None\n\n        def upgrade(self, upgrade_devel: bool, force: bool, ignored: set[str]) -> None:\n            self.upgrade_args = (upgrade_devel, force, ignored)\n\n        def install(self, pkgs: list[str], force: bool = False) -> None:\n            # store as set to ignore ordering\n            self.install_called_with = pkgs\n\n    fake_fpm = FakeFPM(None, None, None, None, None, None, None)\n\n    def fake_fpm_ctor(\n        store_arg,\n        pm_arg,\n        package_search_arg,\n        commands_arg,\n        cache_dir,\n        build_dir,\n        makepkg_user,\n    ):\n        fake_fpm.store = store_arg\n        fake_fpm.pm = pm_arg\n        fake_fpm.package_search = package_search_arg\n        fake_fpm.commands = commands_arg\n        fake_fpm.cache_dir = cache_dir\n        fake_fpm.build_dir = build_dir\n        fake_fpm.makepkg_user = makepkg_user\n        return fake_fpm\n\n    monkeypatch.setattr(aur_plugin, \"ForeignPackageManager\", fake_fpm_ctor)\n\n    printed_lists: list[tuple[str, list[str]]] = []\n    printed_summaries: list[str] = []\n\n    def fake_print_list(title: str, items: list[str]) -> None:\n        printed_lists.append((title, items))\n\n    def fake_print_summary(msg: str) -> None:\n        printed_summaries.append(msg)\n\n    monkeypatch.setattr(aur_plugin.output, \"print_list\", fake_print_list)\n    monkeypatch.setattr(aur_plugin.output, \"print_summary\", fake_print_summary)\n\n    # Use params to test flag propagation into upgrade/install\n    ok = aur.apply(store, dry_run=False, params=[\"aur-upgrade-devel\", \"aur-force\"])\n\n    assert ok is True\n\n    # Removal / demotion logic:\n    #\n    # custom_package_names = {\"custom-aur\"}\n    # currently_installed_foreign = {\"ignored-aur\", \"dep-of-ignored\", \"orphan-foreign\"}\n    # orphans = {\"orphan-foreign\"}\n    #\n    # to_remove candidates:\n    #   (foreign | orphans) - desired - custom - ignored\n    # = {\"ignored-aur\", \"dep-of-ignored\", \"orphan-foreign\"} ∪ {\"orphan-foreign\"}\n    #   - {\"desired-aur\"} - {\"custom-aur\"} - {\"ignored-aur\"}\n    # = {\"dep-of-ignored\", \"orphan-foreign\"}\n    #\n    # dependants_to_keep includes ignored installed foreign -> dep-of-ignored is demoted, orphan-foreign removed.\n\n    assert fake_pm.remove_called_with == {\"orphan-foreign\"}\n    assert fake_pm.set_as_deps_called_with == {\"dep-of-ignored\"}\n\n    # Ensure ignored packages were not removed\n    assert \"ignored-aur\" not in (fake_pm.remove_called_with or set())\n\n    # Upgrade called with flags and ignored set\n    assert fake_fpm.upgrade_args == (\n        True,\n        True,\n        aur.ignored_packages | (fake_pm.remove_called_with or set()),\n    )\n\n    # to_install = (packages | custom_names) - installed_foreign - ignored\n    #            = {\"desired-aur\", \"custom-aur\"} - {\"ignored-aur\", \"dep-of-ignored\", \"orphan-foreign\"}\n    #              - {\"ignored-aur\", \"ignored-not-installed\"}\n    #            = {\"desired-aur\", \"custom-aur\"}\n    assert set(fake_fpm.install_called_with or []) == {\"desired-aur\", \"custom-aur\"}\n    # ignored packages must not be installed\n    assert \"ignored-aur\" not in (fake_fpm.install_called_with or [])\n    assert \"ignored-not-installed\" not in (fake_fpm.install_called_with or [])\n\n    # Also check the printed lists mirror this\n    titles = [t for t, _ in printed_lists]\n    assert \"Removing foreign packages:\" in titles\n    assert \"Setting previously explicitly installed foreign packages as dependencies:\" in titles\n    assert \"Installing foreign packages:\" in titles\n\n    remove_list = next(items for t, items in printed_lists if \"Removing foreign packages:\" in t)\n    demote_list = next(\n        items\n        for t, items in printed_lists\n        if \"Setting previously explicitly installed foreign packages as dependencies:\" in t\n    )\n    install_list = next(items for t, items in printed_lists if \"Installing foreign packages:\" in t)\n\n    assert remove_list == [\"orphan-foreign\"]\n    assert demote_list == [\"dep-of-ignored\"]\n    # Order of install_list is deterministic because sorted() is used\n    assert install_list == [\"custom-aur\", \"desired-aur\"]\n    assert any(\"Upgrading foreign packages.\" in s for s in printed_summaries)\n\n\ndef test_apply_returns_false_on_aur_rpc_error(monkeypatch: pytest.MonkeyPatch) -> None:\n    aur = aur_plugin.AUR()\n    store = FakeStore()\n\n    # Force PackageSearch to fail immediately\n    class FailingPackageSearch:\n        def __init__(self, timeout: int) -> None:\n            raise aur_plugin.AurRPCError(\"RPC down\", \"url\")\n\n    monkeypatch.setattr(aur_plugin, \"PackageSearch\", FailingPackageSearch)\n    monkeypatch.setattr(aur_plugin.os, \"makedirs\", lambda *x, **kw: None)\n\n    errors_logged: list[str] = []\n    continuations: list[str] = []\n    traceback_called: list[bool] = []\n\n    def fake_print_error(msg: str) -> None:\n        errors_logged.append(msg)\n\n    def fake_print_traceback() -> None:\n        traceback_called.append(True)\n\n    monkeypatch.setattr(aur_plugin.output, \"print_error\", fake_print_error)\n    monkeypatch.setattr(aur_plugin.output, \"print_traceback\", fake_print_traceback)\n\n    ok = aur.apply(store, dry_run=False)\n\n    assert ok is False\n    assert any(\"AUR RPC\" in msg or \"fetch data from AUR RPC\" in msg for msg in errors_logged)\n    assert any(\"RPC down\" in msg for msg in errors_logged)\n    assert traceback_called\n"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_aur_package.py",
    "content": "import pathlib\n\nimport pytest\nfrom decman.plugins.aur import package as pkg_mod\nfrom decman.plugins.aur.error import AurRPCError, PKGBUILDParseError\nfrom decman.plugins.aur.package import (\n    CustomPackage,\n    PackageInfo,\n    PackageSearch,\n)\n\n\n@pytest.fixture(autouse=True)\ndef silence_output(monkeypatch):\n    # Avoid real I/O / prompts in tests by default\n    monkeypatch.setattr(pkg_mod.output, \"print_debug\", lambda *a, **k: None)\n    monkeypatch.setattr(pkg_mod.output, \"print_summary\", lambda *a, **k: None)\n    monkeypatch.setattr(\n        pkg_mod.output,\n        \"prompt_number\",\n        lambda *a, **k: 1,  # safe default\n    )\n\n\n# --- PackageInfo -----------------------------------------------------------\n\n\ndef test_packageinfo_requires_exactly_one_source():\n    with pytest.raises(ValueError, match=\"cannot be None\"):\n        PackageInfo(pkgname=\"a\", pkgbase=\"a\", version=\"1.0\")\n\n    with pytest.raises(ValueError, match=\"cannot be set\"):\n        PackageInfo(\n            pkgname=\"a\",\n            pkgbase=\"a\",\n            version=\"1.0\",\n            git_url=\"git://example\",\n            pkgbuild_directory=\"/tmp\",\n        )\n\n\nclass DummyPacman:\n    def __init__(self, installable: set[str]):\n        self._installable = installable\n        self.calls: list[str] = []\n\n    def is_installable(self, name: str) -> bool:\n        self.calls.append(name)\n        return name in self._installable\n\n\ndef _make_pkg_for_deps() -> PackageInfo:\n    return PackageInfo(\n        pkgname=\"pkg\",\n        pkgbase=\"pkg\",\n        version=\"1.0\",\n        git_url=\"git://example\",\n        dependencies=(\"native>=1\", \"foreign=2\"),\n        make_dependencies=(\"make-native\", \"make-foreign>=3\"),\n        check_dependencies=(\"check-foreign<4\", \"check-native\"),\n    )\n\n\ndef test_packageinfo_foreign_and_native_dependencies_are_split_and_stripped():\n    pacman = DummyPacman(\n        {\n            \"native>=1\",\n            \"make-native\",\n            \"check-native\",\n        }\n    )\n    pkg = _make_pkg_for_deps()\n\n    assert pkg.native_dependencies(pacman) == [\"native\"]\n    assert pkg.foreign_dependencies(pacman) == [\"foreign\"]\n    assert pkg.native_make_dependencies(pacman) == [\"make-native\"]\n    assert pkg.foreign_make_dependencies(pacman) == [\"make-foreign\"]\n    assert pkg.native_check_dependencies(pacman) == [\"check-native\"]\n    assert pkg.foreign_check_dependencies(pacman) == [\"check-foreign\"]\n\n\n# --- CustomPackage ---------------------------------------------------------\n\n\ndef test_custompackage_requires_exactly_one_source():\n    with pytest.raises(ValueError, match=\"cannot be None\"):\n        CustomPackage(\"pkg\", git_url=None, pkgbuild_directory=None)\n\n    with pytest.raises(ValueError, match=\"cannot be set\"):\n        CustomPackage(\"pkg\", git_url=\"git://example\", pkgbuild_directory=\"/tmp\")\n\n\nclass DummyCommands:\n    \"\"\"Minimal stub; only here so type checks pass where needed.\"\"\"\n\n    pass\n\n\n@pytest.mark.parametrize(\n    \"srcinfo, expected_version\",\n    [\n        (\n            \"\"\"\n            pkgbase = foo\n                pkgver = 1.2.3\n                pkgrel = 4\n            pkgname = foo\n            \"\"\",\n            \"1.2.3-4\",\n        ),\n        (\n            \"\"\"\n            pkgbase = foo\n                pkgver = 1.2.3\n                pkgrel = 4\n                epoch = 2\n            pkgname = foo\n            \"\"\",\n            \"2:1.2.3-4\",\n        ),\n        (\n            \"\"\"\n            pkgbase = foo\n                pkgver = 1.2.3\n            pkgname = foo\n            \"\"\",\n            \"1.2.3\",\n        ),\n    ],\n)\ndef test_parse_srcinfo_version_handling(srcinfo: str, expected_version: str) -> None:\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=\"/dummy\")\n\n    info = pkg._parse_srcinfo(srcinfo)\n\n    assert info.pkgname == \"foo\"\n    assert info.pkgbase == \"foo\"\n    assert info.version == expected_version\n\n\ndef test_parse_srcinfo_single_package_dependencies() -> None:\n    srcinfo = \"\"\"\n    pkgbase = foo\n        pkgver = 1.2.3\n        pkgrel = 1\n        depends = bar>=1.0\n        makedepends = baz\n        checkdepends = qux\n\n    pkgname = foo\n    \"\"\"\n\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=\"/dummy\")\n\n    info = pkg._parse_srcinfo(srcinfo)\n\n    assert info.dependencies == (\"bar>=1.0\",)\n    assert info.make_dependencies == (\"baz\",)\n    assert info.check_dependencies == (\"qux\",)\n\n\ndef test_parse_srcinfo_split_package_uses_only_target_pkg_dependencies(monkeypatch) -> None:\n    # Ensure arch-specific keys match\n    monkeypatch.setattr(pkg_mod.config, \"arch\", \"x86_64\", raising=False)\n\n    srcinfo = \"\"\"\n    pkgbase = clion\n        pkgver = 2025.3\n        pkgrel = 1\n        makedepends = rsync\n        depends = base-dep\n        depends_x86_64 = base-arch-dep\n\n    pkgname = clion\n        depends = libdbusmenu-glib\n        depends_x86_64 = clion-arch-dep\n        checkdepends = clion-check\n\n    pkgname = clion-jre\n        depends = jre-dep\n        makedepends = jre-make\n\n    pkgname = clion-cmake\n        depends = cmake-dep\n    \"\"\"\n\n    pkg = CustomPackage(pkgname=\"clion\", git_url=None, pkgbuild_directory=\"/dummy\")\n\n    info = pkg._parse_srcinfo(srcinfo)\n\n    # version\n    assert info.pkgbase == \"clion\"\n    assert info.version == \"2025.3-1\"\n\n    # base deps + target pkg deps (including arch-specific)\n    assert info.dependencies == (\n        \"base-dep\",\n        \"base-arch-dep\",\n        \"libdbusmenu-glib\",\n        \"clion-arch-dep\",\n    )\n\n    # only base and target pkg makedepends\n    assert info.make_dependencies == (\"rsync\",)\n\n    # base + target pkg checkdepends\n    assert info.check_dependencies == (\"clion-check\",)\n\n\ndef test_parse_srcinfo_arch_specific_ignored_for_other_arch(monkeypatch) -> None:\n    # Different arch → *_x86_64 keys should be ignored\n    monkeypatch.setattr(pkg_mod.config, \"arch\", \"aarch64\", raising=False)\n\n    srcinfo = \"\"\"\n    pkgbase = foo\n        pkgver = 1.0\n        pkgrel = 1\n        depends_x86_64 = base-arch-dep\n\n    pkgname = foo\n        depends = common-dep\n        depends_x86_64 = pkg-arch-dep\n    \"\"\"\n\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=\"/dummy\")\n\n    info = pkg._parse_srcinfo(srcinfo)\n\n    # Only common deps, no *_x86_64 because arch != x86_64\n    assert info.dependencies == (\"common-dep\",)\n\n\ndef test_parse_srcinfo_missing_required_fields_raises() -> None:\n    # Missing pkgbase\n    srcinfo_no_pkgbase = \"\"\"\n        pkgver = 1.0\n        pkgrel = 1\n        pkgname = foo\n    \"\"\"\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=\"/dummy\")\n\n    with pytest.raises(PKGBUILDParseError) as excinfo:\n        pkg._parse_srcinfo(srcinfo_no_pkgbase)\n    assert \"pkgbase/pkgver\" in str(excinfo.value)\n\n    # Missing pkgver\n    srcinfo_no_pkgver = \"\"\"\n        pkgbase = foo\n        pkgname = foo\n    \"\"\"\n\n    with pytest.raises(PKGBUILDParseError) as excinfo2:\n        pkg._parse_srcinfo(srcinfo_no_pkgver)\n    assert \"pkgbase/pkgver\" in str(excinfo2.value)\n\n\ndef test_parse_srcinfo_missing_target_pkg_raises() -> None:\n    srcinfo = \"\"\"\n    pkgbase = foo\n        pkgver = 1.0\n        pkgrel = 1\n    pkgname = other\n    \"\"\"\n\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=\"/dummy\")\n\n    with pytest.raises(PKGBUILDParseError) as excinfo:\n        pkg._parse_srcinfo(srcinfo)\n\n    msg = str(excinfo.value)\n    assert \"Package foo not found in SRCINFO\" in msg\n    assert \"other\" in msg  # listed in present packages\n\n\ndef test_srcinfo_from_pkgbuild_directory_missing_dir_raises(tmp_path: pathlib.Path) -> None:\n    missing = tmp_path / \"does-not-exist\"\n\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=str(missing))\n\n    with pytest.raises(PKGBUILDParseError) as excinfo:\n        pkg._srcinfo_from_pkgbuild_directory(DummyCommands())\n\n    msg = str(excinfo.value)\n    assert \"does not exist or is not a directory\" in msg\n\n\ndef test_srcinfo_from_pkgbuild_directory_missing_pkgbuild_raises(tmp_path: pathlib.Path) -> None:\n    path = tmp_path / \"pkgdir\"\n    path.mkdir()\n\n    pkg = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=str(path))\n\n    with pytest.raises(PKGBUILDParseError) as excinfo:\n        pkg._srcinfo_from_pkgbuild_directory(DummyCommands())\n\n    msg = str(excinfo.value)\n    assert \"No PKGBUILD found\" in msg\n\n\ndef test_custom_package_equality_and_hash() -> None:\n    a1 = CustomPackage(\n        pkgname=\"foo\", git_url=\"https://example.com/repo.git\", pkgbuild_directory=None\n    )\n    a2 = CustomPackage(\n        pkgname=\"foo\", git_url=\"https://example.com/repo.git\", pkgbuild_directory=None\n    )\n    b = CustomPackage(pkgname=\"foo\", git_url=None, pkgbuild_directory=\"/some/path\")\n\n    assert a1 == a2\n    assert hash(a1) == hash(a2)\n\n    assert a1 != b\n    assert hash(a1) != hash(b)\n\n\ndef test_custom_package_str_git_and_directory() -> None:\n    git_pkg = CustomPackage(\n        pkgname=\"foo\",\n        git_url=\"https://example.com/repo.git\",\n        pkgbuild_directory=None,\n    )\n    dir_pkg = CustomPackage(\n        pkgname=\"foo\",\n        git_url=None,\n        pkgbuild_directory=\"/some/path\",\n    )\n\n    assert \"pkgname=foo\" in str(git_pkg)\n    assert \"git_url=https://example.com/repo.git\" in str(git_pkg)\n\n    assert \"pkgname=foo\" in str(dir_pkg)\n    assert \"pkgbuild_directory=/some/path\" in str(dir_pkg)\n\n\n# --- PackageSearch: caching ------------------------------------------------\n\n\ndef _make_pkg(name: str = \"pkg\") -> PackageInfo:\n    return PackageInfo(\n        pkgname=name,\n        pkgbase=name,\n        version=\"1.0\",\n        git_url=f\"git://example/{name}\",\n        provides=(\"virt-\" + name,),\n        dependencies=(\"dep\",),\n        make_dependencies=(),\n        check_dependencies=(),\n    )\n\n\ndef test_add_custom_pkg_caches_package():\n    search = PackageSearch()\n    pkg = _make_pkg(\"foo\")\n\n    search.add_custom_pkg(pkg)\n\n    assert pkg in search._custom_packages\n    assert search._package_cache[\"foo\"] is pkg\n    assert search._all_providers_cache[\"virt-foo\"] == [\"foo\"]\n\n\ndef test_try_caching_packages_skips_already_cached(monkeypatch):\n    search = PackageSearch()\n    pkg = _make_pkg(\"foo\")\n    search._cache_pkg(pkg)\n\n    calls = []\n\n    def fake_get(*args, **kwargs):\n        calls.append((args, kwargs))\n        raise AssertionError(\"requests.get should not be called\")\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    search.try_caching_packages([\"foo\"])\n    assert calls == []\n\n\ndef test_try_caching_packages_caches_from_aur(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\n                    \"type\": \"success\",\n                    \"results\": [\n                        {\n                            \"Name\": \"bar\",\n                            \"PackageBase\": \"bar-base\",\n                            \"Version\": \"2.0\",\n                            \"Depends\": [\"dep1\"],\n                            \"MakeDepends\": [\"make1\"],\n                            \"CheckDepends\": [\"check1\"],\n                            \"Provides\": [\"virt-bar\"],\n                        }\n                    ],\n                }\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    search.try_caching_packages([\"bar\"])\n\n    assert \"bar\" in search._package_cache\n    info = search._package_cache[\"bar\"]\n    assert isinstance(info, PackageInfo)\n    assert search._all_providers_cache[\"virt-bar\"] == [\"bar\"]\n\n\ndef test_try_caching_packages_aur_returns_error(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\"type\": \"error\", \"error\": \"boom\"}\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    with pytest.raises(AurRPCError):\n        search.try_caching_packages([\"bar\"])\n\n\ndef test_try_caching_packages_request_exception_raises_aur_error(monkeypatch):\n    search = PackageSearch()\n\n    class DummyError(pkg_mod.requests.RequestException):\n        pass\n\n    def fake_get(url, timeout):\n        raise DummyError(\"boom\")\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    with pytest.raises(AurRPCError):\n        search.try_caching_packages([\"bar\"])\n\n\n# --- PackageSearch: get_package_info --------------------------------------\n\n\ndef test_get_package_info_returns_from_cache():\n    search = PackageSearch()\n    pkg = _make_pkg(\"foo\")\n    search._cache_pkg(pkg)\n\n    result = search.get_package_info(\"foo\")\n    assert result is pkg\n\n\ndef test_get_package_info_returns_custom_package_if_not_cached():\n    search = PackageSearch()\n    pkg = _make_pkg(\"foo\")\n    search._custom_packages.append(pkg)\n\n    result = search.get_package_info(\"foo\")\n\n    assert result is pkg\n    assert search._package_cache[\"foo\"] is pkg\n\n\ndef test_get_package_info_aur_not_found_returns_none(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\"type\": \"success\", \"resultcount\": 0, \"results\": []}\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    result = search.get_package_info(\"foo\")\n    assert result is None\n    assert \"foo\" not in search._package_cache\n\n\ndef test_get_package_info_aur_success_caches_and_returns(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\n                    \"type\": \"success\",\n                    \"resultcount\": 1,\n                    \"results\": [\n                        {\n                            \"Name\": \"foo\",\n                            \"PackageBase\": \"foo-base\",\n                            \"Version\": \"1.2\",\n                            \"Depends\": [\"dep1\"],\n                            \"MakeDepends\": [\"make1\"],\n                            \"CheckDepends\": [\"check1\"],\n                            \"Provides\": [\"virt-foo\"],\n                        }\n                    ],\n                }\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    result = search.get_package_info(\"foo\")\n    assert isinstance(result, PackageInfo)\n    assert result.pkgname == \"foo\"\n    assert search._package_cache[\"foo\"] is result\n\n\ndef test_get_package_info_aur_returns_error(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\"type\": \"error\", \"error\": \"boom\"}\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    with pytest.raises(AurRPCError):\n        search.get_package_info(\"foo\")\n\n\ndef test_get_package_info_request_exception_raises_aur_error(monkeypatch):\n    search = PackageSearch()\n\n    class DummyError(pkg_mod.requests.RequestException):\n        pass\n\n    def fake_get(url, timeout):\n        raise DummyError(\"boom\")\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    with pytest.raises(AurRPCError):\n        search.get_package_info(\"foo\")\n\n\n# --- PackageSearch: find_provider -----------------------------------------\n\n\ndef test_find_provider_uses_selected_providers_cache():\n    search = PackageSearch()\n    pkg = _make_pkg(\"foo\")\n    search._selected_providers_cache[\"dep\"] = pkg\n\n    result = search.find_provider(\"dep\")\n    assert result is pkg\n\n\ndef test_find_provider_exact_name_match(monkeypatch):\n    search = PackageSearch()\n    pkg = _make_pkg(\"dep\")\n\n    def fake_get_package_info(name: str):\n        assert name == \"dep\"\n        return pkg\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    result = search.find_provider(\"dep\")\n    assert result is pkg\n    assert search._selected_providers_cache[\"dep\"] is pkg\n\n\ndef test_find_provider_single_known_provider(monkeypatch):\n    search = PackageSearch()\n    pkg = _make_pkg(\"provider\")\n    search._all_providers_cache[\"dep\"] = [\"provider\"]\n\n    def fake_get_package_info(name: str):\n        if name == \"dep\":\n            return None\n        assert name == \"provider\"\n        return pkg\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    result = search.find_provider(\"dep\")\n    assert result is pkg\n    assert search._selected_providers_cache[\"dep\"] is pkg\n\n\ndef test_find_provider_aur_search_not_found(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get_package_info(name: str):\n        # Exact name match should fail\n        return None\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\"type\": \"success\", \"resultcount\": 0, \"results\": []}\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    result = search.find_provider(\"dep\")\n    assert result is None\n\n\ndef test_find_provider_aur_search_single_result(monkeypatch):\n    search = PackageSearch()\n    pkg = _make_pkg(\"provider\")\n\n    def fake_get_package_info(name: str):\n        # first call for stripped_dependency -> None\n        if name == \"dep\":\n            return None\n        assert name == \"provider\"\n        return pkg\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\n                    \"type\": \"success\",\n                    \"resultcount\": 1,\n                    \"results\": [{\"Name\": \"provider\"}],\n                }\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    result = search.find_provider(\"dep\")\n    assert result is pkg\n\n\ndef test_find_provider_aur_search_multiple_results_calls_choose_provider(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get_package_info(name: str):\n        # no exact match\n        return None\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\n                    \"type\": \"success\",\n                    \"resultcount\": 2,\n                    \"results\": [{\"Name\": \"a\"}, {\"Name\": \"b\"}],\n                }\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    sentinel = object()\n\n    def fake_choose(dep, providers, where):\n        assert dep == \"dep\"\n        assert providers == [\"a\", \"b\"]\n        assert where == \"AUR\"\n        return sentinel\n\n    monkeypatch.setattr(search, \"_choose_provider\", fake_choose)\n\n    result = search.find_provider(\"dep\")\n    assert result is sentinel\n\n\ndef test_find_provider_aur_search_error(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get_package_info(name: str):\n        return None\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    def fake_get(url, timeout):\n        class Resp:\n            def json(self):\n                return {\"type\": \"error\", \"error\": \"boom\"}\n\n        return Resp()\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    with pytest.raises(AurRPCError):\n        search.find_provider(\"dep\")\n\n\ndef test_find_provider_aur_search_request_exception_raises_aur_error(monkeypatch):\n    search = PackageSearch()\n\n    def fake_get_package_info(name: str):\n        return None\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    class DummyError(pkg_mod.requests.RequestException):\n        pass\n\n    def fake_get(url, timeout):\n        raise DummyError(\"boom\")\n\n    monkeypatch.setattr(pkg_mod.requests, \"get\", fake_get)\n\n    with pytest.raises(AurRPCError):\n        search.find_provider(\"dep\")\n\n\n# --- PackageSearch: _choose_provider --------------------------------------\n\n\ndef test_choose_provider_prompts_and_caches(monkeypatch):\n    search = PackageSearch()\n    providers = [\"a\", \"b\", \"c\"]\n    selected_pkg = _make_pkg(\"b\")\n\n    # override prompt to select \"2\" (provider \"b\")\n    monkeypatch.setattr(\n        pkg_mod.output,\n        \"prompt_number\",\n        lambda *a, **k: 2,\n    )\n\n    def fake_get_package_info(name: str):\n        assert name == \"b\"\n        return selected_pkg\n\n    monkeypatch.setattr(search, \"get_package_info\", fake_get_package_info)\n\n    result = search._choose_provider(\"dep\", providers, \"AUR\")\n    assert result is selected_pkg\n    assert search._selected_providers_cache[\"dep\"] is selected_pkg\n"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_aur_resolver.py",
    "content": "import pytest\nfrom decman.plugins.aur.error import DependencyCycleError\nfrom decman.plugins.aur.resolver import DepGraph, ForeignPackage\n\n\ndef test_add_dependency():\n    graph = DepGraph()\n\n    graph.add_requirement(\"A\", None)\n    graph.add_requirement(\"B1\", \"A\")\n    graph.add_requirement(\"B2\", \"A\")\n    graph.add_requirement(\"C\", \"B1\")\n\n    assert \"B1\" in graph.package_nodes[\"A\"].children\n    assert \"B2\" in graph.package_nodes[\"A\"].children\n    assert \"C\" in graph.package_nodes[\"B1\"].children\n\n\ndef test_cyclic_dependency_raises():\n    graph = DepGraph()\n\n    graph.add_requirement(\"A\", None)\n    graph.add_requirement(\"B\", \"A\")\n    graph.add_requirement(\"C\", \"B\")\n\n    with pytest.raises(DependencyCycleError):\n        graph.add_requirement(\"A\", \"C\")\n\n\ndef _build_graph_for_outer_deps() -> DepGraph:\n    graph = DepGraph()\n\n    # Roots\n    graph.add_requirement(\"A\", None)\n    graph.add_requirement(\"V\", None)\n\n    # Level B\n    graph.add_requirement(\"B1\", \"A\")\n    graph.add_requirement(\"B2\", \"A\")\n    graph.add_requirement(\"B3\", \"A\")\n\n    # Extra dependency B1 -> B2\n    graph.add_requirement(\"B1\", \"B2\")\n\n    # Level C\n    graph.add_requirement(\"C1\", \"B1\")\n    graph.add_requirement(\"C2\", \"B1\")\n\n    # Level D + cycle-ish edges\n    graph.add_requirement(\"D\", \"C1\")\n    graph.add_requirement(\"C2\", \"D\")\n\n    # Foreign packages and their foreign deps\n    defs = {\n        \"V\": [],\n        \"A\": [\"B1\", \"B2\", \"B3\", \"C1\", \"C2\", \"D\"],\n        \"B1\": [\"C1\", \"C2\", \"D\"],\n        \"B2\": [\"B1\", \"C1\", \"C2\", \"D\"],\n        \"B3\": [],\n        \"C1\": [\"D\", \"C2\"],\n        \"C2\": [],\n        \"D\": [\"C2\"],\n    }\n\n    for name, deps in defs.items():\n        pkg = ForeignPackage(name)\n        pkg.add_foreign_dependency_packages(deps)\n\n    return graph\n\n\ndef _assert_outer_dep_names(graph: DepGraph, expected: set[str]) -> None:\n    result = graph.get_and_remove_outer_dep_pkgs()\n    names = {pkg.name for pkg in result}\n    assert names == expected\n\n\ndef test_get_and_remove_outer_deps_sequence():\n    graph = _build_graph_for_outer_deps()\n\n    _assert_outer_dep_names(graph, {\"C2\", \"B3\", \"V\"})\n    _assert_outer_dep_names(graph, {\"D\"})\n    _assert_outer_dep_names(graph, {\"C1\"})\n    _assert_outer_dep_names(graph, {\"B1\"})\n    _assert_outer_dep_names(graph, {\"B2\"})\n    _assert_outer_dep_names(graph, {\"A\"})\n    _assert_outer_dep_names(graph, set())\n"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_pacman.py",
    "content": "from typing import Any\n\nimport pytest\n\nfrom decman.plugins import pacman as pacman_plugin\n\n\n@pytest.mark.parametrize(\n    \"dep,expected\",\n    [\n        (\"foo\", \"foo\"),\n        (\"foo=1.0\", \"foo\"),\n        (\"bar>=2\", \"bar\"),\n        (\"baz<3\", \"baz\"),\n        (\"multi=1.0-2\", \"multi\"),\n    ],\n)\ndef test_strip_dependency(dep, expected):\n    assert pacman_plugin.strip_dependency(dep) == expected\n\n\nclass FakeStore(dict):\n    def ensure(self, key: str, default: Any) -> None:\n        if key not in self:\n            self[key] = default\n\n\nclass FakeModule:\n    def __init__(self, name: str, packages: set[str]) -> None:\n        self.name = name\n        self._changed = False\n        self._packages = packages\n\n\ndef test_process_modules_collects_packages_and_marks_changed(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    pacman = pacman_plugin.Pacman()\n    store = FakeStore()\n    mod1 = FakeModule(\"mod1\", {\"pkg1\", \"pkg2\"})\n    mod2 = FakeModule(\"mod2\", {\"pkg3\"})\n\n    def fake_run_methods_with_attribute(mod: FakeModule, attr: str) -> set[str]:\n        assert attr == \"__pacman__packages__\"\n        return [mod._packages]\n\n    monkeypatch.setattr(\n        pacman_plugin.plugins,\n        \"run_methods_with_attribute\",\n        fake_run_methods_with_attribute,\n    )\n\n    pacman.process_modules(store, {mod1, mod2})\n\n    # packages collected\n    assert pacman.packages == {\"pkg1\", \"pkg2\", \"pkg3\"}\n    # stored mapping per module\n    assert store[\"packages_for_module\"][\"mod1\"] == {\"pkg1\", \"pkg2\"}\n    assert store[\"packages_for_module\"][\"mod2\"] == {\"pkg3\"}\n    # modules marked changed (first run)\n    assert mod1._changed is True\n    assert mod2._changed is True\n\n\ndef test_apply_dry_run_computes_sets_and_does_not_call_pacman(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    pacman = pacman_plugin.Pacman()\n    store = FakeStore()\n\n    # Desired state\n    pacman.packages = {\"keep-explicit\", \"new-pkg\"}\n\n    # Fake PacmanInterface returned by plugin module\n    class FakePM:\n        def __init__(\n            self, commands, print_highlights, keywords, database_signature_level, database_path\n        ) -> None:  # noqa: D401\n            self.commands = commands\n            self.print_highlights = print_highlights\n            self.keywords = keywords\n            self.remove_called_with: set[str] | None = None\n            self.set_as_deps_called_with: set[str] | None = None\n            self.upgrade_called = False\n            self.install_called_with: set[str] | None = None\n\n        def get_native_explicit(self) -> set[str]:\n            # keep-explicit (in desired), old-explicit (to demote/remove)\n            return {\"keep-explicit\", \"old-explicit\"}\n\n        def get_foreign_explicit(self) -> set[str]:\n            # foreign-package protects its deps\n            return {\"foreign-pkg\"}\n\n        def get_native_orphans(self) -> set[str]:\n            # orphan-explicit is also candidate\n            return {\"orphan-explicit\"}\n\n        def get_dependants(self, pkg: str) -> set[str]:\n            # old-explicit has a foreign dependant -> demote to dep\n            # orphan-explicit has no dependants -> remove\n            if pkg == \"old-explicit\":\n                return {\"foreign-pkg\"}\n            if pkg == \"orphan-explicit\":\n                return set()\n            return set()\n\n        def remove(self, pkgs: set[str]) -> None:\n            self.remove_called_with = pkgs\n\n        def set_as_dependencies(self, pkgs: set[str]) -> None:\n            self.set_as_deps_called_with = pkgs\n\n        def upgrade(self) -> None:\n            self.upgrade_called = True\n\n        def install(self, pkgs: set[str]) -> None:\n            self.install_called_with = pkgs\n\n    fake_pm = FakePM(None, None, None, None, None)\n\n    def fake_pm_ctor(\n        commands, print_highlights, keywords, database_signature_level, database_path\n    ) -> FakePM:\n        # constructor used in Pacman.apply\n        fake_pm.commands = commands\n        fake_pm.print_highlights = print_highlights\n        fake_pm.keywords = keywords\n        return fake_pm\n\n    monkeypatch.setattr(pacman_plugin, \"PacmanInterface\", fake_pm_ctor)\n\n    printed_lists: list[tuple[str, list[str]]] = []\n    printed_summaries: list[str] = []\n\n    def fake_print_list(title: str, items: list[str]) -> None:\n        printed_lists.append((title, items))\n\n    def fake_print_summary(msg: str) -> None:\n        printed_summaries.append(msg)\n\n    monkeypatch.setattr(pacman_plugin.output, \"print_list\", fake_print_list)\n    monkeypatch.setattr(pacman_plugin.output, \"print_summary\", fake_print_summary)\n\n    ok = pacman.apply(store, dry_run=True)\n\n    assert ok is True\n\n    # to_remove = (native | orphans) - desired\n    #           = {keep-explicit, old-explicit} ∪ {orphan-explicit} - {keep-explicit, new-pkg}\n    #           = {old-explicit, orphan-explicit}\n    #\n    # old-explicit has foreign dependant -> demoted to dep\n    # orphan-explicit has no dependants -> removed\n\n    # printed lists (titles and contents)\n    titles = [t for t, _ in printed_lists]\n    assert \"Removing pacman packages:\" in titles\n    assert \"Setting previously explicitly installed packages as dependencies:\" in titles\n    assert \"Installing pacman packages:\" in titles\n\n    # find lists by title\n    remove_list = next(items for t, items in printed_lists if \"Removing pacman packages:\" in t)\n    demote_list = next(\n        items\n        for t, items in printed_lists\n        if \"Setting previously explicitly installed packages as dependencies:\" in t\n    )\n    install_list = next(items for t, items in printed_lists if \"Installing pacman packages:\" in t)\n\n    assert remove_list == [\"orphan-explicit\"]\n    assert demote_list == [\"old-explicit\"]\n    # to_install = desired - currently_installed_native\n    #            = {keep-explicit, new-pkg} - {keep-explicit, old-explicit}\n    #            = {new-pkg}\n    assert install_list == [\"new-pkg\"]\n\n    # Upgrade summary printed even in dry-run\n    assert any(\"Upgrading packages.\" in s for s in printed_summaries)\n\n    # No mutating calls in dry-run\n    assert fake_pm.remove_called_with is None\n    assert fake_pm.set_as_deps_called_with is None\n    assert fake_pm.upgrade_called is False\n    assert fake_pm.install_called_with is None\n\n\ndef test_apply_returns_false_on_command_failure(monkeypatch: pytest.MonkeyPatch) -> None:\n    pacman = pacman_plugin.Pacman()\n    store = FakeStore()\n    pacman.packages = set()\n\n    class FailingPM:\n        def __init__(self, *args, **kwargs) -> None:  # noqa: D401\n            pass\n\n        def get_native_explicit(self) -> set[str]:\n            raise pacman_plugin.errors.CommandFailedError([\"get_native_explicit\"], 10, \"boom\")\n\n    monkeypatch.setattr(pacman_plugin, \"PacmanInterface\", FailingPM)\n\n    errors_logged: list[str] = []\n    continuations: list[str] = []\n    traceback_called = []\n\n    def fake_print_error(msg: str) -> None:\n        errors_logged.append(msg)\n\n    def fake_print_traceback() -> None:\n        traceback_called.append(True)\n\n    def fake_print_continuation(msg: str) -> None:\n        continuations.append(msg)\n\n    monkeypatch.setattr(pacman_plugin.output, \"print_error\", fake_print_error)\n    monkeypatch.setattr(pacman_plugin.output, \"print_traceback\", fake_print_traceback)\n    monkeypatch.setattr(pacman_plugin.output, \"print_continuation\", fake_print_continuation)\n\n    ok = pacman.apply(store, dry_run=False)\n\n    assert ok is False\n    assert any(\"Pacman command exited with an unexpected\" in msg for msg in errors_logged)\n    assert any(\"boom\" in msg for msg in continuations)\n    assert traceback_called  # at least once\n\n\ndef test_ignored_packages_are_not_removed_or_installed(monkeypatch: pytest.MonkeyPatch) -> None:\n    pacman = pacman_plugin.Pacman()\n    store = FakeStore()\n\n    # Desired state: \"already\" and \"new\" should be managed normally.\n    # \"ignored-installed\" is currently installed but not desired -> would normally be removed.\n    # \"ignored-uninstalled\" is desired but not installed -> would normally be installed.\n    pacman.packages = {\"already\", \"new\", \"ignored-uninstalled\"}\n    pacman.ignored_packages = {\"ignored-installed\", \"ignored-uninstalled\"}\n\n    class FakePM:\n        def __init__(\n            self, commands, print_highlights, keywords, database_signature_level, database_path\n        ) -> None:  # noqa: D401\n            self.commands = commands\n            self.print_highlights = print_highlights\n            self.keywords = keywords\n\n            self.remove_called_with: set[str] | None = None\n            self.install_called_with: set[str] | None = None\n            self.set_as_deps_called_with: set[str] | None = None\n            self.upgrade_called = False\n\n        def get_native_explicit(self) -> set[str]:\n            # currently installed explicit packages\n            return {\"ignored-installed\", \"already\"}\n\n        def get_foreign_explicit(self) -> set[str]:\n            return set()\n\n        def get_native_orphans(self) -> set[str]:\n            return set()\n\n        def get_dependants(self, pkg: str) -> set[str]:\n            return set()\n\n        def remove(self, pkgs: set[str]) -> None:\n            self.remove_called_with = pkgs\n\n        def set_as_dependencies(self, pkgs: set[str]) -> None:\n            self.set_as_deps_called_with = pkgs\n\n        def upgrade(self) -> None:\n            self.upgrade_called = True\n\n        def install(self, pkgs: set[str]) -> None:\n            self.install_called_with = pkgs\n\n    fake_pm = FakePM(None, None, None, None, None)\n\n    def fake_pm_ctor(\n        commands, print_highlights, keywords, database_signature_level, database_path\n    ) -> FakePM:\n        fake_pm.commands = commands\n        fake_pm.print_highlights = print_highlights\n        fake_pm.keywords = keywords\n        return fake_pm\n\n    monkeypatch.setattr(pacman_plugin, \"PacmanInterface\", fake_pm_ctor)\n\n    printed_lists: list[tuple[str, list[str]]] = []\n\n    def fake_print_list(title: str, items: list[str]) -> None:\n        printed_lists.append((title, items))\n\n    # don't care about summaries here\n    monkeypatch.setattr(pacman_plugin.output, \"print_list\", fake_print_list)\n    monkeypatch.setattr(pacman_plugin.output, \"print_summary\", lambda *_args, **_kw: None)\n\n    ok = pacman.apply(store, dry_run=False)\n\n    assert ok is True\n\n    # Ignored packages must never be passed to remove() or install()\n    assert (\n        fake_pm.remove_called_with is None or \"ignored-installed\" not in fake_pm.remove_called_with\n    )\n    assert fake_pm.install_called_with is not None\n    assert \"ignored-uninstalled\" not in fake_pm.install_called_with\n\n    # Also ensure the printed install list doesn't contain ignored packages\n    install_items = next(\n        items for title, items in printed_lists if \"Installing pacman packages:\" in title\n    )\n    assert \"ignored-uninstalled\" not in install_items\n    # \"new\" is the only package that should be installed in this scenario\n    assert install_items == [\"new\"]\n"
  },
  {
    "path": "plugins/decman-pacman/tests/test_deep_orphan_removal.py",
    "content": "import pyalpm\nimport pytest\nfrom decman.plugins.aur import AurPacmanInterface\nfrom decman.plugins.pacman import PacmanInterface\n\n\nclass FakePackage:\n    def __init__(self, name: str, is_explicit: bool, required_by: list[str]):\n        self.name = name\n        self.reason = pyalpm.PKG_REASON_EXPLICIT if is_explicit else pyalpm.PKG_REASON_DEPEND\n        self.required_by = required_by\n        self.provides = [name]\n\n    def compute_requiredby(self):\n        return self.required_by\n\n\nclass FakeDB:\n    def __init__(self, pkgcache: list[FakePackage]):\n        self.pkgcache = pkgcache\n\n\nclass FakePyalpmHandle:\n    def __init__(self):\n        pass\n\n    def get_syncdbs(self):\n        return [\n            FakeDB(\n                [\n                    FakePackage(\"a\", True, []),\n                    FakePackage(\"b\", False, [\"a\"]),\n                    FakePackage(\"c\", False, [\"b\"]),\n                    FakePackage(\"d\", False, []),\n                    FakePackage(\"e\", False, [\"f\"]),\n                    FakePackage(\"f\", False, [\"g\"]),\n                    FakePackage(\"g\", False, []),\n                ]\n            )\n        ]\n\n    def get_localdb(self):\n        return FakeDB(\n            self.get_syncdbs()[0].pkgcache\n            + [\n                FakePackage(\"h\", True, []),\n                FakePackage(\"i\", False, [\"h\"]),\n                FakePackage(\"j\", False, []),\n                FakePackage(\"k\", False, [\"l\"]),\n                FakePackage(\"l\", False, []),\n            ]\n        )\n\n\ndef fake_create_pyalpm_handle(self):\n    return FakePyalpmHandle()\n\n\ndef test_get_native_orphans_pacman(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    monkeypatch.setattr(PacmanInterface, \"_create_pyalpm_handle\", fake_create_pyalpm_handle)\n\n    interface = PacmanInterface(\n        None,  # type: ignore\n        False,\n        set(),\n        2048,\n        \"/var/lib/pacman/\",\n    )\n\n    assert interface.get_native_orphans() == {\"d\", \"e\", \"f\", \"g\"}\n\n\ndef test_get_foreign_orphans_aur(\n    monkeypatch: pytest.MonkeyPatch,\n) -> None:\n    monkeypatch.setattr(AurPacmanInterface, \"_create_pyalpm_handle\", fake_create_pyalpm_handle)\n\n    interface = AurPacmanInterface(\n        None,  # type: ignore\n        False,\n        set(),\n        2048,\n        \"/var/lib/pacman/\",\n    )\n\n    assert interface.get_foreign_orphans() == {\"j\", \"k\", \"l\"}\n"
  },
  {
    "path": "plugins/decman-pacman/tests/test_fpm.py",
    "content": "import typing\nfrom unittest.mock import MagicMock\nfrom urllib.parse import parse_qs, unquote, urlparse\n\nimport pytest\nfrom decman.plugins.aur.commands import AurCommands\nfrom decman.plugins.aur.fpm import ForeignPackageManager\nfrom decman.plugins.aur.package import PackageInfo, PackageSearch\n\n\nclass FakeAurPacmanInterface:\n    def __init__(self) -> None:\n        self.installed_native: set[str] = set()\n        self.installed_foreign: dict[str, str] = {}\n        self.explicitly_installed: set[str] = set()\n        self.not_installable: set[str] = set()\n        self.installed_files: list[str] = []  # To track what install_files() actually does\n        self.provided_pkgs: set[str] = set()\n\n    def get_native_explicit(self) -> set[str]:\n        return self.installed_native.intersection(self.explicitly_installed)\n\n    def get_native_orphans(self) -> set[str]:\n        return set()\n\n    def get_foreign_explicit(self) -> set[str]:\n        return set(self.installed_foreign.keys()).intersection(self.explicitly_installed)\n\n    def get_dependants(self, package: str) -> set[str]:\n        return set()\n\n    def set_as_dependencies(self, packages: set[str]):\n        self.explicitly_installed.difference_update(packages)\n\n    def install(self, packages: set[str]):\n        self.installed_native.update(packages)\n        self.explicitly_installed.update(packages)\n\n    def upgrade(self):\n        pass\n\n    def is_provided_by_installed(self, dependency: str) -> bool:\n        return dependency in self.provided_pkgs\n\n    def get_all_packages(self) -> set[str]:\n        return self.installed_native | self.installed_foreign.keys()\n\n    def filter_installed_packages(self, deps: set[str]) -> set[str]:\n        out = set()\n        for d in deps:\n            if not self.is_provided_by_installed(d) and d not in self.get_all_packages():\n                out.add(d)\n        return out\n\n    def remove(self, packages: set[str]):\n        self.installed_native.difference_update(packages)\n        for p in packages:\n            self.installed_foreign.pop(p, None)\n        self.explicitly_installed.difference_update(packages)\n\n    def get_foreign_orphans(self) -> set[str]:\n        return set()\n\n    def is_installable(self, pkg: str) -> bool:\n        return pkg not in self.not_installable\n\n    def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:\n        return list(self.installed_foreign.items())\n\n    def install_dependencies(self, deps: set[str]):\n        self.installed_native.update(deps)\n\n    def install_files(self, files: list[str], as_explicit: set[str]):\n        self.installed_files.extend(files)\n\n        for file in files:\n            self.installed_foreign[file] = \"file\"\n\n        for pkg in as_explicit:\n            self.explicitly_installed.add(pkg)\n\n\nclass FakeStore:\n    def __init__(self) -> None:\n        self._store: dict[str, typing.Any] = {}\n\n    def __getitem__(self, key: str) -> typing.Any:\n        return self._store[key]\n\n    def __setitem__(self, key: str, value: typing.Any) -> None:\n        self._store[key] = value\n\n    def get(self, key: str, default: typing.Any = None) -> typing.Any:\n        return self._store.get(key, default)\n\n    def ensure(self, key: str, default: typing.Any = None):\n        if key not in self._store:\n            self._store[key] = default\n\n    def __enter__(self) -> \"FakeStore\":\n        return self\n\n    def __exit__(self, exc_type, exc, tb):\n        return False\n\n    def save(self) -> None:\n        pass\n\n    def __repr__(self) -> str:\n        return repr(self._store)\n\n\nclass MockAurServer:\n    def __init__(self) -> None:\n        self.db: dict[str, dict] = {}  # Maps pkgname -> raw JSON result dict\n\n    def seed(self, packages: list[PackageInfo]):\n        for pkg in packages:\n            # Reconstruct the raw JSON structure expected by PackageSearch\n            entry = {\n                \"Name\": pkg.pkgname,\n                \"PackageBase\": pkg.pkgbase or pkg.pkgname,\n                \"Version\": pkg.version,\n                \"Description\": \"Mock Description\",\n                \"URL\": \"https://example.com\",\n                \"Depends\": pkg.dependencies,\n                \"MakeDepends\": pkg.make_dependencies,\n                \"CheckDepends\": pkg.check_dependencies,\n                \"Provides\": pkg.provides,\n                # Add other fields if your class relies on them\n            }\n            self.db[pkg.pkgname] = entry\n\n    def handle_request(self, url, *args, **kwargs):\n        parsed = urlparse(url)\n        path = parsed.path\n        query = parse_qs(parsed.query)\n\n        results = []\n\n        # --- Handle: Multi-info query (.../info?arg[]=pkg1&arg[]=pkg2) ---\n        if \"/rpc/v5/info\" in path and \"arg[]\" in query:\n            requested_names = query[\"arg[]\"]\n            for name in requested_names:\n                if name in self.db:\n                    results.append(self.db[name])\n\n        # --- Handle: Single info query (.../rpc/v5/info/pkgname) ---\n        elif \"/rpc/v5/info/\" in path:\n            # Extract package name from end of path\n            pkg_name = path.split(\"/\")[-1]\n            if pkg_name in self.db:\n                results.append(self.db[pkg_name])\n\n        # --- Handle: Search providers (.../rpc/v5/search/dep?by=provides) ---\n        elif \"/rpc/v5/search/\" in path and query.get(\"by\") == [\"provides\"]:\n            search_term = path.split(\"/\")[-1]\n            search_term = unquote(search_term)\n\n            # Linear search through DB for 'Provides'\n            for entry in self.db.values():\n                if search_term in entry.get(\"Provides\", []):\n                    results.append(entry)\n                # Also match if the package name itself matches the provider request\n                elif entry[\"Name\"] == search_term:\n                    results.append(entry)\n\n        # Construct the response object\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.json.return_value = {\n            \"version\": 5,\n            \"type\": \"multiinfo\",\n            \"resultcount\": len(results),\n            \"results\": results,\n        }\n\n        return mock_response\n\n\n@pytest.fixture\ndef mock_aur(mocker):\n    server = MockAurServer()\n    mocker.patch(\"requests.get\", side_effect=server.handle_request)\n    return server\n\n\n@pytest.fixture\ndef mock_pacman(mocker):\n    pacman = FakeAurPacmanInterface()\n    return pacman\n\n\n@pytest.fixture\ndef mock_fpm(mocker, mock_aur, mock_pacman):\n    mock_builder_cls = mocker.patch(\"decman.plugins.aur.fpm.PackageBuilder\")\n    mock_builder_instance = mock_builder_cls.return_value\n    mock_builder_instance.__enter__.return_value = mock_builder_instance\n    mock_builder_instance.__exit__.return_value = None\n\n    # NOTE: find_latest_cached_package must return a tuple, otherwise\n    # the 'assert built_pkg is not None' line in install() will fail.\n    def mock_find_cached(store, package):\n        # return just the package, so that mock pacman can get the package name from the 'file' name\n        return (\"1.0.0\", package)\n\n    mocker.patch(\"decman.plugins.aur.fpm.find_latest_cached_package\", side_effect=mock_find_cached)\n    mocker.patch(\"decman.plugins.aur.fpm.add_package_to_cache\", return_value=None)\n\n    # handle prompts automatically\n    mocker.patch(\"decman.core.output.prompt_confirm\", return_value=True)\n\n    store = FakeStore()\n    search = PackageSearch()\n    commands = AurCommands()\n    mgr = ForeignPackageManager(\n        store=store,  # type: ignore\n        pacman=mock_pacman,\n        search=search,\n        commands=commands,\n        pkg_cache_dir=\"/tmp/cache\",\n        build_dir=\"/tmp/build\",\n        makepkg_user=\"nobody\",\n    )\n\n    return mgr\n\n\ndef test_remove_pacman_deps_provided_by_foreign_packages(\n    mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface\n):\n    mock_pacman.not_installable |= {\"kwin-hifps\", \"qt6-base-hifps\", \"syncthingtray-qt6\"}\n    pkgs = [\n        PackageInfo(\n            pkgbase=\"kwin-hifps\",\n            pkgname=\"kwin-hifps\",\n            version=\"1\",\n            git_url=\"...\",\n            dependencies=(\"qt6-base-hifps\",),\n        ),\n        PackageInfo(\n            pkgbase=\"qt6-base-hifps\",\n            pkgname=\"qt6-base-hifps\",\n            version=\"1\",\n            git_url=\"...\",\n            provides=(\"qt6-base\",),\n        ),\n        PackageInfo(\n            pkgbase=\"syncthingtray-qt6\",\n            pkgname=\"syncthingtray-qt6\",\n            version=\"1\",\n            git_url=\"...\",\n            dependencies=(\"qt6-base\",),\n        ),\n    ]\n    mock_aur.seed(pkgs)\n\n    mock_fpm.install([\"kwin-hifps\", \"syncthingtray-qt6\"])\n\n    assert len(mock_pacman.installed_files) == 3\n    assert mock_pacman.explicitly_installed == {\"kwin-hifps\", \"syncthingtray-qt6\"}\n    assert \"qt6-base\" not in mock_pacman.installed_native\n\n\ndef test_remove_pacman_deps_provided_by_already_installed_foreign_packages(\n    mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface\n):\n    mock_pacman.not_installable |= {\"kwin-hifps\", \"qt6-base-hifps\", \"syncthingtray-qt6\"}\n    pkgs = [\n        PackageInfo(\n            pkgbase=\"kwin-hifps\",\n            pkgname=\"kwin-hifps\",\n            version=\"1\",\n            git_url=\"...\",\n            dependencies=(\"qt6-base-hifps\",),\n        ),\n        PackageInfo(\n            pkgbase=\"qt6-base-hifps\",\n            pkgname=\"qt6-base-hifps\",\n            version=\"1\",\n            git_url=\"...\",\n            provides=(\"qt6-base\",),\n        ),\n        PackageInfo(\n            pkgbase=\"syncthingtray-qt6\",\n            pkgname=\"syncthingtray-qt6\",\n            version=\"1\",\n            git_url=\"...\",\n            dependencies=(\"qt6-base\",),\n        ),\n    ]\n    mock_pacman.installed_foreign = {\n        \"kwin-hifps\": \"1\",\n        \"qt6-base-hifps\": \"1\",\n    }\n    mock_pacman.explicitly_installed.add(\"kwin-hifps\")\n    mock_pacman.provided_pkgs.add(\"qt6-base\")\n    mock_aur.seed(pkgs)\n\n    mock_fpm.install([\"syncthingtray-qt6\"])\n\n    assert len(mock_pacman.installed_files) == 1\n    assert mock_pacman.explicitly_installed == {\"kwin-hifps\", \"syncthingtray-qt6\"}\n    assert \"qt6-base\" not in mock_pacman.installed_native\n\n\ndef test_install_simple_package(\n    mock_fpm, mock_pacman: FakeAurPacmanInterface, mock_aur: MockAurServer\n):\n    mock_pacman.not_installable.add(\"foo\")\n    pkg = PackageInfo(\n        pkgbase=\"foo\",\n        pkgname=\"foo\",\n        version=\"100.0.0\",\n        git_url=\"...\",\n    )\n    mock_aur.seed([pkg])\n\n    mock_fpm.install([\"foo\"])\n\n    assert len(mock_pacman.installed_files) == 1\n    assert \"foo\" in mock_pacman.installed_files[0]\n    assert \"foo\" in mock_pacman.explicitly_installed\n    assert \"foo\" in mock_pacman.installed_foreign\n\n\ndef test_upgrade_foreign_package(mock_fpm, mock_pacman, mock_aur):\n    mock_pacman.not_installable.add(\"my-app\")\n    mock_pacman.installed_foreign = {\"my-app\": \"1.0\"}\n    mock_pacman.explicitly_installed = {\"my-app\"}\n\n    pkg = PackageInfo(\n        pkgbase=\"my-app\",\n        pkgname=\"my-app\",\n        version=\"2.0\",\n        git_url=\"...\",\n    )\n    mock_aur.seed([pkg])\n\n    mock_fpm.upgrade()\n\n    assert len(mock_pacman.installed_files) == 1\n    assert \"my-app\" in mock_pacman.installed_foreign\n    assert \"my-app\" in mock_pacman.installed_files[0]\n\n\ndef test_upgrade_skips_current_package(mock_fpm, mock_pacman, mock_aur):\n    mock_pacman.not_installable.add(\"stable-app\")\n    mock_pacman.installed_foreign = {\"stable-app\": \"5.0\"}\n    mock_pacman.explicitly_installed = {\"stable-app\"}\n\n    pkg = PackageInfo(\n        pkgbase=\"stable-app\",\n        pkgname=\"stable-app\",\n        version=\"5.0\",\n        git_url=\"...\",\n    )\n    mock_aur.seed([pkg])\n\n    mock_fpm.upgrade()\n\n    assert len(mock_pacman.installed_files) == 0\n\n\ndef test_install_resolves_dependencies(mock_fpm, mock_pacman, mock_aur):\n    mock_pacman.not_installable |= {\"lib-helper\", \"main-app\"}\n    pkg_dep = PackageInfo(pkgbase=\"lib-helper\", pkgname=\"lib-helper\", version=\"1.5\", git_url=\"...\")\n    pkg_main = PackageInfo(\n        pkgbase=\"main-app\",\n        pkgname=\"main-app\",\n        version=\"2.0\",\n        dependencies=(\"lib-helper\",),\n        git_url=\"...\",\n    )\n\n    mock_aur.seed([pkg_dep, pkg_main])\n\n    mock_fpm.install([\"main-app\"])\n\n    assert len(mock_pacman.installed_files) == 2\n    assert \"main-app\" in mock_pacman.explicitly_installed\n    assert \"main-app\" in mock_pacman.installed_files\n    assert \"lib-helper\" in mock_pacman.installed_files\n"
  },
  {
    "path": "plugins/decman-systemd/pyproject.toml",
    "content": "[project]\nname = \"decman-systemd\"\nversion = \"1.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\"decman==1.2.1\"]\n\n[project.entry-points.\"decman.plugins\"]\nsystemd = \"decman.plugins.systemd:Systemd\"\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.setuptools.packages.find]\nwhere = [\"src\"]\nnamespaces = true\ninclude = [\"decman.plugins*\"]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n"
  },
  {
    "path": "plugins/decman-systemd/src/decman/plugins/systemd.py",
    "content": "import shutil\n\nimport decman.config as config\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\nimport decman.plugins as plugins\n\n\ndef units(fn):\n    \"\"\"\n    Annotate that this function returns a set of systemd unit names that should be enabled.\n\n    Return type of ``fn``: ``set[str]``\n    \"\"\"\n    fn.__systemd__units__ = True\n    return fn\n\n\ndef user_units(fn):\n    \"\"\"\n    Annotate that this function returns a dict of users and systemd user unit names that should be\n    enabled.\n\n    Return type of ``fn``: ``dict[str, set[str]]``\n    \"\"\"\n    fn.__systemd__user__units__ = True\n    return fn\n\n\nclass SystemdCommands:\n    \"\"\"\n    Default commands for the Systemd plugin.\n    \"\"\"\n\n    def enable_units(self, units: set[str]) -> list[str]:\n        \"\"\"\n        Running this command enables the given systemd units.\n        \"\"\"\n        return [\"systemctl\", \"enable\"] + list(units)\n\n    def disable_units(self, units: set[str]) -> list[str]:\n        \"\"\"\n        Running this command disables the given systemd units.\n        \"\"\"\n        return [\"systemctl\", \"disable\"] + list(units)\n\n    def enable_user_units(self, units: set[str], user: str) -> list[str]:\n        \"\"\"\n        Running this command enables the given systemd units for the user.\n        \"\"\"\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"enable\"] + list(units)\n\n    def disable_user_units(self, units: set[str], user: str) -> list[str]:\n        \"\"\"\n        Running this command disables the given systemd units for the user.\n        \"\"\"\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"disable\"] + list(units)\n\n    def daemon_reload(self) -> list[str]:\n        \"\"\"\n        Running this command reloads the systemd daemon.\n        \"\"\"\n        return [\"systemctl\", \"daemon-reload\"]\n\n    def user_daemon_reload(self, user: str) -> list[str]:\n        \"\"\"\n        Running this command reloads the systemd daemon for the given user.\n        \"\"\"\n        return [\"systemctl\", \"--user\", \"-M\", f\"{user}@\", \"daemon-reload\"]\n\n\nclass Systemd(plugins.Plugin):\n    NAME = \"systemd\"\n\n    def __init__(self) -> None:\n        self.enabled_units: set[str] = set()\n        self.enabled_user_units: dict[str, set[str]] = {}\n        self.commands = SystemdCommands()\n\n    def available(self) -> bool:\n        return shutil.which(\"systemctl\") is not None\n\n    def process_modules(self, store: _store.Store, modules: list[module.Module]):\n        # These store keys are used to track changes in modules.\n        # This way when these change, module can be marked as changed\n        store.ensure(\"systemd_units_for_module\", {})\n        store.ensure(\"systemd_user_units_for_module\", {})\n\n        for mod in modules:\n            store[\"systemd_units_for_module\"].setdefault(mod.name, set())\n            store[\"systemd_user_units_for_module\"].setdefault(mod.name, {})\n\n            units = set().union(*plugins.run_methods_with_attribute(mod, \"__systemd__units__\"))\n            user_units = {\n                k: v\n                for d in plugins.run_methods_with_attribute(mod, \"__systemd__user__units__\")\n                for k, v in d.items()\n            }\n\n            if store[\"systemd_units_for_module\"][mod.name] != units:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified systemd units.\"\n                )\n\n            if store[\"systemd_user_units_for_module\"][mod.name] != user_units:\n                mod._changed = True\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified systemd user units.\"\n                )\n\n            self.enabled_units |= units\n            for user, u_units in user_units.items():\n                self.enabled_user_units.setdefault(user, set()).update(u_units)\n\n            store[\"systemd_units_for_module\"][mod.name] = units\n            store[\"systemd_user_units_for_module\"][mod.name] = user_units\n\n    def apply(\n        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None\n    ) -> bool:\n        store.ensure(\"systemd_units\", set())\n        store.ensure(\"systemd_user_units\", {})\n\n        units_to_enable = set()\n        units_to_disable = set()\n        user_units_to_enable: dict[str, set[str]] = {}\n        user_units_to_disable: dict[str, set[str]] = {}\n\n        for unit in self.enabled_units:\n            if unit not in store[\"systemd_units\"]:\n                units_to_enable.add(unit)\n\n        for unit in store[\"systemd_units\"]:\n            if unit not in self.enabled_units:\n                units_to_disable.add(unit)\n\n        for user, units in self.enabled_user_units.items():\n            store[\"systemd_user_units\"].setdefault(user, set())\n            user_units_to_enable.setdefault(user, set())\n\n            for unit in units:\n                if unit not in store[\"systemd_user_units\"][user]:\n                    user_units_to_enable[user].add(unit)\n\n        for user, units in store[\"systemd_user_units\"].items():\n            self.enabled_user_units.setdefault(user, set())\n            user_units_to_disable.setdefault(user, set())\n\n            for unit in units:\n                if unit not in self.enabled_user_units[user]:\n                    user_units_to_disable[user].add(unit)\n\n        try:\n            output.print_list(\"Enabling systemd units:\", list(units_to_enable))\n            if not dry_run:\n                self.enable_units(store, units_to_enable)\n\n            output.print_list(\"Disabling systemd units:\", list(units_to_disable))\n            if not dry_run:\n                self.disable_units(store, units_to_disable)\n\n            for user, units in user_units_to_enable.items():\n                output.print_list(f\"Enabling systemd units for {user}:\", list(units))\n                if not dry_run:\n                    self.enable_user_units(store, units, user)\n\n            for user, units in user_units_to_disable.items():\n                output.print_list(f\"Disabling systemd units for {user}:\", list(units))\n                if not dry_run:\n                    self.disable_user_units(store, units, user)\n\n            output.print_info(\"Reloading systemd daemon.\")\n            if not dry_run:\n                self.reload_daemon()\n\n            output.print_info(\"Reloading systemd daemon for users.\")\n            if not dry_run:\n                for user in user_units_to_enable.keys() | user_units_to_disable.keys():\n                    self.reload_user_daemon(user)\n        except errors.CommandFailedError as error:\n            output.print_error(\"Running a systemd command failed.\")\n            output.print_error(str(error))\n            if error.output:\n                output.print_command_output(error.output)\n            output.print_traceback()\n            return False\n        return True\n\n    def enable_units(self, store: _store.Store, units: set[str]):\n        \"\"\"\n        Enables the given units.\n        \"\"\"\n        if not units:\n            return\n\n        cmd = self.commands.enable_units(units)\n        command.prg(cmd, pty=config.debug_output)\n\n        store[\"systemd_units\"] |= units\n\n    def disable_units(self, store: _store.Store, units: set[str]):\n        \"\"\"\n        Disables the given units.\n        \"\"\"\n        if not units:\n            return\n\n        cmd = self.commands.disable_units(units)\n        command.prg(cmd, pty=config.debug_output, check=False)\n\n        store[\"systemd_units\"] -= units\n\n    def enable_user_units(self, store: _store.Store, units: set[str], user: str):\n        \"\"\"\n        Enables the given units for the given user.\n        \"\"\"\n        if not units:\n            return\n\n        # Use check=False to avoid issues when units don't exist\n        cmd = self.commands.enable_user_units(units, user)\n        command.prg(cmd, pty=config.debug_output)\n\n        store[\"systemd_user_units\"].setdefault(user, set())\n        store[\"systemd_user_units\"][user] |= units\n\n    def disable_user_units(self, store: _store.Store, units: set[str], user: str):\n        \"\"\"\n        Disables the given units for the given user.\n        \"\"\"\n        if not units:\n            return\n\n        # Use check=False to avoid issues when units don't exist\n        cmd = self.commands.disable_user_units(units, user)\n        command.prg(cmd, pty=config.debug_output, check=False)\n\n        store[\"systemd_user_units\"].setdefault(user, set())\n        store[\"systemd_user_units\"][user] -= units\n\n    def reload_user_daemon(self, user: str):\n        \"\"\"\n        Reloads the user's systemd daemon.\n        \"\"\"\n\n        cmd = self.commands.user_daemon_reload(user)\n        command.prg(cmd, pty=config.debug_output, check=False)\n\n    def reload_daemon(self):\n        \"\"\"\n        Reloads the systemd daemon.\n        \"\"\"\n\n        cmd = self.commands.daemon_reload()\n        command.prg(cmd, pty=config.debug_output)\n"
  },
  {
    "path": "plugins/decman-systemd/tests/test_decman_plugins_systemd.py",
    "content": "import pytest\n\nfrom decman.plugins import systemd as systemd_mod\n\n\nclass DummyStore(dict):\n    def ensure(self, key, default):\n        if key not in self:\n            self[key] = default\n\n\nclass DummyModule:\n    def __init__(self, name: str):\n        self.name = name\n        self._changed = False\n\n\n@pytest.fixture\ndef store():\n    return DummyStore()\n\n\n@pytest.fixture\ndef systemd():\n    return systemd_mod.Systemd()\n\n\ndef test_units_decorator_sets_attribute():\n    @systemd_mod.units\n    def fn():\n        pass\n\n    assert getattr(fn, \"__systemd__units__\", False) is True\n\n\ndef test_user_units_decorator_sets_attribute():\n    @systemd_mod.user_units\n    def fn():\n        pass\n\n    assert getattr(fn, \"__systemd__user__units__\", False) is True\n\n\ndef test_available_true_if_systemctl_found(monkeypatch, systemd):\n    called = {}\n\n    def fake_which(name):\n        called[\"name\"] = name\n        return \"/bin/systemctl\"\n\n    monkeypatch.setattr(systemd_mod.shutil, \"which\", fake_which)\n    assert systemd.available() is True\n    assert called[\"name\"] == \"systemctl\"\n\n\ndef test_available_false_if_systemctl_missing(monkeypatch, systemd):\n    monkeypatch.setattr(systemd_mod.shutil, \"which\", lambda name: None)\n    assert systemd.available() is False\n\n\ndef test_process_modules_marks_changed_and_updates_store(monkeypatch, store, systemd):\n    # initial store empty; ensure keys will be created\n    m1 = DummyModule(\"mod1\")\n    m2 = DummyModule(\"mod2\")\n\n    def fake_run_method(mod, attr):\n        if mod is m1 and attr == \"__systemd__units__\":\n            return [{\"a.service\"}]\n        if mod is m1 and attr == \"__systemd__user__units__\":\n            return [{\"alice\": {\"u1.service\"}}]\n        # m2 has no units\n        return []\n\n    monkeypatch.setattr(systemd_mod.plugins, \"run_methods_with_attribute\", fake_run_method)\n\n    systemd.process_modules(store, {m1, m2})\n\n    # m1 changed from default -> marked _changed\n    assert m1._changed is True\n    # m2 had no units\n    assert m2._changed is False\n\n    # enabled units aggregated\n    assert systemd.enabled_units == {\"a.service\"}\n    assert systemd.enabled_user_units == {\"alice\": {\"u1.service\"}}\n\n    # store updated per module\n    assert store[\"systemd_units_for_module\"][\"mod1\"] == {\"a.service\"}\n    assert store[\"systemd_user_units_for_module\"][\"mod1\"] == {\"alice\": {\"u1.service\"}}\n    assert store[\"systemd_units_for_module\"][\"mod2\"] == set()\n    assert store[\"systemd_user_units_for_module\"][\"mod2\"] == {}\n\n\ndef test_process_modules_no_change_second_run(monkeypatch, store, systemd):\n    m1 = DummyModule(\"mod1\")\n\n    def fake_run_method(mod, attr):\n        if attr == \"__systemd__units__\":\n            return [{\"a.service\"}]\n        if attr == \"__systemd__user__units__\":\n            return [{\"alice\": {\"u1.service\"}}]\n        return []\n\n    monkeypatch.setattr(systemd_mod.plugins, \"run_methods_with_attribute\", fake_run_method)\n\n    # first run populates store\n    systemd.process_modules(store, {m1})\n    m1._changed = False\n\n    # new instance (fresh per-process in real usage)\n    systemd2 = systemd_mod.Systemd()\n    monkeypatch.setattr(systemd_mod.plugins, \"run_methods_with_attribute\", fake_run_method)\n\n    systemd2.process_modules(store, {m1})\n\n    # values in store are same -> _changed stays False\n    assert m1._changed is False\n\n\ndef test_apply_enables_and_disables_units_and_user_units(store):\n    s = systemd_mod.Systemd()\n\n    # Current enabled according to modules\n    s.enabled_units = {\"new.service\"}\n    s.enabled_user_units = {\"alice\": {\"newuser.service\"}}\n\n    # Store says we had an old unit enabled before\n    store[\"systemd_units\"] = {\"old.service\"}\n    store[\"systemd_user_units\"] = {\"alice\": {\"olduser.service\"}}\n\n    calls = []\n\n    def fake_reload_daemon():\n        calls.append((\"reload_daemon\",))\n\n    def fake_reload_user_daemon(user):\n        calls.append((\"reload_user_daemon\", user))\n\n    def fake_enable_units(store_arg, units_arg):\n        calls.append((\"enable_units\", frozenset(units_arg)))\n        store_arg[\"systemd_units\"] |= units_arg\n\n    def fake_disable_units(store_arg, units_arg):\n        calls.append((\"disable_units\", frozenset(units_arg)))\n        store_arg[\"systemd_units\"] -= units_arg\n\n    def fake_enable_user_units(store_arg, units_arg, user):\n        calls.append((\"enable_user_units\", user, frozenset(units_arg)))\n        store_arg[\"systemd_user_units\"].setdefault(user, set()).update(units_arg)\n\n    def fake_disable_user_units(store_arg, units_arg, user):\n        calls.append((\"disable_user_units\", user, frozenset(units_arg)))\n        store_arg[\"systemd_user_units\"].setdefault(user, set()).difference_update(units_arg)\n\n    # patch instance methods (no self parameter expected)\n    s.reload_daemon = fake_reload_daemon\n    s.reload_user_daemon = fake_reload_user_daemon\n    s.enable_units = fake_enable_units\n    s.disable_units = fake_disable_units\n    s.enable_user_units = fake_enable_user_units\n    s.disable_user_units = fake_disable_user_units\n\n    result = s.apply(store, dry_run=False, params=None)\n\n    # reloads called once\n    assert (\"reload_daemon\",) in calls\n    assert (\"reload_user_daemon\", \"alice\") in calls\n\n    # enable/disable correct units\n    assert (\"enable_units\", frozenset({\"new.service\"})) in calls\n    assert (\"disable_units\", frozenset({\"old.service\"})) in calls\n    assert (\"enable_user_units\", \"alice\", frozenset({\"newuser.service\"})) in calls\n    assert (\"disable_user_units\", \"alice\", frozenset({\"olduser.service\"})) in calls\n\n    # store reconciled\n    assert store[\"systemd_units\"] == {\"new.service\"}\n    assert store[\"systemd_user_units\"][\"alice\"] == {\"newuser.service\"}\n\n\ndef test_apply_dry_run_does_not_mutate_store_or_call_commands(store):\n    s = systemd_mod.Systemd()\n    s.enabled_units = {\"new.service\"}\n    s.enabled_user_units = {\"alice\": {\"newuser.service\"}}\n\n    store[\"systemd_units\"] = {\"old.service\"}\n    store[\"systemd_user_units\"] = {\"alice\": {\"olduser.service\"}}\n\n    called = {\"reload\": False, \"enable\": False, \"disable\": False}\n\n    s.reload_daemon = lambda: called.__setitem__(\"reload\", True) or True\n    s.reload_user_daemon = lambda user: called.__setitem__(\"reload\", True) or True\n    s.enable_units = lambda st, u: called.__setitem__(\"enable\", True) or True\n    s.disable_units = lambda st, u: called.__setitem__(\"disable\", True) or True\n    s.enable_user_units = lambda st, u, user: called.__setitem__(\"enable\", True) or True\n    s.disable_user_units = lambda st, u, user: called.__setitem__(\"disable\", True) or True\n\n    result = s.apply(store, dry_run=True, params=None)\n    assert result is True\n\n    # no commands should be called\n    assert called == {\"reload\": False, \"enable\": False, \"disable\": False}\n\n    # store unchanged\n    assert store[\"systemd_units\"] == {\"old.service\"}\n    assert store[\"systemd_user_units\"][\"alice\"] == {\"olduser.service\"}\n\n\ndef test_enable_units_success(monkeypatch, store, systemd):\n    store[\"systemd_units\"] = {\"old.service\"}\n\n    def fake_run(cmd, **kwargs):\n        assert cmd[0] == \"systemctl\"\n        assert cmd[1] == \"enable\"\n        assert \"new.service\" in cmd[2:]\n        return 0, \"ok\"\n\n    monkeypatch.setattr(systemd_mod.command, \"run\", fake_run)\n\n    systemd.enable_units(store, {\"new.service\"})\n    assert store[\"systemd_units\"] == {\"old.service\", \"new.service\"}\n\n\ndef test_disable_units_success(monkeypatch, store, systemd):\n    store[\"systemd_units\"] = {\"old.service\", \"new.service\"}\n\n    def fake_run(cmd, **kwargs):\n        assert cmd[0] == \"systemctl\"\n        assert cmd[1] == \"disable\"\n        assert \"new.service\" in cmd[2:]\n        return 0, \"ok\"\n\n    monkeypatch.setattr(systemd_mod.command, \"run\", fake_run)\n\n    systemd.disable_units(store, {\"new.service\"})\n    assert store[\"systemd_units\"] == {\"old.service\"}\n\n\ndef test_enable_user_units_success(monkeypatch, store, systemd):\n    store[\"systemd_user_units\"] = {\"alice\": {\"olduser.service\"}}\n\n    def fake_run(cmd, **kwargs):\n        assert cmd[0] == \"systemctl\"\n        assert \"--user\" in cmd\n        assert \"enable\" in cmd\n        assert \"newuser.service\" in cmd\n        return 0, \"ok\"\n\n    monkeypatch.setattr(systemd_mod.command, \"run\", fake_run)\n\n    systemd.enable_user_units(store, {\"newuser.service\"}, \"alice\")\n    assert store[\"systemd_user_units\"][\"alice\"] == {\n        \"olduser.service\",\n        \"newuser.service\",\n    }\n\n\ndef test_disable_user_units_success(monkeypatch, store, systemd):\n    store[\"systemd_user_units\"] = {\"alice\": {\"olduser.service\", \"newuser.service\"}}\n\n    def fake_run(cmd, **kwargs):\n        assert cmd[0] == \"systemctl\"\n        assert \"--user\" in cmd\n        assert \"disable\" in cmd\n        assert \"newuser.service\" in cmd\n        return 0, \"ok\"\n\n    monkeypatch.setattr(systemd_mod.command, \"run\", fake_run)\n\n    systemd.disable_user_units(store, {\"newuser.service\"}, \"alice\")\n    assert store[\"systemd_user_units\"][\"alice\"] == {\"olduser.service\"}\n\n\ndef test_reload_daemon_uses_command_run(monkeypatch, systemd):\n    called = {}\n\n    def fake_run(cmd, **kwargs):\n        called[\"cmd\"] = cmd\n        return 0, \"ok\"\n\n    monkeypatch.setattr(systemd_mod.command, \"run\", fake_run)\n    systemd.reload_daemon()\n    assert called[\"cmd\"][:2] == [\"systemctl\", \"daemon-reload\"]\n\n\ndef test_reload_user_daemon_uses_command_run(monkeypatch, systemd):\n    called = {}\n\n    def fake_run(cmd, **kwargs):\n        called[\"cmd\"] = cmd\n        return 0, \"ok\"\n\n    monkeypatch.setattr(systemd_mod.command, \"run\", fake_run)\n    systemd.reload_user_daemon(\"alice\")\n    cmd = called[\"cmd\"]\n    assert cmd[0] == \"systemctl\"\n    assert \"--user\" in cmd\n    assert \"daemon-reload\" in cmd\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"decman\"\nversion = \"1.2.1\"\ndescription = \"Declarative package & configuration manager for Arch Linux.\"\nlicense = \"GPL-3.0-or-later\"\nlicense-files = [\"LICENSE\"]\nauthors = [\n    {name = \"Kivi Kaitaniemi\"}\n]\nrequires-python = \">=3.13\"\n\n[project.optional-dependencies]\npacman = [\"decman-pacman\"]\nsystemd = [\"decman-systemd\"]\nflatpak = [\"decman-flatpak\"]\n\n[project.scripts]\ndecman = \"decman.app:main\"\n\n[dependency-groups]\ndev = [\n    \"ruff>=0.14.9\",\n    \"pytest>=8.4.2\",\n]\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.uv.workspace]\nmembers = [\n    \"plugins/decman-pacman\",\n    \"plugins/decman-systemd\",\n    \"plugins/decman-flatpak\",\n]\n\n[tool.uv.sources]\ndecman = { workspace = true }\ndecman-pacman = { workspace = true }\ndecman-systemd = { workspace = true }\ndecman-flatpak = { workspace = true }\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n\n[tool.ruff]\nline-length = 100\ntarget-version = \"py313\"\n\n[tool.ruff.lint]\nselect = [\n    \"E\", \"F\", \"W\",  # base style/errors\n    \"I\",            # import sorting\n    \"B\",            # bugbear\n]\n\n[tool.ruff.format]\nquote-style = \"double\"\nindent-style = \"space\"\nskip-magic-trailing-comma = false\n\n"
  },
  {
    "path": "src/decman/__init__.py",
    "content": "import typing\n\n# Re-exports\nfrom decman.core.command import prg\nfrom decman.core.error import SourceError\nfrom decman.core.fs import Directory, File, Symlink\nfrom decman.core.module import Module\nfrom decman.core.store import Store\nfrom decman.plugins import Plugin, available_plugins\n\nplugins: dict[str, Plugin] = available_plugins()\n\n# Quick access for default plugins\ntry:\n    from decman.plugins.aur import AUR\n    from decman.plugins.pacman import Pacman\n\n    pacman: None | Pacman = None\n    _pacman = plugins.get(\"pacman\", None)\n    if isinstance(_pacman, Pacman):\n        pacman = _pacman\n\n    aur: None | AUR = None\n    _aur = plugins.get(\"aur\", None)\n    if isinstance(_aur, AUR):\n        aur = _aur\nexcept ModuleNotFoundError:\n    pass\n\ntry:\n    from decman.plugins.flatpak import Flatpak\n\n    flatpak: None | Flatpak = None\n    _flatpak = plugins.get(\"flatpak\", None)\n    if isinstance(_flatpak, Flatpak):\n        flatpak = _flatpak\nexcept ModuleNotFoundError:\n    pass\n\ntry:\n    from decman.plugins.systemd import Systemd\n\n    systemd: None | Systemd = None\n    _systemd = plugins.get(\"systemd\", None)\n    if isinstance(_systemd, Systemd):\n        systemd = _systemd\nexcept ModuleNotFoundError:\n    pass\n\n__all__ = [\n    \"SourceError\",\n    \"File\",\n    \"Directory\",\n    \"Symlink\",\n    \"Module\",\n    \"Store\",\n    \"Plugin\",\n    \"prg\",\n    \"sh\",\n]\n\n# -----------------------------------------\n# Global variables for system configuration\n# -----------------------------------------\nfiles: dict[str, File] = {}\ndirectories: dict[str, Directory] = {}\nsymlinks: dict[str, str | Symlink] = {}\nmodules: list[Module] = []\nexecution_order: list[str] = [\n    \"files\",\n    \"pacman\",\n    \"aur\",\n    \"systemd\",\n]\n\n\ndef sh(\n    sh_cmd: str,\n    user: typing.Optional[str] = None,\n    env_overrides: typing.Optional[dict[str, str]] = None,\n    mimic_login: bool = False,\n    pty: bool = True,\n    check: bool = True,\n) -> str:\n    \"\"\"\n    Shortcut for running a shell command. Returns the output of that command.\n\n    Arguments:\n        sh_cmd:\n            Shell command to execute. The command is passed to the system shell /bin/sh.\n\n        user:\n            User name to run the command as. If set, the command is executed after dropping\n            privileges to this user.\n\n        env_overrides:\n            Environment variables to override or add for the command execution.\n            These values are merged on top of the current process environment.\n\n        mimic_login:\n            If mimic_login is True, will set the following environment variables according to the\n            given user's passwd file details. This only happens when user is set.\n                - HOME\n                - USER\n                - LOGNAME\n                - SHELL\n\n        pty:\n            If True, run the command inside a pseudo-terminal (PTY). This enables interactive\n            behavior and terminal-dependent programs. If False, run the command without a PTY\n            using standard subprocess execution.\n\n        check:\n            If True, raise CommandFailedError when the command exits with a non-zero status.\n            If False, print a warning when encountering a non-zero exit code.\n    \"\"\"\n    cmd = [\"/bin/sh\", \"-c\", sh_cmd]\n    return prg(\n        cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login, pty=pty, check=check\n    )\n"
  },
  {
    "path": "src/decman/app.py",
    "content": "import argparse\nimport os\nimport sys\n\nimport decman\nimport decman.config as conf\nimport decman.core.error as errors\nimport decman.core.file_manager as file_manager\nimport decman.core.module as _module\nimport decman.core.output as output\nimport decman.core.store as _store\n\n_STORE_FILE = \"/var/lib/decman/store.json\"\n\n\ndef main():\n    \"\"\"\n    Main entry for the CLI app\n    \"\"\"\n\n    sys.pycache_prefix = os.path.join(conf.cache_dir, \"python/\")\n\n    parser = argparse.ArgumentParser(\n        prog=\"decman\",\n        description=\"Declarative package & configuration manager for Arch Linux\",\n        epilog=\"See the documentation: https://github.com/kiviktnm/decman\",\n    )\n\n    parser.add_argument(\"--source\", action=\"store\", help=\"python file containing configuration\")\n    parser.add_argument(\n        \"--dry-run\",\n        \"--print\",\n        action=\"store_true\",\n        default=False,\n        help=\"print what would happen as a result of running decman\",\n    )\n    parser.add_argument(\"--debug\", action=\"store_true\", default=False, help=\"show debug output\")\n    parser.add_argument(\n        \"--skip\", nargs=\"*\", type=str, default=[], help=\"skip the following execution steps\"\n    )\n    parser.add_argument(\n        \"--only\", nargs=\"*\", type=str, default=[], help=\"run only the following execution steps\"\n    )\n    parser.add_argument(\n        \"--no-hooks\",\n        action=\"store_true\",\n        default=False,\n        help=\"don't run hook methods for modules\",\n    )\n    parser.add_argument(\n        \"--no-color\",\n        action=\"store_true\",\n        default=False,\n        help=\"don't print messages with color\",\n    )\n    parser.add_argument(\n        \"--params\", nargs=\"*\", default=[], type=str, help=\"additional parameters passed to plugins\"\n    )\n\n    args = parser.parse_args()\n\n    conf.debug_output = args.debug\n\n    if args.no_color:\n        conf.color_output = False\n    else:\n        conf.color_output = output.has_ansi_support()\n\n    if os.getuid() != 0:\n        output.print_error(\"Not running as root. Please run decman as root.\")\n        sys.exit(1)\n\n    original_wd = os.getcwd()\n    failed = False\n\n    try:\n        with _store.Store(_STORE_FILE, args.dry_run) as store:\n            try:\n                _execute_source(store, args)\n                failed = not run_decman(store, args)\n            except errors.SourceError as error:\n                output.print_error(f\"Error raised manually in the source: {error}\")\n                output.print_traceback()\n                failed = True\n            except errors.CommandFailedError as error:\n                output.print_error(str(error))\n                if error.output:\n                    output.print_command_output(error.output)\n                output.print_traceback()\n                failed = True\n            except ValueError as error:\n                output.print_error(\"ValueError raised from the source.\")\n                output.print_error(str(error))\n                output.print_traceback()\n                failed = True\n            except errors.InvalidOnDisableError as error:\n                output.print_error(str(error))\n                output.print_traceback()\n                failed = True\n            except Exception as error:\n                output.print_error(f\"Unexpected error while running decman: {error}\")\n                output.print_traceback()\n                failed = True\n    except OSError as error:\n        output.print_error(\n            f\"Failed to access decman store file '{_STORE_FILE}': {error.strerror or str(error)}.\"\n        )\n        output.print_error(\"This may cause already completed operations to run again.\")\n        output.print_traceback()\n    except KeyboardInterrupt:\n        output.print_error(\"Interrupted by the user.\")\n        failed = True\n    finally:\n        os.chdir(original_wd)\n\n    if failed:\n        sys.exit(1)\n\n\ndef _execute_source(store: _store.Store, args: argparse.Namespace):\n    \"\"\"\n    Runs decman source. May call ``sys.exit(1)`` if user aborts running the source or reading the\n    source fails.\n\n    Raises:\n        ``SourceError``\n            If code in the source raises this error manually.\n\n        ``InvalidOnDisableError``\n            If modules in the source have invalid on_disable functions.\n    \"\"\"\n    source = store.get(\"source_file\", None)\n    source_changed = False\n\n    if args.source is not None:\n        source = args.source\n        source_changed = True\n\n    if source is None:\n        output.print_error(\n            \"Source was not specified. Please specify a source with the '--source' argument.\"\n        )\n        output.print_info(\"Decman will remember the previously specified source.\")\n        sys.exit(1)\n\n    if source_changed or not store.get(\"allow_running_source_without_prompt\", False):\n        output.print_warning(f\"Decman will run the file '{source}' as root!\")\n        output.print_warning(\n            \"Only proceed if you trust the file completely. The file can also import other files.\"\n        )\n\n        if not output.prompt_confirm(\"Proceed?\", default=False):\n            sys.exit(1)\n\n        if output.prompt_confirm(\"Remember this choice?\", default=False):\n            store[\"allow_running_source_without_prompt\"] = True\n\n    source_path = os.path.abspath(source)\n    source_dir = os.path.dirname(source_path)\n    store[\"source_file\"] = source_path\n\n    try:\n        with open(source_path, \"rt\", encoding=\"utf-8\") as file:\n            content = file.read()\n    except OSError as error:\n        output.print_error(f\"Failed to read source '{source_path}': {error.strerror or str(error)}\")\n        sys.exit(1)\n\n    os.chdir(source_dir)\n    sys.path.append(\".\")\n    exec(content)\n\n\ndef run_decman(store: _store.Store, args: argparse.Namespace) -> bool:\n    \"\"\"\n    Runs decman with the given arguments and a store.\n\n    Returns ``True`` if executed succesfully. Otherwise ``False``.\n\n    Raises:\n        ``SourceError``\n            If code in the source raises this error manually.\n\n        ``CommandFailedError``\n            If running any command fails.\n    \"\"\"\n\n    output.print_debug(f\"Available plugins: {' '.join(decman.plugins.keys())}\")\n\n    store.ensure(\"enabled_modules\", [])\n    store.ensure(\"module_on_disable_scripts\", {})\n\n    execution_order = _determine_execution_order(args)\n    new_modules = _find_new_modules(store)\n    disabled_modules = _find_disabled_modules(store)\n\n    # Disable hooks should be run before anything else because they might depend on packages that\n    # are going to get removed.\n    if not args.no_hooks:\n        _run_before_update(store, args)\n        _run_on_disable(store, args, disabled_modules)\n\n    # Run main execution order\n    for step in execution_order:\n        output.print_info(f\"Running step '{step}'.\")\n        match step:\n            case \"files\":\n                if not file_manager.update_files(\n                    store,\n                    decman.modules,\n                    decman.files,\n                    decman.directories,\n                    decman.symlinks,\n                    dry_run=args.dry_run,\n                ):\n                    return False\n            case plugin_name:\n                plugin = decman.plugins.get(plugin_name, None)\n                if plugin:\n                    plugin.process_modules(store, decman.modules)\n                    if not plugin.apply(store, dry_run=args.dry_run, params=args.params):\n                        return False\n                else:\n                    output.print_warning(\n                        f\"Plugin '{plugin_name}' configured in execution_order, \"\n                        \"but not found in available plugins.\"\n                    )\n\n    # On enable and on change should be ran last since they might depend on effects caused by\n    # execution steps.\n    if not args.no_hooks:\n        _run_on_enable(store, args, new_modules)\n        _run_on_change(store, args)\n        _run_after_update(store, args)\n\n    return True\n\n\ndef _determine_execution_order(args: argparse.Namespace) -> list[str]:\n    execution_order = []\n\n    if args.only:\n        output.print_debug(\"Argument '--only' is set. Pruning execution steps.\")\n        for step in decman.execution_order:\n            if step in args.only:\n                output.print_debug(f\"Adding {step} to execution order.\")\n                execution_order.append(step)\n    else:\n        execution_order = decman.execution_order\n\n    for skip in args.skip:\n        output.print_debug(f\"Skipping step {skip}.\")\n        execution_order.remove(skip)\n\n    output.print_debug(f\"Execution order is: {', '.join(execution_order)}.\")\n    return execution_order\n\n\ndef _find_new_modules(store: _store.Store):\n    new_modules = []\n    for module in decman.modules:\n        if module.name not in store[\"enabled_modules\"]:\n            new_modules.append(module.name)\n    output.print_debug(f\"New modules are: {', '.join(new_modules)}.\")\n    return new_modules\n\n\ndef _find_disabled_modules(store: _store.Store):\n    disabled_modules = []\n    enabled_module_names = set(map(lambda m: m.name, decman.modules))\n    for module_name in store[\"enabled_modules\"]:\n        if module_name not in enabled_module_names:\n            disabled_modules.append(module_name)\n    output.print_debug(f\"Disabled modules are: {', '.join(disabled_modules)}.\")\n    return disabled_modules\n\n\ndef _run_before_update(store: _store.Store, args: argparse.Namespace):\n    output.print_summary(\"Running before_update -hooks.\")\n    for module in decman.modules:\n        output.print_debug(f\"Running before_update for {module.name}.\")\n        if not args.dry_run:\n            module.before_update(store)\n\n\ndef _run_on_disable(store: _store.Store, args: argparse.Namespace, disabled_modules: list[str]):\n    if not disabled_modules:\n        return\n\n    output.print_summary(\"Running on_disable -scripts.\")\n\n    for disabled_module in disabled_modules:\n        on_disable_script = store[\"module_on_disable_scripts\"].get(disabled_module, None)\n        if on_disable_script:\n            output.print_debug(f\"Running on_disable for {disabled_module}.\")\n\n            if not args.dry_run:\n                decman.prg([on_disable_script])\n                store[\"enabled_modules\"].remove(disabled_module)\n                store[\"module_on_disable_scripts\"].pop(disabled_module)\n\n\ndef _run_on_enable(store: _store.Store, args: argparse.Namespace, new_modules: list[str]):\n    if not new_modules:\n        return\n\n    output.print_summary(\"Running on_enable -hooks.\")\n    for module in decman.modules:\n        if module.name in new_modules:\n            output.print_debug(f\"Running on_enable for {module.name}.\")\n\n            if not args.dry_run:\n                module.on_enable(store)\n                store[\"enabled_modules\"].append(module.name)\n                try:\n                    script = _module.write_on_disable_script(\n                        module, conf.module_on_disable_scripts_dir\n                    )\n                    if script:\n                        store[\"module_on_disable_scripts\"][module.name] = script\n                except OSError as error:\n                    output.print_error(\n                        f\"Failed to create on_disable script for module {module.name}: \"\n                    )\n                    output.print_error(f\"{error.strerror or str(error)}.\")\n                    output.print_traceback()\n                    output.print_warning(\n                        \"This script will NOT be created when decman runs the next time.\"\n                    )\n                    output.print_warning(\n                        \"You should investigate the reason for the error and try to fix it.\"\n                    )\n                    output.print_warning(\n                        \"Then disable and re-enable this module to create the script.\"\n                    )\n\n\ndef _run_on_change(store: _store.Store, args: argparse.Namespace):\n    output.print_summary(\"Running on_change -hooks.\")\n    for module in decman.modules:\n        if module._changed:\n            output.print_debug(f\"Running on_change for {module.name}.\")\n            if not args.dry_run:\n                module.on_change(store)\n\n\ndef _run_after_update(store: _store.Store, args: argparse.Namespace):\n    output.print_summary(\"Running after_update -hooks.\")\n    for module in decman.modules:\n        output.print_debug(f\"Running after_update for {module.name}.\")\n        if not args.dry_run:\n            module.after_update(store)\n"
  },
  {
    "path": "src/decman/config.py",
    "content": "\"\"\"\nModule for decman configuration options.\n\nNOTE: Do NOT use from imports as global variables might not work as you expect.\n\nOnly use:\n\nimport decman.config\n\nor\n\nimport decman.config as whatever\n\n-- Configuring commands --\n\nCommands are stored as methods in the Commands-class.\nThe global variable 'commands' of this module is an instance of the Commands-class.\n\nTo change the defalts, create a new child class of the Commands-class and set the 'commands'\nvariable to an instance of your class. Look in the example directory for an example.\n\"\"\"\n\ndebug_output: bool = False\nquiet_output: bool = False\ncolor_output: bool = True\n\nmodule_on_disable_scripts_dir: str = \"/var/lib/decman/scripts/\"\ncache_dir: str = \"/var/cache/decman\"\n\narch: str = \"x86_64\"\n"
  },
  {
    "path": "src/decman/core/__init__.py",
    "content": ""
  },
  {
    "path": "src/decman/core/command.py",
    "content": "import errno\nimport fcntl\nimport os\nimport pty\nimport pwd\nimport select\nimport shlex\nimport shutil\nimport signal\nimport struct\nimport subprocess\nimport sys\nimport termios\nimport tty\nimport typing\n\nimport decman.core.error as errors\nimport decman.core.output as output\n\n\ndef get_user_info(user: str) -> tuple[int, int]:\n    \"\"\"\n    Returns UID and GID of the given user.\n\n    If the user doesn't exist, raises UserNotFoundError.\n    \"\"\"\n    info = _get_passwd(user)\n    return info.pw_uid, info.pw_gid\n\n\ndef prg(\n    cmd: list[str],\n    user: typing.Optional[str] = None,\n    env_overrides: typing.Optional[dict[str, str]] = None,\n    pass_environment: bool = True,\n    mimic_login: bool = False,\n    pty: bool = True,\n    check: bool = True,\n) -> str:\n    \"\"\"\n    Shortcut for running a command. Returns the output of that command.\n\n    Arguments:\n        cmd:\n            Command to execute.\n\n        user:\n            User name to run the command as. If set, the command is executed after dropping\n            privileges to this user.\n\n        env_overrides:\n            Environment variables to override or add for the command execution.\n            These values are merged on top of the current process environment.\n\n        mimic_login:\n            If mimic_login is True, will set the following environment variables according to the\n            given user's passwd file details. This only happens when user is set.\n                - HOME\n                - USER\n                - LOGNAME\n                - SHELL\n\n        pty:\n            If True, run the command inside a pseudo-terminal (PTY). This enables interactive\n            behavior and terminal-dependent programs. If False, run the command without a PTY\n            using standard subprocess execution.\n\n            If running in a PTY, the raised CommandFailedError will not contain command output,\n            since it has already been shown to the user.\n\n        check:\n            If True, raise CommandFailedError when the command exits with a non-zero status.\n            If False, print a warning when encountering a non-zero exit code.\n    \"\"\"\n    if pty:\n        result = pty_run(\n            cmd,\n            user=user,\n            env_overrides=env_overrides,\n            pass_environment=pass_environment,\n            mimic_login=mimic_login,\n        )\n    else:\n        result = run(\n            cmd,\n            user=user,\n            env_overrides=env_overrides,\n            pass_environment=pass_environment,\n            mimic_login=mimic_login,\n        )\n\n    if check:\n        # This raises an error if the command failed exiting the function early\n        result = check_run_result(cmd, result, include_output=not pty)\n\n    code, command_output = result\n    if code != 0:\n        output.print_warning(f\"Command '{shlex.join(cmd)}' returned with an exit code {code}.\")\n        if not pty:\n            output.print_command_output(command_output)\n\n    return command_output\n\n\ndef pty_run(\n    command: list[str],\n    user: None | str = None,\n    env_overrides: None | dict[str, str] = None,\n    mimic_login: bool = False,\n    pass_environment: bool = True,\n) -> tuple[int, str]:\n    \"\"\"\n    Runs a given command with the given arguments in a pseudo TTY. The command can be ran as\n    the given user and environment variables can be overridden manually.\n\n    By default this will copy the current environment and pass it to the process. To prevent this\n    set ``pass_environment`` to ``False``.\n\n    If ``mimic_login`` is True, will set the following environment variables according to the given\n    user's passwd file details. This only happens when user is set.\n        - HOME\n        - USER\n        - LOGNAME\n        - SHELL\n\n    If the given command is empty, returns (0, \"\").\n\n    Returns the return code of the command and the output as a string.\n\n    If the user doesn't exist, raises UserNotFoundError.\n    If forking the process fails or stdin is not a TTY, raises OSError.\n    \"\"\"\n    if not command:\n        return 0, \"\"\n\n    if not sys.stdin.isatty():\n        raise OSError(errno.ENOTTY, \"Stdin is not a TTY.\")\n\n    command[0] = shutil.which(command[0]) or command[0]\n\n    output.print_debug(f\"Running command '{shlex.join(command)}'.\")\n\n    env = _build_env(user, env_overrides, mimic_login, pass_environment)\n\n    pid, master_fd = pty.fork()\n    if pid == 0:\n        _exec_in_child(command, env, user)\n\n    return _run_parent(master_fd, pid)\n\n\ndef run(\n    command: list[str],\n    user: None | str = None,\n    env_overrides: None | dict[str, str] = None,\n    mimic_login: bool = False,\n    pass_environment: bool = True,\n) -> tuple[int, str]:\n    \"\"\"\n    Runs a given command with the given arguments. The command can be ran as the given user and\n    environment variables can be overridden manually.\n\n    By default this will copy the current environment and pass it to the process. To prevent this\n    set ``pass_environment`` to ``False``.\n\n    If mimic_login is True, will set the following environment variables according to the given\n    user's passwd file details. This only happens when user is set.\n        - HOME\n        - USER\n        - LOGNAME\n        - SHELL\n\n    If the given command is empty, returns (0, \"\").\n\n    Returns the return code of the command and the output as a string.\n\n    If the user doesn't exist, raises UserNotFoundError.\n    \"\"\"\n    if not command:\n        return 0, \"\"\n\n    command[0] = shutil.which(command[0]) or command[0]\n\n    output.print_debug(f\"Running command '{shlex.join(command)}'.\")\n\n    env = _build_env(user, env_overrides, mimic_login, pass_environment)\n    uid, gid = None, None\n\n    if user:\n        uid, gid = get_user_info(user)\n\n    try:\n        process = subprocess.Popen(\n            command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, user=uid, group=gid\n        )\n        stdout, _ = process.communicate()\n    except OSError as error:\n        # Mirror PTY behavior: \"<cmd>: <error>\\n\" and errno-based exit code\n        msg = error.strerror or str(error)\n        text_output = f\"{command[0]}: {msg}\\n\"\n        code = error.errno if error.errno and error.errno < 128 else 127\n        return code, text_output\n\n    return process.returncode, stdout.decode(\"utf-8\", errors=\"replace\")\n\n\ndef check_run_result(\n    command: list[str], result: tuple[int, str], include_output: bool = True\n) -> tuple[int, str]:\n    \"\"\"\n    Validates the result of a command execution.\n\n    If the command exited with a non-zero return code, raises CommandFailedError\n    containing the original command and its captured output.\n\n    Otherwise, returns the result unchanged.\n    \"\"\"\n    code, output = result\n    if code != 0:\n        if include_output:\n            raise errors.CommandFailedError(command, code, output)\n        else:\n            raise errors.CommandFailedError(command, code, None)\n    return code, output\n\n\ndef _build_env(\n    user: None | str,\n    env_overrides: None | dict[str, str],\n    mimic_login: bool,\n    pass_environment: bool,\n) -> dict[str, str]:\n    env = {}\n    cwd = os.getcwd()\n    output.print_debug(\n        f\"Command environment is: cwd='{cwd}', user='{user}', env_overrides='{env_overrides}', \"\n        f\"mimic_login='{mimic_login}', pass_environment='{pass_environment}'\"\n    )\n\n    if pass_environment:\n        env = os.environ.copy()\n\n    if mimic_login and user:\n        pw = _get_passwd(user)\n        env.update(\n            {\n                \"HOME\": pw.pw_dir,\n                \"USER\": pw.pw_name,\n                \"LOGNAME\": pw.pw_name,\n                \"SHELL\": pw.pw_shell,\n            }\n        )\n\n    if env_overrides:\n        env.update(env_overrides)\n\n    return env\n\n\ndef _exec_in_child(command: list[str], env: dict[str, str], user: None | str) -> typing.NoReturn:\n    try:\n        if user:\n            uid, gid = get_user_info(user=user)\n            os.setgid(gid)\n            os.setuid(uid)\n\n        os.execve(command[0], command, env)\n    except OSError as error:\n        try:\n            os.write(2, f\"{command[0]}: {error.strerror}\\n\".encode())\n        except OSError:\n            # Not much can be done, if outputting the failure state fails\n            pass\n        code = error.errno if (error.errno and error.errno < 128) else 127\n        os._exit(code)\n\n\ndef _run_parent(master_fd: int, pid: int) -> tuple[int, str]:\n    stdin_fd = sys.stdin.fileno()\n    stdout_fd = sys.stdout.fileno()\n\n    # Put stdin into raw mode and save previous termios attributes.\n    old_tattr = termios.tcgetattr(stdin_fd)\n    tty.setraw(stdin_fd)\n\n    # Helper function to set PTY window size to the current terminal size\n    def resize_pty(*args):\n        try:\n            cols, rows = shutil.get_terminal_size()\n            winsz = struct.pack(\"HHHH\", rows, cols, 0, 0)\n            fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz)\n        except OSError:\n            # In case the child has exited before the signal handled was de-registered\n            pass\n\n    # Set PTY window size to match the current terminal size.\n    resize_pty()\n\n    # Handle terminal resizes automatically\n    old_winch = signal.getsignal(signal.SIGWINCH)\n    signal.signal(signal.SIGWINCH, resize_pty)\n\n    try:\n        output_bytes = _relay_pty(master_fd, stdin_fd, stdout_fd)\n    finally:\n        # Restore stdin termios attributes.\n        termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tattr)\n        # Restore previous handler\n        signal.signal(signal.SIGWINCH, old_winch)\n        os.close(master_fd)\n\n    _, status = os.waitpid(pid, 0)\n    exitcode = os.waitstatus_to_exitcode(status)\n    output = output_bytes.decode(\"utf-8\", errors=\"replace\").replace(\"\\r\\n\", \"\\n\")\n    return exitcode, output\n\n\ndef _relay_pty(master_fd: int, stdin_fd: int, stdout_fd: int) -> bytes:\n    \"\"\"\n    Drive interactive I/O between stdin/stdout and the PTY, capturing output.\n    \"\"\"\n    output_chunks: list[bytes] = []\n\n    while True:\n        # Wait until process or stdin has data\n        rlist, _, _ = select.select([master_fd, stdin_fd], [], [])\n\n        # Capture and echo child process\n        if master_fd in rlist:\n            try:\n                data = os.read(master_fd, 1024)\n            except OSError:\n                # Child process probably exited, EOF\n                break\n\n            output_chunks.append(data)\n            try:\n                os.write(stdout_fd, data)\n            except OSError:\n                # stdout closed, ignore\n                pass\n\n        # Forward stdin\n        if stdin_fd in rlist:\n            try:\n                data = os.read(stdin_fd, 1024)\n                os.write(master_fd, data)\n            except OSError:\n                # Either stdin EOF -> no data to pass\n                # or child died -> wait for master_fd to handle\n                pass\n\n    return b\"\".join(output_chunks)\n\n\ndef _get_passwd(user: str) -> pwd.struct_passwd:\n    try:\n        return pwd.getpwnam(user)\n    except KeyError as error:\n        raise errors.UserNotFoundError(user) from error\n"
  },
  {
    "path": "src/decman/core/error.py",
    "content": "import shlex\n\n\nclass SourceError(Exception):\n    \"\"\"\n    Error raised manually from the user's source.\n    \"\"\"\n\n\nclass FSInstallationFailedError(Exception):\n    \"\"\"\n    Error raised when trying to install a file/directory to a target.\n    \"\"\"\n\n    def __init__(self, source: str, target: str, reason: str):\n        self.source = source\n        self.target = target\n        super().__init__(f\"Failed to install file from {source} to {target}: {reason}.\")\n\n\nclass FSSymlinkFailedError(Exception):\n    \"\"\"\n    Error raised when trying to create a symlink to a target.\n    \"\"\"\n\n    def __init__(self, link_name: str, target: str, reason: str):\n        self.link_name = link_name\n        self.target = target\n        super().__init__(f\"Failed to install symlink from {link_name} to {target}: {reason}.\")\n\n\nclass InvalidOnDisableError(Exception):\n    \"\"\"\n    Error raised when trying to create a Module with an invalid on_disable method.\n    \"\"\"\n\n    def __init__(self, module: str, reason: str):\n        self.module = module\n        self.reason = reason\n        super().__init__(\n            f\"Module '{module}' contains an invalid on_disable method. Reason: {reason}.\"\n        )\n\n\nclass UserNotFoundError(Exception):\n    \"\"\"\n    Raised when a specified user cannot be found in the system.\n\n    Attributes:\n        user (str): The user that caused the exception.\n    \"\"\"\n\n    def __init__(self, user: str) -> None:\n        self.user = user\n        super().__init__(f\"The user '{user}' doesn't exist.\")\n\n\nclass GroupNotFoundError(Exception):\n    \"\"\"\n    Raised when a specified group cannot be found in the system.\n\n    Attributes:\n        group (str): The group that caused the exception.\n    \"\"\"\n\n    def __init__(self, group: str) -> None:\n        self.group = group\n        super().__init__(f\"The group '{group}' doesn't exist.\")\n\n\nclass CommandFailedError(Exception):\n    \"\"\"\n    Raised when running a command failed.\n\n    Attributes:\n        command (list[str]): The command that caused the exception.\n        exit_code (int): The exit code of the command\n        output (str|None): Output of the command.\n    \"\"\"\n\n    def __init__(self, command: list[str], exit_code: int, output: str | None) -> None:\n        self.command = shlex.join(command)\n        self.exit_code = exit_code\n        if output:\n            self.output: str | None = output.strip()\n        else:\n            self.output = None\n        super().__init__(\n            f\"Command '{self.command}' returned with a non-zero exit code {self.exit_code}.\"\n        )\n"
  },
  {
    "path": "src/decman/core/file_manager.py",
    "content": "import os\nimport typing\n\nimport decman.core.error as errors\nimport decman.core.fs as fs\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\n\n\ndef update_files(\n    store: _store.Store,\n    modules: list[module.Module],\n    files: dict[str, fs.File],\n    directories: dict[str, fs.Directory],\n    symlinks: dict[str, str | fs.Symlink],\n    dry_run: bool = False,\n) -> bool:\n    \"\"\"\n    Apply the desired file and directory state.\n\n    Installs common and module-provided files and directories, tracks all checked paths, detects\n    changes, removes files no longer managed, and updates the store.\n\n    On failure, no removals are performed and the store is left unchanged.\n\n    Arguments:\n        store:\n            Persistent store used to track managed file paths.\n\n        modules:\n            Enabled modules providing additional files and directories.\n\n        files:\n            Common files to install (target path -> File).\n\n        directories:\n            Common directories to install (target path -> Directory).\n\n        dry_run:\n            If True, perform change detection only without modifying the filesystem.\n\n    Returns:\n        True if all operations completed successfully, False if installation failed.\n    \"\"\"\n    all_checked_files = []\n    all_changed_files = []\n    store.ensure(\"all_files\", [])\n\n    output.print_summary(\"Updating files.\")\n\n    try:\n        output.print_debug(\"Applying common files.\")\n        checked, changed = _install_files(files, dry_run=dry_run)\n        all_checked_files += checked\n        all_changed_files += changed\n\n        output.print_debug(\"Applying common directories.\")\n        checked, changed = _install_directories(directories, dry_run=dry_run)\n        all_checked_files += checked\n        all_changed_files += changed\n\n        output.print_debug(\"Applying common symlinks.\")\n        checked, changed = _install_symlinks(symlinks, dry_run=dry_run)\n        all_checked_files += checked\n        all_changed_files += changed\n\n        for mod in modules:\n            module_changed_files = []\n\n            output.print_debug(f\"Applying files in module '{mod.name}'.\")\n            checked, changed = _install_files(\n                mod.files(),\n                variables=mod.file_variables(),\n                dry_run=dry_run,\n            )\n            all_checked_files += checked\n            module_changed_files += changed\n\n            output.print_debug(f\"Applying directories in module '{mod.name}'.\")\n            checked, changed = _install_directories(\n                mod.directories(),\n                variables=mod.file_variables(),\n                dry_run=dry_run,\n            )\n            all_checked_files += checked\n            module_changed_files += changed\n\n            output.print_debug(f\"Applying symlinks in module '{mod.name}'.\")\n            checked, changed = _install_symlinks(\n                mod.symlinks(),\n                dry_run=dry_run,\n            )\n            all_checked_files += checked\n            module_changed_files += changed\n\n            if len(module_changed_files) > 0:\n                output.print_debug(\n                    f\"Module '{mod.name}' set to changed due to modified \"\n                    f\"files: '{\"', '\".join(module_changed_files)}'.\"\n                )\n                mod._changed = True\n            all_changed_files += module_changed_files\n    except errors.FSInstallationFailedError as error:\n        output.print_error(str(error))\n        output.print_traceback()\n        return False\n    except errors.FSSymlinkFailedError as error:\n        output.print_error(str(error))\n        output.print_traceback()\n        return False\n\n    to_remove = []\n    for file in store[\"all_files\"]:\n        if file not in all_checked_files:\n            to_remove.append(file)\n\n    output.print_list(\"Updated files:\", all_changed_files, elements_per_line=1)\n\n    if not dry_run:\n        for file in to_remove:\n            try:\n                os.remove(file)\n            except OSError as error:\n                output.print_warning(f\"Failed to remove file: '{file}': {error.strerror}.\")\n        store[\"all_files\"] = all_checked_files\n\n    output.print_list(\"Removed files:\", to_remove, elements_per_line=1)\n\n    return True\n\n\ndef _install_files(\n    files: dict[str, fs.File],\n    variables: typing.Optional[dict[str, str]] = None,\n    dry_run: bool = False,\n) -> tuple[list[str], list[str]]:\n    checked_files = []\n    changed_files = []\n\n    for target_filename, file in files.items():\n        output.print_debug(f\"Checking file {target_filename}.\")\n        checked_files.append(target_filename)\n\n        try:\n            if file.copy_to(target_filename, variables=variables, dry_run=dry_run):\n                changed_files.append(target_filename)\n        except FileNotFoundError as error:\n            raise errors.FSInstallationFailedError(\n                file.source_file or \"content\", target_filename, \"Source file doesn't exist.\"\n            ) from error\n        except OSError as error:\n            raise errors.FSInstallationFailedError(\n                file.source_file or \"content\", target_filename, error.strerror or str(error)\n            ) from error\n        except UnicodeEncodeError as error:\n            raise errors.FSInstallationFailedError(\n                file.source_file or \"content\", target_filename, \"Unicode encoding failed.\"\n            ) from error\n        except UnicodeDecodeError as error:\n            raise errors.FSInstallationFailedError(\n                file.source_file or \"content\", target_filename, \"Unicode decoding failed.\"\n            ) from error\n\n    return checked_files, changed_files\n\n\ndef _install_directories(\n    directories: dict[str, fs.Directory],\n    variables: typing.Optional[dict[str, str]] = None,\n    dry_run: bool = False,\n) -> tuple[list[str], list[str]]:\n    checked_files = []\n    changed_files = []\n\n    for target_dirname, directory in directories.items():\n        output.print_debug(f\"Checking directory {target_dirname}.\")\n        try:\n            checked, changed = directory.copy_to(\n                target_dirname, variables=variables, dry_run=dry_run\n            )\n        except FileNotFoundError as error:\n            raise errors.FSInstallationFailedError(\n                directory.source_directory,\n                target_dirname,\n                \"Source directory doesn't exist.\",\n            ) from error\n        except OSError as error:\n            raise errors.FSInstallationFailedError(\n                directory.source_directory, target_dirname, error.strerror or str(error)\n            ) from error\n        except UnicodeEncodeError as error:\n            raise errors.FSInstallationFailedError(\n                directory.source_directory, target_dirname, \"Unicode encoding failed.\"\n            ) from error\n        except UnicodeDecodeError as error:\n            raise errors.FSInstallationFailedError(\n                directory.source_directory, target_dirname, \"Unicode decoding failed.\"\n            ) from error\n\n        checked_files += checked\n        changed_files += changed\n\n    return checked_files, changed_files\n\n\ndef _install_symlinks(\n    symlinks: dict[str, str | fs.Symlink], dry_run: bool = False\n) -> tuple[list[str], list[str]]:\n    checked_files = []\n    changed_files = []\n\n    for link_name, target in symlinks.items():\n        output.print_debug(f\"Checking symlink {link_name}.\")\n        checked_files.append(link_name)\n\n        target_link: fs.Symlink = target if type(target) is fs.Symlink else fs.Symlink(target)  # type: ignore\n        if target_link.link_to(link_name, dry_run):\n            changed_files.append(link_name)\n\n    return checked_files, changed_files\n"
  },
  {
    "path": "src/decman/core/fs.py",
    "content": "import grp\nimport os\nimport shutil\nimport typing\n\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.output as output\n\n\ndef create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):\n    if not os.path.isdir(dirct):\n        parent_dir = os.path.dirname(dirct)\n        if not os.path.isdir(parent_dir):\n            create_missing_dirs(parent_dir, uid, gid)\n\n        output.print_debug(f\"Creating directory '{dirct}'.\")\n        os.mkdir(dirct)\n\n        if uid is not None:\n            assert gid is not None, \"If uid is set, then gid is set.\"\n            os.chown(dirct, uid, gid)\n\n\nclass File:\n    \"\"\"\n    Declarative file specification describing how a file should be materialized at a target path.\n\n    Exactly one of ``source_file`` or ``content`` must be provided.\n\n    The file can be created by copying an existing source file or by writing provided content. For\n    text files, optional variable substitution is applied at copy time. Binary files are copied or\n    written verbatim and never undergo substitution.\n\n    Ownership, permissions, and parent directories are enforced on creation. Missing parent\n    directories are created recursively and assigned the same ownership as the file when specified.\n\n    Parameters:\n        ``source_file``:\n            Path to an existing file to copy from. Mutually exclusive with ``content``.\n\n        ``content``:\n            In-memory file contents to write. Mutually exclusive with ``source_file``.\n\n        ``bin_file``:\n            If ``True``, treat the file as binary. Disables variable substitution and writes bytes\n            verbatim.\n\n        ``encoding``:\n            Text encoding used when reading or writing non-binary files.\n\n        ``owner``:\n            System user name to own the file and created parent directories.\n\n        ``group``:\n            System group name to own the file and created parent directories.\n\n        ``permissions``:\n            File mode applied to the target file (e.g. ``0o644``).\n\n    Raises:\n        ``ValueError``\n            If both ``source_file`` and ``content`` are ``None`` or if both are set.\n\n        ``UserNotFoundError``\n            If ``owner`` does not exist on the system.\n\n        ``GroupNotFoundError``\n            If ``group`` does not exist on the system.\n\n    Notes:\n        Variable substitution is a simple string replacement where each key in ``variables`` is\n        replaced by its corresponding value. No escaping or templating semantics are applied.\n    \"\"\"\n\n    def __init__(\n        self,\n        source_file: typing.Optional[str] = None,\n        content: typing.Optional[str] = None,\n        bin_file: bool = False,\n        encoding: str = \"utf-8\",\n        owner: typing.Optional[str] = None,\n        group: typing.Optional[str] = None,\n        permissions: int = 0o644,\n    ):\n        if source_file is None and content is None:\n            raise ValueError(\"Both source_file and content cannot be None.\")\n\n        if source_file is not None and content is not None:\n            raise ValueError(\"Both source_file and content cannot be set.\")\n\n        self.source_file = source_file\n        self.content = content\n        self.permissions = permissions\n        self.bin_file = bin_file\n        self.encoding = encoding\n        self.uid = None\n        self.gid = None\n\n        if owner is not None:\n            self.uid, self.gid = command.get_user_info(owner)\n\n        if group is not None:\n            try:\n                self.gid = grp.getgrnam(group).gr_gid\n            except KeyError as error:\n                raise errors.GroupNotFoundError(group) from error\n\n    def copy_to(\n        self, target: str, variables: typing.Optional[dict[str, str]] = None, dry_run: bool = False\n    ) -> bool:\n        \"\"\"\n        Copies the contents of this file to the target file if they differ.\n\n        Parameters:\n            target:\n                Path to the target file on disk.\n\n            variables:\n                Optional mapping of literal substrings to replace in the text content before\n                writing. Ignored for binary files and when ``bin_file`` is True.\n\n        Returns:\n            True if the file contents were/would be created or modified.\n            False if the existing file already contained the desired contents.\n\n        Raises:\n            OSError\n                If directory creation, file I/O, permission changes, or ownership changes fail\n                (e.g. permission denied, missing parent path components, I/O errors).\n\n            FileNotFoundError\n                If ``source_file`` is set and does not exist.\n\n            UnicodeDecodeError\n                If a text file cannot be decoded using ``encoding``.\n\n            UnicodeEncodeError\n                If text content cannot be encoded using ``encoding``.\n        \"\"\"\n        if variables is None:\n            variables = {}\n\n        target_directory = os.path.dirname(target)\n\n        if not dry_run:\n            create_missing_dirs(target_directory, self.uid, self.gid)\n\n        changed = self._write_content(target, variables, dry_run)\n        if changed:\n            output.print_debug(f\"File '{target}' changed.\")\n\n        if self.uid is not None and not dry_run:\n            assert self.gid is not None, \"If uid is set, then gid is set.\"\n            os.chown(target, self.uid, self.gid)\n\n        if not dry_run:\n            os.chmod(target, self.permissions)\n        return changed\n\n    def _write_content(self, target: str, variables: dict[str, str], dry_run: bool):\n        # Case 1: copy from source file directly (binary or no substitutions)\n        if self.source_file is not None and (self.bin_file or len(variables) == 0):\n            if os.path.exists(target):\n                with open(self.source_file, \"rb\") as src, open(target, \"rb\") as dst:\n                    if src.read() == dst.read():\n                        return False\n            if not dry_run:\n                shutil.copy(self.source_file, target)\n            return True\n\n        # Case 2: binary content from memory\n        if self.bin_file and self.content is not None:\n            desired_bytes = self.content.encode(encoding=self.encoding)\n            if os.path.exists(target):\n                with open(target, \"rb\") as file:\n                    if file.read() == desired_bytes:\n                        return False\n            if not dry_run:\n                with open(target, \"wb\") as file:\n                    file.write(desired_bytes)\n            return True\n\n        # From here on: text modes with possible substitutions\n\n        # Case 3: text content from source file with substitutions\n        if self.source_file is not None:\n            with open(self.source_file, \"rt\", encoding=self.encoding) as src:\n                content = src.read()\n\n            for var, value in variables.items():\n                content = content.replace(var, value)\n\n            if os.path.exists(target):\n                with open(target, \"rt\", encoding=self.encoding) as file:\n                    if file.read() == content:\n                        return False\n\n            if not dry_run:\n                with open(target, \"wt\", encoding=self.encoding) as file:\n                    file.write(content)\n            return True\n\n        # Case 4: text content from in-memory string with substitutions\n        assert self.content is not None, \"Content should be set since source_file was not set.\"\n        content = self.content\n        for var, value in variables.items():\n            content = content.replace(var, value)\n\n        if os.path.exists(target):\n            with open(target, \"rt\", encoding=self.encoding) as file:\n                if file.read() == content:\n                    return False\n\n        if not dry_run:\n            with open(target, \"wt\", encoding=self.encoding) as file:\n                file.write(content)\n        return True\n\n\nclass Symlink:\n    \"\"\"\n    Declarative specification for linking a source to a destination.\n\n    Parameters:\n        ``target``:\n            Path to an existing file to serve as the target of the symlink.\n\n        ``owner``:\n            User name to own created parent directories.\n\n        ``group``:\n            Group name to own created parent directories.\n\n    Raises:\n        ``UserNotFoundError``\n            If ``owner`` does not exist on the system.\n\n        ``GroupNotFoundError``\n            If ``group`` does not exist on the system.\n    \"\"\"\n\n    def __init__(\n        self,\n        target: str,\n        owner: typing.Optional[str] = None,\n        group: typing.Optional[str] = None,\n    ):\n        self.target = target\n        self.owner = owner\n        self.group = group\n        self.uid = None\n        self.gid = None\n\n        if owner is not None:\n            self.uid, self.gid = command.get_user_info(owner)\n\n        if group is not None:\n            try:\n                self.gid = grp.getgrnam(group).gr_gid\n            except KeyError as error:\n                raise errors.GroupNotFoundError(group) from error\n\n    def link_to(self, link_name: str, dry_run: bool = False) -> bool:\n        \"\"\"\n        Creates a symlink ``link_name`` -> ``target``.\n\n        Parameters:\n            ``link_name``:\n                Path to the target file on disk.\n\n        Returns:\n            True if a new link was/would be created or modified.\n            False if the existing link already contained the desired target.\n\n        Raises:\n            FSSymlinkFailedError\n                If creating the symlink failed due to directory creation, file I/O, permission\n                changes, or ownership changes fail (e.g. permission denied, missing parent path\n                components, I/O errors).\n        \"\"\"\n\n        def _is_symlink_to(path: str, target: str) -> bool:\n            if not os.path.islink(path):\n                return False\n            return os.readlink(path) == target\n\n        output.print_debug(f\"Checking symlink {link_name}.\")\n        try:\n            if _is_symlink_to(link_name, self.target):\n                return False\n\n            if dry_run:\n                return True\n\n            target_directory = os.path.dirname(link_name)\n            create_missing_dirs(target_directory, self.uid, self.gid)\n\n            if os.path.lexists(link_name):\n                os.unlink(link_name)\n\n            os.symlink(self.target, link_name)\n            if self.uid is not None:\n                assert self.gid is not None, \"If uid is set, then gid is set.\"\n                os.chown(link_name, self.uid, self.gid, follow_symlinks=False)\n            return True\n        except OSError as error:\n            raise errors.FSSymlinkFailedError(\n                link_name, self.target, error.strerror or str(error)\n            ) from error\n\n\nclass Directory:\n    \"\"\"\n    Declarative specification for copying the contents of a source directory into a target\n    directory.\n\n    Files are copied using the :class:`File` abstraction, inheriting its ownership,\n    permissions, encoding, and binary/text behavior. Text files can optionally undergo\n    variable substitution before being written.\n\n    Parameters:\n        ``source_directory``:\n            Path to the directory whose contents will be mirrored into the target.\n\n        ``bin_files``:\n            If ``True``, treat all files as binary; disables variable substitution and copies bytes\n            verbatim.\n\n        ``encoding``:\n            Text encoding used when reading or writing non-binary files.\n\n        ``owner``:\n            System user name to own created files and directories.\n\n        ``group``:\n            System group name to own created files and directories.\n\n        ``permissions``:\n            File mode applied to created or updated files (e.g. ``0o644``).\n\n    Raises:\n        ``UserNotFoundError``\n            If ``owner`` does not exist on the system.\n\n        ``GroupNotFoundError``\n            If ``group`` does not exist on the system.\n    \"\"\"\n\n    def __init__(\n        self,\n        source_directory: str,\n        bin_files: bool = False,\n        encoding: str = \"utf-8\",\n        owner: typing.Optional[str] = None,\n        group: typing.Optional[str] = None,\n        permissions: int = 0o644,\n    ):\n        self.source_directory = source_directory\n        self.bin_files = bin_files\n        self.encoding = encoding\n        self.permissions = permissions\n\n        self.owner = owner\n        self.group = group\n        self.uid = None\n        self.gid = None\n\n        if owner is not None:\n            self.uid, self.gid = command.get_user_info(owner)\n\n        if group is not None:\n            try:\n                self.gid = grp.getgrnam(group).gr_gid\n            except KeyError as error:\n                raise errors.GroupNotFoundError(group) from error\n\n    def copy_to(\n        self,\n        target_directory: str,\n        variables: typing.Optional[dict[str, str]] = None,\n        dry_run: bool = False,\n    ) -> tuple[list[str], list[str]]:\n        \"\"\"\n        Copies the files in this directory to the target directory. Only replaces files that differ.\n\n        Parameters:\n            target_directory:\n                Destination directory root. Relative layout from the source is preserved beneath\n                this path.\n\n            variables:\n                Optional mapping of literal substrings to replace in text files before writing.\n                Ignored for binary files.\n\n            dry_run:\n                If ``True``, perform a dry-run: no files are written, but the list of files that\n                *would* be processed is returned.\n\n        Returns:\n            tuple[list[str], list[str]]\n                The first list contains always every file in the source, the second list depends on\n                ``dry_run``\n\n                When ``dry_run`` is ``False``, the second list contains paths of files that were\n                created or whose contents were modified.\n\n                When ``dry_run`` is ``True``, the second list contains paths of all files that would\n                be considered for creation or modification (no changes are actually performed).\n\n        Raises:\n            OSError\n                If directory traversal or file I/O fails (e.g. permission denied).\n\n            FileNotFoundError\n                If ``source_directory`` does not exist or becomes unavailable.\n\n            UnicodeDecodeError\n                If a text file cannot be decoded using ``encoding``.\n\n            UnicodeEncodeError\n                If text content cannot be encoded using ``encoding``.\n        \"\"\"\n        checked = []\n        changed_or_created = []\n        original_wd = os.getcwd()\n        try:\n            os.chdir(self.source_directory)\n            for src_dir, _, src_files in os.walk(\".\"):\n                for src_file in src_files:\n                    src_path = os.path.join(src_dir, src_file)\n                    file = File(\n                        source_file=src_path,\n                        bin_file=self.bin_files,\n                        encoding=self.encoding,\n                        owner=self.owner,\n                        group=self.group,\n                        permissions=self.permissions,\n                    )\n                    target = os.path.normpath(os.path.join(target_directory, src_path))\n                    checked.append(target)\n\n                    if file.copy_to(target, variables, dry_run):\n                        changed_or_created.append(target)\n\n        finally:\n            os.chdir(original_wd)\n        return checked, changed_or_created\n"
  },
  {
    "path": "src/decman/core/module.py",
    "content": "import builtins\nimport dis\nimport inspect\nimport os\nimport textwrap\nimport types\nimport typing\n\nimport decman.core.error as errors\nimport decman.core.fs as fs\nimport decman.core.store as _store\n\n\nclass Module:\n    \"\"\"\n    Unit for organizing related files, packages and other configuration.\n\n    Inherit this class to create your own modules.\n\n    Parameters:\n        name:\n            The name of the module. It must be unique.\n    \"\"\"\n\n    def __init__(self, name: str) -> None:\n        self.name = name\n        self._changed = False\n\n    def __init_subclass__(cls, **kwargs):\n        super().__init_subclass__(**kwargs)\n        m = cls.__dict__.get(\"on_disable\")\n        if m is None:\n            return\n\n        if not isinstance(m, staticmethod):\n            raise errors.InvalidOnDisableError(\n                f\"{cls.__module__}.{cls.__name__}\",\n                \"on_disable must be declared as @staticmethod\",\n            )\n\n        func = m.__func__\n\n        _validate_on_disable(f\"{cls.__module__}.{cls.__name__}\", func)\n\n    def before_update(self, store: _store.Store):\n        \"\"\"\n        Override this method to run python code before updating the system.\n\n        ``store`` can be used to save persistent data between decman runs.\n\n        Handle errors within this function. If an error should abort running decman,\n        raise SourceError or CommandFailedError.\n        \"\"\"\n\n    def after_update(self, store: _store.Store):\n        \"\"\"\n        Override this method to run python code after updating the system.\n\n        ``store`` can be used to save persistent data between decman runs.\n\n        Handle errors within this function. If an error should abort running decman,\n        raise SourceError or CommandFailedError.\n        \"\"\"\n\n    def on_enable(self, store: _store.Store):\n        \"\"\"\n        Override this method to run python code when this module gets enabled.\n\n        ``store`` can be used to save persistent data between decman runs.\n\n        Handle errors within this function. If an error should abort running decman,\n        raise SourceError or CommandFailedError.\n        \"\"\"\n\n    def on_change(self, store: _store.Store):\n        \"\"\"\n        Override this method to run python code after the contents of this module have been\n        changed in the source.\n\n        ``store`` can be used to save persistent data between decman runs.\n\n        Handle errors within this function. If an error should abort running decman,\n        raise SourceError or CommandFailedError.\n        \"\"\"\n\n    @staticmethod\n    def on_disable():\n        \"\"\"\n        Override this method to run python code when this module gets disabled.\n\n        This code will get copied *as is* to a temporary file. Do not use external variables or\n        imports. If you must use imports, define them inside this method.\n        \"\"\"\n\n    def files(self) -> dict[str, fs.File]:\n        \"\"\"\n        Override this method to return files that should be installed as a part of this module.\n        \"\"\"\n        return {}\n\n    def directories(self) -> dict[str, fs.Directory]:\n        \"\"\"\n        Override this method to return directories that should be installed as a part of this\n        module.\n        \"\"\"\n        return {}\n\n    def symlinks(self) -> dict[str, str | fs.Symlink]:\n        \"\"\"\n        Override this method to return symlinks that should be created as a part of this\n        module.\n        \"\"\"\n        return {}\n\n    def file_variables(self) -> dict[str, str]:\n        \"\"\"\n        Override this method to return variables that should replaced with a new value inside\n        this module's text files.\n        \"\"\"\n        return {}\n\n    def __hash__(self) -> int:\n        return hash(self.name)\n\n    def __eq__(self, other: object) -> bool:\n        return isinstance(other, self.__class__) and other.name == self.name\n\n\ndef write_on_disable_script(mod_obj: Module, out_dir: str) -> str | None:\n    \"\"\"\n    Writes a on_disable script for the given module. Returns the path to that script.\n\n    Raises:\n        OSError\n            If creating the script file fails.\n    \"\"\"\n    cls: typing.Type[Module] = type(mod_obj)\n\n    # Get the descriptor so we can unwrap staticmethod\n    desc = cls.__dict__.get(\"on_disable\")\n    if desc is None:\n        return None\n\n    # unwrap staticmethod to get the real function\n    if isinstance(desc, staticmethod):\n        func = desc.__func__\n    else:\n        func = desc  # already a function\n\n    src = inspect.getsource(func)\n    src = textwrap.dedent(src)\n\n    # Build a standalone script that defines the function and calls it\n    script = f\"\"\"#!/usr/bin/env python3\n# generated from {cls.__module__}.{cls.__name__}.on_disable\n\n{src}\n\nif __name__ == \"__main__\":\n    {func.__name__}()\n\"\"\"\n    script_file = fs.File(content=script, permissions=0o755)\n    script_path = os.path.join(out_dir, f\"{mod_obj.name}_on_disable.py\")\n    script_file.copy_to(script_path)\n    return script_path\n\n\ndef _iter_code_objects(code: types.CodeType):\n    yield code\n    for const in code.co_consts:\n        if isinstance(const, types.CodeType):\n            yield from _iter_code_objects(const)\n\n\ndef _validate_on_disable(module_type: str, func: types.FunctionType) -> None:\n    # No args\n    if inspect.signature(func).parameters:\n        raise errors.InvalidOnDisableError(module_type, \"on_disable must take no parameters\")\n\n    bad_names: set[str] = set()\n\n    for code in _iter_code_objects(func.__code__):\n        # No closures anywhere (outer or nested)\n        if code.co_freevars:\n            raise errors.InvalidOnDisableError(\n                module_type, \"on_disable must not close over outer variables\"\n            )\n\n        # No non-builtin globals / nonlocals anywhere\n        for ins in dis.get_instructions(code):\n            if ins.opname in (\"LOAD_GLOBAL\", \"LOAD_DEREF\"):\n                name = ins.argval\n                if not hasattr(builtins, name):\n                    bad_names.add(name)\n\n    if bad_names:\n        raise errors.InvalidOnDisableError(\n            module_type,\n            f\"on_disable uses nonlocal/global names: {', '.join(sorted(bad_names))}\",\n        )\n"
  },
  {
    "path": "src/decman/core/output.py",
    "content": "import os\nimport shutil\nimport sys\nimport traceback\nimport typing\n\nimport decman.config as config\n\n# ─────────────────────────────\n# Visible (non-ANSI) constants\n# ─────────────────────────────\n\n_TAG_TEXT = \"[DECMAN]\"\n_SPACING = \"    \"\n_CONTINUATION_PREFIX_TEXT = f\"{_TAG_TEXT}{_SPACING} \"\n\nINFO = 1\nSUMMARY = 2\n\n\n# ─────────────────────────────\n# Color / formatting helpers\n# ─────────────────────────────\n\n\ndef has_ansi_support() -> bool:\n    \"\"\"\n    Returns True if the running terminal supports ANSI colors or if colors should be enabled.\n    \"\"\"\n    if os.environ.get(\"NO_COLOR\") is not None:\n        return False\n    if os.environ.get(\"FORCE_COLOR\") is not None:\n        return True\n\n    if not sys.stdout.isatty():\n        return False\n\n    term = os.environ.get(\"TERM\", \"\")\n    return term not in (\"\", \"dumb\")\n\n\ndef _apply_color(code: str, text: str) -> str:\n    if not config.color_output:\n        return text\n    return f\"{code}{text}\\033[m\"\n\n\ndef _tag() -> str:\n    if not config.color_output:\n        return _TAG_TEXT\n    return \"[\\033[1;35mDECMAN\\033[m]\"\n\n\ndef _continuation_prefix() -> str:\n    return f\"{_tag()}{_SPACING} \"\n\n\ndef _red(text: str) -> str:\n    return _apply_color(\"\\033[91m\", text)\n\n\ndef _yellow(text: str) -> str:\n    return _apply_color(\"\\033[93m\", text)\n\n\ndef _cyan(text: str) -> str:\n    return _apply_color(\"\\033[96m\", text)\n\n\ndef _green(text: str) -> str:\n    return _apply_color(\"\\033[92m\", text)\n\n\ndef _gray(text: str) -> str:\n    return _apply_color(\"\\033[90m\", text)\n\n\n# ─────────────────────────────\n# Printing helpers\n# ─────────────────────────────\n\n\ndef print_continuation(msg: str, level: int = SUMMARY):\n    \"\"\"\n    Prints a message without a prefix.\n    \"\"\"\n    if level == SUMMARY or config.debug_output or not config.quiet_output:\n        print(f\"{_continuation_prefix()}{msg}\")\n\n\ndef print_error(error_msg: str):\n    \"\"\"\n    Prints an error message to the user.\n    \"\"\"\n    print(f\"{_tag()} {_red('ERROR')}: {error_msg}\")\n\n\ndef print_traceback():\n    \"\"\"\n    Prints the traceback to debug output.\n    \"\"\"\n    for line in traceback.format_exc().splitlines():\n        print_debug(line)\n\n\ndef print_warning(msg: str):\n    \"\"\"\n    Prints a warning to the user.\n    \"\"\"\n    print(f\"{_tag()} {_yellow('WARNING')}: {msg}\")\n\n\ndef print_summary(msg: str):\n    \"\"\"\n    Prints a summary message to the user.\n    \"\"\"\n    print(f\"{_tag()} {_cyan('SUMMARY')}: {msg}\")\n\n\ndef print_info(msg: str):\n    \"\"\"\n    Prints a detailed message to the user if verbose output is not disabled.\n    \"\"\"\n    if config.debug_output or not config.quiet_output:\n        print(f\"{_tag()} INFO: {msg}\")\n\n\ndef print_debug(msg: str):\n    \"\"\"\n    Prints a detailed message to the user if debug messages are enabled.\n    \"\"\"\n    if config.debug_output:\n        print(f\"{_tag()} {_gray('DEBUG')}: {msg}\")\n\n\ndef print_command_output(command_output: str):\n    \"\"\"\n    Prints command output prefixed with a DECMAN tag.\n    \"\"\"\n    for line in command_output.strip().split(\"\\n\"):\n        print_continuation(line.strip())\n\n\n# ─────────────────────────────\n# List printing\n# ─────────────────────────────\n\n\ndef print_list(\n    msg: str,\n    list_to_print: list[str],\n    elements_per_line: typing.Optional[int] = None,\n    max_line_width: typing.Optional[int] = None,\n    limit_to_term_size: bool = True,\n    level: int = SUMMARY,\n):\n    \"\"\"\n    Prints a summary message to the user along with a list of elements.\n\n    If the list is empty, prints nothing.\n    \"\"\"\n    if len(list_to_print) == 0:\n        return\n\n    list_to_print = list_to_print.copy()\n\n    if level == SUMMARY:\n        print_summary(msg)\n    elif level == INFO:\n        print_info(msg)\n\n    print_continuation(\"\", level=level)\n\n    if elements_per_line is None:\n        elements_per_line = len(list_to_print)\n\n    if max_line_width is None:\n        max_line_width = 2**32\n\n    if limit_to_term_size:\n        visible_prefix_len = len(_CONTINUATION_PREFIX_TEXT)\n        max_line_width = shutil.get_terminal_size().columns - visible_prefix_len\n\n    lines = [list_to_print.pop(0)]\n    index = 0\n    elements_in_current_line = 1\n\n    while list_to_print:\n        next_element = list_to_print.pop(0)\n\n        can_fit_elements = elements_in_current_line + 1 <= elements_per_line\n        can_fit_text = len(lines[index]) + len(next_element) <= max_line_width\n\n        if can_fit_text and can_fit_elements:\n            lines[index] += f\" {next_element}\"\n            elements_in_current_line += 1\n        else:\n            lines.append(next_element)\n            index += 1\n            elements_in_current_line = 1\n\n    for line in lines:\n        print_continuation(line, level=level)\n\n    print_continuation(\"\", level=level)\n\n\n# ─────────────────────────────\n# Prompts\n# ─────────────────────────────\n\n\ndef prompt_number(\n    msg: str,\n    min_num: int,\n    max_num: int,\n    default: typing.Optional[int] = None,\n) -> int:\n    \"\"\"\n    Prompts the user for an integer.\n    \"\"\"\n    while True:\n        i = input(f\"{_tag()} {_green('PROMPT')}: {msg}\").strip()\n\n        if default is not None and i == \"\":\n            return default\n\n        try:\n            num = int(i)\n            if min_num <= num <= max_num:\n                return num\n        except ValueError:\n            pass\n\n        print_error(\"Invalid input.\")\n\n\ndef prompt_confirm(msg: str, default: typing.Optional[bool] = None) -> bool:\n    \"\"\"\n    Prompts the user for confirmation.\n    \"\"\"\n    options_suffix = \"(y/n)\"\n    if default is not None:\n        options_suffix = \"(Y/n)\" if default else \"(y/N)\"\n\n    while True:\n        i = input(f\"{_tag()} {_green('PROMPT')} {options_suffix}: {msg} \").strip()\n\n        if default is not None and i == \"\":\n            return default\n\n        if i.lower() in (\"y\", \"ye\", \"yes\"):\n            return True\n\n        if i.lower() in (\"n\", \"no\"):\n            return False\n\n        print_error(\"Invalid input.\")\n"
  },
  {
    "path": "src/decman/core/store.py",
    "content": "import json\nimport os\nimport pathlib\nimport tempfile\nimport typing\n\n\nclass Store:\n    \"\"\"\n    Key-value store for saving decman state.\n    \"\"\"\n\n    def __init__(self, path: str, dry_run: bool = False) -> None:\n        self._store: dict[str, typing.Any] = {}\n        self._path = pathlib.Path(path)\n        self._dry_run = dry_run\n\n        if self._path.exists():\n            with self._path.open(\"rt\", encoding=\"utf-8\") as file:\n                self._store = json.load(file, object_hook=_decode_sets)\n\n    def __getitem__(self, key: str) -> typing.Any:\n        return self._store[key]\n\n    def __setitem__(self, key: str, value: typing.Any) -> None:\n        self._store[key] = value\n\n    def get(self, key: str, default: typing.Any = None) -> typing.Any:\n        return self._store.get(key, default)\n\n    def ensure(self, key: str, default: typing.Any = None):\n        if key not in self._store:\n            self._store[key] = default\n\n    def __enter__(self) -> \"Store\":\n        return self\n\n    def __exit__(self, exc_type, exc, tb):\n        self.save()\n        return False\n\n    def save(self) -> None:\n        \"\"\"\n        Saves the store to the defined path.\n        \"\"\"\n        if self._dry_run:\n            return\n\n        os.makedirs(self._path.parent, exist_ok=True)\n\n        with tempfile.NamedTemporaryFile(\n            \"wt\",\n            encoding=\"utf-8\",\n            dir=self._path.parent,\n            delete=False,\n        ) as tmp:\n            json.dump(self._store, tmp, cls=_SetJSONEncoder, indent=2)\n            tmp.flush()\n            os.fsync(tmp.fileno())\n\n        os.replace(tmp.name, self._path)\n\n    def __repr__(self) -> str:\n        return repr(self._store)\n\n\nclass _SetJSONEncoder(json.JSONEncoder):\n    def default(self, obj: typing.Any) -> typing.Any:\n        if isinstance(obj, set):\n            # generic, works for any set value\n            return {\"__type__\": \"set\", \"items\": list(obj)}\n        return super().default(obj)\n\n\ndef _decode_sets(obj: typing.Any) -> typing.Any:\n    if isinstance(obj, dict) and obj.get(\"__type__\") == \"set\" and \"items\" in obj:\n        # decode lists inside sets as tuples\n        norm = []\n\n        for item in obj[\"items\"]:\n            if isinstance(item, list):\n                norm.append(tuple(item))\n            else:\n                norm.append(item)\n\n        return set(norm)\n    return obj\n"
  },
  {
    "path": "src/decman/extras/__init__.py",
    "content": ""
  },
  {
    "path": "src/decman/extras/gpg.py",
    "content": "import os\nimport pwd\nimport re\nimport subprocess\nfrom dataclasses import dataclass\nfrom typing import Literal, Optional\n\nimport decman\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\nfrom decman.core.error import CommandFailedError\n\nOwnerTrust = Literal[\"never\", \"marginal\", \"full\", \"ultimate\"]\nSourceKind = Literal[\"fingerprint\", \"uri\", \"file\"]\n\n\n_TRUST_MAP = {\n    \"never\": \"1\",\n    \"marginal\": \"2\",\n    \"full\": \"3\",\n    \"ultimate\": \"4\",\n}\n\n_FPR_RE = re.compile(r\"^[0-9A-F]{40}$\")\n\n\n@dataclass(frozen=True)\nclass Key:\n    fingerprint: str\n    source_kind: SourceKind\n    source: str  # keyserver / uri / filepath\n    trust: Optional[OwnerTrust] = None\n\n    def __post_init__(self) -> None:\n        fpr = self.fingerprint.replace(\" \", \"\").upper()\n        if not _FPR_RE.fullmatch(fpr):\n            raise ValueError(f\"invalid OpenPGP fingerprint: {fpr}\")\n        object.__setattr__(self, \"fingerprint\", fpr)\n\n\nclass _GPGInterface:\n    def __init__(self, user: str, home: str):\n        self.user = user\n        self.home = home\n\n    def ensure_home(self) -> bool:\n        \"\"\"\n        Returns True on succees. Returns False if the user doesn't exist.\n        \"\"\"\n\n        def create_missing_dirs(dirct: str, uid: int, gid: int):\n            dirct = os.path.normpath(dirct)\n            if not os.path.isdir(dirct):\n                parent_dir = os.path.dirname(dirct)\n                if not os.path.isdir(parent_dir):\n                    create_missing_dirs(parent_dir, uid, gid)\n\n                os.mkdir(dirct)\n                os.chown(dirct, uid, gid)\n                os.chmod(dirct, 0o700)\n\n        try:\n            u = pwd.getpwnam(self.user)\n            create_missing_dirs(self.home, u.pw_uid, u.pw_gid)\n            return True\n        except OSError as error:\n            raise decman.SourceError(\n                f\"Failed to create GPG directory {self.home} for {self.user}.\"\n            ) from error\n        except KeyError:\n            return False\n\n    def list_fingerprints(self) -> set[str]:\n        out = decman.prg(\n            [\"gpg\", \"--homedir\", self.home, \"--batch\", \"--no-tty\", \"--with-colons\", \"--list-keys\"],\n            user=self.user,\n            pty=False,\n        )\n        fprs: set[str] = set()\n        for line in out.splitlines():\n            if line.startswith(\"fpr:\"):\n                parts = line.split(\":\")\n                if len(parts) > 9 and parts[9]:\n                    fprs.add(parts[9])\n        return fprs\n\n    def set_key_trust(self, keys: list[tuple[str, OwnerTrust]]):\n        if not keys:\n            return\n        lines = [f\"{fpr}:{_TRUST_MAP[trust]}:\" for fpr, trust in keys]\n        data = \"\\n\".join(lines) + \"\\n\"\n\n        cmd = [\n            \"gpg\",\n            \"--homedir\",\n            self.home,\n            \"--batch\",\n            \"--yes\",\n            \"--no-tty\",\n            \"--import-ownertrust\",\n        ]\n        # use subprocess manually since decman exposed functions don't allow setting input\n        p = subprocess.run(\n            cmd,\n            input=data,\n            stdout=subprocess.PIPE,\n            stderr=subprocess.STDOUT,\n            user=self.user,\n            text=True,\n            check=False,\n        )\n        if p.returncode != 0:\n            raise CommandFailedError(cmd, p.returncode, p.stdout)\n\n    def delete_keys(self, fingerprints: list[str]):\n        decman.prg(\n            [\n                \"gpg\",\n                \"--homedir\",\n                self.home,\n                \"--batch\",\n                \"--yes\",\n                \"--no-tty\",\n                \"--delete-keys\",\n            ]\n            + fingerprints,\n            user=self.user,\n            pty=False,\n        )\n\n    def fetch_key(self, uri: str):\n        decman.prg(\n            [\n                \"gpg\",\n                \"--homedir\",\n                self.home,\n                \"--batch\",\n                \"--yes\",\n                \"--no-tty\",\n                \"--fetch-key\",\n                uri,\n            ],\n            user=self.user,\n            pty=False,\n        )\n\n    def import_key(self, path: str):\n        decman.prg(\n            [\n                \"gpg\",\n                \"--homedir\",\n                self.home,\n                \"--batch\",\n                \"--yes\",\n                \"--no-tty\",\n                \"--import\",\n                path,\n            ],\n            user=self.user,\n            pty=False,\n        )\n\n    def receive_key(self, fingerprint: str, keyserver: str):\n        decman.prg(\n            [\n                \"gpg\",\n                \"--homedir\",\n                self.home,\n                \"--batch\",\n                \"--yes\",\n                \"--no-tty\",\n                \"--keyserver\",\n                keyserver,\n                \"--recv-keys\",\n                fingerprint,\n            ],\n            user=self.user,\n            pty=False,\n        )\n\n\nclass GPGReceiver(module.Module):\n    \"\"\"\n    Module for receiving OpenPGP keys.\n\n    This is basically built for importing AUR package keys.\n\n    If trying to add a key to an user that doesn't exist, this module silently skips user.\n\n    It's functionality is limited and I don't recommend using this with your main user account.\n    Instead create specific account for AUR package building and import keys to that account.\n\n    This module doesn't use the GPGME library and instead just calls gpg directly. It's simpler and\n    good enough for this usecase.\n\n    This module is a singleton, meaning that you should create only a one instance of this module\n    and pass that around.\n    \"\"\"\n\n    def __init__(self) -> None:\n        super().__init__(\"gpgreceiver\")\n        self._keys: dict[tuple[str, str], list[Key]] = {}\n\n    def receive_key(\n        self,\n        user: str,\n        gpg_home: str,\n        fingerprint: str,\n        keyserver: str,\n        trust: OwnerTrust | None = None,\n    ):\n        \"\"\"\n        Receives a key.\n\n        The key is imported as the given ``user`` into the specified ``gpg_home``.\n\n        If trust is specified, sets it.\n        \"\"\"\n        self._keys.setdefault((user, gpg_home), []).append(\n            Key(fingerprint, \"fingerprint\", keyserver, trust)\n        )\n\n    def fetch_key(\n        self, user: str, gpg_home: str, fingerprint: str, uri: str, trust: OwnerTrust | None = None\n    ):\n        \"\"\"\n        Fetches a key from a URI.\n\n        The key is imported as the given ``user`` into the specified ``gpg_home``.\n\n        If trust is specified, sets it.\n        \"\"\"\n        self._keys.setdefault((user, gpg_home), []).append(Key(fingerprint, \"uri\", uri, trust))\n\n    def import_key(\n        self, user: str, gpg_home: str, fingerprint: str, file: str, trust: OwnerTrust | None = None\n    ):\n        \"\"\"\n        Imports a key from file.\n\n        The key is imported as the given ``user`` into the specified ``gpg_home``.\n\n        If trust is specified, sets it.\n        \"\"\"\n        self._keys.setdefault((user, gpg_home), []).append(Key(fingerprint, \"file\", file, trust))\n\n    def _add_key(self, gpg: _GPGInterface, key: Key):\n        match key.source_kind:\n            case \"fingerprint\":\n                gpg.receive_key(key.fingerprint, key.source)\n            case \"uri\":\n                gpg.fetch_key(key.source)\n            case \"file\":\n                gpg.import_key(key.source)\n\n    def before_update(self, store: _store.Store):\n        store.ensure(\"gpgreceiver_userhome_keys\", {})\n\n        known_users = {\n            (line.split(\":\", 1)[0], line.split(\":\", 1)[1])\n            for line in store[\"gpgreceiver_userhome_keys\"]\n        }\n\n        for user, gpg_home in self._keys.keys() | known_users:\n            keys = self._keys.get((user, gpg_home), [])\n            gpg = _GPGInterface(user, gpg_home)\n\n            if not gpg.ensure_home():\n                output.print_warning(f\"User {user} doesn't exist, so PGP keys cannot be modified.\")\n                del store[\"gpgreceiver_userhome_keys\"][f\"{user}:{gpg_home}\"]\n                continue\n\n            old_fprs = store[\"gpgreceiver_userhome_keys\"].get(f\"{user}:{gpg_home}\", set())\n            fprs_before_import = gpg.list_fingerprints()\n            new_fprs = set()\n            managed_fprs = set()\n            key_trust_levels = []\n\n            for key in keys:\n                managed_fprs.add(key.fingerprint)\n                if key.trust:\n                    key_trust_levels.append((key.fingerprint, key.trust))\n                if key.fingerprint not in fprs_before_import:\n                    output.print_info(\n                        f\"Adding PGP key {key.fingerprint} to {user}:{gpg_home} \"\n                        f\"from {key.source_kind} {key.source}.\"\n                    )\n                    self._add_key(gpg, key)\n                    new_fprs.add(key.fingerprint)\n\n            fprs_after_import = gpg.list_fingerprints()\n            missing = new_fprs - fprs_after_import\n            if missing:\n                raise decman.SourceError(\n                    f\"Fingerprints for PGP not found after importing all keys: {' '.join(missing)}\"\n                )\n\n            if key_trust_levels:\n                gpg.set_key_trust(key_trust_levels)\n\n            unaccounted_fprs = (fprs_after_import - fprs_before_import) - new_fprs\n            if unaccounted_fprs:\n                output.print_warning(\n                    \"While adding PGP keys these fingerprints were unaccounted for: \"\n                    f\"{' '.join(unaccounted_fprs)}\"\n                )\n                output.print_warning(\"The keys were added, but their ownertrust was not set.\")\n\n            fprs_to_remove = list(old_fprs - managed_fprs)\n            if fprs_to_remove:\n                output.print_list(\n                    f\"Deleting PGP keys from {user}:{gpg_home}\", fprs_to_remove, level=output.INFO\n                )\n                gpg.delete_keys(fprs_to_remove)\n\n            store[\"gpgreceiver_userhome_keys\"][f\"{user}:{gpg_home}\"] = managed_fprs\n"
  },
  {
    "path": "src/decman/extras/users.py",
    "content": "import grp\nimport pwd\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nimport decman.core.command as command\nimport decman.core.module as module\nimport decman.core.output as output\nimport decman.core.store as _store\n\n\n@dataclass(frozen=True)\nclass Group:\n    \"\"\"\n    Represents a group managed by the ``UserManager`` module.\n\n    The ``system`` attribute only affects the creation of this group.\n    After the group has been created, changing the ``system`` attribute does nothing.\n    \"\"\"\n\n    groupname: str\n    gid: Optional[int] = None\n    system: bool = False\n\n    def __str__(self) -> str:\n        parts = []\n        if self.gid is not None:\n            parts.append(f\"gid={self.gid}\")\n        if self.system:\n            parts.append(\"system\")\n        return f\"{self.groupname}({', '.join(parts)})\"\n\n\n@dataclass(frozen=True)\nclass User:\n    \"\"\"\n    Represents a user managed by the ``UserManager`` module.\n\n    The ``system`` attribute only affects the creation of this user.\n    After the user has been created, changing the ``system`` attribute does nothing.\n    \"\"\"\n\n    username: str\n    uid: Optional[int] = None\n    group: Optional[str] = None\n    home: Optional[str] = None\n    shell: Optional[str] = None\n    groups: tuple[str, ...] = ()\n    system: bool = False\n\n    def __str__(self) -> str:\n        parts = []\n        if self.uid is not None:\n            parts.append(f\"uid={self.uid}\")\n        if self.group is not None:\n            parts.append(f\"gid={self.group}\")\n        if self.home is not None:\n            parts.append(f\"home={self.home}\")\n        if self.shell is not None:\n            parts.append(f\"shell={self.shell}\")\n        if self.groups:\n            parts.append(f\"groups={','.join(self.groups)}\")\n        if self.system:\n            parts.append(\"system\")\n        return f\"{self.username}({', '.join(parts)})\"\n\n\nclass UserManager(module.Module):\n    \"\"\"\n    A module for managing users and groups. This module is additive, if you create a user or a group\n    manually, this module will not modify them, unless you explicitly add them to this module.\n\n    Users and groups are created, modified and deleted at ``before_update`` -stage.\n    Users are added to groups and subuids/subgids at ``after_update`` -stage.\n\n    Decman store keys used by this module are:\n\n        - ``usermanager_users``\n        - ``usermanager_groups``\n        - ``usermanager_user_additional_groups``\n        - ``usermanager_user_subuids``\n        - ``usermanager_user_subgids``\n\n    Most management done by this module is with the commands ``useradd``, ``groupadd`` and\n    ``usermod``.\n\n    This module contains useful utilities for the most common user management cases,\n    but it is not complete.\n    If you need advanced user management features you should probably fork this module.\n\n    This module is a singleton, meaning that you should create only a one instance of this module\n    and pass that around.\n    \"\"\"\n\n    def __init__(self) -> None:\n        super().__init__(\"usermanager\")\n        self.users: set[User] = set()\n        self.groups: set[Group] = set()\n        self._user_additional_groups: dict[str, set[str]] = {}\n        self._user_subuids: dict[str, set[tuple[int, int]]] = {}\n        self._user_subgids: dict[str, set[tuple[int, int]]] = {}\n\n    def add_user(self, user: User):\n        \"\"\"\n        Ensures that the user exists with the given attributes.\n        \"\"\"\n        self.users.add(user)\n\n    def add_group(self, group: Group):\n        \"\"\"\n        Ensures that the group exists with the given attributes.\n        \"\"\"\n        self.groups.add(group)\n\n    def add_user_to_group(self, user: str, group: str):\n        \"\"\"\n        Ensures that the user is a member of the given group.\n\n        Both ``user`` and ``group`` should exist.\n        \"\"\"\n        self._user_additional_groups.setdefault(user, set()).add(group)\n\n    def add_subuids(self, user: str, first: int, last: int):\n        \"\"\"\n        Adds the range ``first``-``last`` subordinate uids to the ``user``s account.\n\n        Note!\n\n            This module doesn't parse ``/etc/subuid`` or ``/etc/subgid``.\n            Instead, the added subuids and subgids are stored in the decman store.\n            Stored values are used to remove the added subuids and subgids from the user.\n\n            Manual modifications or clearing the decman store can cause unexpected issues.\n        \"\"\"\n        self._user_subuids.setdefault(user, set()).add((first, last))\n\n    def add_subgids(self, user: str, first: int, last: int):\n        \"\"\"\n        Adds the range ``first``-``last`` subordinate gids to the ``user``s account.\n\n        Note!\n\n            This module doesn't parse ``/etc/subuid`` or ``/etc/subgid``.\n            Instead, the added subuids and subgids are stored in the decman store.\n            Stored values are used to remove the added subuids and subgids from the user.\n\n            Manual modifications or clearing the decman store can cause unexpected issues.\n        \"\"\"\n        self._user_subgids.setdefault(user, set()).add((first, last))\n\n    def _check_user(self, user: User, user_groups_index: dict[str, set[str]]):\n        userdb_name = None\n        userdb_uid = None\n\n        try:\n            userdb_name = pwd.getpwnam(user.username)\n        except KeyError:\n            pass\n\n        try:\n            if user.uid is not None:\n                userdb_uid = pwd.getpwuid(user.uid)\n        except KeyError:\n            pass\n\n        # Prioritize uid match. If uid matches but name doesn't, rename the user.\n        userdb = userdb_uid or userdb_name\n\n        if not userdb:\n            self._add_user(user)\n        else:\n            self._ensure_user_matches(user, userdb, user_groups_index)\n\n    def _add_user(self, user: User):\n        cmd = [\"useradd\"]\n\n        if user.uid is not None:\n            cmd += [\"--uid\", str(user.uid)]\n\n        if user.group:\n            cmd += [\"--gid\", user.group]\n\n        if user.home:\n            cmd += [\"--create-home\", \"--home-dir\", user.home]\n\n        if user.shell:\n            cmd += [\"--shell\", user.shell]\n\n        if user.groups:\n            cmd += [\"--groups\", \",\".join(list(user.groups))]\n\n        if user.system:\n            cmd.append(\"--system\")\n\n        cmd.append(user.username)\n        output.print_info(f\"Creating user {user}.\")\n        command.prg(cmd, pty=False)\n\n    def _ensure_user_matches(\n        self, user: User, userdb: pwd.struct_passwd, user_groups_index: dict[str, set[str]]\n    ):\n        cmd = [\"usermod\"]\n\n        if user.username != userdb.pw_name:\n            cmd += [\"--login\", user.username]\n\n        if user.uid is not None and user.uid != userdb.pw_uid:\n            cmd += [\"--uid\", str(user.uid)]\n\n        if user.group and user.group != grp.getgrgid(userdb.pw_gid).gr_name:\n            cmd += [\"--gid\", user.group]\n\n        if user.home and user.home != userdb.pw_dir:\n            cmd += [\"--move-home\", \"--home\", user.home]\n\n        if user.shell and user.shell != userdb.pw_shell:\n            cmd += [\"--shell\", user.shell]\n\n        # Use old name to support renames, post rename groups match\n        old_groups = user_groups_index.get(userdb.pw_name, set())\n        if user.groups is not None and set(user.groups) != old_groups:\n            if user.groups:\n                cmd += [\"--groups\", \",\".join(list(user.groups))]\n            elif old_groups:\n                # Remove user from other groups\n                cmd += [\"-r\", \"--groups\", \",\".join(old_groups)]\n\n        if len(cmd) > 1:\n            # Use old name to support renames\n            cmd.append(userdb.pw_name)\n            output.print_info(f\"Modifying user {user}.\")\n            command.prg(cmd, pty=False)\n\n    def _user_groups_index(self) -> dict[str, set[str]]:\n        result: dict[str, set[str]] = {}\n        for gr in grp.getgrall():\n            group = gr.gr_name\n            for user in gr.gr_mem:\n                result.setdefault(user, set()).add(group)\n        return result\n\n    def _check_group(self, group: Group):\n        groupdb_name = None\n        groupdb_gid = None\n\n        try:\n            groupdb_name = grp.getgrnam(group.groupname)\n        except KeyError:\n            pass\n\n        try:\n            if group.gid is not None:\n                groupdb_gid = grp.getgrgid(group.gid)\n        except KeyError:\n            pass\n\n        groupdb = groupdb_gid or groupdb_name\n\n        if not groupdb:\n            self._add_group(group)\n        else:\n            self._ensure_group_matches(group, groupdb)\n\n    def _add_group(self, group: Group):\n        cmd = [\"groupadd\"]\n\n        if group.gid is not None:\n            cmd += [\"--gid\", str(group.gid)]\n\n        if group.system:\n            cmd.append(\"--system\")\n\n        cmd.append(group.groupname)\n        output.print_info(f\"Creating group {group}.\")\n        command.prg(cmd, pty=False)\n\n    def _ensure_group_matches(self, group: Group, groupdb: grp.struct_group):\n        cmd = [\"groupmod\"]\n\n        if group.groupname != groupdb.gr_name:\n            cmd += [\"--new-name\", group.groupname]\n\n        if group.gid is not None and group.gid != groupdb.gr_gid:\n            cmd += [\"--gid\", str(group.gid)]\n\n        if len(cmd) > 1:\n            # Use old name to support renames\n            cmd.append(groupdb.gr_name)\n            output.print_info(f\"Modifying group {group}.\")\n            command.prg(cmd, pty=False)\n\n    def _modify_user_groups_subids(self, user: str, store: _store.Store):\n        store.ensure(\"usermanager_user_additional_groups\", {})\n        store.ensure(\"usermanager_user_subuids\", {})\n        store.ensure(\"usermanager_user_subgids\", {})\n\n        old_groups = store[\"usermanager_user_additional_groups\"].get(user, set())\n        old_subuids = store[\"usermanager_user_subuids\"].get(user, set())\n        old_subgids = store[\"usermanager_user_subgids\"].get(user, set())\n\n        new_groups = self._user_additional_groups.get(user, set())\n        new_subuids = self._user_subuids.get(user, set())\n        new_subgids = self._user_subgids.get(user, set())\n\n        groups_to_remove = old_groups - new_groups\n        groups_to_add = new_groups - old_groups\n        subuids_to_remove = old_subuids - new_subuids\n        subuids_to_add = new_subuids - old_subuids\n        subgids_to_remove = old_subgids - new_subgids\n        subgids_to_add = new_subgids - old_subgids\n\n        output.print_list(\n            f\"Removing {user} from groups:\", list(groups_to_remove), level=output.INFO\n        )\n        output.print_list(f\"Adding {user} to groups:\", list(groups_to_add), level=output.INFO)\n\n        # It's not possible to remove and add groups at the same time, so remove groups first\n        if groups_to_remove:\n            command.prg([\"usermod\", \"-r\", \"-G\", \",\".join(groups_to_remove), user], pty=False)\n            # Set these only if things change, no need to clutter the store otherwise\n            store[\"usermanager_user_additional_groups\"][user] = new_groups\n\n        # Rest of the changes can be done with a single command\n        cmd = [\"usermod\"]\n        if groups_to_add:\n            cmd += [\"-a\", \"-G\", \",\".join(groups_to_add)]\n\n        for first, last in subuids_to_remove:\n            output.print_info(f\"Removing subuids {first}-{last} from {user}.\")\n            cmd += [\"--del-subuids\", f\"{first}-{last}\"]\n\n        for first, last in subuids_to_add:\n            output.print_info(f\"Adding subuids {first}-{last} to {user}.\")\n            cmd += [\"--add-subuids\", f\"{first}-{last}\"]\n\n        for first, last in subgids_to_remove:\n            output.print_info(f\"Removing subgids {first}-{last} from {user}.\")\n            cmd += [\"--del-subgids\", f\"{first}-{last}\"]\n\n        for first, last in subgids_to_add:\n            output.print_info(f\"Adding subgids {first}-{last} to {user}.\")\n            cmd += [\"--add-subgids\", f\"{first}-{last}\"]\n\n        if len(cmd) > 1:\n            cmd.append(user)\n            command.prg(cmd, pty=False)\n\n            # Set these only if things change, no need to clutter the store otherwise\n            store[\"usermanager_user_additional_groups\"][user] = new_groups\n            store[\"usermanager_user_subuids\"][user] = new_subuids\n            store[\"usermanager_user_subgids\"][user] = new_subgids\n\n    def _delete_users_and_groups(self, store: _store.Store):\n        store.ensure(\"usermanager_users\", set())\n        store.ensure(\"usermanager_groups\", set())\n\n        managed_users = set(map(lambda u: u.username, self.users))\n        managed_groups = set(map(lambda g: g.groupname, self.groups))\n\n        groups_to_remove = store[\"usermanager_groups\"] - managed_groups\n        users_to_remove = store[\"usermanager_users\"] - managed_users\n\n        for user in users_to_remove:\n            output.print_info(f\"Deleting user {user}.\")\n            command.prg([\"userdel\", user], pty=False)\n\n        store[\"usermanager_users\"] = managed_users\n\n        for group in groups_to_remove:\n            output.print_info(f\"Deleting group {group}.\")\n            command.prg([\"groupdel\", group], pty=False)\n\n        store[\"usermanager_groups\"] = managed_groups\n\n    def before_update(self, store: _store.Store):\n        for group in self.groups:\n            self._check_group(group)\n        user_groups_index = self._user_groups_index()\n        for user in self.users:\n            self._check_user(user, user_groups_index)\n\n        self._delete_users_and_groups(store)\n\n    def after_update(self, store: _store.Store):\n        # Iterate all entries to ensure removals take place\n        for user in pwd.getpwall():\n            self._modify_user_groups_subids(user.pw_name, store)\n"
  },
  {
    "path": "src/decman/plugins/__init__.py",
    "content": "import importlib.metadata as metadata\nimport typing\n\nimport decman.core.module as module\nimport decman.core.store as _store\n\n\nclass Plugin:\n    \"\"\"\n    A Plugin manages one part of a system.\n\n    NAME:\n        Canonical plugin name.\n    \"\"\"\n\n    NAME: str = \"\"\n\n    def available(self) -> bool:\n        \"\"\"\n        Checks if this plugin can be enabled.\n\n        For example, this could check if a required command is available.\n\n        Returns true if this plugin can be enabled.\n        \"\"\"\n        return True\n\n    def apply(\n        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None\n    ) -> bool:\n        \"\"\"\n        Ensures that the state managed by this plugin is present.\n\n        Set ``dry_run`` to only print changes applying this plugin would cause.\n\n        This method must not raise exceptions. Instead it should return False to indicate a\n        failure. The method should handle it's exceptions and print them to the user.\n\n        Returns ``True`` when applying was successful, ``False`` when it failed.\n        \"\"\"\n        return True\n\n    def process_modules(self, store: _store.Store, modules: list[module.Module]):\n        \"\"\"\n        Processes a module.\n        \"\"\"\n\n\ndef run_method_with_attribute(mod: module.Module, attribute: str) -> typing.Any:\n    \"\"\"\n    Runs the first method with the given attribute in the module and returns its returned value.\n    Returns ``None`` if no such method is found.\n\n    Only the first found method with the attribute is ran.\n    \"\"\"\n    for name in dir(mod):\n        attr = getattr(mod, name)\n        if not callable(attr):\n            continue\n        func = getattr(attr, \"__func__\", attr)\n        if getattr(func, attribute, False):\n            return attr()\n\n    return None\n\n\ndef run_methods_with_attribute(mod: module.Module, attribute: str) -> list[typing.Any]:\n    \"\"\"\n    Runs all methods with the given attribute in the module and returns their returned values.\n    Returns an empty list if no such methods are found.\n    \"\"\"\n    values = []\n    for name in dir(mod):\n        attr = getattr(mod, name)\n        if not callable(attr):\n            continue\n        func = getattr(attr, \"__func__\", attr)\n        if getattr(func, attribute, False):\n            values.append(attr())\n\n    return values\n\n\ndef available_plugins() -> dict[str, Plugin]:\n    \"\"\"\n    Returns all available plugins.\n    \"\"\"\n    plugins = {}\n    eps = metadata.entry_points(group=\"decman.plugins\")\n    for ep in eps:\n        cls = ep.load()\n        if not issubclass(cls, Plugin):\n            continue\n        instance = cls()\n\n        if instance.available():\n            plugins[cls.NAME] = instance\n    return plugins\n"
  },
  {
    "path": "src/decman/py.typed",
    "content": ""
  },
  {
    "path": "tests/test_decman_app.py",
    "content": "import argparse\nimport types\n\nimport pytest\n\nimport decman.app as app  # adjust if run_decman lives elsewhere\n\n\nclass DummyStore:\n    def __init__(self, enabled=None, scripts=None):\n        self._data = {}\n        if enabled is not None:\n            self._data[\"enabled_modules\"] = list(enabled)\n        if scripts is not None:\n            self._data[\"module_on_disable_scripts\"] = dict(scripts)\n\n    def __getitem__(self, key):\n        return self._data[key]\n\n    def __setitem__(self, key, value):\n        self._data[key] = value\n\n    def ensure(self, key, default):\n        self._data.setdefault(key, default)\n\n\nclass DummyModule:\n    def __init__(self, name):\n        self.name = name\n        self._changed = False\n        self.before_update_called = False\n        self.on_enable_called = False\n        self.on_change_called = False\n        self.after_update_called = False\n\n    def before_update(self, store):\n        self.before_update_called = True\n\n    def on_enable(self, store):\n        self.on_enable_called = True\n\n    def on_change(self, store):\n        self.on_change_called = True\n\n    def after_update(self, store):\n        self.after_update_called = True\n\n    @staticmethod\n    def on_disable():\n        print(\"Disabled\")\n\n\nclass DummyPlugin:\n    def __init__(self, apply_result=True):\n        self.process_modules_called = False\n        self.apply_called_with = None\n        self.apply_result = apply_result\n\n    def process_modules(self, store, modules):\n        self.process_modules_called = True\n\n    def apply(self, store, dry_run=False, params=None):\n        self.apply_called_with = dry_run\n        return self.apply_result\n\n\ndef make_args(\n    only=None,\n    skip=None,\n    dry_run=False,\n    no_hooks=False,\n):\n    return argparse.Namespace(\n        only=only, skip=skip or [], dry_run=dry_run, no_hooks=no_hooks, params=[]\n    )\n\n\n@pytest.fixture\ndef no_op_output(monkeypatch):\n    ns = types.SimpleNamespace(\n        print_debug=lambda *a, **k: None,\n        print_summary=lambda *a, **k: None,\n        print_info=lambda *a, **k: None,\n        print_warning=lambda *a, **k: None,\n    )\n    monkeypatch.setattr(app, \"output\", ns)\n    return ns\n\n\n@pytest.fixture\ndef base_decman(monkeypatch):\n    # Ensure decman attribute exists on app and has the fields we need\n    dm = types.SimpleNamespace()\n    dm.execution_order = []\n    dm.modules = []\n    dm.files = []\n    dm.directories = []\n    dm.symlinks = {}\n    dm.plugins = {}\n    dm.prg_calls = []\n\n    def prg(cmd):\n        dm.prg_calls.append(cmd)\n\n    dm.prg = prg\n\n    monkeypatch.setattr(app, \"decman\", dm)\n    return dm\n\n\n@pytest.fixture\ndef file_manager(monkeypatch):\n    fm = types.SimpleNamespace()\n    fm.update_files_calls = []\n    fm.result = True\n\n    def update_files(store, modules, files, directories, symlinks, dry_run=False):\n        fm.update_files_calls.append(\n            dict(\n                store=store,\n                modules=list(modules),\n                files=list(files),\n                directories=list(directories),\n                symlinks=list(symlinks),\n                dry_run=dry_run,\n            )\n        )\n        return fm.result\n\n    fm.update_files = update_files\n    monkeypatch.setattr(app, \"file_manager\", fm)\n    return fm\n\n\ndef test_execution_order_only_and_skip(no_op_output, base_decman, file_manager):\n    base_decman.execution_order = [\"files\", \"plugin_a\", \"plugin_b\"]\n\n    args = make_args(\n        only=[\"files\", \"plugin_b\"],\n        skip=[\"plugin_b\"],\n        dry_run=True,\n        no_hooks=True,\n    )\n    store = DummyStore()\n\n    plugin = DummyPlugin(apply_result=False)\n    base_decman.plugins = {\"plugin_b\": plugin}\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n    # Should have run only \"files\"\n    assert len(file_manager.update_files_calls) == 1\n    assert file_manager.update_files_calls[0][\"dry_run\"] is True\n\n\ndef test_returns_false_when_update_files_fails_and_skips_plugins(\n    no_op_output, base_decman, file_manager\n):\n    base_decman.execution_order = [\"files\", \"plugin_a\"]\n\n    plugin = DummyPlugin(apply_result=True)\n    base_decman.plugins = {\"plugin_a\": plugin}\n\n    file_manager.result = False  # update_files fails\n\n    args = make_args(dry_run=False, no_hooks=True)\n    store = DummyStore()\n\n    result = app.run_decman(store, args)\n\n    assert result is False\n    # update_files called once\n    assert len(file_manager.update_files_calls) == 1\n    # plugin should never be touched\n    assert plugin.process_modules_called is False\n    assert plugin.apply_called_with is None\n\n\ndef test_plugin_failure_returns_false(no_op_output, base_decman, file_manager):\n    base_decman.execution_order = [\"plugin_a\"]\n    plugin = DummyPlugin(apply_result=False)\n    base_decman.plugins = {\"plugin_a\": plugin}\n\n    args = make_args(dry_run=False, no_hooks=True)\n    store = DummyStore()\n\n    result = app.run_decman(store, args)\n\n    assert result is False\n    assert plugin.process_modules_called is True\n    assert plugin.apply_called_with is False\n    # No file updates\n    assert file_manager.update_files_calls == []\n\n\ndef test_disabled_modules_run_on_disable_script(no_op_output, base_decman, file_manager):\n    # enabled_modules contains a module that no longer exists\n    store = DummyStore(\n        enabled=[\"present\", \"old_mod\"],\n        scripts={\"old_mod\": \"/tmp/on_disable.sh\"},\n    )\n\n    # Only \"present\" exists now, so \"old_mod\" is disabled\n    base_decman.modules = [DummyModule(\"present\")]\n    base_decman.execution_order = []\n\n    args = make_args(dry_run=False, no_hooks=False)\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n    # prg should be called with the script for old_mod\n    assert base_decman.prg_calls == [[\"/tmp/on_disable.sh\"]]\n    assert store[\"enabled_modules\"] == [\"present\"]\n    assert store[\"module_on_disable_scripts\"] == {}\n\n\ndef test_on_disable_not_run_in_dry_run(no_op_output, base_decman, file_manager):\n    store = DummyStore(\n        enabled=[\"present\", \"old_mod\"],\n        scripts={\"old_mod\": \"/tmp/on_disable.sh\"},\n    )\n    base_decman.modules = [DummyModule(\"present\")]\n    base_decman.execution_order = []\n\n    args = make_args(dry_run=True, no_hooks=True)\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n    # dry_run: on_disable scripts must not be executed\n    assert base_decman.prg_calls == []\n\n\ndef test_hooks_called_for_new_and_changed_modules(\n    no_op_output, base_decman, file_manager, monkeypatch, tmp_path\n):\n    m1 = DummyModule(\"mod1\")\n    m2 = DummyModule(\"mod2\")\n    m1._changed = True\n    m2._changed = False\n\n    base_decman.modules = [m1, m2]\n    base_decman.execution_order = []  # no steps, just hooks\n\n    monkeypatch.setattr(\"decman.config.module_on_disable_scripts_dir\", tmp_path)\n\n    # Only mod2 was previously enabled, so mod1 is \"new\"\n    store = DummyStore(enabled=[\"mod2\"])\n\n    args = make_args(dry_run=False, no_hooks=False)\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n\n    # before_update for all modules\n    assert m1.before_update_called is True\n    assert m2.before_update_called is True\n\n    # on_enable only for new module (mod1)\n    assert m1.on_enable_called is True\n    assert m2.on_enable_called is False\n\n    # on_change only for modules with _changed\n    assert m1.on_change_called is True\n    assert m2.on_change_called is False\n\n    # after_update for all modules\n    assert m1.after_update_called is True\n    assert m2.after_update_called is True\n\n    assert store[\"enabled_modules\"] == [\"mod2\", \"mod1\"]\n    assert store[\"module_on_disable_scripts\"] == {\"mod1\": str(tmp_path / \"mod1_on_disable.py\")}\n\n\ndef test_hooks_not_called_when_no_hooks(no_op_output, base_decman, file_manager):\n    m1 = DummyModule(\"mod1\")\n    m1._changed = True\n\n    base_decman.modules = [m1]\n    base_decman.execution_order = []\n\n    store = DummyStore(enabled=[\"mod1\"])\n\n    args = make_args(dry_run=False, no_hooks=True)\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n\n    assert m1.before_update_called is False\n    assert m1.on_enable_called is False\n    assert m1.on_change_called is False\n    assert m1.after_update_called is False\n\n\ndef test_dry_run_skips_all_hooks_but_runs_steps_with_flag(no_op_output, base_decman, file_manager):\n    m1 = DummyModule(\"mod1\")\n    m1._changed = True\n    base_decman.modules = [m1]\n\n    base_decman.execution_order = [\"files\", \"plugin_a\"]\n    plugin = DummyPlugin(apply_result=True)\n    base_decman.plugins = {\"plugin_a\": plugin}\n\n    store = DummyStore()\n    args = make_args(dry_run=True, no_hooks=False)\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n\n    # Steps executed with dry_run=True\n    assert len(file_manager.update_files_calls) == 1\n    assert file_manager.update_files_calls[0][\"dry_run\"] is True\n    assert plugin.process_modules_called is True\n    assert plugin.apply_called_with is True\n\n    # All hooks skipped due to dry_run\n    assert m1.before_update_called is False\n    assert m1.on_enable_called is False\n    assert m1.on_change_called is False\n    assert m1.after_update_called is False\n\n\ndef test_missing_plugin_emits_warning_but_continues(base_decman, file_manager, monkeypatch):\n    warnings = []\n\n    def warn(msg):\n        warnings.append(msg)\n\n    out = types.SimpleNamespace(\n        print_debug=lambda *a, **k: None,\n        print_summary=lambda *a, **k: None,\n        print_info=lambda *a, **k: None,\n        print_warning=warn,\n    )\n    monkeypatch.setattr(app, \"output\", out)\n\n    base_decman.execution_order = [\"unknown_plugin\"]\n    base_decman.plugins = {}  # none available\n\n    store = DummyStore()\n    args = make_args(dry_run=True, no_hooks=True)\n\n    result = app.run_decman(store, args)\n\n    assert result is True\n    assert any(\"unknown_plugin\" in w for w in warnings)\n"
  },
  {
    "path": "tests/test_decman_core_command.py",
    "content": "import json\nimport sys\nimport typing\n\nimport pytest\n\nimport decman.core.command as command\nimport decman.core.output\n\n\ndef test_prg_pty_true_uses_pty_run_and_check(monkeypatch: pytest.MonkeyPatch):\n    calls: dict[str, typing.Any] = {}\n\n    def fake_pty_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):\n        calls[\"pty_run\"] = (cmd, user, env_overrides, mimic_login)\n        return 0, \"ok\"\n\n    def fake_check_run_result(cmd, result, include_output=None):\n        calls[\"check_run_result\"] = (cmd, result)\n        return result\n\n    def fake_print_warning(msg: str):\n        raise AssertionError(\"print_warning must not be called when code == 0\")\n\n    monkeypatch.setattr(command, \"pty_run\", fake_pty_run)\n    monkeypatch.setattr(command, \"check_run_result\", fake_check_run_result)\n    monkeypatch.setattr(decman.core.output, \"print_warning\", fake_print_warning)\n\n    out = decman.prg(\n        [\"echo\", \"hi\"],\n        user=\"alice\",\n        env_overrides={\"FOO\": \"bar\"},\n        mimic_login=True,\n        pty=True,\n        check=True,\n    )\n\n    assert out == \"ok\"\n    assert calls[\"pty_run\"] == ([\"echo\", \"hi\"], \"alice\", {\"FOO\": \"bar\"}, True)\n    assert calls[\"check_run_result\"] == ([\"echo\", \"hi\"], (0, \"ok\"))\n\n\ndef test_prg_pty_false_uses_run(monkeypatch: pytest.MonkeyPatch):\n    calls: dict[str, typing.Any] = {}\n\n    def fake_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):\n        calls[\"run\"] = (cmd, user, env_overrides, mimic_login)\n        return 0, \"no-pty\"\n\n    def fake_check_run_result(cmd, result, include_output=None):\n        return result\n\n    def fake_print_warning(msg: str):\n        raise AssertionError(\"print_warning must not be called when code == 0\")\n\n    monkeypatch.setattr(command, \"run\", fake_run)\n    monkeypatch.setattr(command, \"check_run_result\", fake_check_run_result)\n    monkeypatch.setattr(decman.core.output, \"print_warning\", fake_print_warning)\n\n    out = decman.prg([\"true\"], pty=False, check=True)\n\n    assert out == \"no-pty\"\n    assert calls[\"run\"] == ([\"true\"], None, None, False)\n\n\ndef test_prg_check_false_warns_on_nonzero(monkeypatch: pytest.MonkeyPatch):\n    calls: dict[str, typing.Any] = {}\n\n    def fake_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):\n        # non-zero exit code\n        return 3, \"bad\"\n\n    def fake_check_run_result(cmd, result, include_output=None):\n        raise AssertionError(\"check_run_result must not be called when check=False\")\n\n    def fake_print_warning(msg: str):\n        calls[\"warning\"] = msg\n\n    monkeypatch.setattr(command, \"run\", fake_run)\n    monkeypatch.setattr(command, \"check_run_result\", fake_check_run_result)\n    monkeypatch.setattr(decman.core.output, \"print_warning\", fake_print_warning)\n\n    out = decman.prg([\"cmd\", \"arg\"], pty=False, check=False)\n\n    assert out == \"bad\"\n    assert \"cmd arg\" in calls[\"warning\"]\n    assert \"exit code 3\" in calls[\"warning\"]\n\n\ndef test_prg_check_true_propagates_command_failed_error(monkeypatch: pytest.MonkeyPatch):\n    class CommandFailedError(Exception):\n        pass\n\n    def fake_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):\n        return 42, \"boom\"\n\n    def fake_check_run_result(cmd, result, include_output=None):\n        raise CommandFailedError((cmd, result))\n\n    def fake_print_warning(msg: str):\n        raise AssertionError(\"print_warning must not be called when check=True and error\")\n\n    monkeypatch.setattr(command, \"run\", fake_run)\n    monkeypatch.setattr(command, \"check_run_result\", fake_check_run_result)\n    monkeypatch.setattr(decman.core.output, \"print_warning\", fake_print_warning)\n\n    with pytest.raises(CommandFailedError):\n        decman.prg([\"boom\"], pty=False, check=True)\n\n\ndef test_run_simple():\n    code, out = command.run([sys.executable, \"-c\", \"print('ok')\"])\n    assert code == 0\n    assert out.strip() == \"ok\"\n\n\ndef test_run_exec_failure():\n    code, out = command.run([\"/does/not/exist\"])\n    assert code != 0\n    assert \"not\" in out.lower()\n\n\ndef test_run_env_overrides_visible_in_child(monkeypatch):\n    code, out = command.run(\n        [\n            sys.executable,\n            \"-c\",\n            (\"import os, json; print(json.dumps({'FOO': os.environ['FOO'], }))\"),\n        ],\n        env_overrides={\"FOO\": \"BAR\"},\n    )\n\n    assert code == 0\n\n    data = json.loads(out.strip())\n    assert data[\"FOO\"] == \"BAR\"\n\n\n@pytest.mark.skipif(not sys.stdin.isatty(), reason=\"requires TTY\")\ndef test_pty_run_simple():\n    code, out = command.pty_run([sys.executable, \"-c\", \"print('ok')\"])\n    assert code == 0\n    assert \"ok\" in out\n    assert \"\\r\\n\" not in out\n"
  },
  {
    "path": "tests/test_decman_core_file_manager.py",
    "content": "import os\n\nimport pytest\n\nimport decman.core.error as errors\nimport decman.core.output as output\nfrom decman.core.file_manager import (\n    _install_directories,\n    _install_files,\n    _install_symlinks,\n    update_files,\n)\n\n\nclass DummyFile:\n    def __init__(self, result=True, exc: BaseException | None = None):\n        self.result = result\n        self.exc = exc\n        self.source_file = None\n        self.calls: list[tuple[str, dict | None, bool]] = []\n\n    def copy_to(self, target: str, variables=None, dry_run: bool = False) -> bool:\n        self.calls.append((target, variables, dry_run))\n        if self.exc is not None:\n            raise self.exc\n        return self.result\n\n\nclass DummyDirectory:\n    def __init__(\n        self,\n        checked: list[str] | None = None,\n        changed: list[str] | None = None,\n        exc: BaseException | None = None,\n        source_directory: str = \"<src>\",\n    ):\n        self.checked = checked or []\n        self.changed = changed or []\n        self.exc = exc\n        self.source_directory = source_directory\n        self.calls: list[tuple[str, dict | None, bool]] = []\n\n    def copy_to(self, target: str, variables=None, dry_run: bool = False):\n        self.calls.append((target, variables, dry_run))\n        if self.exc is not None:\n            raise self.exc\n        return self.checked, self.changed\n\n\nclass DummyModule:\n    def __init__(\n        self,\n        name: str,\n        file_map: dict[str, DummyFile] | None = None,\n        dir_map: dict[str, DummyDirectory] | None = None,\n        symlink_map: dict[str, str] | None = None,\n        file_vars: dict[str, str] | None = None,\n    ):\n        self.name = name\n        self._file_map = file_map or {}\n        self._dir_map = dir_map or {}\n        self._file_vars = file_vars or {}\n        self._symlink_map = symlink_map or {}\n        self._changed = False\n\n    def files(self):\n        return self._file_map\n\n    def directories(self):\n        return self._dir_map\n\n    def symlinks(self):\n        return self._symlink_map\n\n    def file_variables(self):\n        return self._file_vars\n\n\nclass DummyStore:\n    def __init__(self, initial: dict | None = None):\n        self._data = dict(initial or {})\n\n    def __getitem__(self, key):\n        return self._data[key]\n\n    def __setitem__(self, key, value):\n        self._data[key] = value\n\n    def ensure(self, key, default):\n        self._data.setdefault(key, default)\n\n\n# ---- _install_files -------------------------------------------------------\n\n\ndef test_install_files_non_dry_run_tracks_checked_and_changed():\n    f1 = DummyFile(result=True)\n    f2 = DummyFile(result=False)\n    files = {\n        \"/tmp/file1\": f1,\n        \"/tmp/file2\": f2,\n    }\n\n    checked, changed = _install_files(files, variables={\"X\": \"1\"}, dry_run=False)\n\n    assert checked == [\"/tmp/file1\", \"/tmp/file2\"]\n    assert changed == [\"/tmp/file1\"]\n\n    assert f1.calls == [(\"/tmp/file1\", {\"X\": \"1\"}, False)]\n    assert f2.calls == [(\"/tmp/file2\", {\"X\": \"1\"}, False)]\n\n\ndef test_install_files_dry_run_uses_dry_run_flag_and_respects_return_value():\n    f1 = DummyFile(result=True)\n    f2 = DummyFile(result=False)\n    files = {\n        \"/tmp/file1\": f1,\n        \"/tmp/file2\": f2,\n    }\n\n    checked, changed = _install_files(files, variables=None, dry_run=True)\n\n    assert checked == [\"/tmp/file1\", \"/tmp/file2\"]\n    assert changed == [\"/tmp/file1\"]  # only ones that \"would\" change\n\n    assert f1.calls == [(\"/tmp/file1\", None, True)]\n    assert f2.calls == [(\"/tmp/file2\", None, True)]\n\n\n@pytest.mark.parametrize(\n    \"exc\",\n    [\n        FileNotFoundError(\"nope\"),\n        OSError(\"boom\"),\n        UnicodeEncodeError(\"utf-8\", \"x\", 0, 1, \"bad\"),\n        UnicodeDecodeError(\"utf-8\", b\"x\", 0, 1, \"bad\"),\n    ],\n)\ndef test_install_files_wraps_exceptions(exc):\n    f = DummyFile(exc=exc)\n    files = {\"/tmp/file\": f}\n\n    with pytest.raises(errors.FSInstallationFailedError) as e:\n        _install_files(files, dry_run=False)\n\n    msg = str(e.value)\n    assert \"/tmp/file\" in msg\n    assert \"content\" in msg or \"Source file doesn't exist.\" in msg\n\n\n# ---- _install_directories -------------------------------------------------\n\n\ndef test_install_directories_aggregates_checked_and_changed():\n    d1 = DummyDirectory(\n        checked=[\"/tmp/d1/a\", \"/tmp/d1/b\"],\n        changed=[\"/tmp/d1/a\"],\n        source_directory=\"/src/d1\",\n    )\n    d2 = DummyDirectory(\n        checked=[\"/tmp/d2/a\"],\n        changed=[\"/tmp/d2/a\"],\n        source_directory=\"/src/d2\",\n    )\n    dirs = {\n        \"/tmp/d1\": d1,\n        \"/tmp/d2\": d2,\n    }\n\n    checked, changed = _install_directories(dirs, variables={\"Y\": \"2\"}, dry_run=False)\n\n    assert checked == [\"/tmp/d1/a\", \"/tmp/d1/b\", \"/tmp/d2/a\"]\n    assert changed == [\"/tmp/d1/a\", \"/tmp/d2/a\"]\n\n    # dry_run flag and variables propagated\n    assert d1.calls == [(\"/tmp/d1\", {\"Y\": \"2\"}, False)]\n    assert d2.calls == [(\"/tmp/d2\", {\"Y\": \"2\"}, False)]\n\n\n@pytest.mark.parametrize(\n    \"exc\",\n    [\n        FileNotFoundError(\"nope\"),\n        OSError(\"boom\"),\n        UnicodeEncodeError(\"utf-8\", \"x\", 0, 1, \"bad\"),\n        UnicodeDecodeError(\"utf-8\", b\"x\", 0, 1, \"bad\"),\n    ],\n)\ndef test_install_directories_wraps_exceptions(exc):\n    d = DummyDirectory(exc=exc, source_directory=\"/src\")\n    dirs = {\"/tmp/d\": d}\n\n    with pytest.raises(errors.FSInstallationFailedError) as e:\n        _install_directories(dirs, dry_run=False)\n\n    msg = str(e.value)\n    assert \"/tmp/d\" in msg\n    assert \"/src\" in msg\n\n\n# ---- update_files ---------------------------------------------------------\n\n\ndef test_update_files_success_updates_store_and_removes_stale_files(monkeypatch):\n    # Prepare common files/dirs\n    common_file = DummyFile(result=True)\n    common_dir = DummyDirectory(\n        checked=[\"/etc/app/config.d/a.conf\"],\n        changed=[\"/etc/app/config.d/a.conf\"],\n        source_directory=\"/src/config.d\",\n    )\n\n    # Module with its own file\n    mod_file = DummyFile(result=True)\n    m = DummyModule(\n        name=\"mod1\",\n        file_map={\"/etc/app/mod1.conf\": mod_file},\n        dir_map={},\n        file_vars={\"FOO\": \"bar\"},\n    )\n\n    # Store already has some files, including one stale file\n    store = DummyStore(\n        {\"all_files\": [\"/etc/app/common.conf\", \"/etc/app/mod1.conf\", \"/etc/app/stale.conf\"]}\n    )\n\n    removed = []\n\n    def fake_remove(path):\n        removed.append(path)\n\n    monkeypatch.setattr(os, \"remove\", fake_remove)\n\n    # Run\n    ok = update_files(\n        store=store,\n        modules={m},\n        files={\"/etc/app/common.conf\": common_file},\n        directories={\"/etc/app/config.d\": common_dir},\n        symlinks={},\n        dry_run=False,\n    )\n\n    assert ok is True\n\n    # common + dir content + module file were re-checked\n    assert set(store[\"all_files\"]) == {\n        \"/etc/app/common.conf\",\n        \"/etc/app/config.d/a.conf\",\n        \"/etc/app/mod1.conf\",\n    }\n\n    # stale file should be removed\n    assert removed == [\"/etc/app/stale.conf\"]\n\n    # module marked changed because its file changed\n    assert m._changed is True\n\n    # copy_to called for all files with correct dry_run flag\n    assert common_file.calls == [(\"/etc/app/common.conf\", None, False)]\n    assert mod_file.calls == [(\"/etc/app/mod1.conf\", {\"FOO\": \"bar\"}, False)]\n\n\ndef test_update_files_dry_run_does_not_touch_store_or_remove(monkeypatch):\n    common_file = DummyFile(result=True)\n    common_dir = DummyDirectory(\n        checked=[\"/etc/app/config.d/a.conf\"],\n        changed=[\"/etc/app/config.d/a.conf\"],\n        source_directory=\"/src/config.d\",\n    )\n    m = DummyModule(\n        name=\"mod1\",\n        file_map={\"/etc/app/mod1.conf\": DummyFile(result=True)},\n        dir_map={},\n    )\n\n    store = DummyStore({\"all_files\": [\"/etc/app/common.conf\", \"/etc/app/stale.conf\"]})\n    removed = []\n\n    def fake_remove(path):\n        removed.append(path)\n\n    monkeypatch.setattr(os, \"remove\", fake_remove)\n\n    ok = update_files(\n        store=store,\n        modules={m},\n        files={\"/etc/app/common.conf\": common_file},\n        directories={\"/etc/app/config.d\": common_dir},\n        symlinks={},\n        dry_run=True,\n    )\n\n    assert ok is True\n\n    # Store unchanged\n    assert store[\"all_files\"] == [\"/etc/app/common.conf\", \"/etc/app/stale.conf\"]\n\n    # No removals\n    assert removed == []\n\n    # copy_to called with dry_run=True\n    assert common_file.calls == [(\"/etc/app/common.conf\", None, True)]\n\n\ndef test_update_files_propagates_fsinstallation_error_and_does_not_modify_store(monkeypatch):\n    # Use real store.Store to ensure interface compatibility if you prefer\n    store = DummyStore({\"all_files\": [\"/etc/app/keep.conf\"]})\n\n    # Fake failing _install_files\n    def failing_install_files(*args, **kwargs):\n        raise errors.FSInstallationFailedError(\"content\", \"/etc/app/broken.conf\", \"fail\")\n\n    # Capture deletes\n    removed = []\n\n    def fake_remove(path):\n        removed.append(path)\n\n    # Spy on output error/traceback so they exist but don't blow up\n    error_msgs = []\n\n    def fake_print_error(msg):\n        error_msgs.append(msg)\n\n    traces = []\n\n    def fake_print_traceback():\n        traces.append(True)\n\n    import decman.core.file_manager as fm_mod\n\n    monkeypatch.setattr(fm_mod, \"_install_files\", failing_install_files)\n    monkeypatch.setattr(os, \"remove\", fake_remove)\n    monkeypatch.setattr(output, \"print_error\", fake_print_error)\n    monkeypatch.setattr(output, \"print_traceback\", fake_print_traceback)\n\n    ok = update_files(\n        store=store,\n        modules=set(),\n        files={\"/etc/app/broken.conf\": DummyFile()},\n        directories={},\n        symlinks={},\n        dry_run=False,\n    )\n\n    assert ok is False\n\n    # Store unchanged\n    assert store[\"all_files\"] == [\"/etc/app/keep.conf\"]\n\n    # No deletions attempted\n    assert removed == []\n\n    # Error and traceback were logged\n    assert error_msgs\n    assert traces\n\n\n# symlinks\n\n\ndef test_install_symlinks_creates_missing_link_and_parents(tmp_path):\n    target = tmp_path / \"target\"\n    target.write_text(\"x\")\n\n    link = tmp_path / \"a\" / \"b\" / \"link\"\n\n    checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False)\n\n    assert checked == [str(link)]\n    assert changed == [str(link)]\n    assert link.is_symlink()\n    assert os.readlink(link) == str(target)\n\n\ndef test_install_symlinks_no_change_when_already_points_to_target(tmp_path):\n    target = tmp_path / \"target\"\n    target.write_text(\"x\")\n\n    link = tmp_path / \"link\"\n    os.symlink(str(target), str(link))\n\n    checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False)\n\n    assert checked == [str(link)]\n    assert changed == []\n    assert link.is_symlink()\n    assert os.readlink(link) == str(target)\n\n\ndef test_install_symlinks_replaces_wrong_target(tmp_path):\n    target1 = tmp_path / \"target1\"\n    target2 = tmp_path / \"target2\"\n    target1.write_text(\"1\")\n    target2.write_text(\"2\")\n\n    link = tmp_path / \"link\"\n    os.symlink(str(target1), str(link))\n\n    checked, changed = _install_symlinks({str(link): str(target2)}, dry_run=False)\n\n    assert checked == [str(link)]\n    assert changed == [str(link)]\n    assert link.is_symlink()\n    assert os.readlink(link) == str(target2)\n\n\ndef test_install_symlinks_replaces_existing_regular_file(tmp_path):\n    target = tmp_path / \"target\"\n    target.write_text(\"x\")\n\n    link = tmp_path / \"link\"\n    link.write_text(\"not a symlink\")\n\n    checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False)\n\n    assert checked == [str(link)]\n    assert changed == [str(link)]\n    assert link.is_symlink()\n    assert os.readlink(link) == str(target)\n\n\ndef test_install_symlinks_dry_run_does_not_touch_fs(tmp_path):\n    target = tmp_path / \"target\"\n    target.write_text(\"x\")\n\n    link = tmp_path / \"a\" / \"b\" / \"link\"\n\n    checked, changed = _install_symlinks({str(link): str(target)}, dry_run=True)\n\n    assert checked == [str(link)]\n    assert changed == [str(link)]  # would change\n    assert not link.exists()\n\n\ndef test_update_files_tracks_symlinks_and_removes_stale_symlinks(tmp_path):\n    # layout\n    root = tmp_path\n    t = root / \"target\"\n    t.write_text(\"x\")\n\n    live_link = root / \"links\" / \"live\"\n    stale_link = root / \"links\" / \"stale\"\n\n    # pre-existing stale link to be removed\n    os.makedirs(stale_link.parent, exist_ok=True)\n    os.symlink(str(t), str(stale_link))\n\n    m = DummyModule(\n        name=\"mod1\",\n        file_map={},\n        dir_map={},\n        symlink_map={str(live_link): str(t)},\n    )\n\n    store = DummyStore(\n        {\"all_files\": [str(stale_link)]}  # new store key\n    )\n\n    ok = update_files(\n        store=store,\n        modules={m},\n        files={},\n        directories={},\n        symlinks={},\n        dry_run=False,\n    )\n\n    assert ok is True\n\n    # new link exists\n    assert live_link.is_symlink()\n    assert os.readlink(live_link) == str(t)\n\n    # stale link removed\n    assert not stale_link.exists()\n\n    # store updated\n    assert store[\"all_files\"] == [str(live_link)]\n\n\ndef test_update_files_dry_run_does_not_create_or_remove_symlinks(tmp_path):\n    root = tmp_path\n    t = root / \"target\"\n    t.write_text(\"x\")\n\n    live_link = root / \"links\" / \"live\"\n    stale_link = root / \"links\" / \"stale\"\n\n    os.makedirs(stale_link.parent, exist_ok=True)\n    os.symlink(str(t), str(stale_link))\n\n    m = DummyModule(\n        name=\"mod1\",\n        file_map={},\n        dir_map={},\n        symlink_map={str(live_link): str(t)},\n    )\n\n    store = DummyStore({\"all_files\": [str(stale_link)]})\n\n    ok = update_files(\n        store=store,\n        modules={m},\n        files={},\n        directories={},\n        symlinks={},\n        dry_run=True,\n    )\n\n    assert ok is True\n\n    # no fs changes\n    assert not live_link.exists()\n    assert stale_link.is_symlink()\n\n    # store unchanged\n    assert store[\"all_files\"] == [str(stale_link)]\n"
  },
  {
    "path": "tests/test_decman_core_fs.py",
    "content": "import os\nimport stat\nfrom pathlib import Path\n\n# Adjust this import to match your actual module location\nimport decman.core.fs as fs\n\n# --- fs.File tests --------------------------------------------------------------\n\n\ndef test_file_from_content_creates_and_is_idempotent(tmp_path: Path) -> None:\n    target = tmp_path / \"file.txt\"\n\n    f = fs.File(content=\"hello\", permissions=0o600)\n\n    # First run: file must be created and reported as changed\n    changed1 = f.copy_to(str(target))\n    assert changed1 is True\n    assert target.read_text(encoding=\"utf-8\") == \"hello\"\n\n    mode = stat.S_IMODE(target.stat().st_mode)\n    assert mode == 0o600\n\n    # Second run with same configuration: no content change\n    changed2 = f.copy_to(str(target))\n    assert changed2 is False\n    assert target.read_text(encoding=\"utf-8\") == \"hello\"\n    assert stat.S_IMODE(target.stat().st_mode) == 0o600\n\n\ndef test_file_content_with_variables_and_change_detection(tmp_path: Path) -> None:\n    target = tmp_path / \"templated.txt\"\n\n    f = fs.File(content=\"hello {{NAME}}\")\n\n    # First run: NAME=world\n    changed1 = f.copy_to(str(target), {\"{{NAME}}\": \"world\"})\n    assert changed1 is True\n    assert target.read_text(encoding=\"utf-8\") == \"hello world\"\n\n    # Second run: same variables, no change\n    changed2 = f.copy_to(str(target), {\"{{NAME}}\": \"world\"})\n    assert changed2 is False\n    assert target.read_text(encoding=\"utf-8\") == \"hello world\"\n\n    # Third run: different variables, should change\n    changed3 = f.copy_to(str(target), {\"{{NAME}}\": \"there\"})\n    assert changed3 is True\n    assert target.read_text(encoding=\"utf-8\") == \"hello there\"\n\n\ndef test_file_from_source_text_with_and_without_variables(tmp_path: Path) -> None:\n    src = tmp_path / \"src.txt\"\n    src.write_text(\"VALUE={{X}}\", encoding=\"utf-8\")\n    target = tmp_path / \"dst.txt\"\n\n    # Without variables (raw copy)\n    f_raw = fs.File(source_file=str(src))\n    changed1 = f_raw.copy_to(str(target), {})\n    assert changed1 is True\n    assert target.read_text(encoding=\"utf-8\") == \"VALUE={{X}}\"\n\n    # Idempotent raw copy\n    changed2 = f_raw.copy_to(str(target), {})\n    assert changed2 is False\n\n    # With variables (substitution)\n    f_sub = fs.File(source_file=str(src))\n    changed3 = f_sub.copy_to(str(target), {\"{{X}}\": \"42\"})\n    assert changed3 is True\n    assert target.read_text(encoding=\"utf-8\") == \"VALUE=42\"\n\n    # Idempotent after substitution\n    changed4 = f_sub.copy_to(str(target), {\"{{X}}\": \"42\"})\n    assert changed4 is False\n\n\ndef test_file_binary_from_content(tmp_path: Path) -> None:\n    target = tmp_path / \"bin.dat\"\n    payload = b\"\\x00\\x01\\x02hello\"\n\n    f = fs.File(content=payload.decode(\"latin1\"), bin_file=True)\n\n    changed1 = f.copy_to(str(target))\n    assert changed1 is True\n    assert target.read_bytes() == payload\n\n    # Idempotent: second call does not rewrite\n    changed2 = f.copy_to(str(target))\n    assert changed2 is False\n    assert target.read_bytes() == payload\n\n\ndef test_file_binary_copy_from_source(tmp_path: Path) -> None:\n    src = tmp_path / \"src.bin\"\n    payload = b\"\\x10\\x20\\x30binary\"\n    src.write_bytes(payload)\n    target = tmp_path / \"dst.bin\"\n\n    f = fs.File(source_file=str(src), bin_file=True)\n\n    changed1 = f.copy_to(str(target), {\"IGNORED\": \"x\"})\n    assert changed1 is True\n    assert target.read_bytes() == payload\n\n    # Idempotent, comparing bytes\n    changed2 = f.copy_to(str(target), {\"IGNORED\": \"x\"})\n    assert changed2 is False\n    assert target.read_bytes() == payload\n\n\ndef test_file_creates_parent_directories_and_applies_permissions(tmp_path: Path) -> None:\n    nested_dir = tmp_path / \"a\" / \"b\" / \"c\"\n    target = nested_dir / \"file.txt\"\n\n    f = fs.File(content=\"data\", permissions=0o644)\n\n    changed = f.copy_to(str(target))\n    assert changed is True\n    assert target.read_text(encoding=\"utf-8\") == \"data\"\n\n    # Directories created\n    assert nested_dir.is_dir()\n\n    # Permissions on file\n    mode = stat.S_IMODE(target.stat().st_mode)\n    assert mode == 0o644\n\n\n# --- fs.Directory tests ---------------------------------------------------------\n\n\ndef _create_sample_source_tree(root: Path) -> None:\n    (root / \"sub\").mkdir(parents=True)\n    (root / \"a.txt\").write_text(\"A={{X}}\", encoding=\"utf-8\")\n    (root / \"sub\" / \"b.txt\").write_text(\"B={{X}}\", encoding=\"utf-8\")\n\n\ndef test_directory_copy_to_creates_and_is_idempotent(tmp_path: Path) -> None:\n    src_dir = tmp_path / \"src\"\n    dst_dir = tmp_path / \"dst\"\n    src_dir.mkdir()\n\n    _create_sample_source_tree(src_dir)\n\n    d = fs.Directory(\n        source_directory=str(src_dir),\n        bin_files=False,\n        encoding=\"utf-8\",\n        permissions=0o644,\n    )\n\n    # First run: both fs should be created and reported as changed\n    checked1, changed1 = d.copy_to(str(dst_dir), variables={\"{{X}}\": \"1\"})\n    expected_paths = {\n        str(dst_dir / \"a.txt\"),\n        str(dst_dir / \"sub\" / \"b.txt\"),\n    }\n    assert set(changed1) == expected_paths\n    assert set(checked1) == expected_paths\n\n    assert (dst_dir / \"a.txt\").read_text(encoding=\"utf-8\") == \"A=1\"\n    assert (dst_dir / \"sub\" / \"b.txt\").read_text(encoding=\"utf-8\") == \"B=1\"\n\n    # Second run with same variables: no fs should be reported as changed\n    checked2, changed2 = d.copy_to(str(dst_dir), variables={\"{{X}}\": \"1\"})\n    assert changed2 == []\n    assert set(checked2) == expected_paths\n\n\ndef test_directory_copy_to_detects_changes_via_variables(tmp_path: Path) -> None:\n    src_dir = tmp_path / \"src\"\n    dst_dir = tmp_path / \"dst\"\n    src_dir.mkdir()\n    _create_sample_source_tree(src_dir)\n\n    d = fs.Directory(source_directory=str(src_dir))\n\n    # Initial materialization\n    _checked, changed1 = d.copy_to(str(dst_dir), variables={\"{{X}}\": \"alpha\"})\n    assert set(changed1) == {\n        str(dst_dir / \"a.txt\"),\n        str(dst_dir / \"sub\" / \"b.txt\"),\n    }\n\n    # Change variables -> both fs change\n    _checked, changed2 = d.copy_to(str(dst_dir), variables={\"{{X}}\": \"beta\"})\n    assert set(changed2) == {\n        str(dst_dir / \"a.txt\"),\n        str(dst_dir / \"sub\" / \"b.txt\"),\n    }\n\n    assert (dst_dir / \"a.txt\").read_text(encoding=\"utf-8\") == \"A=beta\"\n    assert (dst_dir / \"sub\" / \"b.txt\").read_text(encoding=\"utf-8\") == \"B=beta\"\n\n\ndef test_directory_copy_to_dry_run(tmp_path: Path) -> None:\n    src_dir = tmp_path / \"src\"\n    dst_dir = tmp_path / \"dst\"\n    src_dir.mkdir()\n    _create_sample_source_tree(src_dir)\n\n    d = fs.Directory(source_directory=str(src_dir))\n\n    # First, actually materialize once\n    d.copy_to(str(dst_dir), variables={\"{{X}}\": \"1\"})\n\n    # Now perform dry-run with different variables; contents must not change\n    before_a = (dst_dir / \"a.txt\").read_text(encoding=\"utf-8\")\n    before_b = (dst_dir / \"sub\" / \"b.txt\").read_text(encoding=\"utf-8\")\n\n    _checked, changed_dry = d.copy_to(\n        str(dst_dir),\n        variables={\"{{X}}\": \"2\"},\n        dry_run=True,\n    )\n\n    expected_paths = {\n        str(dst_dir / \"a.txt\"),\n        str(dst_dir / \"sub\" / \"b.txt\"),\n    }\n    assert set(changed_dry) == expected_paths\n\n    # Contents remain as before (no writes in dry-run)\n    assert (dst_dir / \"a.txt\").read_text(encoding=\"utf-8\") == before_a\n    assert (dst_dir / \"sub\" / \"b.txt\").read_text(encoding=\"utf-8\") == before_b\n\n\ndef test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None:\n    src_dir = tmp_path / \"src\"\n    dst_dir = tmp_path / \"dst\"\n    src_dir.mkdir()\n    _create_sample_source_tree(src_dir)\n\n    d = fs.Directory(source_directory=str(src_dir))\n\n    original_cwd = os.getcwd()\n    try:\n        _checked, changed = d.copy_to(str(dst_dir), variables={\"{{X}}\": \"x\"})\n        assert set(changed) == {\n            str(dst_dir / \"a.txt\"),\n            str(dst_dir / \"sub\" / \"b.txt\"),\n        }\n    finally:\n        # Ensure the implementation restored CWD\n        assert os.getcwd() == original_cwd\n\n\ndef test_file_copy_to_dry_run(tmp_path):\n    target = tmp_path / \"file.txt\"\n    f = fs.File(content=\"hello\", permissions=0o600)\n\n    # 1) Dry-run on non-existent file: would create -> returns True, no file written\n    assert not target.exists()\n    changed = f.copy_to(str(target), dry_run=True)\n    assert changed is True\n    assert not target.exists()\n\n    # 2) Actually create the file\n    changed_real = f.copy_to(str(target), dry_run=False)\n    assert changed_real is True\n    assert target.exists()\n    assert target.read_text(encoding=\"utf-8\") == \"hello\"\n\n    # 3) Dry-run with same desired content: would NOT modify -> returns False, file unchanged\n    mtime_before = target.stat().st_mtime\n    changed_again = f.copy_to(str(target), dry_run=True)\n    assert changed_again is False\n    assert target.read_text(encoding=\"utf-8\") == \"hello\"\n    # mtime must not change in dry-run\n    assert target.stat().st_mtime == mtime_before\n"
  },
  {
    "path": "tests/test_decman_core_module.py",
    "content": "import stat\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\nimport decman.core.error as errors\nimport decman.core.module as module\n\n\ndef test_module_without_on_disable_is_accepted():\n    class NoOnDisable(module.Module):\n        def __init__(self):\n            super().__init__(\"no_on_disable\")\n\n    m = NoOnDisable()\n    assert m.name == \"no_on_disable\"\n\n\ndef test_on_disable_must_be_staticmethod():\n    with pytest.raises(errors.InvalidOnDisableError) as exc:\n\n        class NotStatic(module.Module):\n            def on_disable():  # type: ignore[no-redefined-builtin]\n                pass\n\n    msg = str(exc.value)\n    assert \"on_disable must be declared as @staticmethod\" in msg\n\n\ndef test_on_disable_must_take_no_parameters():\n    with pytest.raises(errors.InvalidOnDisableError) as exc:\n\n        class HasArgs(module.Module):\n            @staticmethod\n            def on_disable(x):  # type: ignore[unused-argument]\n                pass\n\n    msg = str(exc.value)\n    assert \"on_disable must take no parameters\" in msg\n\n\nSOME_CONST = 42  # noqa: F841\n\n\ndef test_on_disable_must_not_use_module_level_globals():\n    with pytest.raises(errors.InvalidOnDisableError) as exc:\n\n        class UsesGlobal(module.Module):\n            @staticmethod\n            def on_disable():\n                # will compile as LOAD_GLOBAL for SOME_CONST\n                print(SOME_CONST)\n\n    msg = str(exc.value)\n    assert \"on_disable uses nonlocal/global names\" in msg\n    assert \"SOME_CONST\" in msg\n\n\ndef test_on_disable_must_not_close_over_outer_variables():\n    # closure over outer local -> should be rejected via co_freevars on inner code\n    with pytest.raises(errors.InvalidOnDisableError) as exc:\n\n        class Closure(module.Module):\n            @staticmethod\n            def on_disable():\n                x = 1\n\n                def inner():\n                    # closes over x\n                    print(x)  # pragma: no cover\n\n                inner()\n\n    msg = str(exc.value)\n    assert \"must not close over outer variables\" in msg\n\n\ndef test_on_disable_nested_function_without_closure_is_allowed():\n    class NestedNoClosure(module.Module):\n        def __init__(self):\n            super().__init__(\"nested_no_closure\")\n\n        @staticmethod\n        def on_disable():\n            # nested function that only uses arguments / builtins\n            def inner(msg: str) -> None:\n                print(msg)\n\n            inner(\"OK\")\n\n    # If the class definition above passed without raising, validation succeeded.\n    m = NestedNoClosure()\n    assert m.name == \"nested_no_closure\"\n\n\ndef test_on_disable_can_use_builtins_and_imports_inside_function():\n    class Valid(module.Module):\n        def __init__(self):\n            super().__init__(\"valid\")\n\n        @staticmethod\n        def on_disable():\n            import math\n\n            print(\"sqrt2\", round(math.sqrt(2), 3))\n\n    v = Valid()\n    assert v.name == \"valid\"\n\n\ndef test_write_on_disable_script_returns_none_when_no_on_disable(tmp_path):\n    class NoOnDisable(module.Module):\n        def __init__(self):\n            super().__init__(\"no_on_disable\")\n\n    m = NoOnDisable()\n    script_path = module.write_on_disable_script(m, str(tmp_path))\n    assert script_path is None\n    assert not list(tmp_path.iterdir())\n\n\ndef test_write_on_disable_script_creates_executable_script(tmp_path):\n    class Simple(module.Module):\n        def __init__(self):\n            super().__init__(\"Simple\")\n\n        @staticmethod\n        def on_disable():\n            print(\"ON_DISABLE_RUN\")\n\n    m = Simple()\n    out_dir = tmp_path / \"scripts\"\n    out_dir.mkdir()\n\n    script_path_str = module.write_on_disable_script(m, str(out_dir))\n    assert script_path_str is not None\n\n    script_path = Path(script_path_str)\n    assert script_path.exists()\n\n    mode = script_path.stat().st_mode\n    assert mode & stat.S_IXUSR, \"script must be executable by owner\"\n\n    content = script_path.read_text(encoding=\"utf-8\")\n    assert \"generated from\" in content\n    assert \"def on_disable\" in content\n    assert 'if __name__ == \"__main__\":' in content\n\n    # Execute the generated script and check its output\n    proc = subprocess.run(\n        [sys.executable, str(script_path)],\n        check=True,\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n        text=True,\n    )\n\n    assert \"ON_DISABLE_RUN\" in proc.stdout\n\n\ndef test_write_on_disable_script_uses_module_and_class_in_header(tmp_path):\n    class HeaderCheck(module.Module):\n        def __init__(self):\n            super().__init__(\"HeaderCheck\")\n\n        @staticmethod\n        def on_disable():\n            print(\"HEADER_CHECK\")\n\n    m = HeaderCheck()\n    script_path_str = module.write_on_disable_script(m, str(tmp_path))\n    assert script_path_str is not None\n\n    script_path = Path(script_path_str)\n    content = script_path.read_text(encoding=\"utf-8\")\n\n    # header should reference original module and class\n    assert f\"{HeaderCheck.__module__}.{HeaderCheck.__name__}.on_disable\" in content\n"
  },
  {
    "path": "tests/test_decman_core_output.py",
    "content": "import builtins\nimport types\n\nimport pytest\n\nimport decman.config as config\nimport decman.core.output as output\n\n\n@pytest.fixture(autouse=True)\ndef reset_config():\n    # snapshot & restore config flags between tests\n    orig = types.SimpleNamespace(\n        debug_output=getattr(config, \"debug_output\", False),\n        quiet_output=getattr(config, \"quiet_output\", False),\n        color_output=getattr(config, \"color_output\", True),\n    )\n    yield\n    config.debug_output = orig.debug_output\n    config.quiet_output = orig.quiet_output\n    config.color_output = orig.color_output\n\n\ndef test_print_error_with_color_enabled(capsys):\n    config.color_output = True\n    output.print_error(\"boom\")\n\n    out = capsys.readouterr().out\n    assert \"boom\" in out\n    assert \"ERROR\" in out\n    # crude check that some ANSI escapes are present\n    assert \"\\x1b[\" in out\n\n\ndef test_print_error_with_color_disabled(capsys):\n    config.color_output = False\n    output.print_error(\"boom\")\n\n    out = capsys.readouterr().out\n    assert out.strip().endswith(\"ERROR: boom\")\n    # no ANSI escapes\n    assert \"\\x1b[\" not in out\n\n\ndef test_print_info_respects_quiet_and_debug(capsys):\n    config.quiet_output = True\n    config.debug_output = False\n\n    output.print_info(\"msg 1\")\n    out = capsys.readouterr().out\n    assert out == \"\"  # suppressed\n\n    config.debug_output = True\n    output.print_info(\"msg 2\")\n    out = capsys.readouterr().out\n    assert \"INFO: msg 2\" in out\n\n    config.quiet_output = False\n    config.debug_output = False\n    output.print_info(\"msg 3\")\n    out = capsys.readouterr().out\n    assert \"INFO: msg 3\" in out\n\n\ndef test_print_debug_only_with_debug_enabled(capsys):\n    config.debug_output = False\n    output.print_debug(\"dbg\")\n    assert capsys.readouterr().out == \"\"\n\n    config.debug_output = True\n    output.print_debug(\"dbg\")\n    out = capsys.readouterr().out\n    assert \"DEBUG\" in out\n    assert \"dbg\" in out\n\n\ndef test_print_continuation_respects_level_and_config(capsys):\n    config.quiet_output = True\n    config.debug_output = False\n\n    output.print_continuation(\"x\", level=output.INFO)\n    assert capsys.readouterr().out == \"\"\n\n    output.print_continuation(\"y\", level=output.SUMMARY)\n    out = capsys.readouterr().out\n    assert \"y\" in out\n\n\ndef test_print_list_empty_outputs_nothing(capsys):\n    output.print_list(\"Header\", [])\n    assert capsys.readouterr().out == \"\"\n\n\ndef test_print_list_summary_and_elements(capsys, monkeypatch):\n    # fixed terminal size for deterministic wrapping\n    monkeypatch.setattr(\n        output.shutil, \"get_terminal_size\", lambda: types.SimpleNamespace(columns=80)\n    )\n    config.quiet_output = False\n    config.debug_output = False\n\n    output.print_list(\"Installed packages:\", [\"a\", \"b\", \"c\"])\n\n    out = capsys.readouterr().out.splitlines()\n    # header summary\n    assert any(\"SUMMARY\" in line and \"Installed packages:\" in line for line in out)\n    # list content printed as continuation lines\n    assert any(\"a\" in line for line in out)\n    assert any(\"b\" in line for line in out)\n    assert any(\"c\" in line for line in out)\n\n\ndef test_print_list_respects_elements_per_line_and_width(capsys, monkeypatch):\n    # very small width to force wrapping\n    monkeypatch.setattr(\n        output.shutil, \"get_terminal_size\", lambda: types.SimpleNamespace(columns=30)\n    )\n\n    items = [f\"pkg{i}\" for i in range(5)]\n    output.print_list(\n        \"Pkgs:\",\n        items,\n        elements_per_line=2,\n        limit_to_term_size=True,\n        level=output.SUMMARY,\n    )\n\n    out_lines = capsys.readouterr().out.splitlines()\n    list_lines = [l for l in out_lines if \"pkg\" in l]\n    # at most 2 per line\n    for line in list_lines:\n        assert len([p for p in items if p in line]) <= 2\n\n\ndef test_prompt_number_valid_input(monkeypatch):\n    inputs = iter([\"3\"])\n    monkeypatch.setattr(builtins, \"input\", lambda _: next(inputs))\n\n    res = output.prompt_number(\"Pick\", 1, 5)\n    assert res == 3\n\n\ndef test_prompt_number_invalid_then_valid(monkeypatch, capsys):\n    inputs = iter([\"foo\", \"10\", \"2\"])\n    monkeypatch.setattr(builtins, \"input\", lambda _: next(inputs))\n\n    res = output.prompt_number(\"Pick\", 1, 5)\n    assert res == 2\n\n    out = capsys.readouterr().out\n    # at least one error printed\n    assert \"Invalid input\" in out\n\n\ndef test_prompt_number_default_on_empty(monkeypatch):\n    inputs = iter([\"\"])\n    monkeypatch.setattr(builtins, \"input\", lambda _: next(inputs))\n\n    res = output.prompt_number(\"Pick\", 1, 5, default=4)\n    assert res == 4\n\n\n@pytest.mark.parametrize(\n    \"user_input,default,expected\",\n    [\n        (\"y\", None, True),\n        (\"Y\", None, True),\n        (\"yes\", None, True),\n        (\"n\", None, False),\n        (\"No\", None, False),\n        (\"\", True, True),\n        (\"\", False, False),\n    ],\n)\ndef test_prompt_confirm(monkeypatch, user_input, default, expected):\n    inputs = iter([user_input])\n    monkeypatch.setattr(builtins, \"input\", lambda _: next(inputs))\n\n    res = output.prompt_confirm(\"Continue?\", default=default)\n    assert res is expected\n\n\ndef test_prompt_confirm_invalid_then_yes(monkeypatch, capsys):\n    inputs = iter([\"maybe\", \"y\"])\n    monkeypatch.setattr(builtins, \"input\", lambda _: next(inputs))\n\n    res = output.prompt_confirm(\"Continue?\")\n    assert res is True\n\n    out = capsys.readouterr().out\n    assert \"Invalid input.\" in out\n"
  },
  {
    "path": "tests/test_decman_core_store.py",
    "content": "import json\nfrom pathlib import Path\n\nimport pytest\n\nfrom decman.core.store import Store\n\n\ndef test_store_initially_empty_when_file_missing(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n    assert not path.exists()\n\n    store = Store(path)\n\n    assert store.get(\"missing\") is None\n    with pytest.raises(KeyError):\n        _ = store[\"missing\"]\n\n\ndef test_store_loads_existing_file(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n    original = {\"foo\": \"bar\", \"number\": 123}\n    path.write_text(json.dumps(original), encoding=\"utf-8\")\n\n    store = Store(path)\n\n    assert store[\"foo\"] == \"bar\"\n    assert store[\"number\"] == 123\n    # underlying representation is dict-like\n    assert json.loads(path.read_text(encoding=\"utf-8\")) == original\n\n\ndef test_setitem_and_getitem_roundtrip(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n\n    store = Store(path)\n    store[\"foo\"] = \"bar\"\n    store[\"number\"] = 123\n\n    assert store[\"foo\"] == \"bar\"\n    assert store[\"number\"] == 123\n\n\ndef test_get_with_default(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n\n    store = Store(path)\n    store[\"present\"] = \"value\"\n\n    assert store.get(\"present\") == \"value\"\n    assert store.get(\"missing\") is None\n    assert store.get(\"missing\", \"default\") == \"default\"\n\n\ndef test_save_creates_parent_directory_and_persists(tmp_path: Path) -> None:\n    # use nested directory to ensure parent creation is exercised\n    path = tmp_path / \"nested\" / \"store.json\"\n\n    store = Store(path)\n    store[\"foo\"] = \"bar\"\n\n    store.save()\n\n    assert path.is_file()\n    data = json.loads(path.read_text(encoding=\"utf-8\"))\n    assert data == {\"foo\": \"bar\"}\n\n\ndef test_context_manager_saves_on_normal_exit(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n\n    with Store(path) as store:\n        store[\"foo\"] = \"bar\"\n        store[\"number\"] = 123\n\n    assert path.is_file()\n    data = json.loads(path.read_text(encoding=\"utf-8\"))\n    assert data == {\"foo\": \"bar\", \"number\": 123}\n\n\ndef test_context_manager_saves_even_on_exception(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n\n    with pytest.raises(RuntimeError):\n        with Store(path) as store:\n            store[\"foo\"] = \"bar\"\n            raise RuntimeError(\"boom\")\n\n    # file should still be written despite the exception\n    assert path.is_file()\n    data = json.loads(path.read_text(encoding=\"utf-8\"))\n    assert data == {\"foo\": \"bar\"}\n\n\ndef test_repr_matches_underlying_dict(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n\n    store = Store(path)\n    store[\"foo\"] = \"bar\"\n    store[\"number\"] = 123\n\n    expected = repr({\"foo\": \"bar\", \"number\": 123})\n    assert repr(store) == expected\n\n\ndef test_store_persists_sets(tmp_path: Path) -> None:\n    path = tmp_path / \"store.json\"\n\n    # initial write with sets\n    store = Store(path)\n    store[\"units\"] = {\"a.service\", \"b.service\"}\n    store[\"user_units\"] = {\"alice\": {\"u1.service\", \"u2.service\"}}\n    store.save()\n\n    # raw JSON should be set-encoded, not fail json.dump\n    raw = json.loads(path.read_text(encoding=\"utf-8\"))\n    assert raw[\"units\"][\"__type__\"] == \"set\"\n    assert set(raw[\"units\"][\"items\"]) == {\"a.service\", \"b.service\"}\n\n    assert raw[\"user_units\"][\"alice\"][\"__type__\"] == \"set\"\n    assert set(raw[\"user_units\"][\"alice\"][\"items\"]) == {\"u1.service\", \"u2.service\"}\n\n    # reloading via Store must restore actual set objects\n    reloaded = Store(path)\n    assert reloaded[\"units\"] == {\"a.service\", \"b.service\"}\n    assert isinstance(reloaded[\"units\"], set)\n\n    assert reloaded[\"user_units\"][\"alice\"] == {\"u1.service\", \"u2.service\"}\n    assert isinstance(reloaded[\"user_units\"][\"alice\"], set)\n"
  },
  {
    "path": "tests/test_decman_init.py",
    "content": "import typing\n\nimport pytest\n\nimport decman\n\n\ndef test_sh_calls_prg_with_sh_command(monkeypatch: pytest.MonkeyPatch):\n    calls: dict[str, typing.Any] = {}\n\n    def fake_prg(\n        cmd,\n        user=None,\n        env_overrides=None,\n        mimic_login=False,\n        pty=True,\n        check=True,\n    ):\n        calls[\"prg\"] = (cmd, user, env_overrides, mimic_login, pty, check)\n        return \"output-from-prg\"\n\n    monkeypatch.setattr(decman, \"prg\", fake_prg)\n\n    out = decman.sh(\n        \"echo test\",\n        user=\"bob\",\n        env_overrides={\"X\": \"1\"},\n        mimic_login=True,\n        pty=False,\n        check=False,\n    )\n\n    assert out == \"output-from-prg\"\n\n    cmd, user, env_overrides, mimic_login, pty, check = calls[\"prg\"]\n    assert cmd == [\"/bin/sh\", \"-c\", \"echo test\"]\n    assert user == \"bob\"\n    assert env_overrides == {\"X\": \"1\"}\n    assert mimic_login is True\n    assert pty is False\n    assert check is False\n"
  },
  {
    "path": "tests/test_decman_plugins.py",
    "content": "from decman.core.module import Module\nfrom decman.plugins import run_methods_with_attribute\n\n\ndef mark(attr):\n    attr.__flag__ = True\n    return attr\n\n\ndef test_runs_marked_method_and_returns_value():\n    class M(Module):\n        @mark\n        def foo(self):\n            return 123\n\n    m = M(\"m\")\n    assert run_methods_with_attribute(m, \"__flag__\") == [123]\n\n\ndef test_runs_marked_methods_and_returns_value():\n    class M(Module):\n        @mark\n        def foo(self):\n            return 123\n\n        @mark\n        def bar(self):\n            return 321\n\n    m = M(\"m\")\n    assert run_methods_with_attribute(m, \"__flag__\") == [321, 123]\n\n\ndef test_returns_none_if_no_method_has_attribute():\n    class M(Module):\n        def foo(self):\n            return 1\n\n    m = M(\"m\")\n    assert run_methods_with_attribute(m, \"__flag__\") == []\n"
  }
]