Initial commit
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
*.py[cod]
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Packages
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
eggs
|
||||
parts
|
||||
bin
|
||||
var
|
||||
sdist
|
||||
develop-eggs
|
||||
.installed.cfg
|
||||
lib
|
||||
lib64
|
||||
__pycache__
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.tox
|
||||
nosetests.xml
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
||||
# Mr Developer
|
||||
.mr.developer.cfg
|
||||
.project
|
||||
.pydevproject
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
164
README.md
Normal file
164
README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# locutus
|
||||
|
||||
*A simple, opinionated wrapper for the venerable **[BorgBackup](https://www.borgbackup.org/)**.*
|
||||
|
||||
## Features
|
||||
|
||||
* **Friendlier CLI:** No need to remember archive names or full repo paths.
|
||||
* **Index-based archive access:** Refer to backups by index (most recent is 0), or by name.
|
||||
* **Scriptable:** All commands are suitable for cron or other automation.
|
||||
* **Transparent:** Directly uses Borg; never hides or obfuscates underlying repo. Backups can be fully managed by borg directly, made easier by sourcing locutus.rc in your shell.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Requires Python 3.11+, [BorgBackup](https://www.borgbackup.org/) (CLI), and a UNIX-like OS.
|
||||
|
||||
### From source
|
||||
|
||||
Clone the repo and install with [uv](https://github.com/astral-sh/uv) or pip:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/figtree-dev/locutus.git
|
||||
cd locutus
|
||||
uv tool install .
|
||||
```
|
||||
|
||||
For development/testing:
|
||||
|
||||
```sh
|
||||
uv venv
|
||||
uv source .venv/bin/activate
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
(See `pyproject.toml` for dev dependencies.)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. **Setup your backup environment**
|
||||
|
||||
```sh
|
||||
locutus init
|
||||
```
|
||||
|
||||
* Prompts for your repo and passphrase, creates the rc file.
|
||||
* Edit `~/.config/locutus/locutus.toml` to set your include/exclude/prune rules.
|
||||
|
||||
### 2. **Create a backup**
|
||||
|
||||
```sh
|
||||
locutus backup
|
||||
```
|
||||
|
||||
### 3. **List archives**
|
||||
|
||||
```sh
|
||||
locutus list
|
||||
```
|
||||
|
||||
* Archives are in chronological order with reverse indexing, such as `1: ...`, `0: ...`.
|
||||
|
||||
### 4. **Mount an archive or the entire repo**
|
||||
|
||||
```sh
|
||||
locutus mount 0 ~/mnt/backup # mount most recent archive
|
||||
locutus mount ~/mnt/backup # mount all archives
|
||||
```
|
||||
|
||||
### 5. **Restore files from an archive**
|
||||
|
||||
```sh
|
||||
locutus restore 0 ~/restore-dir
|
||||
```
|
||||
|
||||
### 6. **Unmount a mountpoint**
|
||||
|
||||
```sh
|
||||
locutus umount ~/mnt/backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
* Main config: `~/.config/locutus/locutus.toml` (edit by hand, see sample below)
|
||||
* Profile/RC: `~/.config/locutus/locutus.rc` (holds BORG\_REPO and BORG\_PASSPHRASE)
|
||||
|
||||
**Sample **`locutus.toml`**:**
|
||||
|
||||
```toml
|
||||
[includes]
|
||||
paths = [
|
||||
"/etc",
|
||||
"/home",
|
||||
"/var/www"
|
||||
]
|
||||
|
||||
[excludes]
|
||||
paths = [
|
||||
"*.cache",
|
||||
"/tmp",
|
||||
"/home/*/Downloads"
|
||||
]
|
||||
|
||||
[prune]
|
||||
keep_last = 7
|
||||
keep_daily = 7
|
||||
keep_weekly = 4
|
||||
keep_monthly = 6
|
||||
|
||||
[compact]
|
||||
enabled = true
|
||||
```
|
||||
|
||||
**Sample **`locutus.rc`**:**
|
||||
|
||||
```sh
|
||||
export BORG_REPO="user@host:/path/to/repo"
|
||||
export BORG_PASSPHRASE="your-strong-password"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
* Source code: [`src/locutus/`](src/locutus/)
|
||||
* Tests: [`test/`](test/)
|
||||
* Run tests:
|
||||
|
||||
```sh
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv sync --extra dev
|
||||
pytest
|
||||
```
|
||||
* All CLI entry points are in `main.py` with subcommands for each workflow.
|
||||
|
||||
---
|
||||
|
||||
## Limitations / Roadmap
|
||||
|
||||
* Linux/UNIX only (uses Borg and fusermount).
|
||||
* No built-in scheduling (use cron/systemd).
|
||||
* One config/profile at a time.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
GPLv3 (see `LICENSE`)
|
||||
|
||||
---
|
||||
|
||||
## Author
|
||||
|
||||
Alexander Wainwright ([code@figtree.dev](mailto:code@figtree.dev))
|
||||
|
||||
---
|
||||
|
||||
**Pull requests welcome.**
|
||||
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1750506804,
|
||||
"narHash": "sha256-VLFNc4egNjovYVxDGyBYTrvVCgDYgENp5bVi9fPTDYc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "4206c4cb56751df534751b058295ea61357bbbaa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
42
flake.nix
Normal file
42
flake.nix
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
description = "Locutus - A simple Borg wrapper";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
python = pkgs.python313; # or python312 if you prefer
|
||||
pythonPackages = python.pkgs;
|
||||
locutus = pythonPackages.buildPythonApplication {
|
||||
pname = "locutus";
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
format = "pyproject";
|
||||
nativeBuildInputs = with pkgs; [ makeWrapper python.pkgs.setuptools ];
|
||||
propagatedBuildInputs = with pythonPackages; [
|
||||
];
|
||||
# Install the CLI entrypoint
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/locutus --prefix PATH : ${pkgs.borgbackup}/bin
|
||||
'';
|
||||
};
|
||||
in {
|
||||
packages.default = locutus;
|
||||
apps.default = flake-utils.lib.mkApp {
|
||||
drv = locutus;
|
||||
};
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = [
|
||||
python
|
||||
pkgs.borgbackup
|
||||
locutus
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
5
lint.sh
Executable file
5
lint.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
uvx ruff check src
|
||||
uvx ruff format --diff src
|
||||
uvx mypy src
|
||||
56
locutus.toml
Normal file
56
locutus.toml
Normal file
@@ -0,0 +1,56 @@
|
||||
[includes]
|
||||
paths = [
|
||||
"/etc",
|
||||
"/root",
|
||||
"/srv",
|
||||
"/usr/local/etc",
|
||||
"/usr/local/src",
|
||||
"/usr/share/nginx",
|
||||
"/var/spool",
|
||||
"/var/www",
|
||||
"/home"
|
||||
]
|
||||
|
||||
[excludes]
|
||||
paths = [
|
||||
"*.bak",
|
||||
"*/.cache",
|
||||
"*.config/*Cache/",
|
||||
"*.config/*cache/",
|
||||
"*.config/Signal",
|
||||
"*.config/discord",
|
||||
"*.config/microsoft-edge",
|
||||
"*/Cache*",
|
||||
"*/mnt",
|
||||
"/home/*/.cache",
|
||||
"/home/*/.cargo",
|
||||
"/home/*/.local/*/Trash",
|
||||
"/home/*/.local/lib",
|
||||
"/home/*/.local/pipx",
|
||||
"/home/*/.npm",
|
||||
"/home/*/CMakeFiles",
|
||||
"/home/*/Downloads",
|
||||
"/home/*/downloads",
|
||||
"/home/*/nextcloud",
|
||||
"/home/*/snap",
|
||||
"/home/*/software",
|
||||
"/home/*/venv",
|
||||
"/home/*/workspace/*.obj",
|
||||
"/home/*/workspace/*.obj.d",
|
||||
"/home/*/workspace/_*",
|
||||
"/home/*/.rustup",
|
||||
"/dev",
|
||||
"/proc",
|
||||
"/sys",
|
||||
"/tmp",
|
||||
"/run"
|
||||
]
|
||||
|
||||
[prune]
|
||||
keep_last = 7
|
||||
keep_daily = 7
|
||||
keep_weekly = 4
|
||||
keep_monthly = 6
|
||||
|
||||
[compact]
|
||||
enabled = true
|
||||
59
pyproject.toml
Normal file
59
pyproject.toml
Normal file
@@ -0,0 +1,59 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=40.9.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "locutus"
|
||||
version = "0.1.0"
|
||||
description = "A simple borg wrapper"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
]
|
||||
authors = [
|
||||
{name = "Alexander Wainwright", email = "code@figtree.dev"},
|
||||
]
|
||||
maintainers = [
|
||||
{name = "Alexander Wainwright", email = "code@figtree.dev"},
|
||||
]
|
||||
|
||||
readme = "README.md"
|
||||
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[project.scripts]
|
||||
locutus = "locutus.main:main"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 80
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I", # import sort
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"SIM", # flake8-simplify
|
||||
]
|
||||
ignore = [
|
||||
"E101"
|
||||
]
|
||||
|
||||
[tool.ruff.format]
|
||||
indent-style = "tab"
|
||||
quote-style = "single"
|
||||
line-ending = "lf"
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
ignore_missing_imports = true
|
||||
strict = true
|
||||
7
src/locutus/__init__.py
Normal file
7
src/locutus/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""locutus - A simple borg wrapper"""
|
||||
|
||||
from locutus.config import LocutusConfig
|
||||
|
||||
__version__ = '0.1.0'
|
||||
__author__ = 'Alexander Wainwright <code@figtree.dev>'
|
||||
__all__: list[str] = ['LocutusConfig']
|
||||
173
src/locutus/backup.py
Normal file
173
src/locutus/backup.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from locutus.config import LocutusConfig
|
||||
|
||||
|
||||
def run_backup(args: argparse.Namespace) -> int:
|
||||
"""Orchestrates the backup cycle: create, prune, compact.
|
||||
|
||||
Args:
|
||||
args: Command-line arguments (should include --config, --profile,
|
||||
--dry-run).
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error.
|
||||
"""
|
||||
try:
|
||||
# 1. Load config and rc/env
|
||||
cfg = LocutusConfig(args.config, args.profile)
|
||||
|
||||
# 2. Run backup (borg create)
|
||||
ok = run_borg_create(cfg, args.dry_run)
|
||||
if not ok:
|
||||
print('Backup failed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Run prune
|
||||
ok = run_borg_prune(cfg, args.dry_run)
|
||||
if not ok:
|
||||
print('Prune failed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 4. Run compact (if enabled)
|
||||
if cfg.compact:
|
||||
ok = run_borg_compact(cfg, args.dry_run)
|
||||
if not ok:
|
||||
print('Compact failed.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print('Backup cycle completed successfully.')
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f'Backup failed: {e}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def run_borg_create(cfg: LocutusConfig, dry_run: bool) -> bool:
|
||||
"""Runs borg create with configured includes and excludes.
|
||||
|
||||
Args:
|
||||
cfg: The loaded LocutusConfig object.
|
||||
dry_run: If True, pass --dry-run to borg create.
|
||||
|
||||
Returns:
|
||||
True on success, False if borg returns nonzero.
|
||||
"""
|
||||
# Build archive name: auto-TIMESTAMP
|
||||
from datetime import datetime
|
||||
|
||||
archive_name = 'auto-' + datetime.now().strftime('%Y-%m-%dT%H%M%S')
|
||||
repo = cfg.get_repo()
|
||||
if not repo:
|
||||
print('No BORG_REPO configured.', file=sys.stderr)
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
'borg',
|
||||
'create',
|
||||
'--stats',
|
||||
]
|
||||
if dry_run:
|
||||
cmd.append('--dry-run')
|
||||
|
||||
# Excludes
|
||||
for pattern in cfg.excludes:
|
||||
cmd += ['--exclude', pattern]
|
||||
|
||||
# Archive: repo::archive
|
||||
cmd.append(f'{repo}::{archive_name}')
|
||||
|
||||
# Includes
|
||||
cmd += cfg.includes
|
||||
|
||||
# Prepare env for subprocess
|
||||
env = {**os.environ, **cfg.env}
|
||||
try:
|
||||
result = subprocess.run(cmd, env=env, check=True)
|
||||
return result.returncode == 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg create failed: {e}', file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def run_borg_prune(cfg: LocutusConfig, dry_run: bool) -> bool:
|
||||
"""Runs borg prune with the configured retention policy.
|
||||
|
||||
Args:
|
||||
cfg: The loaded LocutusConfig object.
|
||||
dry_run: If True, passes --dry-run to borg prune.
|
||||
|
||||
Returns:
|
||||
True on success, False if borg returns nonzero or an error.
|
||||
"""
|
||||
repo = cfg.get_repo()
|
||||
if not repo:
|
||||
print('No BORG_REPO configured for prune.', file=sys.stderr)
|
||||
return False
|
||||
|
||||
cmd = ['borg', 'prune', repo, '--list']
|
||||
if dry_run:
|
||||
cmd.append('--dry-run')
|
||||
|
||||
prune = cfg.prune
|
||||
# Map of config keys to borg prune args
|
||||
keep_args = {
|
||||
'keep_last': '--keep-last',
|
||||
'keep_daily': '--keep-daily',
|
||||
'keep_weekly': '--keep-weekly',
|
||||
'keep_monthly': '--keep-monthly',
|
||||
'keep_yearly': '--keep-yearly',
|
||||
}
|
||||
for key, borg_arg in keep_args.items():
|
||||
val = prune.get(key)
|
||||
if val is not None:
|
||||
cmd += [borg_arg, str(val)]
|
||||
|
||||
env = {**os.environ, **cfg.env}
|
||||
try:
|
||||
result = subprocess.run(cmd, env=env, check=True)
|
||||
return result.returncode == 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg prune failed: {e}', file=sys.stderr)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'borg prune failed (unexpected): {e}', file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def run_borg_compact(cfg: LocutusConfig, dry_run: bool) -> bool:
|
||||
"""Runs borg compact
|
||||
|
||||
Args:
|
||||
cfg: The loaded LocutusConfig object.
|
||||
dry_run: If True, prints what would be done but does not actually run
|
||||
compact.
|
||||
|
||||
Returns:
|
||||
True on success, False if borg returns nonzero or an error.
|
||||
"""
|
||||
repo = cfg.get_repo()
|
||||
if not repo:
|
||||
print('No BORG_REPO configured for compact.', file=sys.stderr)
|
||||
return False
|
||||
|
||||
if dry_run:
|
||||
print(f'(dry-run) Would run: borg compact {repo}')
|
||||
return True
|
||||
|
||||
cmd = ['borg', 'compact', repo, '--verbose']
|
||||
|
||||
env = {**os.environ, **cfg.env}
|
||||
try:
|
||||
result = subprocess.run(cmd, env=env, check=True)
|
||||
return result.returncode == 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg compact failed: {e}', file=sys.stderr)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f'borg compact failed (unexpected): {e}', file=sys.stderr)
|
||||
return False
|
||||
71
src/locutus/config.py
Normal file
71
src/locutus/config.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LocutusConfig:
|
||||
"""Loads and provides access to locutus configuration and profile"""
|
||||
|
||||
def __init__(self, toml_path: str, rc_path: str) -> None:
|
||||
self.toml_path: str = toml_path
|
||||
self.rc_path: str = rc_path
|
||||
self.toml: dict[str, Any] = {}
|
||||
self.includes: list[str] = []
|
||||
self.excludes: list[str] = []
|
||||
self.prune: dict[str, Any] = {}
|
||||
self.compact: bool = False
|
||||
self.env: dict[str, str] = {}
|
||||
|
||||
self.load_toml()
|
||||
self.load_rc()
|
||||
|
||||
def load_toml(self) -> None:
|
||||
"""Loads and parses the TOML configuration file
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the TOML file does not exist.
|
||||
tomllib.TOMLDecodeError: If the TOML file is malformed.
|
||||
"""
|
||||
with open(self.toml_path, 'rb') as f:
|
||||
self.toml = tomllib.load(f)
|
||||
self.includes = self.toml.get('includes', {}).get('paths', [])
|
||||
self.excludes = self.toml.get('excludes', {}).get('paths', [])
|
||||
self.prune = self.toml.get('prune', {})
|
||||
self.compact = self.toml.get('compact', {}).get('enabled', False)
|
||||
|
||||
def load_rc(self) -> None:
|
||||
"""Loads environment variables from the rc file
|
||||
|
||||
Parses lines of the form 'export VAR=value' and stores them in self.env.
|
||||
"""
|
||||
self.env = {}
|
||||
if not os.path.isfile(self.rc_path):
|
||||
return
|
||||
with open(self.rc_path) as f:
|
||||
for line in f:
|
||||
m = re.match(
|
||||
r'export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)', line.strip()
|
||||
)
|
||||
if m:
|
||||
key, val = (
|
||||
m.group(1),
|
||||
m.group(2).strip().strip('"').strip("'"),
|
||||
)
|
||||
self.env[key] = val
|
||||
|
||||
def get_repo(self) -> str | None:
|
||||
"""Returns the repository path (BORG_REPO) from the rc file
|
||||
|
||||
Returns:
|
||||
The BORG_REPO string if present, otherwise None.
|
||||
"""
|
||||
return self.env.get('BORG_REPO')
|
||||
|
||||
def get_passphrase(self) -> str | None:
|
||||
"""Returns the passphrase (BORG_PASSPHRASE) from the rc file
|
||||
|
||||
Returns:
|
||||
The BORG_PASSPHRASE string if present, otherwise None.
|
||||
"""
|
||||
return self.env.get('BORG_PASSPHRASE')
|
||||
46
src/locutus/info.py
Normal file
46
src/locutus/info.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from locutus.config import LocutusConfig
|
||||
|
||||
|
||||
def run_info(args: argparse.Namespace) -> int:
|
||||
"""Show current config/profile summary and repository info."""
|
||||
cfg = LocutusConfig(args.config, args.profile)
|
||||
print(f'Config: {cfg.toml_path}')
|
||||
print(f'Profile: {cfg.rc_path}')
|
||||
|
||||
repo = cfg.get_repo()
|
||||
if repo:
|
||||
print(f'Repo: {repo}')
|
||||
else:
|
||||
print('Repo: (not set)')
|
||||
passphrase = cfg.get_passphrase()
|
||||
if passphrase:
|
||||
print('Passphrase: set')
|
||||
else:
|
||||
print('Passphrase: (not set)')
|
||||
|
||||
print('\nIncludes:')
|
||||
for inc in cfg.includes:
|
||||
print(f' {inc}')
|
||||
|
||||
print('\nExcludes:')
|
||||
for exc in cfg.excludes:
|
||||
print(f' {exc}')
|
||||
|
||||
if cfg.prune:
|
||||
prune_str = ', '.join(f'{k}={v}' for k, v in cfg.prune.items())
|
||||
print(f'\nPrune: {prune_str}')
|
||||
|
||||
print('\n[borg info output below]\n')
|
||||
if repo:
|
||||
env = {**os.environ, **cfg.env}
|
||||
try:
|
||||
subprocess.run(['borg', 'info', repo], env=env, check=True)
|
||||
except Exception as e:
|
||||
print(f'borg info failed: {e}', file=sys.stderr)
|
||||
|
||||
return 0
|
||||
92
src/locutus/init.py
Normal file
92
src/locutus/init.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_init(args: argparse.Namespace) -> int:
|
||||
"""Interactively set up a new Borg repository and create an rc file.
|
||||
|
||||
Prompts the user for a repository path and optional encryption passphrase.
|
||||
Creates the specified profile rc file with BORG_REPO (and, if provided,
|
||||
BORG_PASSPHRASE) environment variables. Then runs `borg init` for the repo.
|
||||
|
||||
Args:
|
||||
args: The argparse.Namespace containing command-line arguments.
|
||||
- args.profile: Path to the rc file to create.
|
||||
|
||||
Returns:
|
||||
int: 0 on success, 1 on error or user abort.
|
||||
|
||||
Raises:
|
||||
KeyboardInterrupt: If the user presses Ctrl+C during input.
|
||||
Other exceptions are caught and logged; function returns 1.
|
||||
"""
|
||||
try:
|
||||
repo = input(
|
||||
'Borg repository (e.g., user@host:/path/to/repo): '
|
||||
).strip()
|
||||
if not repo:
|
||||
print('No repository entered. Aborting.')
|
||||
return 1
|
||||
|
||||
passphrase = ''
|
||||
while True:
|
||||
pw = getpass.getpass(
|
||||
'Encryption passphrase (leave empty for no encryption): '
|
||||
)
|
||||
if not pw:
|
||||
break
|
||||
confirm = getpass.getpass('Confirm passphrase: ')
|
||||
if pw == confirm:
|
||||
passphrase = pw
|
||||
break
|
||||
print('Passphrases do not match. Please try again.')
|
||||
|
||||
rc_path = args.profile
|
||||
try:
|
||||
os.makedirs(os.path.dirname(rc_path), exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f'Failed to create config directory: {e}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
with open(rc_path, 'w') as f:
|
||||
f.write(f'export BORG_REPO="{repo}"\n')
|
||||
if passphrase:
|
||||
f.write(f'export BORG_PASSPHRASE="{passphrase}"\n')
|
||||
print(f'Wrote profile (rc) to {rc_path}')
|
||||
except Exception as e:
|
||||
print(f'Failed to write rc file: {e}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# prepare env
|
||||
env = os.environ.copy()
|
||||
env['BORG_REPO'] = repo
|
||||
if passphrase:
|
||||
env['BORG_PASSPHRASE'] = passphrase
|
||||
enc = 'repokey'
|
||||
else:
|
||||
enc = 'none'
|
||||
|
||||
print(f'Running: borg init --encryption={enc} {repo}')
|
||||
try:
|
||||
subprocess.run(
|
||||
['borg', 'init', f'--encryption={enc}', repo],
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
print('Repository initialized.')
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg init failed: {e}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print('\nAborted by user.')
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f'Unexpected error: {e}', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
81
src/locutus/list.py
Normal file
81
src/locutus/list.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from locutus.config import LocutusConfig
|
||||
|
||||
|
||||
def run_list(args: argparse.Namespace) -> int:
|
||||
"""Lists archives or files in a specific archive.
|
||||
|
||||
Args:
|
||||
args: argparse.Namespace with --config, --profile, and optional 'target'
|
||||
(index or name).
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error
|
||||
"""
|
||||
try:
|
||||
cfg = LocutusConfig(args.config, args.profile)
|
||||
repo = cfg.get_repo()
|
||||
if not repo:
|
||||
print('No BORG_REPO configured.', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
env = {**os.environ, **cfg.env}
|
||||
|
||||
# If user gave an archive index or name
|
||||
target = getattr(args, 'target', None)
|
||||
if target is not None:
|
||||
# If it's an int, treat as index (get list first)
|
||||
try:
|
||||
index = int(target)
|
||||
# get archive names
|
||||
cmd = ['borg', 'list', '--short', repo]
|
||||
result = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True, check=True
|
||||
)
|
||||
archives = result.stdout.strip().splitlines()[::-1]
|
||||
if not archives:
|
||||
print('No archives found.')
|
||||
return 1
|
||||
if not (0 <= index < len(archives)):
|
||||
print(
|
||||
f'Archive index out of range (0..{len(archives) - 1}).',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
archive_name = archives[index]
|
||||
except ValueError:
|
||||
# Not an int; treat as archive name
|
||||
archive_name = target
|
||||
|
||||
# Now list files in the selected archive
|
||||
cmd = ['borg', 'list', f'{repo}::{archive_name}']
|
||||
result = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True, check=True
|
||||
)
|
||||
print(result.stdout)
|
||||
return 0
|
||||
|
||||
# No target: list archives as before
|
||||
cmd = ['borg', 'list', '--short', repo]
|
||||
result = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True, check=True
|
||||
)
|
||||
archives = result.stdout.strip().splitlines()[::-1]
|
||||
if not archives:
|
||||
print('No archives found.')
|
||||
return 0
|
||||
|
||||
for idx, name in reversed(list(enumerate(archives))):
|
||||
print(f'{idx:3d}: {name}')
|
||||
return 0
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg list failed: {e}', file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f'borg list failed (unexpected): {e}', file=sys.stderr)
|
||||
return 1
|
||||
122
src/locutus/main.py
Normal file
122
src/locutus/main.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
DEFAULT_CONFIG = os.path.expanduser('~/.config/locutus/locutus.toml')
|
||||
DEFAULT_PROFILE = os.path.expanduser('~/.config/locutus/locutus.rc')
|
||||
|
||||
|
||||
def parse_args() -> tuple[argparse.Namespace, argparse.ArgumentParser]:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog='locutus', description='A simple borg wrapper'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--config',
|
||||
help=(
|
||||
'Path to configuration file (default: '
|
||||
'~/.config/locutus/locutus.toml)'
|
||||
),
|
||||
default=DEFAULT_CONFIG,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--profile',
|
||||
help='Path to profile file (default: ~/.config/locutus/locutus.rc)',
|
||||
default=DEFAULT_PROFILE,
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', required=True)
|
||||
|
||||
# init
|
||||
_init = subparsers.add_parser('init', help='Configure new backup server')
|
||||
|
||||
# backup
|
||||
backup = subparsers.add_parser(
|
||||
'backup', help='Create new backup and perform prune and compact'
|
||||
)
|
||||
backup.add_argument(
|
||||
'--dry-run', action='store_true', help='Do not create the backup'
|
||||
)
|
||||
|
||||
# list
|
||||
list_parser = subparsers.add_parser('list', help='List backups')
|
||||
list_parser.add_argument(
|
||||
'target', nargs='?', help='Archive index (int) or name (str)'
|
||||
)
|
||||
|
||||
# mount
|
||||
mount_parser = subparsers.add_parser(
|
||||
'mount', help='Mount a backup or backups'
|
||||
)
|
||||
mount_parser.add_argument(
|
||||
'archive',
|
||||
nargs='?',
|
||||
help='Archive index (int) or name (str), omit to mount all',
|
||||
)
|
||||
mount_parser.add_argument(
|
||||
'mountpoint', help='Directory to mount the archive'
|
||||
)
|
||||
|
||||
# umount
|
||||
umount_parser = subparsers.add_parser('umount', help='Unmount a mountpoint')
|
||||
umount_parser.add_argument('mountpoint', help='Mountpoint to unmount')
|
||||
|
||||
# restore
|
||||
restore_parser = subparsers.add_parser(
|
||||
'restore', help='Restore files from a backup archive'
|
||||
)
|
||||
restore_parser.add_argument(
|
||||
'archive', help='Archive index (int) or name (str)'
|
||||
)
|
||||
restore_parser.add_argument('targetdir', help='Directory to restore into')
|
||||
|
||||
# info
|
||||
_info_parser = subparsers.add_parser(
|
||||
'info', help='Show current config and repository info'
|
||||
)
|
||||
|
||||
return parser.parse_args(), parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args, parser = parse_args()
|
||||
|
||||
match args.command:
|
||||
case 'init':
|
||||
from .init import run_init
|
||||
|
||||
return run_init(args)
|
||||
case 'backup':
|
||||
from .backup import run_backup
|
||||
|
||||
return run_backup(args)
|
||||
case 'list':
|
||||
from .list import run_list
|
||||
|
||||
return run_list(args)
|
||||
case 'mount':
|
||||
from .mount import run_mount
|
||||
|
||||
return run_mount(args)
|
||||
case 'umount' | 'unmount':
|
||||
from .umount import run_umount
|
||||
|
||||
return run_umount(args)
|
||||
case 'restore':
|
||||
from .restore import run_restore
|
||||
|
||||
return run_restore(args)
|
||||
case 'info':
|
||||
from .info import run_info
|
||||
|
||||
return run_info(args)
|
||||
case _:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
86
src/locutus/mount.py
Normal file
86
src/locutus/mount.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from locutus.config import LocutusConfig
|
||||
|
||||
|
||||
def run_mount(args: argparse.Namespace) -> int:
|
||||
"""Mount a backup archive (by index or name) to the specified directory
|
||||
|
||||
Args:
|
||||
args: argparse.Namespace with --config, --profile, 'archive'
|
||||
(index or name), 'mountpoint' (directory).
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error
|
||||
"""
|
||||
if not hasattr(args, 'mountpoint'):
|
||||
print('Usage: locutus mount [index|name] <mountpoint>', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
cfg = LocutusConfig(args.config, args.profile)
|
||||
repo = cfg.get_repo()
|
||||
if not repo:
|
||||
print('No BORG_REPO configured.', file=sys.stderr)
|
||||
return 1
|
||||
env = {**os.environ, **cfg.env}
|
||||
|
||||
mountpoint = args.mountpoint
|
||||
if not os.path.isdir(mountpoint):
|
||||
print(
|
||||
f"Mountpoint '{mountpoint}' does not exist or is not a directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Determine archive name (by index or direct)
|
||||
target = getattr(args, 'archive', None)
|
||||
archive_name = None
|
||||
if target is None:
|
||||
# Mount all archives
|
||||
archive_spec = repo
|
||||
else:
|
||||
try:
|
||||
index = int(target)
|
||||
# list all archives, reverse order
|
||||
cmd = ['borg', 'list', '--short', repo]
|
||||
result = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True, check=True
|
||||
)
|
||||
archives = result.stdout.strip().splitlines()[::-1]
|
||||
if not archives:
|
||||
print('No archives found.', file=sys.stderr)
|
||||
return 1
|
||||
if not (0 <= index < len(archives)):
|
||||
print(
|
||||
f'Archive index out of range (0..{len(archives) - 1}).',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
archive_name = archives[index]
|
||||
except ValueError:
|
||||
archive_name = target
|
||||
except Exception as e:
|
||||
print(
|
||||
f'borg mount failed during archive lookup: {e}', file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
archive_spec = f'{repo}::{archive_name}'
|
||||
|
||||
# Run borg mount
|
||||
cmd = ['borg', 'mount', archive_spec, mountpoint]
|
||||
try:
|
||||
subprocess.run(cmd, env=env, check=True)
|
||||
if archive_name:
|
||||
print(f"Archive '{archive_name}' mounted on {mountpoint}")
|
||||
else:
|
||||
print(f'Archives mounted on {mountpoint}')
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg mount failed: {e}', file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f'borg mount failed (unexpected): {e}', file=sys.stderr)
|
||||
return 1
|
||||
81
src/locutus/restore.py
Normal file
81
src/locutus/restore.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from locutus.config import LocutusConfig
|
||||
|
||||
|
||||
def run_restore(args: argparse.Namespace) -> int:
|
||||
"""Restore (extract) a backup archive into the specified directory.
|
||||
|
||||
Args:
|
||||
args: argparse.Namespace with --config, --profile, 'archive' (index or
|
||||
name), 'targetdir' (directory).
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error
|
||||
"""
|
||||
if not hasattr(args, 'archive') or not hasattr(args, 'targetdir'):
|
||||
print(
|
||||
'Usage: locutus restore [index|name] <targetdir>', file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
cfg = LocutusConfig(args.config, args.profile)
|
||||
repo = cfg.get_repo()
|
||||
if not repo:
|
||||
print('No BORG_REPO configured.', file=sys.stderr)
|
||||
return 1
|
||||
env = {**os.environ, **cfg.env}
|
||||
|
||||
# Determine archive name (by index or direct)
|
||||
target = args.archive
|
||||
archive_name = None
|
||||
try:
|
||||
index = int(target)
|
||||
cmd = ['borg', 'list', '--short', repo]
|
||||
result = subprocess.run(
|
||||
cmd, env=env, capture_output=True, text=True, check=True
|
||||
)
|
||||
archives = result.stdout.strip().splitlines()[::-1]
|
||||
if not archives:
|
||||
print('No archives found.', file=sys.stderr)
|
||||
return 1
|
||||
if not (0 <= index < len(archives)):
|
||||
print(
|
||||
f'Archive index out of range (0..{len(archives) - 1}).',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
archive_name = archives[index]
|
||||
except ValueError:
|
||||
archive_name = target
|
||||
except Exception as e:
|
||||
print(
|
||||
f'borg restore failed during archive lookup: {e}', file=sys.stderr
|
||||
)
|
||||
return 1
|
||||
|
||||
# Check target dir
|
||||
targetdir = args.targetdir
|
||||
if not os.path.isdir(targetdir):
|
||||
print(
|
||||
f"Target directory '{targetdir}' does not exist or is not a "
|
||||
'directory.',
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Run borg extract
|
||||
cmd = ['borg', 'extract', f'{repo}::{archive_name}']
|
||||
try:
|
||||
subprocess.run(cmd, env=env, check=True, cwd=targetdir)
|
||||
print(f"Archive '{archive_name}' restored to {targetdir}")
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'borg extract failed: {e}', file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f'borg extract failed (unexpected): {e}', file=sys.stderr)
|
||||
return 1
|
||||
46
src/locutus/umount.py
Normal file
46
src/locutus/umount.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def run_umount(args: argparse.Namespace) -> int:
|
||||
"""Unmount a mountpoint (using borg umount or fusermount -u).
|
||||
|
||||
Args:
|
||||
args: argparse.Namespace with 'mountpoint' (str)
|
||||
|
||||
Returns:
|
||||
0 on success, 1 on error
|
||||
"""
|
||||
if not hasattr(args, 'mountpoint'):
|
||||
print('Usage: locutus umount <mountpoint>', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
mountpoint = args.mountpoint
|
||||
if not os.path.isdir(mountpoint):
|
||||
print(
|
||||
f"Mountpoint '{mountpoint}' does not exist or is not a directory.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Try borg umount first
|
||||
cmd = ['borg', 'umount', mountpoint]
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f'Unmounted {mountpoint}')
|
||||
return 0
|
||||
except subprocess.CalledProcessError:
|
||||
# Fallback to fusermount -u
|
||||
cmd = ['fusermount', '-u', mountpoint]
|
||||
try:
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f'Unmounted {mountpoint}')
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f'Failed to unmount {mountpoint}: {e}', file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f'Failed to unmount {mountpoint}: {e}', file=sys.stderr)
|
||||
return 2
|
||||
300
test/test_backup.py
Normal file
300
test/test_backup.py
Normal file
@@ -0,0 +1,300 @@
|
||||
import argparse
|
||||
import pytest
|
||||
import subprocess
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from locutus.backup import (
|
||||
run_backup,
|
||||
run_borg_create,
|
||||
run_borg_prune,
|
||||
run_borg_compact,
|
||||
)
|
||||
|
||||
|
||||
def make_args(config, profile, dry_run=False):
|
||||
args = argparse.Namespace()
|
||||
args.config = config
|
||||
args.profile = profile
|
||||
args.dry_run = dry_run
|
||||
return args
|
||||
|
||||
|
||||
class DummyCfg:
|
||||
def __init__(self, includes=[], excludes=[], prune=True, repo='', env=''):
|
||||
self.includes = includes
|
||||
self.excludes = excludes
|
||||
self.prune = prune
|
||||
self.env = env
|
||||
self._repo = repo
|
||||
|
||||
def get_repo(self):
|
||||
return self._repo
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_cfg(tmp_path):
|
||||
return DummyCfg(
|
||||
includes=['/home/alex/docs', '/home/alex/pics'],
|
||||
excludes=['*.cache', '/tmp'],
|
||||
repo='user@host:/repo',
|
||||
env={'BORG_PASSPHRASE': 'hunter2'},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_compact_cfg():
|
||||
return DummyCfg(
|
||||
repo='user@host:/repo',
|
||||
env={'BORG_PASSPHRASE': 'hunter2'},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch('locutus.backup.LocutusConfig')
|
||||
@mock.patch('locutus.backup.run_borg_create')
|
||||
@mock.patch('locutus.backup.run_borg_prune')
|
||||
@mock.patch('locutus.backup.run_borg_compact')
|
||||
def test_backup_success(mock_compact, mock_prune, mock_create, mock_config):
|
||||
mock_create.return_value = True
|
||||
mock_prune.return_value = True
|
||||
mock_compact.return_value = True
|
||||
# Simulate cfg.compact = True
|
||||
instance = mock_config.return_value
|
||||
instance.compact = True
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
assert run_backup(args) == 0
|
||||
assert mock_create.called
|
||||
assert mock_prune.called
|
||||
assert mock_compact.called
|
||||
|
||||
|
||||
@mock.patch('locutus.backup.LocutusConfig')
|
||||
@mock.patch('locutus.backup.run_borg_create')
|
||||
@mock.patch('locutus.backup.run_borg_prune')
|
||||
@mock.patch('locutus.backup.run_borg_compact')
|
||||
def test_backup_success_no_compact(
|
||||
mock_compact, mock_prune, mock_create, mock_config
|
||||
):
|
||||
mock_create.return_value = True
|
||||
mock_prune.return_value = True
|
||||
# Compact should not be called if cfg.compact is False
|
||||
instance = mock_config.return_value
|
||||
instance.compact = False
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
assert run_backup(args) == 0
|
||||
assert mock_create.called
|
||||
assert mock_prune.called
|
||||
assert not mock_compact.called
|
||||
|
||||
|
||||
@mock.patch('locutus.backup.LocutusConfig')
|
||||
@mock.patch('locutus.backup.run_borg_create')
|
||||
@mock.patch('locutus.backup.run_borg_prune')
|
||||
@mock.patch('locutus.backup.run_borg_compact')
|
||||
def test_backup_fails_on_create(
|
||||
mock_compact, mock_prune, mock_create, mock_config
|
||||
):
|
||||
mock_create.return_value = False
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
assert run_backup(args) == 1
|
||||
|
||||
|
||||
@mock.patch('locutus.backup.LocutusConfig')
|
||||
@mock.patch('locutus.backup.run_borg_create')
|
||||
@mock.patch('locutus.backup.run_borg_prune')
|
||||
@mock.patch('locutus.backup.run_borg_compact')
|
||||
def test_backup_fails_on_prune(
|
||||
mock_compact, mock_prune, mock_create, mock_config
|
||||
):
|
||||
mock_create.return_value = True
|
||||
mock_prune.return_value = False
|
||||
instance = mock_config.return_value
|
||||
instance.compact = True
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
assert run_backup(args) == 1
|
||||
|
||||
|
||||
@mock.patch('locutus.backup.LocutusConfig')
|
||||
@mock.patch('locutus.backup.run_borg_create')
|
||||
@mock.patch('locutus.backup.run_borg_prune')
|
||||
@mock.patch('locutus.backup.run_borg_compact')
|
||||
def test_backup_fails_on_compact(
|
||||
mock_compact, mock_prune, mock_create, mock_config
|
||||
):
|
||||
mock_create.return_value = True
|
||||
mock_prune.return_value = True
|
||||
mock_compact.return_value = False
|
||||
instance = mock_config.return_value
|
||||
instance.compact = True
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
assert run_backup(args) == 1
|
||||
|
||||
|
||||
@mock.patch(
|
||||
'locutus.backup.LocutusConfig', side_effect=Exception('config error')
|
||||
)
|
||||
def test_backup_config_exception(mock_config):
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
assert run_backup(args) == 1
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_borg_create_success(mock_run, dummy_cfg):
|
||||
mock_run.return_value.returncode = 0
|
||||
assert run_borg_create(dummy_cfg, dry_run=False) is True
|
||||
# Check command construction
|
||||
args = mock_run.call_args[0][0]
|
||||
assert args[0:2] == ['borg', 'create']
|
||||
assert '--dry-run' not in args
|
||||
assert '--exclude' in args
|
||||
assert any('/home/alex/docs' in a or '/home/alex/pics' in a for a in args)
|
||||
# Check env contains BORG_PASSPHRASE
|
||||
assert mock_run.call_args[1]['env']['BORG_PASSPHRASE'] == 'hunter2'
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_borg_create_success_dry_run(mock_run, dummy_cfg):
|
||||
mock_run.return_value.returncode = 0
|
||||
assert run_borg_create(dummy_cfg, dry_run=True) is True
|
||||
args = mock_run.call_args[0][0]
|
||||
assert '--dry-run' in args
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_borg_create_no_repo(mock_run, dummy_cfg):
|
||||
dummy_cfg._repo = None
|
||||
assert run_borg_create(dummy_cfg, dry_run=False) is False
|
||||
assert not mock_run.called
|
||||
|
||||
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=subprocess.CalledProcessError(1, ['borg', 'create']),
|
||||
)
|
||||
def test_borg_create_calledprocesserror(mock_run, dummy_cfg, capsys):
|
||||
assert run_borg_create(dummy_cfg, dry_run=False) is False
|
||||
out = capsys.readouterr().err
|
||||
assert 'borg create failed' in out
|
||||
|
||||
|
||||
@mock.patch(
|
||||
'subprocess.run', side_effect=mock.Mock(side_effect=OSError('fail'))
|
||||
)
|
||||
def test_borg_create_raises_other_exception(mock_run, dummy_cfg, capsys):
|
||||
# Simulate unexpected subprocess failure (should still be caught)
|
||||
with mock.patch('subprocess.CalledProcessError', OSError):
|
||||
assert run_borg_create(dummy_cfg, dry_run=False) is False
|
||||
out = capsys.readouterr().err
|
||||
assert 'borg create failed' in out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dummy_prune_cfg():
|
||||
return DummyCfg(
|
||||
repo='user@host:/repo',
|
||||
env={'BORG_PASSPHRASE': 'hunter2'},
|
||||
prune={'keep_last': 3, 'keep_daily': 7},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_prune_success(mock_run, dummy_prune_cfg):
|
||||
mock_run.return_value.returncode = 0
|
||||
assert run_borg_prune(dummy_prune_cfg, dry_run=False) is True
|
||||
args = mock_run.call_args[0][0]
|
||||
assert 'borg' in args
|
||||
assert 'prune' in args
|
||||
assert '--keep-last' in args
|
||||
assert '3' in args
|
||||
assert '--keep-daily' in args
|
||||
assert '7' in args
|
||||
assert '--dry-run' not in args
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_prune_success_dry_run(mock_run, dummy_prune_cfg):
|
||||
mock_run.return_value.returncode = 0
|
||||
assert run_borg_prune(dummy_prune_cfg, dry_run=True) is True
|
||||
args = mock_run.call_args[0][0]
|
||||
assert '--dry-run' in args
|
||||
|
||||
|
||||
def test_prune_no_repo():
|
||||
cfg = DummyCfg(repo=None, env={}, prune={})
|
||||
assert run_borg_prune(cfg, dry_run=False) is False
|
||||
|
||||
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail'))
|
||||
def test_prune_subprocess_exception(mock_run, dummy_prune_cfg, capsys):
|
||||
assert run_borg_prune(dummy_prune_cfg, dry_run=False) is False
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg prune failed' in err
|
||||
|
||||
|
||||
@mock.patch(
|
||||
'subprocess.run', side_effect=mock.Mock(side_effect=OSError('fail'))
|
||||
)
|
||||
def test_prune_raises_other_exception(mock_run, dummy_prune_cfg, capsys):
|
||||
with mock.patch('subprocess.CalledProcessError', OSError):
|
||||
assert run_borg_prune(dummy_prune_cfg, dry_run=False) is False
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg prune failed' in err
|
||||
|
||||
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=mock.Mock(
|
||||
side_effect=pytest.importorskip('subprocess').CalledProcessError(
|
||||
1, ['borg', 'prune']
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_prune_calledprocesserror(mock_run, dummy_prune_cfg, capsys):
|
||||
assert run_borg_prune(dummy_prune_cfg, dry_run=False) is False
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg prune failed' in err
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_compact_success(mock_run, dummy_compact_cfg):
|
||||
mock_run.return_value.returncode = 0
|
||||
assert run_borg_compact(dummy_compact_cfg, dry_run=False) is True
|
||||
args = mock_run.call_args[0][0]
|
||||
assert args[:2] == ['borg', 'compact']
|
||||
assert '--verbose' in args
|
||||
|
||||
|
||||
@mock.patch('subprocess.run')
|
||||
def test_compact_no_repo(mock_run):
|
||||
cfg = DummyCfg(repo=None, env={})
|
||||
assert run_borg_compact(cfg, dry_run=False) is False
|
||||
assert not mock_run.called
|
||||
|
||||
|
||||
def test_compact_dry_run(dummy_compact_cfg, capsys):
|
||||
assert run_borg_compact(dummy_compact_cfg, dry_run=True) is True
|
||||
out = capsys.readouterr().out
|
||||
assert '(dry-run)' in out
|
||||
|
||||
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail'))
|
||||
def test_compact_subprocess_exception(mock_run, dummy_compact_cfg, capsys):
|
||||
assert run_borg_compact(dummy_compact_cfg, dry_run=False) is False
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg compact failed' in err
|
||||
|
||||
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=mock.Mock(
|
||||
side_effect=pytest.importorskip('subprocess').CalledProcessError(
|
||||
1, ['borg', 'compact']
|
||||
)
|
||||
),
|
||||
)
|
||||
def test_compact_calledprocesserror(mock_run, dummy_compact_cfg, capsys):
|
||||
assert run_borg_compact(dummy_compact_cfg, dry_run=False) is False
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg compact failed' in err
|
||||
101
test/test_config.py
Normal file
101
test/test_config.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import pytest
|
||||
|
||||
from locutus import LocutusConfig
|
||||
|
||||
|
||||
def make_toml(path: str, text: str) -> None:
|
||||
with open(path, 'wb') as f:
|
||||
f.write(text.encode())
|
||||
|
||||
|
||||
def make_rc(path: str, text: str) -> None:
|
||||
with open(path, 'w') as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_config_files(tmp_path):
|
||||
# Make temp locutus.toml
|
||||
toml_content = """
|
||||
[includes]
|
||||
paths = ["/test/inc1", "/test/inc2"]
|
||||
|
||||
[excludes]
|
||||
paths = ["*.cache"]
|
||||
|
||||
[prune]
|
||||
keep_last = 2
|
||||
keep_daily = 3
|
||||
|
||||
[compact]
|
||||
enabled = true
|
||||
"""
|
||||
toml_path = tmp_path / 'locutus.toml'
|
||||
make_toml(str(toml_path), toml_content)
|
||||
|
||||
# Make temp locutus.rc
|
||||
rc_content = (
|
||||
'export BORG_REPO="/tmp/repo"\nexport BORG_PASSPHRASE="hunter2"\n'
|
||||
)
|
||||
rc_path = tmp_path / 'locutus.rc'
|
||||
make_rc(str(rc_path), rc_content)
|
||||
|
||||
return str(toml_path), str(rc_path)
|
||||
|
||||
|
||||
def test_config_loads_correctly(temp_config_files):
|
||||
toml_path, rc_path = temp_config_files
|
||||
cfg = LocutusConfig(toml_path, rc_path)
|
||||
|
||||
assert cfg.toml_path == toml_path
|
||||
assert cfg.rc_path == rc_path
|
||||
|
||||
assert cfg.includes == ['/test/inc1', '/test/inc2']
|
||||
assert cfg.excludes == ['*.cache']
|
||||
assert cfg.prune['keep_last'] == 2
|
||||
assert cfg.prune['keep_daily'] == 3
|
||||
assert cfg.compact is True
|
||||
|
||||
assert cfg.get_repo() == '/tmp/repo'
|
||||
assert cfg.get_passphrase() == 'hunter2'
|
||||
|
||||
|
||||
def test_missing_rc(tmp_path):
|
||||
# Valid toml, missing rc
|
||||
toml_content = """
|
||||
[includes]
|
||||
paths = ["/test/inc1"]
|
||||
"""
|
||||
toml_path = tmp_path / 'locutus.toml'
|
||||
make_toml(str(toml_path), toml_content)
|
||||
rc_path = tmp_path / 'missing.rc'
|
||||
|
||||
cfg = LocutusConfig(str(toml_path), str(rc_path))
|
||||
|
||||
assert cfg.includes == ['/test/inc1']
|
||||
assert cfg.get_repo() is None
|
||||
assert cfg.get_passphrase() is None
|
||||
|
||||
|
||||
def test_missing_toml(tmp_path):
|
||||
# Missing toml
|
||||
toml_path = tmp_path / 'missing.toml'
|
||||
rc_path = tmp_path / 'locutus.rc'
|
||||
make_rc(str(rc_path), 'export BORG_REPO="/tmp/repo"\n')
|
||||
with pytest.raises(FileNotFoundError):
|
||||
LocutusConfig(str(toml_path), str(rc_path))
|
||||
|
||||
|
||||
def test_partial_rc(tmp_path):
|
||||
# Valid toml, partial rc (no passphrase)
|
||||
toml_content = """
|
||||
[includes]
|
||||
paths = ["/home/test"]
|
||||
"""
|
||||
toml_path = tmp_path / 'locutus.toml'
|
||||
make_toml(str(toml_path), toml_content)
|
||||
rc_path = tmp_path / 'locutus.rc'
|
||||
make_rc(str(rc_path), 'export BORG_REPO="/somewhere/repo"\n')
|
||||
cfg = LocutusConfig(str(toml_path), str(rc_path))
|
||||
assert cfg.get_repo() == '/somewhere/repo'
|
||||
assert cfg.get_passphrase() is None
|
||||
100
test/test_info.py
Normal file
100
test/test_info.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import argparse
|
||||
from unittest import mock
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
from locutus.info import run_info
|
||||
|
||||
|
||||
def make_args(config, profile):
|
||||
args = argparse.Namespace()
|
||||
args.config = config
|
||||
args.profile = profile
|
||||
return args
|
||||
|
||||
|
||||
@mock.patch('locutus.info.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_info_basic_output(mock_run, mock_config, capsys):
|
||||
mock_config.return_value.toml_path = '/tmp/test.toml'
|
||||
mock_config.return_value.rc_path = '/tmp/test.rc'
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.get_passphrase.return_value = 'hunter2'
|
||||
mock_config.return_value.includes = ['/etc', '/home']
|
||||
mock_config.return_value.excludes = ['*.cache']
|
||||
mock_config.return_value.prune = {'keep_last': 3, 'keep_daily': 1}
|
||||
|
||||
args = make_args('/tmp/test.toml', '/tmp/test.rc')
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
run_info(args)
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert 'Config: /tmp/test.toml' in out
|
||||
assert 'Profile: /tmp/test.rc' in out
|
||||
assert 'Repo: /repo' in out
|
||||
assert 'Passphrase: set' in out
|
||||
assert 'Includes:' in out and '/etc' in out and '/home' in out
|
||||
assert 'Excludes:' in out and '*.cache' in out
|
||||
assert 'Prune: keep_last=3, keep_daily=1' in out
|
||||
assert '[borg info output below]' in out
|
||||
mock_run.assert_called_with(
|
||||
['borg', 'info', '/repo'], env=mock.ANY, check=True
|
||||
)
|
||||
|
||||
|
||||
@mock.patch('locutus.info.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_info_no_repo(mock_run, mock_config, capsys):
|
||||
mock_config.return_value.toml_path = '/tmp/test.toml'
|
||||
mock_config.return_value.rc_path = '/tmp/test.rc'
|
||||
mock_config.return_value.get_repo.return_value = None
|
||||
mock_config.return_value.get_passphrase.return_value = None
|
||||
mock_config.return_value.includes = []
|
||||
mock_config.return_value.excludes = []
|
||||
mock_config.return_value.prune = {}
|
||||
|
||||
args = make_args('/tmp/test.toml', '/tmp/test.rc')
|
||||
run_info(args)
|
||||
out = capsys.readouterr().out
|
||||
assert 'Repo: (not set)' in out
|
||||
assert 'Passphrase: (not set)' in out
|
||||
assert '[borg info output below]' in out
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch('locutus.info.LocutusConfig')
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=subprocess.CalledProcessError(1, ['borg', 'info']),
|
||||
)
|
||||
def test_info_borg_info_fails(mock_run, mock_config, capsys):
|
||||
mock_config.return_value.toml_path = '/tmp/test.toml'
|
||||
mock_config.return_value.rc_path = '/tmp/test.rc'
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.get_passphrase.return_value = 'hunter2'
|
||||
mock_config.return_value.includes = []
|
||||
mock_config.return_value.excludes = []
|
||||
mock_config.return_value.prune = {}
|
||||
|
||||
args = make_args('/tmp/test.toml', '/tmp/test.rc')
|
||||
run_info(args)
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg info failed:' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.info.LocutusConfig')
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail'))
|
||||
def test_info_borg_info_generic_exception(mock_run, mock_config, capsys):
|
||||
mock_config.return_value.toml_path = '/tmp/test.toml'
|
||||
mock_config.return_value.rc_path = '/tmp/test.rc'
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.get_passphrase.return_value = 'hunter2'
|
||||
mock_config.return_value.includes = []
|
||||
mock_config.return_value.excludes = []
|
||||
mock_config.return_value.prune = {}
|
||||
|
||||
args = make_args('/tmp/test.toml', '/tmp/test.rc')
|
||||
run_info(args)
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg info failed: fail' in err
|
||||
169
test/test_init.py
Normal file
169
test/test_init.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import argparse
|
||||
import subprocess
|
||||
from unittest import mock
|
||||
|
||||
from locutus.init import run_init
|
||||
|
||||
|
||||
def make_args(profile_path):
|
||||
args = argparse.Namespace()
|
||||
args.profile = profile_path
|
||||
return args
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_run_init_success_no_passphrase(
|
||||
mock_subproc, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
# Simulate user enters repo path and empty passphrase
|
||||
mock_input.return_value = '/test/repo'
|
||||
mock_getpass.return_value = ''
|
||||
mock_subproc.return_value = mock.Mock(returncode=0)
|
||||
|
||||
rc_path = tmp_path / 'test.rc'
|
||||
args = make_args(str(rc_path))
|
||||
result = run_init(args)
|
||||
|
||||
assert result == 0
|
||||
with open(rc_path) as f:
|
||||
content = f.read()
|
||||
assert 'export BORG_REPO="/test/repo"' in content
|
||||
assert 'BORG_PASSPHRASE' not in content
|
||||
|
||||
mock_subproc.assert_called_once()
|
||||
call_args = mock_subproc.call_args[0][0]
|
||||
assert call_args == ['borg', 'init', '--encryption=none', '/test/repo']
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_run_init_success_with_passphrase(
|
||||
mock_subproc, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
mock_input.return_value = '/secure/repo'
|
||||
# First call is passphrase, second is confirm, both match
|
||||
mock_getpass.side_effect = ['hunter2', 'hunter2']
|
||||
mock_subproc.return_value = mock.Mock(returncode=0)
|
||||
|
||||
rc_path = tmp_path / 'secure.rc'
|
||||
args = make_args(str(rc_path))
|
||||
result = run_init(args)
|
||||
|
||||
assert result == 0
|
||||
with open(rc_path) as f:
|
||||
content = f.read()
|
||||
assert 'export BORG_REPO="/secure/repo"' in content
|
||||
assert 'export BORG_PASSPHRASE="hunter2"' in content
|
||||
|
||||
mock_subproc.assert_called_once()
|
||||
call_args = mock_subproc.call_args[0][0]
|
||||
assert call_args == ['borg', 'init', '--encryption=repokey', '/secure/repo']
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
def test_run_init_empty_repo_aborts(mock_input, tmp_path):
|
||||
mock_input.return_value = ''
|
||||
args = make_args(str(tmp_path / 'fail.rc'))
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
def test_run_init_passphrase_mismatch(mock_getpass, mock_input, tmp_path):
|
||||
mock_input.return_value = '/fail/repo'
|
||||
mock_getpass.side_effect = [
|
||||
'pass1',
|
||||
'pass2',
|
||||
'',
|
||||
] # Third call: user gives up
|
||||
args = make_args(str(tmp_path / 'fail2.rc'))
|
||||
with mock.patch('subprocess.run'):
|
||||
result = run_init(args)
|
||||
assert result == 0 # Exits with no passphrase (encryption=none)
|
||||
with open(args.profile) as f:
|
||||
content = f.read()
|
||||
assert 'BORG_PASSPHRASE' not in content
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch('subprocess.run', side_effect=Exception('init failed'))
|
||||
def test_run_init_subprocess_failure(
|
||||
mock_subproc, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
mock_input.return_value = '/failinit/repo'
|
||||
mock_getpass.return_value = ''
|
||||
args = make_args(str(tmp_path / 'failinit.rc'))
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch('os.makedirs', side_effect=Exception('cannot create dir'))
|
||||
def test_run_init_makedirs_exception(
|
||||
mock_makedirs, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
mock_input.return_value = '/faildir/repo'
|
||||
mock_getpass.return_value = ''
|
||||
args = make_args(str(tmp_path / 'faildir.rc'))
|
||||
with mock.patch('subprocess.run'):
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch('os.makedirs')
|
||||
@mock.patch('builtins.open', side_effect=Exception('cannot open file'))
|
||||
def test_run_init_open_exception(
|
||||
mock_open, mock_makedirs, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
mock_input.return_value = '/failfile/repo'
|
||||
mock_getpass.return_value = ''
|
||||
args = make_args(str(tmp_path / 'failfile.rc'))
|
||||
with mock.patch('subprocess.run'):
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch('os.makedirs')
|
||||
@mock.patch('builtins.open')
|
||||
@mock.patch('subprocess.run', side_effect=Exception('unexpected'))
|
||||
def test_run_init_unexpected_exception(
|
||||
mock_subproc, mock_open, mock_makedirs, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
mock_input.return_value = '/unexpected/repo'
|
||||
mock_getpass.return_value = ''
|
||||
args = make_args(str(tmp_path / 'unexpected.rc'))
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
@mock.patch('builtins.input', side_effect=KeyboardInterrupt)
|
||||
def test_run_init_keyboard_interrupt(mock_input, tmp_path):
|
||||
args = make_args(str(tmp_path / 'kbint.rc'))
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
|
||||
|
||||
@mock.patch('builtins.input')
|
||||
@mock.patch('getpass.getpass')
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=subprocess.CalledProcessError(1, ['borg', 'init']),
|
||||
)
|
||||
def test_run_init_calledprocesserror(
|
||||
mock_subproc, mock_getpass, mock_input, tmp_path
|
||||
):
|
||||
mock_input.return_value = '/failsubproc/repo'
|
||||
mock_getpass.return_value = ''
|
||||
args = make_args(str(tmp_path / 'failsubproc.rc'))
|
||||
result = run_init(args)
|
||||
assert result == 1
|
||||
179
test/test_list.py
Normal file
179
test/test_list.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import argparse
|
||||
from unittest import mock
|
||||
import pytest
|
||||
|
||||
from locutus.list import run_list
|
||||
|
||||
|
||||
def make_args(config, profile, target=None):
|
||||
args = argparse.Namespace()
|
||||
args.config = config
|
||||
args.profile = profile
|
||||
if target is not None:
|
||||
args.target = target
|
||||
return args
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_prints_chrono_and_numbers_reverse(
|
||||
mock_subproc, mock_config, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
|
||||
# Oldest to newest
|
||||
archives = ['archA', 'archB', 'archC']
|
||||
mock_subproc.return_value.stdout = '\n'.join(archives) + '\n'
|
||||
mock_subproc.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
rc = run_list(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out.strip().splitlines()
|
||||
# Should print:
|
||||
# 2: archA
|
||||
# 1: archB
|
||||
# 0: archC
|
||||
assert out[0].strip() == '2: archA'
|
||||
assert out[1].strip() == '1: archB'
|
||||
assert out[2].strip() == '0: archC'
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_by_index_most_recent(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
|
||||
# Oldest to newest
|
||||
archives = ['oldest', 'middle', 'newest']
|
||||
# First subprocess.run: get archives (reversed in code)
|
||||
mock_subproc.side_effect = [
|
||||
mock.Mock(stdout='\n'.join(archives) + '\n', returncode=0),
|
||||
mock.Mock(stdout='file1\nfile2\n', returncode=0),
|
||||
]
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', target='0')
|
||||
rc = run_list(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
# Should show file list for "newest"
|
||||
assert 'file1' in out and 'file2' in out
|
||||
call_args = mock_subproc.call_args_list[1][0][0]
|
||||
# Should call borg list ...::newest
|
||||
assert call_args == ['borg', 'list', '/repo::newest']
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_by_index_out_of_range(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
|
||||
archives = ['archA', 'archB']
|
||||
mock_subproc.return_value.stdout = '\n'.join(archives) + '\n'
|
||||
mock_subproc.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', target='3') # out of range
|
||||
rc = run_list(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Archive index out of range' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_by_name(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
# Only need one subprocess.run (direct by name)
|
||||
mock_subproc.return_value.stdout = 'filex\nfiley\n'
|
||||
mock_subproc.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', target='archive-explicit')
|
||||
rc = run_list(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'filex' in out and 'filey' in out
|
||||
call_args = mock_subproc.call_args[0][0]
|
||||
assert call_args == ['borg', 'list', '/repo::archive-explicit']
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
def test_list_no_repo(mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = None
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
rc = run_list(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'No BORG_REPO configured' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail'))
|
||||
def test_list_unexpected_exception(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
rc = run_list(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg list failed' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=pytest.importorskip('subprocess').CalledProcessError(
|
||||
1, ['borg', 'list']
|
||||
),
|
||||
)
|
||||
def test_list_calledprocesserror(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
rc = run_list(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg list failed' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_no_archives(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_subproc.return_value.stdout = ''
|
||||
mock_subproc.return_value.returncode
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_no_archives_main(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_subproc.return_value.stdout = '' # No archives
|
||||
mock_subproc.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc')
|
||||
rc = run_list(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'No archives found' in out
|
||||
|
||||
|
||||
@mock.patch('locutus.list.LocutusConfig')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_list_by_index_no_archives(mock_subproc, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_subproc.return_value.stdout = '' # No archives
|
||||
mock_subproc.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', target='0')
|
||||
rc = run_list(args)
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert 'No archives found' in out
|
||||
208
test/test_mount.py
Normal file
208
test/test_mount.py
Normal file
@@ -0,0 +1,208 @@
|
||||
import argparse
|
||||
from unittest import mock
|
||||
import pytest
|
||||
import subprocess
|
||||
|
||||
from locutus.mount import run_mount
|
||||
|
||||
|
||||
def make_args(config, profile, mountpoint, archive=None):
|
||||
args = argparse.Namespace()
|
||||
args.config = config
|
||||
args.profile = profile
|
||||
args.mountpoint = mountpoint
|
||||
if archive is not None:
|
||||
args.archive = archive
|
||||
return args
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_all_archives_success(mock_run, mock_isdir, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_isdir.return_value = True
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', '/mnt/test')
|
||||
rc = run_mount(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'Archives mounted on /mnt/test' in out
|
||||
cmd_args = mock_run.call_args[0][0]
|
||||
assert cmd_args == ['borg', 'mount', '/repo', '/mnt/test']
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_by_name_success(mock_run, mock_isdir, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_isdir.return_value = True
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
args = make_args(
|
||||
'dummy.toml', 'dummy.rc', '/mnt/test', archive='archive-xyz'
|
||||
)
|
||||
rc = run_mount(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Archive 'archive-xyz' mounted on /mnt/test" in out
|
||||
cmd_args = mock_run.call_args[0][0]
|
||||
assert cmd_args == ['borg', 'mount', '/repo::archive-xyz', '/mnt/test']
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir')
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_by_index_success(mock_run, mock_isdir, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_isdir.return_value = True
|
||||
mock_run.return_value.returncode = 0
|
||||
archives = ['archA', 'archB', 'archC'] # Oldest to newest
|
||||
mock_run.side_effect = [
|
||||
# First run: borg list
|
||||
mock.Mock(stdout='\n'.join(archives) + '\n', returncode=0),
|
||||
# Second run: borg mount
|
||||
mock.Mock(returncode=0),
|
||||
]
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', '/mnt/test', archive='1')
|
||||
rc = run_mount(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
# Index 1 in reversed list is archB (so index 0=newest=archC, 1=archB, 2=archA)
|
||||
assert "Archive 'archB' mounted on /mnt/test" in out
|
||||
cmd_args = mock_run.call_args_list[1][0][0]
|
||||
assert cmd_args == ['borg', 'mount', '/repo::archB', '/mnt/test']
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
def test_mount_no_repo(mock_config, tmp_path, capsys):
|
||||
mock_config.return_value.get_repo.return_value = None
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc', str(tmp_path), archive='0')
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'No BORG_REPO configured' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=False)
|
||||
def test_mount_invalid_mountpoint(mock_isdir, mock_config, tmp_path, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc', '/not/a/dir', archive='0')
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'does not exist or is not a directory' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail'))
|
||||
def test_mount_unexpected_exception(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc', str(tmp_path), archive='0')
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg mount failed' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_by_index_out_of_range(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
archives = ['a', 'b']
|
||||
mock_run.side_effect = [
|
||||
mock.Mock(stdout='\n'.join(archives) + '\n', returncode=0)
|
||||
]
|
||||
args = make_args(
|
||||
'dummy.toml', 'dummy.rc', str(tmp_path), archive='5'
|
||||
) # Out of range
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Archive index out of range' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_by_index_no_archives(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_run.return_value.stdout = '' # No archives
|
||||
mock_run.return_value.returncode = 0
|
||||
args = make_args('dummy.toml', 'dummy.rc', str(tmp_path), archive='0')
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'No archives found' in err
|
||||
|
||||
|
||||
def test_mount_usage_message(capsys):
|
||||
args = argparse.Namespace()
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Usage:' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_calledprocesserror_on_mount(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
archives = ['a', 'b', 'c']
|
||||
mock_run.side_effect = [
|
||||
mock.Mock(
|
||||
stdout='\n'.join(archives) + '\n', returncode=0
|
||||
), # listing works
|
||||
subprocess.CalledProcessError(1, ['borg', 'mount']), # mount fails
|
||||
]
|
||||
args = make_args('dummy.toml', 'dummy.rc', str(tmp_path), archive='0')
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg mount failed:' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.mount.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_mount_exception_on_mount(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
archives = ['a', 'b', 'c']
|
||||
mock_run.side_effect = [
|
||||
mock.Mock(
|
||||
stdout='\n'.join(archives) + '\n', returncode=0
|
||||
), # listing works
|
||||
Exception(), # mount fails
|
||||
]
|
||||
args = make_args('dummy.toml', 'dummy.rc', str(tmp_path), archive='0')
|
||||
rc = run_mount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg mount failed (unexpected)' in err
|
||||
178
test/test_restore.py
Normal file
178
test/test_restore.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import argparse
|
||||
from unittest import mock
|
||||
import subprocess
|
||||
import pytest
|
||||
|
||||
from locutus.restore import run_restore
|
||||
|
||||
|
||||
def make_args(config, profile, archive, targetdir):
|
||||
args = argparse.Namespace()
|
||||
args.config = config
|
||||
args.profile = profile
|
||||
args.archive = archive
|
||||
args.targetdir = targetdir
|
||||
return args
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_restore_by_index_success(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
archives = ['archA', 'archB', 'archC']
|
||||
mock_run.side_effect = [
|
||||
mock.Mock(stdout='\n'.join(archives) + '\n', returncode=0), # borg list
|
||||
mock.Mock(returncode=0), # borg extract
|
||||
]
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', '1', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'restored to' in out
|
||||
assert 'archB' in out
|
||||
extract_call = mock_run.call_args_list[1][0][0]
|
||||
assert extract_call == ['borg', 'extract', '/repo::archB']
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_restore_by_name_success(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', 'named-archive', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'restored to' in out
|
||||
assert 'named-archive' in out
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert call_args == ['borg', 'extract', '/repo::named-archive']
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_restore_by_index_out_of_range(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
archives = ['a', 'b']
|
||||
mock_run.return_value.stdout = '\n'.join(archives) + '\n'
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', '5', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Archive index out of range' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_restore_by_index_no_archives(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
mock_run.return_value.stdout = ''
|
||||
mock_run.return_value.returncode = 0
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', '0', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'No archives found' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=False)
|
||||
def test_restore_invalid_targetdir(mock_isdir, mock_config, capsys):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', 'archive', '/notadir')
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'does not exist or is not a directory' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
def test_restore_no_repo(mock_config, tmp_path, capsys):
|
||||
mock_config.return_value.get_repo.return_value = None
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc', '0', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'No BORG_REPO configured' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch(
|
||||
'subprocess.run',
|
||||
side_effect=subprocess.CalledProcessError(1, ['borg', 'extract']),
|
||||
)
|
||||
def test_restore_extract_calledprocesserror(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', 'named-archive', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg extract failed' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail!'))
|
||||
def test_restore_extract_generic_exception(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
|
||||
args = make_args('dummy.toml', 'dummy.rc', 'named-archive', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg extract failed (unexpected): fail!' in err
|
||||
|
||||
|
||||
def test_restore_usage_message(capsys):
|
||||
args = argparse.Namespace()
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Usage:' in err
|
||||
|
||||
|
||||
@mock.patch('locutus.restore.LocutusConfig')
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run', side_effect=Exception('index explode'))
|
||||
def test_restore_index_lookup_generic_exception(
|
||||
mock_run, mock_isdir, mock_config, tmp_path, capsys
|
||||
):
|
||||
mock_config.return_value.get_repo.return_value = '/repo'
|
||||
mock_config.return_value.env = {}
|
||||
args = make_args('dummy.toml', 'dummy.rc', '0', str(tmp_path))
|
||||
rc = run_restore(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'borg restore failed during archive lookup: index explode' in err
|
||||
97
test/test_umount.py
Normal file
97
test/test_umount.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import argparse
|
||||
import pytest
|
||||
import subprocess
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from locutus.umount import run_umount
|
||||
|
||||
|
||||
def make_args(mountpoint):
|
||||
args = argparse.Namespace()
|
||||
args.mountpoint = mountpoint
|
||||
return args
|
||||
|
||||
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_umount_borg_success(mock_run, mock_isdir, capsys):
|
||||
mock_run.return_value.returncode = 0
|
||||
args = make_args('/mnt/test')
|
||||
rc = run_umount(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'Unmounted /mnt/test' in out
|
||||
cmd_args = mock_run.call_args[0][0]
|
||||
assert cmd_args == ['borg', 'umount', '/mnt/test']
|
||||
|
||||
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_umount_fusermount_success(mock_run, mock_isdir, capsys):
|
||||
# Simulate borg umount fails, fusermount -u succeeds
|
||||
def side_effect(cmd, **kwargs):
|
||||
if cmd[0] == 'borg':
|
||||
raise subprocess.CalledProcessError(1, cmd)
|
||||
return mock.Mock(returncode=0)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
args = make_args('/mnt/backup')
|
||||
rc = run_umount(args)
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert 'Unmounted /mnt/backup' in out
|
||||
# Second call is fusermount -u
|
||||
assert mock_run.call_args_list[1][0][0] == [
|
||||
'fusermount',
|
||||
'-u',
|
||||
'/mnt/backup',
|
||||
]
|
||||
|
||||
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run', side_effect=Exception('fail'))
|
||||
def test_umount_all_fail(mock_run, mock_isdir, capsys):
|
||||
args = make_args('/mnt/doesnotunmount')
|
||||
rc = run_umount(args)
|
||||
assert rc == 2
|
||||
err = capsys.readouterr().err
|
||||
assert 'Failed to unmount' in err
|
||||
|
||||
|
||||
@mock.patch('os.path.isdir', return_value=False)
|
||||
def test_umount_bad_mountpoint(mock_isdir, capsys):
|
||||
args = make_args('/not/a/dir')
|
||||
rc = run_umount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'does not exist or is not a directory' in err
|
||||
|
||||
|
||||
def test_umount_usage_message(capsys):
|
||||
args = argparse.Namespace()
|
||||
rc = run_umount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Usage:' in err
|
||||
|
||||
|
||||
@mock.patch('os.path.isdir', return_value=True)
|
||||
@mock.patch('subprocess.run')
|
||||
def test_umount_fusermount_inner_exception(mock_run, mock_isdir, capsys):
|
||||
# borg umount fails (CalledProcessError), fusermount -u raises generic Exception
|
||||
def side_effect(cmd, **kwargs):
|
||||
if cmd[0] == 'borg':
|
||||
raise subprocess.CalledProcessError(1, cmd)
|
||||
if cmd[0] == 'fusermount':
|
||||
raise Exception('fusermount explosion!')
|
||||
return mock.Mock(returncode=0)
|
||||
|
||||
mock_run.side_effect = side_effect
|
||||
|
||||
args = make_args('/mnt/boom')
|
||||
rc = run_umount(args)
|
||||
assert rc == 1
|
||||
err = capsys.readouterr().err
|
||||
assert 'Failed to unmount /mnt/boom: fusermount explosion!' in err
|
||||
194
uv.lock
generated
Normal file
194
uv.lock
generated
Normal file
@@ -0,0 +1,194 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload_time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload_time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/e0/98670a80884f64578f0c22cd70c5e81a6e07b08167721c7487b4d70a7ca0/coverage-7.9.1.tar.gz", hash = "sha256:6cf43c78c4282708a28e466316935ec7489a9c487518a77fa68f716c67909cec", size = 813650, upload_time = "2025-06-13T13:02:28.627Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/34/fa69372a07d0903a78ac103422ad34db72281c9fc625eba94ac1185da66f/coverage-7.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95c765060e65c692da2d2f51a9499c5e9f5cf5453aeaf1420e3fc847cc060582", size = 212146, upload_time = "2025-06-13T13:00:48.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/f0/da1894915d2767f093f081c42afeba18e760f12fdd7a2f4acbe00564d767/coverage-7.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ba383dc6afd5ec5b7a0d0c23d38895db0e15bcba7fb0fa8901f245267ac30d86", size = 212536, upload_time = "2025-06-13T13:00:51.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/d5/3fc33b06e41e390f88eef111226a24e4504d216ab8e5d1a7089aa5a3c87a/coverage-7.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37ae0383f13cbdcf1e5e7014489b0d71cc0106458878ccde52e8a12ced4298ed", size = 245092, upload_time = "2025-06-13T13:00:52.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/39/7aa901c14977aba637b78e95800edf77f29f5a380d29768c5b66f258305b/coverage-7.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69aa417a030bf11ec46149636314c24c8d60fadb12fc0ee8f10fda0d918c879d", size = 242806, upload_time = "2025-06-13T13:00:54.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/fc/30e5cfeaf560b1fc1989227adedc11019ce4bb7cce59d65db34fe0c2d963/coverage-7.9.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a4be2a28656afe279b34d4f91c3e26eccf2f85500d4a4ff0b1f8b54bf807338", size = 244610, upload_time = "2025-06-13T13:00:56.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/15/cca62b13f39650bc87b2b92bb03bce7f0e79dd0bf2c7529e9fc7393e4d60/coverage-7.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:382e7ddd5289f140259b610e5f5c58f713d025cb2f66d0eb17e68d0a94278875", size = 244257, upload_time = "2025-06-13T13:00:58.545Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/1a/c0f2abe92c29e1464dbd0ff9d56cb6c88ae2b9e21becdb38bea31fcb2f6c/coverage-7.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e5532482344186c543c37bfad0ee6069e8ae4fc38d073b8bc836fc8f03c9e250", size = 242309, upload_time = "2025-06-13T13:00:59.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/8d/c6fd70848bd9bf88fa90df2af5636589a8126d2170f3aade21ed53f2b67a/coverage-7.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a39d18b3f50cc121d0ce3838d32d58bd1d15dab89c910358ebefc3665712256c", size = 242898, upload_time = "2025-06-13T13:01:02.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/9e/6ca46c7bff4675f09a66fe2797cd1ad6a24f14c9c7c3b3ebe0470a6e30b8/coverage-7.9.1-cp311-cp311-win32.whl", hash = "sha256:dd24bd8d77c98557880def750782df77ab2b6885a18483dc8588792247174b32", size = 214561, upload_time = "2025-06-13T13:01:04.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/30/166978c6302010742dabcdc425fa0f938fa5a800908e39aff37a7a876a13/coverage-7.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:6b55ad10a35a21b8015eabddc9ba31eb590f54adc9cd39bcf09ff5349fd52125", size = 215493, upload_time = "2025-06-13T13:01:05.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/07/a6d2342cd80a5be9f0eeab115bc5ebb3917b4a64c2953534273cf9bc7ae6/coverage-7.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ad935f0016be24c0e97fc8c40c465f9c4b85cbbe6eac48934c0dc4d2568321e", size = 213869, upload_time = "2025-06-13T13:01:09.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/d9/7f66eb0a8f2fce222de7bdc2046ec41cb31fe33fb55a330037833fb88afc/coverage-7.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8de12b4b87c20de895f10567639c0797b621b22897b0af3ce4b4e204a743626", size = 212336, upload_time = "2025-06-13T13:01:10.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/e07cb920ef3addf20f052ee3d54906e57407b6aeee3227a9c91eea38a665/coverage-7.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5add197315a054e92cee1b5f686a2bcba60c4c3e66ee3de77ace6c867bdee7cb", size = 212571, upload_time = "2025-06-13T13:01:12.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f8/96f155de7e9e248ca9c8ff1a40a521d944ba48bec65352da9be2463745bf/coverage-7.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600a1d4106fe66f41e5d0136dfbc68fe7200a5cbe85610ddf094f8f22e1b0300", size = 246377, upload_time = "2025-06-13T13:01:14.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/cf/1d783bd05b7bca5c10ded5f946068909372e94615a4416afadfe3f63492d/coverage-7.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a876e4c3e5a2a1715a6608906aa5a2e0475b9c0f68343c2ada98110512ab1d8", size = 243394, upload_time = "2025-06-13T13:01:16.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/dd/e7b20afd35b0a1abea09fb3998e1abc9f9bd953bee548f235aebd2b11401/coverage-7.9.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81f34346dd63010453922c8e628a52ea2d2ccd73cb2487f7700ac531b247c8a5", size = 245586, upload_time = "2025-06-13T13:01:17.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/38/b30b0006fea9d617d1cb8e43b1bc9a96af11eff42b87eb8c716cf4d37469/coverage-7.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:888f8eee13f2377ce86d44f338968eedec3291876b0b8a7289247ba52cb984cd", size = 245396, upload_time = "2025-06-13T13:01:19.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/e4/4d8ec1dc826e16791f3daf1b50943e8e7e1eb70e8efa7abb03936ff48418/coverage-7.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9969ef1e69b8c8e1e70d591f91bbc37fc9a3621e447525d1602801a24ceda898", size = 243577, upload_time = "2025-06-13T13:01:22.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/f4/b0e96c5c38e6e40ef465c4bc7f138863e2909c00e54a331da335faf0d81a/coverage-7.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:60c458224331ee3f1a5b472773e4a085cc27a86a0b48205409d364272d67140d", size = 244809, upload_time = "2025-06-13T13:01:24.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/65/27e0a1fa5e2e5079bdca4521be2f5dabf516f94e29a0defed35ac2382eb2/coverage-7.9.1-cp312-cp312-win32.whl", hash = "sha256:5f646a99a8c2b3ff4c6a6e081f78fad0dde275cd59f8f49dc4eab2e394332e74", size = 214724, upload_time = "2025-06-13T13:01:25.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/a8/d5b128633fd1a5e0401a4160d02fa15986209a9e47717174f99dc2f7166d/coverage-7.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:30f445f85c353090b83e552dcbbdad3ec84c7967e108c3ae54556ca69955563e", size = 215535, upload_time = "2025-06-13T13:01:27.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/37/84bba9d2afabc3611f3e4325ee2c6a47cd449b580d4a606b240ce5a6f9bf/coverage-7.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:af41da5dca398d3474129c58cb2b106a5d93bbb196be0d307ac82311ca234342", size = 213904, upload_time = "2025-06-13T13:01:29.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/a7/a027970c991ca90f24e968999f7d509332daf6b8c3533d68633930aaebac/coverage-7.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:31324f18d5969feef7344a932c32428a2d1a3e50b15a6404e97cba1cc9b2c631", size = 212358, upload_time = "2025-06-13T13:01:30.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/48/6aaed3651ae83b231556750280682528fea8ac7f1232834573472d83e459/coverage-7.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c804506d624e8a20fb3108764c52e0eef664e29d21692afa375e0dd98dc384f", size = 212620, upload_time = "2025-06-13T13:01:32.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/f4b613f3b44d8b9f144847c89151992b2b6b79cbc506dee89ad0c35f209d/coverage-7.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef64c27bc40189f36fcc50c3fb8f16ccda73b6a0b80d9bd6e6ce4cffcd810bbd", size = 245788, upload_time = "2025-06-13T13:01:33.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d2/de4fdc03af5e4e035ef420ed26a703c6ad3d7a07aff2e959eb84e3b19ca8/coverage-7.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4fe2348cc6ec372e25adec0219ee2334a68d2f5222e0cba9c0d613394e12d86", size = 243001, upload_time = "2025-06-13T13:01:35.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e8/eed18aa5583b0423ab7f04e34659e51101135c41cd1dcb33ac1d7013a6d6/coverage-7.9.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34ed2186fe52fcc24d4561041979a0dec69adae7bce2ae8d1c49eace13e55c43", size = 244985, upload_time = "2025-06-13T13:01:36.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f8/ae9e5cce8885728c934eaa58ebfa8281d488ef2afa81c3dbc8ee9e6d80db/coverage-7.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:25308bd3d00d5eedd5ae7d4357161f4df743e3c0240fa773ee1b0f75e6c7c0f1", size = 245152, upload_time = "2025-06-13T13:01:39.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/c8/272c01ae792bb3af9b30fac14d71d63371db227980682836ec388e2c57c0/coverage-7.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73e9439310f65d55a5a1e0564b48e34f5369bee943d72c88378f2d576f5a5751", size = 243123, upload_time = "2025-06-13T13:01:40.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d0/2819a1e3086143c094ab446e3bdf07138527a7b88cb235c488e78150ba7a/coverage-7.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ab6be0859141b53aa89412a82454b482c81cf750de4f29223d52268a86de67", size = 244506, upload_time = "2025-06-13T13:01:42.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/4e/9f6117b89152df7b6112f65c7a4ed1f2f5ec8e60c4be8f351d91e7acc848/coverage-7.9.1-cp313-cp313-win32.whl", hash = "sha256:64bdd969456e2d02a8b08aa047a92d269c7ac1f47e0c977675d550c9a0863643", size = 214766, upload_time = "2025-06-13T13:01:44.482Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0f/4b59f7c93b52c2c4ce7387c5a4e135e49891bb3b7408dcc98fe44033bbe0/coverage-7.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:be9e3f68ca9edb897c2184ad0eee815c635565dbe7a0e7e814dc1f7cbab92c0a", size = 215568, upload_time = "2025-06-13T13:01:45.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/1e/9679826336f8c67b9c39a359352882b24a8a7aee48d4c9cad08d38d7510f/coverage-7.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:1c503289ffef1d5105d91bbb4d62cbe4b14bec4d13ca225f9c73cde9bb46207d", size = 213939, upload_time = "2025-06-13T13:01:47.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/5b/5c6b4e7a407359a2e3b27bf9c8a7b658127975def62077d441b93a30dbe8/coverage-7.9.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0b3496922cb5f4215bf5caaef4cf12364a26b0be82e9ed6d050f3352cf2d7ef0", size = 213079, upload_time = "2025-06-13T13:01:48.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/22/1e2e07279fd2fd97ae26c01cc2186e2258850e9ec125ae87184225662e89/coverage-7.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9565c3ab1c93310569ec0d86b017f128f027cab0b622b7af288696d7ed43a16d", size = 213299, upload_time = "2025-06-13T13:01:49.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/c0/4c5125a4b69d66b8c85986d3321520f628756cf524af810baab0790c7647/coverage-7.9.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2241ad5dbf79ae1d9c08fe52b36d03ca122fb9ac6bca0f34439e99f8327ac89f", size = 256535, upload_time = "2025-06-13T13:01:51.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/8b/e36a04889dda9960be4263e95e777e7b46f1bb4fc32202612c130a20c4da/coverage-7.9.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bb5838701ca68b10ebc0937dbd0eb81974bac54447c55cd58dea5bca8451029", size = 252756, upload_time = "2025-06-13T13:01:54.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/82/be04eff8083a09a4622ecd0e1f31a2c563dbea3ed848069e7b0445043a70/coverage-7.9.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b30a25f814591a8c0c5372c11ac8967f669b97444c47fd794926e175c4047ece", size = 254912, upload_time = "2025-06-13T13:01:56.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/25/c26610a2c7f018508a5ab958e5b3202d900422cf7cdca7670b6b8ca4e8df/coverage-7.9.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2d04b16a6062516df97969f1ae7efd0de9c31eb6ebdceaa0d213b21c0ca1a683", size = 256144, upload_time = "2025-06-13T13:01:58.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/8b/fb9425c4684066c79e863f1e6e7ecebb49e3a64d9f7f7860ef1688c56f4a/coverage-7.9.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7931b9e249edefb07cd6ae10c702788546341d5fe44db5b6108a25da4dca513f", size = 254257, upload_time = "2025-06-13T13:01:59.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/df/27b882f54157fc1131e0e215b0da3b8d608d9b8ef79a045280118a8f98fe/coverage-7.9.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52e92b01041151bf607ee858e5a56c62d4b70f4dac85b8c8cb7fb8a351ab2c10", size = 255094, upload_time = "2025-06-13T13:02:01.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437, upload_time = "2025-06-13T13:02:02.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605, upload_time = "2025-06-13T13:02:05.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392, upload_time = "2025-06-13T13:02:07.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009, upload_time = "2025-06-13T13:02:25.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000, upload_time = "2025-06-13T13:02:27.173Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload_time = "2025-03-19T20:09:59.721Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload_time = "2025-03-19T20:10:01.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "locutus"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pytest", marker = "extra == 'dev'" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload_time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload_time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload_time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload_time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload_time = "2025-06-21T13:39:12.283Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload_time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload_time = "2025-06-18T05:48:06.109Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload_time = "2025-06-18T05:48:03.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "6.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload_time = "2025-06-12T10:47:47.684Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload_time = "2025-06-12T10:47:45.932Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload_time = "2024-11-27T22:38:36.873Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload_time = "2024-11-27T22:37:54.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload_time = "2024-11-27T22:37:56.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload_time = "2024-11-27T22:37:57.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload_time = "2024-11-27T22:37:59.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload_time = "2024-11-27T22:38:00.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload_time = "2024-11-27T22:38:02.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload_time = "2024-11-27T22:38:03.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload_time = "2024-11-27T22:38:04.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload_time = "2024-11-27T22:38:05.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload_time = "2024-11-27T22:38:06.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload_time = "2024-11-27T22:38:07.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload_time = "2024-11-27T22:38:09.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload_time = "2024-11-27T22:38:10.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload_time = "2024-11-27T22:38:11.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload_time = "2024-11-27T22:38:13.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload_time = "2024-11-27T22:38:14.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload_time = "2024-11-27T22:38:15.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload_time = "2024-11-27T22:38:17.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload_time = "2024-11-27T22:38:19.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload_time = "2024-11-27T22:38:20.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload_time = "2024-11-27T22:38:21.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload_time = "2024-11-27T22:38:22.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload_time = "2024-11-27T22:38:24.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload_time = "2024-11-27T22:38:26.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload_time = "2024-11-27T22:38:27.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload_time = "2024-11-27T22:38:29.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload_time = "2024-11-27T22:38:30.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload_time = "2024-11-27T22:38:31.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload_time = "2024-11-27T22:38:32.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload_time = "2024-11-27T22:38:34.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload_time = "2024-11-27T22:38:35.385Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user