Full Code of kiviktnm/decman for AI

main bb6d65428af3 cached
67 files
452.2 KB
108.0k tokens
607 symbols
1 requests
Download .txt
Showing preview only (476K chars total). Download the full file or copy to clipboard to get everything.
Repository: kiviktnm/decman
Branch: main
Commit: bb6d65428af3
Files: 67
Total size: 452.2 KB

Directory structure:
gitextract_6m5oxmcb/

├── .gitignore
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── completions/
│   ├── _decman
│   ├── decman.bash
│   └── decman.fish
├── docs/
│   ├── README.md
│   ├── aur.md
│   ├── extras.md
│   ├── flatpak.md
│   ├── migrate-to-v1.md
│   ├── pacman.md
│   └── systemd.md
├── example/
│   ├── README.md
│   ├── base.py
│   ├── files/
│   │   ├── mkinitcpio.conf
│   │   └── vimrc
│   ├── kde.py
│   ├── plugin/
│   │   ├── decman_plugin_example.py
│   │   └── pyproject.toml
│   └── source.py
├── plugins/
│   ├── decman-flatpak/
│   │   ├── pyproject.toml
│   │   └── src/
│   │       └── decman/
│   │           └── plugins/
│   │               └── flatpak.py
│   ├── decman-pacman/
│   │   ├── pyproject.toml
│   │   ├── src/
│   │   │   └── decman/
│   │   │       └── plugins/
│   │   │           ├── aur/
│   │   │           │   ├── __init__.py
│   │   │           │   ├── commands.py
│   │   │           │   ├── error.py
│   │   │           │   ├── fpm.py
│   │   │           │   ├── package.py
│   │   │           │   └── resolver.py
│   │   │           └── pacman.py
│   │   └── tests/
│   │       ├── test_decman_plugins_aur.py
│   │       ├── test_decman_plugins_aur_package.py
│   │       ├── test_decman_plugins_aur_resolver.py
│   │       ├── test_decman_plugins_pacman.py
│   │       ├── test_deep_orphan_removal.py
│   │       └── test_fpm.py
│   └── decman-systemd/
│       ├── pyproject.toml
│       ├── src/
│       │   └── decman/
│       │       └── plugins/
│       │           └── systemd.py
│       └── tests/
│           └── test_decman_plugins_systemd.py
├── pyproject.toml
├── src/
│   └── decman/
│       ├── __init__.py
│       ├── app.py
│       ├── config.py
│       ├── core/
│       │   ├── __init__.py
│       │   ├── command.py
│       │   ├── error.py
│       │   ├── file_manager.py
│       │   ├── fs.py
│       │   ├── module.py
│       │   ├── output.py
│       │   └── store.py
│       ├── extras/
│       │   ├── __init__.py
│       │   ├── gpg.py
│       │   └── users.py
│       ├── plugins/
│       │   └── __init__.py
│       └── py.typed
└── tests/
    ├── test_decman_app.py
    ├── test_decman_core_command.py
    ├── test_decman_core_file_manager.py
    ├── test_decman_core_fs.py
    ├── test_decman_core_module.py
    ├── test_decman_core_output.py
    ├── test_decman_core_store.py
    ├── test_decman_init.py
    └── test_decman_plugins.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
__pycache__/

build/
*.egg-info/

venv/
.venv/
dist/


================================================
FILE: DEVELOPMENT.md
================================================
# Commands used in development

Before committing ensure all tests pass and format files.

## Running

Run decman as root to test all changes:

```sh
sudo uv run --all-packages decman
```

## Python shell

Running a python shell with all the packages.

```sh
sudo uv run --all-packages python
sudo uv run --exact --package decman python
```

## Testing

Run all unit tests (`-s` disables output capturing, needed for PTY test):

```sh
uv run --package decman pytest -s tests/
uv run --package decman-pacman pytest plugins/decman-pacman/tests/
uv run --package decman-systemd pytest plugins/decman-systemd/tests/
uv run --package decman-flatpak pytest plugins/decman-flatpak/tests/
```

## Formatting

Format all files:

```sh
uv run ruff format
```

## Linting

Run lints:

```sh
uv run ruff check
```

Apply fixes:

```sh
uv run ruff check --fix
```

## Installing the example plugin

```sh
uv pip install -e example/plugin/
```

Uninstalling:

```sh
uv pip uninstall decman-plugin-example
```

Making the plugin available/unavailable:

```sh
touch /tmp/example_plugin_available
rm /tmp/example_plugin_available
```


================================================
FILE: LICENSE
================================================
                    GNU GENERAL PUBLIC LICENSE
                       Version 3, 29 June 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.

                            Preamble

  The GNU General Public License is a free, copyleft license for
software and other kinds of works.

  The licenses for most software and other practical works are designed
to take away your freedom to share and change the works.  By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.  We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors.  You can apply it to
your programs, too.

  When we speak of free software, we are referring to freedom, not
price.  Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.

  To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights.  Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.

  For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received.  You must make sure that they, too, receive
or can get the source code.  And you must show them these terms so they
know their rights.

  Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.

  For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software.  For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.

  Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so.  This is fundamentally incompatible with the aim of
protecting users' freedom to change the software.  The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable.  Therefore, we
have designed this version of the GPL to prohibit the practice for those
products.  If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.

  Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary.  To prevent this, the GPL assures that
patents cannot be used to render the program non-free.

  The precise terms and conditions for copying, distribution and
modification follow.

                       TERMS AND CONDITIONS

  0. Definitions.

  "This License" refers to version 3 of the GNU General Public License.

  "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.

  "The Program" refers to any copyrightable work licensed under this
License.  Each licensee is addressed as "you".  "Licensees" and
"recipients" may be individuals or organizations.

  To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy.  The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.

  A "covered work" means either the unmodified Program or a work based
on the Program.

  To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy.  Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.

  To "convey" a work means any kind of propagation that enables other
parties to make or receive copies.  Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.

  An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License.  If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.

  1. Source Code.

  The "source code" for a work means the preferred form of the work
for making modifications to it.  "Object code" means any non-source
form of a work.

  A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.

  The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form.  A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.

  The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities.  However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work.  For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.

  The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.

  The Corresponding Source for a work in source code form is that
same work.

  2. Basic Permissions.

  All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met.  This License explicitly affirms your unlimited
permission to run the unmodified Program.  The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work.  This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.

  You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force.  You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright.  Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.

  Conveying under any other circumstances is permitted solely under
the conditions stated below.  Sublicensing is not allowed; section 10
makes it unnecessary.

  3. Protecting Users' Legal Rights From Anti-Circumvention Law.

  No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.

  When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.

  4. Conveying Verbatim Copies.

  You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.

  You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.

  5. Conveying Modified Source Versions.

  You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:

    a) The work must carry prominent notices stating that you modified
    it, and giving a relevant date.

    b) The work must carry prominent notices stating that it is
    released under this License and any conditions added under section
    7.  This requirement modifies the requirement in section 4 to
    "keep intact all notices".

    c) You must license the entire work, as a whole, under this
    License to anyone who comes into possession of a copy.  This
    License will therefore apply, along with any applicable section 7
    additional terms, to the whole of the work, and all its parts,
    regardless of how they are packaged.  This License gives no
    permission to license the work in any other way, but it does not
    invalidate such permission if you have separately received it.

    d) If the work has interactive user interfaces, each must display
    Appropriate Legal Notices; however, if the Program has interactive
    interfaces that do not display Appropriate Legal Notices, your
    work need not make them do so.

  A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit.  Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.

  6. Conveying Non-Source Forms.

  You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:

    a) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by the
    Corresponding Source fixed on a durable physical medium
    customarily used for software interchange.

    b) Convey the object code in, or embodied in, a physical product
    (including a physical distribution medium), accompanied by a
    written offer, valid for at least three years and valid for as
    long as you offer spare parts or customer support for that product
    model, to give anyone who possesses the object code either (1) a
    copy of the Corresponding Source for all the software in the
    product that is covered by this License, on a durable physical
    medium customarily used for software interchange, for a price no
    more than your reasonable cost of physically performing this
    conveying of source, or (2) access to copy the
    Corresponding Source from a network server at no charge.

    c) Convey individual copies of the object code with a copy of the
    written offer to provide the Corresponding Source.  This
    alternative is allowed only occasionally and noncommercially, and
    only if you received the object code with such an offer, in accord
    with subsection 6b.

    d) Convey the object code by offering access from a designated
    place (gratis or for a charge), and offer equivalent access to the
    Corresponding Source in the same way through the same place at no
    further charge.  You need not require recipients to copy the
    Corresponding Source along with the object code.  If the place to
    copy the object code is a network server, the Corresponding Source
    may be on a different server (operated by you or a third party)
    that supports equivalent copying facilities, provided you maintain
    clear directions next to the object code saying where to find the
    Corresponding Source.  Regardless of what server hosts the
    Corresponding Source, you remain obligated to ensure that it is
    available for as long as needed to satisfy these requirements.

    e) Convey the object code using peer-to-peer transmission, provided
    you inform other peers where the object code and Corresponding
    Source of the work are being offered to the general public at no
    charge under subsection 6d.

  A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.

  A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling.  In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage.  For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product.  A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.

  "Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source.  The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.

  If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information.  But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).

  The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed.  Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.

  Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.

  7. Additional Terms.

  "Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law.  If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.

  When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it.  (Additional permissions may be written to require their own
removal in certain cases when you modify the work.)  You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.

  Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:

    a) Disclaiming warranty or limiting liability differently from the
    terms of sections 15 and 16 of this License; or

    b) Requiring preservation of specified reasonable legal notices or
    author attributions in that material or in the Appropriate Legal
    Notices displayed by works containing it; or

    c) Prohibiting misrepresentation of the origin of that material, or
    requiring that modified versions of such material be marked in
    reasonable ways as different from the original version; or

    d) Limiting the use for publicity purposes of names of licensors or
    authors of the material; or

    e) Declining to grant rights under trademark law for use of some
    trade names, trademarks, or service marks; or

    f) Requiring indemnification of licensors and authors of that
    material by anyone who conveys the material (or modified versions of
    it) with contractual assumptions of liability to the recipient, for
    any liability that these contractual assumptions directly impose on
    those licensors and authors.

  All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10.  If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term.  If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.

  If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.

  Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.

  8. Termination.

  You may not propagate or modify a covered work except as expressly
provided under this License.  Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).

  However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.

  Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.

  Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License.  If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.

  9. Acceptance Not Required for Having Copies.

  You are not required to accept this License in order to receive or
run a copy of the Program.  Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance.  However,
nothing other than this License grants you permission to propagate or
modify any covered work.  These actions infringe copyright if you do
not accept this License.  Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.

  10. Automatic Licensing of Downstream Recipients.

  Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License.  You are not responsible
for enforcing compliance by third parties with this License.

  An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations.  If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.

  You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License.  For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.

  11. Patents.

  A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based.  The
work thus licensed is called the contributor's "contributor version".

  A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version.  For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.

  Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.

  In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement).  To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.

  If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients.  "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.

  If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.

  A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License.  You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.

  Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.

  12. No Surrender of Others' Freedom.

  If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License.  If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all.  For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.

  13. Use with the GNU Affero General Public License.

  Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work.  The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.

  14. Revised Versions of this License.

  The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time.  Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.

  Each version is given a distinguishing version number.  If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation.  If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.

  If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.

  Later license versions may give you additional or different
permissions.  However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.

  15. Disclaimer of Warranty.

  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

  16. Limitation of Liability.

  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.

  17. Interpretation of Sections 15 and 16.

  If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.

                     END OF TERMS AND CONDITIONS

            How to Apply These Terms to Your New Programs

  If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.

  To do so, attach the following notices to the program.  It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.

    <one line to give the program's name and a brief idea of what it does.>
    Copyright (C) <year>  <name of author>

    This program is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>.

Also add information on how to contact you by electronic and paper mail.

  If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:

    <program>  Copyright (C) <year>  <name of author>
    This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
    This is free software, and you are welcome to redistribute it
    under certain conditions; type `show c' for details.

The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License.  Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".

  You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.

  The GNU General Public License does not permit incorporating your program
into proprietary programs.  If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library.  If this is what you want to do, use the GNU Lesser General
Public License instead of this License.  But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.


================================================
FILE: README.md
================================================
# Decman

> Decman has breaking changes!
> Decman has undergone an architecture rewrite. The new architecture makes decman more expandable and maintainable.
>
> Migration guide is [here](/docs/migrate-to-v1.md).

Decman 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.

## Overview

[See the example for a quick tutorial.](/example/README.md)

To 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.

`/home/user/config/source.py`:

```py
import decman

from decman import File, Directory

# Declare installed pacman packages
decman.pacman.packages |= {"base", "linux", "linux-firmware", "networkmanager", "ufw", "neovim"}

# Declare installed aur packages
decman.aur.packages |= {"decman"}

# Declare configuration files
# Inline
decman.files["/etc/vconsole.conf"] = File(content="KEYMAP=us")

# From files within your source repository
# (full path here would be /home/user/config/dotfiles/pacman.conf)
decman.files["/etc/pacman.conf"] = File(source_file="./dotfiles/pacman.conf")

# Declare a whole directory
decman.directories["/home/user/.config/nvim"] = Directory(source_directory="./dotfiles/nvim",
                                                          owner="user")
# Ensure that a systemd unit is enabled.
decman.systemd.enabled_units |= {"NetworkManager.service"}
```

To better organize your system configuration, you can create modules.

`/home/user/config/syncthing.py`:

```py
from decman import Module, Store, prg
from decman.plugins import pacman, systemd

# Your custom modules are child classes of the module class.
# They can override methods of the Module-class.
class Syncthing(Module):

    def __init__(self):
        super().__init__(name="syncthing")

    # Run code when a module is first enabled
    def on_enable(self, store: Store):
        # Note: store is a key-value store that will persist between decman runs.
        # You can use it to store your own data as well. Here it is not needed.

        # Call a program
        prg(["ufw", "allow", "syncthing"])

        # Run any python code
        print("Remember to setup syncthing with the browser UI!")

    # On disable is a special method, it will get executed when this module no longer exists.
    # Therefore it must be static, take no parameters, and inline all imports.
    # Imported modules should be available everywhere.
    @staticmethod
    def on_disable():
        # Run code when a module is disabled
        import decman
        decman.prg(["ufw", "deny", "syncthing"])

    # Decorate a function with @pacman.packages to indicate it returns a set of pacman packages
    # to be installed
    @pacman.packages
    def pacman_packages(self) -> set[str]:
        return {"syncthing"}

    # Systemd units are declared in a similiar fashion
    @systemd.user_units
    def systemd_user_units(self) -> dict[str, set[str]]:
        # Systemd user units part of this module
        return {"user": {"syncthing.service"}}
```

Then import your module in your main source file.

`/home/user/config/source.py`:

```py
import decman
from syncthing import Syncthing

decman.modules += [Syncthing()]
```

Then run decman.

> [!WARNING]
> Decman runs as root. This means that your `source.py` will be executed as root as well.

```sh
sudo decman --source /home/user/config/source.py
```

When you first run decman, you must define the source file, but subsequent runs remember the previous value.

```sh
sudo decman
```

Decman has some CLI options, to see them all run:

```sh
decman --help
```

For troubleshooting and submitting issues, you should use the `--debug` option.

```sh
sudo decman --debug
```

[See the complete documentation for using decman.](/docs/README.md)

## Installation

Clone the decman PKGBUILD:

```sh
git clone https://aur.archlinux.org/decman.git
```

Review the PKGBUILD and install it.

```sh
cd decman
makepkg -si
```

Remember to add decman to its own configuration.

```py
import decman
decman.aur.packages |= {"decman"}
```

## What decman manages?

Decman has built-in functionality for managing files and directories. Additionally decman manages system state using plugins. By default decman ships with the following plugins:

- [pacman](/docs/pacman.md)
- [systemd](/docs/systemd.md)
- [aur](/docs/aur.md)
- [flatpak](/docs/flatpak.md)

Additionally management of [users, groups and PGP keys](/docs/extras.md) is provided by built-in modules.

Plugins can be disabled if desired and flatpaks are disabled by default.

Please 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.

### Pacman

Pacman plugins manages native packages. Native packages can be installed from the pacman repositories. This plugin will never touch AUR packages.

```py
import decman

# Packages that decman ensures are installed to the system
decman.pacman.packages |= {"firefox", "reflector"}

# These packages will never get installed or removed by decman.
decman.pacman.ignored_packages |= {"opendoas"}
```

### AUR

> [!NOTE]
> Building of AUR or custom packages is not the primary function of decman. There are some issues that I may or may not fix.
> If you can't build a package using decman, consider adding it to `decman.aur.ignored_packages` and building it yourself.

AUR plugins manages foreign packages. Foreign packages are installed from the AUR or other sources. This plugin will never touch native packages.

```py
import decman
from decman.plugins.aur import CustomPackage

# AUR Packages that decman ensures are installed to the system
decman.aur.packages |= {"android-studio", "fnm-bin"}

# These foreign packages will never get installed or removed by decman.
decman.aur.ignored_packages |= {"yay"}

# You can add packages from custom sources.
# Just add a package name and repository / directory containing a PKGBUILD
decman.aur.custom_packages |= {
    CustomPackage("decman", git_url="https://github.com/kiviktnm/decman-pkgbuild.git"),
    CustomPackage("my-own-package", pkgbuild_directory="/path/to/directory/"),
}
```

### Systemd units

> [!NOTE]
> Decman will only enable and disable systemd services. It will not start or stop them.

Decman 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.

```py
import decman

# System-wide units
decman.systemd.enabled_units |= {"NetworkManager.service"}

# User specific units
decman.systemd.enabled_user_units.setdefault("user", set()).update({"syncthing.service"})
```

### Flatpak

```py
import decman

# Flatpaks that decman ensures are installed to the system
decman.flatpak.packages |= {"org.mozilla.firefox", "org.signal.Signal"}

# Flatpaks can be installed to specific users only
decman.flatpak.user_packages.setdefault("user", {}).update({"com.valvesoftware.Steam"})

# These flatpaks will never get installed or removed by decman.
decman.flatpak.ignored_packages |= {"dev.zed.Zed"}
```

### Users and PGP keys

Decman 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).

Here these modules are used to create a `builduser` for AUR packages.

```python
import decman
import os
from decman.extras.gpg import GPGReceiver
from decman.extras.users import User, UserManager

um = UserManager()
gpg = GPGReceiver()

# Add a normal user
um.add_user(User(
    username="alice",
    groups=("libvirt"),
    shell="/usr/bin/fish",
))

# Create builduser
um.add_user(User(
    username="builduser",
    home="/var/lib/builduser",
    system=True,
))

# Receive desired PGP keys to that account (Spotify as an example)
gpg.fetch_key(
    user="builduser",
    gpg_home="/var/lib/builduser/gnupg",
    fingerprint="E1096BCBFF6D418796DE78515384CE82BA52C83A",
    uri="https://download.spotify.com/debian/pubkey_5384CE82BA52C83A.gpg",
)

# Configure aur to use builduser and the GNUPGHOME.
os.environ["GNUPGHOME"] = "/var/lib/builduser/gnupg"
decman.aur.makepkg_user = "builduser"

# Add version control systems required by the packages
decman.pacman.packages |= {"fossil"}

# Add AUR packages that require PGP keys or builduser setup
decman.aur.packages |= {"spotify", "pikchr-fossil"}

# Order matters here, users should be added before gpg keys
decman.modules += [um, gpg]
```

## Managing plugins and the order of operations

The order of operations is managed by setting `decman.execution_order`. This is also the default.

```py
import decman
decman.execution_order = [
    "files",
    "pacman",
    "aur",
    "systemd",
]
```

This variable also manages which plugins are enabled. To enable flatpaks, simply add the plugin to the execution order.

```py
import decman
decman.execution_order = [
    "files",
    "pacman",
    "aur",
    "flatpak",
    "systemd",
]
```

Note that `files` is not a plugin, but is defined here anyways.

Before the core execution order, decman will run hook methods from `Module`s.

1. `before_update`
2. `on_disable`

After the plugin execution, decman will run the following hook methods.

1. `on_enable`
2. `on_change`
3. `atfer_update`

Operations and hooks may be skipped with command line options.

```sh
# Skip the aur plugin
sudo decman --skip aur

# Only apply file operations
sudo decman --no-hooks --only files
```

## Why use decman?

Here are some reasons why I created decman for myself.

### Configuration as documentation

You 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.

### Modular config

In 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.

```py
from decman import Module

# Look below for an example of a theme module
import theme

class Shell(Module):
    def __init__(self):
        super().__init__("shell")
        self._aliases_text = ""

    def add_alias(self, alias: str, cmd: str):
        self._aliases_text += f"alias {alias}='{cmd}'\n"

    def files(self) -> dict[str, File]:
        return {
            "/home/user/.config/fish/config.fish":
            File(source_file="./files/shell/config.fish", owner="user")
        }

    def file_variables(self) -> dict[str, str]:
        fvars = {
            "%aliases%": self._aliases_text,
        }
        # Remember this line when looking at the next point
        fvars.update(theme.COLORS)
        return fvars
```

### Consistency between applications

Decman'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.

`theme.py`:

```py
COLORS = {
    "%PRIMARY_COLOR%": "#b121ff",
    "%SECONDARY_COLOR%": "#ff5577",
    "%BACKGROUND_COLOR%": "#6a30d5",
    # etc
}
```

### Reproducibility

You can easily reinstall your system using your decman config.

### Dynamic configuration

Using python you can use the same config for different computers and only change some things between them.

```py
import socket

import decman

if socket.gethostname() == "laptop":
    # add brightness controls to your laptop
    decman.pacman.packages |= {"brightnessctl"}
```

## Alternatives

There are some alternatives you may want to consider instead of using decman.

- [Ansible](https://docs.ansible.com/)
- [aconfmgr](https://github.com/CyberShadow/aconfmgr)
- [NixOS](https://nixos.org/)

### Why not use NixOS?

NixOS is a Linux disto built around the idea of declarative system management, so why create a more limited alternative?

I tried NixOS in the past, but it had some issues that caused me to create decman for Arch Linux instead. In my opinion:

- NixOS forces you to do everything the Nix way.
- NixOS requires learning a new domain specific language.
- NixOS is extreme when it comes to declaration. Sometimes you don't want _everything_ to be managed declaratively.

## License

Copyright (C) 2024-2025 Kivi Kaitaniemi

Decman is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as
published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

Decman is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not,
see <https://www.gnu.org/licenses/>.

See [license](LICENSE).


================================================
FILE: completions/_decman
================================================
#compdef decman
# zsh completion for decman

_arguments -s \
  '--source=[python file containing configuration]:config file:_files' \
  '--dry-run[print what would happen as a result of running decman]' \
  '--print[print what would happen as a result of running decman]' \
  '--debug[show debug output]' \
  '--skip[skip the following execution steps]:step(s):_message -r "step"' \
  '--only[run only the following execution steps]:step(s):_message -r "step"' \
  '--no-hooks[don'\''t run hook methods for modules]' \
  '--no-color[don'\''t print messages with color]' \
  '--params[additional parameters passed to plugins]:param(s):_message -r "param"' \
  '--help[show help]'


================================================
FILE: completions/decman.bash
================================================
# bash completion for decman

_decman() {
    local cur prev opts
    COMPREPLY=()
    cur="${COMP_WORDS[COMP_CWORD]}"
    prev="${COMP_WORDS[COMP_CWORD-1]}"

    opts="--source --dry-run --print --debug --skip --only --no-hooks --no-color --params --help"

    case "$prev" in
        --source)
            # file completion
            COMPREPLY=( $(compgen -f -- "$cur") )
            return 0
            ;;
        --skip|--only|--params)
            # free-form list
            return 0
            ;;
    esac

    if [[ "$cur" == --* ]]; then
        COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
        return 0
    fi

    return 0
}
complete -F _decman decman


================================================
FILE: completions/decman.fish
================================================
# fish completion for decman

complete -c decman -l source   -r -d "python file containing configuration" -a "(__fish_complete_path)"
complete -c decman -l dry-run     -d "print what would happen as a result of running decman"
complete -c decman -l print       -d "print what would happen as a result of running decman"
complete -c decman -l debug       -d "show debug output"
complete -c decman -l skip     -r -d "skip the following execution steps"
complete -c decman -l only     -r -d "run only the following execution steps"
complete -c decman -l no-hooks    -d "don't run hook methods for modules"
complete -c decman -l no-color    -d "don't print messages with color"
complete -c decman -l params   -r -d "additional parameters passed to plugins"


================================================
FILE: docs/README.md
================================================
# Decman documentation

This 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).

- [pacman](/docs/pacman.md)
- [systemd](/docs/systemd.md)
- [aur](/docs/aur.md)
- [flatpak](/docs/flatpak.md)

Check out [extras](/docs/extras.md) for documentation for built-in modules.

## Quick notes

"Decman source" or "source" refers to your system configuration. It is set using the `--source` command line argument with decman.

```sh
sudo decman --source /this/is/your/source.py
```

Decman and decman plugins use sets for most collections to avoid duplicates. Remember to add values to sets instead of reassigning it.

```py
import decman

# GOOD
decman.pacman.packages |= {"vim"}

# BAD, now there is only "vim" in the packages, all previous operations were overridden.
decman.pacman.packages = {"vim"}
```

In Python you should not use from imports with global variables. It can lead to issues.

```py
# DO THIS:
import decman
decman.pacman.packages |= {"vim"}

# THIS MAY NOT WORK
from decman import pacman
pacman.packages |= {"vim"}
```

You can still import classes and functions with from imports safely.

```py
from decman import File
# pacman here refers to the pacman module containing the plugin
# not the plugin instance
from decman.plugins import pacman
```

## Decman Store

Decman 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.

Using the store:

```py
# The store is always given as a parameter to a method call.
# You don't need to create new instances.
store["key"] = value

