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.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
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 .
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
return list(self._native_make_dependencies)
def native_check_dependencies(self, pacman: AurPacmanInterface) -> list[str]:
"""
Returns a list of native 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._native_check_dependencies is not None
return list(self._native_check_dependencies)
# --- internal helpers ---------------------------------------------------
@staticmethod
def _classify_dependencies(
deps: tuple[str, ...], pacman: AurPacmanInterface
) -> tuple[tuple[str, ...], tuple[str, ...]]:
native: list[str] = []
foreign: list[str] = []
for dependency in deps:
stripped = pacman_module.strip_dependency(dependency)
if pacman.is_installable(dependency):
native.append(stripped)
else:
foreign.append(stripped)
return tuple(native), tuple(foreign)
def _ensure_dependencies_cached(self, pacman: AurPacmanInterface) -> None:
if self._native_dependencies is not None:
return
native, foreign = self._classify_dependencies(self.dependencies, pacman)
object.__setattr__(self, "_native_dependencies", native)
object.__setattr__(self, "_foreign_dependencies", foreign)
def _ensure_make_dependencies_cached(self, pacman: AurPacmanInterface) -> None:
if self._native_make_dependencies is not None:
return
native, foreign = self._classify_dependencies(self.make_dependencies, pacman)
object.__setattr__(self, "_native_make_dependencies", native)
object.__setattr__(self, "_foreign_make_dependencies", foreign)
def _ensure_check_dependencies_cached(self, pacman: AurPacmanInterface) -> None:
if self._native_check_dependencies is not None:
return
native, foreign = self._classify_dependencies(self.check_dependencies, pacman)
object.__setattr__(self, "_native_check_dependencies", native)
object.__setattr__(self, "_foreign_check_dependencies", foreign)
class CustomPackage:
"""
Custom package installed from some other location than the official repos or the AUR.
``pkgname`` is required because the PKGBUILD might be for split packages.
Exactly one of ``git_url`` or ``pkgbuild_directory`` must be provided.
Parameters:
``pkgname``:
Name of the package.
``git_url``:
URL to a git repository containing the PKGBUILD.
``pkgbuild_directory``:
Path to the directory containing the PKGBUILD.
"""
def __init__(
self, pkgname: str, git_url: str | None = None, pkgbuild_directory: str | None = None
) -> None:
if git_url is None and pkgbuild_directory is None:
raise ValueError("Both git_url and pkgbuild_directory cannot be None.")
if git_url is not None and pkgbuild_directory is not None:
raise ValueError("Both git_url and pkgbuild_directory cannot be set.")
self.pkgname = pkgname
self.git_url = git_url
self.pkgbuild_directory = pkgbuild_directory
def parse(self, commands: AurCommands) -> PackageInfo:
"""
Parses this package's PKGBUILD to ``PackageInfo``.
If this fails, raises a ``PKGBUILDParseError``.
"""
if self.pkgbuild_directory is not None:
srcinfo = self._srcinfo_from_pkgbuild_directory(commands)
else:
srcinfo = self._srcinfo_from_git(commands)
return self._parse_srcinfo(srcinfo)
def __eq__(self, other: object) -> bool:
if not isinstance(other, CustomPackage):
return False
return (
self.git_url == other.git_url
and self.pkgbuild_directory == other.pkgbuild_directory
and self.pkgname == other.pkgname
)
def __hash__(self) -> int:
return hash((self.pkgname, self.git_url, self.pkgbuild_directory))
def __str__(self) -> str:
if self.git_url is not None:
return f"CustomPackage(pkgname={self.pkgname}, git_url={self.git_url})"
return (
f"CustomPackage(pkgname={self.pkgname}, pkgbuild_directory={self.pkgbuild_directory})"
)
def _srcinfo_from_pkgbuild_directory(self, commands: AurCommands) -> str:
assert self.pkgbuild_directory is not None, (
"This will not get called if pkgbuild_directory is unset."
)
path = pathlib.Path(self.pkgbuild_directory)
if not path.is_dir():
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
f"pkgbuild_directory '{path}' does not exist or is not a directory.",
)
if not (path / "PKGBUILD").exists():
raise PKGBUILDParseError(
self.git_url, self.pkgbuild_directory, f"No PKGBUILD found in '{path}'."
)
# Since makepkg cannot run as root even when just printing the SRCINFO,
# use a tmpdir and the user 'nobody'
try:
with tempfile.TemporaryDirectory(prefix="decman-pkgbuild-") as tmpdir:
shutil.copytree(path, tmpdir, dirs_exist_ok=True)
# Allow the user 'nobody' to use this directory
mode = 0o777
for root, dirs, files in os.walk(tmpdir):
for name in dirs + files:
os.chmod(os.path.join(root, name), mode)
os.chmod(tmpdir, 0o777)
return self._run_makepkg_printsrcinfo(pathlib.Path(tmpdir), commands)
except OSError as error:
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
"Failed to create temporary directory for the PKGBUILD.",
) from error
def _srcinfo_from_git(self, commands: AurCommands) -> str:
assert self.git_url is not None, "This will not get called if git_url is unset."
try:
with tempfile.TemporaryDirectory(prefix="decman-pkgbuild-") as tmpdir:
tmp_path = pathlib.Path(tmpdir)
# Allow the user 'nobody' to use this directory
os.chmod(tmpdir, 0o777)
try:
cmd = commands.git_clone(self.git_url, tmpdir)
# Use the user nobody, since that will be used later to generate SRCINFO
command.prg(cmd, user="nobody", pty=config.debug_output)
except errors.CommandFailedError as error:
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
"Failed to clone PKGBUILD repository.",
) from error
if not (tmp_path / "PKGBUILD").exists():
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
f"Cloned repository '{self.git_url}' does not contain a PKGBUILD.",
)
return self._run_makepkg_printsrcinfo(tmp_path, commands)
except OSError as error:
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
"Failed to create temporary directory for the PKGBUILD.",
) from error
def _run_makepkg_printsrcinfo(self, path: pathlib.Path, commands: AurCommands) -> str:
orig_wd = os.getcwd()
try:
os.chdir(path)
cmd = commands.print_srcinfo()
# No need to use the makepkg_user config option here.
# For just printing the SRCINFO, hardcoded 'nobody' works
srcinfo = command.prg(cmd, user="nobody", pty=False)
except errors.CommandFailedError as error:
raise PKGBUILDParseError(
self.git_url, self.pkgbuild_directory, "Failed to generate SRCINFO using makepkg."
) from error
finally:
os.chdir(orig_wd)
return srcinfo
def _parse_srcinfo(self, srcinfo: str) -> PackageInfo:
pkgbase: str | None = None
pkgver: str | None = None
pkgrel: str | None = None
epoch: str | None = None
provides: list[str] = []
# I'm not sure if split packages can have dependencies listed in the base.
# Easy to handle regardless
base_depends: list[str] = []
base_makedepends: list[str] = []
base_checkdepends: list[str] = []
pkg_depends: list[str] = []
pkg_makedepends: list[str] = []
pkg_checkdepends: list[str] = []
current_pkg: str | None = None
found_pkgnames = set()
for raw in srcinfo.splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = (part.strip() for part in line.split("=", 1))
is_base = current_pkg is None
is_target_pkg = current_pkg == self.pkgname
match key:
case "pkgbase":
pkgbase = value
current_pkg = None
case "pkgname":
current_pkg = value
found_pkgnames.add(value)
case "pkgver":
if pkgver is None or current_pkg == self.pkgname:
pkgver = value
case "pkgrel":
if pkgrel is None or current_pkg == self.pkgname:
pkgrel = value
case "epoch":
if epoch is None or current_pkg == self.pkgname:
epoch = value
case "provides":
if is_target_pkg:
provides.append(value)
case "depends":
if is_base:
base_depends.append(value)
elif is_target_pkg:
pkg_depends.append(value)
case "makedepends":
if is_base:
base_makedepends.append(value)
elif is_target_pkg:
pkg_makedepends.append(value)
case "checkdepends":
if is_base:
base_checkdepends.append(value)
elif is_target_pkg:
pkg_checkdepends.append(value)
case _ if key.startswith("depends") and key.removeprefix("depends_") == config.arch:
if is_base:
base_depends.append(value)
elif is_target_pkg:
pkg_depends.append(value)
case _ if (
key.startswith("makedepends")
and key.removeprefix("makedepends_") == config.arch
):
if is_base:
base_makedepends.append(value)
elif is_target_pkg:
pkg_makedepends.append(value)
case _ if (
key.startswith("checkdepends")
and key.removeprefix("checkdepends_") == config.arch
):
if is_base:
base_checkdepends.append(value)
elif is_target_pkg:
pkg_checkdepends.append(value)
if pkgbase is None or pkgver is None:
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
"Missing required fields (pkgbase/pkgver) in SRCINFO.",
)
if self.pkgname not in found_pkgnames:
raise PKGBUILDParseError(
self.git_url,
self.pkgbuild_directory,
f"Package {self.pkgname} not found in SRCINFO. "
f"Packages present: {' '.join(found_pkgnames)}.",
)
version_core = pkgver
if pkgrel is not None:
version_core = f"{version_core}-{pkgrel}"
if epoch is not None:
version = f"{epoch}:{version_core}"
else:
version = version_core
return PackageInfo(
pkgname=self.pkgname,
pkgbase=pkgbase,
version=version,
git_url=self.git_url,
pkgbuild_directory=self.pkgbuild_directory,
provides=tuple(provides),
dependencies=tuple(base_depends + pkg_depends),
make_dependencies=tuple(base_makedepends + pkg_makedepends),
check_dependencies=tuple(base_checkdepends + pkg_checkdepends),
)
class PackageSearch:
"""
Allows searcing for packages / providers from the AUR as well as user defined sources.
Results are cached and custom packages are preferred.
"""
def __init__(self, aur_rpc_timeout: int = 30) -> None:
self._package_cache: dict[str, PackageInfo] = {}
self._selected_providers_cache: dict[str, PackageInfo] = {}
self._all_providers_cache: dict[str, list[str]] = {}
self._custom_packages: list[PackageInfo] = []
self._timeout = aur_rpc_timeout
def add_custom_pkg(self, user_pkg: PackageInfo):
"""
Adds the given package to custom packages.
"""
self._custom_packages.append(user_pkg)
self._cache_pkg(user_pkg)
def _cache_pkg(self, pkg: PackageInfo):
for provided_pkg in pkg.provides:
self._all_providers_cache.setdefault(provided_pkg, []).append(pkg.pkgname)
self._package_cache[pkg.pkgname] = pkg
def try_caching_packages(self, packages: list[str]):
"""
Tries caching the given packages. Virtual packages may not be cached.
This can be used before calling get_package_info or find_provider multiple individual
times, because then those methods don't have to make new AUR RPC requests.
"""
uncached_packages = list(filter(lambda p: p not in self._package_cache, packages))
if len(uncached_packages) == 0:
return
output.print_debug(f"Trying to cache {uncached_packages}.")
max_pkgs_per_request = 200
while uncached_packages:
to_request = map(lambda p: f"arg[]={p}", uncached_packages[:max_pkgs_per_request])
uncached_packages = uncached_packages[max_pkgs_per_request:]
url = f"https://aur.archlinux.org/rpc/v5/info?{'&'.join(to_request)}"
output.print_debug(f"Request URL = {url}")
try:
request = requests.get(url, timeout=self._timeout)
d = request.json()
if d["type"] == "error":
raise AurRPCError(f"AUR RPC returned error: {d['error']}", url)
for result in d["results"]:
pkgname = result["Name"]
if pkgname in self._package_cache:
continue
for user_package in self._custom_packages:
if user_package.pkgname == pkgname:
output.print_debug(f"'{pkgname}' found in custom packages.")
self._cache_pkg(user_package)
break
else: # if not in user_packages then:
info = PackageInfo(
pkgname=result["Name"],
pkgbase=result["PackageBase"],
version=result["Version"],
dependencies=result.get("Depends", []),
make_dependencies=result.get("MakeDepends", []),
check_dependencies=result.get("CheckDepends", []),
provides=result.get("Provides", []),
git_url=f"https://aur.archlinux.org/{result['PackageBase']}.git",
)
self._cache_pkg(info)
output.print_debug("Request completed.")
except (requests.RequestException, KeyError) as e:
raise AurRPCError(
f"Failed to fetch package information for {uncached_packages} from AUR RPC.",
url,
) from e
def get_package_info(self, package: str) -> PackageInfo | None:
"""
Returns information about a package.
If the package is not custom, fetches information from the AUR.
Returns None if no such AUR package exists.
"""
output.print_debug(f"Getting info for package '{package}'.")
if package in self._package_cache:
output.print_debug(f"'{package}' found in cache.")
return self._package_cache[package]
# This code is probably not needed since all user packages should be cached
for user_package in self._custom_packages:
if user_package.pkgname == package:
output.print_debug(f"'{package}' found in custom packages.")
self._cache_pkg(user_package)
return user_package
url = f"https://aur.archlinux.org/rpc/v5/info/{package}"
output.print_debug(f"Requesting info for '{package}' from AUR. URL = {url}")
try:
request = requests.get(url, timeout=self._timeout)
d = request.json()
if d["type"] == "error":
raise AurRPCError(f"AUR RPC returned error: {d['error']}", url)
if d["resultcount"] == 0:
output.print_debug(f"'{package}' not found.")
return None
output.print_debug(f"'{package}' found from AUR.")
result = d["results"][0]
info = PackageInfo(
pkgname=result["Name"],
pkgbase=result["PackageBase"],
version=result["Version"],
dependencies=result.get("Depends", []),
make_dependencies=result.get("MakeDepends", []),
check_dependencies=result.get("CheckDepends", []),
provides=result.get("Provides", []),
git_url=f"https://aur.archlinux.org/{result['PackageBase']}.git",
)
self._cache_pkg(info)
return info
except (requests.RequestException, KeyError) as e:
raise AurRPCError(
f"Failed to fetch package information for {package} from AUR RPC.",
url,
) from e
def find_provider(self, stripped_dependency: str) -> PackageInfo | None:
"""
Finds a provider for a dependency. The dependency should not contain version constraints.
May prompt the user to select if multiple are available.
"""
output.print_debug(f"Finding provider for '{stripped_dependency}'.")
if stripped_dependency in self._selected_providers_cache:
output.print_debug(f"'{stripped_dependency}' found in cache.")
return self._selected_providers_cache[stripped_dependency]
output.print_debug("Are there exact name matches?")
exact_name_match = self.get_package_info(stripped_dependency)
if exact_name_match is not None:
output.print_debug("Exact name match found.")
self._selected_providers_cache[stripped_dependency] = exact_name_match
return exact_name_match
output.print_debug("No exact name matches found. Finding providers.")
known_pkg_results = self._all_providers_cache.get(stripped_dependency, [])
for user_package in self._custom_packages:
if (
stripped_dependency in user_package.provides
and stripped_dependency not in known_pkg_results
):
known_pkg_results.append(user_package.pkgname)
if len(known_pkg_results) == 1:
pkg = self.get_package_info(known_pkg_results[0])
assert pkg is not None
output.print_debug(
f"Single provider for '{stripped_dependency}' found in known packages: '{pkg}'."
)
self._selected_providers_cache[stripped_dependency] = pkg
return pkg
if len(known_pkg_results) > 1:
return self._choose_provider(stripped_dependency, known_pkg_results, "user packages")
url = f"https://aur.archlinux.org/rpc/v5/search/{stripped_dependency}?by=provides"
output.print_debug(
f"Requesting providers for '{stripped_dependency}' from AUR. URL = {url}"
)
try:
request = requests.get(url, timeout=self._timeout)
d = request.json()
if d["type"] == "error":
raise AurRPCError(f"AUR RPC returned error: {d['error']}", url)
if d["resultcount"] == 0:
output.print_debug(f"'{stripped_dependency}' not found.")
return None
results = list(map(lambda r: r["Name"], d["results"]))
if len(results) == 1:
pkgname = results[0]
output.print_debug(
f"Single provider for '{stripped_dependency}' found from AUR: '{pkgname}'"
)
info = self.get_package_info(pkgname)
return info
return self._choose_provider(stripped_dependency, results, "AUR")
except (requests.RequestException, KeyError) as e:
raise AurRPCError(
f"Failed to search for {stripped_dependency} from AUR RPC.",
url,
) from e
def _choose_provider(
self, dep: str, possible_providers: list[str], where: str
) -> PackageInfo | None:
min_selection = 1
max_selection = len(possible_providers)
output.print_summary(f"Found {len(possible_providers)} providers for {dep} from {where}.")
providers = "Providers: "
for index, name in enumerate(possible_providers):
providers += f"{index + 1}:{name} "
output.print_summary(providers)
selection = output.prompt_number(
f"Select a provider [{min_selection}-{max_selection}] (default: {min_selection}): ",
min_selection,
max_selection,
default=min_selection,
)
info = self.get_package_info(possible_providers[selection - 1])
if info is not None:
self._selected_providers_cache[dep] = info
return info
================================================
FILE: plugins/decman-pacman/src/decman/plugins/aur/resolver.py
================================================
import typing
from decman.plugins.aur.error import DependencyCycleError
class ForeignPackage:
"""
Class used to keep track of foreign recursive dependency packages of an foreign package.
"""
def __init__(self, name: str):
self.name = name
self._all_recursive_foreign_deps: set[str] = set()
def __eq__(self, value: object, /) -> bool:
if isinstance(value, self.__class__):
return (
self.name == value.name
and self._all_recursive_foreign_deps == value._all_recursive_foreign_deps
)
return False
def __hash__(self) -> int:
return self.name.__hash__()
def __repr__(self) -> str:
return f"{self.name}: {{{' '.join(self._all_recursive_foreign_deps)}}}"
def __str__(self) -> str:
return f"{self.name}"
def add_foreign_dependency_packages(self, package_names: typing.Iterable[str]):
"""
Adds dependencies to the package.
"""
self._all_recursive_foreign_deps.update(package_names)
def get_all_recursive_foreign_dep_pkgs(self) -> set[str]:
"""
Returns all dependencies and sub-dependencies of the package.
"""
return set(self._all_recursive_foreign_deps)
class DepNode:
"""
A Node of the DepGraph
"""
def __init__(self, package: ForeignPackage) -> None:
self.parents: dict[str, DepNode] = {}
self.children: dict[str, DepNode] = {}
self.pkg = package
def is_pkgname_in_parents_recursive(self, pkgname: str) -> bool:
"""
Returns True if the given package name is in the parents of this DepNode.
"""
for name, parent in self.parents.items():
if name == pkgname or parent.is_pkgname_in_parents_recursive(pkgname):
return True
return False
class DepGraph:
"""
Represents a graph between foreign packages
"""
def __init__(self) -> None:
self.package_nodes: dict[str, DepNode] = {}
self._childless_node_names: set[str] = set()
def add_requirement(self, child_pkgname: str, parent_pkgname: typing.Optional[str]):
"""
Adds a connection between two packages, creating the child package if it doesn't exist.
The parent is the package that requires the child package.
"""
child_node = self.package_nodes.get(child_pkgname, DepNode(ForeignPackage(child_pkgname)))
self.package_nodes[child_pkgname] = child_node
if len(child_node.children) == 0:
self._childless_node_names.add(child_pkgname)
if parent_pkgname is None:
return
parent_node = self.package_nodes[parent_pkgname]
if parent_node.is_pkgname_in_parents_recursive(child_pkgname):
raise DependencyCycleError(child_pkgname, parent_pkgname)
parent_node.children[child_pkgname] = child_node
child_node.parents[parent_pkgname] = parent_node
if parent_pkgname in self._childless_node_names:
self._childless_node_names.remove(parent_pkgname)
def get_and_remove_outer_dep_pkgs(self) -> list[ForeignPackage]:
"""
Returns all childless nodes of the dependency package graph and removes them.
"""
new_childless_node_names = set()
result = []
for childless_node_name in self._childless_node_names:
childless_node = self.package_nodes[childless_node_name]
for parent in childless_node.parents.values():
new_deps = childless_node.pkg.get_all_recursive_foreign_dep_pkgs()
new_deps.add(childless_node.pkg.name)
parent.pkg.add_foreign_dependency_packages(new_deps)
del parent.children[childless_node_name]
if len(parent.children) == 0:
new_childless_node_names.add(parent.pkg.name)
result.append(childless_node.pkg)
self._childless_node_names = new_childless_node_names
return result
================================================
FILE: plugins/decman-pacman/src/decman/plugins/pacman.py
================================================
import re
import shutil
from typing import Callable
import pyalpm
import decman.config as config
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 pacman package names that should be installed.
Return type of ``fn``: ``set[str]``
"""
fn.__pacman__packages__ = True
return fn
def strip_dependency(dep: str) -> str:
"""
Removes version spefications from a dependency name.
"""
rx = re.compile("(=.*|>.*|<.*)")
return rx.sub("", dep)
class Pacman(plugins.Plugin):
"""
Plugin that manages pacman packages added directly to ``packages`` or declared by modules via
``@packages``.
"""
NAME = "pacman"
def __init__(self) -> None:
self.packages: set[str] = set()
self.ignored_packages: set[str] = set()
self.commands = PacmanCommands()
self.print_highlights = True
self.keywords = {
"pacsave",
"pacnew",
# These cause too many false positives IMO
# "warning",
# "error",
# "note",
}
self.database_signature_level = pyalpm.SIG_DATABASE_OPTIONAL
self.database_path = "/var/lib/pacman/"
def available(self) -> bool:
return shutil.which("pacman") is not None
def process_modules(self, store: _store.Store, modules: list[module.Module]):
# This is used to track changes in modules.
store.ensure("packages_for_module", {})
for mod in modules:
store["packages_for_module"].setdefault(mod.name, set())
packages = set().union(*plugins.run_methods_with_attribute(mod, "__pacman__packages__"))
if store["packages_for_module"][mod.name] != packages:
mod._changed = True
output.print_debug(
f"Module '{mod.name}' set to changed due to modified pacman packages."
)
self.packages |= packages
store["packages_for_module"][mod.name] = packages
def apply(
self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None
) -> bool:
try:
pm = PacmanInterface(
self.commands,
self.print_highlights,
self.keywords,
self.database_signature_level,
self.database_path,
)
currently_installed_native = pm.get_native_explicit()
currently_installed_foreign = pm.get_foreign_explicit()
orphans = pm.get_native_orphans()
to_remove = (
(currently_installed_native | orphans) - self.packages - self.ignored_packages
)
actually_to_remove = set()
to_set_as_dependencies = set()
dependants_to_keep = self.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 pacman 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 packages as dependencies:",
sorted(to_set_as_dependencies),
)
if not dry_run:
pm.set_as_dependencies(to_set_as_dependencies)
output.print_summary("Upgrading packages.")
if not dry_run:
pm.upgrade()
to_install = self.packages - currently_installed_native - self.ignored_packages
output.print_list("Installing pacman packages:", sorted(to_install))
if not dry_run:
pm.install(to_install)
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(
"Pacman 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
class 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)
class PacmanInterface:
"""
High level interface for running pacman commands.
On failure methods raise a ``CommandFailedError`` or ``pyalpm.error``.
"""
def __init__(
self,
commands: PacmanCommands,
print_highlights: bool,
keywords: set[str],
dbsiglevel: int,
dbpath: str,
) -> None:
self._commands = commands
self._print_highlights = print_highlights
self._keywords = keywords
self._dbsiglevel = dbsiglevel
self._dbpath = dbpath
self._handle = self._create_pyalpm_handle()
self._name_index = self._create_name_index()
self._local_provides_index = self._create_local_provides_index()
self._provides_index = self._create_provides_index()
self._requiredby_index = self._create_requiredby_index()
def _create_pyalpm_handle(self):
root = "/"
h = pyalpm.Handle(root, self._dbpath)
cmd = self._commands.list_pacman_repos()
repos = command.prg(cmd, pty=False).strip().split("\n")
# Empty string means no DBs
if "" in repos and len(repos) == 1:
return
for repo in repos:
h.register_syncdb(repo, self._dbsiglevel)
return h
def _create_name_index(self) -> dict[str, pyalpm.Package]:
return {pkg.name: pkg for db in self._handle.get_syncdbs() for pkg in db.pkgcache}
def _create_local_provides_index(self) -> dict[str, set[str]]:
out: dict[str, set[str]] = {}
for pkg in self._handle.get_localdb().pkgcache:
for p in pkg.provides:
out.setdefault(strip_dependency(p), set()).add(pkg.name)
out.setdefault(p, set()).add(pkg.name)
return out
def _create_provides_index(self) -> dict[str, set[str]]:
out: dict[str, set[str]] = {}
for db in self._handle.get_syncdbs():
for pkg in db.pkgcache:
for p in pkg.provides:
out.setdefault(strip_dependency(p), set()).add(pkg.name)
out.setdefault(p, set()).add(pkg.name)
return out
def _create_requiredby_index(self) -> dict[str, set[str]]:
return {p.name: set(p.compute_requiredby()) for p in self._handle.get_localdb().pkgcache}
def _is_native(self, package: str) -> bool:
return package in self._name_index
def _is_foreign(self, package: str) -> bool:
return not self._is_native(package)
def get_all_packages(self) -> set[str]:
"""
Returns a set of all installed packages.
"""
return {pkg for pkg in self._handle.get_localdb().pkgcache}
def get_native_explicit(self) -> set[str]:
"""
Returns a set of explicitly installed native packages.
"""
out: set[str] = set()
for pkg in self._handle.get_localdb().pkgcache:
if pkg.reason == pyalpm.PKG_REASON_EXPLICIT and self._is_native(pkg.name):
out.add(pkg.name)
return out
return packages
def _get_orphans(self, filter_fn: Callable[["PacmanInterface", str], bool]) -> set[str]:
orphans: set[str] = {
p.name
for p in self._handle.get_localdb().pkgcache
if p.reason == pyalpm.PKG_REASON_DEPEND and filter_fn(self, p.name)
}
# Prune orphans until there are only packages that are requiredby other orphans
changed = True
while changed:
changed = False
for name in tuple(orphans):
if self._requiredby_index.get(name, set()) - orphans:
orphans.remove(name)
changed = True
return orphans
def get_native_orphans(self) -> set[str]:
"""
Returns a set of orphaned native packages.
"""
return self._get_orphans(PacmanInterface._is_native)
def get_foreign_explicit(self) -> set[str]:
"""
Returns a set of explicitly installed foreign packages.
"""
out: set[str] = set()
for pkg in self._handle.get_localdb().pkgcache:
if pkg.reason == pyalpm.PKG_REASON_EXPLICIT and not self._is_native(pkg.name):
out.add(pkg.name)
return out
def get_dependants(self, package: str) -> set[str]:
"""
Returns a set of installed packages that depend on the given package.
Includes the package itself.
"""
local = self._handle.get_localdb()
seen: set[str] = set()
stack = [package]
while stack:
name = stack.pop()
if name in seen:
continue
seen.add(name)
pkg = local.get_pkg(name)
if pkg is None:
continue
for dep in pkg.compute_requiredby():
if dep not in seen:
stack.append(dep)
return seen
def set_as_dependencies(self, packages: set[str]):
"""
Marks the given packages as dependency packages.
"""
if not packages:
return
cmd = self._commands.set_as_dependencies(packages)
command.prg(cmd, pty=config.debug_output)
def install(self, packages: set[str]):
"""
Installs the given packages. If the packages are already installed, marks them as
explicitly installed.
"""
if not packages:
return
cmd = self._commands.install(packages)
pacman_output = command.prg(cmd)
self.print_highlighted_pacman_messages(pacman_output)
cmd = self._commands.set_as_explicit(packages)
command.prg(cmd, pty=config.debug_output)
def upgrade(self):
"""
Upgrades all packages.
"""
cmd = self._commands.upgrade()
pacman_output = command.prg(cmd)
self.print_highlighted_pacman_messages(pacman_output)
def remove(self, packages: set[str]):
"""
Removes the given packages.
"""
if not packages:
return
cmd = self._commands.remove(packages)
pacman_output = command.prg(cmd)
self.print_highlighted_pacman_messages(pacman_output)
def print_highlighted_pacman_messages(self, pacman_output: str):
"""
Prints lines that contain pacman output keywords.
"""
if not self._print_highlights:
return
lines = pacman_output.split("\n")
highlight_lines = []
for index, line in enumerate(lines):
for keyword in self._keywords:
if keyword.lower() in line.lower():
highlight_lines.append(f"lines: {index}-{index + 2}")
if index >= 1:
highlight_lines.append(lines[index - 1])
highlight_lines.append(line)
if index + 1 < len(lines):
highlight_lines.append(lines[index + 1])
highlight_lines.append("")
# Break, as to not print the same line again if it contains multiple keywords
break
if highlight_lines:
output.print_summary("Pacman output highlights:")
for line in highlight_lines:
if line.startswith("lines:"):
output.print_summary(line)
else:
output.print_continuation(line)
================================================
FILE: plugins/decman-pacman/tests/test_decman_plugins_aur.py
================================================
from typing import Any
import pytest
from decman.plugins import aur as aur_plugin
class FakeStore(dict):
def ensure(self, key: str, default: Any) -> None:
if key not in self:
self[key] = default
class FakeModule:
def __init__(self, name: str, aur_pkgs: set[str], custom_pkgs: set[Any]) -> None:
self.name = name
self._changed = False
self._aur_pkgs = aur_pkgs
self._custom_pkgs = custom_pkgs
class FakeCustomPackage:
def __init__(self, pkgname: str) -> None:
self.pkgname = pkgname
def __hash__(self) -> int: # needed because instances go into sets
return hash(self.pkgname)
def __eq__(self, other: object) -> bool:
return isinstance(other, FakeCustomPackage) and self.pkgname == other.pkgname
def parse(self, commands: Any) -> str:
# Whatever ForeignPackageManager expects; we just need something to feed into add_custom_pkg
return f"parsed-{self.pkgname}"
def test_process_modules_collects_aur_and_custom_packages_and_marks_changed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
aur = aur_plugin.AUR()
store = FakeStore()
cp1 = FakeCustomPackage("custom1")
cp2 = FakeCustomPackage("custom2")
mod1 = FakeModule("mod1", {"aur1", "aur2"}, {cp1})
mod2 = FakeModule("mod2", {"aur3"}, {cp2})
def fake_run_methods_with_attribute(mod: FakeModule, attr: str):
if attr == "__aur__packages__":
return [mod._aur_pkgs]
if attr == "__custom__packages__":
return [mod._custom_pkgs]
return []
monkeypatch.setattr(
aur_plugin.plugins, "run_methods_with_attribute", fake_run_methods_with_attribute
)
aur.process_modules(store, {mod1, mod2})
# union of all aur/custom packages collected
assert aur.packages == {"aur1", "aur2", "aur3"}
assert aur.custom_packages == {cp1, cp2}
# stored per-module
assert store["aur_packages_for_module"]["mod1"] == {"aur1", "aur2"}
assert store["aur_packages_for_module"]["mod2"] == {"aur3"}
assert store["custom_packages_for_module"]["mod1"] == {str(cp1)}
assert store["custom_packages_for_module"]["mod2"] == {str(cp2)}
# first run: modules marked changed
assert mod1._changed is True
assert mod2._changed is True
def test_apply_respects_ignored_packages_and_protects_their_dependencies(
monkeypatch: pytest.MonkeyPatch,
) -> None:
aur = aur_plugin.AUR()
store = FakeStore()
# Desired AUR/custom state
aur.packages = {"desired-aur"}
cp = FakeCustomPackage("custom-aur")
aur.custom_packages = {cp}
# Ignored foreign package (installed) and an ignored but *uninstalled* package
aur.ignored_packages = {"ignored-aur", "ignored-not-installed"}
# Fake PackageSearch
class FakePackageSearch:
def __init__(self, timeout: int) -> None:
self.timeout = timeout
self.added: list[Any] = []
def add_custom_pkg(self, parsed: Any) -> None:
self.added.append(parsed)
monkeypatch.setattr(aur_plugin, "PackageSearch", FakePackageSearch)
monkeypatch.setattr(aur_plugin.os, "makedirs", lambda *x, **kw: None)
# Fake pacman interface for foreign/native info
class FakePM:
def __init__(self, commands, print_highlights, keywords, dbsiglevel, dbpath) -> None:
self.commands = commands
self.print_highlights = print_highlights
self.keywords = keywords
self.remove_called_with: set[str] | None = None
self.set_as_deps_called_with: set[str] | None = None
def get_native_explicit(self) -> set[str]:
# no natives needed for this scenario
return set()
def get_foreign_explicit(self) -> set[str]:
# All explicitly installed foreign packages:
# - ignored-aur (ignored, must stay and protect deps)
# - dep-of-ignored (candidate; has ignored dependant)
# - orphan-foreign (candidate; no dependants)
return {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
def get_foreign_orphans(self) -> set[str]:
# orphan-foreign also considered orphan
return {"orphan-foreign"}
def get_dependants(self, pkg: str) -> set[str]:
if pkg == "dep-of-ignored":
# ignored-aur depends on dep-of-ignored -> must demote, not remove
return {"ignored-aur"}
if pkg == "orphan-foreign":
return set()
return set()
def remove(self, pkgs: set[str]) -> None:
self.remove_called_with = pkgs
def set_as_dependencies(self, pkgs: set[str]) -> None:
self.set_as_deps_called_with = pkgs
fake_pm = FakePM(None, None, None, None, None)
def fake_pm_ctor(
commands,
print_highlights,
keywords,
dbsiglevel,
dbpath,
) -> FakePM:
fake_pm.commands = commands
fake_pm.print_highlights = print_highlights
fake_pm.keywords = keywords
return fake_pm
monkeypatch.setattr(aur_plugin, "AurPacmanInterface", fake_pm_ctor)
# Fake ForeignPackageManager
class FakeFPM:
def __init__(
self,
store_arg,
pm_arg,
package_search_arg,
commands_arg,
cache_dir,
build_dir,
makepkg_user,
) -> None:
self.store = store_arg
self.pm = pm_arg
self.package_search = package_search_arg
self.commands = commands_arg
self.cache_dir = cache_dir
self.build_dir = build_dir
self.makepkg_user = makepkg_user
self.upgrade_args: tuple[bool, bool, set[str]] | None = None
self.install_called_with: list[str] | None = None
def upgrade(self, upgrade_devel: bool, force: bool, ignored: set[str]) -> None:
self.upgrade_args = (upgrade_devel, force, ignored)
def install(self, pkgs: list[str], force: bool = False) -> None:
# store as set to ignore ordering
self.install_called_with = pkgs
fake_fpm = FakeFPM(None, None, None, None, None, None, None)
def fake_fpm_ctor(
store_arg,
pm_arg,
package_search_arg,
commands_arg,
cache_dir,
build_dir,
makepkg_user,
):
fake_fpm.store = store_arg
fake_fpm.pm = pm_arg
fake_fpm.package_search = package_search_arg
fake_fpm.commands = commands_arg
fake_fpm.cache_dir = cache_dir
fake_fpm.build_dir = build_dir
fake_fpm.makepkg_user = makepkg_user
return fake_fpm
monkeypatch.setattr(aur_plugin, "ForeignPackageManager", fake_fpm_ctor)
printed_lists: list[tuple[str, list[str]]] = []
printed_summaries: list[str] = []
def fake_print_list(title: str, items: list[str]) -> None:
printed_lists.append((title, items))
def fake_print_summary(msg: str) -> None:
printed_summaries.append(msg)
monkeypatch.setattr(aur_plugin.output, "print_list", fake_print_list)
monkeypatch.setattr(aur_plugin.output, "print_summary", fake_print_summary)
# Use params to test flag propagation into upgrade/install
ok = aur.apply(store, dry_run=False, params=["aur-upgrade-devel", "aur-force"])
assert ok is True
# Removal / demotion logic:
#
# custom_package_names = {"custom-aur"}
# currently_installed_foreign = {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
# orphans = {"orphan-foreign"}
#
# to_remove candidates:
# (foreign | orphans) - desired - custom - ignored
# = {"ignored-aur", "dep-of-ignored", "orphan-foreign"} ∪ {"orphan-foreign"}
# - {"desired-aur"} - {"custom-aur"} - {"ignored-aur"}
# = {"dep-of-ignored", "orphan-foreign"}
#
# dependants_to_keep includes ignored installed foreign -> dep-of-ignored is demoted, orphan-foreign removed.
assert fake_pm.remove_called_with == {"orphan-foreign"}
assert fake_pm.set_as_deps_called_with == {"dep-of-ignored"}
# Ensure ignored packages were not removed
assert "ignored-aur" not in (fake_pm.remove_called_with or set())
# Upgrade called with flags and ignored set
assert fake_fpm.upgrade_args == (
True,
True,
aur.ignored_packages | (fake_pm.remove_called_with or set()),
)
# to_install = (packages | custom_names) - installed_foreign - ignored
# = {"desired-aur", "custom-aur"} - {"ignored-aur", "dep-of-ignored", "orphan-foreign"}
# - {"ignored-aur", "ignored-not-installed"}
# = {"desired-aur", "custom-aur"}
assert set(fake_fpm.install_called_with or []) == {"desired-aur", "custom-aur"}
# ignored packages must not be installed
assert "ignored-aur" not in (fake_fpm.install_called_with or [])
assert "ignored-not-installed" not in (fake_fpm.install_called_with or [])
# Also check the printed lists mirror this
titles = [t for t, _ in printed_lists]
assert "Removing foreign packages:" in titles
assert "Setting previously explicitly installed foreign packages as dependencies:" in titles
assert "Installing foreign packages:" in titles
remove_list = next(items for t, items in printed_lists if "Removing foreign packages:" in t)
demote_list = next(
items
for t, items in printed_lists
if "Setting previously explicitly installed foreign packages as dependencies:" in t
)
install_list = next(items for t, items in printed_lists if "Installing foreign packages:" in t)
assert remove_list == ["orphan-foreign"]
assert demote_list == ["dep-of-ignored"]
# Order of install_list is deterministic because sorted() is used
assert install_list == ["custom-aur", "desired-aur"]
assert any("Upgrading foreign packages." in s for s in printed_summaries)
def test_apply_returns_false_on_aur_rpc_error(monkeypatch: pytest.MonkeyPatch) -> None:
aur = aur_plugin.AUR()
store = FakeStore()
# Force PackageSearch to fail immediately
class FailingPackageSearch:
def __init__(self, timeout: int) -> None:
raise aur_plugin.AurRPCError("RPC down", "url")
monkeypatch.setattr(aur_plugin, "PackageSearch", FailingPackageSearch)
monkeypatch.setattr(aur_plugin.os, "makedirs", lambda *x, **kw: None)
errors_logged: list[str] = []
continuations: list[str] = []
traceback_called: list[bool] = []
def fake_print_error(msg: str) -> None:
errors_logged.append(msg)
def fake_print_traceback() -> None:
traceback_called.append(True)
monkeypatch.setattr(aur_plugin.output, "print_error", fake_print_error)
monkeypatch.setattr(aur_plugin.output, "print_traceback", fake_print_traceback)
ok = aur.apply(store, dry_run=False)
assert ok is False
assert any("AUR RPC" in msg or "fetch data from AUR RPC" in msg for msg in errors_logged)
assert any("RPC down" in msg for msg in errors_logged)
assert traceback_called
================================================
FILE: plugins/decman-pacman/tests/test_decman_plugins_aur_package.py
================================================
import pathlib
import pytest
from decman.plugins.aur import package as pkg_mod
from decman.plugins.aur.error import AurRPCError, PKGBUILDParseError
from decman.plugins.aur.package import (
CustomPackage,
PackageInfo,
PackageSearch,
)
@pytest.fixture(autouse=True)
def silence_output(monkeypatch):
# Avoid real I/O / prompts in tests by default
monkeypatch.setattr(pkg_mod.output, "print_debug", lambda *a, **k: None)
monkeypatch.setattr(pkg_mod.output, "print_summary", lambda *a, **k: None)
monkeypatch.setattr(
pkg_mod.output,
"prompt_number",
lambda *a, **k: 1, # safe default
)
# --- PackageInfo -----------------------------------------------------------
def test_packageinfo_requires_exactly_one_source():
with pytest.raises(ValueError, match="cannot be None"):
PackageInfo(pkgname="a", pkgbase="a", version="1.0")
with pytest.raises(ValueError, match="cannot be set"):
PackageInfo(
pkgname="a",
pkgbase="a",
version="1.0",
git_url="git://example",
pkgbuild_directory="/tmp",
)
class DummyPacman:
def __init__(self, installable: set[str]):
self._installable = installable
self.calls: list[str] = []
def is_installable(self, name: str) -> bool:
self.calls.append(name)
return name in self._installable
def _make_pkg_for_deps() -> PackageInfo:
return PackageInfo(
pkgname="pkg",
pkgbase="pkg",
version="1.0",
git_url="git://example",
dependencies=("native>=1", "foreign=2"),
make_dependencies=("make-native", "make-foreign>=3"),
check_dependencies=("check-foreign<4", "check-native"),
)
def test_packageinfo_foreign_and_native_dependencies_are_split_and_stripped():
pacman = DummyPacman(
{
"native>=1",
"make-native",
"check-native",
}
)
pkg = _make_pkg_for_deps()
assert pkg.native_dependencies(pacman) == ["native"]
assert pkg.foreign_dependencies(pacman) == ["foreign"]
assert pkg.native_make_dependencies(pacman) == ["make-native"]
assert pkg.foreign_make_dependencies(pacman) == ["make-foreign"]
assert pkg.native_check_dependencies(pacman) == ["check-native"]
assert pkg.foreign_check_dependencies(pacman) == ["check-foreign"]
# --- CustomPackage ---------------------------------------------------------
def test_custompackage_requires_exactly_one_source():
with pytest.raises(ValueError, match="cannot be None"):
CustomPackage("pkg", git_url=None, pkgbuild_directory=None)
with pytest.raises(ValueError, match="cannot be set"):
CustomPackage("pkg", git_url="git://example", pkgbuild_directory="/tmp")
class DummyCommands:
"""Minimal stub; only here so type checks pass where needed."""
pass
@pytest.mark.parametrize(
"srcinfo, expected_version",
[
(
"""
pkgbase = foo
pkgver = 1.2.3
pkgrel = 4
pkgname = foo
""",
"1.2.3-4",
),
(
"""
pkgbase = foo
pkgver = 1.2.3
pkgrel = 4
epoch = 2
pkgname = foo
""",
"2:1.2.3-4",
),
(
"""
pkgbase = foo
pkgver = 1.2.3
pkgname = foo
""",
"1.2.3",
),
],
)
def test_parse_srcinfo_version_handling(srcinfo: str, expected_version: str) -> None:
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy")
info = pkg._parse_srcinfo(srcinfo)
assert info.pkgname == "foo"
assert info.pkgbase == "foo"
assert info.version == expected_version
def test_parse_srcinfo_single_package_dependencies() -> None:
srcinfo = """
pkgbase = foo
pkgver = 1.2.3
pkgrel = 1
depends = bar>=1.0
makedepends = baz
checkdepends = qux
pkgname = foo
"""
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy")
info = pkg._parse_srcinfo(srcinfo)
assert info.dependencies == ("bar>=1.0",)
assert info.make_dependencies == ("baz",)
assert info.check_dependencies == ("qux",)
def test_parse_srcinfo_split_package_uses_only_target_pkg_dependencies(monkeypatch) -> None:
# Ensure arch-specific keys match
monkeypatch.setattr(pkg_mod.config, "arch", "x86_64", raising=False)
srcinfo = """
pkgbase = clion
pkgver = 2025.3
pkgrel = 1
makedepends = rsync
depends = base-dep
depends_x86_64 = base-arch-dep
pkgname = clion
depends = libdbusmenu-glib
depends_x86_64 = clion-arch-dep
checkdepends = clion-check
pkgname = clion-jre
depends = jre-dep
makedepends = jre-make
pkgname = clion-cmake
depends = cmake-dep
"""
pkg = CustomPackage(pkgname="clion", git_url=None, pkgbuild_directory="/dummy")
info = pkg._parse_srcinfo(srcinfo)
# version
assert info.pkgbase == "clion"
assert info.version == "2025.3-1"
# base deps + target pkg deps (including arch-specific)
assert info.dependencies == (
"base-dep",
"base-arch-dep",
"libdbusmenu-glib",
"clion-arch-dep",
)
# only base and target pkg makedepends
assert info.make_dependencies == ("rsync",)
# base + target pkg checkdepends
assert info.check_dependencies == ("clion-check",)
def test_parse_srcinfo_arch_specific_ignored_for_other_arch(monkeypatch) -> None:
# Different arch → *_x86_64 keys should be ignored
monkeypatch.setattr(pkg_mod.config, "arch", "aarch64", raising=False)
srcinfo = """
pkgbase = foo
pkgver = 1.0
pkgrel = 1
depends_x86_64 = base-arch-dep
pkgname = foo
depends = common-dep
depends_x86_64 = pkg-arch-dep
"""
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy")
info = pkg._parse_srcinfo(srcinfo)
# Only common deps, no *_x86_64 because arch != x86_64
assert info.dependencies == ("common-dep",)
def test_parse_srcinfo_missing_required_fields_raises() -> None:
# Missing pkgbase
srcinfo_no_pkgbase = """
pkgver = 1.0
pkgrel = 1
pkgname = foo
"""
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy")
with pytest.raises(PKGBUILDParseError) as excinfo:
pkg._parse_srcinfo(srcinfo_no_pkgbase)
assert "pkgbase/pkgver" in str(excinfo.value)
# Missing pkgver
srcinfo_no_pkgver = """
pkgbase = foo
pkgname = foo
"""
with pytest.raises(PKGBUILDParseError) as excinfo2:
pkg._parse_srcinfo(srcinfo_no_pkgver)
assert "pkgbase/pkgver" in str(excinfo2.value)
def test_parse_srcinfo_missing_target_pkg_raises() -> None:
srcinfo = """
pkgbase = foo
pkgver = 1.0
pkgrel = 1
pkgname = other
"""
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/dummy")
with pytest.raises(PKGBUILDParseError) as excinfo:
pkg._parse_srcinfo(srcinfo)
msg = str(excinfo.value)
assert "Package foo not found in SRCINFO" in msg
assert "other" in msg # listed in present packages
def test_srcinfo_from_pkgbuild_directory_missing_dir_raises(tmp_path: pathlib.Path) -> None:
missing = tmp_path / "does-not-exist"
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory=str(missing))
with pytest.raises(PKGBUILDParseError) as excinfo:
pkg._srcinfo_from_pkgbuild_directory(DummyCommands())
msg = str(excinfo.value)
assert "does not exist or is not a directory" in msg
def test_srcinfo_from_pkgbuild_directory_missing_pkgbuild_raises(tmp_path: pathlib.Path) -> None:
path = tmp_path / "pkgdir"
path.mkdir()
pkg = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory=str(path))
with pytest.raises(PKGBUILDParseError) as excinfo:
pkg._srcinfo_from_pkgbuild_directory(DummyCommands())
msg = str(excinfo.value)
assert "No PKGBUILD found" in msg
def test_custom_package_equality_and_hash() -> None:
a1 = CustomPackage(
pkgname="foo", git_url="https://example.com/repo.git", pkgbuild_directory=None
)
a2 = CustomPackage(
pkgname="foo", git_url="https://example.com/repo.git", pkgbuild_directory=None
)
b = CustomPackage(pkgname="foo", git_url=None, pkgbuild_directory="/some/path")
assert a1 == a2
assert hash(a1) == hash(a2)
assert a1 != b
assert hash(a1) != hash(b)
def test_custom_package_str_git_and_directory() -> None:
git_pkg = CustomPackage(
pkgname="foo",
git_url="https://example.com/repo.git",
pkgbuild_directory=None,
)
dir_pkg = CustomPackage(
pkgname="foo",
git_url=None,
pkgbuild_directory="/some/path",
)
assert "pkgname=foo" in str(git_pkg)
assert "git_url=https://example.com/repo.git" in str(git_pkg)
assert "pkgname=foo" in str(dir_pkg)
assert "pkgbuild_directory=/some/path" in str(dir_pkg)
# --- PackageSearch: caching ------------------------------------------------
def _make_pkg(name: str = "pkg") -> PackageInfo:
return PackageInfo(
pkgname=name,
pkgbase=name,
version="1.0",
git_url=f"git://example/{name}",
provides=("virt-" + name,),
dependencies=("dep",),
make_dependencies=(),
check_dependencies=(),
)
def test_add_custom_pkg_caches_package():
search = PackageSearch()
pkg = _make_pkg("foo")
search.add_custom_pkg(pkg)
assert pkg in search._custom_packages
assert search._package_cache["foo"] is pkg
assert search._all_providers_cache["virt-foo"] == ["foo"]
def test_try_caching_packages_skips_already_cached(monkeypatch):
search = PackageSearch()
pkg = _make_pkg("foo")
search._cache_pkg(pkg)
calls = []
def fake_get(*args, **kwargs):
calls.append((args, kwargs))
raise AssertionError("requests.get should not be called")
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
search.try_caching_packages(["foo"])
assert calls == []
def test_try_caching_packages_caches_from_aur(monkeypatch):
search = PackageSearch()
def fake_get(url, timeout):
class Resp:
def json(self):
return {
"type": "success",
"results": [
{
"Name": "bar",
"PackageBase": "bar-base",
"Version": "2.0",
"Depends": ["dep1"],
"MakeDepends": ["make1"],
"CheckDepends": ["check1"],
"Provides": ["virt-bar"],
}
],
}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
search.try_caching_packages(["bar"])
assert "bar" in search._package_cache
info = search._package_cache["bar"]
assert isinstance(info, PackageInfo)
assert search._all_providers_cache["virt-bar"] == ["bar"]
def test_try_caching_packages_aur_returns_error(monkeypatch):
search = PackageSearch()
def fake_get(url, timeout):
class Resp:
def json(self):
return {"type": "error", "error": "boom"}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
with pytest.raises(AurRPCError):
search.try_caching_packages(["bar"])
def test_try_caching_packages_request_exception_raises_aur_error(monkeypatch):
search = PackageSearch()
class DummyError(pkg_mod.requests.RequestException):
pass
def fake_get(url, timeout):
raise DummyError("boom")
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
with pytest.raises(AurRPCError):
search.try_caching_packages(["bar"])
# --- PackageSearch: get_package_info --------------------------------------
def test_get_package_info_returns_from_cache():
search = PackageSearch()
pkg = _make_pkg("foo")
search._cache_pkg(pkg)
result = search.get_package_info("foo")
assert result is pkg
def test_get_package_info_returns_custom_package_if_not_cached():
search = PackageSearch()
pkg = _make_pkg("foo")
search._custom_packages.append(pkg)
result = search.get_package_info("foo")
assert result is pkg
assert search._package_cache["foo"] is pkg
def test_get_package_info_aur_not_found_returns_none(monkeypatch):
search = PackageSearch()
def fake_get(url, timeout):
class Resp:
def json(self):
return {"type": "success", "resultcount": 0, "results": []}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
result = search.get_package_info("foo")
assert result is None
assert "foo" not in search._package_cache
def test_get_package_info_aur_success_caches_and_returns(monkeypatch):
search = PackageSearch()
def fake_get(url, timeout):
class Resp:
def json(self):
return {
"type": "success",
"resultcount": 1,
"results": [
{
"Name": "foo",
"PackageBase": "foo-base",
"Version": "1.2",
"Depends": ["dep1"],
"MakeDepends": ["make1"],
"CheckDepends": ["check1"],
"Provides": ["virt-foo"],
}
],
}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
result = search.get_package_info("foo")
assert isinstance(result, PackageInfo)
assert result.pkgname == "foo"
assert search._package_cache["foo"] is result
def test_get_package_info_aur_returns_error(monkeypatch):
search = PackageSearch()
def fake_get(url, timeout):
class Resp:
def json(self):
return {"type": "error", "error": "boom"}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
with pytest.raises(AurRPCError):
search.get_package_info("foo")
def test_get_package_info_request_exception_raises_aur_error(monkeypatch):
search = PackageSearch()
class DummyError(pkg_mod.requests.RequestException):
pass
def fake_get(url, timeout):
raise DummyError("boom")
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
with pytest.raises(AurRPCError):
search.get_package_info("foo")
# --- PackageSearch: find_provider -----------------------------------------
def test_find_provider_uses_selected_providers_cache():
search = PackageSearch()
pkg = _make_pkg("foo")
search._selected_providers_cache["dep"] = pkg
result = search.find_provider("dep")
assert result is pkg
def test_find_provider_exact_name_match(monkeypatch):
search = PackageSearch()
pkg = _make_pkg("dep")
def fake_get_package_info(name: str):
assert name == "dep"
return pkg
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
result = search.find_provider("dep")
assert result is pkg
assert search._selected_providers_cache["dep"] is pkg
def test_find_provider_single_known_provider(monkeypatch):
search = PackageSearch()
pkg = _make_pkg("provider")
search._all_providers_cache["dep"] = ["provider"]
def fake_get_package_info(name: str):
if name == "dep":
return None
assert name == "provider"
return pkg
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
result = search.find_provider("dep")
assert result is pkg
assert search._selected_providers_cache["dep"] is pkg
def test_find_provider_aur_search_not_found(monkeypatch):
search = PackageSearch()
def fake_get_package_info(name: str):
# Exact name match should fail
return None
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
def fake_get(url, timeout):
class Resp:
def json(self):
return {"type": "success", "resultcount": 0, "results": []}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
result = search.find_provider("dep")
assert result is None
def test_find_provider_aur_search_single_result(monkeypatch):
search = PackageSearch()
pkg = _make_pkg("provider")
def fake_get_package_info(name: str):
# first call for stripped_dependency -> None
if name == "dep":
return None
assert name == "provider"
return pkg
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
def fake_get(url, timeout):
class Resp:
def json(self):
return {
"type": "success",
"resultcount": 1,
"results": [{"Name": "provider"}],
}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
result = search.find_provider("dep")
assert result is pkg
def test_find_provider_aur_search_multiple_results_calls_choose_provider(monkeypatch):
search = PackageSearch()
def fake_get_package_info(name: str):
# no exact match
return None
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
def fake_get(url, timeout):
class Resp:
def json(self):
return {
"type": "success",
"resultcount": 2,
"results": [{"Name": "a"}, {"Name": "b"}],
}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
sentinel = object()
def fake_choose(dep, providers, where):
assert dep == "dep"
assert providers == ["a", "b"]
assert where == "AUR"
return sentinel
monkeypatch.setattr(search, "_choose_provider", fake_choose)
result = search.find_provider("dep")
assert result is sentinel
def test_find_provider_aur_search_error(monkeypatch):
search = PackageSearch()
def fake_get_package_info(name: str):
return None
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
def fake_get(url, timeout):
class Resp:
def json(self):
return {"type": "error", "error": "boom"}
return Resp()
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
with pytest.raises(AurRPCError):
search.find_provider("dep")
def test_find_provider_aur_search_request_exception_raises_aur_error(monkeypatch):
search = PackageSearch()
def fake_get_package_info(name: str):
return None
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
class DummyError(pkg_mod.requests.RequestException):
pass
def fake_get(url, timeout):
raise DummyError("boom")
monkeypatch.setattr(pkg_mod.requests, "get", fake_get)
with pytest.raises(AurRPCError):
search.find_provider("dep")
# --- PackageSearch: _choose_provider --------------------------------------
def test_choose_provider_prompts_and_caches(monkeypatch):
search = PackageSearch()
providers = ["a", "b", "c"]
selected_pkg = _make_pkg("b")
# override prompt to select "2" (provider "b")
monkeypatch.setattr(
pkg_mod.output,
"prompt_number",
lambda *a, **k: 2,
)
def fake_get_package_info(name: str):
assert name == "b"
return selected_pkg
monkeypatch.setattr(search, "get_package_info", fake_get_package_info)
result = search._choose_provider("dep", providers, "AUR")
assert result is selected_pkg
assert search._selected_providers_cache["dep"] is selected_pkg
================================================
FILE: plugins/decman-pacman/tests/test_decman_plugins_aur_resolver.py
================================================
import pytest
from decman.plugins.aur.error import DependencyCycleError
from decman.plugins.aur.resolver import DepGraph, ForeignPackage
def test_add_dependency():
graph = DepGraph()
graph.add_requirement("A", None)
graph.add_requirement("B1", "A")
graph.add_requirement("B2", "A")
graph.add_requirement("C", "B1")
assert "B1" in graph.package_nodes["A"].children
assert "B2" in graph.package_nodes["A"].children
assert "C" in graph.package_nodes["B1"].children
def test_cyclic_dependency_raises():
graph = DepGraph()
graph.add_requirement("A", None)
graph.add_requirement("B", "A")
graph.add_requirement("C", "B")
with pytest.raises(DependencyCycleError):
graph.add_requirement("A", "C")
def _build_graph_for_outer_deps() -> DepGraph:
graph = DepGraph()
# Roots
graph.add_requirement("A", None)
graph.add_requirement("V", None)
# Level B
graph.add_requirement("B1", "A")
graph.add_requirement("B2", "A")
graph.add_requirement("B3", "A")
# Extra dependency B1 -> B2
graph.add_requirement("B1", "B2")
# Level C
graph.add_requirement("C1", "B1")
graph.add_requirement("C2", "B1")
# Level D + cycle-ish edges
graph.add_requirement("D", "C1")
graph.add_requirement("C2", "D")
# Foreign packages and their foreign deps
defs = {
"V": [],
"A": ["B1", "B2", "B3", "C1", "C2", "D"],
"B1": ["C1", "C2", "D"],
"B2": ["B1", "C1", "C2", "D"],
"B3": [],
"C1": ["D", "C2"],
"C2": [],
"D": ["C2"],
}
for name, deps in defs.items():
pkg = ForeignPackage(name)
pkg.add_foreign_dependency_packages(deps)
return graph
def _assert_outer_dep_names(graph: DepGraph, expected: set[str]) -> None:
result = graph.get_and_remove_outer_dep_pkgs()
names = {pkg.name for pkg in result}
assert names == expected
def test_get_and_remove_outer_deps_sequence():
graph = _build_graph_for_outer_deps()
_assert_outer_dep_names(graph, {"C2", "B3", "V"})
_assert_outer_dep_names(graph, {"D"})
_assert_outer_dep_names(graph, {"C1"})
_assert_outer_dep_names(graph, {"B1"})
_assert_outer_dep_names(graph, {"B2"})
_assert_outer_dep_names(graph, {"A"})
_assert_outer_dep_names(graph, set())
================================================
FILE: plugins/decman-pacman/tests/test_decman_plugins_pacman.py
================================================
from typing import Any
import pytest
from decman.plugins import pacman as pacman_plugin
@pytest.mark.parametrize(
"dep,expected",
[
("foo", "foo"),
("foo=1.0", "foo"),
("bar>=2", "bar"),
("baz<3", "baz"),
("multi=1.0-2", "multi"),
],
)
def test_strip_dependency(dep, expected):
assert pacman_plugin.strip_dependency(dep) == expected
class FakeStore(dict):
def ensure(self, key: str, default: Any) -> None:
if key not in self:
self[key] = default
class FakeModule:
def __init__(self, name: str, packages: set[str]) -> None:
self.name = name
self._changed = False
self._packages = packages
def test_process_modules_collects_packages_and_marks_changed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
mod1 = FakeModule("mod1", {"pkg1", "pkg2"})
mod2 = FakeModule("mod2", {"pkg3"})
def fake_run_methods_with_attribute(mod: FakeModule, attr: str) -> set[str]:
assert attr == "__pacman__packages__"
return [mod._packages]
monkeypatch.setattr(
pacman_plugin.plugins,
"run_methods_with_attribute",
fake_run_methods_with_attribute,
)
pacman.process_modules(store, {mod1, mod2})
# packages collected
assert pacman.packages == {"pkg1", "pkg2", "pkg3"}
# stored mapping per module
assert store["packages_for_module"]["mod1"] == {"pkg1", "pkg2"}
assert store["packages_for_module"]["mod2"] == {"pkg3"}
# modules marked changed (first run)
assert mod1._changed is True
assert mod2._changed is True
def test_apply_dry_run_computes_sets_and_does_not_call_pacman(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
# Desired state
pacman.packages = {"keep-explicit", "new-pkg"}
# Fake PacmanInterface returned by plugin module
class FakePM:
def __init__(
self, commands, print_highlights, keywords, database_signature_level, database_path
) -> None: # noqa: D401
self.commands = commands
self.print_highlights = print_highlights
self.keywords = keywords
self.remove_called_with: set[str] | None = None
self.set_as_deps_called_with: set[str] | None = None
self.upgrade_called = False
self.install_called_with: set[str] | None = None
def get_native_explicit(self) -> set[str]:
# keep-explicit (in desired), old-explicit (to demote/remove)
return {"keep-explicit", "old-explicit"}
def get_foreign_explicit(self) -> set[str]:
# foreign-package protects its deps
return {"foreign-pkg"}
def get_native_orphans(self) -> set[str]:
# orphan-explicit is also candidate
return {"orphan-explicit"}
def get_dependants(self, pkg: str) -> set[str]:
# old-explicit has a foreign dependant -> demote to dep
# orphan-explicit has no dependants -> remove
if pkg == "old-explicit":
return {"foreign-pkg"}
if pkg == "orphan-explicit":
return set()
return set()
def remove(self, pkgs: set[str]) -> None:
self.remove_called_with = pkgs
def set_as_dependencies(self, pkgs: set[str]) -> None:
self.set_as_deps_called_with = pkgs
def upgrade(self) -> None:
self.upgrade_called = True
def install(self, pkgs: set[str]) -> None:
self.install_called_with = pkgs
fake_pm = FakePM(None, None, None, None, None)
def fake_pm_ctor(
commands, print_highlights, keywords, database_signature_level, database_path
) -> FakePM:
# constructor used in Pacman.apply
fake_pm.commands = commands
fake_pm.print_highlights = print_highlights
fake_pm.keywords = keywords
return fake_pm
monkeypatch.setattr(pacman_plugin, "PacmanInterface", fake_pm_ctor)
printed_lists: list[tuple[str, list[str]]] = []
printed_summaries: list[str] = []
def fake_print_list(title: str, items: list[str]) -> None:
printed_lists.append((title, items))
def fake_print_summary(msg: str) -> None:
printed_summaries.append(msg)
monkeypatch.setattr(pacman_plugin.output, "print_list", fake_print_list)
monkeypatch.setattr(pacman_plugin.output, "print_summary", fake_print_summary)
ok = pacman.apply(store, dry_run=True)
assert ok is True
# to_remove = (native | orphans) - desired
# = {keep-explicit, old-explicit} ∪ {orphan-explicit} - {keep-explicit, new-pkg}
# = {old-explicit, orphan-explicit}
#
# old-explicit has foreign dependant -> demoted to dep
# orphan-explicit has no dependants -> removed
# printed lists (titles and contents)
titles = [t for t, _ in printed_lists]
assert "Removing pacman packages:" in titles
assert "Setting previously explicitly installed packages as dependencies:" in titles
assert "Installing pacman packages:" in titles
# find lists by title
remove_list = next(items for t, items in printed_lists if "Removing pacman packages:" in t)
demote_list = next(
items
for t, items in printed_lists
if "Setting previously explicitly installed packages as dependencies:" in t
)
install_list = next(items for t, items in printed_lists if "Installing pacman packages:" in t)
assert remove_list == ["orphan-explicit"]
assert demote_list == ["old-explicit"]
# to_install = desired - currently_installed_native
# = {keep-explicit, new-pkg} - {keep-explicit, old-explicit}
# = {new-pkg}
assert install_list == ["new-pkg"]
# Upgrade summary printed even in dry-run
assert any("Upgrading packages." in s for s in printed_summaries)
# No mutating calls in dry-run
assert fake_pm.remove_called_with is None
assert fake_pm.set_as_deps_called_with is None
assert fake_pm.upgrade_called is False
assert fake_pm.install_called_with is None
def test_apply_returns_false_on_command_failure(monkeypatch: pytest.MonkeyPatch) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
pacman.packages = set()
class FailingPM:
def __init__(self, *args, **kwargs) -> None: # noqa: D401
pass
def get_native_explicit(self) -> set[str]:
raise pacman_plugin.errors.CommandFailedError(["get_native_explicit"], 10, "boom")
monkeypatch.setattr(pacman_plugin, "PacmanInterface", FailingPM)
errors_logged: list[str] = []
continuations: list[str] = []
traceback_called = []
def fake_print_error(msg: str) -> None:
errors_logged.append(msg)
def fake_print_traceback() -> None:
traceback_called.append(True)
def fake_print_continuation(msg: str) -> None:
continuations.append(msg)
monkeypatch.setattr(pacman_plugin.output, "print_error", fake_print_error)
monkeypatch.setattr(pacman_plugin.output, "print_traceback", fake_print_traceback)
monkeypatch.setattr(pacman_plugin.output, "print_continuation", fake_print_continuation)
ok = pacman.apply(store, dry_run=False)
assert ok is False
assert any("Pacman command exited with an unexpected" in msg for msg in errors_logged)
assert any("boom" in msg for msg in continuations)
assert traceback_called # at least once
def test_ignored_packages_are_not_removed_or_installed(monkeypatch: pytest.MonkeyPatch) -> None:
pacman = pacman_plugin.Pacman()
store = FakeStore()
# Desired state: "already" and "new" should be managed normally.
# "ignored-installed" is currently installed but not desired -> would normally be removed.
# "ignored-uninstalled" is desired but not installed -> would normally be installed.
pacman.packages = {"already", "new", "ignored-uninstalled"}
pacman.ignored_packages = {"ignored-installed", "ignored-uninstalled"}
class FakePM:
def __init__(
self, commands, print_highlights, keywords, database_signature_level, database_path
) -> None: # noqa: D401
self.commands = commands
self.print_highlights = print_highlights
self.keywords = keywords
self.remove_called_with: set[str] | None = None
self.install_called_with: set[str] | None = None
self.set_as_deps_called_with: set[str] | None = None
self.upgrade_called = False
def get_native_explicit(self) -> set[str]:
# currently installed explicit packages
return {"ignored-installed", "already"}
def get_foreign_explicit(self) -> set[str]:
return set()
def get_native_orphans(self) -> set[str]:
return set()
def get_dependants(self, pkg: str) -> set[str]:
return set()
def remove(self, pkgs: set[str]) -> None:
self.remove_called_with = pkgs
def set_as_dependencies(self, pkgs: set[str]) -> None:
self.set_as_deps_called_with = pkgs
def upgrade(self) -> None:
self.upgrade_called = True
def install(self, pkgs: set[str]) -> None:
self.install_called_with = pkgs
fake_pm = FakePM(None, None, None, None, None)
def fake_pm_ctor(
commands, print_highlights, keywords, database_signature_level, database_path
) -> FakePM:
fake_pm.commands = commands
fake_pm.print_highlights = print_highlights
fake_pm.keywords = keywords
return fake_pm
monkeypatch.setattr(pacman_plugin, "PacmanInterface", fake_pm_ctor)
printed_lists: list[tuple[str, list[str]]] = []
def fake_print_list(title: str, items: list[str]) -> None:
printed_lists.append((title, items))
# don't care about summaries here
monkeypatch.setattr(pacman_plugin.output, "print_list", fake_print_list)
monkeypatch.setattr(pacman_plugin.output, "print_summary", lambda *_args, **_kw: None)
ok = pacman.apply(store, dry_run=False)
assert ok is True
# Ignored packages must never be passed to remove() or install()
assert (
fake_pm.remove_called_with is None or "ignored-installed" not in fake_pm.remove_called_with
)
assert fake_pm.install_called_with is not None
assert "ignored-uninstalled" not in fake_pm.install_called_with
# Also ensure the printed install list doesn't contain ignored packages
install_items = next(
items for title, items in printed_lists if "Installing pacman packages:" in title
)
assert "ignored-uninstalled" not in install_items
# "new" is the only package that should be installed in this scenario
assert install_items == ["new"]
================================================
FILE: plugins/decman-pacman/tests/test_deep_orphan_removal.py
================================================
import pyalpm
import pytest
from decman.plugins.aur import AurPacmanInterface
from decman.plugins.pacman import PacmanInterface
class FakePackage:
def __init__(self, name: str, is_explicit: bool, required_by: list[str]):
self.name = name
self.reason = pyalpm.PKG_REASON_EXPLICIT if is_explicit else pyalpm.PKG_REASON_DEPEND
self.required_by = required_by
self.provides = [name]
def compute_requiredby(self):
return self.required_by
class FakeDB:
def __init__(self, pkgcache: list[FakePackage]):
self.pkgcache = pkgcache
class FakePyalpmHandle:
def __init__(self):
pass
def get_syncdbs(self):
return [
FakeDB(
[
FakePackage("a", True, []),
FakePackage("b", False, ["a"]),
FakePackage("c", False, ["b"]),
FakePackage("d", False, []),
FakePackage("e", False, ["f"]),
FakePackage("f", False, ["g"]),
FakePackage("g", False, []),
]
)
]
def get_localdb(self):
return FakeDB(
self.get_syncdbs()[0].pkgcache
+ [
FakePackage("h", True, []),
FakePackage("i", False, ["h"]),
FakePackage("j", False, []),
FakePackage("k", False, ["l"]),
FakePackage("l", False, []),
]
)
def fake_create_pyalpm_handle(self):
return FakePyalpmHandle()
def test_get_native_orphans_pacman(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(PacmanInterface, "_create_pyalpm_handle", fake_create_pyalpm_handle)
interface = PacmanInterface(
None, # type: ignore
False,
set(),
2048,
"/var/lib/pacman/",
)
assert interface.get_native_orphans() == {"d", "e", "f", "g"}
def test_get_foreign_orphans_aur(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(AurPacmanInterface, "_create_pyalpm_handle", fake_create_pyalpm_handle)
interface = AurPacmanInterface(
None, # type: ignore
False,
set(),
2048,
"/var/lib/pacman/",
)
assert interface.get_foreign_orphans() == {"j", "k", "l"}
================================================
FILE: plugins/decman-pacman/tests/test_fpm.py
================================================
import typing
from unittest.mock import MagicMock
from urllib.parse import parse_qs, unquote, urlparse
import pytest
from decman.plugins.aur.commands import AurCommands
from decman.plugins.aur.fpm import ForeignPackageManager
from decman.plugins.aur.package import PackageInfo, PackageSearch
class FakeAurPacmanInterface:
def __init__(self) -> None:
self.installed_native: set[str] = set()
self.installed_foreign: dict[str, str] = {}
self.explicitly_installed: set[str] = set()
self.not_installable: set[str] = set()
self.installed_files: list[str] = [] # To track what install_files() actually does
self.provided_pkgs: set[str] = set()
def get_native_explicit(self) -> set[str]:
return self.installed_native.intersection(self.explicitly_installed)
def get_native_orphans(self) -> set[str]:
return set()
def get_foreign_explicit(self) -> set[str]:
return set(self.installed_foreign.keys()).intersection(self.explicitly_installed)
def get_dependants(self, package: str) -> set[str]:
return set()
def set_as_dependencies(self, packages: set[str]):
self.explicitly_installed.difference_update(packages)
def install(self, packages: set[str]):
self.installed_native.update(packages)
self.explicitly_installed.update(packages)
def upgrade(self):
pass
def is_provided_by_installed(self, dependency: str) -> bool:
return dependency in self.provided_pkgs
def get_all_packages(self) -> set[str]:
return self.installed_native | self.installed_foreign.keys()
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 remove(self, packages: set[str]):
self.installed_native.difference_update(packages)
for p in packages:
self.installed_foreign.pop(p, None)
self.explicitly_installed.difference_update(packages)
def get_foreign_orphans(self) -> set[str]:
return set()
def is_installable(self, pkg: str) -> bool:
return pkg not in self.not_installable
def get_versioned_foreign_packages(self) -> list[tuple[str, str]]:
return list(self.installed_foreign.items())
def install_dependencies(self, deps: set[str]):
self.installed_native.update(deps)
def install_files(self, files: list[str], as_explicit: set[str]):
self.installed_files.extend(files)
for file in files:
self.installed_foreign[file] = "file"
for pkg in as_explicit:
self.explicitly_installed.add(pkg)
class FakeStore:
def __init__(self) -> None:
self._store: dict[str, typing.Any] = {}
def __getitem__(self, key: str) -> typing.Any:
return self._store[key]
def __setitem__(self, key: str, value: typing.Any) -> None:
self._store[key] = value
def get(self, key: str, default: typing.Any = None) -> typing.Any:
return self._store.get(key, default)
def ensure(self, key: str, default: typing.Any = None):
if key not in self._store:
self._store[key] = default
def __enter__(self) -> "FakeStore":
return self
def __exit__(self, exc_type, exc, tb):
return False
def save(self) -> None:
pass
def __repr__(self) -> str:
return repr(self._store)
class MockAurServer:
def __init__(self) -> None:
self.db: dict[str, dict] = {} # Maps pkgname -> raw JSON result dict
def seed(self, packages: list[PackageInfo]):
for pkg in packages:
# Reconstruct the raw JSON structure expected by PackageSearch
entry = {
"Name": pkg.pkgname,
"PackageBase": pkg.pkgbase or pkg.pkgname,
"Version": pkg.version,
"Description": "Mock Description",
"URL": "https://example.com",
"Depends": pkg.dependencies,
"MakeDepends": pkg.make_dependencies,
"CheckDepends": pkg.check_dependencies,
"Provides": pkg.provides,
# Add other fields if your class relies on them
}
self.db[pkg.pkgname] = entry
def handle_request(self, url, *args, **kwargs):
parsed = urlparse(url)
path = parsed.path
query = parse_qs(parsed.query)
results = []
# --- Handle: Multi-info query (.../info?arg[]=pkg1&arg[]=pkg2) ---
if "/rpc/v5/info" in path and "arg[]" in query:
requested_names = query["arg[]"]
for name in requested_names:
if name in self.db:
results.append(self.db[name])
# --- Handle: Single info query (.../rpc/v5/info/pkgname) ---
elif "/rpc/v5/info/" in path:
# Extract package name from end of path
pkg_name = path.split("/")[-1]
if pkg_name in self.db:
results.append(self.db[pkg_name])
# --- Handle: Search providers (.../rpc/v5/search/dep?by=provides) ---
elif "/rpc/v5/search/" in path and query.get("by") == ["provides"]:
search_term = path.split("/")[-1]
search_term = unquote(search_term)
# Linear search through DB for 'Provides'
for entry in self.db.values():
if search_term in entry.get("Provides", []):
results.append(entry)
# Also match if the package name itself matches the provider request
elif entry["Name"] == search_term:
results.append(entry)
# Construct the response object
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"version": 5,
"type": "multiinfo",
"resultcount": len(results),
"results": results,
}
return mock_response
@pytest.fixture
def mock_aur(mocker):
server = MockAurServer()
mocker.patch("requests.get", side_effect=server.handle_request)
return server
@pytest.fixture
def mock_pacman(mocker):
pacman = FakeAurPacmanInterface()
return pacman
@pytest.fixture
def mock_fpm(mocker, mock_aur, mock_pacman):
mock_builder_cls = mocker.patch("decman.plugins.aur.fpm.PackageBuilder")
mock_builder_instance = mock_builder_cls.return_value
mock_builder_instance.__enter__.return_value = mock_builder_instance
mock_builder_instance.__exit__.return_value = None
# NOTE: find_latest_cached_package must return a tuple, otherwise
# the 'assert built_pkg is not None' line in install() will fail.
def mock_find_cached(store, package):
# return just the package, so that mock pacman can get the package name from the 'file' name
return ("1.0.0", package)
mocker.patch("decman.plugins.aur.fpm.find_latest_cached_package", side_effect=mock_find_cached)
mocker.patch("decman.plugins.aur.fpm.add_package_to_cache", return_value=None)
# handle prompts automatically
mocker.patch("decman.core.output.prompt_confirm", return_value=True)
store = FakeStore()
search = PackageSearch()
commands = AurCommands()
mgr = ForeignPackageManager(
store=store, # type: ignore
pacman=mock_pacman,
search=search,
commands=commands,
pkg_cache_dir="/tmp/cache",
build_dir="/tmp/build",
makepkg_user="nobody",
)
return mgr
def test_remove_pacman_deps_provided_by_foreign_packages(
mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface
):
mock_pacman.not_installable |= {"kwin-hifps", "qt6-base-hifps", "syncthingtray-qt6"}
pkgs = [
PackageInfo(
pkgbase="kwin-hifps",
pkgname="kwin-hifps",
version="1",
git_url="...",
dependencies=("qt6-base-hifps",),
),
PackageInfo(
pkgbase="qt6-base-hifps",
pkgname="qt6-base-hifps",
version="1",
git_url="...",
provides=("qt6-base",),
),
PackageInfo(
pkgbase="syncthingtray-qt6",
pkgname="syncthingtray-qt6",
version="1",
git_url="...",
dependencies=("qt6-base",),
),
]
mock_aur.seed(pkgs)
mock_fpm.install(["kwin-hifps", "syncthingtray-qt6"])
assert len(mock_pacman.installed_files) == 3
assert mock_pacman.explicitly_installed == {"kwin-hifps", "syncthingtray-qt6"}
assert "qt6-base" not in mock_pacman.installed_native
def test_remove_pacman_deps_provided_by_already_installed_foreign_packages(
mock_fpm, mock_aur, mock_pacman: FakeAurPacmanInterface
):
mock_pacman.not_installable |= {"kwin-hifps", "qt6-base-hifps", "syncthingtray-qt6"}
pkgs = [
PackageInfo(
pkgbase="kwin-hifps",
pkgname="kwin-hifps",
version="1",
git_url="...",
dependencies=("qt6-base-hifps",),
),
PackageInfo(
pkgbase="qt6-base-hifps",
pkgname="qt6-base-hifps",
version="1",
git_url="...",
provides=("qt6-base",),
),
PackageInfo(
pkgbase="syncthingtray-qt6",
pkgname="syncthingtray-qt6",
version="1",
git_url="...",
dependencies=("qt6-base",),
),
]
mock_pacman.installed_foreign = {
"kwin-hifps": "1",
"qt6-base-hifps": "1",
}
mock_pacman.explicitly_installed.add("kwin-hifps")
mock_pacman.provided_pkgs.add("qt6-base")
mock_aur.seed(pkgs)
mock_fpm.install(["syncthingtray-qt6"])
assert len(mock_pacman.installed_files) == 1
assert mock_pacman.explicitly_installed == {"kwin-hifps", "syncthingtray-qt6"}
assert "qt6-base" not in mock_pacman.installed_native
def test_install_simple_package(
mock_fpm, mock_pacman: FakeAurPacmanInterface, mock_aur: MockAurServer
):
mock_pacman.not_installable.add("foo")
pkg = PackageInfo(
pkgbase="foo",
pkgname="foo",
version="100.0.0",
git_url="...",
)
mock_aur.seed([pkg])
mock_fpm.install(["foo"])
assert len(mock_pacman.installed_files) == 1
assert "foo" in mock_pacman.installed_files[0]
assert "foo" in mock_pacman.explicitly_installed
assert "foo" in mock_pacman.installed_foreign
def test_upgrade_foreign_package(mock_fpm, mock_pacman, mock_aur):
mock_pacman.not_installable.add("my-app")
mock_pacman.installed_foreign = {"my-app": "1.0"}
mock_pacman.explicitly_installed = {"my-app"}
pkg = PackageInfo(
pkgbase="my-app",
pkgname="my-app",
version="2.0",
git_url="...",
)
mock_aur.seed([pkg])
mock_fpm.upgrade()
assert len(mock_pacman.installed_files) == 1
assert "my-app" in mock_pacman.installed_foreign
assert "my-app" in mock_pacman.installed_files[0]
def test_upgrade_skips_current_package(mock_fpm, mock_pacman, mock_aur):
mock_pacman.not_installable.add("stable-app")
mock_pacman.installed_foreign = {"stable-app": "5.0"}
mock_pacman.explicitly_installed = {"stable-app"}
pkg = PackageInfo(
pkgbase="stable-app",
pkgname="stable-app",
version="5.0",
git_url="...",
)
mock_aur.seed([pkg])
mock_fpm.upgrade()
assert len(mock_pacman.installed_files) == 0
def test_install_resolves_dependencies(mock_fpm, mock_pacman, mock_aur):
mock_pacman.not_installable |= {"lib-helper", "main-app"}
pkg_dep = PackageInfo(pkgbase="lib-helper", pkgname="lib-helper", version="1.5", git_url="...")
pkg_main = PackageInfo(
pkgbase="main-app",
pkgname="main-app",
version="2.0",
dependencies=("lib-helper",),
git_url="...",
)
mock_aur.seed([pkg_dep, pkg_main])
mock_fpm.install(["main-app"])
assert len(mock_pacman.installed_files) == 2
assert "main-app" in mock_pacman.explicitly_installed
assert "main-app" in mock_pacman.installed_files
assert "lib-helper" in mock_pacman.installed_files
================================================
FILE: plugins/decman-systemd/pyproject.toml
================================================
[project]
name = "decman-systemd"
version = "1.1.0"
requires-python = ">=3.13"
dependencies = ["decman==1.2.1"]
[project.entry-points."decman.plugins"]
systemd = "decman.plugins.systemd:Systemd"
[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-systemd/src/decman/plugins/systemd.py
================================================
import shutil
import decman.config as config
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 units(fn):
"""
Annotate that this function returns a set of systemd unit names that should be enabled.
Return type of ``fn``: ``set[str]``
"""
fn.__systemd__units__ = True
return fn
def user_units(fn):
"""
Annotate that this function returns a dict of users and systemd user unit names that should be
enabled.
Return type of ``fn``: ``dict[str, set[str]]``
"""
fn.__systemd__user__units__ = True
return fn
class 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"]
class Systemd(plugins.Plugin):
NAME = "systemd"
def __init__(self) -> None:
self.enabled_units: set[str] = set()
self.enabled_user_units: dict[str, set[str]] = {}
self.commands = SystemdCommands()
def available(self) -> bool:
return shutil.which("systemctl") 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("systemd_units_for_module", {})
store.ensure("systemd_user_units_for_module", {})
for mod in modules:
store["systemd_units_for_module"].setdefault(mod.name, set())
store["systemd_user_units_for_module"].setdefault(mod.name, {})
units = set().union(*plugins.run_methods_with_attribute(mod, "__systemd__units__"))
user_units = {
k: v
for d in plugins.run_methods_with_attribute(mod, "__systemd__user__units__")
for k, v in d.items()
}
if store["systemd_units_for_module"][mod.name] != units:
mod._changed = True
output.print_debug(
f"Module '{mod.name}' set to changed due to modified systemd units."
)
if store["systemd_user_units_for_module"][mod.name] != user_units:
mod._changed = True
output.print_debug(
f"Module '{mod.name}' set to changed due to modified systemd user units."
)
self.enabled_units |= units
for user, u_units in user_units.items():
self.enabled_user_units.setdefault(user, set()).update(u_units)
store["systemd_units_for_module"][mod.name] = units
store["systemd_user_units_for_module"][mod.name] = user_units
def apply(
self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None
) -> bool:
store.ensure("systemd_units", set())
store.ensure("systemd_user_units", {})
units_to_enable = set()
units_to_disable = set()
user_units_to_enable: dict[str, set[str]] = {}
user_units_to_disable: dict[str, set[str]] = {}
for unit in self.enabled_units:
if unit not in store["systemd_units"]:
units_to_enable.add(unit)
for unit in store["systemd_units"]:
if unit not in self.enabled_units:
units_to_disable.add(unit)
for user, units in self.enabled_user_units.items():
store["systemd_user_units"].setdefault(user, set())
user_units_to_enable.setdefault(user, set())
for unit in units:
if unit not in store["systemd_user_units"][user]:
user_units_to_enable[user].add(unit)
for user, units in store["systemd_user_units"].items():
self.enabled_user_units.setdefault(user, set())
user_units_to_disable.setdefault(user, set())
for unit in units:
if unit not in self.enabled_user_units[user]:
user_units_to_disable[user].add(unit)
try:
output.print_list("Enabling systemd units:", list(units_to_enable))
if not dry_run:
self.enable_units(store, units_to_enable)
output.print_list("Disabling systemd units:", list(units_to_disable))
if not dry_run:
self.disable_units(store, units_to_disable)
for user, units in user_units_to_enable.items():
output.print_list(f"Enabling systemd units for {user}:", list(units))
if not dry_run:
self.enable_user_units(store, units, user)
for user, units in user_units_to_disable.items():
output.print_list(f"Disabling systemd units for {user}:", list(units))
if not dry_run:
self.disable_user_units(store, units, user)
output.print_info("Reloading systemd daemon.")
if not dry_run:
self.reload_daemon()
output.print_info("Reloading systemd daemon for users.")
if not dry_run:
for user in user_units_to_enable.keys() | user_units_to_disable.keys():
self.reload_user_daemon(user)
except errors.CommandFailedError as error:
output.print_error("Running a systemd command failed.")
output.print_error(str(error))
if error.output:
output.print_command_output(error.output)
output.print_traceback()
return False
return True
def enable_units(self, store: _store.Store, units: set[str]):
"""
Enables the given units.
"""
if not units:
return
cmd = self.commands.enable_units(units)
command.prg(cmd, pty=config.debug_output)
store["systemd_units"] |= units
def disable_units(self, store: _store.Store, units: set[str]):
"""
Disables the given units.
"""
if not units:
return
cmd = self.commands.disable_units(units)
command.prg(cmd, pty=config.debug_output, check=False)
store["systemd_units"] -= units
def enable_user_units(self, store: _store.Store, units: set[str], user: str):
"""
Enables the given units for the given user.
"""
if not units:
return
# Use check=False to avoid issues when units don't exist
cmd = self.commands.enable_user_units(units, user)
command.prg(cmd, pty=config.debug_output)
store["systemd_user_units"].setdefault(user, set())
store["systemd_user_units"][user] |= units
def disable_user_units(self, store: _store.Store, units: set[str], user: str):
"""
Disables the given units for the given user.
"""
if not units:
return
# Use check=False to avoid issues when units don't exist
cmd = self.commands.disable_user_units(units, user)
command.prg(cmd, pty=config.debug_output, check=False)
store["systemd_user_units"].setdefault(user, set())
store["systemd_user_units"][user] -= units
def reload_user_daemon(self, user: str):
"""
Reloads the user's systemd daemon.
"""
cmd = self.commands.user_daemon_reload(user)
command.prg(cmd, pty=config.debug_output, check=False)
def reload_daemon(self):
"""
Reloads the systemd daemon.
"""
cmd = self.commands.daemon_reload()
command.prg(cmd, pty=config.debug_output)
================================================
FILE: plugins/decman-systemd/tests/test_decman_plugins_systemd.py
================================================
import pytest
from decman.plugins import systemd as systemd_mod
class DummyStore(dict):
def ensure(self, key, default):
if key not in self:
self[key] = default
class DummyModule:
def __init__(self, name: str):
self.name = name
self._changed = False
@pytest.fixture
def store():
return DummyStore()
@pytest.fixture
def systemd():
return systemd_mod.Systemd()
def test_units_decorator_sets_attribute():
@systemd_mod.units
def fn():
pass
assert getattr(fn, "__systemd__units__", False) is True
def test_user_units_decorator_sets_attribute():
@systemd_mod.user_units
def fn():
pass
assert getattr(fn, "__systemd__user__units__", False) is True
def test_available_true_if_systemctl_found(monkeypatch, systemd):
called = {}
def fake_which(name):
called["name"] = name
return "/bin/systemctl"
monkeypatch.setattr(systemd_mod.shutil, "which", fake_which)
assert systemd.available() is True
assert called["name"] == "systemctl"
def test_available_false_if_systemctl_missing(monkeypatch, systemd):
monkeypatch.setattr(systemd_mod.shutil, "which", lambda name: None)
assert systemd.available() is False
def test_process_modules_marks_changed_and_updates_store(monkeypatch, store, systemd):
# initial store empty; ensure keys will be created
m1 = DummyModule("mod1")
m2 = DummyModule("mod2")
def fake_run_method(mod, attr):
if mod is m1 and attr == "__systemd__units__":
return [{"a.service"}]
if mod is m1 and attr == "__systemd__user__units__":
return [{"alice": {"u1.service"}}]
# m2 has no units
return []
monkeypatch.setattr(systemd_mod.plugins, "run_methods_with_attribute", fake_run_method)
systemd.process_modules(store, {m1, m2})
# m1 changed from default -> marked _changed
assert m1._changed is True
# m2 had no units
assert m2._changed is False
# enabled units aggregated
assert systemd.enabled_units == {"a.service"}
assert systemd.enabled_user_units == {"alice": {"u1.service"}}
# store updated per module
assert store["systemd_units_for_module"]["mod1"] == {"a.service"}
assert store["systemd_user_units_for_module"]["mod1"] == {"alice": {"u1.service"}}
assert store["systemd_units_for_module"]["mod2"] == set()
assert store["systemd_user_units_for_module"]["mod2"] == {}
def test_process_modules_no_change_second_run(monkeypatch, store, systemd):
m1 = DummyModule("mod1")
def fake_run_method(mod, attr):
if attr == "__systemd__units__":
return [{"a.service"}]
if attr == "__systemd__user__units__":
return [{"alice": {"u1.service"}}]
return []
monkeypatch.setattr(systemd_mod.plugins, "run_methods_with_attribute", fake_run_method)
# first run populates store
systemd.process_modules(store, {m1})
m1._changed = False
# new instance (fresh per-process in real usage)
systemd2 = systemd_mod.Systemd()
monkeypatch.setattr(systemd_mod.plugins, "run_methods_with_attribute", fake_run_method)
systemd2.process_modules(store, {m1})
# values in store are same -> _changed stays False
assert m1._changed is False
def test_apply_enables_and_disables_units_and_user_units(store):
s = systemd_mod.Systemd()
# Current enabled according to modules
s.enabled_units = {"new.service"}
s.enabled_user_units = {"alice": {"newuser.service"}}
# Store says we had an old unit enabled before
store["systemd_units"] = {"old.service"}
store["systemd_user_units"] = {"alice": {"olduser.service"}}
calls = []
def fake_reload_daemon():
calls.append(("reload_daemon",))
def fake_reload_user_daemon(user):
calls.append(("reload_user_daemon", user))
def fake_enable_units(store_arg, units_arg):
calls.append(("enable_units", frozenset(units_arg)))
store_arg["systemd_units"] |= units_arg
def fake_disable_units(store_arg, units_arg):
calls.append(("disable_units", frozenset(units_arg)))
store_arg["systemd_units"] -= units_arg
def fake_enable_user_units(store_arg, units_arg, user):
calls.append(("enable_user_units", user, frozenset(units_arg)))
store_arg["systemd_user_units"].setdefault(user, set()).update(units_arg)
def fake_disable_user_units(store_arg, units_arg, user):
calls.append(("disable_user_units", user, frozenset(units_arg)))
store_arg["systemd_user_units"].setdefault(user, set()).difference_update(units_arg)
# patch instance methods (no self parameter expected)
s.reload_daemon = fake_reload_daemon
s.reload_user_daemon = fake_reload_user_daemon
s.enable_units = fake_enable_units
s.disable_units = fake_disable_units
s.enable_user_units = fake_enable_user_units
s.disable_user_units = fake_disable_user_units
result = s.apply(store, dry_run=False, params=None)
# reloads called once
assert ("reload_daemon",) in calls
assert ("reload_user_daemon", "alice") in calls
# enable/disable correct units
assert ("enable_units", frozenset({"new.service"})) in calls
assert ("disable_units", frozenset({"old.service"})) in calls
assert ("enable_user_units", "alice", frozenset({"newuser.service"})) in calls
assert ("disable_user_units", "alice", frozenset({"olduser.service"})) in calls
# store reconciled
assert store["systemd_units"] == {"new.service"}
assert store["systemd_user_units"]["alice"] == {"newuser.service"}
def test_apply_dry_run_does_not_mutate_store_or_call_commands(store):
s = systemd_mod.Systemd()
s.enabled_units = {"new.service"}
s.enabled_user_units = {"alice": {"newuser.service"}}
store["systemd_units"] = {"old.service"}
store["systemd_user_units"] = {"alice": {"olduser.service"}}
called = {"reload": False, "enable": False, "disable": False}
s.reload_daemon = lambda: called.__setitem__("reload", True) or True
s.reload_user_daemon = lambda user: called.__setitem__("reload", True) or True
s.enable_units = lambda st, u: called.__setitem__("enable", True) or True
s.disable_units = lambda st, u: called.__setitem__("disable", True) or True
s.enable_user_units = lambda st, u, user: called.__setitem__("enable", True) or True
s.disable_user_units = lambda st, u, user: called.__setitem__("disable", True) or True
result = s.apply(store, dry_run=True, params=None)
assert result is True
# no commands should be called
assert called == {"reload": False, "enable": False, "disable": False}
# store unchanged
assert store["systemd_units"] == {"old.service"}
assert store["systemd_user_units"]["alice"] == {"olduser.service"}
def test_enable_units_success(monkeypatch, store, systemd):
store["systemd_units"] = {"old.service"}
def fake_run(cmd, **kwargs):
assert cmd[0] == "systemctl"
assert cmd[1] == "enable"
assert "new.service" in cmd[2:]
return 0, "ok"
monkeypatch.setattr(systemd_mod.command, "run", fake_run)
systemd.enable_units(store, {"new.service"})
assert store["systemd_units"] == {"old.service", "new.service"}
def test_disable_units_success(monkeypatch, store, systemd):
store["systemd_units"] = {"old.service", "new.service"}
def fake_run(cmd, **kwargs):
assert cmd[0] == "systemctl"
assert cmd[1] == "disable"
assert "new.service" in cmd[2:]
return 0, "ok"
monkeypatch.setattr(systemd_mod.command, "run", fake_run)
systemd.disable_units(store, {"new.service"})
assert store["systemd_units"] == {"old.service"}
def test_enable_user_units_success(monkeypatch, store, systemd):
store["systemd_user_units"] = {"alice": {"olduser.service"}}
def fake_run(cmd, **kwargs):
assert cmd[0] == "systemctl"
assert "--user" in cmd
assert "enable" in cmd
assert "newuser.service" in cmd
return 0, "ok"
monkeypatch.setattr(systemd_mod.command, "run", fake_run)
systemd.enable_user_units(store, {"newuser.service"}, "alice")
assert store["systemd_user_units"]["alice"] == {
"olduser.service",
"newuser.service",
}
def test_disable_user_units_success(monkeypatch, store, systemd):
store["systemd_user_units"] = {"alice": {"olduser.service", "newuser.service"}}
def fake_run(cmd, **kwargs):
assert cmd[0] == "systemctl"
assert "--user" in cmd
assert "disable" in cmd
assert "newuser.service" in cmd
return 0, "ok"
monkeypatch.setattr(systemd_mod.command, "run", fake_run)
systemd.disable_user_units(store, {"newuser.service"}, "alice")
assert store["systemd_user_units"]["alice"] == {"olduser.service"}
def test_reload_daemon_uses_command_run(monkeypatch, systemd):
called = {}
def fake_run(cmd, **kwargs):
called["cmd"] = cmd
return 0, "ok"
monkeypatch.setattr(systemd_mod.command, "run", fake_run)
systemd.reload_daemon()
assert called["cmd"][:2] == ["systemctl", "daemon-reload"]
def test_reload_user_daemon_uses_command_run(monkeypatch, systemd):
called = {}
def fake_run(cmd, **kwargs):
called["cmd"] = cmd
return 0, "ok"
monkeypatch.setattr(systemd_mod.command, "run", fake_run)
systemd.reload_user_daemon("alice")
cmd = called["cmd"]
assert cmd[0] == "systemctl"
assert "--user" in cmd
assert "daemon-reload" in cmd
================================================
FILE: pyproject.toml
================================================
[project]
name = "decman"
version = "1.2.1"
description = "Declarative package & configuration manager for Arch Linux."
license = "GPL-3.0-or-later"
license-files = ["LICENSE"]
authors = [
{name = "Kivi Kaitaniemi"}
]
requires-python = ">=3.13"
[project.optional-dependencies]
pacman = ["decman-pacman"]
systemd = ["decman-systemd"]
flatpak = ["decman-flatpak"]
[project.scripts]
decman = "decman.app:main"
[dependency-groups]
dev = [
"ruff>=0.14.9",
"pytest>=8.4.2",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.uv.workspace]
members = [
"plugins/decman-pacman",
"plugins/decman-systemd",
"plugins/decman-flatpak",
]
[tool.uv.sources]
decman = { workspace = true }
decman-pacman = { workspace = true }
decman-systemd = { workspace = true }
decman-flatpak = { workspace = true }
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py313"
[tool.ruff.lint]
select = [
"E", "F", "W", # base style/errors
"I", # import sorting
"B", # bugbear
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
================================================
FILE: src/decman/__init__.py
================================================
import typing
# Re-exports
from decman.core.command import prg
from decman.core.error import SourceError
from decman.core.fs import Directory, File, Symlink
from decman.core.module import Module
from decman.core.store import Store
from decman.plugins import Plugin, available_plugins
plugins: dict[str, Plugin] = available_plugins()
# Quick access for default plugins
try:
from decman.plugins.aur import AUR
from decman.plugins.pacman import Pacman
pacman: None | Pacman = None
_pacman = plugins.get("pacman", None)
if isinstance(_pacman, Pacman):
pacman = _pacman
aur: None | AUR = None
_aur = plugins.get("aur", None)
if isinstance(_aur, AUR):
aur = _aur
except ModuleNotFoundError:
pass
try:
from decman.plugins.flatpak import Flatpak
flatpak: None | Flatpak = None
_flatpak = plugins.get("flatpak", None)
if isinstance(_flatpak, Flatpak):
flatpak = _flatpak
except ModuleNotFoundError:
pass
try:
from decman.plugins.systemd import Systemd
systemd: None | Systemd = None
_systemd = plugins.get("systemd", None)
if isinstance(_systemd, Systemd):
systemd = _systemd
except ModuleNotFoundError:
pass
__all__ = [
"SourceError",
"File",
"Directory",
"Symlink",
"Module",
"Store",
"Plugin",
"prg",
"sh",
]
# -----------------------------------------
# Global variables for system configuration
# -----------------------------------------
files: dict[str, File] = {}
directories: dict[str, Directory] = {}
symlinks: dict[str, str | Symlink] = {}
modules: list[Module] = []
execution_order: list[str] = [
"files",
"pacman",
"aur",
"systemd",
]
def sh(
sh_cmd: str,
user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None,
mimic_login: bool = False,
pty: bool = True,
check: bool = True,
) -> str:
"""
Shortcut for running a shell command. Returns the output of that command.
Arguments:
sh_cmd:
Shell command to execute. The command is passed to the system shell /bin/sh.
user:
User name to run the command as. If set, the command is executed after dropping
privileges to this user.
env_overrides:
Environment variables to override or add for the command execution.
These values are merged on top of the current process environment.
mimic_login:
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:
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 CommandFailedError when the command exits with a non-zero status.
If False, print a warning when encountering a non-zero exit code.
"""
cmd = ["/bin/sh", "-c", sh_cmd]
return prg(
cmd, user=user, env_overrides=env_overrides, mimic_login=mimic_login, pty=pty, check=check
)
================================================
FILE: src/decman/app.py
================================================
import argparse
import os
import sys
import decman
import decman.config as conf
import decman.core.error as errors
import decman.core.file_manager as file_manager
import decman.core.module as _module
import decman.core.output as output
import decman.core.store as _store
_STORE_FILE = "/var/lib/decman/store.json"
def main():
"""
Main entry for the CLI app
"""
sys.pycache_prefix = os.path.join(conf.cache_dir, "python/")
parser = argparse.ArgumentParser(
prog="decman",
description="Declarative package & configuration manager for Arch Linux",
epilog="See the documentation: https://github.com/kiviktnm/decman",
)
parser.add_argument("--source", action="store", help="python file containing configuration")
parser.add_argument(
"--dry-run",
"--print",
action="store_true",
default=False,
help="print what would happen as a result of running decman",
)
parser.add_argument("--debug", action="store_true", default=False, help="show debug output")
parser.add_argument(
"--skip", nargs="*", type=str, default=[], help="skip the following execution steps"
)
parser.add_argument(
"--only", nargs="*", type=str, default=[], help="run only the following execution steps"
)
parser.add_argument(
"--no-hooks",
action="store_true",
default=False,
help="don't run hook methods for modules",
)
parser.add_argument(
"--no-color",
action="store_true",
default=False,
help="don't print messages with color",
)
parser.add_argument(
"--params", nargs="*", default=[], type=str, help="additional parameters passed to plugins"
)
args = parser.parse_args()
conf.debug_output = args.debug
if args.no_color:
conf.color_output = False
else:
conf.color_output = output.has_ansi_support()
if os.getuid() != 0:
output.print_error("Not running as root. Please run decman as root.")
sys.exit(1)
original_wd = os.getcwd()
failed = False
try:
with _store.Store(_STORE_FILE, args.dry_run) as store:
try:
_execute_source(store, args)
failed = not run_decman(store, args)
except errors.SourceError as error:
output.print_error(f"Error raised manually in the source: {error}")
output.print_traceback()
failed = True
except errors.CommandFailedError as error:
output.print_error(str(error))
if error.output:
output.print_command_output(error.output)
output.print_traceback()
failed = True
except ValueError as error:
output.print_error("ValueError raised from the source.")
output.print_error(str(error))
output.print_traceback()
failed = True
except errors.InvalidOnDisableError as error:
output.print_error(str(error))
output.print_traceback()
failed = True
except Exception as error:
output.print_error(f"Unexpected error while running decman: {error}")
output.print_traceback()
failed = True
except OSError as error:
output.print_error(
f"Failed to access decman store file '{_STORE_FILE}': {error.strerror or str(error)}."
)
output.print_error("This may cause already completed operations to run again.")
output.print_traceback()
except KeyboardInterrupt:
output.print_error("Interrupted by the user.")
failed = True
finally:
os.chdir(original_wd)
if failed:
sys.exit(1)
def _execute_source(store: _store.Store, args: argparse.Namespace):
"""
Runs decman source. May call ``sys.exit(1)`` if user aborts running the source or reading the
source fails.
Raises:
``SourceError``
If code in the source raises this error manually.
``InvalidOnDisableError``
If modules in the source have invalid on_disable functions.
"""
source = store.get("source_file", None)
source_changed = False
if args.source is not None:
source = args.source
source_changed = True
if source is None:
output.print_error(
"Source was not specified. Please specify a source with the '--source' argument."
)
output.print_info("Decman will remember the previously specified source.")
sys.exit(1)
if source_changed or not store.get("allow_running_source_without_prompt", False):
output.print_warning(f"Decman will run the file '{source}' as root!")
output.print_warning(
"Only proceed if you trust the file completely. The file can also import other files."
)
if not output.prompt_confirm("Proceed?", default=False):
sys.exit(1)
if output.prompt_confirm("Remember this choice?", default=False):
store["allow_running_source_without_prompt"] = True
source_path = os.path.abspath(source)
source_dir = os.path.dirname(source_path)
store["source_file"] = source_path
try:
with open(source_path, "rt", encoding="utf-8") as file:
content = file.read()
except OSError as error:
output.print_error(f"Failed to read source '{source_path}': {error.strerror or str(error)}")
sys.exit(1)
os.chdir(source_dir)
sys.path.append(".")
exec(content)
def run_decman(store: _store.Store, args: argparse.Namespace) -> bool:
"""
Runs decman with the given arguments and a store.
Returns ``True`` if executed succesfully. Otherwise ``False``.
Raises:
``SourceError``
If code in the source raises this error manually.
``CommandFailedError``
If running any command fails.
"""
output.print_debug(f"Available plugins: {' '.join(decman.plugins.keys())}")
store.ensure("enabled_modules", [])
store.ensure("module_on_disable_scripts", {})
execution_order = _determine_execution_order(args)
new_modules = _find_new_modules(store)
disabled_modules = _find_disabled_modules(store)
# Disable hooks should be run before anything else because they might depend on packages that
# are going to get removed.
if not args.no_hooks:
_run_before_update(store, args)
_run_on_disable(store, args, disabled_modules)
# Run main execution order
for step in execution_order:
output.print_info(f"Running step '{step}'.")
match step:
case "files":
if not file_manager.update_files(
store,
decman.modules,
decman.files,
decman.directories,
decman.symlinks,
dry_run=args.dry_run,
):
return False
case plugin_name:
plugin = decman.plugins.get(plugin_name, None)
if plugin:
plugin.process_modules(store, decman.modules)
if not plugin.apply(store, dry_run=args.dry_run, params=args.params):
return False
else:
output.print_warning(
f"Plugin '{plugin_name}' configured in execution_order, "
"but not found in available plugins."
)
# On enable and on change should be ran last since they might depend on effects caused by
# execution steps.
if not args.no_hooks:
_run_on_enable(store, args, new_modules)
_run_on_change(store, args)
_run_after_update(store, args)
return True
def _determine_execution_order(args: argparse.Namespace) -> list[str]:
execution_order = []
if args.only:
output.print_debug("Argument '--only' is set. Pruning execution steps.")
for step in decman.execution_order:
if step in args.only:
output.print_debug(f"Adding {step} to execution order.")
execution_order.append(step)
else:
execution_order = decman.execution_order
for skip in args.skip:
output.print_debug(f"Skipping step {skip}.")
execution_order.remove(skip)
output.print_debug(f"Execution order is: {', '.join(execution_order)}.")
return execution_order
def _find_new_modules(store: _store.Store):
new_modules = []
for module in decman.modules:
if module.name not in store["enabled_modules"]:
new_modules.append(module.name)
output.print_debug(f"New modules are: {', '.join(new_modules)}.")
return new_modules
def _find_disabled_modules(store: _store.Store):
disabled_modules = []
enabled_module_names = set(map(lambda m: m.name, decman.modules))
for module_name in store["enabled_modules"]:
if module_name not in enabled_module_names:
disabled_modules.append(module_name)
output.print_debug(f"Disabled modules are: {', '.join(disabled_modules)}.")
return disabled_modules
def _run_before_update(store: _store.Store, args: argparse.Namespace):
output.print_summary("Running before_update -hooks.")
for module in decman.modules:
output.print_debug(f"Running before_update for {module.name}.")
if not args.dry_run:
module.before_update(store)
def _run_on_disable(store: _store.Store, args: argparse.Namespace, disabled_modules: list[str]):
if not disabled_modules:
return
output.print_summary("Running on_disable -scripts.")
for disabled_module in disabled_modules:
on_disable_script = store["module_on_disable_scripts"].get(disabled_module, None)
if on_disable_script:
output.print_debug(f"Running on_disable for {disabled_module}.")
if not args.dry_run:
decman.prg([on_disable_script])
store["enabled_modules"].remove(disabled_module)
store["module_on_disable_scripts"].pop(disabled_module)
def _run_on_enable(store: _store.Store, args: argparse.Namespace, new_modules: list[str]):
if not new_modules:
return
output.print_summary("Running on_enable -hooks.")
for module in decman.modules:
if module.name in new_modules:
output.print_debug(f"Running on_enable for {module.name}.")
if not args.dry_run:
module.on_enable(store)
store["enabled_modules"].append(module.name)
try:
script = _module.write_on_disable_script(
module, conf.module_on_disable_scripts_dir
)
if script:
store["module_on_disable_scripts"][module.name] = script
except OSError as error:
output.print_error(
f"Failed to create on_disable script for module {module.name}: "
)
output.print_error(f"{error.strerror or str(error)}.")
output.print_traceback()
output.print_warning(
"This script will NOT be created when decman runs the next time."
)
output.print_warning(
"You should investigate the reason for the error and try to fix it."
)
output.print_warning(
"Then disable and re-enable this module to create the script."
)
def _run_on_change(store: _store.Store, args: argparse.Namespace):
output.print_summary("Running on_change -hooks.")
for module in decman.modules:
if module._changed:
output.print_debug(f"Running on_change for {module.name}.")
if not args.dry_run:
module.on_change(store)
def _run_after_update(store: _store.Store, args: argparse.Namespace):
output.print_summary("Running after_update -hooks.")
for module in decman.modules:
output.print_debug(f"Running after_update for {module.name}.")
if not args.dry_run:
module.after_update(store)
================================================
FILE: src/decman/config.py
================================================
"""
Module for decman configuration options.
NOTE: Do NOT use from imports as global variables might not work as you expect.
Only use:
import decman.config
or
import decman.config as whatever
-- Configuring commands --
Commands are stored as methods in the Commands-class.
The global variable 'commands' of this module is an instance of the Commands-class.
To change the defalts, create a new child class of the Commands-class and set the 'commands'
variable to an instance of your class. Look in the example directory for an example.
"""
debug_output: bool = False
quiet_output: bool = False
color_output: bool = True
module_on_disable_scripts_dir: str = "/var/lib/decman/scripts/"
cache_dir: str = "/var/cache/decman"
arch: str = "x86_64"
================================================
FILE: src/decman/core/__init__.py
================================================
================================================
FILE: src/decman/core/command.py
================================================
import errno
import fcntl
import os
import pty
import pwd
import select
import shlex
import shutil
import signal
import struct
import subprocess
import sys
import termios
import tty
import typing
import decman.core.error as errors
import decman.core.output as output
def get_user_info(user: str) -> tuple[int, int]:
"""
Returns UID and GID of the given user.
If the user doesn't exist, raises UserNotFoundError.
"""
info = _get_passwd(user)
return info.pw_uid, info.pw_gid
def prg(
cmd: list[str],
user: typing.Optional[str] = None,
env_overrides: typing.Optional[dict[str, str]] = None,
pass_environment: bool = True,
mimic_login: bool = False,
pty: bool = True,
check: bool = True,
) -> str:
"""
Shortcut for running a command. Returns the output of that command.
Arguments:
cmd:
Command to execute.
user:
User name to run the command as. If set, the command is executed after dropping
privileges to this user.
env_overrides:
Environment variables to override or add for the command execution.
These values are merged on top of the current process environment.
mimic_login:
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:
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.
If running in a PTY, the raised CommandFailedError will not contain command output,
since it has already been shown to the user.
check:
If True, raise CommandFailedError when the command exits with a non-zero status.
If False, print a warning when encountering a non-zero exit code.
"""
if pty:
result = pty_run(
cmd,
user=user,
env_overrides=env_overrides,
pass_environment=pass_environment,
mimic_login=mimic_login,
)
else:
result = run(
cmd,
user=user,
env_overrides=env_overrides,
pass_environment=pass_environment,
mimic_login=mimic_login,
)
if check:
# This raises an error if the command failed exiting the function early
result = check_run_result(cmd, result, include_output=not pty)
code, command_output = result
if code != 0:
output.print_warning(f"Command '{shlex.join(cmd)}' returned with an exit code {code}.")
if not pty:
output.print_command_output(command_output)
return command_output
def pty_run(
command: list[str],
user: None | str = None,
env_overrides: None | dict[str, str] = None,
mimic_login: bool = False,
pass_environment: bool = True,
) -> tuple[int, str]:
"""
Runs a given command with the given arguments in a pseudo TTY. The command can be ran as
the given user and environment variables can be overridden manually.
By default this will copy the current environment and pass it to the process. To prevent this
set ``pass_environment`` to ``False``.
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
If the given command is empty, returns (0, "").
Returns the return code of the command and the output as a string.
If the user doesn't exist, raises UserNotFoundError.
If forking the process fails or stdin is not a TTY, raises OSError.
"""
if not command:
return 0, ""
if not sys.stdin.isatty():
raise OSError(errno.ENOTTY, "Stdin is not a TTY.")
command[0] = shutil.which(command[0]) or command[0]
output.print_debug(f"Running command '{shlex.join(command)}'.")
env = _build_env(user, env_overrides, mimic_login, pass_environment)
pid, master_fd = pty.fork()
if pid == 0:
_exec_in_child(command, env, user)
return _run_parent(master_fd, pid)
def run(
command: list[str],
user: None | str = None,
env_overrides: None | dict[str, str] = None,
mimic_login: bool = False,
pass_environment: bool = True,
) -> tuple[int, str]:
"""
Runs a given command with the given arguments. The command can be ran as the given user and
environment variables can be overridden manually.
By default this will copy the current environment and pass it to the process. To prevent this
set ``pass_environment`` to ``False``.
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
If the given command is empty, returns (0, "").
Returns the return code of the command and the output as a string.
If the user doesn't exist, raises UserNotFoundError.
"""
if not command:
return 0, ""
command[0] = shutil.which(command[0]) or command[0]
output.print_debug(f"Running command '{shlex.join(command)}'.")
env = _build_env(user, env_overrides, mimic_login, pass_environment)
uid, gid = None, None
if user:
uid, gid = get_user_info(user)
try:
process = subprocess.Popen(
command, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, user=uid, group=gid
)
stdout, _ = process.communicate()
except OSError as error:
# Mirror PTY behavior: ": \n" and errno-based exit code
msg = error.strerror or str(error)
text_output = f"{command[0]}: {msg}\n"
code = error.errno if error.errno and error.errno < 128 else 127
return code, text_output
return process.returncode, stdout.decode("utf-8", errors="replace")
def check_run_result(
command: list[str], result: tuple[int, str], include_output: bool = True
) -> tuple[int, str]:
"""
Validates the result of a command execution.
If the command exited with a non-zero return code, raises CommandFailedError
containing the original command and its captured output.
Otherwise, returns the result unchanged.
"""
code, output = result
if code != 0:
if include_output:
raise errors.CommandFailedError(command, code, output)
else:
raise errors.CommandFailedError(command, code, None)
return code, output
def _build_env(
user: None | str,
env_overrides: None | dict[str, str],
mimic_login: bool,
pass_environment: bool,
) -> dict[str, str]:
env = {}
cwd = os.getcwd()
output.print_debug(
f"Command environment is: cwd='{cwd}', user='{user}', env_overrides='{env_overrides}', "
f"mimic_login='{mimic_login}', pass_environment='{pass_environment}'"
)
if pass_environment:
env = os.environ.copy()
if mimic_login and user:
pw = _get_passwd(user)
env.update(
{
"HOME": pw.pw_dir,
"USER": pw.pw_name,
"LOGNAME": pw.pw_name,
"SHELL": pw.pw_shell,
}
)
if env_overrides:
env.update(env_overrides)
return env
def _exec_in_child(command: list[str], env: dict[str, str], user: None | str) -> typing.NoReturn:
try:
if user:
uid, gid = get_user_info(user=user)
os.setgid(gid)
os.setuid(uid)
os.execve(command[0], command, env)
except OSError as error:
try:
os.write(2, f"{command[0]}: {error.strerror}\n".encode())
except OSError:
# Not much can be done, if outputting the failure state fails
pass
code = error.errno if (error.errno and error.errno < 128) else 127
os._exit(code)
def _run_parent(master_fd: int, pid: int) -> tuple[int, str]:
stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno()
# Put stdin into raw mode and save previous termios attributes.
old_tattr = termios.tcgetattr(stdin_fd)
tty.setraw(stdin_fd)
# Helper function to set PTY window size to the current terminal size
def resize_pty(*args):
try:
cols, rows = shutil.get_terminal_size()
winsz = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsz)
except OSError:
# In case the child has exited before the signal handled was de-registered
pass
# Set PTY window size to match the current terminal size.
resize_pty()
# Handle terminal resizes automatically
old_winch = signal.getsignal(signal.SIGWINCH)
signal.signal(signal.SIGWINCH, resize_pty)
try:
output_bytes = _relay_pty(master_fd, stdin_fd, stdout_fd)
finally:
# Restore stdin termios attributes.
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tattr)
# Restore previous handler
signal.signal(signal.SIGWINCH, old_winch)
os.close(master_fd)
_, status = os.waitpid(pid, 0)
exitcode = os.waitstatus_to_exitcode(status)
output = output_bytes.decode("utf-8", errors="replace").replace("\r\n", "\n")
return exitcode, output
def _relay_pty(master_fd: int, stdin_fd: int, stdout_fd: int) -> bytes:
"""
Drive interactive I/O between stdin/stdout and the PTY, capturing output.
"""
output_chunks: list[bytes] = []
while True:
# Wait until process or stdin has data
rlist, _, _ = select.select([master_fd, stdin_fd], [], [])
# Capture and echo child process
if master_fd in rlist:
try:
data = os.read(master_fd, 1024)
except OSError:
# Child process probably exited, EOF
break
output_chunks.append(data)
try:
os.write(stdout_fd, data)
except OSError:
# stdout closed, ignore
pass
# Forward stdin
if stdin_fd in rlist:
try:
data = os.read(stdin_fd, 1024)
os.write(master_fd, data)
except OSError:
# Either stdin EOF -> no data to pass
# or child died -> wait for master_fd to handle
pass
return b"".join(output_chunks)
def _get_passwd(user: str) -> pwd.struct_passwd:
try:
return pwd.getpwnam(user)
except KeyError as error:
raise errors.UserNotFoundError(user) from error
================================================
FILE: src/decman/core/error.py
================================================
import shlex
class SourceError(Exception):
"""
Error raised manually from the user's source.
"""
class FSInstallationFailedError(Exception):
"""
Error raised when trying to install a file/directory to a target.
"""
def __init__(self, source: str, target: str, reason: str):
self.source = source
self.target = target
super().__init__(f"Failed to install file from {source} to {target}: {reason}.")
class FSSymlinkFailedError(Exception):
"""
Error raised when trying to create a symlink to a target.
"""
def __init__(self, link_name: str, target: str, reason: str):
self.link_name = link_name
self.target = target
super().__init__(f"Failed to install symlink from {link_name} to {target}: {reason}.")
class InvalidOnDisableError(Exception):
"""
Error raised when trying to create a Module with an invalid on_disable method.
"""
def __init__(self, module: str, reason: str):
self.module = module
self.reason = reason
super().__init__(
f"Module '{module}' contains an invalid on_disable method. Reason: {reason}."
)
class UserNotFoundError(Exception):
"""
Raised when a specified user cannot be found in the system.
Attributes:
user (str): The user that caused the exception.
"""
def __init__(self, user: str) -> None:
self.user = user
super().__init__(f"The user '{user}' doesn't exist.")
class GroupNotFoundError(Exception):
"""
Raised when a specified group cannot be found in the system.
Attributes:
group (str): The group that caused the exception.
"""
def __init__(self, group: str) -> None:
self.group = group
super().__init__(f"The group '{group}' doesn't exist.")
class CommandFailedError(Exception):
"""
Raised when running a command failed.
Attributes:
command (list[str]): The command that caused the exception.
exit_code (int): The exit code of the command
output (str|None): Output of the command.
"""
def __init__(self, command: list[str], exit_code: int, output: str | None) -> None:
self.command = shlex.join(command)
self.exit_code = exit_code
if output:
self.output: str | None = output.strip()
else:
self.output = None
super().__init__(
f"Command '{self.command}' returned with a non-zero exit code {self.exit_code}."
)
================================================
FILE: src/decman/core/file_manager.py
================================================
import os
import typing
import decman.core.error as errors
import decman.core.fs as fs
import decman.core.module as module
import decman.core.output as output
import decman.core.store as _store
def update_files(
store: _store.Store,
modules: list[module.Module],
files: dict[str, fs.File],
directories: dict[str, fs.Directory],
symlinks: dict[str, str | fs.Symlink],
dry_run: bool = False,
) -> bool:
"""
Apply the desired file and directory state.
Installs common and module-provided files and directories, tracks all checked paths, detects
changes, removes files no longer managed, and updates the store.
On failure, no removals are performed and the store is left unchanged.
Arguments:
store:
Persistent store used to track managed file paths.
modules:
Enabled modules providing additional files and directories.
files:
Common files to install (target path -> File).
directories:
Common directories to install (target path -> Directory).
dry_run:
If True, perform change detection only without modifying the filesystem.
Returns:
True if all operations completed successfully, False if installation failed.
"""
all_checked_files = []
all_changed_files = []
store.ensure("all_files", [])
output.print_summary("Updating files.")
try:
output.print_debug("Applying common files.")
checked, changed = _install_files(files, dry_run=dry_run)
all_checked_files += checked
all_changed_files += changed
output.print_debug("Applying common directories.")
checked, changed = _install_directories(directories, dry_run=dry_run)
all_checked_files += checked
all_changed_files += changed
output.print_debug("Applying common symlinks.")
checked, changed = _install_symlinks(symlinks, dry_run=dry_run)
all_checked_files += checked
all_changed_files += changed
for mod in modules:
module_changed_files = []
output.print_debug(f"Applying files in module '{mod.name}'.")
checked, changed = _install_files(
mod.files(),
variables=mod.file_variables(),
dry_run=dry_run,
)
all_checked_files += checked
module_changed_files += changed
output.print_debug(f"Applying directories in module '{mod.name}'.")
checked, changed = _install_directories(
mod.directories(),
variables=mod.file_variables(),
dry_run=dry_run,
)
all_checked_files += checked
module_changed_files += changed
output.print_debug(f"Applying symlinks in module '{mod.name}'.")
checked, changed = _install_symlinks(
mod.symlinks(),
dry_run=dry_run,
)
all_checked_files += checked
module_changed_files += changed
if len(module_changed_files) > 0:
output.print_debug(
f"Module '{mod.name}' set to changed due to modified "
f"files: '{"', '".join(module_changed_files)}'."
)
mod._changed = True
all_changed_files += module_changed_files
except errors.FSInstallationFailedError as error:
output.print_error(str(error))
output.print_traceback()
return False
except errors.FSSymlinkFailedError as error:
output.print_error(str(error))
output.print_traceback()
return False
to_remove = []
for file in store["all_files"]:
if file not in all_checked_files:
to_remove.append(file)
output.print_list("Updated files:", all_changed_files, elements_per_line=1)
if not dry_run:
for file in to_remove:
try:
os.remove(file)
except OSError as error:
output.print_warning(f"Failed to remove file: '{file}': {error.strerror}.")
store["all_files"] = all_checked_files
output.print_list("Removed files:", to_remove, elements_per_line=1)
return True
def _install_files(
files: dict[str, fs.File],
variables: typing.Optional[dict[str, str]] = None,
dry_run: bool = False,
) -> tuple[list[str], list[str]]:
checked_files = []
changed_files = []
for target_filename, file in files.items():
output.print_debug(f"Checking file {target_filename}.")
checked_files.append(target_filename)
try:
if file.copy_to(target_filename, variables=variables, dry_run=dry_run):
changed_files.append(target_filename)
except FileNotFoundError as error:
raise errors.FSInstallationFailedError(
file.source_file or "content", target_filename, "Source file doesn't exist."
) from error
except OSError as error:
raise errors.FSInstallationFailedError(
file.source_file or "content", target_filename, error.strerror or str(error)
) from error
except UnicodeEncodeError as error:
raise errors.FSInstallationFailedError(
file.source_file or "content", target_filename, "Unicode encoding failed."
) from error
except UnicodeDecodeError as error:
raise errors.FSInstallationFailedError(
file.source_file or "content", target_filename, "Unicode decoding failed."
) from error
return checked_files, changed_files
def _install_directories(
directories: dict[str, fs.Directory],
variables: typing.Optional[dict[str, str]] = None,
dry_run: bool = False,
) -> tuple[list[str], list[str]]:
checked_files = []
changed_files = []
for target_dirname, directory in directories.items():
output.print_debug(f"Checking directory {target_dirname}.")
try:
checked, changed = directory.copy_to(
target_dirname, variables=variables, dry_run=dry_run
)
except FileNotFoundError as error:
raise errors.FSInstallationFailedError(
directory.source_directory,
target_dirname,
"Source directory doesn't exist.",
) from error
except OSError as error:
raise errors.FSInstallationFailedError(
directory.source_directory, target_dirname, error.strerror or str(error)
) from error
except UnicodeEncodeError as error:
raise errors.FSInstallationFailedError(
directory.source_directory, target_dirname, "Unicode encoding failed."
) from error
except UnicodeDecodeError as error:
raise errors.FSInstallationFailedError(
directory.source_directory, target_dirname, "Unicode decoding failed."
) from error
checked_files += checked
changed_files += changed
return checked_files, changed_files
def _install_symlinks(
symlinks: dict[str, str | fs.Symlink], dry_run: bool = False
) -> tuple[list[str], list[str]]:
checked_files = []
changed_files = []
for link_name, target in symlinks.items():
output.print_debug(f"Checking symlink {link_name}.")
checked_files.append(link_name)
target_link: fs.Symlink = target if type(target) is fs.Symlink else fs.Symlink(target) # type: ignore
if target_link.link_to(link_name, dry_run):
changed_files.append(link_name)
return checked_files, changed_files
================================================
FILE: src/decman/core/fs.py
================================================
import grp
import os
import shutil
import typing
import decman.core.command as command
import decman.core.error as errors
import decman.core.output as output
def create_missing_dirs(dirct: str, uid: typing.Optional[int], gid: typing.Optional[int]):
if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir):
create_missing_dirs(parent_dir, uid, gid)
output.print_debug(f"Creating directory '{dirct}'.")
os.mkdir(dirct)
if uid is not None:
assert gid is not None, "If uid is set, then gid is set."
os.chown(dirct, uid, gid)
class File:
"""
Declarative file specification describing how a file should be materialized at a target path.
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``:
Path to an existing file to copy from. Mutually exclusive with ``content``.
``content``:
In-memory file contents to write. Mutually exclusive with ``source_file``.
``bin_file``:
If ``True``, treat the file as binary. Disables variable substitution and writes bytes
verbatim.
``encoding``:
Text encoding used when reading or writing non-binary files.
``owner``:
System user name to own the file and created parent directories.
``group``:
System group name to own the file and created parent directories.
``permissions``:
File mode applied to the target file (e.g. ``0o644``).
Raises:
``ValueError``
If both ``source_file`` and ``content`` are ``None`` or if both are set.
``UserNotFoundError``
If ``owner`` does not exist on the system.
``GroupNotFoundError``
If ``group`` does not exist on the system.
Notes:
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.
"""
def __init__(
self,
source_file: typing.Optional[str] = None,
content: typing.Optional[str] = None,
bin_file: bool = False,
encoding: str = "utf-8",
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
permissions: int = 0o644,
):
if source_file is None and content is None:
raise ValueError("Both source_file and content cannot be None.")
if source_file is not None and content is not None:
raise ValueError("Both source_file and content cannot be set.")
self.source_file = source_file
self.content = content
self.permissions = permissions
self.bin_file = bin_file
self.encoding = encoding
self.uid = None
self.gid = None
if owner is not None:
self.uid, self.gid = command.get_user_info(owner)
if group is not None:
try:
self.gid = grp.getgrnam(group).gr_gid
except KeyError as error:
raise errors.GroupNotFoundError(group) from error
def copy_to(
self, target: str, variables: typing.Optional[dict[str, str]] = None, dry_run: bool = False
) -> bool:
"""
Copies the contents of this file to the target file if they differ.
Parameters:
target:
Path to the target file on disk.
variables:
Optional mapping of literal substrings to replace in the text content before
writing. Ignored for binary files and when ``bin_file`` is True.
Returns:
True if the file contents were/would be created or modified.
False if the existing file already contained the desired contents.
Raises:
OSError
If directory creation, file I/O, permission changes, or ownership changes fail
(e.g. permission denied, missing parent path components, I/O errors).
FileNotFoundError
If ``source_file`` is set and does not exist.
UnicodeDecodeError
If a text file cannot be decoded using ``encoding``.
UnicodeEncodeError
If text content cannot be encoded using ``encoding``.
"""
if variables is None:
variables = {}
target_directory = os.path.dirname(target)
if not dry_run:
create_missing_dirs(target_directory, self.uid, self.gid)
changed = self._write_content(target, variables, dry_run)
if changed:
output.print_debug(f"File '{target}' changed.")
if self.uid is not None and not dry_run:
assert self.gid is not None, "If uid is set, then gid is set."
os.chown(target, self.uid, self.gid)
if not dry_run:
os.chmod(target, self.permissions)
return changed
def _write_content(self, target: str, variables: dict[str, str], dry_run: bool):
# Case 1: copy from source file directly (binary or no substitutions)
if self.source_file is not None and (self.bin_file or len(variables) == 0):
if os.path.exists(target):
with open(self.source_file, "rb") as src, open(target, "rb") as dst:
if src.read() == dst.read():
return False
if not dry_run:
shutil.copy(self.source_file, target)
return True
# Case 2: binary content from memory
if self.bin_file and self.content is not None:
desired_bytes = self.content.encode(encoding=self.encoding)
if os.path.exists(target):
with open(target, "rb") as file:
if file.read() == desired_bytes:
return False
if not dry_run:
with open(target, "wb") as file:
file.write(desired_bytes)
return True
# From here on: text modes with possible substitutions
# Case 3: text content from source file with substitutions
if self.source_file is not None:
with open(self.source_file, "rt", encoding=self.encoding) as src:
content = src.read()
for var, value in variables.items():
content = content.replace(var, value)
if os.path.exists(target):
with open(target, "rt", encoding=self.encoding) as file:
if file.read() == content:
return False
if not dry_run:
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True
# Case 4: text content from in-memory string with substitutions
assert self.content is not None, "Content should be set since source_file was not set."
content = self.content
for var, value in variables.items():
content = content.replace(var, value)
if os.path.exists(target):
with open(target, "rt", encoding=self.encoding) as file:
if file.read() == content:
return False
if not dry_run:
with open(target, "wt", encoding=self.encoding) as file:
file.write(content)
return True
class Symlink:
"""
Declarative specification for linking a source to a destination.
Parameters:
``target``:
Path to an existing file to serve as the target of the symlink.
``owner``:
User name to own created parent directories.
``group``:
Group name to own created parent directories.
Raises:
``UserNotFoundError``
If ``owner`` does not exist on the system.
``GroupNotFoundError``
If ``group`` does not exist on the system.
"""
def __init__(
self,
target: str,
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
):
self.target = target
self.owner = owner
self.group = group
self.uid = None
self.gid = None
if owner is not None:
self.uid, self.gid = command.get_user_info(owner)
if group is not None:
try:
self.gid = grp.getgrnam(group).gr_gid
except KeyError as error:
raise errors.GroupNotFoundError(group) from error
def link_to(self, link_name: str, dry_run: bool = False) -> bool:
"""
Creates a symlink ``link_name`` -> ``target``.
Parameters:
``link_name``:
Path to the target file on disk.
Returns:
True if a new link was/would be created or modified.
False if the existing link already contained the desired target.
Raises:
FSSymlinkFailedError
If creating the symlink failed due to directory creation, file I/O, permission
changes, or ownership changes fail (e.g. permission denied, missing parent path
components, I/O errors).
"""
def _is_symlink_to(path: str, target: str) -> bool:
if not os.path.islink(path):
return False
return os.readlink(path) == target
output.print_debug(f"Checking symlink {link_name}.")
try:
if _is_symlink_to(link_name, self.target):
return False
if dry_run:
return True
target_directory = os.path.dirname(link_name)
create_missing_dirs(target_directory, self.uid, self.gid)
if os.path.lexists(link_name):
os.unlink(link_name)
os.symlink(self.target, link_name)
if self.uid is not None:
assert self.gid is not None, "If uid is set, then gid is set."
os.chown(link_name, self.uid, self.gid, follow_symlinks=False)
return True
except OSError as error:
raise errors.FSSymlinkFailedError(
link_name, self.target, error.strerror or str(error)
) from error
class Directory:
"""
Declarative specification for copying the contents of a source directory into a target
directory.
Files are copied using the :class:`File` abstraction, inheriting its ownership,
permissions, encoding, and binary/text behavior. Text files can optionally undergo
variable substitution before being written.
Parameters:
``source_directory``:
Path to the directory whose contents will be mirrored into the target.
``bin_files``:
If ``True``, treat all files as binary; disables variable substitution and copies bytes
verbatim.
``encoding``:
Text encoding used when reading or writing non-binary files.
``owner``:
System user name to own created files and directories.
``group``:
System group name to own created files and directories.
``permissions``:
File mode applied to created or updated files (e.g. ``0o644``).
Raises:
``UserNotFoundError``
If ``owner`` does not exist on the system.
``GroupNotFoundError``
If ``group`` does not exist on the system.
"""
def __init__(
self,
source_directory: str,
bin_files: bool = False,
encoding: str = "utf-8",
owner: typing.Optional[str] = None,
group: typing.Optional[str] = None,
permissions: int = 0o644,
):
self.source_directory = source_directory
self.bin_files = bin_files
self.encoding = encoding
self.permissions = permissions
self.owner = owner
self.group = group
self.uid = None
self.gid = None
if owner is not None:
self.uid, self.gid = command.get_user_info(owner)
if group is not None:
try:
self.gid = grp.getgrnam(group).gr_gid
except KeyError as error:
raise errors.GroupNotFoundError(group) from error
def copy_to(
self,
target_directory: str,
variables: typing.Optional[dict[str, str]] = None,
dry_run: bool = False,
) -> tuple[list[str], list[str]]:
"""
Copies the files in this directory to the target directory. Only replaces files that differ.
Parameters:
target_directory:
Destination directory root. Relative layout from the source is preserved beneath
this path.
variables:
Optional mapping of literal substrings to replace in text files before writing.
Ignored for binary files.
dry_run:
If ``True``, perform a dry-run: no files are written, but the list of files that
*would* be processed is returned.
Returns:
tuple[list[str], list[str]]
The first list contains always every file in the source, the second list depends on
``dry_run``
When ``dry_run`` is ``False``, the second list contains paths of files that were
created or whose contents were modified.
When ``dry_run`` is ``True``, the second list contains paths of all files that would
be considered for creation or modification (no changes are actually performed).
Raises:
OSError
If directory traversal or file I/O fails (e.g. permission denied).
FileNotFoundError
If ``source_directory`` does not exist or becomes unavailable.
UnicodeDecodeError
If a text file cannot be decoded using ``encoding``.
UnicodeEncodeError
If text content cannot be encoded using ``encoding``.
"""
checked = []
changed_or_created = []
original_wd = os.getcwd()
try:
os.chdir(self.source_directory)
for src_dir, _, src_files in os.walk("."):
for src_file in src_files:
src_path = os.path.join(src_dir, src_file)
file = File(
source_file=src_path,
bin_file=self.bin_files,
encoding=self.encoding,
owner=self.owner,
group=self.group,
permissions=self.permissions,
)
target = os.path.normpath(os.path.join(target_directory, src_path))
checked.append(target)
if file.copy_to(target, variables, dry_run):
changed_or_created.append(target)
finally:
os.chdir(original_wd)
return checked, changed_or_created
================================================
FILE: src/decman/core/module.py
================================================
import builtins
import dis
import inspect
import os
import textwrap
import types
import typing
import decman.core.error as errors
import decman.core.fs as fs
import decman.core.store as _store
class Module:
"""
Unit for organizing related files, packages and other configuration.
Inherit this class to create your own modules.
Parameters:
name:
The name of the module. It must be unique.
"""
def __init__(self, name: str) -> None:
self.name = name
self._changed = False
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
m = cls.__dict__.get("on_disable")
if m is None:
return
if not isinstance(m, staticmethod):
raise errors.InvalidOnDisableError(
f"{cls.__module__}.{cls.__name__}",
"on_disable must be declared as @staticmethod",
)
func = m.__func__
_validate_on_disable(f"{cls.__module__}.{cls.__name__}", func)
def before_update(self, store: _store.Store):
"""
Override this method to run python code before updating the system.
``store`` can be used to save persistent data between decman runs.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
def after_update(self, store: _store.Store):
"""
Override this method to run python code after updating the system.
``store`` can be used to save persistent data between decman runs.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
def on_enable(self, store: _store.Store):
"""
Override this method to run python code when this module gets enabled.
``store`` can be used to save persistent data between decman runs.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
def on_change(self, store: _store.Store):
"""
Override this method to run python code after the contents of this module have been
changed in the source.
``store`` can be used to save persistent data between decman runs.
Handle errors within this function. If an error should abort running decman,
raise SourceError or CommandFailedError.
"""
@staticmethod
def on_disable():
"""
Override this method to run python code when this module gets disabled.
This code will get copied *as is* to a temporary file. Do not use external variables or
imports. If you must use imports, define them inside this method.
"""
def files(self) -> dict[str, fs.File]:
"""
Override this method to return files that should be installed as a part of this module.
"""
return {}
def directories(self) -> dict[str, fs.Directory]:
"""
Override this method to return directories that should be installed as a part of this
module.
"""
return {}
def symlinks(self) -> dict[str, str | fs.Symlink]:
"""
Override this method to return symlinks that should be created as a part of this
module.
"""
return {}
def file_variables(self) -> dict[str, str]:
"""
Override this method to return variables that should replaced with a new value inside
this module's text files.
"""
return {}
def __hash__(self) -> int:
return hash(self.name)
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__) and other.name == self.name
def write_on_disable_script(mod_obj: Module, out_dir: str) -> str | None:
"""
Writes a on_disable script for the given module. Returns the path to that script.
Raises:
OSError
If creating the script file fails.
"""
cls: typing.Type[Module] = type(mod_obj)
# Get the descriptor so we can unwrap staticmethod
desc = cls.__dict__.get("on_disable")
if desc is None:
return None
# unwrap staticmethod to get the real function
if isinstance(desc, staticmethod):
func = desc.__func__
else:
func = desc # already a function
src = inspect.getsource(func)
src = textwrap.dedent(src)
# Build a standalone script that defines the function and calls it
script = f"""#!/usr/bin/env python3
# generated from {cls.__module__}.{cls.__name__}.on_disable
{src}
if __name__ == "__main__":
{func.__name__}()
"""
script_file = fs.File(content=script, permissions=0o755)
script_path = os.path.join(out_dir, f"{mod_obj.name}_on_disable.py")
script_file.copy_to(script_path)
return script_path
def _iter_code_objects(code: types.CodeType):
yield code
for const in code.co_consts:
if isinstance(const, types.CodeType):
yield from _iter_code_objects(const)
def _validate_on_disable(module_type: str, func: types.FunctionType) -> None:
# No args
if inspect.signature(func).parameters:
raise errors.InvalidOnDisableError(module_type, "on_disable must take no parameters")
bad_names: set[str] = set()
for code in _iter_code_objects(func.__code__):
# No closures anywhere (outer or nested)
if code.co_freevars:
raise errors.InvalidOnDisableError(
module_type, "on_disable must not close over outer variables"
)
# No non-builtin globals / nonlocals anywhere
for ins in dis.get_instructions(code):
if ins.opname in ("LOAD_GLOBAL", "LOAD_DEREF"):
name = ins.argval
if not hasattr(builtins, name):
bad_names.add(name)
if bad_names:
raise errors.InvalidOnDisableError(
module_type,
f"on_disable uses nonlocal/global names: {', '.join(sorted(bad_names))}",
)
================================================
FILE: src/decman/core/output.py
================================================
import os
import shutil
import sys
import traceback
import typing
import decman.config as config
# ─────────────────────────────
# Visible (non-ANSI) constants
# ─────────────────────────────
_TAG_TEXT = "[DECMAN]"
_SPACING = " "
_CONTINUATION_PREFIX_TEXT = f"{_TAG_TEXT}{_SPACING} "
INFO = 1
SUMMARY = 2
# ─────────────────────────────
# Color / formatting helpers
# ─────────────────────────────
def has_ansi_support() -> bool:
"""
Returns True if the running terminal supports ANSI colors or if colors should be enabled.
"""
if os.environ.get("NO_COLOR") is not None:
return False
if os.environ.get("FORCE_COLOR") is not None:
return True
if not sys.stdout.isatty():
return False
term = os.environ.get("TERM", "")
return term not in ("", "dumb")
def _apply_color(code: str, text: str) -> str:
if not config.color_output:
return text
return f"{code}{text}\033[m"
def _tag() -> str:
if not config.color_output:
return _TAG_TEXT
return "[\033[1;35mDECMAN\033[m]"
def _continuation_prefix() -> str:
return f"{_tag()}{_SPACING} "
def _red(text: str) -> str:
return _apply_color("\033[91m", text)
def _yellow(text: str) -> str:
return _apply_color("\033[93m", text)
def _cyan(text: str) -> str:
return _apply_color("\033[96m", text)
def _green(text: str) -> str:
return _apply_color("\033[92m", text)
def _gray(text: str) -> str:
return _apply_color("\033[90m", text)
# ─────────────────────────────
# Printing helpers
# ─────────────────────────────
def print_continuation(msg: str, level: int = SUMMARY):
"""
Prints a message without a prefix.
"""
if level == SUMMARY or config.debug_output or not config.quiet_output:
print(f"{_continuation_prefix()}{msg}")
def print_error(error_msg: str):
"""
Prints an error message to the user.
"""
print(f"{_tag()} {_red('ERROR')}: {error_msg}")
def print_traceback():
"""
Prints the traceback to debug output.
"""
for line in traceback.format_exc().splitlines():
print_debug(line)
def print_warning(msg: str):
"""
Prints a warning to the user.
"""
print(f"{_tag()} {_yellow('WARNING')}: {msg}")
def print_summary(msg: str):
"""
Prints a summary message to the user.
"""
print(f"{_tag()} {_cyan('SUMMARY')}: {msg}")
def print_info(msg: str):
"""
Prints a detailed message to the user if verbose output is not disabled.
"""
if config.debug_output or not config.quiet_output:
print(f"{_tag()} INFO: {msg}")
def print_debug(msg: str):
"""
Prints a detailed message to the user if debug messages are enabled.
"""
if config.debug_output:
print(f"{_tag()} {_gray('DEBUG')}: {msg}")
def print_command_output(command_output: str):
"""
Prints command output prefixed with a DECMAN tag.
"""
for line in command_output.strip().split("\n"):
print_continuation(line.strip())
# ─────────────────────────────
# List printing
# ─────────────────────────────
def print_list(
msg: str,
list_to_print: list[str],
elements_per_line: typing.Optional[int] = None,
max_line_width: typing.Optional[int] = None,
limit_to_term_size: bool = True,
level: int = SUMMARY,
):
"""
Prints a summary message to the user along with a list of elements.
If the list is empty, prints nothing.
"""
if len(list_to_print) == 0:
return
list_to_print = list_to_print.copy()
if level == SUMMARY:
print_summary(msg)
elif level == INFO:
print_info(msg)
print_continuation("", level=level)
if elements_per_line is None:
elements_per_line = len(list_to_print)
if max_line_width is None:
max_line_width = 2**32
if limit_to_term_size:
visible_prefix_len = len(_CONTINUATION_PREFIX_TEXT)
max_line_width = shutil.get_terminal_size().columns - visible_prefix_len
lines = [list_to_print.pop(0)]
index = 0
elements_in_current_line = 1
while list_to_print:
next_element = list_to_print.pop(0)
can_fit_elements = elements_in_current_line + 1 <= elements_per_line
can_fit_text = len(lines[index]) + len(next_element) <= max_line_width
if can_fit_text and can_fit_elements:
lines[index] += f" {next_element}"
elements_in_current_line += 1
else:
lines.append(next_element)
index += 1
elements_in_current_line = 1
for line in lines:
print_continuation(line, level=level)
print_continuation("", level=level)
# ─────────────────────────────
# Prompts
# ─────────────────────────────
def prompt_number(
msg: str,
min_num: int,
max_num: int,
default: typing.Optional[int] = None,
) -> int:
"""
Prompts the user for an integer.
"""
while True:
i = input(f"{_tag()} {_green('PROMPT')}: {msg}").strip()
if default is not None and i == "":
return default
try:
num = int(i)
if min_num <= num <= max_num:
return num
except ValueError:
pass
print_error("Invalid input.")
def prompt_confirm(msg: str, default: typing.Optional[bool] = None) -> bool:
"""
Prompts the user for confirmation.
"""
options_suffix = "(y/n)"
if default is not None:
options_suffix = "(Y/n)" if default else "(y/N)"
while True:
i = input(f"{_tag()} {_green('PROMPT')} {options_suffix}: {msg} ").strip()
if default is not None and i == "":
return default
if i.lower() in ("y", "ye", "yes"):
return True
if i.lower() in ("n", "no"):
return False
print_error("Invalid input.")
================================================
FILE: src/decman/core/store.py
================================================
import json
import os
import pathlib
import tempfile
import typing
class Store:
"""
Key-value store for saving decman state.
"""
def __init__(self, path: str, dry_run: bool = False) -> None:
self._store: dict[str, typing.Any] = {}
self._path = pathlib.Path(path)
self._dry_run = dry_run
if self._path.exists():
with self._path.open("rt", encoding="utf-8") as file:
self._store = json.load(file, object_hook=_decode_sets)
def __getitem__(self, key: str) -> typing.Any:
return self._store[key]
def __setitem__(self, key: str, value: typing.Any) -> None:
self._store[key] = value
def get(self, key: str, default: typing.Any = None) -> typing.Any:
return self._store.get(key, default)
def ensure(self, key: str, default: typing.Any = None):
if key not in self._store:
self._store[key] = default
def __enter__(self) -> "Store":
return self
def __exit__(self, exc_type, exc, tb):
self.save()
return False
def save(self) -> None:
"""
Saves the store to the defined path.
"""
if self._dry_run:
return
os.makedirs(self._path.parent, exist_ok=True)
with tempfile.NamedTemporaryFile(
"wt",
encoding="utf-8",
dir=self._path.parent,
delete=False,
) as tmp:
json.dump(self._store, tmp, cls=_SetJSONEncoder, indent=2)
tmp.flush()
os.fsync(tmp.fileno())
os.replace(tmp.name, self._path)
def __repr__(self) -> str:
return repr(self._store)
class _SetJSONEncoder(json.JSONEncoder):
def default(self, obj: typing.Any) -> typing.Any:
if isinstance(obj, set):
# generic, works for any set value
return {"__type__": "set", "items": list(obj)}
return super().default(obj)
def _decode_sets(obj: typing.Any) -> typing.Any:
if isinstance(obj, dict) and obj.get("__type__") == "set" and "items" in obj:
# decode lists inside sets as tuples
norm = []
for item in obj["items"]:
if isinstance(item, list):
norm.append(tuple(item))
else:
norm.append(item)
return set(norm)
return obj
================================================
FILE: src/decman/extras/__init__.py
================================================
================================================
FILE: src/decman/extras/gpg.py
================================================
import os
import pwd
import re
import subprocess
from dataclasses import dataclass
from typing import Literal, Optional
import decman
import decman.core.module as module
import decman.core.output as output
import decman.core.store as _store
from decman.core.error import CommandFailedError
OwnerTrust = Literal["never", "marginal", "full", "ultimate"]
SourceKind = Literal["fingerprint", "uri", "file"]
_TRUST_MAP = {
"never": "1",
"marginal": "2",
"full": "3",
"ultimate": "4",
}
_FPR_RE = re.compile(r"^[0-9A-F]{40}$")
@dataclass(frozen=True)
class Key:
fingerprint: str
source_kind: SourceKind
source: str # keyserver / uri / filepath
trust: Optional[OwnerTrust] = None
def __post_init__(self) -> None:
fpr = self.fingerprint.replace(" ", "").upper()
if not _FPR_RE.fullmatch(fpr):
raise ValueError(f"invalid OpenPGP fingerprint: {fpr}")
object.__setattr__(self, "fingerprint", fpr)
class _GPGInterface:
def __init__(self, user: str, home: str):
self.user = user
self.home = home
def ensure_home(self) -> bool:
"""
Returns True on succees. Returns False if the user doesn't exist.
"""
def create_missing_dirs(dirct: str, uid: int, gid: int):
dirct = os.path.normpath(dirct)
if not os.path.isdir(dirct):
parent_dir = os.path.dirname(dirct)
if not os.path.isdir(parent_dir):
create_missing_dirs(parent_dir, uid, gid)
os.mkdir(dirct)
os.chown(dirct, uid, gid)
os.chmod(dirct, 0o700)
try:
u = pwd.getpwnam(self.user)
create_missing_dirs(self.home, u.pw_uid, u.pw_gid)
return True
except OSError as error:
raise decman.SourceError(
f"Failed to create GPG directory {self.home} for {self.user}."
) from error
except KeyError:
return False
def list_fingerprints(self) -> set[str]:
out = decman.prg(
["gpg", "--homedir", self.home, "--batch", "--no-tty", "--with-colons", "--list-keys"],
user=self.user,
pty=False,
)
fprs: set[str] = set()
for line in out.splitlines():
if line.startswith("fpr:"):
parts = line.split(":")
if len(parts) > 9 and parts[9]:
fprs.add(parts[9])
return fprs
def set_key_trust(self, keys: list[tuple[str, OwnerTrust]]):
if not keys:
return
lines = [f"{fpr}:{_TRUST_MAP[trust]}:" for fpr, trust in keys]
data = "\n".join(lines) + "\n"
cmd = [
"gpg",
"--homedir",
self.home,
"--batch",
"--yes",
"--no-tty",
"--import-ownertrust",
]
# use subprocess manually since decman exposed functions don't allow setting input
p = subprocess.run(
cmd,
input=data,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
user=self.user,
text=True,
check=False,
)
if p.returncode != 0:
raise CommandFailedError(cmd, p.returncode, p.stdout)
def delete_keys(self, fingerprints: list[str]):
decman.prg(
[
"gpg",
"--homedir",
self.home,
"--batch",
"--yes",
"--no-tty",
"--delete-keys",
]
+ fingerprints,
user=self.user,
pty=False,
)
def fetch_key(self, uri: str):
decman.prg(
[
"gpg",
"--homedir",
self.home,
"--batch",
"--yes",
"--no-tty",
"--fetch-key",
uri,
],
user=self.user,
pty=False,
)
def import_key(self, path: str):
decman.prg(
[
"gpg",
"--homedir",
self.home,
"--batch",
"--yes",
"--no-tty",
"--import",
path,
],
user=self.user,
pty=False,
)
def receive_key(self, fingerprint: str, keyserver: str):
decman.prg(
[
"gpg",
"--homedir",
self.home,
"--batch",
"--yes",
"--no-tty",
"--keyserver",
keyserver,
"--recv-keys",
fingerprint,
],
user=self.user,
pty=False,
)
class GPGReceiver(module.Module):
"""
Module for receiving OpenPGP keys.
This is basically built for importing AUR package keys.
If trying to add a key to an user that doesn't exist, this module silently skips user.
It's functionality is limited and I don't recommend using this with your main user account.
Instead create specific account for AUR package building and import keys to that account.
This module doesn't use the GPGME library and instead just calls gpg directly. It's simpler and
good enough for this usecase.
This module is a singleton, meaning that you should create only a one instance of this module
and pass that around.
"""
def __init__(self) -> None:
super().__init__("gpgreceiver")
self._keys: dict[tuple[str, str], list[Key]] = {}
def receive_key(
self,
user: str,
gpg_home: str,
fingerprint: str,
keyserver: str,
trust: OwnerTrust | None = None,
):
"""
Receives a key.
The key is imported as the given ``user`` into the specified ``gpg_home``.
If trust is specified, sets it.
"""
self._keys.setdefault((user, gpg_home), []).append(
Key(fingerprint, "fingerprint", keyserver, trust)
)
def fetch_key(
self, user: str, gpg_home: str, fingerprint: str, uri: str, trust: OwnerTrust | None = None
):
"""
Fetches a key from a URI.
The key is imported as the given ``user`` into the specified ``gpg_home``.
If trust is specified, sets it.
"""
self._keys.setdefault((user, gpg_home), []).append(Key(fingerprint, "uri", uri, trust))
def import_key(
self, user: str, gpg_home: str, fingerprint: str, file: str, trust: OwnerTrust | None = None
):
"""
Imports a key from file.
The key is imported as the given ``user`` into the specified ``gpg_home``.
If trust is specified, sets it.
"""
self._keys.setdefault((user, gpg_home), []).append(Key(fingerprint, "file", file, trust))
def _add_key(self, gpg: _GPGInterface, key: Key):
match key.source_kind:
case "fingerprint":
gpg.receive_key(key.fingerprint, key.source)
case "uri":
gpg.fetch_key(key.source)
case "file":
gpg.import_key(key.source)
def before_update(self, store: _store.Store):
store.ensure("gpgreceiver_userhome_keys", {})
known_users = {
(line.split(":", 1)[0], line.split(":", 1)[1])
for line in store["gpgreceiver_userhome_keys"]
}
for user, gpg_home in self._keys.keys() | known_users:
keys = self._keys.get((user, gpg_home), [])
gpg = _GPGInterface(user, gpg_home)
if not gpg.ensure_home():
output.print_warning(f"User {user} doesn't exist, so PGP keys cannot be modified.")
del store["gpgreceiver_userhome_keys"][f"{user}:{gpg_home}"]
continue
old_fprs = store["gpgreceiver_userhome_keys"].get(f"{user}:{gpg_home}", set())
fprs_before_import = gpg.list_fingerprints()
new_fprs = set()
managed_fprs = set()
key_trust_levels = []
for key in keys:
managed_fprs.add(key.fingerprint)
if key.trust:
key_trust_levels.append((key.fingerprint, key.trust))
if key.fingerprint not in fprs_before_import:
output.print_info(
f"Adding PGP key {key.fingerprint} to {user}:{gpg_home} "
f"from {key.source_kind} {key.source}."
)
self._add_key(gpg, key)
new_fprs.add(key.fingerprint)
fprs_after_import = gpg.list_fingerprints()
missing = new_fprs - fprs_after_import
if missing:
raise decman.SourceError(
f"Fingerprints for PGP not found after importing all keys: {' '.join(missing)}"
)
if key_trust_levels:
gpg.set_key_trust(key_trust_levels)
unaccounted_fprs = (fprs_after_import - fprs_before_import) - new_fprs
if unaccounted_fprs:
output.print_warning(
"While adding PGP keys these fingerprints were unaccounted for: "
f"{' '.join(unaccounted_fprs)}"
)
output.print_warning("The keys were added, but their ownertrust was not set.")
fprs_to_remove = list(old_fprs - managed_fprs)
if fprs_to_remove:
output.print_list(
f"Deleting PGP keys from {user}:{gpg_home}", fprs_to_remove, level=output.INFO
)
gpg.delete_keys(fprs_to_remove)
store["gpgreceiver_userhome_keys"][f"{user}:{gpg_home}"] = managed_fprs
================================================
FILE: src/decman/extras/users.py
================================================
import grp
import pwd
from dataclasses import dataclass
from typing import Optional
import decman.core.command as command
import decman.core.module as module
import decman.core.output as output
import decman.core.store as _store
@dataclass(frozen=True)
class Group:
"""
Represents a group managed by the ``UserManager`` module.
The ``system`` attribute only affects the creation of this group.
After the group has been created, changing the ``system`` attribute does nothing.
"""
groupname: str
gid: Optional[int] = None
system: bool = False
def __str__(self) -> str:
parts = []
if self.gid is not None:
parts.append(f"gid={self.gid}")
if self.system:
parts.append("system")
return f"{self.groupname}({', '.join(parts)})"
@dataclass(frozen=True)
class User:
"""
Represents a user managed by the ``UserManager`` module.
The ``system`` attribute only affects the creation of this user.
After the user has been created, changing the ``system`` attribute does nothing.
"""
username: str
uid: Optional[int] = None
group: Optional[str] = None
home: Optional[str] = None
shell: Optional[str] = None
groups: tuple[str, ...] = ()
system: bool = False
def __str__(self) -> str:
parts = []
if self.uid is not None:
parts.append(f"uid={self.uid}")
if self.group is not None:
parts.append(f"gid={self.group}")
if self.home is not None:
parts.append(f"home={self.home}")
if self.shell is not None:
parts.append(f"shell={self.shell}")
if self.groups:
parts.append(f"groups={','.join(self.groups)}")
if self.system:
parts.append("system")
return f"{self.username}({', '.join(parts)})"
class UserManager(module.Module):
"""
A module for managing users and groups. This module is additive, if you create a user or a group
manually, this module will not modify them, unless you explicitly add them to this module.
Users and groups are created, modified and deleted at ``before_update`` -stage.
Users are added to groups and subuids/subgids at ``after_update`` -stage.
Decman store keys used by this module are:
- ``usermanager_users``
- ``usermanager_groups``
- ``usermanager_user_additional_groups``
- ``usermanager_user_subuids``
- ``usermanager_user_subgids``
Most management done by this module is with the commands ``useradd``, ``groupadd`` and
``usermod``.
This module contains useful utilities for the most common user management cases,
but it is not complete.
If you need advanced user management features you should probably fork this module.
This module is a singleton, meaning that you should create only a one instance of this module
and pass that around.
"""
def __init__(self) -> None:
super().__init__("usermanager")
self.users: set[User] = set()
self.groups: set[Group] = set()
self._user_additional_groups: dict[str, set[str]] = {}
self._user_subuids: dict[str, set[tuple[int, int]]] = {}
self._user_subgids: dict[str, set[tuple[int, int]]] = {}
def add_user(self, user: User):
"""
Ensures that the user exists with the given attributes.
"""
self.users.add(user)
def add_group(self, group: Group):
"""
Ensures that the group exists with the given attributes.
"""
self.groups.add(group)
def add_user_to_group(self, user: str, group: str):
"""
Ensures that the user is a member of the given group.
Both ``user`` and ``group`` should exist.
"""
self._user_additional_groups.setdefault(user, set()).add(group)
def add_subuids(self, user: str, first: int, last: int):
"""
Adds the range ``first``-``last`` subordinate uids to the ``user``s account.
Note!
This module doesn't parse ``/etc/subuid`` or ``/etc/subgid``.
Instead, the added subuids and subgids are stored in the decman store.
Stored values are used to remove the added subuids and subgids from the user.
Manual modifications or clearing the decman store can cause unexpected issues.
"""
self._user_subuids.setdefault(user, set()).add((first, last))
def add_subgids(self, user: str, first: int, last: int):
"""
Adds the range ``first``-``last`` subordinate gids to the ``user``s account.
Note!
This module doesn't parse ``/etc/subuid`` or ``/etc/subgid``.
Instead, the added subuids and subgids are stored in the decman store.
Stored values are used to remove the added subuids and subgids from the user.
Manual modifications or clearing the decman store can cause unexpected issues.
"""
self._user_subgids.setdefault(user, set()).add((first, last))
def _check_user(self, user: User, user_groups_index: dict[str, set[str]]):
userdb_name = None
userdb_uid = None
try:
userdb_name = pwd.getpwnam(user.username)
except KeyError:
pass
try:
if user.uid is not None:
userdb_uid = pwd.getpwuid(user.uid)
except KeyError:
pass
# Prioritize uid match. If uid matches but name doesn't, rename the user.
userdb = userdb_uid or userdb_name
if not userdb:
self._add_user(user)
else:
self._ensure_user_matches(user, userdb, user_groups_index)
def _add_user(self, user: User):
cmd = ["useradd"]
if user.uid is not None:
cmd += ["--uid", str(user.uid)]
if user.group:
cmd += ["--gid", user.group]
if user.home:
cmd += ["--create-home", "--home-dir", user.home]
if user.shell:
cmd += ["--shell", user.shell]
if user.groups:
cmd += ["--groups", ",".join(list(user.groups))]
if user.system:
cmd.append("--system")
cmd.append(user.username)
output.print_info(f"Creating user {user}.")
command.prg(cmd, pty=False)
def _ensure_user_matches(
self, user: User, userdb: pwd.struct_passwd, user_groups_index: dict[str, set[str]]
):
cmd = ["usermod"]
if user.username != userdb.pw_name:
cmd += ["--login", user.username]
if user.uid is not None and user.uid != userdb.pw_uid:
cmd += ["--uid", str(user.uid)]
if user.group and user.group != grp.getgrgid(userdb.pw_gid).gr_name:
cmd += ["--gid", user.group]
if user.home and user.home != userdb.pw_dir:
cmd += ["--move-home", "--home", user.home]
if user.shell and user.shell != userdb.pw_shell:
cmd += ["--shell", user.shell]
# Use old name to support renames, post rename groups match
old_groups = user_groups_index.get(userdb.pw_name, set())
if user.groups is not None and set(user.groups) != old_groups:
if user.groups:
cmd += ["--groups", ",".join(list(user.groups))]
elif old_groups:
# Remove user from other groups
cmd += ["-r", "--groups", ",".join(old_groups)]
if len(cmd) > 1:
# Use old name to support renames
cmd.append(userdb.pw_name)
output.print_info(f"Modifying user {user}.")
command.prg(cmd, pty=False)
def _user_groups_index(self) -> dict[str, set[str]]:
result: dict[str, set[str]] = {}
for gr in grp.getgrall():
group = gr.gr_name
for user in gr.gr_mem:
result.setdefault(user, set()).add(group)
return result
def _check_group(self, group: Group):
groupdb_name = None
groupdb_gid = None
try:
groupdb_name = grp.getgrnam(group.groupname)
except KeyError:
pass
try:
if group.gid is not None:
groupdb_gid = grp.getgrgid(group.gid)
except KeyError:
pass
groupdb = groupdb_gid or groupdb_name
if not groupdb:
self._add_group(group)
else:
self._ensure_group_matches(group, groupdb)
def _add_group(self, group: Group):
cmd = ["groupadd"]
if group.gid is not None:
cmd += ["--gid", str(group.gid)]
if group.system:
cmd.append("--system")
cmd.append(group.groupname)
output.print_info(f"Creating group {group}.")
command.prg(cmd, pty=False)
def _ensure_group_matches(self, group: Group, groupdb: grp.struct_group):
cmd = ["groupmod"]
if group.groupname != groupdb.gr_name:
cmd += ["--new-name", group.groupname]
if group.gid is not None and group.gid != groupdb.gr_gid:
cmd += ["--gid", str(group.gid)]
if len(cmd) > 1:
# Use old name to support renames
cmd.append(groupdb.gr_name)
output.print_info(f"Modifying group {group}.")
command.prg(cmd, pty=False)
def _modify_user_groups_subids(self, user: str, store: _store.Store):
store.ensure("usermanager_user_additional_groups", {})
store.ensure("usermanager_user_subuids", {})
store.ensure("usermanager_user_subgids", {})
old_groups = store["usermanager_user_additional_groups"].get(user, set())
old_subuids = store["usermanager_user_subuids"].get(user, set())
old_subgids = store["usermanager_user_subgids"].get(user, set())
new_groups = self._user_additional_groups.get(user, set())
new_subuids = self._user_subuids.get(user, set())
new_subgids = self._user_subgids.get(user, set())
groups_to_remove = old_groups - new_groups
groups_to_add = new_groups - old_groups
subuids_to_remove = old_subuids - new_subuids
subuids_to_add = new_subuids - old_subuids
subgids_to_remove = old_subgids - new_subgids
subgids_to_add = new_subgids - old_subgids
output.print_list(
f"Removing {user} from groups:", list(groups_to_remove), level=output.INFO
)
output.print_list(f"Adding {user} to groups:", list(groups_to_add), level=output.INFO)
# It's not possible to remove and add groups at the same time, so remove groups first
if groups_to_remove:
command.prg(["usermod", "-r", "-G", ",".join(groups_to_remove), user], pty=False)
# Set these only if things change, no need to clutter the store otherwise
store["usermanager_user_additional_groups"][user] = new_groups
# Rest of the changes can be done with a single command
cmd = ["usermod"]
if groups_to_add:
cmd += ["-a", "-G", ",".join(groups_to_add)]
for first, last in subuids_to_remove:
output.print_info(f"Removing subuids {first}-{last} from {user}.")
cmd += ["--del-subuids", f"{first}-{last}"]
for first, last in subuids_to_add:
output.print_info(f"Adding subuids {first}-{last} to {user}.")
cmd += ["--add-subuids", f"{first}-{last}"]
for first, last in subgids_to_remove:
output.print_info(f"Removing subgids {first}-{last} from {user}.")
cmd += ["--del-subgids", f"{first}-{last}"]
for first, last in subgids_to_add:
output.print_info(f"Adding subgids {first}-{last} to {user}.")
cmd += ["--add-subgids", f"{first}-{last}"]
if len(cmd) > 1:
cmd.append(user)
command.prg(cmd, pty=False)
# Set these only if things change, no need to clutter the store otherwise
store["usermanager_user_additional_groups"][user] = new_groups
store["usermanager_user_subuids"][user] = new_subuids
store["usermanager_user_subgids"][user] = new_subgids
def _delete_users_and_groups(self, store: _store.Store):
store.ensure("usermanager_users", set())
store.ensure("usermanager_groups", set())
managed_users = set(map(lambda u: u.username, self.users))
managed_groups = set(map(lambda g: g.groupname, self.groups))
groups_to_remove = store["usermanager_groups"] - managed_groups
users_to_remove = store["usermanager_users"] - managed_users
for user in users_to_remove:
output.print_info(f"Deleting user {user}.")
command.prg(["userdel", user], pty=False)
store["usermanager_users"] = managed_users
for group in groups_to_remove:
output.print_info(f"Deleting group {group}.")
command.prg(["groupdel", group], pty=False)
store["usermanager_groups"] = managed_groups
def before_update(self, store: _store.Store):
for group in self.groups:
self._check_group(group)
user_groups_index = self._user_groups_index()
for user in self.users:
self._check_user(user, user_groups_index)
self._delete_users_and_groups(store)
def after_update(self, store: _store.Store):
# Iterate all entries to ensure removals take place
for user in pwd.getpwall():
self._modify_user_groups_subids(user.pw_name, store)
================================================
FILE: src/decman/plugins/__init__.py
================================================
import importlib.metadata as metadata
import typing
import decman.core.module as module
import decman.core.store as _store
class Plugin:
"""
A Plugin manages one part of a system.
NAME:
Canonical plugin name.
"""
NAME: str = ""
def available(self) -> bool:
"""
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.
"""
return True
def apply(
self, store: _store.Store, dry_run: bool = False, params: list[str] | None = None
) -> bool:
"""
Ensures that the state managed by this plugin is present.
Set ``dry_run`` to only print changes applying this plugin would cause.
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.
Returns ``True`` when applying was successful, ``False`` when it failed.
"""
return True
def process_modules(self, store: _store.Store, modules: list[module.Module]):
"""
Processes a module.
"""
def run_method_with_attribute(mod: module.Module, attribute: str) -> typing.Any:
"""
Runs the first method with the given attribute in the module and returns its returned value.
Returns ``None`` if no such method is found.
Only the first found method with the attribute is ran.
"""
for name in dir(mod):
attr = getattr(mod, name)
if not callable(attr):
continue
func = getattr(attr, "__func__", attr)
if getattr(func, attribute, False):
return attr()
return None
def run_methods_with_attribute(mod: module.Module, attribute: str) -> list[typing.Any]:
"""
Runs all methods with the given attribute in the module and returns their returned values.
Returns an empty list if no such methods are found.
"""
values = []
for name in dir(mod):
attr = getattr(mod, name)
if not callable(attr):
continue
func = getattr(attr, "__func__", attr)
if getattr(func, attribute, False):
values.append(attr())
return values
def available_plugins() -> dict[str, Plugin]:
"""
Returns all available plugins.
"""
plugins = {}
eps = metadata.entry_points(group="decman.plugins")
for ep in eps:
cls = ep.load()
if not issubclass(cls, Plugin):
continue
instance = cls()
if instance.available():
plugins[cls.NAME] = instance
return plugins
================================================
FILE: src/decman/py.typed
================================================
================================================
FILE: tests/test_decman_app.py
================================================
import argparse
import types
import pytest
import decman.app as app # adjust if run_decman lives elsewhere
class DummyStore:
def __init__(self, enabled=None, scripts=None):
self._data = {}
if enabled is not None:
self._data["enabled_modules"] = list(enabled)
if scripts is not None:
self._data["module_on_disable_scripts"] = dict(scripts)
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._data[key] = value
def ensure(self, key, default):
self._data.setdefault(key, default)
class DummyModule:
def __init__(self, name):
self.name = name
self._changed = False
self.before_update_called = False
self.on_enable_called = False
self.on_change_called = False
self.after_update_called = False
def before_update(self, store):
self.before_update_called = True
def on_enable(self, store):
self.on_enable_called = True
def on_change(self, store):
self.on_change_called = True
def after_update(self, store):
self.after_update_called = True
@staticmethod
def on_disable():
print("Disabled")
class DummyPlugin:
def __init__(self, apply_result=True):
self.process_modules_called = False
self.apply_called_with = None
self.apply_result = apply_result
def process_modules(self, store, modules):
self.process_modules_called = True
def apply(self, store, dry_run=False, params=None):
self.apply_called_with = dry_run
return self.apply_result
def make_args(
only=None,
skip=None,
dry_run=False,
no_hooks=False,
):
return argparse.Namespace(
only=only, skip=skip or [], dry_run=dry_run, no_hooks=no_hooks, params=[]
)
@pytest.fixture
def no_op_output(monkeypatch):
ns = types.SimpleNamespace(
print_debug=lambda *a, **k: None,
print_summary=lambda *a, **k: None,
print_info=lambda *a, **k: None,
print_warning=lambda *a, **k: None,
)
monkeypatch.setattr(app, "output", ns)
return ns
@pytest.fixture
def base_decman(monkeypatch):
# Ensure decman attribute exists on app and has the fields we need
dm = types.SimpleNamespace()
dm.execution_order = []
dm.modules = []
dm.files = []
dm.directories = []
dm.symlinks = {}
dm.plugins = {}
dm.prg_calls = []
def prg(cmd):
dm.prg_calls.append(cmd)
dm.prg = prg
monkeypatch.setattr(app, "decman", dm)
return dm
@pytest.fixture
def file_manager(monkeypatch):
fm = types.SimpleNamespace()
fm.update_files_calls = []
fm.result = True
def update_files(store, modules, files, directories, symlinks, dry_run=False):
fm.update_files_calls.append(
dict(
store=store,
modules=list(modules),
files=list(files),
directories=list(directories),
symlinks=list(symlinks),
dry_run=dry_run,
)
)
return fm.result
fm.update_files = update_files
monkeypatch.setattr(app, "file_manager", fm)
return fm
def test_execution_order_only_and_skip(no_op_output, base_decman, file_manager):
base_decman.execution_order = ["files", "plugin_a", "plugin_b"]
args = make_args(
only=["files", "plugin_b"],
skip=["plugin_b"],
dry_run=True,
no_hooks=True,
)
store = DummyStore()
plugin = DummyPlugin(apply_result=False)
base_decman.plugins = {"plugin_b": plugin}
result = app.run_decman(store, args)
assert result is True
# Should have run only "files"
assert len(file_manager.update_files_calls) == 1
assert file_manager.update_files_calls[0]["dry_run"] is True
def test_returns_false_when_update_files_fails_and_skips_plugins(
no_op_output, base_decman, file_manager
):
base_decman.execution_order = ["files", "plugin_a"]
plugin = DummyPlugin(apply_result=True)
base_decman.plugins = {"plugin_a": plugin}
file_manager.result = False # update_files fails
args = make_args(dry_run=False, no_hooks=True)
store = DummyStore()
result = app.run_decman(store, args)
assert result is False
# update_files called once
assert len(file_manager.update_files_calls) == 1
# plugin should never be touched
assert plugin.process_modules_called is False
assert plugin.apply_called_with is None
def test_plugin_failure_returns_false(no_op_output, base_decman, file_manager):
base_decman.execution_order = ["plugin_a"]
plugin = DummyPlugin(apply_result=False)
base_decman.plugins = {"plugin_a": plugin}
args = make_args(dry_run=False, no_hooks=True)
store = DummyStore()
result = app.run_decman(store, args)
assert result is False
assert plugin.process_modules_called is True
assert plugin.apply_called_with is False
# No file updates
assert file_manager.update_files_calls == []
def test_disabled_modules_run_on_disable_script(no_op_output, base_decman, file_manager):
# enabled_modules contains a module that no longer exists
store = DummyStore(
enabled=["present", "old_mod"],
scripts={"old_mod": "/tmp/on_disable.sh"},
)
# Only "present" exists now, so "old_mod" is disabled
base_decman.modules = [DummyModule("present")]
base_decman.execution_order = []
args = make_args(dry_run=False, no_hooks=False)
result = app.run_decman(store, args)
assert result is True
# prg should be called with the script for old_mod
assert base_decman.prg_calls == [["/tmp/on_disable.sh"]]
assert store["enabled_modules"] == ["present"]
assert store["module_on_disable_scripts"] == {}
def test_on_disable_not_run_in_dry_run(no_op_output, base_decman, file_manager):
store = DummyStore(
enabled=["present", "old_mod"],
scripts={"old_mod": "/tmp/on_disable.sh"},
)
base_decman.modules = [DummyModule("present")]
base_decman.execution_order = []
args = make_args(dry_run=True, no_hooks=True)
result = app.run_decman(store, args)
assert result is True
# dry_run: on_disable scripts must not be executed
assert base_decman.prg_calls == []
def test_hooks_called_for_new_and_changed_modules(
no_op_output, base_decman, file_manager, monkeypatch, tmp_path
):
m1 = DummyModule("mod1")
m2 = DummyModule("mod2")
m1._changed = True
m2._changed = False
base_decman.modules = [m1, m2]
base_decman.execution_order = [] # no steps, just hooks
monkeypatch.setattr("decman.config.module_on_disable_scripts_dir", tmp_path)
# Only mod2 was previously enabled, so mod1 is "new"
store = DummyStore(enabled=["mod2"])
args = make_args(dry_run=False, no_hooks=False)
result = app.run_decman(store, args)
assert result is True
# before_update for all modules
assert m1.before_update_called is True
assert m2.before_update_called is True
# on_enable only for new module (mod1)
assert m1.on_enable_called is True
assert m2.on_enable_called is False
# on_change only for modules with _changed
assert m1.on_change_called is True
assert m2.on_change_called is False
# after_update for all modules
assert m1.after_update_called is True
assert m2.after_update_called is True
assert store["enabled_modules"] == ["mod2", "mod1"]
assert store["module_on_disable_scripts"] == {"mod1": str(tmp_path / "mod1_on_disable.py")}
def test_hooks_not_called_when_no_hooks(no_op_output, base_decman, file_manager):
m1 = DummyModule("mod1")
m1._changed = True
base_decman.modules = [m1]
base_decman.execution_order = []
store = DummyStore(enabled=["mod1"])
args = make_args(dry_run=False, no_hooks=True)
result = app.run_decman(store, args)
assert result is True
assert m1.before_update_called is False
assert m1.on_enable_called is False
assert m1.on_change_called is False
assert m1.after_update_called is False
def test_dry_run_skips_all_hooks_but_runs_steps_with_flag(no_op_output, base_decman, file_manager):
m1 = DummyModule("mod1")
m1._changed = True
base_decman.modules = [m1]
base_decman.execution_order = ["files", "plugin_a"]
plugin = DummyPlugin(apply_result=True)
base_decman.plugins = {"plugin_a": plugin}
store = DummyStore()
args = make_args(dry_run=True, no_hooks=False)
result = app.run_decman(store, args)
assert result is True
# Steps executed with dry_run=True
assert len(file_manager.update_files_calls) == 1
assert file_manager.update_files_calls[0]["dry_run"] is True
assert plugin.process_modules_called is True
assert plugin.apply_called_with is True
# All hooks skipped due to dry_run
assert m1.before_update_called is False
assert m1.on_enable_called is False
assert m1.on_change_called is False
assert m1.after_update_called is False
def test_missing_plugin_emits_warning_but_continues(base_decman, file_manager, monkeypatch):
warnings = []
def warn(msg):
warnings.append(msg)
out = types.SimpleNamespace(
print_debug=lambda *a, **k: None,
print_summary=lambda *a, **k: None,
print_info=lambda *a, **k: None,
print_warning=warn,
)
monkeypatch.setattr(app, "output", out)
base_decman.execution_order = ["unknown_plugin"]
base_decman.plugins = {} # none available
store = DummyStore()
args = make_args(dry_run=True, no_hooks=True)
result = app.run_decman(store, args)
assert result is True
assert any("unknown_plugin" in w for w in warnings)
================================================
FILE: tests/test_decman_core_command.py
================================================
import json
import sys
import typing
import pytest
import decman.core.command as command
import decman.core.output
def test_prg_pty_true_uses_pty_run_and_check(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_pty_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):
calls["pty_run"] = (cmd, user, env_overrides, mimic_login)
return 0, "ok"
def fake_check_run_result(cmd, result, include_output=None):
calls["check_run_result"] = (cmd, result)
return result
def fake_print_warning(msg: str):
raise AssertionError("print_warning must not be called when code == 0")
monkeypatch.setattr(command, "pty_run", fake_pty_run)
monkeypatch.setattr(command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.core.output, "print_warning", fake_print_warning)
out = decman.prg(
["echo", "hi"],
user="alice",
env_overrides={"FOO": "bar"},
mimic_login=True,
pty=True,
check=True,
)
assert out == "ok"
assert calls["pty_run"] == (["echo", "hi"], "alice", {"FOO": "bar"}, True)
assert calls["check_run_result"] == (["echo", "hi"], (0, "ok"))
def test_prg_pty_false_uses_run(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):
calls["run"] = (cmd, user, env_overrides, mimic_login)
return 0, "no-pty"
def fake_check_run_result(cmd, result, include_output=None):
return result
def fake_print_warning(msg: str):
raise AssertionError("print_warning must not be called when code == 0")
monkeypatch.setattr(command, "run", fake_run)
monkeypatch.setattr(command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.core.output, "print_warning", fake_print_warning)
out = decman.prg(["true"], pty=False, check=True)
assert out == "no-pty"
assert calls["run"] == (["true"], None, None, False)
def test_prg_check_false_warns_on_nonzero(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):
# non-zero exit code
return 3, "bad"
def fake_check_run_result(cmd, result, include_output=None):
raise AssertionError("check_run_result must not be called when check=False")
def fake_print_warning(msg: str):
calls["warning"] = msg
monkeypatch.setattr(command, "run", fake_run)
monkeypatch.setattr(command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.core.output, "print_warning", fake_print_warning)
out = decman.prg(["cmd", "arg"], pty=False, check=False)
assert out == "bad"
assert "cmd arg" in calls["warning"]
assert "exit code 3" in calls["warning"]
def test_prg_check_true_propagates_command_failed_error(monkeypatch: pytest.MonkeyPatch):
class CommandFailedError(Exception):
pass
def fake_run(cmd, user=None, env_overrides=None, pass_environment=None, mimic_login=False):
return 42, "boom"
def fake_check_run_result(cmd, result, include_output=None):
raise CommandFailedError((cmd, result))
def fake_print_warning(msg: str):
raise AssertionError("print_warning must not be called when check=True and error")
monkeypatch.setattr(command, "run", fake_run)
monkeypatch.setattr(command, "check_run_result", fake_check_run_result)
monkeypatch.setattr(decman.core.output, "print_warning", fake_print_warning)
with pytest.raises(CommandFailedError):
decman.prg(["boom"], pty=False, check=True)
def test_run_simple():
code, out = command.run([sys.executable, "-c", "print('ok')"])
assert code == 0
assert out.strip() == "ok"
def test_run_exec_failure():
code, out = command.run(["/does/not/exist"])
assert code != 0
assert "not" in out.lower()
def test_run_env_overrides_visible_in_child(monkeypatch):
code, out = command.run(
[
sys.executable,
"-c",
("import os, json; print(json.dumps({'FOO': os.environ['FOO'], }))"),
],
env_overrides={"FOO": "BAR"},
)
assert code == 0
data = json.loads(out.strip())
assert data["FOO"] == "BAR"
@pytest.mark.skipif(not sys.stdin.isatty(), reason="requires TTY")
def test_pty_run_simple():
code, out = command.pty_run([sys.executable, "-c", "print('ok')"])
assert code == 0
assert "ok" in out
assert "\r\n" not in out
================================================
FILE: tests/test_decman_core_file_manager.py
================================================
import os
import pytest
import decman.core.error as errors
import decman.core.output as output
from decman.core.file_manager import (
_install_directories,
_install_files,
_install_symlinks,
update_files,
)
class DummyFile:
def __init__(self, result=True, exc: BaseException | None = None):
self.result = result
self.exc = exc
self.source_file = None
self.calls: list[tuple[str, dict | None, bool]] = []
def copy_to(self, target: str, variables=None, dry_run: bool = False) -> bool:
self.calls.append((target, variables, dry_run))
if self.exc is not None:
raise self.exc
return self.result
class DummyDirectory:
def __init__(
self,
checked: list[str] | None = None,
changed: list[str] | None = None,
exc: BaseException | None = None,
source_directory: str = "",
):
self.checked = checked or []
self.changed = changed or []
self.exc = exc
self.source_directory = source_directory
self.calls: list[tuple[str, dict | None, bool]] = []
def copy_to(self, target: str, variables=None, dry_run: bool = False):
self.calls.append((target, variables, dry_run))
if self.exc is not None:
raise self.exc
return self.checked, self.changed
class DummyModule:
def __init__(
self,
name: str,
file_map: dict[str, DummyFile] | None = None,
dir_map: dict[str, DummyDirectory] | None = None,
symlink_map: dict[str, str] | None = None,
file_vars: dict[str, str] | None = None,
):
self.name = name
self._file_map = file_map or {}
self._dir_map = dir_map or {}
self._file_vars = file_vars or {}
self._symlink_map = symlink_map or {}
self._changed = False
def files(self):
return self._file_map
def directories(self):
return self._dir_map
def symlinks(self):
return self._symlink_map
def file_variables(self):
return self._file_vars
class DummyStore:
def __init__(self, initial: dict | None = None):
self._data = dict(initial or {})
def __getitem__(self, key):
return self._data[key]
def __setitem__(self, key, value):
self._data[key] = value
def ensure(self, key, default):
self._data.setdefault(key, default)
# ---- _install_files -------------------------------------------------------
def test_install_files_non_dry_run_tracks_checked_and_changed():
f1 = DummyFile(result=True)
f2 = DummyFile(result=False)
files = {
"/tmp/file1": f1,
"/tmp/file2": f2,
}
checked, changed = _install_files(files, variables={"X": "1"}, dry_run=False)
assert checked == ["/tmp/file1", "/tmp/file2"]
assert changed == ["/tmp/file1"]
assert f1.calls == [("/tmp/file1", {"X": "1"}, False)]
assert f2.calls == [("/tmp/file2", {"X": "1"}, False)]
def test_install_files_dry_run_uses_dry_run_flag_and_respects_return_value():
f1 = DummyFile(result=True)
f2 = DummyFile(result=False)
files = {
"/tmp/file1": f1,
"/tmp/file2": f2,
}
checked, changed = _install_files(files, variables=None, dry_run=True)
assert checked == ["/tmp/file1", "/tmp/file2"]
assert changed == ["/tmp/file1"] # only ones that "would" change
assert f1.calls == [("/tmp/file1", None, True)]
assert f2.calls == [("/tmp/file2", None, True)]
@pytest.mark.parametrize(
"exc",
[
FileNotFoundError("nope"),
OSError("boom"),
UnicodeEncodeError("utf-8", "x", 0, 1, "bad"),
UnicodeDecodeError("utf-8", b"x", 0, 1, "bad"),
],
)
def test_install_files_wraps_exceptions(exc):
f = DummyFile(exc=exc)
files = {"/tmp/file": f}
with pytest.raises(errors.FSInstallationFailedError) as e:
_install_files(files, dry_run=False)
msg = str(e.value)
assert "/tmp/file" in msg
assert "content" in msg or "Source file doesn't exist." in msg
# ---- _install_directories -------------------------------------------------
def test_install_directories_aggregates_checked_and_changed():
d1 = DummyDirectory(
checked=["/tmp/d1/a", "/tmp/d1/b"],
changed=["/tmp/d1/a"],
source_directory="/src/d1",
)
d2 = DummyDirectory(
checked=["/tmp/d2/a"],
changed=["/tmp/d2/a"],
source_directory="/src/d2",
)
dirs = {
"/tmp/d1": d1,
"/tmp/d2": d2,
}
checked, changed = _install_directories(dirs, variables={"Y": "2"}, dry_run=False)
assert checked == ["/tmp/d1/a", "/tmp/d1/b", "/tmp/d2/a"]
assert changed == ["/tmp/d1/a", "/tmp/d2/a"]
# dry_run flag and variables propagated
assert d1.calls == [("/tmp/d1", {"Y": "2"}, False)]
assert d2.calls == [("/tmp/d2", {"Y": "2"}, False)]
@pytest.mark.parametrize(
"exc",
[
FileNotFoundError("nope"),
OSError("boom"),
UnicodeEncodeError("utf-8", "x", 0, 1, "bad"),
UnicodeDecodeError("utf-8", b"x", 0, 1, "bad"),
],
)
def test_install_directories_wraps_exceptions(exc):
d = DummyDirectory(exc=exc, source_directory="/src")
dirs = {"/tmp/d": d}
with pytest.raises(errors.FSInstallationFailedError) as e:
_install_directories(dirs, dry_run=False)
msg = str(e.value)
assert "/tmp/d" in msg
assert "/src" in msg
# ---- update_files ---------------------------------------------------------
def test_update_files_success_updates_store_and_removes_stale_files(monkeypatch):
# Prepare common files/dirs
common_file = DummyFile(result=True)
common_dir = DummyDirectory(
checked=["/etc/app/config.d/a.conf"],
changed=["/etc/app/config.d/a.conf"],
source_directory="/src/config.d",
)
# Module with its own file
mod_file = DummyFile(result=True)
m = DummyModule(
name="mod1",
file_map={"/etc/app/mod1.conf": mod_file},
dir_map={},
file_vars={"FOO": "bar"},
)
# Store already has some files, including one stale file
store = DummyStore(
{"all_files": ["/etc/app/common.conf", "/etc/app/mod1.conf", "/etc/app/stale.conf"]}
)
removed = []
def fake_remove(path):
removed.append(path)
monkeypatch.setattr(os, "remove", fake_remove)
# Run
ok = update_files(
store=store,
modules={m},
files={"/etc/app/common.conf": common_file},
directories={"/etc/app/config.d": common_dir},
symlinks={},
dry_run=False,
)
assert ok is True
# common + dir content + module file were re-checked
assert set(store["all_files"]) == {
"/etc/app/common.conf",
"/etc/app/config.d/a.conf",
"/etc/app/mod1.conf",
}
# stale file should be removed
assert removed == ["/etc/app/stale.conf"]
# module marked changed because its file changed
assert m._changed is True
# copy_to called for all files with correct dry_run flag
assert common_file.calls == [("/etc/app/common.conf", None, False)]
assert mod_file.calls == [("/etc/app/mod1.conf", {"FOO": "bar"}, False)]
def test_update_files_dry_run_does_not_touch_store_or_remove(monkeypatch):
common_file = DummyFile(result=True)
common_dir = DummyDirectory(
checked=["/etc/app/config.d/a.conf"],
changed=["/etc/app/config.d/a.conf"],
source_directory="/src/config.d",
)
m = DummyModule(
name="mod1",
file_map={"/etc/app/mod1.conf": DummyFile(result=True)},
dir_map={},
)
store = DummyStore({"all_files": ["/etc/app/common.conf", "/etc/app/stale.conf"]})
removed = []
def fake_remove(path):
removed.append(path)
monkeypatch.setattr(os, "remove", fake_remove)
ok = update_files(
store=store,
modules={m},
files={"/etc/app/common.conf": common_file},
directories={"/etc/app/config.d": common_dir},
symlinks={},
dry_run=True,
)
assert ok is True
# Store unchanged
assert store["all_files"] == ["/etc/app/common.conf", "/etc/app/stale.conf"]
# No removals
assert removed == []
# copy_to called with dry_run=True
assert common_file.calls == [("/etc/app/common.conf", None, True)]
def test_update_files_propagates_fsinstallation_error_and_does_not_modify_store(monkeypatch):
# Use real store.Store to ensure interface compatibility if you prefer
store = DummyStore({"all_files": ["/etc/app/keep.conf"]})
# Fake failing _install_files
def failing_install_files(*args, **kwargs):
raise errors.FSInstallationFailedError("content", "/etc/app/broken.conf", "fail")
# Capture deletes
removed = []
def fake_remove(path):
removed.append(path)
# Spy on output error/traceback so they exist but don't blow up
error_msgs = []
def fake_print_error(msg):
error_msgs.append(msg)
traces = []
def fake_print_traceback():
traces.append(True)
import decman.core.file_manager as fm_mod
monkeypatch.setattr(fm_mod, "_install_files", failing_install_files)
monkeypatch.setattr(os, "remove", fake_remove)
monkeypatch.setattr(output, "print_error", fake_print_error)
monkeypatch.setattr(output, "print_traceback", fake_print_traceback)
ok = update_files(
store=store,
modules=set(),
files={"/etc/app/broken.conf": DummyFile()},
directories={},
symlinks={},
dry_run=False,
)
assert ok is False
# Store unchanged
assert store["all_files"] == ["/etc/app/keep.conf"]
# No deletions attempted
assert removed == []
# Error and traceback were logged
assert error_msgs
assert traces
# symlinks
def test_install_symlinks_creates_missing_link_and_parents(tmp_path):
target = tmp_path / "target"
target.write_text("x")
link = tmp_path / "a" / "b" / "link"
checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False)
assert checked == [str(link)]
assert changed == [str(link)]
assert link.is_symlink()
assert os.readlink(link) == str(target)
def test_install_symlinks_no_change_when_already_points_to_target(tmp_path):
target = tmp_path / "target"
target.write_text("x")
link = tmp_path / "link"
os.symlink(str(target), str(link))
checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False)
assert checked == [str(link)]
assert changed == []
assert link.is_symlink()
assert os.readlink(link) == str(target)
def test_install_symlinks_replaces_wrong_target(tmp_path):
target1 = tmp_path / "target1"
target2 = tmp_path / "target2"
target1.write_text("1")
target2.write_text("2")
link = tmp_path / "link"
os.symlink(str(target1), str(link))
checked, changed = _install_symlinks({str(link): str(target2)}, dry_run=False)
assert checked == [str(link)]
assert changed == [str(link)]
assert link.is_symlink()
assert os.readlink(link) == str(target2)
def test_install_symlinks_replaces_existing_regular_file(tmp_path):
target = tmp_path / "target"
target.write_text("x")
link = tmp_path / "link"
link.write_text("not a symlink")
checked, changed = _install_symlinks({str(link): str(target)}, dry_run=False)
assert checked == [str(link)]
assert changed == [str(link)]
assert link.is_symlink()
assert os.readlink(link) == str(target)
def test_install_symlinks_dry_run_does_not_touch_fs(tmp_path):
target = tmp_path / "target"
target.write_text("x")
link = tmp_path / "a" / "b" / "link"
checked, changed = _install_symlinks({str(link): str(target)}, dry_run=True)
assert checked == [str(link)]
assert changed == [str(link)] # would change
assert not link.exists()
def test_update_files_tracks_symlinks_and_removes_stale_symlinks(tmp_path):
# layout
root = tmp_path
t = root / "target"
t.write_text("x")
live_link = root / "links" / "live"
stale_link = root / "links" / "stale"
# pre-existing stale link to be removed
os.makedirs(stale_link.parent, exist_ok=True)
os.symlink(str(t), str(stale_link))
m = DummyModule(
name="mod1",
file_map={},
dir_map={},
symlink_map={str(live_link): str(t)},
)
store = DummyStore(
{"all_files": [str(stale_link)]} # new store key
)
ok = update_files(
store=store,
modules={m},
files={},
directories={},
symlinks={},
dry_run=False,
)
assert ok is True
# new link exists
assert live_link.is_symlink()
assert os.readlink(live_link) == str(t)
# stale link removed
assert not stale_link.exists()
# store updated
assert store["all_files"] == [str(live_link)]
def test_update_files_dry_run_does_not_create_or_remove_symlinks(tmp_path):
root = tmp_path
t = root / "target"
t.write_text("x")
live_link = root / "links" / "live"
stale_link = root / "links" / "stale"
os.makedirs(stale_link.parent, exist_ok=True)
os.symlink(str(t), str(stale_link))
m = DummyModule(
name="mod1",
file_map={},
dir_map={},
symlink_map={str(live_link): str(t)},
)
store = DummyStore({"all_files": [str(stale_link)]})
ok = update_files(
store=store,
modules={m},
files={},
directories={},
symlinks={},
dry_run=True,
)
assert ok is True
# no fs changes
assert not live_link.exists()
assert stale_link.is_symlink()
# store unchanged
assert store["all_files"] == [str(stale_link)]
================================================
FILE: tests/test_decman_core_fs.py
================================================
import os
import stat
from pathlib import Path
# Adjust this import to match your actual module location
import decman.core.fs as fs
# --- fs.File tests --------------------------------------------------------------
def test_file_from_content_creates_and_is_idempotent(tmp_path: Path) -> None:
target = tmp_path / "file.txt"
f = fs.File(content="hello", permissions=0o600)
# First run: file must be created and reported as changed
changed1 = f.copy_to(str(target))
assert changed1 is True
assert target.read_text(encoding="utf-8") == "hello"
mode = stat.S_IMODE(target.stat().st_mode)
assert mode == 0o600
# Second run with same configuration: no content change
changed2 = f.copy_to(str(target))
assert changed2 is False
assert target.read_text(encoding="utf-8") == "hello"
assert stat.S_IMODE(target.stat().st_mode) == 0o600
def test_file_content_with_variables_and_change_detection(tmp_path: Path) -> None:
target = tmp_path / "templated.txt"
f = fs.File(content="hello {{NAME}}")
# First run: NAME=world
changed1 = f.copy_to(str(target), {"{{NAME}}": "world"})
assert changed1 is True
assert target.read_text(encoding="utf-8") == "hello world"
# Second run: same variables, no change
changed2 = f.copy_to(str(target), {"{{NAME}}": "world"})
assert changed2 is False
assert target.read_text(encoding="utf-8") == "hello world"
# Third run: different variables, should change
changed3 = f.copy_to(str(target), {"{{NAME}}": "there"})
assert changed3 is True
assert target.read_text(encoding="utf-8") == "hello there"
def test_file_from_source_text_with_and_without_variables(tmp_path: Path) -> None:
src = tmp_path / "src.txt"
src.write_text("VALUE={{X}}", encoding="utf-8")
target = tmp_path / "dst.txt"
# Without variables (raw copy)
f_raw = fs.File(source_file=str(src))
changed1 = f_raw.copy_to(str(target), {})
assert changed1 is True
assert target.read_text(encoding="utf-8") == "VALUE={{X}}"
# Idempotent raw copy
changed2 = f_raw.copy_to(str(target), {})
assert changed2 is False
# With variables (substitution)
f_sub = fs.File(source_file=str(src))
changed3 = f_sub.copy_to(str(target), {"{{X}}": "42"})
assert changed3 is True
assert target.read_text(encoding="utf-8") == "VALUE=42"
# Idempotent after substitution
changed4 = f_sub.copy_to(str(target), {"{{X}}": "42"})
assert changed4 is False
def test_file_binary_from_content(tmp_path: Path) -> None:
target = tmp_path / "bin.dat"
payload = b"\x00\x01\x02hello"
f = fs.File(content=payload.decode("latin1"), bin_file=True)
changed1 = f.copy_to(str(target))
assert changed1 is True
assert target.read_bytes() == payload
# Idempotent: second call does not rewrite
changed2 = f.copy_to(str(target))
assert changed2 is False
assert target.read_bytes() == payload
def test_file_binary_copy_from_source(tmp_path: Path) -> None:
src = tmp_path / "src.bin"
payload = b"\x10\x20\x30binary"
src.write_bytes(payload)
target = tmp_path / "dst.bin"
f = fs.File(source_file=str(src), bin_file=True)
changed1 = f.copy_to(str(target), {"IGNORED": "x"})
assert changed1 is True
assert target.read_bytes() == payload
# Idempotent, comparing bytes
changed2 = f.copy_to(str(target), {"IGNORED": "x"})
assert changed2 is False
assert target.read_bytes() == payload
def test_file_creates_parent_directories_and_applies_permissions(tmp_path: Path) -> None:
nested_dir = tmp_path / "a" / "b" / "c"
target = nested_dir / "file.txt"
f = fs.File(content="data", permissions=0o644)
changed = f.copy_to(str(target))
assert changed is True
assert target.read_text(encoding="utf-8") == "data"
# Directories created
assert nested_dir.is_dir()
# Permissions on file
mode = stat.S_IMODE(target.stat().st_mode)
assert mode == 0o644
# --- fs.Directory tests ---------------------------------------------------------
def _create_sample_source_tree(root: Path) -> None:
(root / "sub").mkdir(parents=True)
(root / "a.txt").write_text("A={{X}}", encoding="utf-8")
(root / "sub" / "b.txt").write_text("B={{X}}", encoding="utf-8")
def test_directory_copy_to_creates_and_is_idempotent(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = fs.Directory(
source_directory=str(src_dir),
bin_files=False,
encoding="utf-8",
permissions=0o644,
)
# First run: both fs should be created and reported as changed
checked1, changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"})
expected_paths = {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
assert set(changed1) == expected_paths
assert set(checked1) == expected_paths
assert (dst_dir / "a.txt").read_text(encoding="utf-8") == "A=1"
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == "B=1"
# Second run with same variables: no fs should be reported as changed
checked2, changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "1"})
assert changed2 == []
assert set(checked2) == expected_paths
def test_directory_copy_to_detects_changes_via_variables(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = fs.Directory(source_directory=str(src_dir))
# Initial materialization
_checked, changed1 = d.copy_to(str(dst_dir), variables={"{{X}}": "alpha"})
assert set(changed1) == {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
# Change variables -> both fs change
_checked, changed2 = d.copy_to(str(dst_dir), variables={"{{X}}": "beta"})
assert set(changed2) == {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
assert (dst_dir / "a.txt").read_text(encoding="utf-8") == "A=beta"
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == "B=beta"
def test_directory_copy_to_dry_run(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = fs.Directory(source_directory=str(src_dir))
# First, actually materialize once
d.copy_to(str(dst_dir), variables={"{{X}}": "1"})
# Now perform dry-run with different variables; contents must not change
before_a = (dst_dir / "a.txt").read_text(encoding="utf-8")
before_b = (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8")
_checked, changed_dry = d.copy_to(
str(dst_dir),
variables={"{{X}}": "2"},
dry_run=True,
)
expected_paths = {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
assert set(changed_dry) == expected_paths
# Contents remain as before (no writes in dry-run)
assert (dst_dir / "a.txt").read_text(encoding="utf-8") == before_a
assert (dst_dir / "sub" / "b.txt").read_text(encoding="utf-8") == before_b
def test_directory_copy_to_restores_working_directory(tmp_path: Path) -> None:
src_dir = tmp_path / "src"
dst_dir = tmp_path / "dst"
src_dir.mkdir()
_create_sample_source_tree(src_dir)
d = fs.Directory(source_directory=str(src_dir))
original_cwd = os.getcwd()
try:
_checked, changed = d.copy_to(str(dst_dir), variables={"{{X}}": "x"})
assert set(changed) == {
str(dst_dir / "a.txt"),
str(dst_dir / "sub" / "b.txt"),
}
finally:
# Ensure the implementation restored CWD
assert os.getcwd() == original_cwd
def test_file_copy_to_dry_run(tmp_path):
target = tmp_path / "file.txt"
f = fs.File(content="hello", permissions=0o600)
# 1) Dry-run on non-existent file: would create -> returns True, no file written
assert not target.exists()
changed = f.copy_to(str(target), dry_run=True)
assert changed is True
assert not target.exists()
# 2) Actually create the file
changed_real = f.copy_to(str(target), dry_run=False)
assert changed_real is True
assert target.exists()
assert target.read_text(encoding="utf-8") == "hello"
# 3) Dry-run with same desired content: would NOT modify -> returns False, file unchanged
mtime_before = target.stat().st_mtime
changed_again = f.copy_to(str(target), dry_run=True)
assert changed_again is False
assert target.read_text(encoding="utf-8") == "hello"
# mtime must not change in dry-run
assert target.stat().st_mtime == mtime_before
================================================
FILE: tests/test_decman_core_module.py
================================================
import stat
import subprocess
import sys
from pathlib import Path
import pytest
import decman.core.error as errors
import decman.core.module as module
def test_module_without_on_disable_is_accepted():
class NoOnDisable(module.Module):
def __init__(self):
super().__init__("no_on_disable")
m = NoOnDisable()
assert m.name == "no_on_disable"
def test_on_disable_must_be_staticmethod():
with pytest.raises(errors.InvalidOnDisableError) as exc:
class NotStatic(module.Module):
def on_disable(): # type: ignore[no-redefined-builtin]
pass
msg = str(exc.value)
assert "on_disable must be declared as @staticmethod" in msg
def test_on_disable_must_take_no_parameters():
with pytest.raises(errors.InvalidOnDisableError) as exc:
class HasArgs(module.Module):
@staticmethod
def on_disable(x): # type: ignore[unused-argument]
pass
msg = str(exc.value)
assert "on_disable must take no parameters" in msg
SOME_CONST = 42 # noqa: F841
def test_on_disable_must_not_use_module_level_globals():
with pytest.raises(errors.InvalidOnDisableError) as exc:
class UsesGlobal(module.Module):
@staticmethod
def on_disable():
# will compile as LOAD_GLOBAL for SOME_CONST
print(SOME_CONST)
msg = str(exc.value)
assert "on_disable uses nonlocal/global names" in msg
assert "SOME_CONST" in msg
def test_on_disable_must_not_close_over_outer_variables():
# closure over outer local -> should be rejected via co_freevars on inner code
with pytest.raises(errors.InvalidOnDisableError) as exc:
class Closure(module.Module):
@staticmethod
def on_disable():
x = 1
def inner():
# closes over x
print(x) # pragma: no cover
inner()
msg = str(exc.value)
assert "must not close over outer variables" in msg
def test_on_disable_nested_function_without_closure_is_allowed():
class NestedNoClosure(module.Module):
def __init__(self):
super().__init__("nested_no_closure")
@staticmethod
def on_disable():
# nested function that only uses arguments / builtins
def inner(msg: str) -> None:
print(msg)
inner("OK")
# If the class definition above passed without raising, validation succeeded.
m = NestedNoClosure()
assert m.name == "nested_no_closure"
def test_on_disable_can_use_builtins_and_imports_inside_function():
class Valid(module.Module):
def __init__(self):
super().__init__("valid")
@staticmethod
def on_disable():
import math
print("sqrt2", round(math.sqrt(2), 3))
v = Valid()
assert v.name == "valid"
def test_write_on_disable_script_returns_none_when_no_on_disable(tmp_path):
class NoOnDisable(module.Module):
def __init__(self):
super().__init__("no_on_disable")
m = NoOnDisable()
script_path = module.write_on_disable_script(m, str(tmp_path))
assert script_path is None
assert not list(tmp_path.iterdir())
def test_write_on_disable_script_creates_executable_script(tmp_path):
class Simple(module.Module):
def __init__(self):
super().__init__("Simple")
@staticmethod
def on_disable():
print("ON_DISABLE_RUN")
m = Simple()
out_dir = tmp_path / "scripts"
out_dir.mkdir()
script_path_str = module.write_on_disable_script(m, str(out_dir))
assert script_path_str is not None
script_path = Path(script_path_str)
assert script_path.exists()
mode = script_path.stat().st_mode
assert mode & stat.S_IXUSR, "script must be executable by owner"
content = script_path.read_text(encoding="utf-8")
assert "generated from" in content
assert "def on_disable" in content
assert 'if __name__ == "__main__":' in content
# Execute the generated script and check its output
proc = subprocess.run(
[sys.executable, str(script_path)],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert "ON_DISABLE_RUN" in proc.stdout
def test_write_on_disable_script_uses_module_and_class_in_header(tmp_path):
class HeaderCheck(module.Module):
def __init__(self):
super().__init__("HeaderCheck")
@staticmethod
def on_disable():
print("HEADER_CHECK")
m = HeaderCheck()
script_path_str = module.write_on_disable_script(m, str(tmp_path))
assert script_path_str is not None
script_path = Path(script_path_str)
content = script_path.read_text(encoding="utf-8")
# header should reference original module and class
assert f"{HeaderCheck.__module__}.{HeaderCheck.__name__}.on_disable" in content
================================================
FILE: tests/test_decman_core_output.py
================================================
import builtins
import types
import pytest
import decman.config as config
import decman.core.output as output
@pytest.fixture(autouse=True)
def reset_config():
# snapshot & restore config flags between tests
orig = types.SimpleNamespace(
debug_output=getattr(config, "debug_output", False),
quiet_output=getattr(config, "quiet_output", False),
color_output=getattr(config, "color_output", True),
)
yield
config.debug_output = orig.debug_output
config.quiet_output = orig.quiet_output
config.color_output = orig.color_output
def test_print_error_with_color_enabled(capsys):
config.color_output = True
output.print_error("boom")
out = capsys.readouterr().out
assert "boom" in out
assert "ERROR" in out
# crude check that some ANSI escapes are present
assert "\x1b[" in out
def test_print_error_with_color_disabled(capsys):
config.color_output = False
output.print_error("boom")
out = capsys.readouterr().out
assert out.strip().endswith("ERROR: boom")
# no ANSI escapes
assert "\x1b[" not in out
def test_print_info_respects_quiet_and_debug(capsys):
config.quiet_output = True
config.debug_output = False
output.print_info("msg 1")
out = capsys.readouterr().out
assert out == "" # suppressed
config.debug_output = True
output.print_info("msg 2")
out = capsys.readouterr().out
assert "INFO: msg 2" in out
config.quiet_output = False
config.debug_output = False
output.print_info("msg 3")
out = capsys.readouterr().out
assert "INFO: msg 3" in out
def test_print_debug_only_with_debug_enabled(capsys):
config.debug_output = False
output.print_debug("dbg")
assert capsys.readouterr().out == ""
config.debug_output = True
output.print_debug("dbg")
out = capsys.readouterr().out
assert "DEBUG" in out
assert "dbg" in out
def test_print_continuation_respects_level_and_config(capsys):
config.quiet_output = True
config.debug_output = False
output.print_continuation("x", level=output.INFO)
assert capsys.readouterr().out == ""
output.print_continuation("y", level=output.SUMMARY)
out = capsys.readouterr().out
assert "y" in out
def test_print_list_empty_outputs_nothing(capsys):
output.print_list("Header", [])
assert capsys.readouterr().out == ""
def test_print_list_summary_and_elements(capsys, monkeypatch):
# fixed terminal size for deterministic wrapping
monkeypatch.setattr(
output.shutil, "get_terminal_size", lambda: types.SimpleNamespace(columns=80)
)
config.quiet_output = False
config.debug_output = False
output.print_list("Installed packages:", ["a", "b", "c"])
out = capsys.readouterr().out.splitlines()
# header summary
assert any("SUMMARY" in line and "Installed packages:" in line for line in out)
# list content printed as continuation lines
assert any("a" in line for line in out)
assert any("b" in line for line in out)
assert any("c" in line for line in out)
def test_print_list_respects_elements_per_line_and_width(capsys, monkeypatch):
# very small width to force wrapping
monkeypatch.setattr(
output.shutil, "get_terminal_size", lambda: types.SimpleNamespace(columns=30)
)
items = [f"pkg{i}" for i in range(5)]
output.print_list(
"Pkgs:",
items,
elements_per_line=2,
limit_to_term_size=True,
level=output.SUMMARY,
)
out_lines = capsys.readouterr().out.splitlines()
list_lines = [l for l in out_lines if "pkg" in l]
# at most 2 per line
for line in list_lines:
assert len([p for p in items if p in line]) <= 2
def test_prompt_number_valid_input(monkeypatch):
inputs = iter(["3"])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_number("Pick", 1, 5)
assert res == 3
def test_prompt_number_invalid_then_valid(monkeypatch, capsys):
inputs = iter(["foo", "10", "2"])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_number("Pick", 1, 5)
assert res == 2
out = capsys.readouterr().out
# at least one error printed
assert "Invalid input" in out
def test_prompt_number_default_on_empty(monkeypatch):
inputs = iter([""])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_number("Pick", 1, 5, default=4)
assert res == 4
@pytest.mark.parametrize(
"user_input,default,expected",
[
("y", None, True),
("Y", None, True),
("yes", None, True),
("n", None, False),
("No", None, False),
("", True, True),
("", False, False),
],
)
def test_prompt_confirm(monkeypatch, user_input, default, expected):
inputs = iter([user_input])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_confirm("Continue?", default=default)
assert res is expected
def test_prompt_confirm_invalid_then_yes(monkeypatch, capsys):
inputs = iter(["maybe", "y"])
monkeypatch.setattr(builtins, "input", lambda _: next(inputs))
res = output.prompt_confirm("Continue?")
assert res is True
out = capsys.readouterr().out
assert "Invalid input." in out
================================================
FILE: tests/test_decman_core_store.py
================================================
import json
from pathlib import Path
import pytest
from decman.core.store import Store
def test_store_initially_empty_when_file_missing(tmp_path: Path) -> None:
path = tmp_path / "store.json"
assert not path.exists()
store = Store(path)
assert store.get("missing") is None
with pytest.raises(KeyError):
_ = store["missing"]
def test_store_loads_existing_file(tmp_path: Path) -> None:
path = tmp_path / "store.json"
original = {"foo": "bar", "number": 123}
path.write_text(json.dumps(original), encoding="utf-8")
store = Store(path)
assert store["foo"] == "bar"
assert store["number"] == 123
# underlying representation is dict-like
assert json.loads(path.read_text(encoding="utf-8")) == original
def test_setitem_and_getitem_roundtrip(tmp_path: Path) -> None:
path = tmp_path / "store.json"
store = Store(path)
store["foo"] = "bar"
store["number"] = 123
assert store["foo"] == "bar"
assert store["number"] == 123
def test_get_with_default(tmp_path: Path) -> None:
path = tmp_path / "store.json"
store = Store(path)
store["present"] = "value"
assert store.get("present") == "value"
assert store.get("missing") is None
assert store.get("missing", "default") == "default"
def test_save_creates_parent_directory_and_persists(tmp_path: Path) -> None:
# use nested directory to ensure parent creation is exercised
path = tmp_path / "nested" / "store.json"
store = Store(path)
store["foo"] = "bar"
store.save()
assert path.is_file()
data = json.loads(path.read_text(encoding="utf-8"))
assert data == {"foo": "bar"}
def test_context_manager_saves_on_normal_exit(tmp_path: Path) -> None:
path = tmp_path / "store.json"
with Store(path) as store:
store["foo"] = "bar"
store["number"] = 123
assert path.is_file()
data = json.loads(path.read_text(encoding="utf-8"))
assert data == {"foo": "bar", "number": 123}
def test_context_manager_saves_even_on_exception(tmp_path: Path) -> None:
path = tmp_path / "store.json"
with pytest.raises(RuntimeError):
with Store(path) as store:
store["foo"] = "bar"
raise RuntimeError("boom")
# file should still be written despite the exception
assert path.is_file()
data = json.loads(path.read_text(encoding="utf-8"))
assert data == {"foo": "bar"}
def test_repr_matches_underlying_dict(tmp_path: Path) -> None:
path = tmp_path / "store.json"
store = Store(path)
store["foo"] = "bar"
store["number"] = 123
expected = repr({"foo": "bar", "number": 123})
assert repr(store) == expected
def test_store_persists_sets(tmp_path: Path) -> None:
path = tmp_path / "store.json"
# initial write with sets
store = Store(path)
store["units"] = {"a.service", "b.service"}
store["user_units"] = {"alice": {"u1.service", "u2.service"}}
store.save()
# raw JSON should be set-encoded, not fail json.dump
raw = json.loads(path.read_text(encoding="utf-8"))
assert raw["units"]["__type__"] == "set"
assert set(raw["units"]["items"]) == {"a.service", "b.service"}
assert raw["user_units"]["alice"]["__type__"] == "set"
assert set(raw["user_units"]["alice"]["items"]) == {"u1.service", "u2.service"}
# reloading via Store must restore actual set objects
reloaded = Store(path)
assert reloaded["units"] == {"a.service", "b.service"}
assert isinstance(reloaded["units"], set)
assert reloaded["user_units"]["alice"] == {"u1.service", "u2.service"}
assert isinstance(reloaded["user_units"]["alice"], set)
================================================
FILE: tests/test_decman_init.py
================================================
import typing
import pytest
import decman
def test_sh_calls_prg_with_sh_command(monkeypatch: pytest.MonkeyPatch):
calls: dict[str, typing.Any] = {}
def fake_prg(
cmd,
user=None,
env_overrides=None,
mimic_login=False,
pty=True,
check=True,
):
calls["prg"] = (cmd, user, env_overrides, mimic_login, pty, check)
return "output-from-prg"
monkeypatch.setattr(decman, "prg", fake_prg)
out = decman.sh(
"echo test",
user="bob",
env_overrides={"X": "1"},
mimic_login=True,
pty=False,
check=False,
)
assert out == "output-from-prg"
cmd, user, env_overrides, mimic_login, pty, check = calls["prg"]
assert cmd == ["/bin/sh", "-c", "echo test"]
assert user == "bob"
assert env_overrides == {"X": "1"}
assert mimic_login is True
assert pty is False
assert check is False
================================================
FILE: tests/test_decman_plugins.py
================================================
from decman.core.module import Module
from decman.plugins import run_methods_with_attribute
def mark(attr):
attr.__flag__ = True
return attr
def test_runs_marked_method_and_returns_value():
class M(Module):
@mark
def foo(self):
return 123
m = M("m")
assert run_methods_with_attribute(m, "__flag__") == [123]
def test_runs_marked_methods_and_returns_value():
class M(Module):
@mark
def foo(self):
return 123
@mark
def bar(self):
return 321
m = M("m")
assert run_methods_with_attribute(m, "__flag__") == [321, 123]
def test_returns_none_if_no_method_has_attribute():
class M(Module):
def foo(self):
return 1
m = M("m")
assert run_methods_with_attribute(m, "__flag__") == []