# To ensure that a key exists (with a default value if it doesn't)
store.ensure("my_dict", {})
store["my_dict"]["dict_key"] = 3
```

This store is also available to plugins and modules. The following keys are used by decman:

- `allow_running_source_without_prompt`
- `source_file`
- `enabled_modules`
- `module_on_disable_scripts`
- `all_files`

Details about the keys used by each plugin are provided in the plugin’s documentation.

## Configuring decman

Decman has a small number of configuration options. They are set in your source file with python. These values are prioritized over command line options.

Import the config to modify it.

```py
import decman.config
```

Enable debug messages

```py
decman.config.debug_output = False
```

Disable info messages

```py
decman.config.quiet_output = False
```

Set colored output. This setting should not be used. It should be passed as a command line argument or an environment variable instead.

- Command line argument: `--no-color`
- Environment variables:
  - `NO_COLOR`: disables color
  - `FORCE_COLOR`: enables color

```py
  decman.config.color_output = True
```

Directory for scripts containing Modules' on_disable code

```py
decman.config.module_on_disable_scripts_dir = "/var/lib/decman/scripts/"
```

Cache directory. Plugins like the AUR plugin use this directory as their own cache.

```py
decman.config.cache_dir = "/var/cache/decman"
```

The architecture of the computer's CPU. Currently, this is only used by the AUR plugin, but it may be useful for some other plugins.

```py
decman.config.arch = "x86_64"
```

## Files, directories and symlink

Decman 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.

Symlinks management is simpler and more limited than for files since symlinks cannot have file permissions or ownership.

Variables can only be defined for files within modules. See the module example for using file variables.

Files and directories are updated during the `files` execution order step.

### File

Declarative file specification describing how a file should be materialized at a target path.

```py
from decman import File
import decman

# To declare a file, add it's target path and create a File object
decman.files["/home/me/.config/nvim/init.lua"] = File(
    source_file="./dotfiles/nvim/init.lua",
    bin_file=False,
    encoding="utf-8",
    owner="me",
    group="users",
    permissions=0o700,
)
```

Exactly one of `source_file` or `content` must be provided.

The 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.

Ownership, permissions, and parent directories are enforced on creation. Missing parent directories are created recursively and assigned the same ownership as the file when specified.

#### Parameters:

- `source_file: str`: Path to an existing file to copy from. Mutually exclusive with `content`.
- `content: str`: In-memory file contents to write. Mutually exclusive with `source_file`.
- `bin_file: bool`: If `True`, treat the file as binary. Disables variable substitution and writes bytes verbatim.
- `encoding: str`: Text encoding used when reading or writing non-binary files.
- `owner: str`: System user name to own the file and created parent directories.
- `group: str`: System group name to own the file and created parent directories. By default the `owner`'s group is used.
- `permissions: int`: File mode applied to the target file (e.g. `0o644`).

Note: 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.

### Directory

Declarative specification for copying the contents of a source directory into a target directory.

```py
from decman import Directory
import decman

# To declare a directory, add it's target path and create a Directory object
decman.directories["/home/me/.config/nvim"] = Directory(
    source_directory="./dotfiles/nvim",
    bin_files=False,
    encoding="utf-8",
    owner="me",
    group="users",
    permissions=0o600,
)
```

For text files in the directory, optional variable substitution is applied at copy time. Binary files are copied or written verbatim and never undergo substitution.

Ownership, 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.

#### Parameters:

- `source_directory: str`: Path to the directory whose contents will be mirrored into the target.
- `bin_files: bool`: If `True`, treat all files as binary. Disables variable substitution and copies bytes verbatim.
- `encoding: str`: Text encoding used when reading or writing non-binary files.
- `owner: str`: System user name to own the files and directories.
- `group: str`: System group name to own the files and directories. By default the `owner`'s group is used.
- `permissions: int`: File mode applied to the created or updated files (e.g. `0o644`).

### Symlink

Declare a link to a target. Missing directories are created. If you need to configure parent folder permissions, use `Symlink` objects.

```py
import decman

# Replaces sudo with doas
# /usr/bin/sudo -> /usr/bin/doas
decman.symlinks["/usr/bin/sudo"] = "/usr/bin/doas"

# I don't know why would you ever do this but as an example
decman.symlinks["/home/me/.bin/mydoas"] = decman.Symlink("/usr/bin/doas", owner="me", group="users")
```

## Modules

Modules allow grouping related functionality together.

A **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.

Each module is uniquely identified by its `name`.

Remember 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.

```py
import decman
decman.modules += [MyModule()]
```

### Basic Structure

```python
from decman import Module

class MyModule(Module):
    def __init__(self) -> None:
        super().__init__("my-module")
```

### Lifecycle Hooks

Modules can hook into specific phases of a decman run by overriding methods.

#### Before update

Executed **before** any updates are applied.

```python
def before_update(self, store):
    ...
```

#### After update

Executed **after** all updates are applied.

```python
def after_update(self, store):
    ...
```

#### On enable

Executed **once**, when the module transitions from disabled to enabled.

```python
def on_enable(self, store):
    ...
```

#### On change

Executed when the module’s **content changes** between runs. Module's content is deemed changed if:

- If files or directories defined within the module have their content updated
- A plugin marks the module as changed
  - For example, the pacman plugin marks a module as changed if the packages defined within that module change

```python
def on_change(self, store):
    ...
```

#### On disable

Executed when the module is disabled. A module is disabled when it's removed from the modules set.

**Must be declared as `@staticmethod`.**
Validated at class creation time.

```python
@staticmethod
def on_disable():
    import os
    os.remove("/some/file")
```

**Important constraints:**

- Code is copied verbatim into a temporary file
- No external variables
- Imports must be inside the function
- Signature must be exactly `on_disable()`

### Filesystem Declarations

Modules can declaratively define files and directories to be installed.

#### Files

Returns a mapping of target paths to `File` objects.

```python
def files(self) -> dict[str, File]:
    return {
        "/etc/myapp/config.conf": File(source_file="./dotfiles/config.conf"),
    }
```

#### Directories

Returns a mapping of target paths to `Directory` objects.

```python
def directories(self) -> dict[str, Directory]:
    return {
        "/var/lib/myapp": Directory(source_directory="./dotfiles/myapp"),
    }
```

#### File Variable Substitution

Defines variables that are substituted inside **text files** belonging to the module.

```python
def file_variables(self) -> dict[str, str]:
    return {
        "HOSTNAME": "example.com",
        "PORT": "8080",
    }
```

#### Symlinks

Defines symlinks fro the module.

```py
def symlinks(self) -> dict[str, str | Symlink]:
    return {
        "/etc/resolv.conf": "/run/systemd/resolve/resolv.conf",
        "/home/me/.config/app/file.conf": Symlink("/home/me/.file.conf", owner="me"),
    }
```

### Extending with plugins

To 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.

```py
from decman.plugins import pacman

@pacman.packages
def pacman_packages(self) -> set[str]:
    return {"wget", "zip"}
```

## Plugins

Plugins 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.

To manage the execution order of plugins set `decman.execution_order`.

```py
import decman
decman.execution_order = [
    "files", # not a plugin but included here
    "pacman",
    "aur",
    "flatpak",
    "systemd",
]
```

Available plugins are found in `decman.plugins`. You can add your own plugins to that dictionary.

```py
import decman
my_plugin = MyPlugin()
decman.plugins["my-plugin"] = my_plugin

# Remember to include your plugin in the execution order
decman.execution_order += ["my-plugin"]
```

For conveniance, decman provides some plugins with quick access.

```py
import decman

assert decman.pacman == decman.plugins.get("pacman")
assert decman.aur == decman.plugins.get("aur")
assert decman.systemd == decman.plugins.get("systemd")
assert decman.flatpak == decman.plugins.get("flatpak")
```

### Creating custom plugins

Create your own modules by subclassing `Plugin`. Then override the methods documented below.

#### Basic Structure

```python
from decman.plugins import Plugin

class MyPlugin(Plugin):
    # Plugins should be singletons. (Only one instance exists ever.)
    # This name should be the same as the key used in decman.plugins dict.
    NAME = "my-plugin"
```

#### Availability check

Checks 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.

This 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`.

Please 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.

```py
def available(self) -> bool:
    return True
```

#### Process modules

This 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.

This method only gathers information. It doesn't apply it.

```py
from decman import Store, Module

def process_modules(self, store: Store, modules: list[Module]):
    ...

    # Toy example for setting modules as changed
    for module in modules:
        module._changed = True
```

#### Apply

Ensures that the state managed by this plugin is present on the system.

`dry_run` indicates that changes should only be printed, not yet applied.

`params` is a list of strings passed as command line arguments. For example running `decman --params abc def` would cause `params = ["abc", "def"]`.

This method must not raise exceptions. Instead it should return `False` to indicate a
failure. The method should handle it's exceptions and print them to the user.

```py
from decman import Store

def apply(
    self, store: Store, dry_run: bool = False, params: list[str] | None = None
) -> bool:
    return True
```

### Installing plugins as Python packages

You 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.

In `pyproject.toml` set:

```toml
[project.entry-points."decman.plugins"]
pacman = "decman.plugins.pacman:Pacman"
aur = "decman.plugins.aur:AUR"
```

## Useful utilities

Decman ships with some useful utilites that can help with modules and plugins.

### Run commands

Runs a command and returns its output.

```py
import decman
decman.prg(
    ["nvim", "--headless", "+Lazy! sync", "+qa"],
    user = "user",
    env_overrides = {"EXAMPLE": "value"},
    pass_environment = True,
    mimic_login = True,
    pty = True,
    check = True,
)
```

#### Parameters

- `cmd: list[str]`: Command to execute.
- `user: str`: User name to run the command as. If set, the command is executed after dropping privileges to this user.
- `pass_environment: bool`: Copy decman's execution environment variables and pass them to the subprocess.
- `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.
- `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.
  - `HOME`
  - `USER`
  - `LOGNAME`
  - `SHELL`
- `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.
- `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.

### Run a command in a shell

Runs 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.

```py
import decman
decman.sh(
    "echo $EXAMPLE | less",
    user = "user",
    env_overrides = {"EXAMPLE": "value"},
    mimic_login = True,
    pty = True,
    check = True,
)
```

#### Parameters

- `sh_cmd: str`: Shell command to execute.
- `user: str`: User name to run the command as. If set, the command is executed after dropping privileges to this user.
- `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.
- `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.
  - `HOME`
  - `USER`
  - `LOGNAME`
  - `SHELL`
- `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.
- `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.

### Errors

When 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.

```py
import decman
raise decman.SourceError("boom")
```

### Decman Core

Additionally, 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.


================================================
FILE: docs/aur.md
================================================
# AUR

> [!NOTE]
> 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.
> If you can't build a package with this plugin, consider adding it to `ignored_packages` and building it yourself.

AUR 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.

It manages packages exactly the same way as the pacman plugin.

> 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.

Building 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.

Build packages are by default stored in a cache `/var/cache/decman/aur`. This plugin keeps 3 most recent versions of all packages.

When installing packages from other version control systems than git, you'll need to install the package for that VCS.

## Usage

Define AUR packages. These will be installed from the AUR.

```py
import decman
decman.aur.packages |= {"android-studio", "fnm-bin"}
```

Define ignored foreing packages. These can be AUR packages or other foreign packages. These packages will never get installed or removed by the plugin.

```py
decman.aur.ignored_packages |= {"yay"}
```

Define 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.

```py
from decman.plugins.aur import CustomPackage
decman.aur.custom_packages |= {
    CustomPackage("decman", git_url="https://github.com/kiviktnm/decman-pkgbuild.git"),
    CustomPackage("my-own-package", pkgbuild_directory="/path/to/directory/"),
}
```

This plugin's execution order step name is `aur`.

### Command line

This plugin accepts params via the command line.

```sh
sudo decman --params aur-upgrade-devel aur-force
```

`aur-upgrade-devel` causes devel packages (packages from version control, such as `*-git` packages) to be upgraded.

`aur-force` causes decman to rebuild packages that were already cached.

### Within modules

Modules 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]`.

```py
import decman
from decman.plugins import aur

class MyModule(decman.Module):
    ...

    @aur.packages
    def aur_packages_defined_in_this_module(self) -> set[str]:
        return {"android-studio", "fnm-bin"}

    @aur.custom_packages
    def custom_packages_defined_in_this_module(self) -> set[aur.CustomPackage]:
        return {
            CustomPackage("decman", git_url="https://github.com/kiviktnm/decman-pkgbuild.git"),
        }
```

If these sets change, this plugin will flag the module as changed. The module's `on_change` method will be executed.

## Recommended setup

I 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.

```python
import decman
import os
from decman.extras.gpg import GPGReceiver
from decman.extras.users import User, UserManager

um = UserManager()
gpg = GPGReceiver()

# Create builduser
um.add_user(User(
    username="builduser",
    home="/var/lib/builduser",
    system=True,
))

# Receive desired PGP keys to that account (Spotify as an example)
gpg.fetch_key(
    user="builduser",
    gpg_home="/var/lib/builduser/gnupg",
    fingerprint="E1096BCBFF6D418796DE78515384CE82BA52C83A",
    uri="https://download.spotify.com/debian/pubkey_5384CE82BA52C83A.gpg",
)

# Configure aur to use builduser and the GNUPGHOME.
os.environ["GNUPGHOME"] = "/var/lib/builduser/gnupg"
decman.aur.makepkg_user = "builduser"

# Add version control systems required by the packages
decman.pacman.packages |= {"fossil"}

# Add AUR packages that require PGP keys or builduser setup
decman.aur.packages |= {"spotify", "pikchr-fossil"}

decman.modules += [um, gpg]
```

## Keys used in the decman store

- `aur_packages_for_module`
- `custom_packages_for_module`

## Configuration

This module has partially the same configuration with pacman. You'll have to define pacman output keywords and database options again.

```py
import decman
# set keywords
decman.aur.keywords = {"pacsave", "pacnew", "warning"}
# disable the feature
decman.aur.print_highlights = False

# signature level for querying existing databases
decman.aur.database_signature_level = 2048 # pyalpm.SIG_DATABASE_OPTIONAL

# path to databases
decman.aur.database_path = "/var/lib/pacman/"
```

There are some options related to building packages.

```py
# Timeout for fetching information from AUR
decman.aur.aur_rpc_timeout = 30
# User which builds AUR packages
decman.aur.makepkg_user = "nobody"
# Directory used for building packages
decman.aur.build_dir = "/tmp/decman/build"
```

Some 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.

```py
import os
os.environ["GNUPGHOME"] = "/home/kk/.gnupg/"
decman.aur.makepkg_user = "kk"
```

Additionally 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.

```py
from decman.plugins import aur
import decman

class MyAurAndPacmanCommands(aur.AurCommands):
    def install_as_dependencies(self, pkgs: set[str]) -> list[str]:
        """
        Running this command installs the given packages from pacman repositories.
        The packages are installed as dependencies.
        """
        return ["pacman", "-S", "--needed", "--asdeps"] + list(pkgs)

    def install_files_as_dependencies(self, pkg_files: list[str]) -> list[str]:
        """
        Running this command installs the given packages files as dependencies.
        """
        return ["pacman", "-U", "--asdeps"] + pkg_files

    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
        """
        Running this command outputs -1 when the installed version is older than the new version.
        """
        return ["vercmp", installed_version, new_version]

    def git_clone(self, repo: str, dest: str) -> list[str]:
        """
        Running this command clones a git repository to the the given destination.
        """
        return ["git", "clone", repo, dest]

    def git_diff(self, from_commit: str) -> list[str]:
        """
        Running this command outputs the difference between the given commit and
        the current state of the repository.
        """
        return ["git", "diff", from_commit]

    def git_get_commit_id(self) -> list[str]:
        """
        Running this command outputs the current commit id.
        """
        return ["git", "rev-parse", "HEAD"]

    def git_log_commit_ids(self) -> list[str]:
        """
        Running this command outputs commit hashes of the repository.
        """
        return ["git", "log", "--format=format:%H"]

    def review_file(self, file: str) -> list[str]:
        """
        Running this command outputs a file for the user to see.
        """
        return ["less", file]

    def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:
        """
        Running this command creates a new arch chroot to the chroot directory and installs the
        given packages there.
        """
        return ["mkarchroot", chroot_dir] + list(with_pkgs)

    def install_chroot(self, chroot_dir: str, packages: list[str]):
        """
        Running this command installs the given packages to the given chroot.
        """
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-S",
            "--needed",
            "--noconfirm",
        ] + packages

    def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[str]:
        """
        This command prints a real name of a package.
        For example, it prints the package which provides a virtual package.
        """
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-Sddp",
            "--print-format=%n",
            pkg,
        ]

    def remove_chroot(self, chroot_dir: str, packages: set[str]):
        """
        Running this command removes the given packages from the given chroot.
        """
        return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + list(packages)

    def make_chroot_pkg(
        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
    ) -> list[str]:
        """
        Running this command creates a package file using the given chroot.
        The package is created as the user and the pkg_files_to_install are installed
        in the chroot before the package is created.
        """
        makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]

        for pkgfile in pkgfiles_to_install:
            makechrootpkg_cmd += ["-I", pkgfile]

        return makechrootpkg_cmd

    def print_srcinfo(self) -> list[str]:
        """
        Running this command prints SRCINFO generated from the package in the current
        working directory.
        """
        return ["makepkg", "--printsrcinfo"]

    # -------------------------------------------
    # Here I override some PacmanCommand methods.
    # -------------------------------------------

    def set_as_explicit(self, pkgs: set[str]) -> list[str]:
        """
        Running this command sets the given as explicitly installed.
        """
        return ["pacman", "-D", "--asexplicit"] + list(pkgs)

    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:
        """
        Running this command sets the given packages as dependencies.
        """
        return ["pacman", "-D", "--asdeps"] + list(pkgs)
```

Applying the commands is easy.

```py
import decman
decman.pacman.commands = MyAurAndPacmanCommands()
decman.aur.commands = MyAurAndPacmanCommands()
```


================================================
FILE: docs/extras.md
================================================
# Extras

Decman 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.

## User and group management module

```python
import decman.extras.users
```

A decman module for managing system users, groups, and supplementary group membership and subordinate UID/GID ranges for existing users.

The 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.

### Provided types

#### `Group`

Represents a managed group.

```python
@dataclass(frozen=True)
class Group:
    groupname: str
    gid: Optional[int] = None
    system: bool = False
```

Fields:

- `groupname`: Group name.
- `gid`: Desired numeric GID. If omitted, system assigns one.
- `system`: Only affects _creation_ (`groupadd --system`). Changing this after creation does nothing.

#### `User`

Represents a managed user.

```python
@dataclass(frozen=True)
class User:
    username: str
    uid: Optional[int] = None
    group: Optional[str] = None
    home: Optional[str] = None
    shell: Optional[str] = None
    groups: tuple[str, ...] = ()
    system: bool = False
```

Fields:

- `username`: Login name.
- `uid`: Desired numeric UID. If omitted, system assigns one.
- `group`: Primary group name.
- `home`: Home directory.
- `shell`: Login shell.
- `groups`: Supplementary groups set.
- `system`: Only affects _creation_ (`useradd --system`). Changing this after creation does nothing.

### `UserManager` module

```python
class UserManager(Module):
```

#### Lifecycle

- Before update
  - Create/modify managed groups.
  - Create/modify managed users.
  - Delete previously-managed users/groups that are no longer listed.
- After update
  - Apply **additional** supplementary group membership and **subuid/subgid** ranges (including removals).

#### Store keys

The module persists state in decman store under these keys:

- `usermanager_users`
- `usermanager_groups`
- `usermanager_user_additional_groups`
- `usermanager_user_subuids`
- `usermanager_user_subgids`

The module does **not** parse `/etc/subuid` or `/etc/subgid`; it relies on these store keys to compute additions/removals.

#### Methods

##### `add_user(user: User)`

Ensure a user exists with the configured attributes.

Notes:

- 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.

##### `add_group(group: Group)`

Ensure a group exists with the configured attributes.

##### `add_user_to_group(user: str, group: str)`

Ensure `user` is a member of `group`.

- This is applied in `after_update`.
- Both `user` and `group` are expected to exist

You should not use this method for users added with `add_user`.

##### `add_subuids(user: str, first: int, last: int)`

Ensure subordinate UID range `first-last` is present for `user`.

##### `add_subgids(user: str, first: int, last: int)`

Ensure subordinate GID range `first-last` is present for `user`.

### Example usage

```python
from decman.extras.users import UserManager, User, Group

um = UserManager()

um.add_group(Group("containers", system=True))
um.add_user(User(
    username="alice",
    uid=1001,
    group="users",
    home="/home/alice",
    groups=(),
    shell="/bin/zsh",
))

um.add_user_to_group("bob", "containers")

um.add_subuids("alice", 100000, 165535)
um.add_subgids("alice", 100000, 165535)

import decman
decman.modules += [um]
```

## GPG receiver module

```python
import decman.extras.gpg
```

Manages 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.

This 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.

### Types

#### `OwnerTrust`

Valid ownertrust levels:

- `never`
- `marginal`
- `full`
- `ultimate`

These map to GnuPG `--import-ownertrust` numeric levels `1..4`.

#### `SourceKind`

How a key is imported:

- `fingerprint`: fetch from keyserver via `--recv-keys`
- `uri`: fetch from URI via `--fetch-key`
- `file`: import from local file via `--import`

#### `Key`

Represents one managed key entry.

Fields:

- `fingerprint`: OpenPGP fingerprint, validated to be exactly 40 hex chars (spaces allowed in input; normalized by removing spaces and uppercasing).
- `source_kind`: one of `fingerprint | uri | file`.
- `source`: keyserver (for `fingerprint`), URI (for `uri`), or filepath (for `file`).
- `trust`: optional `OwnerTrust` to set via ownertrust import.

Validation behavior:

- Fingerprint is normalized: `replace(" ", "").upper()`.
- Fingerprint must match `^[0-9A-F]{40}$`; otherwise `ValueError`.

### `GPGReceiver` module

```python
class GPGReceiver(module.Module):
```

#### Store keys

The module persists state in decman store under these keys:

- `gpgreceiver_userhome_keys`

It relies on the store to keep track which keys were added by it.

#### Public API

##### `receive_key(user: str, gpg_home: str, fingerprint: str, keyserver: str, trust: OwnerTrust | None = None)`

Receives a key with a `fingerprint` from a `keyserver` to a `gpg_home` owned by `user`.

If `trust` is provided, ownertrust is set after import.

##### `fetch_key(user: str, gpg_home: str, fingerprint: str, uri: str, trust: OwnerTrust | None=None)`

Receives a key with a `fingerprint` from a `uri` to a `gpg_home` owned by `user`.

If `trust` is provided, ownertrust is set after import.

##### `import_key(user: str, gpg_home: str, fingerprint: str, file: str, trust: OwnerTrust | None =None)`

Receives a key with a `fingerprint` from a local `file` to a `gpg_home` owned by `user`.

If `trust` is provided, ownertrust is set after import.

### Example usage

```python
from decman.modules.gpg import GPGReceiver
import decman

gpg = GPGReceiver()

# Receive a key from a keyserver
gpg.receive_key(
    user="builduser",
    gpg_home="/var/lib/builduser/gnupg",
    fingerprint="AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA AAAA",
    keyserver="hkps://keyserver.ubuntu.com",
    trust="marginal",
)

# Fetch a key from a URI
gpg.fetch_key(
    user="alice",
    gpg_home="/home/alice/.gnupg",
    fingerprint="BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB BBBB",
    uri="https://example.org/signing-key.asc",
)

# Import a key from a local file
gpg.import_key(
    user="bob",
    gpg_home="/home/bob/.gnupg",
    fingerprint="CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC CCCC",
    file="/etc/decman/keys/custom.asc",
)

decman.modules += [gpg]
```


================================================
FILE: docs/flatpak.md
================================================
# Flatpak

The 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.

This 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.

## Usage

Define systemd-wide flatpaks.

```py
import decman
decman.flatpak.packages |= {"org.mozilla.firefox", "org.signal.Signal"}
```

Define user-specific flatpaks.

```py
decman.flatpak.user_packages.setdefault("user", {}).update({"com.valvesoftware.Steam"})
```

Define ignored flatpaks. This plugin won't install them nor remove them. This list affects user and system flatpaks.

```py
decman.flatpak.ignored_packages |= {"dev.zed.Zed"}
```

### Within modules

Modules 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.

```py
import decman
from decman.plugins import flatpak

class MyModule(decman.Module):
    ...

    @flatpak.packages
    def units_defined_in_this_module(self) -> set[str]:
        return {"org.signal.Signal", "org.mozilla.firefox"}

    @flatpak.user_packages
    def user_units_defined_in_this_module(self) -> dict[str, set[str]]:
        return {"user": {"com.valvesoftware.Steam"}}
```

If packages or user packages change, this plugin will flag the module as changed. The module's `on_change` method will be executed.

## Keys used in the decman store

- `flatpaks_for_module`
- `user_flatpaks_for_module`

## Configuration

It's possible to override the commands this plugin uses. Create your own `FlatpakCommands` class and override methods returning commands. These are the defaults.

```py
from decman.plugins import flatpak

class MyCommands(flatpak.FlatpakCommands):
    def list_apps(self, as_user: bool) -> list[str]:
        """
        Running this command outputs a newline separated list of installed flatpak application IDs.

        If ``as_user`` is ``True``, run the command as the user whose packages should be listed.

        NOTE: The first line says 'Application ID' and should be ignored.
        """
        return [
            "flatpak",
            "list",
            "--app",
            "--user" if as_user else "--system",
            "--columns",
            "application",
        ]

    def install(self, pkgs: set[str], as_user: bool) -> list[str]:
        """
        Running this command installs all listed packages, and their dependencies/runtimes
        automatically.

        If ``as_user`` is ``True``, run the command as the user for whom packages are installed.
        """
        return [
            "flatpak",
            "install",
            "--user" if as_user else "--system",
        ] + sorted(pkgs)

    def upgrade(self, as_user: bool) -> list[str]:
        """
        Updates all installed flatpaks including runtimes and dependencies.

        If ``as_user`` is ``True``, run the command as the user whose flatpaks are updated.
        """
        return [
            "flatpak",
            "update",
            "--user" if as_user else "--system",
        ]

    def remove(self, pkgs: set[str], as_user: bool) -> list[str]:
        """
        Running this command will remove the listed packages.

        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.
        """
        return [
            "flatpak",
            "remove",
            "--user" if as_user else "--system",
        ] + sorted(pkgs)

    def remove_unused(self, as_user: bool) -> list[str]:
        """
        This will remove all unused flatpak dependencies and runtimes.

        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.
        """
        return [
            "flatpak",
            "remove",
            "--unused",
            "--user" if as_user else "--system",
        ]
```

Then set the commands.

```py
import decman
decman.flatpak.commands = MyCommands()
```


================================================
FILE: docs/migrate-to-v1.md
================================================
# Migrating to the new architecture

I 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.

## Few notes about changed behavior

This change is mostly architectural and doesn't change decman's behavior, but there are a few exceptions.

- The pacman plugin will now remove orphan packages.
  - **Packages that are only optionally required by other packages are considered orphans.**
- Explicitly installed packages that are required by other explicitly installed packages are no longer uninstalled when removed from the source.
- Module's `on_disable` will now be executed when the module is no longer present in `decman.modules`.
  - It will be executed even when the module is removed completely from the source
- Order of operations has changed. While the order of operations is now configurable, returning to the previous way is not possible.
- 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.

Since there are changes in the internal logic, it is possible that there are more breaking changes, but I haven't thought about them yet.

## After upgrading decman

After 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.

```sh
sudo rm /var/lib/decman/store.json
sudo rm -r /var/cache/decman/
```

## Changes

One 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.

### Files and directories

Files and directories are still managed the same way.

```py
import decman
decman.files["/etc/pacman.conf"] = File(source_file="./dotfiles/pacman.conf")
decman.directories["/home/user/.config/nvim"] = Directory(source_directory="./dotfiles/nvim",
```

### Pacman packages

#### Old

```py
decman.packages += ["devtools", "git", "networkmanager"]
decman.ignored_packages += ["rustup", "yay"]
```

#### New

Packages 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.

```py
decman.pacman.packages |= {"devtools", "git", "networkmanager"}
decman.pacman.ignored_packages |= {"rustup"}
```

### AUR packages

#### Old

```py
decman.aur_packages += ["decman", "android-studio"]
decman.ignored_packages += ["rustup", "yay"]
```

#### New

Packages are now defined with the plugin. Sets are used instead of lists. Ignored packages contains only foreign packages for example AUR packages.

```py
decman.aur.packages |= {"decman", "android-studio"}
decman.aur.ignored_packages |= {"yay"}
```

### User Packages

#### Old

```py
decman.user_packages.append(
    UserPackage(
        pkgname="decman",
        version="0.4.2",
        provides=["decman"],
        dependencies=[
            "python",
            "python-requests",
            "devtools",
            "pacman",
            "systemd",
            "git",
            "less",
        ],
        make_dependencies=[
            "python-setuptools",
            "python-build",
            "python-installer",
            "python-wheel",
        ],
        git_url="https://github.com/kiviktnm/decman-pkgbuild.git",
    )
)
```

#### New

User 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.

```py
from decman.plugins import aur
decman.aur.custom_packages |= {aur.CustomPackage("decman", git_url="https://github.com/kiviktnm/decman-pkgbuild.git")}
```

### Systemd services

#### Old

```py
decman.enabled_systemd_units += ["NetworkManager.service"]
decman.enabled_systemd_user_units.setdefault("kk", []).append("syncthing.service")
```

#### New

Units are now defined with the plugin. Sets are used instead of lists.

```py
decman.systemd.enabled_units |= {"NetworkManager.service"}
decman.systemd.enabled_user_units.setdefault("user", set()).add("syncthing.service")
```

### Flatpaks

#### Old

```py
decman.flatpak_packages += ["org.mozilla.firefox"]
decman.ignored_flatpak_packages += ["org.signal.Signal"]
decman.flatpak_user_packages.setdefault("kk", []).append("com.valvesoftware.Steam")
```

#### New

Packages are now defined with the plugin. Sets are used instead of lists.

```py
decman.flatpak.packages |= {"org.mozilla.firefox"}
decman.flatpak.ignored_packages |= {"org.signal.Signal"}
decman.flatpak.user_packages.setdefault("kk", {}).update({"com.valvesoftware.Steam"})
```

### Changes to modules

#### Old

```py
import decman
from decman import Module, prg, sh

decman.modules += [MyModule()]

class MyModule(Module):
    def __init__(self):
        self.pkgs = ["rust"]
        self.update_rustup = False
        super().__init__(name="Example module", enabled=True, version="1")

    def enable_my_custom_feature(self, b: bool):
        if b:
            self.pkgs = ["rustup"]
            self.update_rustup = True

    def on_enable(self):
        sh("groupadd mygroup")
        prg(["usermod", "--append", "--groups", "mygroup", "kk"])

    def on_disable(self):
        sh("whoami", user="kk")
        sh("echo $HI", env_overrides={"HI": "Hello!"})

    def after_update(self):
        if self.update_rustup:
            prg(["rustup", "update"], user="kk")

    def after_version_change(self):
        prg(["mkinitcpio", "-P"])

    def file_variables(self) -> dict[str, str]:
        return {"%msg%": "Hello, world!"}

    def files(self) -> dict[str, File]:
        return {
            "/usr/local/bin/say-hello": File(
                content="#!/usr/bin/env bash\necho %msg%", permissions=0o755
            ),
            "/usr/local/share/say-hello/image.png": File(
                source_file="files/i-dont-exist.png", bin_file=True
            ),
        }

    def directories(self) -> dict[str, Directory]:
        return {
            "/home/kk/.config/mod-app/": Directory(
                source_directory="files/app-config", owner="kk"
            )
        }

    def pacman_packages(self) -> list[str]:
        return self.pkgs

    def user_packages(self) -> list[UserPackage]:
        return [UserPackage(...)]

    def aur_packages(self) -> list[str]:
        return ["protonvpn"]

    def flatpak_packages(self) -> list[str]:
        return ["org.mozilla.firefox"]

    def flatpak_user_packages(self) -> dict[str, list[str]]:
        return {"username": ["io.github.kolunmi.Bazaar"]}

    def systemd_units(self) -> list[str]:
        return ["reflector.timer"]

    def systemd_user_units(self) -> dict[str, list[str]]:
        return {"kk": ["syncthing.service"]}
```

#### New

Modules 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.

Module methods will get a `Store` instance passed to them as an argument. It can be used to store key-value pairs between decman runs.

Files 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.

```py
import decman
from decman import Module, Store, prg, sh
from decman.plugins import pacman, aur, systemd, flatpak

decman.modules += [MyModule()]

class MyModule(Module):
    def __init__(self):
        self.pkgs = {"rust"}
        self.update_rustup = False
        super().__init__("Example module")

    def enable_my_custom_feature(self, b: bool):
        if b:
            self.pkgs = {"rustup"}
            self.update_rustup = True

    def on_enable(self, store: Store):
        sh("groupadd mygroup")
        prg(["usermod", "--append", "--groups", "mygroup", "kk"])
        store["value"] = True

    @staticmethod
    def on_disable():
        from decman import sh
        sh("whoami", user="kk")
        sh("echo $HI", env_overrides={"HI": "Hello!"})

    def after_update(self, store: Store):
        if self.update_rustup:
            prg(["rustup", "update"], user="kk")

    def on_change(self, store: Store):
        prg(["mkinitcpio", "-P"])

    def file_variables(self) -> dict[str, str]:
        return {"%msg%": "Hello, world!"}

    def files(self) -> dict[str, File]:
        return {
            "/usr/local/bin/say-hello": File(
                content="#!/usr/bin/env bash\necho %msg%", permissions=0o755
            ),
            "/usr/local/share/say-hello/image.png": File(
                source_file="files/i-dont-exist.png", bin_file=True
            ),
        }

    def directories(self) -> dict[str, Directory]:
        return {
            "/home/kk/.config/mod-app/": Directory(
                source_directory="files/app-config", owner="kk"
            )
        }

    @pacman.packages
    def my_pacman_packages(self) -> set[str]:
        return self.pkgs

    @aur.custom_packages
    def my_user_packages(self) -> set[aur.CustomPackage]:
        return [aur.CustomPackage(...)]

    @aur.packages
    def my_aur_packages(self) -> set[str]:
        return {"protonvpn-cli"}

    @flatpak.packages
    def my_flatpak_packages(self) -> set[str]:
        return {"org.mozilla.firefox"}

    @flatpak.user_packages
    def my_flatpak_user_packages(self) -> dict[str, set[str]]:
        return {"username": {"io.github.kolunmi.Bazaar"}}

    @systemd.units
    def my_systemd_units(self) -> set[str]:
        return {"reflector.timer"}

    @systemd.user_units
    def my_systemd_user_units(self) -> dict[str, set[str]]:
        return {"kk": {"syncthing.service"}}
```

## Configuration changes

With 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.

### Global options

#### Old

```py
import decman.config

decman.config.debug_output = False
decman.config.suppress_command_output = True
decman.config.quiet_output = False
```

#### New

`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.

Other options stayed the same. These will now override values passed as CLI arguments.

```py
decman.config.debug_output = False
decman.config.quiet_output = False
```

### Seperately enabled features

#### Old

```py
decman.config.enable_fpm = True
decman.config.enable_flatpak = False
```

#### New

These options are managed by setting `decman.execution_order`. Add or remove steps as needed.

```py
import decman
decman.execution_order = [
    "files",
    "pacman",
    "aur", # AUR/fpm enabled
    "systemd",
    # "flatpak", # Flatpak disabled
]
```

### Pacman options

#### Old

```py
decman.config.pacman_output_keywords = [
    "pacsave",
    "pacnew",
]
decman.config.print_pacman_output_highlights = True
```

#### New

These are now moved under the pacman plugin and renamed. Keywords is no longer a `list`. It is now a `set`.

```py
import decman
decman.pacman.keywords = {"pacsave", "pacnew"}
decman.pacman.print_highlights = True
```

You'll have to set them for the aur plugin seperately. I recommend sharing the values between the plugins.

```py
import decman
decman.aur.keywords = {"pacsave", "pacnew"}
decman.aur.print_highlights = False
```

### Foreign package management related options

#### Old

```py
decman.config.aur_rpc_timeout = 30
decman.config.makepkg_user = "kk"
decman.config.build_dir = "/tmp/decman/build"
decman.config.pkg_cache_dir = "/var/cache/decman"
decman.config.number_of_packages_stored_in_cache = 3
decman.config.valid_pkgexts = [
    ".pkg.tar",
    ".pkg.tar.gz",
    ".pkg.tar.bz2",
    ".pkg.tar.xz",
    ".pkg.tar.zst",
    ".pkg.tar.lzo",
    ".pkg.tar.lrz",
    ".pkg.tar.lz4",
    ".pkg.tar.lz",
    ".pkg.tar.Z",
]
```

#### New

Options `number_of_packages_stored_in_cache` and `valid_pkgexts` got removed. The default values are no longer configurable. I deemed these settings unnecessary.

`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.

```py
decman.config.cache_dir = "/var/cache/decman"
```

Other options are now moved under the aur plugin.

```py
decman.aur.aur_rpc_timeout = 30
decman.aur.makepkg_user = "nobody"
decman.aur.build_dir = "/tmp/decman/build"
```

### Commands

Command 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).

#### Old

Here are the old defaults.

```py
decman.config.commands = MyCommands()

class MyCommands(decman.config.Commands):
    def list_pkgs(self) -> list[str]:
        return ["pacman", "-Qeq", "--color=never"]

    def list_flatpak_pkgs(self, as_user: bool = False) -> list[str]:
        return [
            "flatpak",
            "list",
            "--app",
            "--user" if as_user else "--system",
            "--columns",
            "application",
        ]

    def list_foreign_pkgs_versioned(self) -> list[str]:
        return ["pacman", "-Qm", "--color=never"]

    def install_pkgs(self, pkgs: list[str]) -> list[str]:
        return ["pacman", "-S", "--color=always", "--needed"] + pkgs

    def install_flatpak_pkgs(self, pkgs: list[str], as_user: bool = False) -> list[str]:
        return ["flatpak", "install", "-y", "--user" if as_user else "--system"] + pkgs

    def install_files(self, pkg_files: list[str]) -> list[str]:
        return ["pacman", "-U", "--color=always", "--asdeps"] + pkg_files

    def set_as_explicitly_installed(self, pkgs: list[str]) -> list[str]:
        return ["pacman", "-D", "--color=always", "--asexplicit"] + pkgs

    def install_deps(self, deps: list[str]) -> list[str]:
        return ["pacman", "-S", "--color=always", "--needed", "--asdeps"] + deps

    def is_installable(self, pkg: str) -> list[str]:
        return ["pacman", "-Sddp", pkg]

    def upgrade(self) -> list[str]:
        return ["pacman", "-Syu", "--color=always"]

    def upgrade_flatpak(self, as_user: bool = False) -> list[str]:
        return [
            "flatpak",
            "update",
            "--noninteractive",
            "-y",
            "--user" if as_user else "--system",
        ]

    def remove(self, pkgs: list[str]) -> list[str]:
        return ["pacman", "-Rs", "--color=always"] + pkgs

    def remove_flatpak(self, pkgs: list[str], as_user: bool = False) -> list[str]:
        return [
            "flatpak",
            "remove",
            "--noninteractive",
            "-y",
            "--user" if as_user else "--system",
        ] + pkgs

    def remove_unused_flatpak(self, as_user: bool = False) -> list[str]:
        return [
            "flatpak",
            "remove",
            "--noninteractive",
            "-y",
            "--unused",
            "--user" if as_user else "--system",
        ]

    def enable_units(self, units: list[str]) -> list[str]:
        return ["systemctl", "enable"] + units

    def disable_units(self, units: list[str]) -> list[str]:
        return ["systemctl", "disable"] + units

    def enable_user_units(self, units: list[str], user: str) -> list[str]:
        return ["systemctl", "--user", "-M", f"{user}@", "enable"] + units

    def disable_user_units(self, units: list[str], user: str) -> list[str]:
        return ["systemctl", "--user", "-M", f"{user}@", "disable"] + units

    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
        return ["vercmp", installed_version, new_version]

    def git_clone(self, repo: str, dest: str) -> list[str]:
        return ["git", "clone", repo, dest]

    def git_diff(self, from_commit: str) -> list[str]:
        return ["git", "diff", from_commit]

    def git_get_commit_id(self) -> list[str]:
        return ["git", "rev-parse", "HEAD"]

    def git_log_commit_ids(self) -> list[str]:
        return ["git", "log", "--format=format:%H"]

    def review_file(self, file: str) -> list[str]:
        return ["less", file]

    def make_chroot(self, chroot_dir: str, with_pkgs: list[str]) -> list[str]:
        return ["mkarchroot", chroot_dir] + with_pkgs

    def install_chroot_packages(self, chroot_dir: str, packages: list[str]):
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-S",
            "--needed",
            "--noconfirm",
        ] + packages

    def resolve_real_name(self, chroot_dir: str, pkg: str) -> list[str]:
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-Sddp",
            "--print-format=%n",
            pkg,
        ]

    def remove_chroot_packages(self, chroot_dir: str, packages: list[str]):
        return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + packages

    def make_chroot_pkg(
        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
    ) -> list[str]:
        makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]

        for pkgfile in pkgfiles_to_install:
            makechrootpkg_cmd += ["-I", pkgfile]

        return makechrootpkg_cmd
```

#### New

AUR 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.

These values are the new defaults.

```py
import decman
from decman.plugins import aur

decman.aur.commands = MyAurAndPacmanCommands()
decman.pacman.commands = MyAurAndPacmanCommands()

class MyAurAndPacmanCommands(aur.AurCommands):
    def install(self, pkgs: set[str]) -> list[str]:
        return ["pacman", "-S", "--needed"] + list(pkgs)

    def upgrade(self) -> list[str]:
        return ["pacman", "-Syu"]

    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:
        return ["pacman", "-D", "--asdeps"] + list(pkgs)

    def set_as_explicit(self, pkgs: set[str]) -> list[str]:
        return ["pacman", "-D", "--asexplicit"] + list(pkgs)

    def remove(self, pkgs: set[str]) -> list[str]:
        return ["pacman", "-Rs"] + list(pkgs)

    def install_as_dependencies(self, pkgs: set[str]) -> list[str]:
        return ["pacman", "-S", "--needed", "--asdeps"] + list(pkgs)

    def install_files_as_dependencies(self, pkg_files: list[str]) -> list[str]:
        return ["pacman", "-U", "--asdeps"] + pkg_files

    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
        return ["vercmp", installed_version, new_version]

    def git_clone(self, repo: str, dest: str) -> list[str]:
        return ["git", "clone", repo, dest]

    def git_diff(self, from_commit: str) -> list[str]:
        return ["git", "diff", from_commit]

    def git_get_commit_id(self) -> list[str]:
        return ["git", "rev-parse", "HEAD"]

    def git_log_commit_ids(self) -> list[str]:
        return ["git", "log", "--format=format:%H"]

    def review_file(self, file: str) -> list[str]:
        return ["less", file]

    def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:
        return ["mkarchroot", chroot_dir] + list(with_pkgs)

    def install_chroot(self, chroot_dir: str, packages: list[str]):
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-S",
            "--needed",
            "--noconfirm",
        ] + packages

    def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[str]:
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-Sddp",
            "--print-format=%n",
            pkg,
        ]

    def remove_chroot(self, chroot_dir: str, packages: set[str]):
        return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + list(packages)

    def make_chroot_pkg(
        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
    ) -> list[str]:
        makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]

        for pkgfile in pkgfiles_to_install:
            makechrootpkg_cmd += ["-I", pkgfile]

        return makechrootpkg_cmd

    def print_srcinfo(self) -> list[str]:
        return ["makepkg", "--printsrcinfo"]
```

Systemd commands:

```py
import decman
from decman.plugins import systemd

decman.systemd.commands = MyCommands()

class MyCommands(SystemdCommands):

    def enable_units(self, units: set[str]) -> list[str]:
        return ["systemctl", "enable"] + list(units)

    def disable_units(self, units: set[str]) -> list[str]:
        return ["systemctl", "disable"] + list(units)

    def enable_user_units(self, units: set[str], user: str) -> list[str]:
        return ["systemctl", "--user", "-M", f"{user}@", "enable"] + list(units)

    def disable_user_units(self, units: set[str], user: str) -> list[str]:
        return ["systemctl", "--user", "-M", f"{user}@", "disable"] + list(units)

    def daemon_reload(self) -> list[str]:
        return ["systemctl", "daemon-reload"]

    def user_daemon_reload(self, user: str) -> list[str]:
        return ["systemctl", "--user", "-M", f"{user}@", "daemon-reload"]
```

Flatpak commands:

```py
import decman
from decman.plugins import flatpak

decman.flatpak.commands = MyCommands()

class MyCommands(FlatpakCommands):
    def list_apps(self, as_user: bool) -> list[str]:
        return [
            "flatpak",
            "list",
            "--app",
            "--user" if as_user else "--system",
            "--columns",
            "application",
        ]

    def install(self, pkgs: set[str], as_user: bool) -> list[str]:
        return [
            "flatpak",
            "install",
            "--user" if as_user else "--system",
        ] + sorted(pkgs)

    def upgrade(self, as_user: bool) -> list[str]:
        return [
            "flatpak",
            "update",
            "--user" if as_user else "--system",
        ]

    def remove(self, pkgs: set[str], as_user: bool) -> list[str]:
        return [
            "flatpak",
            "remove",
            "--user" if as_user else "--system",
        ] + sorted(pkgs)

    def remove_unused(self, as_user: bool) -> list[str]:
        return [
            "flatpak",
            "remove",
            "--unused",
            "--user" if as_user else "--system",
        ]
```


================================================
FILE: docs/pacman.md
================================================
# Pacman

Pacman 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.

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.

Please 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.

## Usage

Define system packages.

```py
import decman
decman.pacman.packages |= {"sudo", "vim"}
```

Define ignored packages. This plugin won't install them nor remove them.

```py
# Include only packages found in the pacman repositories in here.
decman.pacman.ignored_packages |= {"opendoas"}
```

This plugin's execution order step name is `pacman`.

### Within modules

Modules 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.

```py
import decman
from decman.plugins import pacman

class MyModule(decman.Module):
    ...

    @pacman.packages
    def packages_defined_in_this_module(self) -> set[str]:
        return {"tmux", "kitty"}
```

If this set changes, this plugin will flag the module as changed. The module's `on_change` method will be executed.

## Keys used in the decman store

- `packages_for_module`

## Configuration

This 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.

```py
import decman

# set keywords
decman.pacman.keywords = {"pacsave", "pacnew", "warning"}

# disable the feature
decman.pacman.print_highlights = False

# signature level for querying existing databases
decman.pacman.database_signature_level = 2048 # pyalpm.SIG_DATABASE_OPTIONAL

# path to databases
decman.pacman.database_path = "/var/lib/pacman/"
```

Additionally it's possible to override the commands this plugin uses. Create your own `PacmanCommands` class and override methods returning commands. These are the defaults.

```py
from decman.plugins import pacman

class MyCommands(pacman.PacmanCommands):
    def list_pacman_repos(self) -> list[str]:
        """
        Running this command prints a newline seperated list of pacman repositories.
        """
        return ["pacman-conf", "--repo-list"]

    def install(self, pkgs: set[str]) -> list[str]:
        """
        Running this command installs the given packages from pacman repositories.
        """
        return ["pacman", "-S", "--needed"] + list(pkgs)

    def upgrade(self) -> list[str]:
        """
        Running this command upgrades all pacman packages from pacman repositories.
        """
        return ["pacman", "-Syu"]

    def set_as_dependencies(self, pkgs: set[str]) -> list[str]:
        """
        Running this command sets the given packages as dependencies.
        """
        return ["pacman", "-D", "--asdeps"] + list(pkgs)

    def set_as_explicit(self, pkgs: set[str]) -> list[str]:
        """
        Running this command sets the given as explicitly installed.
        """
        return ["pacman", "-D", "--asexplicit"] + list(pkgs)

    def remove(self, pkgs: set[str]) -> list[str]:
        """
        Running this command removes the given packages and their dependencies
        (that aren't required by other packages).
        """
        return ["pacman", "-Rs"] + list(pkgs)
```

Then set the commands.

```py
import decman
decman.pacman.commands = MyCommands()
```


================================================
FILE: docs/systemd.md
================================================
# Systemd

The 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.

This 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.

Decman will only enable and disable systemd services. It will not start or stop them. Starting or stopping them automatically can cause issues.

## Usage

Declare system-wide units.

```py
import decman
decman.systemd.enabled_units |= {"NetworkManager.service", "ufw.service"}
```

Declare 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.

```py
decman.systemd.enabled_user_units.setdefault("user", set()).update({"syncthing.service"})
```

This plugin's execution order step name is `systemd`.

### Within modules

Modules 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.

```py
import decman
from decman.plugins import systemd

class MyModule(decman.Module):
    ...

    @systemd.units
    def units_defined_in_this_module(self) -> set[str]:
        return {"NetworkManager.service", "ufw.service"}

    @systemd.user_units
    def user_units_defined_in_this_module(self) -> dict[str, set[str]]:
        return {"user": {"syncthing.service"}}
```

If units or user units change, this plugin will flag the module as changed. The module's `on_change` method will be executed.

## Keys used in the decman store

- `systemd_units_for_module`
- `systemd_user_units_for_module`

## Configuration

It's possible to override the commands this plugin uses. Create your own `SystemdCommands` class and override methods returning commands. These are the defaults.

```py
from decman.plugins import systemd

class MyCommands(systemd.SystemdCommands):
    """
    Default commands for the Systemd plugin.
    """

    def enable_units(self, units: set[str]) -> list[str]:
        """
        Running this command enables the given systemd units.
        """
        return ["systemctl", "enable"] + list(units)

    def disable_units(self, units: set[str]) -> list[str]:
        """
        Running this command disables the given systemd units.
        """
        return ["systemctl", "disable"] + list(units)

    def enable_user_units(self, units: set[str], user: str) -> list[str]:
        """
        Running this command enables the given systemd units for the user.
        """
        return ["systemctl", "--user", "-M", f"{user}@", "enable"] + list(units)

    def disable_user_units(self, units: set[str], user: str) -> list[str]:
        """
        Running this command disables the given systemd units for the user.
        """
        return ["systemctl", "--user", "-M", f"{user}@", "disable"] + list(units)

    def daemon_reload(self) -> list[str]:
        """
        Running this command reloads the systemd daemon.
        """
        return ["systemctl", "daemon-reload"]

    def user_daemon_reload(self, user: str) -> list[str]:
        """
        Running this command reloads the systemd daemon for the given user.
        """
        return ["systemctl", "--user", "-M", f"{user}@", "daemon-reload"]
```

Then set the commands.

```py
import decman
decman.systemd.commands = MyCommands()
```


================================================
FILE: example/README.md
================================================
# Example

This 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.

## Tutorial

### Installing decman

I will first install git and base-devel. Then I'll clone the PKGBUILD and install decman.

```sh
sudo pacman -S git base-devel
git clone https://aur.archlinux.org/decman.git
cd decman/
makepkg -sic
```

### Starting out

I will create a source directory for the system's configuration.

```sh
mkdir ~/source
cd ~/source
```

Decman will remove all explicitly installed packages not found in the source. Let's find all explicitly installed packages.

```sh
$ pacman -Qeq
base
base-devel
btrfs-progs
decman
dosfstools
efibootmgr
git
grub
linux
openssh
qemu-guest-agent
sudo
vim
```

First 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.

Instead of adding all of these packages to `decman.pacman.packages`, I will first create a module for base system packages in `~/source/base.py`.

```py
import decman
from decman.plugins import pacman, aur

class BaseModule(decman.Module):

    def __init__(self):
        # I'll intend this module to be a singleton (only one instance ever),
        # so I'll inline the module name
        super().__init__("base")

    @pacman.packages
    def pkgs(self) -> set[str]:
        return {
            "base",
            "btrfs-progs",
            "dosfstools",
            "efibootmgr",
            "grub",
            "linux",

            # I'll also include git and base-devel here, they are essential to this system
            "git",
            "base-devel",
        }

    @aur.packages
    def aurpkgs(self) -> set[str]:
        return {"decman"}
```

Then 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`.

```py
import decman
from base import BaseModule

decman.pacman.packages |= {"openssh", "qemu-guest-agent", "sudo", "vim"}
decman.modules += [BaseModule()]
```

This config is already enough to run decman for the first time.

```sh
sudo decman --source /home/arch/source/source.py
```

This will run a system upgrade, but otherwise nothing else happens, since my system already matches the desired configuration.

### Extending my config with files and commands

Now 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`.

```py
class BaseModule(decman.Module):
    ...

    def files(self) -> dict[str, decman.File]:
        return {"/etc/mkinitcpio.conf": decman.File(source_file="./files/mkinitcpio.conf")}

    def on_change(self, store):
        decman.prg(["mkinitcpio", "-P"])
```

I'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`.

```py
import decman

...

decman.files["/home/arch/.vimrc"] = decman.File(source_file="./files/vimrc", owner="arch", permissions=0o600)
```

Then 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.

```sh
sudo decman --skip aur
```

### Systemd services and flatpaks

I 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.

```py
import decman
from decman.plugins import pacman, systemd

class KDE(decman.Module):

    def __init__(self):
        super().__init__("kde")

    @pacman.packages
    def pkgs(self) -> set[str]:
        return {
            "plasma-desktop",
            "konsole",
            "sddm",
        }

    @systemd.units
    def units(self) -> set[str]:
        return {"sddm.service"}
```

I'll add the module to enabled modules in `~/source/source.py`.

```py
import decman
from base import BaseModule
from kde import KDE

...

decman.modules += [BaseModule(), KDE()]
```

I'll run decman once again. I'll also start SDDM manually, since decman can't autostart it.

```sh
sudo decman
sudo systemctl start sddm
```

Lastly 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.

```sh
sudo pacman -S flatpak
```

Then I'll modify `~/source/source.py`. I must add `flatpak` to execution steps to run the plugin.

```py
import decman

...

decman.execution_order = [
    "files",
    "pacman",
    "aur",
    "flatpak",
    "systemd",
]

decman.pacman.packages.add("flatpak")
decman.flatpak.packages |= {"org.mozilla.firefox", "org.signal.Signal"}
```

Then run decman.

```sh
sudo decman
```

### Maintaining a system with decman

Decman 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.

## Plugins

It 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).


================================================
FILE: example/base.py
================================================
import decman
from decman.plugins import aur, pacman


class BaseModule(decman.Module):
    def __init__(self):
        # I'll intend this module to be a singleton (only one instance ever),
        # so I'll inline the module name
        super().__init__("base")

    @pacman.packages
    def pkgs(self) -> set[str]:
        return {
            "base",
            "btrfs-progs",
            "dosfstools",
            "efibootmgr",
            "grub",
            "linux",
            # I'll also include git and base-devel here, they are essential to this system
            "git",
            "base-devel",
        }

    @aur.packages
    def aurpkgs(self) -> set[str]:
        return {"decman"}

    def files(self) -> dict[str, decman.File]:
        return {"/etc/mkinitcpio.conf": decman.File(source_file="./files/mkinitcpio.conf")}

    def on_change(self, store):
        decman.prg(["mkinitcpio", "-P"])


================================================
FILE: example/files/mkinitcpio.conf
================================================
MODULES=()
BINARIES=()
FILES=()
HOOKS=(base systemd autodetect microcode modconf kms keyboard keymap sd-vconsole block filesystems fsck)


================================================
FILE: example/files/vimrc
================================================
set number
syntax on


================================================
FILE: example/kde.py
================================================
import decman
from decman.plugins import pacman, systemd


class KDE(decman.Module):
    def __init__(self):
        super().__init__("kde")

    @pacman.packages
    def pkgs(self) -> set[str]:
        return {
            "plasma-desktop",
            "konsole",
            "sddm",
        }

    @systemd.units
    def units(self) -> set[str]:
        return {"sddm.service"}


================================================
FILE: example/plugin/decman_plugin_example.py
================================================
import os

import decman


class Example(decman.Plugin):
    NAME = "example"

    def available(self) -> bool:
        return os.path.exists("/tmp/example_plugin_available")

    def process_modules(self, store: decman.Store, modules: list[decman.Module]):
        # Toy example for setting modules as changed
        for module in modules:
            module._changed = True

    def apply(
        self, store: decman.Store, dry_run: bool = False, params: list[str] | None = None
    ) -> bool:
        return True


================================================
FILE: example/plugin/pyproject.toml
================================================
[project]
name = "decman-plugin-example"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
    "decman",
]

[project.entry-points."decman.plugins"]
example = "decman_plugin_example:Example"

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"


================================================
FILE: example/source.py
================================================
from base import BaseModule
from kde import KDE

import decman

decman.pacman.packages |= {"openssh", "qemu-guest-agent", "sudo", "vim"}

decman.modules += [BaseModule(), KDE()]

decman.files["/home/arch/.vimrc"] = decman.File(
    source_file="./files/vimrc", owner="arch", permissions=0o600
)

decman.execution_order = [
    "files",
    "pacman",
    "aur",
    "flatpak",
    "systemd",
]

decman.pacman.packages.add("flatpak")
decman.flatpak.packages |= {"org.mozilla.firefox", "org.signal.Signal"}


================================================
FILE: plugins/decman-flatpak/pyproject.toml
================================================
[project]
name = "decman-flatpak"
version = "1.1.0"
requires-python = ">=3.13"
dependencies = ["decman==1.2.1"]

[project.entry-points."decman.plugins"]
flatpak = "decman.plugins.flatpak:Flatpak"

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
include = ["decman.plugins*"]

[tool.pytest.ini_options]
testpaths = ["tests"]


================================================
FILE: plugins/decman-flatpak/src/decman/plugins/flatpak.py
================================================
import shutil

import decman.core.command as command
import decman.core.error as errors
import decman.core.module as module
import decman.core.output as output
import decman.core.store as _store
import decman.plugins as plugins


def packages(fn):
    """
    Annotate that this function returns a set of flatpak package names that should be installed.

    Return type of ``fn``: ``set[str]``
    """
    fn.__flatpak__packages__ = True
    return fn


def user_packages(fn):
    """
    Annotate that this function returns a dict of users and flatpak packages that should be
    installed.

    Return type of ``fn``: ``dict[str, set[str]]``
    """
    fn.__flatpak__user__packages__ = True
    return fn


class Flatpak(plugins.Plugin):
    """
    Plugin that manages flatpak packages added directly to ``packages`` or declared by modules via
    ``@flatpak.packages``. User packages are managed as well.
    """

    NAME = "flatpak"

    def __init__(self) -> None:
        self.packages: set[str] = set()
        self.user_packages: dict[str, set[str]] = {}
        self.ignored_packages: set[str] = set()
        self.commands = FlatpakCommands()

    def available(self) -> bool:
        return shutil.which("flatpak") is not None

    def process_modules(self, store: _store.Store, modules: list[module.Module]):
        # These store keys are used to track changes in modules.
        # This way when these change, module can be marked as changed
        store.ensure("flatpaks_for_module", {})
        store.ensure("user_flatpaks_for_module", {})

        for mod in modules:
            store["flatpaks_for_module"].setdefault(mod.name, set())
            store["user_flatpaks_for_module"].setdefault(mod.name, {})

            packages = set().union(
                *plugins.run_methods_with_attribute(mod, "__flatpak__packages__")
            )
            user_packages = {
                k: v
                for d in plugins.run_methods_with_attribute(mod, "__flatpak__user__packages__")
                for k, v in d.items()
            }

            if store["flatpaks_for_module"][mod.name] != packages:
                mod._changed = True
                output.print_debug(
                    f"Module '{mod.name}' set to changed due to modified system flatpaks."
                )

            if store["user_flatpaks_for_module"][mod.name] != user_packages:
                mod._changed = True
                output.print_debug(
                    f"Module '{mod.name}' set to changed due to modified user flatpaks."
                )

            self.packages |= packages
            for user, flatpaks in user_packages.items():
                self.user_packages.setdefault(user, set()).update(flatpaks)

            store["flatpaks_for_module"][mod.name] = packages
            store["user_flatpaks_for_module"][mod.name] = user_packages

    def apply(
        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None
    ) -> bool:
        pm = FlatpakInterface(self.commands)

        try:
            self.apply_packages(pm, None, self.packages, self.ignored_packages, dry_run)

            for user, packages in self.user_packages.items():
                self.apply_packages(pm, user, packages, self.ignored_packages, dry_run)
        except errors.CommandFailedError as error:
            output.print_error("Running a flatpak command failed.")
            output.print_error(
                "Flatpak command exited with an unexpected return code. You may have cancelled a "
                "flatpak operation."
            )
            output.print_error(str(error))
            if error.output:
                output.print_command_output(error.output)
            output.print_traceback()
            return False
        return True

    def apply_packages(
        self,
        flatpak: "FlatpakInterface",
        user: str | None,
        packages: set[str],
        ignored_packages: set[str],
        dry_run: bool,
    ):
        currently_installed = flatpak.get_apps(user)
        to_remove = currently_installed - packages - ignored_packages
        to_install = packages - currently_installed - ignored_packages

        for_user_msg = f" for {user}" if user else ""

        if to_remove:
            output.print_list(f"Removing flatpak packages{for_user_msg}:", sorted(to_remove))
            if not dry_run:
                flatpak.remove(to_remove, user)

        output.print_summary(f"Upgrading packages{for_user_msg}.")
        if not dry_run:
            flatpak.upgrade(user)

        if to_install:
            output.print_list(f"Installing flatpak packages{for_user_msg}:", sorted(to_install))
            if not dry_run:
                flatpak.install(to_install, user)


class FlatpakCommands:
    def list_apps(self, as_user: bool) -> list[str]:
        """
        Running this command outputs a newline separated list of installed flatpak application IDs.

        If ``as_user`` is ``True``, run the command as the user whose packages should be listed.

        NOTE: The first line says 'Application ID' and should be ignored.
        """
        return [
            "flatpak",
            "list",
            "--app",
            "--user" if as_user else "--system",
            "--columns",
            "application",
        ]

    def install(self, pkgs: set[str], as_user: bool) -> list[str]:
        """
        Running this command installs all listed packages, and their dependencies/runtimes
        automatically.

        If ``as_user`` is ``True``, run the command as the user for whom packages are installed.
        """
        return [
            "flatpak",
            "install",
            "--user" if as_user else "--system",
        ] + sorted(pkgs)

    def upgrade(self, as_user: bool) -> list[str]:
        """
        Updates all installed flatpaks including runtimes and dependencies.

        If ``as_user`` is ``True``, run the command as the user whose flatpaks are updated.
        """
        return [
            "flatpak",
            "update",
            "--user" if as_user else "--system",
        ]

    def remove(self, pkgs: set[str], as_user: bool) -> list[str]:
        """
        Running this command will remove the listed packages.

        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.
        """

        return [
            "flatpak",
            "remove",
            "--user" if as_user else "--system",
        ] + sorted(pkgs)

    def remove_unused(self, as_user: bool) -> list[str]:
        """
        This will remove all unused flatpak dependencies and runtimes.

        If ``as_user`` is ``True``, run the command as the user for whom packages are removed.
        """
        return [
            "flatpak",
            "remove",
            "--unused",
            "--user" if as_user else "--system",
        ]


class FlatpakInterface:
    """
    High level interface for running pacman commands.

    On failure methods raise a ``CommandFailedError``.
    """

    def __init__(self, commands: FlatpakCommands) -> None:
        self._commands = commands

    def get_apps(self, user: str | None = None) -> set[str]:
        """
        Returns a set of installed flatpak apps.

        If ``user`` is set, returns flatpak apps for that user.
        """
        as_user = user is not None

        cmd = self._commands.list_apps(as_user=as_user)
        _, packages_text = command.check_run_result(
            cmd, command.run(cmd, user=user, mimic_login=as_user)
        )
        packages = packages_text.strip().split("\n")

        # In case no apps are installed, the list contains this
        if "" in packages:
            packages.remove("")

        return set(packages)

    def install(self, packages: set[str], user: str | None = None):
        """
        Installs the given packages.

        If ``user`` is set, installs packages for that user.
        """
        if not packages:
            return

        as_user = user is not None

        cmd = self._commands.install(packages, as_user)
        command.prg(cmd, user=user, mimic_login=as_user)

    def upgrade(self, user: str | None = None):
        """
        Upgrades all packages.

        If ``user`` is set, upgrades packages for that user.
        """
        as_user = user is not None
        cmd = self._commands.upgrade(as_user)
        command.prg(cmd, user=user, mimic_login=as_user)

    def remove(self, packages: set[str], user: str | None = None):
        """
        Removes the given packages as well as unused dependencies.

        If ``user`` is set, removes packages for that user.
        """
        if not packages:
            return

        as_user = user is not None
        cmd = self._commands.remove(packages, as_user)
        command.prg(cmd, user=user, mimic_login=as_user)

        cmd = self._commands.remove_unused(as_user)
        command.prg(cmd, user=user, mimic_login=as_user)


================================================
FILE: plugins/decman-pacman/pyproject.toml
================================================
[project]
name = "decman-pacman"
version = "1.1.0"
requires-python = ">=3.13"
dependencies = [
  "decman==1.2.1",
  "pyalpm",
  "requests",
]

[dependency-groups]
dev = [
    "pytest>=8.4.2",
    "pytest-mock>=3.15.1",
]

[project.entry-points."decman.plugins"]
pacman = "decman.plugins.pacman:Pacman"
aur = "decman.plugins.aur:AUR"

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]
namespaces = true
include = ["decman.plugins*"]

[tool.pytest.ini_options]
testpaths = ["tests"]


================================================
FILE: plugins/decman-pacman/src/decman/plugins/aur/__init__.py
================================================
import os
import shutil

import pyalpm
from decman.plugins.aur.commands import AurCommands, AurPacmanInterface
from decman.plugins.aur.error import (
    AurRPCError,
    DependencyCycleError,
    ForeignPackageManagerError,
    PKGBUILDParseError,
)
from decman.plugins.aur.fpm import ForeignPackageManager
from decman.plugins.aur.package import CustomPackage, PackageSearch

import decman.config as config
import decman.core.error as errors
import decman.core.module as module
import decman.core.output as output
import decman.core.store as _store
import decman.plugins as plugins

# Re-exports
__all__ = [
    "AUR",
    "AurCommands",
    "CustomPackage",
    "packages",
    "custom_packages",
]


def packages(fn):
    """
    Annotate that this function returns a set of AUR package names that should be installed.

    Return type of ``fn``: ``set[str]``
    """
    fn.__aur__packages__ = True
    return fn


def custom_packages(fn):
    """
    Annotate that this function returns a set of ``CustomPackage``s that should be installed.

    Return type of ``fn``: ``set[CustomPackage]``
    """
    fn.__custom__packages__ = True
    return fn


class AUR(plugins.Plugin):
    """
    Plugin that manages additional pacman packages installed outside the pacman repos.

    AUR packages are added directly to ``packages`` or declared by modules via ``@aur.packages``.

    Custom packages are added directly to ``custom_packages`` or declared by modules via
    ``@aur.custom_packages``.
    """

    NAME = "aur"

    def __init__(self) -> None:
        self.packages: set[str] = set()
        self.custom_packages: set[CustomPackage] = set()
        self.ignored_packages: set[str] = set()
        self.commands: AurCommands = AurCommands()

        self.database_signature_level = pyalpm.SIG_DATABASE_OPTIONAL
        self.database_path = "/var/lib/pacman/"

        self.aur_rpc_timeout: int = 30
        self.print_highlights: bool = True
        self.keywords: set[str] = {
            "pacsave",
            "pacnew",
            # These cause too many false positives IMO
            # "warning",
            # "error",
            # "note",
        }
        self.build_dir: str = "/tmp/decman/build"
        self.makepkg_user: str = "nobody"

    def available(self) -> bool:
        return (
            shutil.which("pacman") is not None
            and shutil.which("git") is not None
            and shutil.which("mkarchroot") is not None
        )

    def process_modules(self, store: _store.Store, modules: list[module.Module]):
        # This is used to track changes in modules.
        store.ensure("aur_packages_for_module", {})
        store.ensure("custom_packages_for_module", {})

        for mod in modules:
            store["aur_packages_for_module"].setdefault(mod.name, set())
            store["custom_packages_for_module"].setdefault(mod.name, set())

            aur_packages = set().union(
                *plugins.run_methods_with_attribute(mod, "__aur__packages__")
            )
            custom_packages = set().union(
                *plugins.run_methods_with_attribute(mod, "__custom__packages__")
            )
            custom_package_strs = set(map(str, custom_packages))

            if store["aur_packages_for_module"][mod.name] != aur_packages:
                mod._changed = True
                output.print_debug(
                    f"Module '{mod.name}' set to changed due to modified aur packages."
                )

            if store["custom_packages_for_module"][mod.name] != custom_package_strs:
                mod._changed = True
                output.print_debug(
                    f"Module '{mod.name}' set to changed due to modified custom packages."
                )

            self.packages |= aur_packages
            self.custom_packages |= custom_packages

            store["aur_packages_for_module"][mod.name] = aur_packages
            store["custom_packages_for_module"][mod.name] = custom_package_strs

    def apply(
        self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None
    ) -> bool:
        params = params or []
        upgrade_devel = "aur-upgrade-devel" in params
        force = "aur-force" in params
        pkg_cache_dir = os.path.join(config.cache_dir, "aur/")

        if not dry_run:
            try:
                os.makedirs(pkg_cache_dir, exist_ok=True)
            except OSError as error:
                output.print_error(
                    "Failed to ensure AUR package cache directory exists: "
                    f"{error.strerror or error}"
                )
                output.print_traceback()

                return False

        try:
            package_search = PackageSearch(self.aur_rpc_timeout)
            for custom_package in self.custom_packages:
                package_search.add_custom_pkg(custom_package.parse(self.commands))
            pm = AurPacmanInterface(
                self.commands,
                self.print_highlights,
                self.keywords,
                self.database_signature_level,
                self.database_path,
            )
            fpm = ForeignPackageManager(
                store,
                pm,
                package_search,
                self.commands,
                pkg_cache_dir,
                self.build_dir,
                self.makepkg_user,
            )

            custom_package_names = {p.pkgname for p in self.custom_packages}
            currently_installed_native = pm.get_native_explicit()
            currently_installed_foreign = pm.get_foreign_explicit()
            orphans = pm.get_foreign_orphans()

            to_remove = (
                (currently_installed_foreign | orphans)
                - self.packages
                - custom_package_names
                - self.ignored_packages
            )

            actually_to_remove = set()
            to_set_as_dependencies = set()

            dependants_to_keep = (
                self.packages
                | custom_package_names
                | currently_installed_native
                # don't remove ignored packages' dependencies
                | (self.ignored_packages & currently_installed_foreign)
            )
            for package in to_remove:
                dependants = pm.get_dependants(package)
                if dependants & dependants_to_keep:
                    to_set_as_dependencies.add(package)
                else:
                    actually_to_remove.add(package)

            if actually_to_remove:
                output.print_list("Removing foreign packages:", sorted(actually_to_remove))
                if not dry_run:
                    pm.remove(actually_to_remove)

            if to_set_as_dependencies:
                output.print_list(
                    "Setting previously explicitly installed foreign packages as dependencies:",
                    sorted(to_set_as_dependencies),
                )
                if not dry_run:
                    pm.set_as_dependencies(to_set_as_dependencies)

            output.print_summary("Upgrading foreign packages.")
            if not dry_run:
                # don't try to upgrade removed packages
                fpm.upgrade(upgrade_devel, force, self.ignored_packages | actually_to_remove)

            to_install = (
                (self.packages | custom_package_names)
                - currently_installed_foreign
                - self.ignored_packages
            )
            output.print_list("Installing foreign packages:", sorted(to_install))

            if not dry_run:
                fpm.install(list(to_install), force=force)
        except AurRPCError as error:
            output.print_error("Failed to fetch data from AUR RPC.")
            output.print_error(str(error))
            output.print_traceback()
            return False
        except DependencyCycleError as error:
            output.print_error("Foreign package dependency cycle detected.")
            output.print_error(str(error))
            output.print_traceback()
            return False
        except PKGBUILDParseError as error:
            output.print_error("Failed to parse a CustomPackage PKGBUILD.")
            output.print_error(str(error))
            output.print_traceback()
            return False
        except ForeignPackageManagerError as error:
            output.print_error("Foreign package manager failed.")
            output.print_error(str(error))
            output.print_traceback()
            return False
        except pyalpm.error as error:
            output.print_error("Failed to query pacman databases with pyalpm.")
            output.print_error(str(error))
            output.print_traceback()
            return False
        except errors.CommandFailedError as error:
            output.print_error(
                "AUR command exited with an unexpected return code. You may have cancelled a "
                "pacman operation."
            )
            output.print_error(str(error))
            if error.output:
                output.print_command_output(error.output)
            output.print_traceback()
            return False

        return True


================================================
FILE: plugins/decman-pacman/src/decman/plugins/aur/commands.py
================================================
import decman.plugins.pacman as pacman

import decman.config as config
import decman.core.command as command


class AurCommands(pacman.PacmanCommands):
    def install_as_dependencies(self, pkgs: set[str]) -> list[str]:
        """
        Running this command installs the given packages from pacman repositories.
        The packages are installed as dependencies.
        """
        return ["pacman", "-S", "--needed", "--asdeps"] + list(pkgs)

    def install_files_as_dependencies(self, pkg_files: list[str]) -> list[str]:
        """
        Running this command installs the given packages files as dependencies.
        """
        return ["pacman", "-U", "--asdeps"] + pkg_files

    def compare_versions(self, installed_version: str, new_version: str) -> list[str]:
        """
        Running this command outputs -1 when the installed version is older than the new version.
        """
        return ["vercmp", installed_version, new_version]

    def git_clone(self, repo: str, dest: str) -> list[str]:
        """
        Running this command clones a git repository to the the given destination.
        """
        return ["git", "clone", repo, dest]

    def git_diff(self, from_commit: str) -> list[str]:
        """
        Running this command outputs the difference between the given commit and
        the current state of the repository.
        """
        return ["git", "diff", from_commit]

    def git_get_commit_id(self) -> list[str]:
        """
        Running this command outputs the current commit id.
        """
        return ["git", "rev-parse", "HEAD"]

    def git_log_commit_ids(self) -> list[str]:
        """
        Running this command outputs commit hashes of the repository.
        """
        return ["git", "log", "--format=format:%H"]

    def review_file(self, file: str) -> list[str]:
        """
        Running this command outputs a file for the user to see.
        """
        return ["less", file]

    def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:
        """
        Running this command creates a new arch chroot to the chroot directory and installs the
        given packages there.
        """
        return ["mkarchroot", chroot_dir] + list(with_pkgs)

    def install_chroot(self, chroot_dir: str, packages: list[str]):
        """
        Running this command installs the given packages to the given chroot.
        """
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-S",
            "--needed",
            "--noconfirm",
        ] + packages

    def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[str]:
        """
        This command prints a real name of a package.
        For example, it prints the package which provides a virtual package.
        """
        return [
            "arch-nspawn",
            chroot_dir,
            "pacman",
            "-Sddp",
            "--print-format=%n",
            pkg,
        ]

    def remove_chroot(self, chroot_dir: str, packages: set[str]):
        """
        Running this command removes the given packages from the given chroot.
        """
        return ["arch-nspawn", chroot_dir, "pacman", "-Rsu", "--noconfirm"] + list(packages)

    def make_chroot_pkg(
        self, chroot_wd_dir: str, user: str, pkgfiles_to_install: list[str]
    ) -> list[str]:
        """
        Running this command creates a package file using the given chroot.
        The package is created as the user and the pkg_files_to_install are installed
        in the chroot before the package is created.
        """
        makechrootpkg_cmd = ["makechrootpkg", "-c", "-r", chroot_wd_dir, "-U", user]

        for pkgfile in pkgfiles_to_install:
            makechrootpkg_cmd += ["-I", pkgfile]

        return makechrootpkg_cmd

    def print_srcinfo(self) -> list[str]:
        """
        Running this command prints SRCINFO generated from the package in the current
        working directory.
        """
        return ["makepkg", "--printsrcinfo"]


class AurPacmanInterface(pacman.PacmanInterface):
    """
    High level interface for running pacman commands.

    On failure methods raise a ``CommandFailedError``.
    """

    def __init__(
        self,
        commands: AurCommands,
        print_highlights: bool,
        keywords: set[str],
        dbsiglevel: int,
        dbpath: str,
    ) -> None:
        super().__init__(commands, print_highlights, keywords, dbsiglevel, dbpath)
        self._installable: dict[str, bool] = {}
        self._aur_commands = commands

    def get_foreign_orphans(self) -> set[str]:
        """
        Returns a set of orphaned foreign packages.
        """
        return self._get_orphans(pacman.PacmanInterface._is_foreign)

    def is_provided_by_installed(self, dependency: str) -> bool:
        return pacman.strip_dependency(dependency) in self._local_provides_index

    def filter_installed_packages(self, deps: set[str]) -> set[str]:
        out = set()
        for d in deps:
            if not self.is_provided_by_installed(d) and d not in self.get_all_packages():
                out.add(d)
        return out

    def is_installable(self, pkg: str) -> bool:
        """
        Returns True if a package can be installed using pacman.
        """
        return (
            pacman.strip_dependency(pkg) in self._name_index
            or pacman.strip_dependency(pkg) in self._provides_index
        )

    def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
        """
        Returns a list of installed packages and their versions that aren't from pacman databases,
        basically AUR packages.
        """
        out: list[tuple[str, str]] = []
        for pkg in self._handle.get_localdb().pkgcache:
            if not self._is_native(pkg.name):
                out.append((pkg.name, pkg.version))
        return out

    def install_dependencies(self, deps: set[str]):
        """
        Installs the given dependencies.
        """
        if not deps:
            return

        cmd = self._aur_commands.install_as_dependencies(deps)
        pacman_output = command.prg(cmd)
        self.print_highlighted_pacman_messages(pacman_output)

    def install_files(self, files: list[str], as_explicit: set[str]):
        """
        Installs the given files first as dependencies. Then the packages listed in as_explicit are
        installed explicitly.
        """
        if not files:
            return

        cmd = self._aur_commands.install_files_as_dependencies(files)
        pacman_output = command.prg(cmd)
        self.print_highlighted_pacman_messages(pacman_output)

        if not as_explicit:
            return

        cmd = self._commands.set_as_explicit(as_explicit)
        command.prg(cmd, pty=config.debug_output)


================================================
FILE: plugins/decman-pacman/src/decman/plugins/aur/error.py
================================================
class ForeignPackageManagerError(Exception):
    """
    Error raised from the ForeignPackageManager
    """


class DependencyCycleError(Exception):
    """
    Error raised when a dependency cycle is detected involving foreign packages.
    """

    def __init__(self, package1: str, package2: str):
        super().__init__(
            f"Foreign package dependency cycle detected involving '{package1}' "
            f"and '{package2}'. Foreign package dependencies are also required "
            "during package building and therefore dependency cycles cannot be handled."
        )


class PKGBUILDParseError(Exception):
    """
    Error raised when parsing a PKGBUILD fails.
    """

    def __init__(self, git_url: str | None, pkgbuild_directory: str | None, message: str) -> None:
        # Only one of these should be set
        self.pkgbuild_source = git_url or pkgbuild_directory
        self.message = message
        super().__init__(f"Failed to parse PKGBUILD from '{self.pkgbuild_source}': {message}")


class AurRPCError(Exception):
    """
    Error raised when accessing AUR RPC fails.
    """

    def __init__(self, message: str, url: str):
        self.message = message
        self.url = url
        super().__init__(f"Failed to complete AUR RPC request to '{url}': {message}")


================================================
FILE: plugins/decman-pacman/src/decman/plugins/aur/fpm.py
================================================
import os
import shutil
import time
import typing

from decman.plugins.aur.commands import AurCommands
from decman.plugins.aur.error import ForeignPackageManagerError
from decman.plugins.aur.package import AurPacmanInterface, PackageSearch
from decman.plugins.aur.resolver import DepGraph, ForeignPackage

import decman.config as config
import decman.core.command as command
import decman.core.error as errors
import decman.core.output as output
import decman.core.store as _store


def find_latest_cached_package(store: _store.Store, package: str) -> tuple[str, str] | None:
    """
    Returns the latest version and path of a package stored in the built packages cache as a
    tuple (version, path).
    """
    store.ensure("package_file_cache", {})
    entries = store["package_file_cache"].get(package)

    if entries is None:
        return None

    latest_version = None
    latest_path = None
    latest_timestamp = 0

    for version, path, timestamp in entries:
        if latest_timestamp < timestamp and os.path.exists(path):
            latest_timestamp = timestamp
            latest_version = version
            latest_path = path

    output.print_debug(f"Latest file for {package} is '{latest_path}'.")

    if latest_path is None:
        return None

    assert latest_version is not None, "If latest_path is set, then latest_version is set."
    return (latest_version, latest_path)


def add_package_to_cache(store: _store.Store, package: str, version: str, path_to_built_pkg: str):
    """
    Adds a built package to the package file cache. Tries to remove excess cached packages.
    """
    store.ensure("package_file_cache", {})

    new_entry = (version, path_to_built_pkg, int(time.time()))
    entries = store["package_file_cache"].get(package, [])
    for _, already_cached_path, __ in entries:
        if already_cached_path == path_to_built_pkg:
            output.print_debug(
                f"Trying to cache {package} version {version}, but the version is already cached: "
                f"{already_cached_path}"
            )
            return
    entries.append(new_entry)

    store["package_file_cache"][package] = entries
    clean_package_cache(store, package)


def clean_package_cache(store: _store.Store, package: str):
    oldest_path = None
    oldest_timestamp = None
    index_of_oldest = None

    entries = store["package_file_cache"][package]
    output.print_debug(f"Package cache has {len(entries)} entries.")

    number_of_packages_stored_in_cache = 3

    if len(entries) <= number_of_packages_stored_in_cache:
        output.print_debug("Old files will not be removed.")
        return

    for index, entry in enumerate(entries):
        _, path, timestamp = entry
        if oldest_timestamp is None or oldest_timestamp > timestamp:
            oldest_timestamp = timestamp
            oldest_path = path
            index_of_oldest = index

    output.print_debug(f"Oldest cached file for {package} is '{oldest_path}'.")
    if oldest_path is None:
        return
    assert index_of_oldest is not None

    entries.pop(index_of_oldest)
    if os.path.exists(oldest_path):
        output.print_debug(f"Removing '{oldest_path}' from the package cache.")
        try:
            os.remove(oldest_path)
        except OSError as e:
            output.print_error(f"Failed to remove file '{oldest_path}' from the package cache.")
            output.print_error(e.strerror or str(e))
            output.print_error("You'll have to remove the file manually.")

    store["package_file_cache"][package] = entries


def is_devel(package: str) -> bool:
    """
    Returns True if the given package is a devel package.
    """
    devel_suffixes = [
        "-git",
        "-hg",
        "-bzr",
        "-svn",
        "-cvs",
        "-darcs",
    ]
    for suffix in devel_suffixes:
        if package.endswith(suffix):
            return True
    return False


class ResolvedDependencies:
    """
    Result of dependency resolution.
    """

    def __init__(self) -> None:
        self.pacman_deps: set[str] = set()
        self.foreign_pkgs: set[str] = set()
        self.foreign_dep_pkgs: set[str] = set()
        self.foreign_build_dep_pkgs: set[str] = set()
        self.build_order: list[str] = []
        self.packages: dict[str, ForeignPackage] = {}
        # maps dependency names to package names
        self.providers: dict[str, list[str]] = {}
        self.all_provided: set[str] = set()
        self._pkgbases_to_pkgs: dict[str, set[str]] = {}
        self._pkgs_to_pkgbases: dict[str, str] = {}

    def add_pkgbase_info(self, pkgname: str, pkgbase: str):
        """
        Adds information about a which package belongs in which package base.
        """
        pkgs = self._pkgbases_to_pkgs.get(pkgbase, set())
        pkgs.add(pkgname)
        self._pkgbases_to_pkgs[pkgbase] = pkgs
        self._pkgs_to_pkgbases[pkgname] = pkgbase

    def get_pkgbase(self, pkgname: str) -> str:
        """
        Returns the package base of an package.
        """
        return self._pkgs_to_pkgbases[pkgname]

    def get_pkgs_with_common_pkgbase(self, pkgname: str) -> set[str]:
        """
        Returns all packages that have the same package base as the given package.
        """
        pkgbase = self._pkgs_to_pkgbases[pkgname]
        return self._pkgbases_to_pkgs[pkgbase]

    def all_pkgbases(self) -> list[str]:
        """
        Returns all pkgbases.
        """
        return list(self._pkgbases_to_pkgs)

    def get_some_pkgname(self, pkgbase: str) -> str:
        """
        Returns some package name that the given pkgbase has.
        """
        return list(self._pkgbases_to_pkgs[pkgbase])[0]


class ForeignPackageManager:
    """
    Class for dealing with foreign packages.
    """

    def __init__(
        self,
        store: _store.Store,
        pacman: AurPacmanInterface,
        search: PackageSearch,
        commands: AurCommands,
        pkg_cache_dir: str,
        build_dir: str,
        makepkg_user: str,
    ):
        self._store = store
        self._pacman = pacman
        self._search = search
        self._commands = commands
        self._pkg_cache_dir = pkg_cache_dir
        self._build_dir = build_dir
        self._makepkg_user = makepkg_user

    def upgrade(
        self,
        upgrade_devel: bool = False,
        force: bool = False,
        ignored_pkgs: typing.Optional[set[str]] = None,
    ):
        """
        Upgrades all foreign packages.
        """
        if ignored_pkgs is None:
            ignored_pkgs = set()

        output.print_info("Determining foreign packages to upgrade.")

        all_foreign_pkgs = self._pacman.get_versioned_foreign_packages()
        all_explicit_foreign_pkgs = set(self._pacman.get_foreign_explicit())
        output.print_debug(f"Foreign packages to check for upgrades: {all_foreign_pkgs}")

        self._search.try_caching_packages(list(map(lambda p: p[0], all_foreign_pkgs)))

        as_explicit = []
        as_deps = []
        for pkg, ver in all_foreign_pkgs:
            if pkg in ignored_pkgs:
                continue

            info = self._search.get_package_info(pkg)
            if info is None:
                raise ForeignPackageManagerError(
                    f"Failed to find '{pkg}' from AUR or user provided packages."
                )

            if self.should_upgrade_package(pkg, ver, info.version, upgrade_devel):
                if pkg in all_explicit_foreign_pkgs:
                    as_explicit.append(pkg)
                else:
                    as_deps.append(pkg)

        output.print_debug(
            f"The following foreign packages will be upgraded: {' '.join(as_explicit)}"
        )

        self.install(as_explicit, as_deps, force)

    def install(
        self,
        foreign_pkgs: list[str],
        foreign_dep_pkgs: typing.Optional[list[str]] = None,
        force: bool = False,
    ):
        """
        Installs the given foreign packages and their dependencies (both pacman/AUR).
        """

        if foreign_dep_pkgs is None:
            foreign_dep_pkgs = []

        if len(foreign_pkgs) == 0 and len(foreign_dep_pkgs) == 0:
            return

        resolved_dependencies = self.resolve_dependencies(foreign_pkgs, foreign_dep_pkgs)

        output.print_list(
            "The following foreign packages will be installed explicitly:",
            sorted(resolved_dependencies.foreign_pkgs),
        )

        output.print_list(
            "The following foreign packages will be installed as dependencies:",
            sorted(resolved_dependencies.foreign_dep_pkgs),
        )

        output.print_list(
            "The following foreign packages will be built in order to install other packages. "
            "They will not be installed:",
            sorted(resolved_dependencies.foreign_build_dep_pkgs),
        )

        if not output.prompt_confirm("Proceed?", default=True):
            raise ForeignPackageManagerError("Installing aborted by the user.")

        needed_pacman_deps = self._pacman.filter_installed_packages(
            resolved_dependencies.pacman_deps - resolved_dependencies.all_provided
        )
        output.print_summary("Installing foreign package dependencies from pacman.")
        self._pacman.install_dependencies(needed_pacman_deps)

        try:
            with PackageBuilder(
                self._search,
                self._store,
                self._pacman,
                resolved_dependencies,
                self._commands,
                self._pkg_cache_dir,
                self._build_dir,
                self._makepkg_user,
            ) as builder:
                while resolved_dependencies.build_order:
                    to_build = resolved_dependencies.build_order.pop(0)

                    pkgbase = resolved_dependencies.get_pkgbase(to_build)
                    package_names = resolved_dependencies.get_pkgs_with_common_pkgbase(to_build)

                    packages = [
                        resolved_dependencies.packages[pkgname] for pkgname in package_names
                    ]

                    builder.build_packages(pkgbase, packages, force)
        except OSError as e:
            raise ForeignPackageManagerError("Failed to build packages.") from e

        packages_to_install = resolved_dependencies.foreign_pkgs
        packages_to_install |= resolved_dependencies.foreign_dep_pkgs

        package_files_to_install = []
        for pkg in packages_to_install:
            built_pkg = find_latest_cached_package(self._store, pkg)
            assert built_pkg is not None
            _, path = built_pkg
            package_files_to_install.append(path)

        if package_files_to_install or force:
            output.print_summary("Installing foreign packages.")
            self._pacman.install_files(
                package_files_to_install,
                as_explicit=resolved_dependencies.foreign_pkgs
                - resolved_dependencies.foreign_dep_pkgs,
            )
        else:
            output.print_summary("No packages to install.")

    def resolve_dependencies(
        self,
        foreign_pkgs: list[str],
        foreign_dep_pkgs: typing.Optional[list[str]] = None,
    ) -> ResolvedDependencies:
        """
        Resolves foreign dependencies of foreign packages.
        """

        output.print_info("Resolving foreign package dependencies.")
        output.print_debug(f"Packages: {foreign_pkgs}")

        if foreign_dep_pkgs is None:
            foreign_dep_pkgs = []

        result = ResolvedDependencies()
        result.foreign_pkgs = set(foreign_pkgs)
        result.foreign_dep_pkgs = set(foreign_dep_pkgs)

        graph = DepGraph()

        for name in foreign_pkgs + foreign_dep_pkgs:
            graph.add_requirement(name, None)

        seen_packages = set(foreign_pkgs + foreign_dep_pkgs)
        to_process = foreign_pkgs + foreign_dep_pkgs
        total_processed = 0

        self._search.try_caching_packages(to_process)

        def process_dep(pkgname: str, depname: str, add_to: set[str]):
            dep_info = self._search.find_provider(depname)

            if dep_info is None:
                raise ForeignPackageManagerError(
                    f"Failed to find '{depname}' from AUR or user provided packages."
                )

            add_to.add(dep_info.pkgname)

            output.print_debug(f"Adding dependency {dep_info.pkgname} to package {pkgname}.")
            graph.add_requirement(dep_info.pkgname, pkgname)
            if dep_info.pkgname not in seen_packages:
                to_process.append(dep_info.pkgname)
                seen_packages.add(dep_info.pkgname)

        while to_process:
            pkgname = to_process.pop()

            info = self._search.get_package_info(pkgname)
            if info is None:
                raise ForeignPackageManagerError(
                    f"Failed to find '{pkgname}' from AUR or user provided packages."
                )

            for provided in info.provides:
                result.providers.setdefault(provided, []).append(pkgname)
                result.all_provided.add(provided)

            result.pacman_deps.update(info.native_dependencies(self._pacman))
            result.add_pkgbase_info(pkgname, info.pkgbase)

            build_deps = info.foreign_make_dependencies(
                self._pacman
            ) + info.foreign_check_dependencies(self._pacman)

            self._search.try_caching_packages(info.foreign_dependencies(self._pacman) + build_deps)

            for depname in info.foreign_dependencies(self._pacman):
                process_dep(pkgname, depname, result.foreign_dep_pkgs)

            for depname in build_deps:
                process_dep(pkgname, depname, result.foreign_build_dep_pkgs)

            total_processed += 1
            output.print_info(f"Progress: {total_processed}/{len(seen_packages)}.")

        output.print_info("Determining build order.")

        while True:
            to_add = graph.get_and_remove_outer_dep_pkgs()

            if len(to_add) == 0:
                break

            for pkg in to_add:
                if pkg not in result.packages:
                    output.print_debug(f"Adding {pkg} to build_order.")
                    result.build_order.append(pkg.name)
                    result.packages[pkg.name] = pkg

        return result

    def should_upgrade_package(
        self,
        package: str,
        installed_version: str,
        fetched_version: str,
        upgrade_devel=False,
    ) -> bool:
        """
        Returns True if a package should be upgraded.
        """

        if upgrade_devel and is_devel(package):
            output.print_debug(f"Package {package} is devel package. It should be upgraded.")
            return True

        try:
            cmd = self._commands.compare_versions(installed_version, fetched_version)
            vercmp_output = command.prg(cmd, pty=False)
            should_upgrade = int(vercmp_output) < 0

            output.print_debug(
                f"Installed version is: {installed_version}. "
                f"Available version is {fetched_version}. Should upgrade: {should_upgrade}."
            )
            return should_upgrade
        except (ValueError, errors.CommandFailedError) as error:
            output.print_error(f"{error}")
            raise ForeignPackageManagerError("Failed to compare versions using vercmp.") from error


class PackageBuilder:
    """
    Used for building packages in a chroot.
    """

    always_included_packages = ["base-devel", "git"]

    def __init__(
        self,
        search: PackageSearch,
        store: _store.Store,
        pacman: AurPacmanInterface,
        resolved_deps: ResolvedDependencies,
        commands: AurCommands,
        pkg_cache_dir: str,
        build_dir: str,
        makepkg_user: str,
    ):
        self._search = search
        self._store = store
        self._pacman = pacman
        self._resolved_deps = resolved_deps
        self._commands = commands
        self.pkg_cache_dir = pkg_cache_dir
        self.build_dir = build_dir
        self.makepkg_user = makepkg_user
        self.valid_pkgexts = [
            ".pkg.tar",
            ".pkg.tar.gz",
            ".pkg.tar.bz2",
            ".pkg.tar.xz",
            ".pkg.tar.zst",
            ".pkg.tar.lzo",
            ".pkg.tar.lrz",
            ".pkg.tar.lz4",
            ".pkg.tar.lz",
            ".pkg.tar.Z",
        ]
        self.chroot_wd_dir = os.path.join(build_dir, "chroot")
        self.chroot_dir = os.path.join(self.chroot_wd_dir, "root")
        self.pkgbase_dir_map: dict[str, str] = {}
        self.original_wd = ""
        self._pkgs_in_chroot = set(PackageBuilder.always_included_packages)
        self._pkgs_in_chroot.update(resolved_deps.pacman_deps)

    def __enter__(self):
        self.store_wd()
        self.create_build_environment()

        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self.restore_wd()
        self.remove_build_environment()

    def store_wd(self):
        """
        Remembers the current working directory as the original working directory.
        """
        self.original_wd = os.getcwd()

    def restore_wd(self):
        """
        Returns to the original working directory.
        """
        os.chdir(self.original_wd)

    def create_build_environment(self):
        """
        Creates a new chroot and clones all PKGBUILDS.
        """
        output.print_info("Creating a build environment..")

        if os.path.exists(self.build_dir):
            output.print_info("Removing previous build directory.")
            self.remove_build_environment()

        output.print_info("Getting all PKGBUILDS.")

        # Set up PKGBUILDS
        for pkgbase in self._resolved_deps.all_pkgbases():
            pkgbuild_dir = os.path.join(self.build_dir, pkgbase)
            self.pkgbase_dir_map[pkgbase] = pkgbuild_dir
            os.makedirs(pkgbuild_dir)
            os.chdir(pkgbuild_dir)

            pkgbase_info = self._search.get_package_info(
                self._resolved_deps.get_some_pkgname(pkgbase)
            )

            assert pkgbase_info is not None, (
                "All dependencies and packages should be resolved "
                "during the creation of ResolvedDependencies."
            )

            output.print_debug(f"Git URL for '{pkgbase}' is '{pkgbase_info.git_url}'")
            output.print_debug(
                f"PKGBUILD directory for '{pkgbase}' is '{pkgbase_info.pkgbuild_directory}'"
            )
            self._fetch_and_review_pkgbuild(
                pkgbase, pkgbase_info.git_url, pkgbase_info.pkgbuild_directory
            )
            shutil.chown(pkgbuild_dir, user=self.makepkg_user)

        output.print_info("Creating a new chroot.")
        os.makedirs(self.chroot_wd_dir)

        # Remove GNUPGHOME from mkarchroot environment variables since it may interfere with
        # the chroot creation
        mkarchroot_env_vars = os.environ.copy()
        try:
            del mkarchroot_env_vars["GNUPGHOME"]
            output.print_debug("Removed GNUPGHOME variable from mkarchroot environment.")
        except KeyError:
            pass

        cmd = self._commands.make_chroot(self.chroot_dir, self._pkgs_in_chroot)
        command.prg(
            cmd, env_overrides=mkarchroot_env_vars, pass_environment=False, pty=config.debug_output
        )

    def remove_build_environment(self):
        """
        Deletes the build environment.
        """
        shutil.rmtree(self.build_dir)

    def build_packages(self, package_base: str, packages: list[ForeignPackage], force: bool):
        """
        Builds package(s) with the same package base.

        Set force to true to force rebuilds of packages that are already cached
        """

        package_names = list(map(lambda p: p.name, packages))

        # Rebuild is only needed if at least one package is not in the cache.

        if self._are_all_pkgs_cached(packages) and not force:
            output.print_info(f"Skipped building '{' '.join(package_names)}'. Already up to date.")
            return

        output.print_info(f"Building '{' '.join(package_names)}'.")

        chroot_new_pacman_pkgs, chroot_pkg_files = self._get_chroot_packages(packages)

        pkgbuild_dir = self.pkgbase_dir_map[package_base]
        os.chdir(pkgbuild_dir)

        output.print_debug(f"Chroot dir is: '{self.chroot_dir}', pkgbuild dir is '{pkgbuild_dir}'.")

        output.print_info("Installing build dependencies to chroot.")

        cmd = self._commands.install_chroot(
            self.chroot_dir, chroot_new_pacman_pkgs + PackageBuilder.always_included_packages
        )
        command.prg(cmd, pty=config.debug_output)
        output.print_info("Making package.")

        cmd = self._commands.make_chroot_pkg(
            self.chroot_wd_dir, self.makepkg_user, chroot_pkg_files
        )
        command.prg(cmd)

        for pkgname in package_names:
            file = self._find_pkgfile(pkgname, pkgbuild_dir)

            dest = shutil.copy(file, self.pkg_cache_dir)

            pkg_info = self._search.get_package_info(pkgname)

            # Because all dependencies and packages should be resolved during the creation
            # of ResolvedDependencies.
            assert pkg_info is not None
            version = pkg_info.version

            output.print_debug(
                f"Adding '{pkgname}', version: '{version}' to cache as file '{dest}'."
            )

            add_package_to_cache(self._store, pkgname, version, dest)

        if len(chroot_new_pacman_pkgs) != 0:
            to_remove = set()
            for p in chroot_new_pacman_pkgs:
                if p not in self._pkgs_in_chroot:
                    cmd = self._commands.resolve_real_name_chroot(self.chroot_dir, p)
                    _, cmd_output = command.check_run_result(cmd, command.run(cmd))
                    real_pkgname = cmd_output.strip()
                    to_remove.add(real_pkgname)

            if to_remove:
                output.print_info("Removing build dependencies from chroot.")
                cmd = self._commands.remove_chroot(self.chroot_dir, to_remove)
                command.prg(cmd, pty=config.debug_output)
            else:
                output.print_debug("No build dependencies to remove from chroot.")

        output.print_info(f"Finished building: '{' '.join(package_names)}'.")

    def _are_all_pkgs_cached(self, pkgs: list[ForeignPackage]) -> bool:
        for pkg in pkgs:
            cache_entry = find_latest_cached_package(self._store, pkg.name)
            if cache_entry is None:
                return False
            cached_version, _ = cache_entry

            pkg_info = self._search.get_package_info(pkg.name)

            # Because all dependencies and packages should be resolved during the creation
            # of ResolvedDependencies. git_url should not be None.
            assert pkg_info is not None
            fetched_version = pkg_info.version

            if cached_version != fetched_version or is_devel(pkg.name):
                return False
        return True

    def _get_chroot_packages(
        self, pkgs_to_build: list[ForeignPackage]
    ) -> tuple[list[str], list[str]]:
        """
        Returns a tuple of pacman build dependencies and built foreign pkgs files that are needed
        in the chroot before building. pkgs_to_build share the same pkgbase.
        """
        chroot_pacman_build_deps = set()
        chroot_foreign_pkgs = set()

        def add_to_pacman_build_deps(deps: list[str]):
            for dep in deps:
                if dep not in self._resolved_deps.pacman_deps:
                    chroot_pacman_build_deps.add(dep)

        for pkg in pkgs_to_build:
            info = self._search.get_package_info(pkg.name)
            # Because all dependencies and packages should be resolved during the creation
            # of ResolvedDependencies. git_url should not be None.
            assert info is not None

            add_to_pacman_build_deps(info.native_make_dependencies(self._pacman))
            add_to_pacman_build_deps(info.native_check_dependencies(self._pacman))

            foreign_deps = pkg.get_all_recursive_foreign_dep_pkgs()
            chroot_foreign_pkgs.update(foreign_deps)

            # Add pacman deps of foreign packages
            for dep in foreign_deps:
                dep_info = self._search.get_package_info(dep)
                # Because all dependencies and packages should be resolved during the creation
                # of ResolvedDependencies. git_url should not be None.
                assert dep_info is not None

                add_to_pacman_build_deps(dep_info.native_make_dependencies(self._pacman))
                add_to_pacman_build_deps(dep_info.native_check_dependencies(self._pacman))

        # Packages with the same pkgbase might depend on each other,
        # but they don't need to be installed for the build to succeed.
        for pkg in pkgs_to_build:
            if pkg.name in chroot_foreign_pkgs:
                chroot_foreign_pkgs.remove(pkg.name)

        chroot_foreign_pkg_files = []

        for foreign_pkg in chroot_foreign_pkgs:
            entry = find_latest_cached_package(self._store, foreign_pkg)
            assert entry is not None, (
                "Build order determines that the dependencies are built "
                "before and thus are found in the cache."
            )

            _, file = entry

            chroot_foreign_pkg_files.append(file)

        return (list(chroot_pacman_build_deps), chroot_foreign_pkg_files)

    def _find_pkgfile(self, pkgname: str, pkgbuild_dir: str) -> str:
        # HACK: Because we don't know the pkgarch we can't be sure what is the build result.
        # Instead: we just try with pre- and postfixes.

        matches = []

        info = self._search.get_package_info(pkgname)
        assert info is not None
        prefix = info.pkg_file_prefix()

        for file in os.scandir(pkgbuild_dir):
            if file.is_file() and file.name.startswith(prefix):
                for ext in self.valid_pkgexts:
                    if file.name.endswith(ext):
                        matches.append(file.path)
                        continue

        if len(matches) != 1:
            raise ForeignPackageManagerError(
                f"Failed to build package '{pkgname}', because the pkg file cannot be determined. "
                f"Possible files are: {matches}"
            )

        return matches[0]

    def _fetch_and_review_pkgbuild(
        self, pkgbase: str, git_url: str | None, pkgbuild_directory: str | None
    ):
        """
        Fetches a PKGBUILD to the current directory.

        PKGBUILD will be cloned using git if ``git_url`` is set.
        PKGBUILD will be copied from ``pkgbuild_directory`` if it is set.

        The user is prompted to review the PKGBUILD and confirm if the package should be built.
        """

        self._store.ensure("pkgbuild_latest_reviewed_commits", {})

        if git_url:
            cmd = self._commands.git_clone(git_url, ".")
            command.prg(cmd, pty=config.debug_output)

        if pkgbuild_directory:
            try:
                shutil.copytree(pkgbuild_directory, ".", dirs_exist_ok=True)

                # Chmod to 755 to allow reading files
                mode = 0o755
                for root, dirs, files in os.walk("."):
                    for name in dirs + files:
                        os.chmod(os.path.join(root, name), mode)
                os.chmod(".", mode)
            except OSError as error:
                raise ForeignPackageManagerError(f"Failed to copy {pkgbuild_directory}.") from error

        if output.prompt_confirm(f"Review PKGBUILD or show diff for {pkgbase}?", default=True):
            latest_reviewed_commit = None
            git_commit_ids = []

            if git_url:
                latest_reviewed_commit = self._store["pkgbuild_latest_reviewed_commits"].get(
                    pkgbase
                )

                cmd = self._commands.git_log_commit_ids()
                git_output = command.prg(cmd, pty=False)
                git_commit_ids = git_output.strip().split("\n")

            if latest_reviewed_commit is None or latest_reviewed_commit not in git_commit_ids:
                try:
                    for file in os.scandir("."):
                        if file.is_file() and not file.name.startswith("."):
                            cmd = self._commands.review_file(file.path)
                            command.prg(cmd)
                except OSError as error:
                    raise ForeignPackageManagerError(
                        f"Failed to review files in directory for {pkgbase}."
                    ) from error

            else:
                cmd = self._commands.git_diff(latest_reviewed_commit)
                command.prg(cmd)

        if output.prompt_confirm("Build this package?", default=True):
            cmd = self._commands.git_get_commit_id()
            rc, git_output = command.run(cmd)
            if rc == 0:
                commit_id = git_output.strip()
                self._store["pkgbuild_latest_reviewed_commits"][pkgbase] = commit_id
            else:
                output.print_debug(
                    f"{pkgbase} is not in a git repository. Commit ID cannot be saved."
                )
        else:
            raise ForeignPackageManagerError("Building aborted.")


================================================
FILE: plugins/decman-pacman/src/decman/plugins/aur/package.py
================================================
import dataclasses
import os
import pathlib
import shutil
import tempfile

import decman.plugins.pacman as pacman_module
import requests  # type: ignore
from decman.plugins.aur.commands import AurCommands, AurPacmanInterface
from decman.plugins.aur.error import AurRPCError, PKGBUILDParseError

import decman.config as config
import decman.core.command as command
import decman.core.error as errors
import decman.core.output as output


@dataclasses.dataclass(frozen=True, slots=True)
class PackageInfo:
    """
    Immutable description of a package to be built or installed.

    This class represents *resolved* package metadata and is intended to be
    passed around as pure data.

    Exactly one source must be specified:
    - ``git_url`` for VCS-based (e.g. AUR) packages
    - ``pkgbuild_directory`` for local PKGBUILD-based packages

    Invariants:
    - ``pkgname`` uniquely identifies the package.
    - ``pkgbase`` groups split packages.
    - Exactly one of ``git_url`` or ``pkgbuild_directory`` is set.
    - All dependency containers are immutable.

    This object is safe for hashing, set membership, and reuse across runs.
    """

    pkgname: str
    pkgbase: str
    version: str

    git_url: str | None = None
    pkgbuild_directory: str | None = None
    provides: tuple[str, ...] = dataclasses.field(default_factory=tuple)
    dependencies: tuple[str, ...] = dataclasses.field(default_factory=tuple)
    make_dependencies: tuple[str, ...] = dataclasses.field(default_factory=tuple)
    check_dependencies: tuple[str, ...] = dataclasses.field(default_factory=tuple)

    # Caches (excluded from eq/hash)
    _native_dependencies: tuple[str, ...] | None = dataclasses.field(
        default=None, init=False, repr=False, compare=False
    )
    _foreign_dependencies: tuple[str, ...] | None = dataclasses.field(
        default=None, init=False, repr=False, compare=False
    )

    _native_make_dependencies: tuple[str, ...] | None = dataclasses.field(
        default=None, init=False, repr=False, compare=False
    )
    _foreign_make_dependencies: tuple[str, ...] | None = dataclasses.field(
        default=None, init=False, repr=False, compare=False
    )

    _native_check_dependencies: tuple[str, ...] | None = dataclasses.field(
        default=None, init=False, repr=False, compare=False
    )
    _foreign_check_dependencies: tuple[str, ...] | None = dataclasses.field(
        default=None, init=False, repr=False, compare=False
    )

    def __post_init__(self) -> None:
        if self.git_url is None and self.pkgbuild_directory is None:
            raise ValueError("Both git_url and pkgbuild_directory cannot be None.")

        if self.git_url is not None and self.pkgbuild_directory is not None:
            raise ValueError("Both git_url and pkgbuild_directory cannot be set.")

    def pkg_file_prefix(self) -> str:
        """
        Returns the beginning of the file created from building this package.
        """
        return f"{self.pkgname}-{self.version}"

    # --- public API ---------------------------------------------------------

    def foreign_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
        """
        Returns a list of foreign dependencies of this package.

        The dependencies are stripped of their version constraints if there are any.
        """
        self._ensure_dependencies_cached(pacman)
        assert self._foreign_dependencies is not None
        return list(self._foreign_dependencies)

    def foreign_make_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
        """
        Returns a list of foreign make dependencies of this package.

        The dependencies are stripped of their version constraints if there are any.
        """
        self._ensure_make_dependencies_cached(pacman)
        assert self._foreign_make_dependencies is not None
        return list(self._foreign_make_dependencies)

    def foreign_check_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
        """
        Returns a list of foreign check dependencies of this package.

        The dependencies are stripped of their version constraints if there are any.
        """
        self._ensure_check_dependencies_cached(pacman)
        assert self._foreign_check_dependencies is not None
        return list(self._foreign_check_dependencies)

    def native_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
        """
        Returns a list of native dependencies of this package.

        The dependencies are stripped of their version constraints if there are any.
        """
        self._ensure_dependencies_cached(pacman)
        assert self._native_dependencies is not None
        return list(self._native_dependencies)

    def native_make_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
        """
        Returns a list of native make dependencies of this package.

        The dependencies are stripped of their version constraints if there are any.
        """
        self._ensure_make_dependencies_cached(pacman)
        assert self._native_make_dependencies is not None
        
Download .txt
gitextract_6m5oxmcb/

├── .gitignore
├── DEVELOPMENT.md
├── LICENSE
├── README.md
├── completions/
│   ├── _decman
│   ├── decman.bash
│   └── decman.fish
├── docs/
│   ├── README.md
│   ├── aur.md
│   ├── extras.md
│   ├── flatpak.md
│   ├── migrate-to-v1.md
│   ├── pacman.md
│   └── systemd.md
├── example/
│   ├── README.md
│   ├── base.py
│   ├── files/
│   │   ├── mkinitcpio.conf
│   │   └── vimrc
│   ├── kde.py
│   ├── plugin/
│   │   ├── decman_plugin_example.py
│   │   └── pyproject.toml
│   └── source.py
├── plugins/
│   ├── decman-flatpak/
│   │   ├── pyproject.toml
│   │   └── src/
│   │       └── decman/
│   │           └── plugins/
│   │               └── flatpak.py
│   ├── decman-pacman/
│   │   ├── pyproject.toml
│   │   ├── src/
│   │   │   └── decman/
│   │   │       └── plugins/
│   │   │           ├── aur/
│   │   │           │   ├── __init__.py
│   │   │           │   ├── commands.py
│   │   │           │   ├── error.py
│   │   │           │   ├── fpm.py
│   │   │           │   ├── package.py
│   │   │           │   └── resolver.py
│   │   │           └── pacman.py
│   │   └── tests/
│   │       ├── test_decman_plugins_aur.py
│   │       ├── test_decman_plugins_aur_package.py
│   │       ├── test_decman_plugins_aur_resolver.py
│   │       ├── test_decman_plugins_pacman.py
│   │       ├── test_deep_orphan_removal.py
│   │       └── test_fpm.py
│   └── decman-systemd/
│       ├── pyproject.toml
│       ├── src/
│       │   └── decman/
│       │       └── plugins/
│       │           └── systemd.py
│       └── tests/
│           └── test_decman_plugins_systemd.py
├── pyproject.toml
├── src/
│   └── decman/
│       ├── __init__.py
│       ├── app.py
│       ├── config.py
│       ├── core/
│       │   ├── __init__.py
│       │   ├── command.py
│       │   ├── error.py
│       │   ├── file_manager.py
│       │   ├── fs.py
│       │   ├── module.py
│       │   ├── output.py
│       │   └── store.py
│       ├── extras/
│       │   ├── __init__.py
│       │   ├── gpg.py
│       │   └── users.py
│       ├── plugins/
│       │   └── __init__.py
│       └── py.typed
└── tests/
    ├── test_decman_app.py
    ├── test_decman_core_command.py
    ├── test_decman_core_file_manager.py
    ├── test_decman_core_fs.py
    ├── test_decman_core_module.py
    ├── test_decman_core_output.py
    ├── test_decman_core_store.py
    ├── test_decman_init.py
    └── test_decman_plugins.py
Download .txt
SYMBOL INDEX (607 symbols across 40 files)

FILE: example/base.py
  class BaseModule (line 5) | class BaseModule(decman.Module):
    method __init__ (line 6) | def __init__(self):
    method pkgs (line 12) | def pkgs(self) -> set[str]:
    method aurpkgs (line 26) | def aurpkgs(self) -> set[str]:
    method files (line 29) | def files(self) -> dict[str, decman.File]:
    method on_change (line 32) | def on_change(self, store):

FILE: example/kde.py
  class KDE (line 5) | class KDE(decman.Module):
    method __init__ (line 6) | def __init__(self):
    method pkgs (line 10) | def pkgs(self) -> set[str]:
    method units (line 18) | def units(self) -> set[str]:

FILE: example/plugin/decman_plugin_example.py
  class Example (line 6) | class Example(decman.Plugin):
    method available (line 9) | def available(self) -> bool:
    method process_modules (line 12) | def process_modules(self, store: decman.Store, modules: list[decman.Mo...
    method apply (line 17) | def apply(

FILE: plugins/decman-flatpak/src/decman/plugins/flatpak.py
  function packages (line 11) | def packages(fn):
  function user_packages (line 21) | def user_packages(fn):
  class Flatpak (line 32) | class Flatpak(plugins.Plugin):
    method __init__ (line 40) | def __init__(self) -> None:
    method available (line 46) | def available(self) -> bool:
    method process_modules (line 49) | def process_modules(self, store: _store.Store, modules: list[module.Mo...
    method apply (line 87) | def apply(
    method apply_packages (line 110) | def apply_packages(
  class FlatpakCommands (line 139) | class FlatpakCommands:
    method list_apps (line 140) | def list_apps(self, as_user: bool) -> list[str]:
    method install (line 157) | def install(self, pkgs: set[str], as_user: bool) -> list[str]:
    method upgrade (line 170) | def upgrade(self, as_user: bool) -> list[str]:
    method remove (line 182) | def remove(self, pkgs: set[str], as_user: bool) -> list[str]:
    method remove_unused (line 195) | def remove_unused(self, as_user: bool) -> list[str]:
  class FlatpakInterface (line 209) | class FlatpakInterface:
    method __init__ (line 216) | def __init__(self, commands: FlatpakCommands) -> None:
    method get_apps (line 219) | def get_apps(self, user: str | None = None) -> set[str]:
    method install (line 239) | def install(self, packages: set[str], user: str | None = None):
    method upgrade (line 253) | def upgrade(self, user: str | None = None):
    method remove (line 263) | def remove(self, packages: set[str], user: str | None = None):

FILE: plugins/decman-pacman/src/decman/plugins/aur/__init__.py
  function packages (line 32) | def packages(fn):
  function custom_packages (line 42) | def custom_packages(fn):
  class AUR (line 52) | class AUR(plugins.Plugin):
    method __init__ (line 64) | def __init__(self) -> None:
    method available (line 86) | def available(self) -> bool:
    method process_modules (line 93) | def process_modules(self, store: _store.Store, modules: list[module.Mo...
    method apply (line 128) | def apply(

FILE: plugins/decman-pacman/src/decman/plugins/aur/commands.py
  class AurCommands (line 7) | class AurCommands(pacman.PacmanCommands):
    method install_as_dependencies (line 8) | def install_as_dependencies(self, pkgs: set[str]) -> list[str]:
    method install_files_as_dependencies (line 15) | def install_files_as_dependencies(self, pkg_files: list[str]) -> list[...
    method compare_versions (line 21) | def compare_versions(self, installed_version: str, new_version: str) -...
    method git_clone (line 27) | def git_clone(self, repo: str, dest: str) -> list[str]:
    method git_diff (line 33) | def git_diff(self, from_commit: str) -> list[str]:
    method git_get_commit_id (line 40) | def git_get_commit_id(self) -> list[str]:
    method git_log_commit_ids (line 46) | def git_log_commit_ids(self) -> list[str]:
    method review_file (line 52) | def review_file(self, file: str) -> list[str]:
    method make_chroot (line 58) | def make_chroot(self, chroot_dir: str, with_pkgs: set[str]) -> list[str]:
    method install_chroot (line 65) | def install_chroot(self, chroot_dir: str, packages: list[str]):
    method resolve_real_name_chroot (line 78) | def resolve_real_name_chroot(self, chroot_dir: str, pkg: str) -> list[...
    method remove_chroot (line 92) | def remove_chroot(self, chroot_dir: str, packages: set[str]):
    method make_chroot_pkg (line 98) | def make_chroot_pkg(
    method print_srcinfo (line 113) | def print_srcinfo(self) -> list[str]:
  class AurPacmanInterface (line 121) | class AurPacmanInterface(pacman.PacmanInterface):
    method __init__ (line 128) | def __init__(
    method get_foreign_orphans (line 140) | def get_foreign_orphans(self) -> set[str]:
    method is_provided_by_installed (line 146) | def is_provided_by_installed(self, dependency: str) -> bool:
    method filter_installed_packages (line 149) | def filter_installed_packages(self, deps: set[str]) -> set[str]:
    method is_installable (line 156) | def is_installable(self, pkg: str) -> bool:
    method get_versioned_foreign_packages (line 165) | def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
    method install_dependencies (line 176) | def install_dependencies(self, deps: set[str]):
    method install_files (line 187) | def install_files(self, files: list[str], as_explicit: set[str]):

FILE: plugins/decman-pacman/src/decman/plugins/aur/error.py
  class ForeignPackageManagerError (line 1) | class ForeignPackageManagerError(Exception):
  class DependencyCycleError (line 7) | class DependencyCycleError(Exception):
    method __init__ (line 12) | def __init__(self, package1: str, package2: str):
  class PKGBUILDParseError (line 20) | class PKGBUILDParseError(Exception):
    method __init__ (line 25) | def __init__(self, git_url: str | None, pkgbuild_directory: str | None...
  class AurRPCError (line 32) | class AurRPCError(Exception):
    method __init__ (line 37) | def __init__(self, message: str, url: str):

FILE: plugins/decman-pacman/src/decman/plugins/aur/fpm.py
  function find_latest_cached_package (line 18) | def find_latest_cached_package(store: _store.Store, package: str) -> tup...
  function add_package_to_cache (line 48) | def add_package_to_cache(store: _store.Store, package: str, version: str...
  function clean_package_cache (line 69) | def clean_package_cache(store: _store.Store, package: str):
  function is_devel (line 108) | def is_devel(package: str) -> bool:
  class ResolvedDependencies (line 126) | class ResolvedDependencies:
    method __init__ (line 131) | def __init__(self) -> None:
    method add_pkgbase_info (line 144) | def add_pkgbase_info(self, pkgname: str, pkgbase: str):
    method get_pkgbase (line 153) | def get_pkgbase(self, pkgname: str) -> str:
    method get_pkgs_with_common_pkgbase (line 159) | def get_pkgs_with_common_pkgbase(self, pkgname: str) -> set[str]:
    method all_pkgbases (line 166) | def all_pkgbases(self) -> list[str]:
    method get_some_pkgname (line 172) | def get_some_pkgname(self, pkgbase: str) -> str:
  class ForeignPackageManager (line 179) | class ForeignPackageManager:
    method __init__ (line 184) | def __init__(
    method upgrade (line 202) | def upgrade(
    method install (line 246) | def install(
    method resolve_dependencies (line 334) | def resolve_dependencies(
    method should_upgrade_package (line 427) | def should_upgrade_package(
  class PackageBuilder (line 457) | class PackageBuilder:
    method __init__ (line 464) | def __init__(
    method __enter__ (line 502) | def __enter__(self):
    method __exit__ (line 508) | def __exit__(self, exc_type, exc_value, traceback):
    method store_wd (line 512) | def store_wd(self):
    method restore_wd (line 518) | def restore_wd(self):
    method create_build_environment (line 524) | def create_build_environment(self):
    method remove_build_environment (line 578) | def remove_build_environment(self):
    method build_packages (line 584) | def build_packages(self, package_base: str, packages: list[ForeignPack...
    method _are_all_pkgs_cached (line 657) | def _are_all_pkgs_cached(self, pkgs: list[ForeignPackage]) -> bool:
    method _get_chroot_packages (line 675) | def _get_chroot_packages(
    method _find_pkgfile (line 733) | def _find_pkgfile(self, pkgname: str, pkgbuild_dir: str) -> str:
    method _fetch_and_review_pkgbuild (line 758) | def _fetch_and_review_pkgbuild(

FILE: plugins/decman-pacman/src/decman/plugins/aur/package.py
  class PackageInfo (line 19) | class PackageInfo:
    method __post_init__ (line 72) | def __post_init__(self) -> None:
    method pkg_file_prefix (line 79) | def pkg_file_prefix(self) -> str:
    method foreign_dependencies (line 87) | def foreign_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
    method foreign_make_dependencies (line 97) | def foreign_make_dependencies(self, pacman: AurPacmanInterface) -> lis...
    method foreign_check_dependencies (line 107) | def foreign_check_dependencies(self, pacman: AurPacmanInterface) -> li...
    method native_dependencies (line 117) | def native_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
    method native_make_dependencies (line 127) | def native_make_dependencies(self, pacman: AurPacmanInterface) -> list...
    method native_check_dependencies (line 137) | def native_check_dependencies(self, pacman: AurPacmanInterface) -> lis...
    method _classify_dependencies (line 150) | def _classify_dependencies(
    method _ensure_dependencies_cached (line 165) | def _ensure_dependencies_cached(self, pacman: AurPacmanInterface) -> N...
    method _ensure_make_dependencies_cached (line 173) | def _ensure_make_dependencies_cached(self, pacman: AurPacmanInterface)...
    method _ensure_check_dependencies_cached (line 181) | def _ensure_check_dependencies_cached(self, pacman: AurPacmanInterface...
  class CustomPackage (line 190) | class CustomPackage:
    method __init__ (line 209) | def __init__(
    method parse (line 222) | def parse(self, commands: AurCommands) -> PackageInfo:
    method __eq__ (line 235) | def __eq__(self, other: object) -> bool:
    method __hash__ (line 244) | def __hash__(self) -> int:
    method __str__ (line 247) | def __str__(self) -> str:
    method _srcinfo_from_pkgbuild_directory (line 254) | def _srcinfo_from_pkgbuild_directory(self, commands: AurCommands) -> str:
    method _srcinfo_from_git (line 293) | def _srcinfo_from_git(self, commands: AurCommands) -> str:
    method _run_makepkg_printsrcinfo (line 326) | def _run_makepkg_printsrcinfo(self, path: pathlib.Path, commands: AurC...
    method _parse_srcinfo (line 343) | def _parse_srcinfo(self, srcinfo: str) -> PackageInfo:
  class PackageSearch (line 477) | class PackageSearch:
    method __init__ (line 484) | def __init__(self, aur_rpc_timeout: int = 30) -> None:
    method add_custom_pkg (line 491) | def add_custom_pkg(self, user_pkg: PackageInfo):
    method _cache_pkg (line 498) | def _cache_pkg(self, pkg: PackageInfo):
    method try_caching_packages (line 504) | def try_caching_packages(self, packages: list[str]):
    method get_package_info (line 566) | def get_package_info(self, package: str) -> PackageInfo | None:
    method find_provider (line 622) | def find_provider(self, stripped_dependency: str) -> PackageInfo | None:
    method _choose_provider (line 697) | def _choose_provider(

FILE: plugins/decman-pacman/src/decman/plugins/aur/resolver.py
  class ForeignPackage (line 6) | class ForeignPackage:
    method __init__ (line 11) | def __init__(self, name: str):
    method __eq__ (line 15) | def __eq__(self, value: object, /) -> bool:
    method __hash__ (line 23) | def __hash__(self) -> int:
    method __repr__ (line 26) | def __repr__(self) -> str:
    method __str__ (line 29) | def __str__(self) -> str:
    method add_foreign_dependency_packages (line 32) | def add_foreign_dependency_packages(self, package_names: typing.Iterab...
    method get_all_recursive_foreign_dep_pkgs (line 38) | def get_all_recursive_foreign_dep_pkgs(self) -> set[str]:
  class DepNode (line 45) | class DepNode:
    method __init__ (line 50) | def __init__(self, package: ForeignPackage) -> None:
    method is_pkgname_in_parents_recursive (line 55) | def is_pkgname_in_parents_recursive(self, pkgname: str) -> bool:
  class DepGraph (line 65) | class DepGraph:
    method __init__ (line 70) | def __init__(self) -> None:
    method add_requirement (line 74) | def add_requirement(self, child_pkgname: str, parent_pkgname: typing.O...
    method get_and_remove_outer_dep_pkgs (line 100) | def get_and_remove_outer_dep_pkgs(self) -> list[ForeignPackage]:

FILE: plugins/decman-pacman/src/decman/plugins/pacman.py
  function packages (line 16) | def packages(fn):
  function strip_dependency (line 26) | def strip_dependency(dep: str) -> str:
  class Pacman (line 34) | class Pacman(plugins.Plugin):
    method __init__ (line 42) | def __init__(self) -> None:
    method available (line 58) | def available(self) -> bool:
    method process_modules (line 61) | def process_modules(self, store: _store.Store, modules: list[module.Mo...
    method apply (line 80) | def apply(
  class PacmanCommands (line 150) | class PacmanCommands:
    method list_pacman_repos (line 151) | def list_pacman_repos(self) -> list[str]:
    method install (line 157) | def install(self, pkgs: set[str]) -> list[str]:
    method upgrade (line 163) | def upgrade(self) -> list[str]:
    method set_as_dependencies (line 169) | def set_as_dependencies(self, pkgs: set[str]) -> list[str]:
    method set_as_explicit (line 175) | def set_as_explicit(self, pkgs: set[str]) -> list[str]:
    method remove (line 181) | def remove(self, pkgs: set[str]) -> list[str]:
  class PacmanInterface (line 189) | class PacmanInterface:
    method __init__ (line 196) | def __init__(
    method _create_pyalpm_handle (line 215) | def _create_pyalpm_handle(self):
    method _create_name_index (line 232) | def _create_name_index(self) -> dict[str, pyalpm.Package]:
    method _create_local_provides_index (line 235) | def _create_local_provides_index(self) -> dict[str, set[str]]:
    method _create_provides_index (line 243) | def _create_provides_index(self) -> dict[str, set[str]]:
    method _create_requiredby_index (line 252) | def _create_requiredby_index(self) -> dict[str, set[str]]:
    method _is_native (line 255) | def _is_native(self, package: str) -> bool:
    method _is_foreign (line 258) | def _is_foreign(self, package: str) -> bool:
    method get_all_packages (line 261) | def get_all_packages(self) -> set[str]:
    method get_native_explicit (line 267) | def get_native_explicit(self) -> set[str]:
    method _get_orphans (line 279) | def _get_orphans(self, filter_fn: Callable[["PacmanInterface", str], b...
    method get_native_orphans (line 296) | def get_native_orphans(self) -> set[str]:
    method get_foreign_explicit (line 302) | def get_foreign_explicit(self) -> set[str]:
    method get_dependants (line 312) | def get_dependants(self, package: str) -> set[str]:
    method set_as_dependencies (line 338) | def set_as_dependencies(self, packages: set[str]):
    method install (line 348) | def install(self, packages: set[str]):
    method upgrade (line 364) | def upgrade(self):
    method remove (line 372) | def remove(self, packages: set[str]):
    method print_highlighted_pacman_messages (line 383) | def print_highlighted_pacman_messages(self, pacman_output: str):

FILE: plugins/decman-pacman/tests/test_decman_plugins_aur.py
  class FakeStore (line 8) | class FakeStore(dict):
    method ensure (line 9) | def ensure(self, key: str, default: Any) -> None:
  class FakeModule (line 14) | class FakeModule:
    method __init__ (line 15) | def __init__(self, name: str, aur_pkgs: set[str], custom_pkgs: set[Any...
  class FakeCustomPackage (line 22) | class FakeCustomPackage:
    method __init__ (line 23) | def __init__(self, pkgname: str) -> None:
    method __hash__ (line 26) | def __hash__(self) -> int:  # needed because instances go into sets
    method __eq__ (line 29) | def __eq__(self, other: object) -> bool:
    method parse (line 32) | def parse(self, commands: Any) -> str:
  function test_process_modules_collects_aur_and_custom_packages_and_marks_changed (line 37) | def test_process_modules_collects_aur_and_custom_packages_and_marks_chan...
  function test_apply_respects_ignored_packages_and_protects_their_dependencies (line 77) | def test_apply_respects_ignored_packages_and_protects_their_dependencies(
  function test_apply_returns_false_on_aur_rpc_error (line 284) | def test_apply_returns_false_on_aur_rpc_error(monkeypatch: pytest.Monkey...

FILE: plugins/decman-pacman/tests/test_decman_plugins_aur_package.py
  function silence_output (line 14) | def silence_output(monkeypatch):
  function test_packageinfo_requires_exactly_one_source (line 28) | def test_packageinfo_requires_exactly_one_source():
  class DummyPacman (line 42) | class DummyPacman:
    method __init__ (line 43) | def __init__(self, installable: set[str]):
    method is_installable (line 47) | def is_installable(self, name: str) -> bool:
  function _make_pkg_for_deps (line 52) | def _make_pkg_for_deps() -> PackageInfo:
  function test_packageinfo_foreign_and_native_dependencies_are_split_and_stripped (line 64) | def test_packageinfo_foreign_and_native_dependencies_are_split_and_strip...
  function test_custompackage_requires_exactly_one_source (line 85) | def test_custompackage_requires_exactly_one_source():
  class DummyCommands (line 93) | class DummyCommands:
  function test_parse_srcinfo_version_handling (line 131) | def test_parse_srcinfo_version_handling(srcinfo: str, expected_version: ...
  function test_parse_srcinfo_single_package_dependencies (line 141) | def test_parse_srcinfo_single_package_dependencies() -> None:
  function test_parse_srcinfo_split_package_uses_only_target_pkg_dependencies (line 162) | def test_parse_srcinfo_split_package_uses_only_target_pkg_dependencies(m...
  function test_parse_srcinfo_arch_specific_ignored_for_other_arch (line 210) | def test_parse_srcinfo_arch_specific_ignored_for_other_arch(monkeypatch)...
  function test_parse_srcinfo_missing_required_fields_raises (line 233) | def test_parse_srcinfo_missing_required_fields_raises() -> None:
  function test_parse_srcinfo_missing_target_pkg_raises (line 257) | def test_parse_srcinfo_missing_target_pkg_raises() -> None:
  function test_srcinfo_from_pkgbuild_directory_missing_dir_raises (line 275) | def test_srcinfo_from_pkgbuild_directory_missing_dir_raises(tmp_path: pa...
  function test_srcinfo_from_pkgbuild_directory_missing_pkgbuild_raises (line 287) | def test_srcinfo_from_pkgbuild_directory_missing_pkgbuild_raises(tmp_pat...
  function test_custom_package_equality_and_hash (line 300) | def test_custom_package_equality_and_hash() -> None:
  function test_custom_package_str_git_and_directory (line 316) | def test_custom_package_str_git_and_directory() -> None:
  function _make_pkg (line 338) | def _make_pkg(name: str = "pkg") -> PackageInfo:
  function test_add_custom_pkg_caches_package (line 351) | def test_add_custom_pkg_caches_package():
  function test_try_caching_packages_skips_already_cached (line 362) | def test_try_caching_packages_skips_already_cached(monkeypatch):
  function test_try_caching_packages_caches_from_aur (line 379) | def test_try_caching_packages_caches_from_aur(monkeypatch):
  function test_try_caching_packages_aur_returns_error (line 412) | def test_try_caching_packages_aur_returns_error(monkeypatch):
  function test_try_caching_packages_request_exception_raises_aur_error (line 428) | def test_try_caching_packages_request_exception_raises_aur_error(monkeyp...
  function test_get_package_info_returns_from_cache (line 446) | def test_get_package_info_returns_from_cache():
  function test_get_package_info_returns_custom_package_if_not_cached (line 455) | def test_get_package_info_returns_custom_package_if_not_cached():
  function test_get_package_info_aur_not_found_returns_none (line 466) | def test_get_package_info_aur_not_found_returns_none(monkeypatch):
  function test_get_package_info_aur_success_caches_and_returns (line 483) | def test_get_package_info_aur_success_caches_and_returns(monkeypatch):
  function test_get_package_info_aur_returns_error (line 515) | def test_get_package_info_aur_returns_error(monkeypatch):
  function test_get_package_info_request_exception_raises_aur_error (line 531) | def test_get_package_info_request_exception_raises_aur_error(monkeypatch):
  function test_find_provider_uses_selected_providers_cache (line 549) | def test_find_provider_uses_selected_providers_cache():
  function test_find_provider_exact_name_match (line 558) | def test_find_provider_exact_name_match(monkeypatch):
  function test_find_provider_single_known_provider (line 573) | def test_find_provider_single_known_provider(monkeypatch):
  function test_find_provider_aur_search_not_found (line 591) | def test_find_provider_aur_search_not_found(monkeypatch):
  function test_find_provider_aur_search_single_result (line 613) | def test_find_provider_aur_search_single_result(monkeypatch):
  function test_find_provider_aur_search_multiple_results_calls_choose_provider (line 643) | def test_find_provider_aur_search_multiple_results_calls_choose_provider...
  function test_find_provider_aur_search_error (line 679) | def test_find_provider_aur_search_error(monkeypatch):
  function test_find_provider_aur_search_request_exception_raises_aur_error (line 700) | def test_find_provider_aur_search_request_exception_raises_aur_error(mon...
  function test_choose_provider_prompts_and_caches (line 723) | def test_choose_provider_prompts_and_caches(monkeypatch):

FILE: plugins/decman-pacman/tests/test_decman_plugins_aur_resolver.py
  function test_add_dependency (line 6) | def test_add_dependency():
  function test_cyclic_dependency_raises (line 19) | def test_cyclic_dependency_raises():
  function _build_graph_for_outer_deps (line 30) | def _build_graph_for_outer_deps() -> DepGraph:
  function _assert_outer_dep_names (line 72) | def _assert_outer_dep_names(graph: DepGraph, expected: set[str]) -> None:
  function test_get_and_remove_outer_deps_sequence (line 78) | def test_get_and_remove_outer_deps_sequence():

FILE: plugins/decman-pacman/tests/test_decman_plugins_pacman.py
  function test_strip_dependency (line 18) | def test_strip_dependency(dep, expected):
  class FakeStore (line 22) | class FakeStore(dict):
    method ensure (line 23) | def ensure(self, key: str, default: Any) -> None:
  class FakeModule (line 28) | class FakeModule:
    method __init__ (line 29) | def __init__(self, name: str, packages: set[str]) -> None:
  function test_process_modules_collects_packages_and_marks_changed (line 35) | def test_process_modules_collects_packages_and_marks_changed(
  function test_apply_dry_run_computes_sets_and_does_not_call_pacman (line 65) | def test_apply_dry_run_computes_sets_and_does_not_call_pacman(
  function test_apply_returns_false_on_command_failure (line 188) | def test_apply_returns_false_on_command_failure(monkeypatch: pytest.Monk...
  function test_ignored_packages_are_not_removed_or_installed (line 227) | def test_ignored_packages_are_not_removed_or_installed(monkeypatch: pyte...

FILE: plugins/decman-pacman/tests/test_deep_orphan_removal.py
  class FakePackage (line 7) | class FakePackage:
    method __init__ (line 8) | def __init__(self, name: str, is_explicit: bool, required_by: list[str]):
    method compute_requiredby (line 14) | def compute_requiredby(self):
  class FakeDB (line 18) | class FakeDB:
    method __init__ (line 19) | def __init__(self, pkgcache: list[FakePackage]):
  class FakePyalpmHandle (line 23) | class FakePyalpmHandle:
    method __init__ (line 24) | def __init__(self):
    method get_syncdbs (line 27) | def get_syncdbs(self):
    method get_localdb (line 42) | def get_localdb(self):
  function fake_create_pyalpm_handle (line 55) | def fake_create_pyalpm_handle(self):
  function test_get_native_orphans_pacman (line 59) | def test_get_native_orphans_pacman(
  function test_get_foreign_orphans_aur (line 75) | def test_get_foreign_orphans_aur(

FILE: plugins/decman-pacman/tests/test_fpm.py
  class FakeAurPacmanInterface (line 11) | class FakeAurPacmanInterface:
    method __init__ (line 12) | def __init__(self) -> None:
    method get_native_explicit (line 20) | def get_native_explicit(self) -> set[str]:
    method get_native_orphans (line 23) | def get_native_orphans(self) -> set[str]:
    method get_foreign_explicit (line 26) | def get_foreign_explicit(self) -> set[str]:
    method get_dependants (line 29) | def get_dependants(self, package: str) -> set[str]:
    method set_as_dependencies (line 32) | def set_as_dependencies(self, packages: set[str]):
    method install (line 35) | def install(self, packages: set[str]):
    method upgrade (line 39) | def upgrade(self):
    method is_provided_by_installed (line 42) | def is_provided_by_installed(self, dependency: str) -> bool:
    method get_all_packages (line 45) | def get_all_packages(self) -> set[str]:
    method filter_installed_packages (line 48) | def filter_installed_packages(self, deps: set[str]) -> set[str]:
    method remove (line 55) | def remove(self, packages: set[str]):
    method get_foreign_orphans (line 61) | def get_foreign_orphans(self) -> set[str]:
    method is_installable (line 64) | def is_installable(self, pkg: str) -> bool:
    method get_versioned_foreign_packages (line 67) | def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
    method install_dependencies (line 70) | def install_dependencies(self, deps: set[str]):
    method install_files (line 73) | def install_files(self, files: list[str], as_explicit: set[str]):
  class FakeStore (line 83) | class FakeStore:
    method __init__ (line 84) | def __init__(self) -> None:
    method __getitem__ (line 87) | def __getitem__(self, key: str) -> typing.Any:
    method __setitem__ (line 90) | def __setitem__(self, key: str, value: typing.Any) -> None:
    method get (line 93) | def get(self, key: str, default: typing.Any = None) -> typing.Any:
    method ensure (line 96) | def ensure(self, key: str, default: typing.Any = None):
    method __enter__ (line 100) | def __enter__(self) -> "FakeStore":
    method __exit__ (line 103) | def __exit__(self, exc_type, exc, tb):
    method save (line 106) | def save(self) -> None:
    method __repr__ (line 109) | def __repr__(self) -> str:
  class MockAurServer (line 113) | class MockAurServer:
    method __init__ (line 114) | def __init__(self) -> None:
    method seed (line 117) | def seed(self, packages: list[PackageInfo]):
    method handle_request (line 134) | def handle_request(self, url, *args, **kwargs):
  function mock_aur (line 182) | def mock_aur(mocker):
  function mock_pacman (line 189) | def mock_pacman(mocker):
  function mock_fpm (line 195) | def mock_fpm(mocker, mock_aur, mock_pacman):
  function test_remove_pacman_deps_provided_by_foreign_packages (line 229) | def test_remove_pacman_deps_provided_by_foreign_packages(
  function test_remove_pacman_deps_provided_by_already_installed_foreign_packages (line 265) | def test_remove_pacman_deps_provided_by_already_installed_foreign_packages(
  function test_install_simple_package (line 307) | def test_install_simple_package(
  function test_upgrade_foreign_package (line 327) | def test_upgrade_foreign_package(mock_fpm, mock_pacman, mock_aur):
  function test_upgrade_skips_current_package (line 347) | def test_upgrade_skips_current_package(mock_fpm, mock_pacman, mock_aur):
  function test_install_resolves_dependencies (line 365) | def test_install_resolves_dependencies(mock_fpm, mock_pacman, mock_aur):

FILE: plugins/decman-systemd/src/decman/plugins/systemd.py
  function units (line 12) | def units(fn):
  function user_units (line 22) | def user_units(fn):
  class SystemdCommands (line 33) | class SystemdCommands:
    method enable_units (line 38) | def enable_units(self, units: set[str]) -> list[str]:
    method disable_units (line 44) | def disable_units(self, units: set[str]) -> list[str]:
    method enable_user_units (line 50) | def enable_user_units(self, units: set[str], user: str) -> list[str]:
    method disable_user_units (line 56) | def disable_user_units(self, units: set[str], user: str) -> list[str]:
    method daemon_reload (line 62) | def daemon_reload(self) -> list[str]:
    method user_daemon_reload (line 68) | def user_daemon_reload(self, user: str) -> list[str]:
  class Systemd (line 75) | class Systemd(plugins.Plugin):
    method __init__ (line 78) | def __init__(self) -> None:
    method available (line 83) | def available(self) -> bool:
    method process_modules (line 86) | def process_modules(self, store: _store.Store, modules: list[module.Mo...
    method apply (line 122) | def apply(
    method enable_units (line 193) | def enable_units(self, store: _store.Store, units: set[str]):
    method disable_units (line 205) | def disable_units(self, store: _store.Store, units: set[str]):
    method enable_user_units (line 217) | def enable_user_units(self, store: _store.Store, units: set[str], user...
    method disable_user_units (line 231) | def disable_user_units(self, store: _store.Store, units: set[str], use...
    method reload_user_daemon (line 245) | def reload_user_daemon(self, user: str):
    method reload_daemon (line 253) | def reload_daemon(self):

FILE: plugins/decman-systemd/tests/test_decman_plugins_systemd.py
  class DummyStore (line 6) | class DummyStore(dict):
    method ensure (line 7) | def ensure(self, key, default):
  class DummyModule (line 12) | class DummyModule:
    method __init__ (line 13) | def __init__(self, name: str):
  function store (line 19) | def store():
  function systemd (line 24) | def systemd():
  function test_units_decorator_sets_attribute (line 28) | def test_units_decorator_sets_attribute():
  function test_user_units_decorator_sets_attribute (line 36) | def test_user_units_decorator_sets_attribute():
  function test_available_true_if_systemctl_found (line 44) | def test_available_true_if_systemctl_found(monkeypatch, systemd):
  function test_available_false_if_systemctl_missing (line 56) | def test_available_false_if_systemctl_missing(monkeypatch, systemd):
  function test_process_modules_marks_changed_and_updates_store (line 61) | def test_process_modules_marks_changed_and_updates_store(monkeypatch, st...
  function test_process_modules_no_change_second_run (line 94) | def test_process_modules_no_change_second_run(monkeypatch, store, systemd):
  function test_apply_enables_and_disables_units_and_user_units (line 120) | def test_apply_enables_and_disables_units_and_user_units(store):
  function test_apply_dry_run_does_not_mutate_store_or_call_commands (line 180) | def test_apply_dry_run_does_not_mutate_store_or_call_commands(store):
  function test_enable_units_success (line 208) | def test_enable_units_success(monkeypatch, store, systemd):
  function test_disable_units_success (line 223) | def test_disable_units_success(monkeypatch, store, systemd):
  function test_enable_user_units_success (line 238) | def test_enable_user_units_success(monkeypatch, store, systemd):
  function test_disable_user_units_success (line 257) | def test_disable_user_units_success(monkeypatch, store, systemd):
  function test_reload_daemon_uses_command_run (line 273) | def test_reload_daemon_uses_command_run(monkeypatch, systemd):
  function test_reload_user_daemon_uses_command_run (line 285) | def test_reload_user_daemon_uses_command_run(monkeypatch, systemd):

FILE: src/decman/__init__.py
  function sh (line 77) | def sh(

FILE: src/decman/app.py
  function main (line 16) | def main():
  function _execute_source (line 120) | def _execute_source(store: _store.Store, args: argparse.Namespace):
  function run_decman (line 174) | def run_decman(store: _store.Store, args: argparse.Namespace) -> bool:
  function _determine_execution_order (line 239) | def _determine_execution_order(args: argparse.Namespace) -> list[str]:
  function _find_new_modules (line 259) | def _find_new_modules(store: _store.Store):
  function _find_disabled_modules (line 268) | def _find_disabled_modules(store: _store.Store):
  function _run_before_update (line 278) | def _run_before_update(store: _store.Store, args: argparse.Namespace):
  function _run_on_disable (line 286) | def _run_on_disable(store: _store.Store, args: argparse.Namespace, disab...
  function _run_on_enable (line 303) | def _run_on_enable(store: _store.Store, args: argparse.Namespace, new_mo...
  function _run_on_change (line 338) | def _run_on_change(store: _store.Store, args: argparse.Namespace):
  function _run_after_update (line 347) | def _run_after_update(store: _store.Store, args: argparse.Namespace):

FILE: src/decman/core/command.py
  function get_user_info (line 21) | def get_user_info(user: str) -> tuple[int, int]:
  function prg (line 31) | def prg(
  function pty_run (line 105) | def pty_run(
  function run (line 152) | def run(
  function check_run_result (line 207) | def check_run_result(
  function _build_env (line 227) | def _build_env(
  function _exec_in_child (line 260) | def _exec_in_child(command: list[str], env: dict[str, str], user: None |...
  function _run_parent (line 278) | def _run_parent(master_fd: int, pid: int) -> tuple[int, str]:
  function _relay_pty (line 318) | def _relay_pty(master_fd: int, stdin_fd: int, stdout_fd: int) -> bytes:
  function _get_passwd (line 356) | def _get_passwd(user: str) -> pwd.struct_passwd:

FILE: src/decman/core/error.py
  class SourceError (line 4) | class SourceError(Exception):
  class FSInstallationFailedError (line 10) | class FSInstallationFailedError(Exception):
    method __init__ (line 15) | def __init__(self, source: str, target: str, reason: str):
  class FSSymlinkFailedError (line 21) | class FSSymlinkFailedError(Exception):
    method __init__ (line 26) | def __init__(self, link_name: str, target: str, reason: str):
  class InvalidOnDisableError (line 32) | class InvalidOnDisableError(Exception):
    method __init__ (line 37) | def __init__(self, module: str, reason: str):
  class UserNotFoundError (line 45) | class UserNotFoundError(Exception):
    method __init__ (line 53) | def __init__(self, user: str) -> None:
  class GroupNotFoundError (line 58) | class GroupNotFoundError(Exception):
    method __init__ (line 66) | def __init__(self, group: str) -> None:
  class CommandFailedError (line 71) | class CommandFailedError(Exception):
    method __init__ (line 81) | def __init__(self, command: list[str], exit_code: int, output: str | N...

FILE: src/decman/core/file_manager.py
  function update_files (line 11) | def update_files(
  function _install_files (line 133) | def _install_files(
  function _install_directories (line 168) | def _install_directories(
  function _install_symlinks (line 207) | def _install_symlinks(

FILE: src/decman/core/fs.py
  function create_missing_dirs (line 11) | def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typi...
  class File (line 25) | class File:
    method __init__ (line 76) | def __init__(
    method copy_to (line 109) | def copy_to(
    method _write_content (line 161) | def _write_content(self, target: str, variables: dict[str, str], dry_r...
  class Symlink (line 221) | class Symlink:
    method __init__ (line 243) | def __init__(
    method link_to (line 264) | def link_to(self, link_name: str, dry_run: bool = False) -> bool:
  class Directory (line 313) | class Directory:
    method __init__ (line 350) | def __init__(
    method copy_to (line 378) | def copy_to(

FILE: src/decman/core/module.py
  class Module (line 14) | class Module:
    method __init__ (line 25) | def __init__(self, name: str) -> None:
    method __init_subclass__ (line 29) | def __init_subclass__(cls, **kwargs):
    method before_update (line 45) | def before_update(self, store: _store.Store):
    method after_update (line 55) | def after_update(self, store: _store.Store):
    method on_enable (line 65) | def on_enable(self, store: _store.Store):
    method on_change (line 75) | def on_change(self, store: _store.Store):
    method on_disable (line 87) | def on_disable():
    method files (line 95) | def files(self) -> dict[str, fs.File]:
    method directories (line 101) | def directories(self) -> dict[str, fs.Directory]:
    method symlinks (line 108) | def symlinks(self) -> dict[str, str | fs.Symlink]:
    method file_variables (line 115) | def file_variables(self) -> dict[str, str]:
    method __hash__ (line 122) | def __hash__(self) -> int:
    method __eq__ (line 125) | def __eq__(self, other: object) -> bool:
  function write_on_disable_script (line 129) | def write_on_disable_script(mod_obj: Module, out_dir: str) -> str | None:
  function _iter_code_objects (line 168) | def _iter_code_objects(code: types.CodeType):
  function _validate_on_disable (line 175) | def _validate_on_disable(module_type: str, func: types.FunctionType) -> ...

FILE: src/decman/core/output.py
  function has_ansi_support (line 26) | def has_ansi_support() -> bool:
  function _apply_color (line 42) | def _apply_color(code: str, text: str) -> str:
  function _tag (line 48) | def _tag() -> str:
  function _continuation_prefix (line 54) | def _continuation_prefix() -> str:
  function _red (line 58) | def _red(text: str) -> str:
  function _yellow (line 62) | def _yellow(text: str) -> str:
  function _cyan (line 66) | def _cyan(text: str) -> str:
  function _green (line 70) | def _green(text: str) -> str:
  function _gray (line 74) | def _gray(text: str) -> str:
  function print_continuation (line 83) | def print_continuation(msg: str, level: int = SUMMARY):
  function print_error (line 91) | def print_error(error_msg: str):
  function print_traceback (line 98) | def print_traceback():
  function print_warning (line 106) | def print_warning(msg: str):
  function print_summary (line 113) | def print_summary(msg: str):
  function print_info (line 120) | def print_info(msg: str):
  function print_debug (line 128) | def print_debug(msg: str):
  function print_command_output (line 136) | def print_command_output(command_output: str):
  function print_list (line 149) | def print_list(
  function prompt_number (line 213) | def prompt_number(
  function prompt_confirm (line 238) | def prompt_confirm(msg: str, default: typing.Optional[bool] = None) -> b...

FILE: src/decman/core/store.py
  class Store (line 8) | class Store:
    method __init__ (line 13) | def __init__(self, path: str, dry_run: bool = False) -> None:
    method __getitem__ (line 22) | def __getitem__(self, key: str) -> typing.Any:
    method __setitem__ (line 25) | def __setitem__(self, key: str, value: typing.Any) -> None:
    method get (line 28) | def get(self, key: str, default: typing.Any = None) -> typing.Any:
    method ensure (line 31) | def ensure(self, key: str, default: typing.Any = None):
    method __enter__ (line 35) | def __enter__(self) -> "Store":
    method __exit__ (line 38) | def __exit__(self, exc_type, exc, tb):
    method save (line 42) | def save(self) -> None:
    method __repr__ (line 63) | def __repr__(self) -> str:
  class _SetJSONEncoder (line 67) | class _SetJSONEncoder(json.JSONEncoder):
    method default (line 68) | def default(self, obj: typing.Any) -> typing.Any:
  function _decode_sets (line 75) | def _decode_sets(obj: typing.Any) -> typing.Any:

FILE: src/decman/extras/gpg.py
  class Key (line 29) | class Key:
    method __post_init__ (line 35) | def __post_init__(self) -> None:
  class _GPGInterface (line 42) | class _GPGInterface:
    method __init__ (line 43) | def __init__(self, user: str, home: str):
    method ensure_home (line 47) | def ensure_home(self) -> bool:
    method list_fingerprints (line 74) | def list_fingerprints(self) -> set[str]:
    method set_key_trust (line 88) | def set_key_trust(self, keys: list[tuple[str, OwnerTrust]]):
    method delete_keys (line 116) | def delete_keys(self, fingerprints: list[str]):
    method fetch_key (line 132) | def fetch_key(self, uri: str):
    method import_key (line 148) | def import_key(self, path: str):
    method receive_key (line 164) | def receive_key(self, fingerprint: str, keyserver: str):
  class GPGReceiver (line 183) | class GPGReceiver(module.Module):
    method __init__ (line 201) | def __init__(self) -> None:
    method receive_key (line 205) | def receive_key(
    method fetch_key (line 224) | def fetch_key(
    method import_key (line 236) | def import_key(
    method _add_key (line 248) | def _add_key(self, gpg: _GPGInterface, key: Key):
    method before_update (line 257) | def before_update(self, store: _store.Store):

FILE: src/decman/extras/users.py
  class Group (line 13) | class Group:
    method __str__ (line 25) | def __str__(self) -> str:
  class User (line 35) | class User:
    method __str__ (line 51) | def __str__(self) -> str:
  class UserManager (line 68) | class UserManager(module.Module):
    method __init__ (line 95) | def __init__(self) -> None:
    method add_user (line 103) | def add_user(self, user: User):
    method add_group (line 109) | def add_group(self, group: Group):
    method add_user_to_group (line 115) | def add_user_to_group(self, user: str, group: str):
    method add_subuids (line 123) | def add_subuids(self, user: str, first: int, last: int):
    method add_subgids (line 137) | def add_subgids(self, user: str, first: int, last: int):
    method _check_user (line 151) | def _check_user(self, user: User, user_groups_index: dict[str, set[str...
    method _add_user (line 174) | def _add_user(self, user: User):
    method _ensure_user_matches (line 199) | def _ensure_user_matches(
    method _user_groups_index (line 234) | def _user_groups_index(self) -> dict[str, set[str]]:
    method _check_group (line 242) | def _check_group(self, group: Group):
    method _add_group (line 264) | def _add_group(self, group: Group):
    method _ensure_group_matches (line 277) | def _ensure_group_matches(self, group: Group, groupdb: grp.struct_group):
    method _modify_user_groups_subids (line 292) | def _modify_user_groups_subids(self, user: str, store: _store.Store):
    method _delete_users_and_groups (line 353) | def _delete_users_and_groups(self, store: _store.Store):
    method before_update (line 375) | def before_update(self, store: _store.Store):
    method after_update (line 384) | def after_update(self, store: _store.Store):

FILE: src/decman/plugins/__init__.py
  class Plugin (line 8) | class Plugin:
    method available (line 18) | def available(self) -> bool:
    method apply (line 28) | def apply(
    method process_modules (line 43) | def process_modules(self, store: _store.Store, modules: list[module.Mo...
  function run_method_with_attribute (line 49) | def run_method_with_attribute(mod: module.Module, attribute: str) -> typ...
  function run_methods_with_attribute (line 67) | def run_methods_with_attribute(mod: module.Module, attribute: str) -> li...
  function available_plugins (line 84) | def available_plugins() -> dict[str, Plugin]:

FILE: tests/test_decman_app.py
  class DummyStore (line 9) | class DummyStore:
    method __init__ (line 10) | def __init__(self, enabled=None, scripts=None):
    method __getitem__ (line 17) | def __getitem__(self, key):
    method __setitem__ (line 20) | def __setitem__(self, key, value):
    method ensure (line 23) | def ensure(self, key, default):
  class DummyModule (line 27) | class DummyModule:
    method __init__ (line 28) | def __init__(self, name):
    method before_update (line 36) | def before_update(self, store):
    method on_enable (line 39) | def on_enable(self, store):
    method on_change (line 42) | def on_change(self, store):
    method after_update (line 45) | def after_update(self, store):
    method on_disable (line 49) | def on_disable():
  class DummyPlugin (line 53) | class DummyPlugin:
    method __init__ (line 54) | def __init__(self, apply_result=True):
    method process_modules (line 59) | def process_modules(self, store, modules):
    method apply (line 62) | def apply(self, store, dry_run=False, params=None):
  function make_args (line 67) | def make_args(
  function no_op_output (line 79) | def no_op_output(monkeypatch):
  function base_decman (line 91) | def base_decman(monkeypatch):
  function file_manager (line 112) | def file_manager(monkeypatch):
  function test_execution_order_only_and_skip (line 135) | def test_execution_order_only_and_skip(no_op_output, base_decman, file_m...
  function test_returns_false_when_update_files_fails_and_skips_plugins (line 157) | def test_returns_false_when_update_files_fails_and_skips_plugins(
  function test_plugin_failure_returns_false (line 180) | def test_plugin_failure_returns_false(no_op_output, base_decman, file_ma...
  function test_disabled_modules_run_on_disable_script (line 197) | def test_disabled_modules_run_on_disable_script(no_op_output, base_decma...
  function test_on_disable_not_run_in_dry_run (line 219) | def test_on_disable_not_run_in_dry_run(no_op_output, base_decman, file_m...
  function test_hooks_called_for_new_and_changed_modules (line 236) | def test_hooks_called_for_new_and_changed_modules(
  function test_hooks_not_called_when_no_hooks (line 278) | def test_hooks_not_called_when_no_hooks(no_op_output, base_decman, file_...
  function test_dry_run_skips_all_hooks_but_runs_steps_with_flag (line 299) | def test_dry_run_skips_all_hooks_but_runs_steps_with_flag(no_op_output, ...
  function test_missing_plugin_emits_warning_but_continues (line 328) | def test_missing_plugin_emits_warning_but_continues(base_decman, file_ma...

FILE: tests/test_decman_core_command.py
  function test_prg_pty_true_uses_pty_run_and_check (line 11) | def test_prg_pty_true_uses_pty_run_and_check(monkeypatch: pytest.MonkeyP...
  function test_prg_pty_false_uses_run (line 43) | def test_prg_pty_false_uses_run(monkeypatch: pytest.MonkeyPatch):
  function test_prg_check_false_warns_on_nonzero (line 66) | def test_prg_check_false_warns_on_nonzero(monkeypatch: pytest.MonkeyPatch):
  function test_prg_check_true_propagates_command_failed_error (line 90) | def test_prg_check_true_propagates_command_failed_error(monkeypatch: pyt...
  function test_run_simple (line 111) | def test_run_simple():
  function test_run_exec_failure (line 117) | def test_run_exec_failure():
  function test_run_env_overrides_visible_in_child (line 123) | def test_run_env_overrides_visible_in_child(monkeypatch):
  function test_pty_run_simple (line 140) | def test_pty_run_simple():

FILE: tests/test_decman_core_file_manager.py
  class DummyFile (line 15) | class DummyFile:
    method __init__ (line 16) | def __init__(self, result=True, exc: BaseException | None = None):
    method copy_to (line 22) | def copy_to(self, target: str, variables=None, dry_run: bool = False) ...
  class DummyDirectory (line 29) | class DummyDirectory:
    method __init__ (line 30) | def __init__(
    method copy_to (line 43) | def copy_to(self, target: str, variables=None, dry_run: bool = False):
  class DummyModule (line 50) | class DummyModule:
    method __init__ (line 51) | def __init__(
    method files (line 66) | def files(self):
    method directories (line 69) | def directories(self):
    method symlinks (line 72) | def symlinks(self):
    method file_variables (line 75) | def file_variables(self):
  class DummyStore (line 79) | class DummyStore:
    method __init__ (line 80) | def __init__(self, initial: dict | None = None):
    method __getitem__ (line 83) | def __getitem__(self, key):
    method __setitem__ (line 86) | def __setitem__(self, key, value):
    method ensure (line 89) | def ensure(self, key, default):
  function test_install_files_non_dry_run_tracks_checked_and_changed (line 96) | def test_install_files_non_dry_run_tracks_checked_and_changed():
  function test_install_files_dry_run_uses_dry_run_flag_and_respects_return_value (line 113) | def test_install_files_dry_run_uses_dry_run_flag_and_respects_return_val...
  function test_install_files_wraps_exceptions (line 139) | def test_install_files_wraps_exceptions(exc):
  function test_install_directories_aggregates_checked_and_changed (line 154) | def test_install_directories_aggregates_checked_and_changed():
  function test_install_directories_wraps_exceptions (line 189) | def test_install_directories_wraps_exceptions(exc):
  function test_update_files_success_updates_store_and_removes_stale_files (line 204) | def test_update_files_success_updates_store_and_removes_stale_files(monk...
  function test_update_files_dry_run_does_not_touch_store_or_remove (line 264) | def test_update_files_dry_run_does_not_touch_store_or_remove(monkeypatch):
  function test_update_files_propagates_fsinstallation_error_and_does_not_modify_store (line 306) | def test_update_files_propagates_fsinstallation_error_and_does_not_modif...
  function test_install_symlinks_creates_missing_link_and_parents (line 363) | def test_install_symlinks_creates_missing_link_and_parents(tmp_path):
  function test_install_symlinks_no_change_when_already_points_to_target (line 377) | def test_install_symlinks_no_change_when_already_points_to_target(tmp_pa...
  function test_install_symlinks_replaces_wrong_target (line 392) | def test_install_symlinks_replaces_wrong_target(tmp_path):
  function test_install_symlinks_replaces_existing_regular_file (line 409) | def test_install_symlinks_replaces_existing_regular_file(tmp_path):
  function test_install_symlinks_dry_run_does_not_touch_fs (line 424) | def test_install_symlinks_dry_run_does_not_touch_fs(tmp_path):
  function test_update_files_tracks_symlinks_and_removes_stale_symlinks (line 437) | def test_update_files_tracks_symlinks_and_removes_stale_symlinks(tmp_path):
  function test_update_files_dry_run_does_not_create_or_remove_symlinks (line 483) | def test_update_files_dry_run_does_not_create_or_remove_symlinks(tmp_path):

FILE: tests/test_decman_core_fs.py
  function test_file_from_content_creates_and_is_idempotent (line 11) | def test_file_from_content_creates_and_is_idempotent(tmp_path: Path) -> ...
  function test_file_content_with_variables_and_change_detection (line 31) | def test_file_content_with_variables_and_change_detection(tmp_path: Path...
  function test_file_from_source_text_with_and_without_variables (line 52) | def test_file_from_source_text_with_and_without_variables(tmp_path: Path...
  function test_file_binary_from_content (line 78) | def test_file_binary_from_content(tmp_path: Path) -> None:
  function test_file_binary_copy_from_source (line 94) | def test_file_binary_copy_from_source(tmp_path: Path) -> None:
  function test_file_creates_parent_directories_and_applies_permissions (line 112) | def test_file_creates_parent_directories_and_applies_permissions(tmp_pat...
  function _create_sample_source_tree (line 133) | def _create_sample_source_tree(root: Path) -> None:
  function test_directory_copy_to_creates_and_is_idempotent (line 139) | def test_directory_copy_to_creates_and_is_idempotent(tmp_path: Path) -> ...
  function test_directory_copy_to_detects_changes_via_variables (line 171) | def test_directory_copy_to_detects_changes_via_variables(tmp_path: Path)...
  function test_directory_copy_to_dry_run (line 197) | def test_directory_copy_to_dry_run(tmp_path: Path) -> None:
  function test_directory_copy_to_restores_working_directory (line 229) | def test_directory_copy_to_restores_working_directory(tmp_path: Path) ->...
  function test_file_copy_to_dry_run (line 249) | def test_file_copy_to_dry_run(tmp_path):

FILE: tests/test_decman_core_module.py
  function test_module_without_on_disable_is_accepted (line 12) | def test_module_without_on_disable_is_accepted():
  function test_on_disable_must_be_staticmethod (line 21) | def test_on_disable_must_be_staticmethod():
  function test_on_disable_must_take_no_parameters (line 32) | def test_on_disable_must_take_no_parameters():
  function test_on_disable_must_not_use_module_level_globals (line 47) | def test_on_disable_must_not_use_module_level_globals():
  function test_on_disable_must_not_close_over_outer_variables (line 61) | def test_on_disable_must_not_close_over_outer_variables():
  function test_on_disable_nested_function_without_closure_is_allowed (line 80) | def test_on_disable_nested_function_without_closure_is_allowed():
  function test_on_disable_can_use_builtins_and_imports_inside_function (line 98) | def test_on_disable_can_use_builtins_and_imports_inside_function():
  function test_write_on_disable_script_returns_none_when_no_on_disable (line 113) | def test_write_on_disable_script_returns_none_when_no_on_disable(tmp_path):
  function test_write_on_disable_script_creates_executable_script (line 124) | def test_write_on_disable_script_creates_executable_script(tmp_path):
  function test_write_on_disable_script_uses_module_and_class_in_header (line 163) | def test_write_on_disable_script_uses_module_and_class_in_header(tmp_path):

FILE: tests/test_decman_core_output.py
  function reset_config (line 11) | def reset_config():
  function test_print_error_with_color_enabled (line 24) | def test_print_error_with_color_enabled(capsys):
  function test_print_error_with_color_disabled (line 35) | def test_print_error_with_color_disabled(capsys):
  function test_print_info_respects_quiet_and_debug (line 45) | def test_print_info_respects_quiet_and_debug(capsys):
  function test_print_debug_only_with_debug_enabled (line 65) | def test_print_debug_only_with_debug_enabled(capsys):
  function test_print_continuation_respects_level_and_config (line 77) | def test_print_continuation_respects_level_and_config(capsys):
  function test_print_list_empty_outputs_nothing (line 89) | def test_print_list_empty_outputs_nothing(capsys):
  function test_print_list_summary_and_elements (line 94) | def test_print_list_summary_and_elements(capsys, monkeypatch):
  function test_print_list_respects_elements_per_line_and_width (line 113) | def test_print_list_respects_elements_per_line_and_width(capsys, monkeyp...
  function test_prompt_number_valid_input (line 135) | def test_prompt_number_valid_input(monkeypatch):
  function test_prompt_number_invalid_then_valid (line 143) | def test_prompt_number_invalid_then_valid(monkeypatch, capsys):
  function test_prompt_number_default_on_empty (line 155) | def test_prompt_number_default_on_empty(monkeypatch):
  function test_prompt_confirm (line 175) | def test_prompt_confirm(monkeypatch, user_input, default, expected):
  function test_prompt_confirm_invalid_then_yes (line 183) | def test_prompt_confirm_invalid_then_yes(monkeypatch, capsys):

FILE: tests/test_decman_core_store.py
  function test_store_initially_empty_when_file_missing (line 9) | def test_store_initially_empty_when_file_missing(tmp_path: Path) -> None:
  function test_store_loads_existing_file (line 20) | def test_store_loads_existing_file(tmp_path: Path) -> None:
  function test_setitem_and_getitem_roundtrip (line 33) | def test_setitem_and_getitem_roundtrip(tmp_path: Path) -> None:
  function test_get_with_default (line 44) | def test_get_with_default(tmp_path: Path) -> None:
  function test_save_creates_parent_directory_and_persists (line 55) | def test_save_creates_parent_directory_and_persists(tmp_path: Path) -> N...
  function test_context_manager_saves_on_normal_exit (line 69) | def test_context_manager_saves_on_normal_exit(tmp_path: Path) -> None:
  function test_context_manager_saves_even_on_exception (line 81) | def test_context_manager_saves_even_on_exception(tmp_path: Path) -> None:
  function test_repr_matches_underlying_dict (line 95) | def test_repr_matches_underlying_dict(tmp_path: Path) -> None:
  function test_store_persists_sets (line 106) | def test_store_persists_sets(tmp_path: Path) -> None:

FILE: tests/test_decman_init.py
  function test_sh_calls_prg_with_sh_command (line 8) | def test_sh_calls_prg_with_sh_command(monkeypatch: pytest.MonkeyPatch):

FILE: tests/test_decman_plugins.py
  function mark (line 5) | def mark(attr):
  function test_runs_marked_method_and_returns_value (line 10) | def test_runs_marked_method_and_returns_value():
  function test_runs_marked_methods_and_returns_value (line 20) | def test_runs_marked_methods_and_returns_value():
  function test_returns_none_if_no_method_has_attribute (line 34) | def test_returns_none_if_no_method_has_attribute():
Condensed preview — 67 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (490K chars).
[
  {
    "path": ".gitignore",
    "chars": 53,
    "preview": "__pycache__/\n\nbuild/\n*.egg-info/\n\nvenv/\n.venv/\ndist/\n"
  },
  {
    "path": "DEVELOPMENT.md",
    "chars": 1117,
    "preview": "# Commands used in development\n\nBefore committing ensure all tests pass and format files.\n\n## Running\n\nRun decman as roo"
  },
  {
    "path": "LICENSE",
    "chars": 35149,
    "preview": "                    GNU GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007 Free "
  },
  {
    "path": "README.md",
    "chars": 13562,
    "preview": "# Decman\n\n> Decman has breaking changes!\n> Decman has undergone an architecture rewrite. The new architecture makes decm"
  },
  {
    "path": "completions/_decman",
    "chars": 679,
    "preview": "#compdef decman\n# zsh completion for decman\n\n_arguments -s \\\n  '--source=[python file containing configuration]:config f"
  },
  {
    "path": "completions/decman.bash",
    "chars": 673,
    "preview": "# bash completion for decman\n\n_decman() {\n    local cur prev opts\n    COMPREPLY=()\n    cur=\"${COMP_WORDS[COMP_CWORD]}\"\n "
  },
  {
    "path": "completions/decman.fish",
    "chars": 753,
    "preview": "# fish completion for decman\n\ncomplete -c decman -l source   -r -d \"python file containing configuration\" -a \"(__fish_co"
  },
  {
    "path": "docs/README.md",
    "chars": 18512,
    "preview": "# Decman documentation\n\nThis contains the documentation for decman. Each plugin has its own documentation. For a quick o"
  },
  {
    "path": "docs/aur.md",
    "chars": 11658,
    "preview": "# AUR\n\n> [!NOTE]\n> While this plugin exists with the sole purpose of installing foreing packages, this functionality is "
  },
  {
    "path": "docs/extras.md",
    "chars": 6780,
    "preview": "# Extras\n\nDecman ships with some built in modules. They implement functionality that is probably useful for declarative "
  },
  {
    "path": "docs/flatpak.md",
    "chars": 4499,
    "preview": "# Flatpak\n\nThe flatpak plugin is used to manage flatpak apps. It manages both systemd-wide and user-specific flatpaks. F"
  },
  {
    "path": "docs/migrate-to-v1.md",
    "chars": 23526,
    "preview": "# Migrating to the new architecture\n\nI recommend reading decman's new documentation. This document is supposed to be a q"
  },
  {
    "path": "docs/pacman.md",
    "chars": 4399,
    "preview": "# Pacman\n\nPacman plugin can be used to manage pacman packages. The pacman plugin manages only native packages found in a"
  },
  {
    "path": "docs/systemd.md",
    "chars": 3747,
    "preview": "# Systemd\n\nThe systemd plugin can enable systemd services, system wide or for a specific user. It will enable all units "
  },
  {
    "path": "example/README.md",
    "chars": 5968,
    "preview": "# Example\n\nThis directory contains an example of a minimal decman configuration. This also functions as a tutorial for s"
  },
  {
    "path": "example/base.py",
    "chars": 915,
    "preview": "import decman\nfrom decman.plugins import aur, pacman\n\n\nclass BaseModule(decman.Module):\n    def __init__(self):\n        "
  },
  {
    "path": "example/files/mkinitcpio.conf",
    "chars": 137,
    "preview": "MODULES=()\nBINARIES=()\nFILES=()\nHOOKS=(base systemd autodetect microcode modconf kms keyboard keymap sd-vconsole block f"
  },
  {
    "path": "example/files/vimrc",
    "chars": 21,
    "preview": "set number\nsyntax on\n"
  },
  {
    "path": "example/kde.py",
    "chars": 380,
    "preview": "import decman\nfrom decman.plugins import pacman, systemd\n\n\nclass KDE(decman.Module):\n    def __init__(self):\n        sup"
  },
  {
    "path": "example/plugin/decman_plugin_example.py",
    "chars": 518,
    "preview": "import os\n\nimport decman\n\n\nclass Example(decman.Plugin):\n    NAME = \"example\"\n\n    def available(self) -> bool:\n        "
  },
  {
    "path": "example/plugin/pyproject.toml",
    "chars": 290,
    "preview": "[project]\nname = \"decman-plugin-example\"\nversion = \"0.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\n    \"decman\",\n]\n\n"
  },
  {
    "path": "example/source.py",
    "chars": 504,
    "preview": "from base import BaseModule\nfrom kde import KDE\n\nimport decman\n\ndecman.pacman.packages |= {\"openssh\", \"qemu-guest-agent\""
  },
  {
    "path": "plugins/decman-flatpak/pyproject.toml",
    "chars": 430,
    "preview": "[project]\nname = \"decman-flatpak\"\nversion = \"1.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\"decman==1.2.1\"]\n\n[projec"
  },
  {
    "path": "plugins/decman-flatpak/src/decman/plugins/flatpak.py",
    "chars": 9010,
    "preview": "import shutil\n\nimport decman.core.command as command\nimport decman.core.error as errors\nimport decman.core.module as mod"
  },
  {
    "path": "plugins/decman-pacman/pyproject.toml",
    "chars": 567,
    "preview": "[project]\nname = \"decman-pacman\"\nversion = \"1.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\n  \"decman==1.2.1\",\n  \"pya"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/__init__.py",
    "chars": 9222,
    "preview": "import os\nimport shutil\n\nimport pyalpm\nfrom decman.plugins.aur.commands import AurCommands, AurPacmanInterface\nfrom decm"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/commands.py",
    "chars": 6846,
    "preview": "import decman.plugins.pacman as pacman\n\nimport decman.config as config\nimport decman.core.command as command\n\n\nclass Aur"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/error.py",
    "chars": 1305,
    "preview": "class ForeignPackageManagerError(Exception):\n    \"\"\"\n    Error raised from the ForeignPackageManager\n    \"\"\"\n\n\nclass Dep"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/fpm.py",
    "chars": 29650,
    "preview": "import os\nimport shutil\nimport time\nimport typing\n\nfrom decman.plugins.aur.commands import AurCommands\nfrom decman.plugi"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/package.py",
    "chars": 27930,
    "preview": "import dataclasses\nimport os\nimport pathlib\nimport shutil\nimport tempfile\n\nimport decman.plugins.pacman as pacman_module"
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/aur/resolver.py",
    "chars": 4050,
    "preview": "import typing\n\nfrom decman.plugins.aur.error import DependencyCycleError\n\n\nclass ForeignPackage:\n    \"\"\"\n    Class used "
  },
  {
    "path": "plugins/decman-pacman/src/decman/plugins/pacman.py",
    "chars": 13693,
    "preview": "import re\nimport shutil\nfrom typing import Callable\n\nimport pyalpm\n\nimport decman.config as config\nimport decman.core.co"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_aur.py",
    "chars": 11179,
    "preview": "from typing import Any\n\nimport pytest\n\nfrom decman.plugins import aur as aur_plugin\n\n\nclass FakeStore(dict):\n    def ens"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_aur_package.py",
    "chars": 20379,
    "preview": "import pathlib\n\nimport pytest\nfrom decman.plugins.aur import package as pkg_mod\nfrom decman.plugins.aur.error import Aur"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_aur_resolver.py",
    "chars": 2338,
    "preview": "import pytest\nfrom decman.plugins.aur.error import DependencyCycleError\nfrom decman.plugins.aur.resolver import DepGraph"
  },
  {
    "path": "plugins/decman-pacman/tests/test_decman_plugins_pacman.py",
    "chars": 10937,
    "preview": "from typing import Any\n\nimport pytest\n\nfrom decman.plugins import pacman as pacman_plugin\n\n\n@pytest.mark.parametrize(\n  "
  },
  {
    "path": "plugins/decman-pacman/tests/test_deep_orphan_removal.py",
    "chars": 2336,
    "preview": "import pyalpm\nimport pytest\nfrom decman.plugins.aur import AurPacmanInterface\nfrom decman.plugins.pacman import PacmanIn"
  },
  {
    "path": "plugins/decman-pacman/tests/test_fpm.py",
    "chars": 12353,
    "preview": "import typing\nfrom unittest.mock import MagicMock\nfrom urllib.parse import parse_qs, unquote, urlparse\n\nimport pytest\nfr"
  },
  {
    "path": "plugins/decman-systemd/pyproject.toml",
    "chars": 430,
    "preview": "[project]\nname = \"decman-systemd\"\nversion = \"1.1.0\"\nrequires-python = \">=3.13\"\ndependencies = [\"decman==1.2.1\"]\n\n[projec"
  },
  {
    "path": "plugins/decman-systemd/src/decman/plugins/systemd.py",
    "chars": 8965,
    "preview": "import shutil\n\nimport decman.config as config\nimport decman.core.command as command\nimport decman.core.error as errors\ni"
  },
  {
    "path": "plugins/decman-systemd/tests/test_decman_plugins_systemd.py",
    "chars": 9595,
    "preview": "import pytest\n\nfrom decman.plugins import systemd as systemd_mod\n\n\nclass DummyStore(dict):\n    def ensure(self, key, def"
  },
  {
    "path": "pyproject.toml",
    "chars": 1203,
    "preview": "[project]\nname = \"decman\"\nversion = \"1.2.1\"\ndescription = \"Declarative package & configuration manager for Arch Linux.\"\n"
  },
  {
    "path": "src/decman/__init__.py",
    "chars": 3357,
    "preview": "import typing\n\n# Re-exports\nfrom decman.core.command import prg\nfrom decman.core.error import SourceError\nfrom decman.co"
  },
  {
    "path": "src/decman/app.py",
    "chars": 12436,
    "preview": "import argparse\nimport os\nimport sys\n\nimport decman\nimport decman.config as conf\nimport decman.core.error as errors\nimpo"
  },
  {
    "path": "src/decman/config.py",
    "chars": 752,
    "preview": "\"\"\"\nModule for decman configuration options.\n\nNOTE: Do NOT use from imports as global variables might not work as you ex"
  },
  {
    "path": "src/decman/core/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/decman/core/command.py",
    "chars": 10983,
    "preview": "import errno\nimport fcntl\nimport os\nimport pty\nimport pwd\nimport select\nimport shlex\nimport shutil\nimport signal\nimport "
  },
  {
    "path": "src/decman/core/error.py",
    "chars": 2527,
    "preview": "import shlex\n\n\nclass SourceError(Exception):\n    \"\"\"\n    Error raised manually from the user's source.\n    \"\"\"\n\n\nclass F"
  },
  {
    "path": "src/decman/core/file_manager.py",
    "chars": 7733,
    "preview": "import os\nimport typing\n\nimport decman.core.error as errors\nimport decman.core.fs as fs\nimport decman.core.module as mod"
  },
  {
    "path": "src/decman/core/fs.py",
    "chars": 15594,
    "preview": "import grp\nimport os\nimport shutil\nimport typing\n\nimport decman.core.command as command\nimport decman.core.error as erro"
  },
  {
    "path": "src/decman/core/module.py",
    "chars": 6126,
    "preview": "import builtins\nimport dis\nimport inspect\nimport os\nimport textwrap\nimport types\nimport typing\n\nimport decman.core.error"
  },
  {
    "path": "src/decman/core/output.py",
    "chars": 5875,
    "preview": "import os\nimport shutil\nimport sys\nimport traceback\nimport typing\n\nimport decman.config as config\n\n# ───────────────────"
  },
  {
    "path": "src/decman/core/store.py",
    "chars": 2358,
    "preview": "import json\nimport os\nimport pathlib\nimport tempfile\nimport typing\n\n\nclass Store:\n    \"\"\"\n    Key-value store for saving"
  },
  {
    "path": "src/decman/extras/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "src/decman/extras/gpg.py",
    "chars": 9932,
    "preview": "import os\nimport pwd\nimport re\nimport subprocess\nfrom dataclasses import dataclass\nfrom typing import Literal, Optional\n"
  },
  {
    "path": "src/decman/extras/users.py",
    "chars": 13570,
    "preview": "import grp\nimport pwd\nfrom dataclasses import dataclass\nfrom typing import Optional\n\nimport decman.core.command as comma"
  },
  {
    "path": "src/decman/plugins/__init__.py",
    "chars": 2696,
    "preview": "import importlib.metadata as metadata\nimport typing\n\nimport decman.core.module as module\nimport decman.core.store as _st"
  },
  {
    "path": "src/decman/py.typed",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/test_decman_app.py",
    "chars": 9810,
    "preview": "import argparse\nimport types\n\nimport pytest\n\nimport decman.app as app  # adjust if run_decman lives elsewhere\n\n\nclass Du"
  },
  {
    "path": "tests/test_decman_core_command.py",
    "chars": 4671,
    "preview": "import json\nimport sys\nimport typing\n\nimport pytest\n\nimport decman.core.command as command\nimport decman.core.output\n\n\nd"
  },
  {
    "path": "tests/test_decman_core_file_manager.py",
    "chars": 13843,
    "preview": "import os\n\nimport pytest\n\nimport decman.core.error as errors\nimport decman.core.output as output\nfrom decman.core.file_m"
  },
  {
    "path": "tests/test_decman_core_fs.py",
    "chars": 8780,
    "preview": "import os\nimport stat\nfrom pathlib import Path\n\n# Adjust this import to match your actual module location\nimport decman."
  },
  {
    "path": "tests/test_decman_core_module.py",
    "chars": 5012,
    "preview": "import stat\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\nimport decman.core.error as errors\nimp"
  },
  {
    "path": "tests/test_decman_core_output.py",
    "chars": 5347,
    "preview": "import builtins\nimport types\n\nimport pytest\n\nimport decman.config as config\nimport decman.core.output as output\n\n\n@pytes"
  },
  {
    "path": "tests/test_decman_core_store.py",
    "chars": 3670,
    "preview": "import json\nfrom pathlib import Path\n\nimport pytest\n\nfrom decman.core.store import Store\n\n\ndef test_store_initially_empt"
  },
  {
    "path": "tests/test_decman_init.py",
    "chars": 935,
    "preview": "import typing\n\nimport pytest\n\nimport decman\n\n\ndef test_sh_calls_prg_with_sh_command(monkeypatch: pytest.MonkeyPatch):\n  "
  },
  {
    "path": "tests/test_decman_plugins.py",
    "chars": 832,
    "preview": "from decman.core.module import Module\nfrom decman.plugins import run_methods_with_attribute\n\n\ndef mark(attr):\n    attr._"
  }
]

About this extraction

This page contains the full source code of the kiviktnm/decman GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 67 files (452.2 KB), approximately 108.0k tokens, and a symbol index with 607 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!