feat(foundations): add foundations tier — three teaching-first Python projects

Adds the foundations tier: three Python projects pitched at someone
who has never written Python before, ramping from a single-file hash
identifier up to the cryptographic boundary of the beginner tier.

Projects
- hash-identifier: identify ~30 hash formats by prefix, length, and
  charset (one file, ~30 tests).
- http-headers-scanner: fetch a URL and grade its security headers
  A–F (httpx + rich + respx, single-file scanner, 13 tests).
- password-manager: Argon2id + AES-256-GCM encrypted CLI vault with
  atomic durable writes and advisory file locking (the hardest
  foundations project — stepping stone into the beginner tier).

Each project ships with
- Heavily-commented source as a teaching aid (foundations-tier override
  of the no-comments rule — explanations targeted at first-time readers).
- A full learn/ folder: Overview, Concepts, Architecture, line-by-line
  Implementation walkthrough, Challenges.
- README with ASCII banner, badges (incl. tier + difficulty), demo
  sessions, command tables, learn-folder index, see-also links.
- install.sh + justfile + uv-managed dependencies.
- pytest + ruff + mypy + pylint config, all linters at 10.00/10.

Also centralizes the docs/ exclude in .gitignore (was a per-project
list of advanced/beginner docs paths) so dev-only audit reports and
plan documents stay local across the whole repo without needing
individual entries.
This commit is contained in:
CarterPerez-dev 2026-05-13 13:57:33 -04:00
parent 7d69339413
commit d97662ee61
56 changed files with 18808 additions and 2 deletions

3
.gitignore vendored
View File

@ -7,8 +7,7 @@
.worktrees/
DEMO-TRACKER.md
PROJECTS/beginner/hash-cracker/docs/
PROJECTS/advanced/monitor-the-situation-dashboard/docs/
/docs/
# Python virtualenvs and caches
.venv/

View File

@ -0,0 +1,70 @@
# ©AngelaMos | 2026
# .gitignore
#
# This file lists patterns of files and folders that git should NEVER
# track. Anything matching a pattern below is invisible to `git status`
# and won't be committed. We do this to avoid two problems:
#
# 1. Privacy — we never want to accidentally commit a sensitive file
# a user might pass to the tool.
#
# 2. Noise — build artifacts and caches change constantly. Tracking
# them would clutter every commit and create merge conflicts.
#
# Each line is a "glob" pattern (* matches any characters).
# -----------------------------------------------------------------------------
# Python build/install artifacts
# -----------------------------------------------------------------------------
# __pycache__: bytecode Python generates from .py files when it runs them.
# We never edit these, and they regenerate on every run, so ignore them.
__pycache__/
# *.py[cod] = .pyc, .pyo, .pyd — older bytecode formats Python may emit.
*.py[cod]
*$py.class
# Build outputs from `pip wheel`, hatchling, etc.
*.egg-info/
*.egg
build/
dist/
.eggs/
*.so
# -----------------------------------------------------------------------------
# Virtual environments — created by `uv venv`, never committed
# -----------------------------------------------------------------------------
# A virtual environment is a per-project Python install. It lives inside
# the project directory. Committing it would balloon the repo and pin
# the user to your operating system. Always recreate locally.
.venv/
venv/
env/
# -----------------------------------------------------------------------------
# Tooling caches — these speed up repeated runs but never need committing
# -----------------------------------------------------------------------------
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Coverage report files from pytest-cov.
.coverage
htmlcov/
# -----------------------------------------------------------------------------
# IDE / editor scratch files
# -----------------------------------------------------------------------------
# Each editor drops its own metadata. None of it belongs in the repo.
.vscode/
.idea/
*.swp
*.swo
# macOS Finder dumps these in every directory it has opened.
.DS_Store
# -----------------------------------------------------------------------------
# Local environment files (may contain secrets)
# -----------------------------------------------------------------------------
# We never use .env in this project, but list it just in case. Anything
# containing secrets should never reach the public repo.
.env
.env.local

View File

@ -0,0 +1,104 @@
# ©AngelaMos | 2026
# .style.yapf
#
# Configuration for yapf, our Python code formatter. yapf rewrites
# Python files to match these rules so every file looks identical
# no matter who wrote it.
#
# The two settings most worth knowing:
# based_on_style = pep8 → start from PEP 8 (the official style)
# column_limit = 75 → wrap lines at 75 characters
#
# Run with: `just format`
[style]
# Start from PEP 8 (Python's official style guide). Every other
# setting below tweaks PEP 8 in some specific direction.
based_on_style = pep8
# Wrap any line longer than this many columns.
column_limit = 75
# Use 4 spaces per indent level (PEP 8 standard).
indent_width = 4
# Continuation lines (when a statement wraps) also indent by 4.
continuation_indent_width = 4
# Don't indent the closing bracket of a multi-line call/list/dict
indent_closing_brackets = false
# DO de-indent the closing bracket — line it up with the opener.
dedent_closing_brackets = true
# Don't add 4 spaces of indent to blank lines inside functions.
indent_blank_lines = false
# Two spaces between code and the # of a trailing comment.
spaces_before_comment = 2
# No spaces around ** (power) operator: 2**3 not 2 ** 3.
spaces_around_power_operator = false
# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
spaces_around_default_or_named_assign = true
# No extra space before a comma followed by a closing bracket.
space_between_ending_comma_and_closing_bracket = false
# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
space_inside_brackets = false
# Spaces around colons in slices: x[1 : 5] not x[1:5].
spaces_around_subscript_colon = true
# No blank line before nested def/class.
blank_line_before_nested_class_or_def = false
# No blank line between a class and its docstring.
blank_line_before_class_docstring = false
# Two blank lines around top-level functions and classes (PEP 8).
blank_lines_around_top_level_definition = 2
# Two blank lines between top-level imports and module-level variables.
blank_lines_between_top_level_imports_and_variables = 2
# No blank line before the module docstring.
blank_line_before_module_docstring = false
# When wrapping `a and b`, put `and` at the start of the new line.
split_before_logical_operator = true
# When wrapping a function call, put the first argument on a new line.
split_before_first_argument = true
# Put each named arg on its own line when wrapping.
split_before_named_assigns = true
# Multi-line comprehensions wrap onto separate lines.
split_complex_comprehension = true
# Don't split right after the opening parenthesis.
split_before_expression_after_opening_paren = false
# Put the closing bracket on its own line when wrapping.
split_before_closing_bracket = true
# When splitting at commas, give every value its own line.
split_all_comma_separated_values = true
# But don't recursively force ALL top-level commas onto new lines.
split_all_top_level_comma_separated_values = false
# Don't merge separate brackets into one.
coalesce_brackets = false
# Each dict entry gets its own line in multi-line dicts.
each_dict_entry_on_separate_line = true
# Disallow lambdas that span multiple lines.
allow_multiline_lambdas = false
# Disallow dict keys that span multiple lines.
allow_multiline_dictionary_keys = false
# Penalty score for splitting at import names — 0 means free to split.
split_penalty_import_names = 0
# Don't try to fit an already-broken statement back onto fewer lines.
join_multiple_lines = false
# Closing bracket lines up with the visual indent of the opener.
align_closing_bracket_with_visual_indent = true
# Don't add parentheses to clarify operator precedence automatically.
arithmetic_precedence_indication = false
# Penalty for splitting after each line — yapf weighs this against rules.
split_penalty_for_added_line_split = 275
# Use spaces for indentation, never tabs (PEP 8).
use_tabs = false
# Don't split on a `.` when wrapping (so `obj.method()` stays together).
split_before_dot = false
# When the last argument in a call has a trailing comma, force a split.
split_arguments_when_comma_terminated = true
# Function names yapf treats as i18n calls — leave their args alone.
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
# Comment markers yapf treats as i18n metadata.
i18n_comment = ['# Translators:', '# i18n:']
# Penalty for splitting comprehension iterables.
split_penalty_comprehension = 80
# Penalty for splitting right after an opening bracket.
split_penalty_after_opening_bracket = 280
# Penalty for splitting before an `if` in a ternary expression.
split_penalty_before_if_expr = 0
# Penalty for splitting before a bitwise operator.
split_penalty_bitwise_operator = 290
# Penalty for splitting before a logical operator (already lowered).
split_penalty_logical_operator = 0

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,109 @@
```ruby
██╗ ██╗ █████╗ ███████╗██╗ ██╗ ██╗██████╗
██║ ██║██╔══██╗██╔════╝██║ ██║ ██║██╔══██╗
███████║███████║███████╗███████║ ██║██║ ██║
██╔══██║██╔══██║╚════██║██╔══██║ ██║██║ ██║
██║ ██║██║ ██║███████║██║ ██║ ██║██████╔╝
╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝╚═════╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Foundations-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/hash-identifier)
[![Tier: Foundations](https://img.shields.io/badge/Tier-Foundations-00C9A7?style=flat&logo=bookstack&logoColor=white)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
[![Python 3.14](https://img.shields.io/badge/Python-3.14-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Tests](https://img.shields.io/badge/tests-pytest-0A9EDC?style=flat&logo=pytest&logoColor=white)](https://pytest.org)
[![Lint](https://img.shields.io/badge/lint-ruff%20%2B%20mypy%20%2B%20pylint-D7FF64?style=flat)](https://github.com/astral-sh/ruff)
> Identify the algorithm behind a hash string by its prefix, length, and character set — the first move in any password-cracking workflow.
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
> [!NOTE]
> **Foundations tier** — this project is built for someone who has never written Python before. The source code is heavily commented as a teaching aid, the `learn/` folder explains every concept from zero, and the whole tool is one readable file. If you already know Python, jump straight to [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural cracking companion to this identifier.
## What It Does
- Identify ~30 hash formats by prefix (`$2b$`, `$argon2id$`, `$apr1$`, `pbkdf2_sha256$`, `{SSHA}`, and more)
- Identify common hex hashes by length (MD5, SHA-1, SHA-256, SHA-512, NTLM, MD4, RIPEMD, BLAKE2, SHA-3)
- Recognize MySQL5, NetNTLMv1/v2, and traditional 13-char DES crypt by shape
- Detect non-hash inputs (JWTs, base64 blobs) and tell the user what they actually pasted
- Return ranked candidates with `high` / `medium` / `low` confidence and a one-line *reason* for every guess
- Pure-function core — no network, no filesystem, no global state, instant runtime
- Rich-rendered colored output table; clean exit codes for shell scripting
## Quick Start
```bash
./install.sh
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
# ✔ MD5 (medium) — 32 hex chars, most likely candidate at this length
```
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Demo Hashes
Try these — each demonstrates a different identification path:
| Hash | Detected as | Why |
|------|-------------|-----|
| `5f4dcc3b5aa765d61d8327deb882cf99` | MD5 | 32 hex chars — most likely candidate at this length |
| `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | SHA-256 | 64 hex chars — most likely candidate at this length |
| `$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK` | bcrypt | prefix `$2b$` — bcrypt PHC string, 2b variant (current) |
| `$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG` | Argon2id | prefix `$argon2id$` — modern PHC string, the current standard |
| `$apr1$JlOdSlVe$ipa1mTAv3LFRBHHzqaIaH/` | Apache MD5-crypt | prefix `$apr1$` — Apache htpasswd MD5 variant (`htpasswd -m`) |
| `*A4B6157319038724E3560894F7F932C8886EBFCF` | MySQL5 | starts with `*` followed by 40 uppercase hex chars |
| `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgN...` | JWT (not a hash) | leading `eyJ` is base64 of `{"` — JWT, not a hash |
```bash
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK'
just run -- '*A4B6157319038724E3560894F7F932C8886EBFCF'
just run -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```
> [!IMPORTANT]
> Always wrap hashes that begin with `$` in **single quotes**. Without quotes your shell will try to expand `$2`, `$P$`, `$1$` etc. as shell variables and silently mangle the input.
## Tooling
```bash
just # list available recipes
just test # run pytest (30+ tests, runs in under a second)
just lint # ruff + mypy --strict + pylint
just format # yapf
just run -- <h> # identify a hash
```
## Requirements
- **Python 3.14+** — the install script will check.
- [`uv`](https://github.com/astral-sh/uv) — modern Python package manager (auto-installed by `./install.sh`).
- [`just`](https://github.com/casey/just) — command runner (auto-installed by `./install.sh`).
No compilers, no system libraries, no network access required. The project is one Python file plus tests.
## Learn
This project includes step-by-step learning materials covering security theory, architecture, and implementation — written for someone who has never touched Python before.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, common problems |
| [01 - Concepts](learn/01-CONCEPTS.md) | What hashes are, real-world breaches, the three identification signals |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | Three-layer architecture, six-step decision pipeline, data-driven design |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Line-by-line walkthrough — every Python feature explained when first encountered |
| [04 - Challenges](learn/04-CHALLENGES.md) | Five tiers of extension ideas, from adding a prefix rule to building an ML classifier |
## See Also
- [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural sibling. Once this tool tells you *what* a hash is, that one teaches you how to crack it.
- [`PROJECTS/foundations/http-headers-scanner`](../http-headers-scanner) — another foundations-tier Python project, slightly more involved I/O.
- [`PROJECTS/foundations/password-manager`](../password-manager) — the hardest foundations-tier project; covers Argon2id, AES-GCM, and on-disk vaults.
## License
AGPL 3.0

View File

@ -0,0 +1,679 @@
"""
©AngelaMos | 2026
hash_identifier.py
Identify what kind of hash a string is, by inspecting its shape
When someone hands you a string of gibberish like
`5f4dcc3b5aa765d61d8327deb882cf99` or `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...`,
the first question is: what algorithm produced it? You can NOT crack
or analyze a hash without knowing what flavor of hash you are looking
at. Every cracking tool hashcat, john the ripper needs you to
specify an algorithm before it will start
This script does the looking-at part. Given a hash string, it returns
ranked candidates with a confidence score and a short reason
How identification actually works
There is no magic here. Hash strings carry shape clues
1. PREFIX. Many modern hashes are stored in "PHC string format"
a self-describing format that starts with a marker like `$2b$`
(bcrypt) or `$argon2id$` (Argon2id). When we see a known prefix
we know the algorithm with HIGH confidence
2. LENGTH. Raw hex output of a hash function is always the same
length: MD5 produces 16 bytes = 32 hex chars; SHA-1 produces 20
bytes = 40 hex chars; SHA-256 produces 32 bytes = 64 hex chars,
and so on. Length alone narrows the field
3. CHARSET. Different formats use different alphabets. Hex hashes
use only 0-9 and a-f. Base64 uses 0-9, A-Z, a-z, +, /, and =.
A string with `+` in it is not a hex hash
So our algorithm is: try prefix rules first, fall back to length +
charset rules, return the candidates ranked by confidence
What this script can and cannot do
CAN: suggest likely algorithms for a hash you found
CANNOT: tell you the password the hash was made from
(that is hashcat's job — see ../../beginner/hash-cracker)
What this file exposes
HashCandidate one ranked guess (algorithm, confidence, reason)
identify(text) return ranked candidates for a hash string
main() CLI entry point used by `hashid <hash>`
"""
# Standard library: parse command-line flags like `--top 3` into a
# nice object so we do not have to slice `sys.argv` by hand.
import argparse
# Standard library: access to interpreter internals — we use it to
# write to stderr and to exit the process with a specific status code.
import sys
# Standard library: a decorator that turns a class into a small,
# immutable data record without writing `__init__` boilerplate.
from dataclasses import dataclass
# Standard library: a type hint that pins a value to a small fixed
# set of strings (here: "high" / "medium" / "low"). Mypy catches typos.
from typing import Literal
# Third-party (rich): the printer that actually draws the table to
# the terminal, with color and Unicode support.
from rich.console import Console
# Third-party (rich): builds the colored ASCII table we print for
# ranked hash candidates.
from rich.table import Table
# =============================================================================
# Confidence type — only three valid values
# =============================================================================
# Literal["high", "medium", "low"] is a type hint that says "this string
# can ONLY be one of these three values." Mypy will catch typos like
# "hgih" at edit time. We chose Literal over an Enum because Carter's
# style guide prefers Literals for small fixed sets
Confidence = Literal["high", "medium", "low"]
# =============================================================================
# Result type — what identify() returns for each guess
# =============================================================================
@dataclass(frozen = True, slots = True)
class HashCandidate:
"""
One possible identification of a hash string
`frozen=True` makes the dataclass immutable once created, its
fields cannot change. `slots=True` makes instances lightweight in
memory. Together these two flags create a clean "value object"
Fields
------
algorithm
Human-readable algorithm name like "SHA-256" or "bcrypt"
confidence
How sure we are. "high" comes from definitive prefix matches,
"medium" from length matches that have only one obvious
candidate, "low" for length matches that could be many things
reason
Short explanation we display next to the algorithm name. Keeps
the output debuggable the user can see WHY each guess fired
"""
algorithm: str
confidence: Confidence
reason: str
# =============================================================================
# Prefix rules — strongest signal we have
# =============================================================================
# Modern hashes use PHC-style strings: a leading `$` marker tells you
# exactly what algorithm produced the hash. When we see one of these
# prefixes we report HIGH confidence. The third element of each tuple
# is a short note we include in the reason field
#
# Order matters when prefixes overlap. We list more specific prefixes
# FIRST so they match before generic ones (e.g. `$argon2id$` before
# `$argon2$` would matter if both existed in the table)
PREFIX_RULES: list[tuple[str, str, str]] = [
# Argon2 family — won the 2015 Password Hashing Competition
("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
("$argon2d$", "Argon2d", "PHC string, GPU-resistant variant"),
# bcrypt and its many variants — workhorse for the past 15 years
("$2y$", "bcrypt", "bcrypt PHC string, 2y variant (PHP)"),
("$2b$", "bcrypt", "bcrypt PHC string, 2b variant (current)"),
("$2a$", "bcrypt", "bcrypt PHC string, 2a variant (legacy)"),
("$2x$", "bcrypt", "bcrypt PHC string, 2x variant (legacy fix)"),
# Unix crypt(3) family — what /etc/shadow uses on Linux
("$6$", "SHA-512 crypt", "Unix crypt(3) using SHA-512 (default on Linux)"),
("$5$", "SHA-256 crypt", "Unix crypt(3) using SHA-256"),
("$1$", "MD5 crypt", "Unix crypt(3) using MD5 (legacy, weak)"),
# Apache htpasswd MD5 variant — same MD5 family as $1$ above but
# with Apache's own salt-handling tweak. This is the format that
# `htpasswd -m` emits by default, which makes it FAR more common
# in the wild than the Unix $1$ form (every basic-auth tutorial
# ends with one of these in a .htpasswd file)
("$apr1$", "Apache MD5-crypt", "Apache htpasswd MD5 variant (`htpasswd -m`)"),
# yescrypt — newer Linux default in some distributions
("$y$", "yescrypt", "PHC string, modern Linux crypt successor"),
# phpass — used by WordPress, phpBB, and other PHP apps
("$P$", "phpass", "WordPress / phpBB password hash"),
("$H$", "phpass", "phpBB-style phpass variant"),
# Drupal 7
("$S$", "Drupal 7 (SHA-512)", "Drupal 7 PHC-style hash"),
# scrypt as some implementations encode it
("$7$", "scrypt", "scrypt PHC-style hash"),
# Django's default — recognizable by the algorithm name in the prefix
("pbkdf2_sha256$", "Django PBKDF2-SHA256", "Django default password hash"),
("pbkdf2_sha1$", "Django PBKDF2-SHA1", "Django legacy password hash"),
("bcrypt_sha256$", "Django bcrypt-SHA256", "Django bcrypt wrapper"),
("argon2$", "Django Argon2", "Django Argon2 wrapper"),
# LDAP password schemes — base64 payload after the marker
("{SSHA}", "LDAP SSHA", "LDAP salted SHA-1 (base64 payload)"),
("{SHA}", "LDAP SHA", "LDAP SHA-1 (base64 payload)"),
("{SMD5}", "LDAP SMD5", "LDAP salted MD5 (base64 payload)"),
("{MD5}", "LDAP MD5", "LDAP MD5 (base64 payload)"),
("{CRYPT}", "LDAP CRYPT", "LDAP wrapping a crypt(3) hash"),
]
# =============================================================================
# Length-and-hex rules — fallback when no prefix matched
# =============================================================================
# Raw hash output is always the same length, so a string of N hex chars
# narrows down the algorithm. The list of algorithms for each length is
# sorted by REAL-WORLD prevalence. The first item gets MEDIUM confidence
# (the "most likely default"), the rest LOW (still possible)
# Hex chars are 0-9 plus a-f (or A-F if uppercase)
HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
# Uppercase-only hex charset — some formats (MySQL5) ONLY ever emit
# uppercase hex because they print via the `%02X` C format specifier,
# so checking membership in this tighter charset lets us reject
# lowercase strings as false-positives instead of confidently saying
# "yes that is MySQL5" to an obviously hand-edited input
_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
# Length-in-hex-chars → list of algorithms, ordered by commonality
HEX_LENGTH_RULES: dict[int, list[str]] = {
# 16 hex chars = 8 bytes = 64 bits. This is the pre-MySQL-4.1
# OLD_PASSWORD() output — still produced today by MySQL's
# OLD_PASSWORD() SQL function for legacy compatibility, and
# still appears in CTFs and old MySQL breach dumps. The only
# other thing that produces 16 hex chars in a security context
# is a 64-bit CRC, which is rare enough that MySQL323 outranks it
16: ["MySQL323", "CRC-64"],
# 32 hex chars = 16 bytes = 128 bits
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
# 40 hex chars = 20 bytes = 160 bits
40: ["SHA-1", "RIPEMD-160"],
# 48 hex chars = 24 bytes = 192 bits
48: ["Tiger-192"],
# 56 hex chars = 28 bytes = 224 bits
56: ["SHA-224", "SHA3-224"],
# 64 hex chars = 32 bytes = 256 bits
64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
# 80 hex chars = 40 bytes = 320 bits (uncommon)
80: ["RIPEMD-320"],
# 96 hex chars = 48 bytes = 384 bits
96: ["SHA-384", "SHA3-384"],
# 128 hex chars = 64 bytes = 512 bits
128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
}
# =============================================================================
# Helpers
# =============================================================================
def _is_hex(text: str) -> bool:
"""
Return True iff every character in text is a hex digit and text is non-empty
A hash like "5f4dcc..." passes; an empty string or anything with
a non-hex character fails. We use the HEX_CHARSET frozenset for
membership tests because `c in frozenset` is O(1) lookup, faster
than `c in "0123456789abcdef..."` for big inputs
"""
return bool(text) and all(c in HEX_CHARSET for c in text)
# MySQL5 layout: `*` followed by 40 uppercase hex chars.
# Pulling both numbers out as named constants keeps the helper below
# from reading like sprinkled-in magic numbers — `_MYSQL5_TOTAL_LENGTH`
# tells the reader WHY the helper compares to 41 (40 hex chars + the
# leading `*`) instead of leaving 41 unexplained
_MYSQL5_HEX_BODY_LENGTH = 40
_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
def _is_mysql5(text: str) -> bool:
"""
Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars
MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
a leading `*`. Real MySQL5 output uses the `%02X` C format
specifier, which is uppercase-only. We reject lowercase here so
we do not return a confident HIGH-confidence "MySQL5" verdict on
a hand-edited or mistyped string better to fall through to
no-match than to lie with conviction
NOTE: we cannot just call `body.isupper()` to enforce the case.
Python's `str.isupper` returns False when a string contains NO
cased characters at all, which would wrongly reject an all-digit
hex body like "0123456789ABCDEF..." with no letters in it.
Checking membership in an uppercase-only charset is the test
that actually matches the spec
"""
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
return False
body = text[1 :]
return all(c in _HEX_UPPER_CHARSET for c in body)
# Traditional DES crypt — legacy /etc/passwd hashes from pre-shadow
# Unix systems. They have no prefix at all: just 13 characters drawn
# from a specific 64-char alphabet (the same alphabet used by all
# crypt(3) variants for their base64-style output). We pull both the
# charset and the expected length out as named constants so the helper
# below reads like its meaning instead of like sprinkled magic numbers
_DESCRYPT_CHARSET: frozenset[str] = frozenset(
"./0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
)
_DESCRYPT_TOTAL_LENGTH = 13
def _is_descrypt(text: str) -> bool:
"""
Return True for traditional 13-char DES crypt (legacy /etc/passwd)
No prefix at all just 13 characters drawn from `./0-9A-Za-z`.
Old enough that you mostly see it in retro CTFs now, but real
enough that hashcat still ships a mode (1500) for cracking it.
We report MEDIUM (not HIGH) confidence on a match because a 13-char
string in that charset CAN be other things (some session IDs,
encoded values), and a beginner deserves an honest confidence
level rather than a false-positive at HIGH
"""
return (
len(text) == _DESCRYPT_TOTAL_LENGTH
and all(c in _DESCRYPT_CHARSET for c in text)
)
# =============================================================================
# The actual identifier
# =============================================================================
# pylint flags this function for having many return statements and many
# branches. That is the cost of writing the function as six numbered,
# linear steps — each step gets its own short block and returns the
# moment it finds a match, which is the structure that maps cleanly
# onto the docstring. Refactoring into helper functions would obscure
# the pedagogical line-by-line flow, so we accept the extra returns
# here as a deliberate teaching choice and silence the two warnings
# pylint: disable=too-many-return-statements,too-many-branches
def identify(raw_input: str) -> list[HashCandidate]:
"""
Return ranked candidates for what algorithm produced `raw_input`
Algorithm
---------
Whitespace is trimmed from `raw_input` first. Then the six
matching steps below run in order each step's labeled comment
(`# ----- Step N: ... -----`) inside the function body matches
the number in this list, so the docstring and the code stay in
lockstep:
1. Walk the PREFIX_RULES table. The first prefix that matches
wins (HIGH confidence). Each table row is unique enough that
at most one entry can match a given input
2. Check special non-PHC formats in order NetNTLMv1/v2
challenge-response records, MySQL5 (`*<40 hex>`), and the
legacy 13-char DES crypt. Each has a distinctive shape that
takes precedence over generic length-based matching
3. If the input is pure hex, look up its length in
HEX_LENGTH_RULES and report each candidate. The first entry
at each length gets MEDIUM confidence (the modern default);
the rest LOW
4. If the input has a `$<algo>$...` shape but no PREFIX_RULES
row matched, fall back to a generic PHC string match LOW
confidence because we only matched the shape, not a specific
rule
5. If the input looks like a JWT (`eyJ...`) or a base64 blob
(contains `+`, `/`, or `=`), say so with LOW confidence
these are not hashes, but a beginner deserves to know what
they pasted instead of a silent no-match
6. If nothing matched at all, return an empty list
Parameters
----------
raw_input
The hash string to identify. Whitespace is trimmed before
analysis but the rest is treated literally case matters
because some algorithms use uppercase output
Returns
-------
list[HashCandidate]
Possibly empty. When non-empty, candidates are ordered by
confidence (high before medium before low) and within each
confidence band by likelihood
"""
# Trim whitespace — copy-pasted hashes often arrive with trailing
# newlines or leading spaces. We do not modify case because some
# formats are case-sensitive
text = raw_input.strip()
if not text:
return []
# ----- Step 1: prefix rules -----
# Walk the table top-to-bottom. Entries in PREFIX_RULES are crafted
# so that no two of them can EVER both match the same input — the
# first prefix that matches is THE match. So we return it right
# away instead of continuing the loop. HIGH confidence is the
# right label because a known prefix is a definitive self-
# identification: the hash literally announces what algorithm
# produced it, we are not guessing
for prefix, algorithm, note in PREFIX_RULES:
if text.startswith(prefix):
return [
HashCandidate(
algorithm = algorithm,
confidence = "high",
reason = f"prefix `{prefix}` — {note}",
)
]
# ----- Step 2: special non-PHC formats -----
# Formats that do not fit the `$algo$...` PHC mold but still have
# unmistakable shapes. We check each in turn before falling through
# to the generic length-based step
# NetNTLMv1 / NetNTLMv2 — the dominant outputs of AD pentest
# tools like Responder, secretsdump, ntlmrelayx, and Inveigh.
# They are NOT hashes in the "irreversible function" sense —
# they are challenge-response records — but every beginner who
# runs their first AD lab pastes one of these into a hash
# identifier. The literal `::` (an empty second field, where the
# LM-hash used to live) is the structural giveaway. We test v2
# FIRST because v2's hmac field at index 4 is 32 hex chars while
# v1's nthash at the same index is 48 — so if we tested v1 first
# we would still match v2 inputs at parts[3] (also 48 in v2's
# case for the challenge length in some encodings)
if "::" in text and text.count(":") >= 4:
parts = text.split(":")
# NetNTLMv2 layout:
# user :: domain : challenge : hmac(32 hex) : blob(>=32 hex)
# The hmac at parts[4] is exactly 32 hex chars — that single
# property is enough to disambiguate v2 from v1
if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
return [
HashCandidate(
algorithm = "NetNTLMv2",
confidence = "high",
reason =
"user::domain:challenge:hmac(32 hex):blob shape",
)
]
# NetNTLMv1 layout:
# user :: domain : lmhash(48 hex) : nthash(48 hex) : challenge
# The lmhash at parts[3] is exactly 48 hex chars
if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
return [
HashCandidate(
algorithm = "NetNTLMv1",
confidence = "high",
reason =
"user::domain:lm(48 hex):nt(48 hex):challenge shape",
)
]
# MySQL5 — literal `*` + 40 uppercase hex chars
if _is_mysql5(text):
return [
HashCandidate(
algorithm = "MySQL5",
confidence = "high",
reason =
"starts with `*` followed by 40 uppercase hex chars",
)
]
# Traditional 13-char DES crypt — legacy /etc/passwd format
# with no prefix at all. We report MEDIUM (not HIGH) because the
# 13-char `./0-9A-Za-z` shape isn't fully unique to DES crypt
if _is_descrypt(text):
return [
HashCandidate(
algorithm = "DES crypt",
confidence = "medium",
reason =
"13 chars in `./0-9A-Za-z` — legacy /etc/passwd format",
)
]
# ----- Step 3: length + hex charset -----
if _is_hex(text):
algorithms = HEX_LENGTH_RULES.get(len(text), [])
candidates: list[HashCandidate] = []
for index, algorithm in enumerate(algorithms):
# The first listed algorithm for each length is the modern
# default and gets MEDIUM confidence. The rest are still
# possible but less common in 2026 — LOW confidence
confidence: Confidence = "medium" if index == 0 else "low"
label = (
"most likely candidate at this length"
if index == 0 else "also possible at this length"
)
candidates.append(
HashCandidate(
algorithm = algorithm,
confidence = confidence,
reason = f"{len(text)} hex chars — {label}",
)
)
return candidates
# ----- Step 4: generic PHC string fallback -----
# If the input starts with `$<name>$...` and <name> looks like a
# plausible algorithm identifier, it is almost certainly a PHC
# string from an algorithm we do not have a specific rule for.
# Passlib alone ships ~30 PHC encodings; our PREFIX_RULES table
# covers only the most common 20-or-so. Reporting it as a generic
# PHC string with the extracted algorithm name is strictly better
# than silence, and the LOW confidence label is honest about not
# having a specific rule to confirm the algorithm
if text.startswith("$"):
# Drop the leading `$`, then look for the second `$` that
# closes the algorithm-name field. If there's no second `$`,
# this isn't a PHC string at all, just a string that happens
# to start with `$` — fall through
rest = text[1 :]
if "$" in rest:
algo_name = rest.split("$", 1)[0]
# The PHC spec restricts algorithm IDs to alphanumeric
# plus `-` and `_`. We accept exactly that charset and
# reject anything weirder — anything containing spaces,
# punctuation, etc. is almost certainly not a real PHC
# string and we'd rather stay silent than make up a name
if algo_name and all(c.isalnum() or c in "-_"
for c in algo_name):
return [
HashCandidate(
algorithm = f"PHC string ({algo_name})",
confidence = "low",
reason =
f"`${algo_name}$...` shape — generic PHC, no specific rule",
)
]
# ----- Step 5: not-a-hash shape hints -----
# Beginners often paste JWTs or base64 blobs into a hash
# identifier — they are auth tokens, not hashes, but the user
# may not know that yet. Returning a short shape-hint at LOW
# confidence is more educational than a silent no-match: the
# user finds out WHAT they pasted and that this tool is not
# going to crack it for them
if text.startswith("eyJ"):
# JWTs always begin with `eyJ` because their JSON header
# `{"alg":...}` base64-encodes to a string starting with
# those three characters (`{"` → base64 → `eyI`/`eyJ`)
return [
HashCandidate(
algorithm = "JWT (not a hash)",
confidence = "low",
reason =
"leading `eyJ` is base64 of `{\"` — JWT, not a hash",
)
]
if any(c in text for c in "+/=") and len(text) > 8:
# Hex hashes NEVER contain `+`, `/`, or `=`. If our input
# does, it is almost certainly base64-encoded data of some
# kind. The `> 8` length floor avoids flagging short
# strings like "a+b=c" as base64
return [
HashCandidate(
algorithm = "Base64 blob (not a hash)",
confidence = "low",
reason = "contains base64-only chars (`+`, `/`, `=`)",
)
]
# ----- Step 6: nothing matched -----
# If we got here, the input has no known prefix, no special
# shape, no hex length we recognize, no PHC-string shape, and
# no obvious not-a-hash shape either. Returning an empty list
# is better than returning bad guesses — the CLI prints a
# clean "could not identify" message instead
return []
# =============================================================================
# CLI — argparse + a rich table
# =============================================================================
def _build_argument_parser() -> argparse.ArgumentParser:
"""
Construct the argparse parser used by main()
Pulled out into its own function so tests can call it without
actually running the CLI. Each argument is documented inline
"""
parser = argparse.ArgumentParser(
prog = "hashid",
description = (
"Identify a hash string by prefix, length, and charset. "
"Returns ranked candidates with confidence and reasoning."
),
)
parser.add_argument(
"hash",
help =
"The hash string to identify (wrap in single quotes if it contains $).",
)
parser.add_argument(
"--top",
"-n",
type = int,
default = 5,
help = "Show at most this many candidates (default: 5).",
)
return parser
def _render_table(
raw_input: str,
candidates: list[HashCandidate],
console: Console,
) -> None:
"""
Print a rich Table showing the identified candidates
A Table is a bordered grid. We give it three columns: algorithm,
confidence (color-coded), and reason
"""
table = Table(
title = f"Candidates for: {raw_input.strip()}",
title_style = "bold cyan",
show_lines = False,
)
table.add_column("algorithm", style = "bold white", no_wrap = True)
table.add_column("confidence", no_wrap = True)
table.add_column("reason", style = "dim")
# Color confidence levels so the eye can scan them quickly.
# green → yellow → cyan is a gradient that reads "strong, weaker,
# weakest" without ever colliding with red, which is reserved for
# the no-match error message printed elsewhere in main(). Painting
# "low" in red would make three weak-but-valid guesses look like
# three errors at a glance — broken visual hierarchy
confidence_colors: dict[Confidence,
str] = {
"high": "green",
"medium": "yellow",
"low": "cyan",
}
for candidate in candidates:
color = confidence_colors[candidate.confidence]
table.add_row(
candidate.algorithm,
f"[{color}]{candidate.confidence}[/{color}]",
candidate.reason,
)
console.print(table)
def main() -> int:
"""
CLI entry point return an exit code (0 = ok, 1 = nothing found)
Wrapping the body in a function (instead of running at module
import time) means the test suite can import this module without
accidentally executing the CLI
"""
parser = _build_argument_parser()
args = parser.parse_args()
console = Console()
candidates = identify(args.hash)
if not candidates:
# `[red]...[/red]` is rich's inline color markup
console.print(
"[red]No identification possible.[/red] "
"Input did not match any known prefix, special format, "
"or hex length."
)
return 1
# Trim to the requested top-N
trimmed = candidates[: args.top]
_render_table(args.hash, trimmed, console)
# Helpful nudge — point the user at the cracker once they know
# what algorithm to target. Foundations tier is meant to chain
if trimmed[0].confidence == "high":
console.print(
"\n[dim]Next step: try the matching cracker mode "
"(see ../../beginner/hash-cracker).[/dim]"
)
return 0
# Standard "if invoked directly as a script" guard — lets the file be
# imported by tests without firing main()
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,177 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# install.sh
#
# Zero-friction install script. Anyone who clones this project should
# be able to run `./install.sh` and end up with a working setup,
# regardless of whether they have uv or just installed yet.
#
# What this script does, in order:
# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
# 2. Installs uv if it is missing (uv is the Python package manager we use)
# 3. Installs just if it is missing (just is the command runner)
# 4. Calls `just setup` to create the venv and install dependencies
# 5. Prints next steps
#
# Run with: ./install.sh
# Or: bash install.sh
# -----------------------------------------------------------------------------
# Bash safety flags — fail fast and loud
# -----------------------------------------------------------------------------
# -e : exit immediately if any command returns a non-zero (error) status
# -u : treat unset variables as an error
# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
set -euo pipefail
# -----------------------------------------------------------------------------
# Color helpers — pretty terminal output without external dependencies
# -----------------------------------------------------------------------------
# These are ANSI escape codes. \033 is the ESC character; the bracketed
# digits tell the terminal which color to switch to. NC = "no color",
# resets back to whatever the terminal had before.
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
# Tiny helper functions so we don't repeat the format strings everywhere.
# `>&2` redirects to stderr (where errors belong) instead of stdout.
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
success() { echo -e "${GREEN}[OK]${NC} $1"; }
# -----------------------------------------------------------------------------
# Step 1 — Confirm Python 3.13+ is on the system
# -----------------------------------------------------------------------------
check_python() {
info "Checking for Python 3.13+..."
# `command -v <name>` prints the path of <name> if it exists, nothing
# otherwise. `&>/dev/null` discards both stdout and stderr — we only
# care about the exit code (0 = found, non-zero = missing).
if ! command -v python3 &>/dev/null; then
error "python3 not found. Please install Python 3.13 or newer."
error " macOS: brew install python@3.13"
error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
error " Windows: download from python.org"
exit 1
fi
# Read the version from Python itself — the most reliable source.
# `local` makes these variables function-scoped instead of leaking
# into the rest of the script.
local version
version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
local major minor
# `cut -d. -f1` splits the string on `.` and takes field 1.
major=$(echo "$version" | cut -d. -f1)
minor=$(echo "$version" | cut -d. -f2)
# `(( ... ))` is bash arithmetic context — lets us write `<` `>` etc.
# The compound condition fails if major < 3, OR if major == 3 and
# minor < 13. So Python 3.12 fails, 3.13 passes, 4.0 passes.
if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
error "Python 3.13+ is required, found Python $version"
exit 1
fi
success "Python $version detected"
}
# -----------------------------------------------------------------------------
# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
# -----------------------------------------------------------------------------
install_uv() {
# Already installed? Print confirmation and bail out of this function.
# `return 0` exits the function with success — the caller continues.
if command -v uv &>/dev/null; then
success "uv already installed ($(uv --version))"
return 0
fi
info "Installing uv (Python package manager)..."
# Pipe the official install script into sh. `-LsSf`:
# -L : follow redirects
# -s : silent (no progress meter)
# -S : show errors even when silent
# -f : fail on HTTP errors instead of writing them to disk
curl -LsSf https://astral.sh/uv/install.sh | sh
# The installer drops uv into ~/.local/bin or ~/.cargo/bin.
# Add both to PATH for the rest of THIS script's run.
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
# If we still cannot find uv, something went wrong.
if ! command -v uv &>/dev/null; then
error "uv install completed but \`uv\` is still not on PATH."
error "Restart your shell and re-run this script, or add uv to PATH manually."
exit 1
fi
success "uv installed"
}
# -----------------------------------------------------------------------------
# Step 3 — Install just if missing (https://github.com/casey/just)
# -----------------------------------------------------------------------------
install_just() {
if command -v just &>/dev/null; then
success "just already installed ($(just --version))"
return 0
fi
info "Installing just (command runner)..."
# Make sure the install destination exists first.
mkdir -p "$HOME/.local/bin"
# Official install script. `--to <dir>` controls where the binary lands.
# `--proto '=https'` rejects any protocol but HTTPS.
# `--tlsv1.2` insists on a modern TLS version.
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
| bash -s -- --to "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
success "just installed"
}
# -----------------------------------------------------------------------------
# Step 4 — Use just to set up the project (venv + dependencies)
# -----------------------------------------------------------------------------
project_setup() {
info "Running 'just setup'..."
# Calling our own justfile recipe — single source of truth for setup.
just setup
}
# -----------------------------------------------------------------------------
# Main — orchestrate the steps and print next instructions
# -----------------------------------------------------------------------------
main() {
echo ""
echo "================================================"
echo " hash-identifier — install"
echo "================================================"
echo ""
check_python
install_uv
install_just
project_setup
echo ""
echo "================================================"
success "Install complete!"
echo "================================================"
echo ""
echo "Next steps:"
echo " just run -- 5f4dcc3b5aa765d61d8327deb882cf99 # identify a hash"
echo " just run -- --help # see options"
echo " just test # run the test suite"
echo ""
}
# `"$@"` forwards every argument the script was called with into main.
# We do not use any args today, but keeping this pattern means future
# flags can be added without changing the bottom of the file.
main "$@"

View File

@ -0,0 +1,189 @@
# ©AngelaMos | 2026
# justfile
#
# A "justfile" is a list of commands you can run with `just <name>`.
# Think of it as a project's command center — instead of remembering
# `uv run pytest -v`, you just type `just test`.
#
# Why use just instead of make? It is simpler, cross-platform,
# and the syntax is easier to read.
#
# Show all commands: `just`
# Run a command: `just <name>` (e.g. `just setup`)
# Export every variable defined here as an environment variable for
# the recipes that just runs.
set export
# On Linux/macOS, run recipe lines with bash and -u (error on
# unset variables) and -c (read commands from a string).
set shell := ["bash", "-uc"]
# On Windows, fall back to PowerShell with no logo / non-interactive.
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Make recipe arguments available to shebang scripts as `$1`, `$2`,
# `$@`, and `$#`. Without this, shebang recipes only see args via the
# textual `{{args}}` substitution, which is unsafe for inputs that
# contain `$` (the substituted text gets re-expanded by bash).
set positional-arguments
# Show available commands when you run `just` with no args.
default:
@just --list --unsorted
# =============================================================================
# Setup Commands
# =============================================================================
# One-shot first-time setup — creates .venv and installs everything
[group('setup')]
setup:
@echo "Creating virtual environment with uv..."
# uv venv creates a .venv/ folder using the system Python that
# matches `requires-python` in pyproject.toml.
# --allow-existing makes the recipe safe to re-run after a partial install.
uv venv --allow-existing
@echo ""
@echo "Installing dependencies (including dev tools)..."
# --all-extras pulls in every optional-dependencies group (just `dev`
# for us). Without this, dev tools like pytest do not get installed.
uv sync --all-extras
@echo ""
@echo "✓ Setup complete!"
@echo ""
@echo "Try it out:"
@echo " just run 5f4dcc3b5aa765d61d8327deb882cf99"
@echo " just test"
# Install runtime dependencies only (no dev tools)
[group('setup')]
install:
uv sync
# Install runtime + dev dependencies
[group('setup')]
install-dev:
uv sync --all-extras
# =============================================================================
# Testing & Quality Checks
# =============================================================================
# Run the test suite
[group('test')]
test:
@echo "Running tests..."
# `uv run` runs a command inside the project's virtual environment
# without us having to `source .venv/bin/activate` first.
uv run pytest
# Run all linters in sequence (ruff + pylint + mypy)
[group('test')]
lint:
@echo "=== Ruff ==="
uv run ruff check hash_identifier.py test_hash_identifier.py
@echo ""
@echo "=== Pylint ==="
uv run pylint hash_identifier.py
@echo ""
@echo "=== Mypy ==="
uv run mypy hash_identifier.py
@echo ""
@echo "✓ All linters passed"
# Auto-format every Python file with yapf
[group('test')]
format:
@echo "Formatting code with yapf..."
# -i = in place. Edits the files directly instead of printing diffs.
uv run yapf -i hash_identifier.py test_hash_identifier.py
@echo "✓ Code formatted"
# Auto-fix what ruff can fix on its own (unused imports, etc.)
[group('test')]
fix:
uv run ruff check hash_identifier.py test_hash_identifier.py --fix
# =============================================================================
# Run the CLI
# =============================================================================
# Run hashid — pass the hash to identify after `--`
# Example: just run 5f4dcc3b5aa765d61d8327deb882cf99
# just run '$2b$12$abcdefghijklmnopqrstuv'
# [no-exit-message] silences just's "Recipe `run` failed with exit
# code N" line when hashid exits non-zero. hashid uses exit 1 to mean
# "no candidates matched" — a legitimate result, not a failure. The
# exit code itself is still propagated to whoever invoked just, so
# CI use cases keep working.
[group('run')]
[no-exit-message]
run *args:
#!/usr/bin/env bash
# If no args were given, print a friendly usage block and exit
# cleanly. Without this, `just run` (no args) would invoke
# `uv run hashid` with nothing, argparse would exit 2, and just
# would tack a "Recipe `run` failed" error on top — confusing for
# someone just trying to see how the command works.
if [ $# -eq 0 ]; then
cat <<'EOF'
Usage: just run -- <hash>
Examples:
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
just run -- '$2b$12$abcdefghijklmnopqrstuv'
See all options:
just run -- --help
EOF
exit 0
fi
# Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
# TEXTUAL substitution done by just before bash runs the script,
# so a hash like $2b$12$abc would get its $2b and $12 expanded as
# bash variables and arrive at hashid mangled. "$@" hands bash the
# original argv unchanged.
uv run hashid "$@"
# =============================================================================
# Utility / Cleanup
# =============================================================================
# Delete the venv and all build / cache artifacts
[group('utility')]
clean:
rm -rf .venv
rm -rf __pycache__
rm -rf .mypy_cache .ruff_cache .pytest_cache
rm -rf *.egg-info build dist
rm -rf .coverage htmlcov
@echo "✓ Cleaned"
# Lock the exact dependency versions to uv.lock
[group('utility')]
lock:
uv lock
# Upgrade all dependencies to latest allowed versions
[group('utility')]
update:
uv lock --upgrade
uv sync --all-extras
# =============================================================================
# CI Pipeline
# =============================================================================
# Full pipeline: setup + lint + test. For first-time runs.
[group('ci')]
all: setup lint test
@echo ""
@echo "✓ Setup, lint, and tests all passed"
# Lint + test only — what CI runs after dependencies are installed
[group('ci')]
ci: lint test
@echo "✓ CI checks passed"

View File

@ -0,0 +1,158 @@
# Hash Identifier
## What this is
A small Python program that looks at a string of weird-looking characters and tells you what kind of cryptographic hash it probably is. You give it something like this:
```
5f4dcc3b5aa765d61d8327deb882cf99
```
and it tells you "that's an MD5 hash" with a reason for why it thinks so.
That's the whole job. It does not crack the hash. It does not turn the hash back into a password. It just answers the question "what flavor of hash is this?" — which is the question you have to answer *first* before any other tool will help you.
## Why anyone needs this
The first thing that happens when a real attack succeeds is that the attacker walks out with a database dump full of password hashes. The hashes look like nonsense, but they are not random — every hash carries clues about how it was made. Once you know the algorithm (MD5, SHA-256, bcrypt, Argon2, whatever) you can hand the hash to a cracking tool like [hashcat](https://hashcat.net) or [John the Ripper](https://www.openwall.com/john/) and start trying to recover the original password.
But here's the thing: hashcat needs you to tell it the algorithm. It has [over 400 hash modes](https://hashcat.net/wiki/doku.php?id=example_hashes), each with a different number. Mode 0 is MD5. Mode 100 is SHA-1. Mode 3200 is bcrypt. If you pick the wrong mode, hashcat will sit there forever and find nothing. So before cracking, you identify. That's this tool.
**Real-world moments where you'd reach for this:**
- A pentester finds a dump file on a compromised server full of strings like `$2b$12$EixZaYVK1...` and needs to know what to feed hashcat.
- A CTF challenge hands you a hash and zero hints about what algorithm made it.
- You're reading a breach writeup and want to understand whether the leaked passwords were stored as fast unsalted MD5 (a disaster) or slow salted bcrypt (much better).
- The [2012 LinkedIn breach](https://en.wikipedia.org/wiki/2012_LinkedIn_hack) leaked 6.5 million unsalted SHA-1 hashes. The first thing any researcher had to do before doing *anything* was confirm "yes, these are SHA-1." Forty-character hex strings. Easy. The tool you're about to read would have told them that in milliseconds.
## What you will learn
**Security ideas:**
- What a cryptographic hash actually is (a function that turns any input into a fixed-length jumble that you can't reverse).
- The three signals every hash leaks about itself: its **prefix**, its **length**, and its **character set**.
- Why modern password hashes (`$2b$...`, `$argon2id$...`) *announce themselves* on purpose, and why old fast hashes (MD5, SHA-1) don't.
- The difference between a fast hash (made for speed, terrible for passwords) and a slow hash (made on purpose to resist cracking).
- Why you can never recover the password from a hash, only *guess* the password and check if its hash matches.
**Python ideas (assuming this is your first time):**
- How to read a Python file from top to bottom and understand what it's doing.
- What `import` does and where the standard library ends and third-party packages begin.
- Functions, type hints (`str`, `int`, `list[str]`), and what `-> bool` means after a function signature.
- `@dataclass` — a shortcut for making little record-like objects.
- `frozenset`, `dict`, `list`, `tuple` — the core Python containers and when to pick which.
- How a command-line tool actually starts running (the `if __name__ == "__main__"` line at the bottom).
- How a test file works and why every function in the main code has tests next to it.
**Tools you'll touch:**
- [`uv`](https://github.com/astral-sh/uv) — the modern Python package manager. Like `pip` but ~100× faster.
- [`just`](https://github.com/casey/just) — a command runner. Instead of memorizing long commands, you type `just test` or `just run`.
- [`rich`](https://github.com/Textualize/rich) — the library that prints the pretty colored table at the end.
- [`pytest`](https://pytest.org) — Python's test runner.
- [`ruff`](https://github.com/astral-sh/ruff) + [`mypy`](https://mypy-lang.org) + [`pylint`](https://pylint.org) — the linters that yell at you if your code is wrong, slow, or sloppy.
## What you need before starting
**Knowledge you should have:**
- You've used a terminal at least once (you know what `cd` and `ls` do).
- You vaguely know that "a hash" is a one-way function. If not, [01-CONCEPTS.md](./01-CONCEPTS.md) will get you there in 10 minutes.
- You can read code, or at least you're willing to. We will explain every Python feature as we hit it.
**Knowledge you do NOT need:**
- Any prior Python experience. The whole point of the **foundations** tier is that you start here.
- Any prior cybersecurity experience.
- Any math beyond "counting." There is no math in this project. Cryptography uses math under the hood, but identifying a hash by its shape doesn't.
**Software you need installed:**
- Python 3.14 or newer.
- The `uv` tool (the install script will get this for you if you don't have it).
- The `just` tool (also handled by the install script).
- A terminal. Any terminal. On Mac it's Terminal.app or iTerm2; on Linux it's whatever your distro shipped; on Windows it's WSL2 + Ubuntu (we strongly recommend WSL2 instead of native Windows).
You do *not* need an IDE — a text editor is fine. We recommend [VS Code](https://code.visualstudio.com) with the Python extension, but `nano`, `vim`, `helix`, or whatever you already use will work.
## Quick start
From inside `PROJECTS/foundations/hash-identifier/`:
```bash
./install.sh
```
That script will install `uv` and `just` if missing, create a virtual environment (an isolated Python sandbox just for this project), install all the dependencies, and verify the tests pass. It prints what it's doing as it goes — read the output, don't just close the terminal.
Then try the tool:
```bash
just run -- 5f4dcc3b5aa765d61d8327deb882cf99
```
You should see a colored table identifying that string as MD5 (with NTLM, MD4, and RIPEMD-128 as less-likely alternatives — all four produce 32 hex characters, so length alone can't separate them).
Try a few more:
```bash
# bcrypt — modern password hash, announces itself with the $2b$ prefix
just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G'
# SHA-256 — 64 hex characters
just run -- e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
# A JWT (this is NOT a hash, but the tool will say so politely)
just run -- eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U
# Total garbage — the tool will say "no idea" rather than guess
just run -- helloworld
```
**Note on quoting:** when a hash starts with `$`, you must wrap it in single quotes (`'$2b$...'`). Without quotes, your shell will try to expand `$2` as a shell variable and chop the hash up. This is a shell thing, not a Python thing — every Unix shell does it.
## Project layout
```
hash-identifier/
├── hash_identifier.py the whole tool — one file, ~680 lines
├── test_hash_identifier.py tests for every behavior the tool claims to have
├── install.sh one-shot setup script
├── justfile shortcuts for run / test / lint / format
├── pyproject.toml project config: dependencies, linter rules, etc.
├── README.md short pointer to this learn/ folder
├── learn/ you are here
│ ├── 00-OVERVIEW.md quick start (this file)
│ ├── 01-CONCEPTS.md what hashes are and how identification works
│ ├── 02-ARCHITECTURE.md how the code is structured, with diagrams
│ ├── 03-IMPLEMENTATION.md line-by-line walkthrough of the code
│ └── 04-CHALLENGES.md extension ideas if you want to go further
└── assets/ images, screenshots
```
One file of code is on purpose. The foundations tier is meant to be readable in one sitting. The intermediate and advanced tiers split into many files; foundations does not.
## Where to go next
1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** — understand *what* a hash is, *why* identification is the first move, and *how* prefix/length/charset clues actually work. Read this even if you think you know it; the framing matters.
2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** — see the six-step pipeline the tool uses to make a decision, drawn out as a flow diagram.
3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** — read `hash_identifier.py` with us, line by line. Every Python feature gets explained when it first appears.
4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** — extensions you can try on your own once you've absorbed the rest.
## Common problems
**"command not found: just"**
The install script should set this up, but if it didn't: `curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash`. Then close and reopen your terminal so it sees the new tool.
**"command not found: uv"**
Same idea: `curl -LsSf https://astral.sh/uv/install.sh | sh`, then reopen your terminal.
**`just run -- $2b$12$...` chops the hash up**
You forgot the single quotes around the hash. Re-run with `just run -- '$2b$12$...'`.
**"ModuleNotFoundError: No module named 'rich'"**
You ran `python hash_identifier.py` directly instead of `just run`. The `just run` recipe uses the virtual environment that has `rich` installed. Either use `just run`, or activate the venv first: `source .venv/bin/activate`, *then* `python hash_identifier.py <hash>`.
**Tests fail right after install**
Tests should pass on a fresh `./install.sh`. If they don't, you probably have an older Python (run `python --version`; you need 3.14+). On Ubuntu, install it via [`deadsnakes`](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa); on Mac use [Homebrew](https://brew.sh).

View File

@ -0,0 +1,210 @@
# Concepts
This page builds up the ideas you need to understand the code. We start from "what is a hash" and end at "here is exactly why a 32-character hex string is probably MD5." No prior security knowledge required.
## 1. What is a hash?
A **cryptographic hash function** takes any input — a single byte, a password, a 4GB video file — and produces a fixed-length output that looks like random garbage. Same input always gives the same output. Different inputs give different outputs. And critically: if you only have the output, you cannot work backwards to the input.
Picture it as a kitchen blender that only goes forwards:
```
"password" ─────► [ MD5 blender ] ─────► 5f4dcc3b5aa765d61d8327deb882cf99
"hello" ─────► [ MD5 blender ] ─────► 5d41402abc4b2a76b9719d911017c592
"hello!" ─────► [ MD5 blender ] ─────► d9014c4624844aa5bac314773d6b689a
└─ change ONE character → totally different output
```
A few properties that fall out of this:
- **Deterministic.** Same input, same output, every single time. This is the whole reason hashes are useful — you can store the hash of a password, and when someone logs in you re-hash what they typed and compare.
- **Fixed-length output.** Whether you hash `"a"` or the entire Bible, MD5 always produces 32 hex characters. SHA-256 always produces 64. This length is the first big clue we use to identify which algorithm was used.
- **One-way.** You can go from password → hash, but not from hash → password. There is no "un-hash" button. The only way to figure out which password produced a hash is to *guess passwords and hash them yourself* until you get a match. That guessing is what hashcat does.
- **Avalanche effect.** Change one letter of the input and the entire output changes. `"password"` and `"Password"` produce hashes that share zero characters.
If you want to play with this for yourself, in a Python REPL (`uv run python`):
```python
>>> import hashlib
>>> hashlib.md5(b"password").hexdigest()
'5f4dcc3b5aa765d61d8327deb882cf99'
>>> hashlib.md5(b"Password").hexdigest()
'dc647eb65e6711e155375218212b3964'
```
The `b"..."` syntax means "this is bytes, not text" — hash functions work on raw bytes, not characters. Don't worry about that distinction yet; just notice that one letter change produced a completely different hash.
## 2. Why hashes exist (the password storage problem)
Imagine you run a website. Users sign up with a password. The naive thing to do is store passwords in your database directly:
```
+----------+------------+
| username | password |
+----------+------------+
| alice | hunter2 |
| bob | letmein |
+----------+------------+
```
This is a catastrophe waiting to happen. The moment somebody breaches your database — and someone always eventually does — every user is exposed. Worse, since people [reuse passwords across sites](https://www.security.org/digital-safety/password-reuse-statistics/), the attacker now has the keys to Alice's bank, Alice's email, and Alice's Netflix.
The fix is to never store the password itself. Store its hash:
```
+----------+----------------------------------+
| username | password_hash |
+----------+----------------------------------+
| alice | 5f4dcc3b5aa765d61d8327deb882cf99 | ← MD5("hunter2")... if it were
| bob | 0d107d09f5bbe40cade3de5c71e9e9b7 | "password" (it isn't)
+----------+----------------------------------+
```
When Alice logs in, you hash what she just typed and compare it to the stored hash. If they match, she gets in. You never knew her password and you never have to.
The attacker who steals this database now has hashes, not passwords. They have to *guess* every password by hashing candidate passwords and comparing. With a fast hash like MD5, a modern GPU can try billions of guesses per second. With a slow hash like bcrypt, it can try only thousands. That's the entire reason modern systems use slow hashes — not because they're "more secure" in some abstract way, but because they make guessing *expensive*.
## 3. Real breaches that turned on hash identification
Identifying the hash format is the *first move* in every password-leak story. Until you know what algorithm made the hashes, nothing else happens.
**[2012 LinkedIn breach](https://en.wikipedia.org/wiki/2012_LinkedIn_hack)** — 6.5 million unsalted SHA-1 hashes leaked. Forty hex characters each. Researchers identified the format in seconds, then cracked 90% of the hashes in 72 hours because SHA-1 is fast and the passwords had no salt (more on salt below). LinkedIn later admitted [117 million more accounts](https://www.theguardian.com/technology/2016/may/19/linkedin-2012-data-breach-hack-117-million-email-password-details) were exposed than originally disclosed.
**[2013 Adobe breach](https://krebsonsecurity.com/2013/11/adobe-breach-impacted-at-least-38-million-users/)** — 153 million accounts, with passwords stored using 3DES encryption (not even hashing) and no unique salts. The lack of unique salts meant identical passwords produced identical ciphertext. Researchers could see, just by looking at the dump, which 1.9 million accounts shared the password `123456`.
**[2016 Yahoo breach](https://en.wikipedia.org/wiki/Yahoo!_data_breaches)** — 3 billion accounts. Some hashed with MD5 (catastrophic), some with bcrypt (much better). The mixed format made identification the first task before any defense analysis could proceed.
**[2019 Collection #1](https://www.troyhunt.com/the-773-million-record-collection-1-data-reach/)** — 773 million email/password pairs aggregated from past breaches. Researchers had to sort which hashes were which algorithm before anything else.
The pattern is the same every time: dump appears → identify the algorithm → decide whether cracking is feasible → reach for hashcat.
## 4. The three signals a hash leaks about itself
This is the heart of the tool. A hash string carries up to three clues about its own origin: its **prefix**, its **length**, and its **character set**. We use these in order, strongest first.
### Signal 1: prefix (the strongest clue)
Modern password hashes use a self-describing format called **PHC string format** (PHC stands for "Password Hashing Competition," the contest that gave us Argon2 in 2015). A PHC string starts with a marker that announces the algorithm:
```
$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
^^^^^^^^^^
└─ "I am an Argon2id hash. You don't have to guess."
```
```
$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G.VHvgvWK
^^^^
└─ "I am a bcrypt hash, variant 2b, cost factor 12."
```
When a hash announces itself like this, identification is essentially free — you just compare prefixes. The tool reports **HIGH confidence** for prefix matches because the hash literally told you what it is. The PHC spec is documented [here](https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md) if you want the full grammar.
Old hashes do *not* do this. MD5 and SHA-1 just give you the raw hex digest with no prefix. Which is why the modern shift to PHC was a big deal — it makes the system self-documenting.
Here's a non-exhaustive cheat sheet of common prefixes. The full table is in `hash_identifier.py` under `PREFIX_RULES`:
| Prefix | Algorithm | Where you see it |
| ------------------ | ------------------- | ----------------------------------------------- |
| `$argon2id$` | Argon2id | Modern web apps, the current gold standard |
| `$2b$` | bcrypt | Workhorse for the past 15 years |
| `$6$` | SHA-512 crypt | `/etc/shadow` on most Linux distros |
| `$apr1$` | Apache MD5-crypt | `.htpasswd` files (Apache basic auth) |
| `$P$` | phpass | WordPress and old phpBB forums |
| `pbkdf2_sha256$` | Django PBKDF2 | Default for Django web apps |
| `{SSHA}` | LDAP salted SHA-1 | LDAP directory passwords |
### Signal 2: length (the second-strongest clue)
Hash functions emit fixed-length output. If you see no prefix but you have 64 hex characters, you can be confident it came from one of the 256-bit hash family (SHA-256, SHA3-256, BLAKE2s-256, RIPEMD-256). Each algorithm always emits the same number of bytes:
```
algorithm bytes hex chars
─────────────────────────────────────
MD5 16 32
SHA-1 20 40
SHA-224 28 56
SHA-256 32 64
SHA-384 48 96
SHA-512 64 128
```
The reason the number of hex characters is double the number of bytes: each byte is 8 bits, but a hex character represents only 4 bits (one of 16 possible values: `0-9a-f`). So you need two hex characters per byte.
Length narrows the field but rarely picks a unique winner. 32 hex characters could be MD5, NTLM, MD4, or RIPEMD-128 — they all produce 128 bits of output. So when the tool matches on length, it reports **MEDIUM confidence** for the most likely algorithm at that length and **LOW** for the rest. "Most likely" means "most common in the wild in 2026" — MD5 vastly outranks RIPEMD-128.
### Signal 3: charset (used as a sanity check)
The character set of the hash narrows things further. Three common alphabets show up:
- **Hex:** only `0-9a-f` (or `0-9A-F` if uppercase). Used by raw MD5, SHA-family, NTLM, etc.
- **Base64-ish:** `0-9A-Za-z+/=`. Used by LDAP and some Java password formats.
- **crypt(3) base64:** a peculiar alphabet `./0-9A-Za-z` (note the `.` and `/` at the start, no `=` for padding). Used by bcrypt and the old Unix crypt formats.
A string with `+` in it is *not* a hex hash. A string with `*` followed by 40 uppercase hex characters is almost certainly MySQL5 (and only MySQL5 — because MySQL prints its hashes using the `%02X` C format specifier, which is uppercase-only).
Charset alone rarely identifies anything, but it's the tiebreaker that lets us *rule things out*. For example, the helper `_is_mysql5` in the code refuses to match if the body is lowercase, because real MySQL5 output is always uppercase. Better to say "I don't know" than to lie with confidence.
## 5. Salts (a quick detour)
You'll see the word **salt** all over the place when you read about password hashing. A salt is a unique random string that you mix into the password before hashing, then store alongside the hash:
```
hash = bcrypt("hunter2" + random_salt_for_alice)
```
The point of a salt is to make every user's hash *different*, even if two users picked the same password. Without salts, you can sort the database by hash column, count duplicates, and immediately learn which password is most popular (this is exactly how researchers learned `123456` was Adobe's most popular password — no cracking required).
Salts also defeat **rainbow tables**: precomputed lookup tables that map `hash → password` for billions of common passwords. With a unique salt per user, every entry in the rainbow table would have to be recomputed for *every salt*, which is infeasible.
A PHC string like `$2b$12$EixZaYVK1fsbw1ZfbX3OXe...` contains the salt right there in the string (the `EixZaYVK1fsbw1ZfbX3OXe` part). That's not a security problem — the salt is supposed to be public. Its job is to make every hash unique, not to be secret.
## 6. Why identification has to come first
Cracking tools don't auto-detect the algorithm — they need you to tell them.
```bash
# Hashcat, mode 0 (MD5):
hashcat -m 0 -a 0 5f4dcc3b5aa765d61d8327deb882cf99 wordlist.txt
# Hashcat, mode 3200 (bcrypt):
hashcat -m 3200 -a 0 '$2b$12$EixZaY...' wordlist.txt
# John the Ripper, --format=raw-md5:
john --format=raw-md5 --wordlist=wordlist.txt hashes.txt
```
Pick the wrong mode and hashcat will sit there comparing your bcrypt hash against MD5 outputs forever and finding nothing. So *before* you crack, you identify.
This is why a tool like the one in this project (and the older [`hashid`](https://github.com/psypanda/hashID) and [`hash-identifier`](https://github.com/blackploit/hash-identifier) projects it's inspired by) exists. It is the first step. Our tool is a beginner-friendly clone of that idea, written so you can read every line and understand every decision.
## 7. The tradeoff this tool is making
We could theoretically achieve higher accuracy by trying every algorithm and computing diagnostics. We don't. We make decisions from *string shape alone*:
- We never run any hash function.
- We never make network requests.
- We never touch the filesystem.
- We never call any external tool.
This makes the tool **fast** (instant), **safe** (impossible to leak data), and **trivially testable** (every test is "given input X, expect output Y"). It's a pure function, in the mathematical sense — same input always gives same output, no side effects.
The cost is that we sometimes report multiple candidates with MEDIUM/LOW confidence. We could in principle pick a winner if we tried each algorithm against a known wordlist — but that's a different tool. That tool is the cracker, and it lives in `PROJECTS/beginner/hash-cracker`. Our tool's whole job is to point you at the *right cracker mode* in the first place.
## 8. What the tool will and will not do
| Will | Will not |
| ----------------------------------------------------- | --------------------------------------------------- |
| Identify ~30 hash formats by prefix | Crack any hash |
| Identify common hex hashes by length | Compute hashes for you |
| Recognize MySQL5, NetNTLM, DES crypt by shape | Call hashcat or john for you |
| Tell you "that's a JWT" or "that's base64, not a hash"| Tell you the password |
| Print ranked candidates with confidence and reasoning | Make network requests |
| Run as a one-shot CLI in milliseconds | Touch the filesystem |
## 9. Where to go from here
Now that you know *what* the tool is doing and *why*, read **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** to see how the code is structured into a six-step decision pipeline. Then **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks the actual Python file with you, function by function.

View File

@ -0,0 +1,353 @@
# Architecture
This page is about *how the code is shaped*. Not what each line does — that's the next page. Here we zoom out and look at how the pieces fit together, what data flows where, and why we picked this shape over the alternatives.
## 1. The big picture
The whole tool is one Python file: `hash_identifier.py`. Everything that runs lives in that file. There are three layers inside it:
```
┌─────────────────────────────────────────────────────────────┐
│ CLI layer (main, _build_argument_parser, _render_table) │
│ - reads command-line arguments │
│ - prints the colored table to your terminal │
│ - returns an exit code │
└──────────────────────────┬──────────────────────────────────┘
│ calls
┌─────────────────────────────────────────────────────────────┐
│ Pure-function layer (identify) │
│ - the actual decision-making │
│ - takes a string, returns a list of HashCandidate │
│ - touches NO files, NO network, NO global state │
└──────────────────────────┬──────────────────────────────────┘
│ uses
┌─────────────────────────────────────────────────────────────┐
│ Data layer (PREFIX_RULES, HEX_LENGTH_RULES, charsets) │
│ - lookup tables describing what we know about hashes │
│ - read-only, defined at module load time │
└─────────────────────────────────────────────────────────────┘
```
Reading top to bottom, the CLI layer is the **outside** of the program: it deals with the human. The pure-function layer is the **brain**: it takes a clean string in and returns a clean answer out. The data layer is the **knowledge**: every "we know X about hash Y" lives in one of those tables, not scattered through the code.
This three-way split is deliberate. We can test the brain in isolation without ever spawning a CLI — and the test file does exactly that, calling `identify()` directly. We can change how the table looks (color, layout, JSON output) without touching the brain. And we can add new hash formats by adding a row to a table, not by adding a new function.
## 2. Data flow on a single run
Here's what happens when you type `just run -- 5f4dcc3b5aa765d61d8327deb882cf99`:
```
(your terminal)
│ "5f4dcc3b5aa765d61d8327deb882cf99"
┌─────────────────────────────────────┐
│ argparse │
│ parses sys.argv into args.hash │
└────────────────┬────────────────────┘
│ args.hash = "5f4dcc..."
┌─────────────────────────────────────┐
│ identify(args.hash) │
│ │
│ text = args.hash.strip() │
│ │
│ ┌─────────────────────────────┐ │
│ │ Step 1: prefix match? │ │
│ └────────────┬────────────────┘ │
│ no match │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Step 2: special shape? │ │
│ │ (NetNTLM / MySQL5 / DES) │ │
│ └────────────┬────────────────┘ │
│ no match │ │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Step 3: hex + length match? │ │
│ │ → 32 hex chars → MD5/NTLM..│ ✔ │
│ └────────────┬────────────────┘ │
│ │ │
│ returns [HashCandidate, ...] │
└────────────────┬────────────────────┘
┌─────────────────────────────────────┐
│ _render_table() │
│ builds a rich.Table, prints it │
└────────────────┬────────────────────┘
(your terminal)
┌─────────────────────────────────────────────────────────┐
│ Candidates for: 5f4dcc3b5aa765d61d8327deb882cf99 │
│ ╭───────────┬────────────┬───────────────────────────╮ │
│ │ algorithm │ confidence │ reason │ │
│ ├───────────┼────────────┼───────────────────────────┤ │
│ │ MD5 │ medium │ 32 hex chars — most likely│ │
│ │ NTLM │ low │ 32 hex chars — also poss. │ │
│ │ MD4 │ low │ 32 hex chars — also poss. │ │
│ │ RIPEMD-128│ low │ 32 hex chars — also poss. │ │
│ ╰───────────┴────────────┴───────────────────────────╯ │
└─────────────────────────────────────────────────────────┘
```
The brain is the middle box. Everything above and below it is just plumbing: getting the string in, getting the table out.
## 3. The six-step decision pipeline
The brain (`identify()`) is structured as **six numbered steps**. Each step is a chance to short-circuit and return a verdict. If a step matches, the function returns immediately. If not, control falls through to the next step.
This shape — "try the strongest signal first, fall back to weaker ones" — is called a **decision cascade** or **rule pipeline**. You'll see this pattern all over security tooling: spam filters, IDS rules, antivirus heuristics, fingerprinting. They all share the same skeleton.
```
┌────────────────────────────────┐
│ Step 1: PREFIX_RULES? │ HIGH confidence
│ Walk the prefix table. │ ────────► return
│ Any prefix start match wins. │ first match
└─────────────┬──────────────────┘
│ no match
┌────────────────────────────────┐
│ Step 2: special shapes? │ HIGH/MEDIUM
│ NetNTLMv2 / NetNTLMv1 (`::`) │ ────────► return
│ MySQL5 (`*` + 40 upper hex) │ first match
│ DES crypt (13 chars) │
└─────────────┬──────────────────┘
│ no match
┌────────────────────────────────┐
│ Step 3: pure hex? │ MEDIUM/LOW
│ If yes, look up length in │ ────────► return
│ HEX_LENGTH_RULES, return all │ ranked list
│ candidates ranked by likelihood│
└─────────────┬──────────────────┘
│ not hex
┌────────────────────────────────┐
│ Step 4: generic `$algo$...`? │ LOW
│ Looks like a PHC string but │ ────────► return
│ we have no specific rule. │ generic match
│ Report it as a generic PHC. │
└─────────────┬──────────────────┘
│ no
┌────────────────────────────────┐
│ Step 5: shape hint? │ LOW
│ Looks like a JWT (eyJ...) or │ ────────► return
│ base64 (`+`, `/`, `=`)? │ "not a hash"
│ Tell the user it's not a hash. │
└─────────────┬──────────────────┘
│ no
┌────────────────────────────────┐
│ Step 6: give up. │ none
│ Return empty list. │ ────────► []
│ CLI prints "could not identify"│
└────────────────────────────────┘
```
Order matters here, and the order isn't arbitrary. We always try **the most specific test first** and **the most general test last**:
1. PHC prefixes are dead giveaways — the hash itself names the algorithm.
2. Special shapes (NetNTLM, MySQL5, DES crypt) are also strong: they have distinctive structures.
3. Hex + length is a *narrowing* signal, not a definitive one — it picks a family, not a member.
4. Generic PHC fallback catches hashes that *look* PHC-shaped but aren't in our table.
5. Shape hints handle the common "I pasted the wrong thing" case (people drop JWTs into hash identifiers all the time).
6. Empty list = honest "I don't know."
If we reversed the order — say, checked length before prefix — we would misclassify a bcrypt hash as "60 hex chars, no length rule" because we'd never look at its `$2b$` prefix. Order encodes priority.
## 4. The HashCandidate object
The brain doesn't return a string — it returns a list of `HashCandidate` objects. A candidate has three fields:
```
┌─────────────────────────────────────────────────────────────┐
│ HashCandidate │
│ │
│ algorithm: str e.g. "MD5", "bcrypt", "SHA-256" │
│ confidence: Literal "high" | "medium" | "low" │
│ reason: str "prefix `$2b$` — bcrypt PHC..." │
│ │
│ frozen=True ── immutable, can't be mutated after creation│
│ slots=True ── memory-efficient (no __dict__) │
└─────────────────────────────────────────────────────────────┘
```
The shape is intentional. **`algorithm`** is what hashcat wants to know. **`confidence`** tells the human reader how much to trust this guess. **`reason`** is the *evidence* — a one-line explanation of why the tool made this guess. The reason field is what makes the tool teachable: the user sees not just "bcrypt" but "prefix `$2b$` — bcrypt PHC string, 2b variant (current)."
We use `@dataclass(frozen=True, slots=True)` instead of writing a class with `__init__` and `__repr__` by hand:
- **`frozen=True`** means once you build a `HashCandidate`, you can't mutate it. If somewhere in the code tried `candidate.algorithm = "something else"`, Python would raise `FrozenInstanceError`. This makes the data flow predictable: a candidate that comes out of `identify()` is the same candidate everywhere it shows up later.
- **`slots=True`** is a memory optimization. Without slots, every instance carries around a `__dict__` for adding attributes on the fly. We don't need that, so we turn it off and save memory.
Both flags also signal *intent* to a reader: "this is a value object, not a mutable bag of state." That signal matters more than the bytes saved.
## 5. The data tables as the source of truth
If you wanted to add a new hash format to this tool, you would not write new logic. You would add a row to one of these tables:
```
PREFIX_RULES: list of (prefix, algorithm, note)
─────────────────────────────────────────────────
("$argon2id$", "Argon2id", "modern PHC string..."),
("$2b$", "bcrypt", "bcrypt PHC string..."),
("$6$", "SHA-512 crypt", "Unix crypt..."),
... ~25 more rows
HEX_LENGTH_RULES: dict of {length_in_hex_chars: [algorithms]}
─────────────────────────────────────────────────────────────
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
40: ["SHA-1", "RIPEMD-160"],
64: ["SHA-256", "SHA3-256", "BLAKE2s-256", "RIPEMD-256"],
128: ["SHA-512", "SHA3-512", "BLAKE2b-512", "Whirlpool"],
... etc
HEX_CHARSET, _HEX_UPPER_CHARSET, _DESCRYPT_CHARSET
──────────────────────────────────────────────────
The alphabets used by each format. frozenset for fast lookup.
```
This is called a **data-driven design**. The rules live in data, not code. Three benefits:
1. **Adding a new format is one line.** No new function, no new test scaffolding to write.
2. **The rules are inspectable.** You can read `PREFIX_RULES` and immediately see every format the tool knows.
3. **The rules are testable.** The test file iterates over `PREFIX_RULES` and confirms each prefix is recognized — so the data and the behavior cannot drift out of sync.
When you read the implementation page next, watch how few of the function bodies have *if/elif/elif* chains. The decisions happen inside table lookups, not inside conditionals. That's the data-driven design at work.
## 6. Two helper functions
The brain is one big function (`identify`), but two small helpers live next to it. They both answer yes/no questions about the input string:
```
┌──────────────────────────────────────────────────────────┐
│ _is_hex(text) -> bool │
│ "Is every character of text a valid hex digit?" │
│ Used in step 3 to decide whether to look up length. │
├──────────────────────────────────────────────────────────┤
│ _is_mysql5(text) -> bool │
│ "Does text look like `*` + 40 uppercase hex chars?" │
│ Used in step 2 for MySQL5 detection. │
├──────────────────────────────────────────────────────────┤
│ _is_descrypt(text) -> bool │
│ "Is text 13 chars from `./0-9A-Za-z`?" │
│ Used in step 2 for legacy DES crypt detection. │
└──────────────────────────────────────────────────────────┘
```
The leading underscore (`_is_hex`, not `is_hex`) is a Python convention meaning **"this is module-private."** It says to other developers: "this is an implementation detail of `hash_identifier.py`; don't import it from somewhere else." Python doesn't *enforce* this — you can still import private names — but every linter and every reviewer will flag you if you do.
The helpers are tiny on purpose. Each one is a single boolean question. We pull them out of `identify()` not because they're complicated but because giving them a name makes `identify()` read like English: "if `_is_hex(text)`, do hex-length matching." If we inlined the test, the eye would have to parse it.
## 7. The CLI layer
The CLI layer is the part the human actually interacts with. It does three things:
```
┌────────────────────────────────────────────────────────────┐
│ _build_argument_parser() │
│ Sets up argparse — defines that the program takes one │
│ positional argument (`hash`) and an optional `--top N`
│ flag. Returns a configured parser. │
│ │
│ Pulled out of main() so tests can build the parser │
│ without actually running the CLI. │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ _render_table(raw_input, candidates, console) │
│ Builds a rich.Table object, adds one row per candidate, │
│ colors the confidence column (green/yellow/cyan), │
│ and prints it. │
│ │
│ Takes the rich Console as an argument so tests can pass │
│ a captured-output Console and verify what got printed. │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────┐
│ main() │
│ Parses args, calls identify(), prints the table. │
│ Returns an exit code: │
│ 0 → at least one candidate found │
│ 1 → no candidates (printed an error message) │
│ │
│ The exit code lets shell scripts do │
`if hashid "$x"; then ...`
│ to react to whether identification succeeded. │
└────────────────────────────────────────────────────────────┘
```
The crucial design choice in the CLI layer is **dependency injection**. `_render_table` takes a `Console` object as a parameter instead of creating one inside. That sounds fancy, but it just means: "give me the printer to use." The function doesn't care if you give it a real terminal Console or a test Console that captures output to a string. This makes the function testable without writing to your real terminal during tests.
## 8. The test file mirrors the brain
`test_hash_identifier.py` is structured to mirror `hash_identifier.py`. It tests every behavior the tool claims to have:
```
test_bcrypt_prefix_is_recognized ── covers PREFIX_RULES row $2b$
test_argon2id_prefix_is_recognized ── covers PREFIX_RULES row $argon2id$
test_apr1_prefix_is_recognized ── covers PREFIX_RULES row $apr1$
test_sha512_crypt_prefix_is_recognized ── covers PREFIX_RULES row $6$
test_django_pbkdf2_prefix_is_recognized ── covers PREFIX_RULES row pbkdf2_sha256$
test_mysql5_format_is_recognized ── covers Step 2 / _is_mysql5
test_mysql5_rejects_lowercase_body ── covers the "be honest, don't lie" rule
test_netntlmv2_format_is_recognized ── covers Step 2 / NetNTLMv2
test_netntlmv1_format_is_recognized ── covers Step 2 / NetNTLMv1
test_descrypt_format_is_recognized ── covers Step 2 / _is_descrypt
test_md5_length_returns_md5_first ── covers Step 3 / 32 hex chars
test_sha1_length_returns_sha1_first ── covers Step 3 / 40 hex chars
test_sha256_length_returns_sha256_first ── covers Step 3 / 64 hex chars
test_mysql323_length_returns_mysql323_first ── covers Step 3 / 16 hex chars
test_unknown_phc_string_falls_back_to_generic
── covers Step 4
test_jwt_input_is_called_out_as_not_a_hash ── covers Step 5
test_base64_blob_is_called_out_as_not_a_hash── covers Step 5
test_empty_input_returns_no_candidates ── covers Step 6 (edge case)
test_garbage_returns_no_candidates ── covers Step 6
test_input_is_trimmed_of_whitespace ── covers the .strip() at the top
test_hash_candidate_is_frozen ── covers the @dataclass(frozen=True)
test_every_prefix_rule_is_recognized_with_high_confidence
── meta-test: iterates over
── PREFIX_RULES and asserts every
── row produces a HIGH-confidence
── match. Keeps data and code in sync.
```
The meta-test at the bottom is the most interesting one. It's a guard against future regressions: if you add a new row to `PREFIX_RULES` and forget to update the matching logic, this test fires. The test loops over the data, not over hardcoded inputs — so the test grows automatically as the data table grows.
## 9. Why pure functions matter here
The brain (`identify()`) is what's called a **pure function**:
- Given the same input, it always returns the same output.
- It doesn't modify anything outside itself (no global variables, no files, no network).
- It doesn't depend on anything outside itself (no current time, no environment variables, no random numbers).
This sounds like a small thing. It's enormous. Pure functions are:
- **Trivially testable.** `assert identify("5f4d...") == [HashCandidate(...)]`. No mocking, no setup, no teardown.
- **Trivially parallelizable.** You could run `identify()` on a million hashes across 16 CPU cores with zero coordination, because no two calls can interfere with each other.
- **Trivially cacheable.** Same input → same output → memoize freely.
- **Trivially understandable.** You can read `identify()` in isolation. You don't have to know what state the program is in.
Most real programs can't be all-pure — they have to read files, send packets, write to databases. But you can almost always *carve out* a pure core and put a thin shell around it that does the side-effecty stuff. That's exactly the architecture here: pure brain in the middle, side-effecty CLI shell around it.
This is sometimes called the [Functional Core, Imperative Shell](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) pattern. It's worth learning the name because once you see it, you'll spot it everywhere.
## 10. Next up
You now know the shape: three layers, six steps, three data tables, three helpers, one `HashCandidate` record, one CLI shell. Read **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** next and we'll walk every line of `hash_identifier.py` together.

View File

@ -0,0 +1,616 @@
# Implementation walkthrough
This page reads `hash_identifier.py` from top to bottom with you. Every Python feature gets explained the first time it shows up. If you're brand new to Python, read this with the source file open in another window so you can see the lines we're talking about.
> Throughout this page, we say "the source file" to mean `hash_identifier.py`. Open it now: `code hash_identifier.py`, or `nano hash_identifier.py`, or whatever editor you use.
## 1. The file header
```python
"""
©AngelaMos | 2026
hash_identifier.py
Identify what kind of hash a string is, by inspecting its shape
...
"""
```
That triple-quoted block at the very top of the file is a **module docstring**. In Python, anything inside `"""..."""` is a string literal. When the file is loaded, Python sees this string sitting at the top with no name attached to it, and treats it as documentation for the whole module. You can read it later with `help(hash_identifier)` or by hovering over the import in an IDE.
The first line, `©AngelaMos | 2026`, is the copyright marker required by every file in this repo. The second line is the filename. Then a longer human-readable explanation of what the file does. You'll see the same pattern in every file in `PROJECTS/foundations/`.
> **Why a docstring instead of a comment?** Python has both. A `# comment` is stripped before the code runs. A `"""docstring"""` is stored on the module/function/class and is available at runtime via `__doc__`. Tools like Sphinx, mkdocs, and your IDE's hover help all read docstrings, not comments. Rule of thumb: use docstrings for "what this thing is and how to use it," and use comments for "why this *specific line* exists."
## 2. Imports
```python
import argparse
import sys
from dataclasses import dataclass
from typing import Literal
from rich.console import Console
from rich.table import Table
```
An `import` statement brings code from another file into yours. Python ships with hundreds of modules in its **standard library** (always available, no install needed) and you can install more from [PyPI](https://pypi.org/) using `uv add <package>`.
There are two `import` shapes:
- `import argparse` — imports the whole module under its own name. You then refer to things inside it as `argparse.ArgumentParser`.
- `from dataclasses import dataclass` — imports just one thing out of the module, so you can use it bare: `dataclass` instead of `dataclasses.dataclass`.
Use the second form when you only need one or two things and they have descriptive names. Use the first form when you'd otherwise pull in a bunch of names that might collide with yours.
A blank line separates standard-library imports from third-party imports. That's a [PEP 8](https://peps.python.org/pep-0008/#imports) convention. Linters will yell at you if you mix them.
Tour of what we just imported:
| Import | What it is | Why we need it |
| ------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------- |
| `argparse` | Standard-library CLI argument parser | Turns `sys.argv` into nice attributes (`args.hash`) |
| `sys` | Standard library, talks to the Python interpreter | We use `sys.exit(...)` to set the program's exit code |
| `dataclass` | A decorator that turns a class into a small record | Saves us writing `__init__` for `HashCandidate` |
| `Literal` | A type hint meaning "this value is one of these specific strings" | Pins `confidence` to `"high" / "medium" / "low"` |
| `Console` | Third-party, from the [`rich`](https://github.com/Textualize/rich) library | The thing that draws colored text to the terminal |
| `Table` | Also from `rich` | Builds the colored ASCII table we print to the user |
## 3. The Literal type
```python
Confidence = Literal["high", "medium", "low"]
```
This line creates a **type alias**. We give the type `Literal["high", "medium", "low"]` a friendly name, `Confidence`, and use that name everywhere else.
A `Literal` type says: "the value of this thing must be exactly one of these specific values — not just any string." With mypy (our type checker) turned on, code like this:
```python
candidate = HashCandidate(algorithm="MD5", confidence="hgih", reason=...)
^^^^^^
typo!
```
would be flagged at edit time, before you ever run the code. Without `Literal`, the type would be `str` and `"hgih"` would slide by until a user noticed the misspelled output.
We picked `Literal` over Python's `Enum` because for small fixed sets of strings, `Literal` is lighter weight — no separate class definition, no `.value` attribute to remember. (For bigger sets or when you need behavior on the values, `Enum` is the right choice.)
## 4. The HashCandidate dataclass
```python
@dataclass(frozen=True, slots=True)
class HashCandidate:
"""One possible identification of a hash string ..."""
algorithm: str
confidence: Confidence
reason: str
```
This is the data record the brain returns. Let's unpack what's happening:
- **`class HashCandidate:`** defines a new type called `HashCandidate`. A class is a blueprint for objects.
- **`algorithm: str`** declares an attribute named `algorithm` of type `str` (a string). The colon-then-type syntax is a **type annotation**. It's optional in Python but heavily used in modern code.
- **`@dataclass(...)`** is a *decorator*. A decorator is a function that wraps your class and modifies it. The `@dataclass` decorator looks at the three attributes you declared (`algorithm`, `confidence`, `reason`) and generates an `__init__` method, a `__repr__` method, and a few other dunder methods automatically. Without `@dataclass`, you'd have to write:
```python
class HashCandidate:
def __init__(self, algorithm: str, confidence: Confidence, reason: str):
self.algorithm = algorithm
self.confidence = confidence
self.reason = reason
def __repr__(self):
return f"HashCandidate(algorithm={self.algorithm!r}, ...)"
def __eq__(self, other):
...
```
`@dataclass` writes all that for you.
- **`frozen=True`** makes instances immutable. After the object is built, `candidate.algorithm = "different"` raises `FrozenInstanceError`. This is what makes `HashCandidate` a **value object** — like an integer or a tuple, it doesn't change after creation.
- **`slots=True`** is a memory optimization. By default, every Python object has a `__dict__` so you can add arbitrary attributes to it on the fly. We don't want that — the three fields are all we'll ever have. `slots=True` tells Python to allocate a fixed array for the three fields, skipping the dict. Faster, smaller, and `obj.typo = "anything"` now also fails (which is good — it catches bugs).
You'll use both flags together a lot for tiny data records. Together they say "this is a record, treat it like one."
## 5. The PREFIX_RULES table
```python
PREFIX_RULES: list[tuple[str, str, str]] = [
("$argon2id$", "Argon2id", "modern PHC string, the current standard"),
("$argon2i$", "Argon2i", "PHC string, side-channel-resistant variant"),
...
]
```
This is a `list` of `tuple`s. Let's break that down.
A **list** is Python's basic ordered container. You write it with square brackets: `[1, 2, 3]`. You can add to it, remove from it, index into it (`items[0]`).
A **tuple** is like a list but immutable. You write it with parentheses: `(1, 2, 3)`. You can read from it but you can't change it after creation. Tuples are the right container when the *position* of each value has meaning — like coordinates `(x, y)`, or here, "the prefix, the algorithm name, and the note" always in that order.
So `PREFIX_RULES` is a list of 3-tuples. Each tuple says "if you see this prefix, the algorithm is this name, and here is a short note about it."
The type annotation `list[tuple[str, str, str]]` says exactly that: "a list whose elements are tuples of three strings." This is purely for the human reader and for mypy — at runtime Python doesn't enforce it.
> **Why a list and not a dict?** A dict would let us look up by prefix in O(1) time. But our prefixes are not all the same length — `$2b$` is 4 chars, `$argon2id$` is 10. There's no fast "is this string a prefix of that string for any key in my dict" operation, so we walk the list. Performance is fine because the list is short (~25 entries) and we only do this once per program invocation.
Notice the comment groupings — `# Argon2 family`, `# bcrypt and its many variants` — these are the only kind of comment we use heavily in foundations-tier projects. They name *sections* of related data so the reader can scan.
The order of entries matters when two prefixes could overlap. Specifically, `$argon2id$` must come before `$argon2$` because `"$argon2id$something".startswith("$argon2$")` would *also* be true if `$argon2$` were in our table. We list more specific prefixes first so they match first.
## 6. The HEX_LENGTH_RULES table
```python
HEX_CHARSET: frozenset[str] = frozenset("0123456789abcdefABCDEF")
_HEX_UPPER_CHARSET: frozenset[str] = frozenset("0123456789ABCDEF")
HEX_LENGTH_RULES: dict[int, list[str]] = {
16: ["MySQL323", "CRC-64"],
32: ["MD5", "NTLM", "MD4", "RIPEMD-128"],
40: ["SHA-1", "RIPEMD-160"],
...
}
```
A **set** is an unordered container of unique values. A **frozenset** is a set you can't modify after creation. We use frozenset here because:
1. We never need to add/remove characters from the hex alphabet — it's known and fixed.
2. Lookup (`c in HEX_CHARSET`) is O(1) — constant time. Faster than `c in "0123456789abcdef..."` which would scan the string character by character.
3. Marking it `frozenset` signals to the reader: "this is a fixed constant, don't try to mutate it."
A **dict** (dictionary) is a mapping from keys to values. You write it with curly braces: `{key: value, key: value}`. Lookup is O(1) on the key.
`HEX_LENGTH_RULES` maps "length-in-hex-chars" to "list of algorithm names that produce that length." So `HEX_LENGTH_RULES[32]` is `["MD5", "NTLM", "MD4", "RIPEMD-128"]` — the four algorithms that produce a 32-hex-character output.
`_HEX_UPPER_CHARSET` starts with an underscore. Convention: **leading underscore means module-private.** It's saying "this is an implementation detail, not part of the public interface." Python doesn't enforce this, but every linter does. The uppercase variant exists because MySQL5 prints its hex in uppercase only (`%02X` C format), so we use a tighter charset to avoid false positives on hand-typed inputs.
## 7. The `_is_hex` helper
```python
def _is_hex(text: str) -> bool:
"""Return True iff every character in text is a hex digit and text is non-empty"""
return bool(text) and all(c in HEX_CHARSET for c in text)
```
A `def` statement defines a function. Reading this signature:
- `def _is_hex(text: str) -> bool:` declares a function named `_is_hex` that takes one argument `text` of type `str` and returns a `bool` (True or False).
- The leading underscore makes it module-private.
The body is one line. Let's read it right to left:
- `(c in HEX_CHARSET for c in text)` is a **generator expression**. It produces a sequence of booleans: for each character `c` in `text`, yield `True` if `c` is in our hex charset, else `False`.
- `all(...)` takes that sequence and returns `True` only if *every* yielded value is True. Equivalent to "every character of text is a hex digit."
- `bool(text)` evaluates to `False` if `text` is empty, `True` otherwise. We need this guard because `all([])` of an empty sequence returns True (mathematically reasonable, practically annoying — an empty string is not a valid hex string).
- `... and ...` short-circuits: if `bool(text)` is False, we don't bother checking `all(...)`.
The word `iff` in the docstring is shorthand for "if and only if." Math nerds and CS people use it constantly; it means a biconditional.
## 8. MySQL5 detection
```python
_MYSQL5_HEX_BODY_LENGTH = 40
_MYSQL5_TOTAL_LENGTH = _MYSQL5_HEX_BODY_LENGTH + 1
def _is_mysql5(text: str) -> bool:
"""Return True for MySQL5 password format: `*` then 40 UPPERCASE hex chars ..."""
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
return False
body = text[1:]
return all(c in _HEX_UPPER_CHARSET for c in body)
```
The two constants at the top come from one of the rules of this codebase: **no magic numbers**. Compare:
```python
if len(text) != 41 or not text.startswith("*"):
return False
```
vs:
```python
if len(text) != _MYSQL5_TOTAL_LENGTH or not text.startswith("*"):
return False
```
Both work. Only the second one tells the reader *why* 41 is the right number (it's 40 hex chars for the body plus 1 for the leading `*`).
The function does three things:
1. Check the total length is exactly 41 and the first character is `*`. If not, bail with False.
2. Slice off the leading `*` using `text[1:]`. Python slicing: `text[start:stop]` gives you the substring from index `start` (inclusive) to `stop` (exclusive). Omitting `stop` means "to the end." So `text[1:]` means "everything from index 1 onwards" — i.e. drop the first character.
3. Check that every character in the body is in our uppercase-only hex charset.
The docstring contains an important caveat: we *cannot* use `body.isupper()` to enforce uppercase, because Python's `str.isupper()` returns False for a string with no cased characters at all. So `"0123456789ABCDEF...".isupper()` would correctly return True, but an all-digit body would return False — wrongly rejecting valid input. Checking membership in `_HEX_UPPER_CHARSET` is the test that actually matches the spec.
This is the kind of subtle gotcha you only learn from being burned. Worth memorizing: **don't use `.isupper()` / `.islower()` as a "this string contains only uppercase chars" check.**
## 9. DES crypt detection
```python
_DESCRYPT_CHARSET: frozenset[str] = frozenset(
"./0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
)
_DESCRYPT_TOTAL_LENGTH = 13
def _is_descrypt(text: str) -> bool:
"""Return True for traditional 13-char DES crypt (legacy /etc/passwd) ..."""
return (
len(text) == _DESCRYPT_TOTAL_LENGTH
and all(c in _DESCRYPT_CHARSET for c in text)
)
```
DES crypt is the *original* Unix password hash format from the 1970s. No prefix, no salt marker, just 13 characters from a specific 64-character alphabet.
The charset definition uses **string literal concatenation**: three string literals sitting next to each other are automatically joined by Python at parse time. So this:
```python
"./0123456789"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
```
is exactly the same as:
```python
"./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
```
But the split form is much easier to read. This works for any sequence of adjacent string literals — useful when wrapping long strings.
`_is_descrypt` itself is two checks: right length, right alphabet. Both must be true (`and`). The whole expression is wrapped in parentheses just for visual layout — Python allows you to split expressions across lines if they're inside `()`, `[]`, or `{}`.
## 10. The `identify` function — the brain
This is the main attraction. It's about 100 lines long and follows the six-step pipeline from [02-ARCHITECTURE.md](./02-ARCHITECTURE.md).
### 10a. The pylint silencing comment
```python
# pylint: disable=too-many-return-statements,too-many-branches
```
This comment turns off two specific pylint warnings *for this function only*. Pylint normally complains about functions with lots of `return` statements or lots of branches, because they're usually a sign that you should refactor.
We're keeping the warnings off because in this case the branching *is* the structure. Six numbered steps, each potentially returning a result — refactoring them into six helper functions would scatter the pipeline across the file and make it harder, not easier, to read. So we acknowledge the warnings, explain why we're keeping them off in the inline comment block above, and turn them off explicitly.
> **Rule of thumb on silencing linters:** never silence broadly. Always silence the *specific* warning, on the *specific* function, with a comment that explains why. If you find yourself silencing the same warning everywhere, your linter config is wrong, not your code.
### 10b. The signature and docstring
```python
def identify(raw_input: str) -> list[HashCandidate]:
"""Return ranked candidates for what algorithm produced `raw_input` ..."""
```
Takes a string. Returns a list of `HashCandidate` objects. That's the whole contract. The function does not raise exceptions for unknown inputs — it returns an empty list instead. (Throwing exceptions for "I don't know" forces every caller to wrap the call in try/except. Returning an empty list is cleaner.)
The docstring is **Numpy-style**: it has labeled sections (`Parameters`, `Returns`) with the parameter name on its own line and the description indented underneath. Numpy style is what most scientific Python uses. The other big style is Google ("Args:" and "Returns:" with colons), and you'll see both in the wild. Either is fine; just pick one and be consistent.
### 10c. Trim and bail on empty
```python
text = raw_input.strip()
if not text:
return []
```
`str.strip()` returns a *new* string with leading and trailing whitespace removed. (Strings are immutable in Python — every "modifying" method actually returns a new string.) We do this because hashes copy-pasted from terminals often arrive with trailing newlines or leading spaces.
We do *not* lowercase the text. Some formats (MySQL5) are case-sensitive on purpose.
The `if not text:` check catches the empty string. In Python, an empty string is **falsy** — it counts as False in a boolean context. So `not text` is True when `text` is empty. Same for empty list, empty dict, `None`, and `0`. Get used to this — it's one of Python's defining features.
### 10d. Step 1 — walk PREFIX_RULES
```python
for prefix, algorithm, note in PREFIX_RULES:
if text.startswith(prefix):
return [
HashCandidate(
algorithm=algorithm,
confidence="high",
reason=f"prefix `{prefix}` — {note}",
)
]
```
A `for ... in ...` loop iterates over each element of a sequence. Here, each element of `PREFIX_RULES` is a 3-tuple, so we **unpack** it directly into three variables — `prefix`, `algorithm`, `note` — in one go. This is called *tuple unpacking* and it's used constantly in Python.
`text.startswith(prefix)` is a method on `str` that returns True if `text` begins with `prefix`. There's also `endswith`, by the way.
`f"..."` is an **f-string** (formatted string literal). Anything inside `{}` is evaluated and inserted into the string. So `f"prefix `{prefix}` — {note}"` produces something like `"prefix \`$2b$\` — bcrypt PHC string, 2b variant (current)"`. F-strings are the modern way to format strings in Python (3.6+); avoid the older `%` and `.format()` styles in new code.
The function returns a list containing one `HashCandidate`. We use keyword arguments (`algorithm=algorithm`) instead of positional ones so the call reads clearly even if you don't remember the parameter order.
The whole `if text.startswith(prefix):` check returns immediately on the first match. This is fine because the table is designed so that no two prefixes can match the same input (the longer, more specific ones come first).
### 10e. Step 2 — special non-PHC shapes
NetNTLMv2, NetNTLMv1, MySQL5, DES crypt. Each gets its own block.
```python
if "::" in text and text.count(":") >= 4:
parts = text.split(":")
if (len(parts) >= 6 and len(parts[4]) == 32 and _is_hex(parts[4])):
return [HashCandidate(algorithm="NetNTLMv2", ...)]
if (len(parts) >= 6 and len(parts[3]) == 48 and _is_hex(parts[3])):
return [HashCandidate(algorithm="NetNTLMv1", ...)]
```
A few Python features here:
- `"::" in text` returns True if the substring `"::"` appears anywhere in `text`. The `in` operator works on strings (substring check), lists (membership), dicts (key lookup), sets (membership), and any iterable.
- `text.count(":")` returns how many times `:` appears in the string.
- `text.split(":")` returns a list of substrings, splitting on `:`. So `"a:b:c".split(":")` gives `["a", "b", "c"]`.
- `parts[3]` indexes into the list. Python uses zero-based indexing, so `parts[3]` is the *fourth* element.
NetNTLMv2 records look like `user::domain:challenge:hmac(32 hex):blob`. We split on `:`, then look at part index 4 (the hmac field): if it's exactly 32 hex characters, we've got a v2. NetNTLMv1 looks similar but the field at index 3 is 48 hex chars (the LM hash). We test v2 *first* because v2's distinguishing field at index 4 is more specific.
This is the messiest step in the whole function. NetNTLM records were not designed to be pretty — they evolved from Microsoft authentication protocols of the 1990s. The shape match is the best we can do without parsing the entire NTLM protocol.
```python
if _is_mysql5(text):
return [HashCandidate(algorithm="MySQL5", confidence="high", ...)]
if _is_descrypt(text):
return [HashCandidate(algorithm="DES crypt", confidence="medium", ...)]
```
Calls our helpers. Note that MySQL5 gets **HIGH** confidence (the `*` + 40 uppercase hex shape is essentially unique) while DES crypt gets **MEDIUM** (a 13-char `./0-9A-Za-z` string could plausibly be other things — some session IDs, some encoded values). Honesty is a feature.
### 10f. Step 3 — hex + length lookup
```python
if _is_hex(text):
algorithms = HEX_LENGTH_RULES.get(len(text), [])
candidates: list[HashCandidate] = []
for index, algorithm in enumerate(algorithms):
confidence: Confidence = "medium" if index == 0 else "low"
label = (
"most likely candidate at this length"
if index == 0 else "also possible at this length"
)
candidates.append(
HashCandidate(algorithm=algorithm, confidence=confidence, reason=...)
)
return candidates
```
Three new Python features here:
- **`dict.get(key, default)`** returns the value if the key exists, or `default` if not. So `HEX_LENGTH_RULES.get(len(text), [])` returns the list of algorithms at this length, or an empty list if no rule exists for this length. This avoids a `KeyError` exception that would happen with `HEX_LENGTH_RULES[len(text)]` on an unknown length.
- **`enumerate(iterable)`** is a built-in that wraps an iterable and yields `(index, value)` pairs. So `for index, algorithm in enumerate(algorithms)` walks the list and gives us both the position and the value. The first algorithm gets index 0, the second gets index 1, etc.
- **Ternary expression**: `value_if_true if condition else value_if_false`. So `"medium" if index == 0 else "low"` evaluates to `"medium"` for the first item and `"low"` for the rest. This is the Python equivalent of `cond ? a : b` in C/JavaScript.
The first algorithm at each length is the one that's most common in 2026 — MD5 at length 32, SHA-1 at length 40, SHA-256 at length 64. The list ordering in `HEX_LENGTH_RULES` is by descending prevalence, so "first" really does mean "most likely."
`candidates.append(item)` adds an item to the end of the list. `list[T]` is a generic type — `list[HashCandidate]` means "a list whose elements are HashCandidate objects." The empty list literal `[]` is unannotated, so we write `candidates: list[HashCandidate] = []` to tell mypy what we intend the list to hold.
### 10g. Step 4 — generic PHC fallback
```python
if text.startswith("$"):
rest = text[1:]
if "$" in rest:
algo_name = rest.split("$", 1)[0]
if algo_name and all(c.isalnum() or c in "-_" for c in algo_name):
return [HashCandidate(algorithm=f"PHC string ({algo_name})", ...)]
```
If the input starts with `$` but didn't match any of our specific rules, it might still be a PHC string from an algorithm we don't have a rule for. We try to extract the algorithm name and report it as a generic PHC at LOW confidence.
- `text[1:]` slices off the leading `$`.
- `rest.split("$", 1)` splits on `$` but only once — the optional second argument to `split` is the max number of splits. So `"argon2id$v=19$...".split("$", 1)` returns `["argon2id", "v=19$..."]`. Without the `1`, it would split on every `$` and we'd lose information.
- `[0]` takes the first element of the resulting list.
- `c.isalnum()` is a `str` method returning True if `c` is a letter or digit. We use this to validate that the algorithm name field contains only PHC-legal characters (alphanumeric plus `-` and `_`). Anything weirder and we bail rather than make up an algorithm name from garbage.
The whole step is gated on the algorithm name being non-empty AND every character being legal. Better to report nothing than to guess wildly.
### 10h. Step 5 — shape hints
```python
if text.startswith("eyJ"):
return [HashCandidate(algorithm="JWT (not a hash)", ...)]
if any(c in text for c in "+/=") and len(text) > 8:
return [HashCandidate(algorithm="Base64 blob (not a hash)", ...)]
```
People paste JWTs and base64 blobs into hash identifiers all the time. Rather than returning a silent "no match," we point out what they probably pasted.
JWTs always start with `eyJ` because the JWT header is JSON like `{"alg":"HS256","typ":"JWT"}`, and the bytes `{"` base64-encoded begin with `eyI` or `eyJ`. The leading `eyJ` is essentially diagnostic for JWTs.
`any(c in text for c in "+/=")` is a generator expression inside `any()` — mirror of the `all(...)` we used in `_is_hex`. `any()` returns True if *any* element of the sequence is True. Together: "is there any character in `'+/='` that appears in `text`?" If yes, the input contains base64-only characters and cannot be a hex hash.
The `len(text) > 8` floor exists because a short string like `"a+b=c"` might trip the base64 check accidentally. We require enough length to be sure it's actually base64, not a math expression.
### 10i. Step 6 — give up
```python
return []
```
Empty list. The CLI prints "could not identify" when it sees this. Always better to admit defeat than to lie with confidence.
## 11. The CLI layer
### 11a. The argument parser
```python
def _build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="hashid", description="...")
parser.add_argument("hash", help="The hash string to identify ...")
parser.add_argument("--top", "-n", type=int, default=5, help="...")
return parser
```
`argparse.ArgumentParser` is the standard library's CLI parser. We set:
- `prog="hashid"` — the program name shown in help text.
- `description="..."` — the one-line summary at the top of `--help`.
Then we add two arguments:
- **`"hash"`** — a positional argument. The user types `hashid <hash>`. Required. After parsing, `args.hash` holds the string.
- **`"--top", "-n"`** — an optional flag with both a long form (`--top 3`) and short form (`-n 3`). `type=int` converts the string the user typed into an integer. `default=5` is what you get if the user doesn't pass it.
The function *returns* the parser without calling `.parse_args()`. Why? So the test file can build the parser, inspect it, run it on test input, etc., without actually executing the CLI. **Separating construction from execution is a recurring pattern for testability.** Whenever you find yourself writing `something().run()` in one line, ask whether someone needs to build that something without running it.
### 11b. The table renderer
```python
def _render_table(raw_input, candidates, console) -> None:
table = Table(title=f"Candidates for: {raw_input.strip()}", ...)
table.add_column("algorithm", style="bold white", no_wrap=True)
table.add_column("confidence", no_wrap=True)
table.add_column("reason", style="dim")
confidence_colors: dict[Confidence, str] = {
"high": "green",
"medium": "yellow",
"low": "cyan",
}
for candidate in candidates:
color = confidence_colors[candidate.confidence]
table.add_row(
candidate.algorithm,
f"[{color}]{candidate.confidence}[/{color}]",
candidate.reason,
)
console.print(table)
```
`rich.Table` is a class from the `rich` library. You create a Table, add columns, add rows, then print it. The library handles all the box-drawing characters, color codes, terminal-width detection, and Unicode handling.
The `f"[{color}]{candidate.confidence}[/{color}]"` syntax is `rich`'s inline color markup. `[green]text[/green]` colors `text` green. We use a dict to look up the color for each confidence level — three colors, predictable, easy to change in one place if we wanted to.
The `-> None` return type means the function doesn't return anything meaningful (it just has side effects: printing to the terminal). This is the right annotation for "this function does its work via side effects."
`console` is passed in as a parameter rather than created inside. Same idea as the parser: the test can pass a captured-output Console, the real CLI passes a real Console. Dependency injection at work.
### 11c. `main()` and the script guard
```python
def main() -> int:
parser = _build_argument_parser()
args = parser.parse_args()
console = Console()
candidates = identify(args.hash)
if not candidates:
console.print("[red]No identification possible.[/red] ...")
return 1
trimmed = candidates[:args.top]
_render_table(args.hash, trimmed, console)
if trimmed[0].confidence == "high":
console.print("\n[dim]Next step: try the matching cracker ...[/dim]")
return 0
if __name__ == "__main__":
sys.exit(main())
```
`main()` is the entry point. The body reads like English:
1. Build the parser.
2. Parse `sys.argv` (`parser.parse_args()` defaults to using `sys.argv`).
3. Build a Console.
4. Run the brain on the user's input.
5. If no candidates, print an error and return exit code 1.
6. Trim to the top-N results (`candidates[:args.top]` is slicing — same as before).
7. Render the table.
8. If the top candidate is HIGH confidence, print a hint about what to do next.
9. Return exit code 0.
The function returns the exit code as an integer. We hand it to `sys.exit()` at the bottom.
The final block:
```python
if __name__ == "__main__":
sys.exit(main())
```
is the classic Python script idiom. `__name__` is a special variable Python sets automatically:
- When you run `python hash_identifier.py`, Python sets `__name__ = "__main__"`.
- When you `import hash_identifier` from somewhere else, Python sets `__name__ = "hash_identifier"`.
So `if __name__ == "__main__":` means "only do this when the file is run directly, not when it's imported." This lets the test file `import hash_identifier` and call `identify()` without accidentally firing the CLI.
`sys.exit(N)` terminates the program with exit code N. Exit code 0 conventionally means success; non-zero means failure. Shell scripts (`if hashid "$x"; then ...`) read the exit code to decide what to do next.
## 12. Running through a real example
Let's trace what happens for `just run -- '$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G'`.
```
1. Shell invokes: python hash_identifier.py '$2b$12$EixZ...'
2. Python runs the file. sys.argv = [".../hash_identifier.py", "$2b$12$EixZ..."]
3. Since __name__ == "__main__", call sys.exit(main()).
4. main() builds parser, calls parser.parse_args().
5. argparse sees positional arg → args.hash = "$2b$12$EixZ..."
args.top = 5 (default).
6. main() calls identify(args.hash).
7. identify():
text = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
text is non-empty.
Step 1: walk PREFIX_RULES.
First row $argon2id$ — does text start with "$argon2id$"? no.
... a few more rows ...
Row ("$2b$", "bcrypt", ...) — does text start with "$2b$"? YES!
return [HashCandidate(algorithm="bcrypt", confidence="high", reason="prefix `$2b$` — bcrypt PHC string, 2b variant (current)")]
8. Back in main(): candidates is non-empty.
9. trimmed = candidates[:5] → just the one candidate.
10. _render_table(args.hash, trimmed, console)
Build a Table with title "Candidates for: $2b$12$EixZ...".
Add three columns.
Build one row: ("bcrypt", "[green]high[/green]", "prefix `$2b$` — ...").
console.print(table) — rich draws the colored ASCII table to your terminal.
11. Top candidate is HIGH confidence → print the "Next step" nudge.
12. return 0.
13. sys.exit(0) — clean exit.
```
Total elapsed time: well under a millisecond for the brain, a few more for `rich` to render. Almost all of the program's runtime is `rich` drawing the table.
## 13. The test file, in brief
Open `test_hash_identifier.py` if you haven't yet. It's structured as ~25 small `test_*` functions. Each one:
1. Builds a known input.
2. Calls `identify(input)` or `_is_mysql5(input)` etc.
3. Asserts something about the output (`assert candidates[0].algorithm == "bcrypt"`).
A few interesting ones to read:
- **`test_every_prefix_rule_is_recognized_with_high_confidence`** (~line 531) — loops over `PREFIX_RULES` and confirms every row produces a HIGH-confidence match. Adds rows automatically if you add new prefixes.
- **`test_mysql5_rejects_lowercase_body`** (~line 188) — confirms the "don't lie with confidence" rule from the implementation.
- **`test_hash_candidate_is_frozen`** (~line 481) — uses `pytest.raises` to confirm that mutating a frozen dataclass raises `FrozenInstanceError`.
Run them: `just test`. The whole suite is ~30 tests and finishes in under a second.
## 14. What to try next
You've read the file. To make the knowledge stick:
1. Try `just run -- <hash>` with weird inputs — empty string (after quoting), pure digits, super long strings, hashes with trailing whitespace, JWTs, base64.
2. Open `hash_identifier.py` and add a `print()` statement inside `identify()` to see which step matches for each input. Then remove it before committing.
3. Add a new prefix to `PREFIX_RULES` — for instance, scrypt sometimes shows up with `$scrypt$` prefix. Add it, add a test, run `just test`.
4. Read **[04-CHALLENGES.md](./04-CHALLENGES.md)** for harder extension ideas.

View File

@ -0,0 +1,222 @@
# Challenges
You've read the code. You know what every line does. Now the only way to make this knowledge actually yours is to *change* the code. This page is a ladder of extensions, easiest to hardest. Don't skip rungs — each one teaches a thing that the next one assumes.
For every challenge: **write the test first.** Then make the test pass. That's the rhythm professional developers actually use. The test file already shows you the pattern — copy one of the existing `test_*` functions, change the input and expected output, watch it fail, then change the code until it passes.
## Tier 1 — get comfortable
### Challenge 1.1: Add a new prefix rule
`PREFIX_RULES` has ~25 entries today. There are dozens more. Pick one from the list below and add it:
| Prefix | Algorithm | Where it comes from |
| ------------ | ------------------ | ----------------------------------------- |
| `$pbkdf2$` | PBKDF2-SHA1 (Atlassian) | Older Atlassian / Jira hashes |
| `$ml$` | macOS / iCloud Keychain | Apple PBKDF2-SHA512 |
| `{x-pbkdf2}` | PBKDF2 (some Atlassian) | LDAP-style wrapper |
| `$sha1$` | sha1crypt | A rare crypt(3) variant |
| `$md5,` | Solaris MD5 crypt | Note the comma instead of `$` — be careful |
**Steps:**
1. Open `hash_identifier.py`. Add one row to `PREFIX_RULES`.
2. Open `test_hash_identifier.py`. Copy an existing prefix test (e.g. `test_argon2id_prefix_is_recognized`). Rename it. Replace the input string with a sample of your new prefix. Update the assertion.
3. Run `just test`. The new test should pass. The meta-test `test_every_prefix_rule_is_recognized_with_high_confidence` should also still pass.
4. Run the tool on your new input: `just run -- '$pbkdf2$...'`. Confirm the new algorithm shows up.
**What you learn:** the table-driven design pays off — you wrote zero new logic, just data. That's the goal.
### Challenge 1.2: Add a length to HEX_LENGTH_RULES
There's no rule for 24 hex chars (96 bits) right now. That length is rare but `Tiger-128` and some old custom hashes produce it.
1. Add `24: ["Tiger-128"]` to `HEX_LENGTH_RULES`.
2. Write a test (`test_tiger128_length_returns_tiger128`).
3. Run `just test`.
**Twist:** what should happen if someone passes a 24-character string that *isn't* hex? The existing `_is_hex` check should handle it. Read step 3 of `identify()` and confirm.
### Challenge 1.3: Add a `--json` output mode
Right now the CLI only prints a colored table. Add a `--json` flag that prints the candidates as JSON instead. JSON is what every other tool will want to consume — your output becomes machine-readable.
**Hints:**
- `argparse` supports boolean flags via `action="store_true"`. Add `parser.add_argument("--json", action="store_true", help="...")`.
- The standard library `json` module has `json.dumps(data)`.
- `HashCandidate` is a dataclass, so `dataclasses.asdict(candidate)` converts it to a plain dict that `json.dumps` can serialize.
- Test it: `just run -- --json 5f4d...` should output a JSON array.
**Twist:** make the JSON include a top-level `input` field with the original string, so a downstream tool knows what was identified. And pretty-print with `indent=2`.
## Tier 2 — actually new behavior
### Challenge 2.1: Read hashes from a file or stdin
Right now the tool takes one hash per invocation. Real workflows have files with millions of hashes. Extend the CLI so it can take input from a file (`--file hashes.txt`) or from standard input when no positional argument is given (so `cat hashes.txt | hashid` works).
**Hints:**
- Make the positional `hash` argument optional with `nargs="?"`.
- Add `--file` with `type=argparse.FileType("r")`.
- Use `sys.stdin.read()` (or iterate `sys.stdin` line-by-line) when both are missing.
- The output for batch input should be different — probably one line per input, not a colored table for each. Decide what format makes sense and document it.
**Twist:** what should happen if the same input appears 1000 times in the file? Should you re-run `identify()` each time, or cache results? Try both and measure with `just run` on a file of 1M repeated hashes. (Bigger lesson: caching is only a win when the function is pure. Ours is.)
### Challenge 2.2: Add hashcat-mode hints
Hashcat assigns a numeric mode to every algorithm: 0 for MD5, 100 for SHA-1, 3200 for bcrypt, etc. The full list is documented at [hashcat.net/wiki/doku.php?id=example_hashes](https://hashcat.net/wiki/doku.php?id=example_hashes).
Extend `HashCandidate` with an optional `hashcat_mode: int | None` field. When you build a candidate, look up its mode (you'll need a `dict[str, int]` mapping algorithm name → mode) and fill it in.
Then print the mode in the table, and update the "next step" nudge at the end of `main()` to suggest the exact hashcat command:
```
Next step: hashcat -m 3200 -a 0 '$2b$12$EixZ...' wordlist.txt
```
**Hints:**
- `Optional` fields on a dataclass need defaults: `hashcat_mode: int | None = None`.
- The mode lookup is another data table — keep the data-driven design.
- John the Ripper uses different names (e.g. `bcrypt`, `raw-md5`). Add those too if you're feeling generous.
### Challenge 2.3: Recognize more "not a hash" inputs
Step 5 only catches JWTs and base64 blobs. Many other things get pasted into hash identifiers by accident. Add detectors for:
- **URLs** — start with `http://` or `https://`. Tell the user it's a URL.
- **Hex with `0x` prefix** — Ethereum addresses, memory addresses. Tell the user.
- **Base58** — used by Bitcoin addresses and IPFS hashes. Alphabet is `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz` (no `0`, `O`, `I`, `l`).
- **Base32** — uppercase letters + digits 2-7. Used by some Tor onion addresses and TOTP secrets.
Each one is a new branch in step 5, returning a LOW-confidence "not a hash" candidate. Be honest with the confidence — these are *shape* hints, not certainties.
## Tier 3 — extend the model
### Challenge 3.1: Multi-hash detection in one string
Some breach dumps contain *combined* records like `user:hash:salt`. Real workflows often have to split these into the constituent parts before identification. Add a `--split` mode that:
1. Takes a string with colon-separated fields.
2. Identifies each field independently.
3. Prints a table showing which field is what (username, hash, salt, garbage).
**Hints:**
- This is mostly heuristics. A field that's all letters is probably a username; a field that matches a hash rule is probably the hash; a short random-looking field could be a salt.
- Pull the field-classification logic out of `identify()` — it's a new layer on top.
- Test on real-looking lines like `alice:$2b$12$EixZ...`, `bob:5f4dcc3b5aa765d61d8327deb882cf99:salt123`.
### Challenge 3.2: Confidence rebalancing with evidence weights
Right now `confidence` is a single value: high, medium, or low. But evidence is more nuanced. A 32-hex string that *also* contains no characters above 'f' is "very likely hex." A 13-character string from the DES charset is "13-char DES-compatible" but the same characters could appear in many short strings.
Replace `confidence: Literal["high", "medium", "low"]` with `confidence_score: float` in the range 0.01.0. Compute the score from multiple evidence weights:
- prefix match: 0.95
- special shape match: 0.85
- length match (1st candidate): 0.55
- length match (Nth candidate): 0.55 / N
- charset match: small additive bonus
- not-a-hash hint: 0.30
Then in the CLI, map score back to a color (>0.8 green, 0.50.8 yellow, <0.5 cyan) for display. The user still sees three buckets; the internal model gets richer.
**What you learn:** how scoring systems work under the hood. This is the same idea as Bayesian spam classifiers, search relevance ranking, and antivirus heuristics — combine multiple weak signals into one numeric score, then bucket for display.
### Challenge 3.3: Suggest the *crack difficulty* alongside the algorithm
Once you know the algorithm, you also implicitly know how hard the hash is to crack. MD5 cracks at billions of guesses per second on a modern GPU; bcrypt at thousands. Argon2id with strong parameters might be hundreds.
Add a `crack_difficulty: Literal["trivial", "moderate", "hard", "very_hard"]` field to `HashCandidate`, filled in from a per-algorithm table. Print it in the output table.
**Twist:** for parameterized hashes (bcrypt cost factor, Argon2 memory/time), parse the parameters out of the PHC string. A bcrypt hash with cost factor 4 is much weaker than one with cost factor 14. Have your output reflect that:
```
algorithm difficulty reason
───────── ────────── ──────────────────────────────────────────
bcrypt moderate cost=4 — much weaker than the default 12
bcrypt hard cost=12 — the modern default
bcrypt very_hard cost=14 — paranoid setting
```
## Tier 4 — make it real
### Challenge 4.1: Run identification against a real breach dump
The [HaveIBeenPwned](https://haveibeenpwned.com/Passwords) password file is a publicly-distributed list of ~1 billion SHA-1 hashes of leaked passwords, available as a torrent. (Use the **SHA-1** version, not the NTLM version — and use it only for educational analysis; don't try to "crack" it for any malicious purpose. The hashes themselves are public.)
Run your tool against the first 1000 lines. Confirm it identifies them all as SHA-1 (40 hex chars). Measure throughput: how many hashes per second can your tool process? Where's the bottleneck?
**Bigger lesson:** the brain is microseconds; the bottleneck is the CLI printing. To process millions of hashes you'd skip `rich` and stream JSON to stdout. That's a different program for a different use case.
### Challenge 4.2: Compare to `hashid` and `name-that-hash`
Two existing tools do roughly what ours does:
- [hashid](https://github.com/psypanda/hashID) — the classic, ~10 years old, written in pure Python.
- [name-that-hash](https://github.com/HashPals/Name-That-Hash) — newer, more thorough, more aggressive about guessing.
Run all three on the same inputs. Compare:
- Which detects more formats?
- Which has the best false-positive rate (says "definitely SHA-256" on garbage that isn't)?
- Which is fastest on a batch input?
Write up your findings. This is exactly the kind of work security researchers do when picking a tool for production use.
### Challenge 4.3: Wrap it into a `pre-commit` hook
`pre-commit` is a tool that runs checks before you `git commit`. People sometimes accidentally commit password hashes to repos (extremely bad). Build a `pre-commit` hook that runs your identifier on every changed file and refuses the commit if it finds anything that looks like a real hash.
**Hints:**
- Read [pre-commit's hook tutorial](https://pre-commit.com/#new-hooks).
- For each line of each changed file, run `identify(line)`. If the top candidate is HIGH confidence and isn't a generic-PHC or not-a-hash, refuse.
- Exit code 1 = block the commit; exit code 0 = allow it.
- Add allowlist comments like `# pragma: allow-hash` so that legitimate test fixtures don't get blocked.
This is the kind of "small tool, real impact" project that ends up in real security teams' toolchains.
## Tier 5 — break the model
### Challenge 5.1: Why is this a hard problem?
So far we've matched on *structure*: prefix, length, charset. But the model has limits. Consider:
- Two different algorithms producing the same length output (MD5 vs NTLM at 32 hex chars). We *can't* distinguish them from structure alone.
- An algorithm whose output is sometimes uppercase and sometimes lowercase (different libraries produce different cases for the same algo).
- A truncated SHA-256 — someone took the first 32 hex chars of a SHA-256 output and called it a "hash." We'd identify it as MD5.
Write a short document (`docs/limitations.md`) listing every case where the tool *cannot* distinguish two formats and explain why. This is the kind of honesty good security tools document up-front. Don't pretend the tool is more capable than it is.
### Challenge 5.2: Probabilistic identification with a ML classifier
The structural approach is interpretable but limited. The opposite extreme is to train a classifier on labeled hashes and let it learn the structure for you.
Build a tiny experiment:
1. Generate 100k labeled training samples: known passwords hashed with each algorithm. (Use Python's `hashlib`, `bcrypt`, `argon2-cffi`, etc.)
2. Train a simple classifier — `sklearn.linear_model.LogisticRegression` with character-n-gram features works fine for this.
3. Run the classifier on held-out test samples. Measure accuracy.
4. Compare to your rule-based identifier on the same test set.
**What you'll learn:** the rule-based system probably wins on common formats (it has all the structural priors baked in) and the ML model probably wins on unusual cases (it picks up patterns you didn't think to encode). Real-world tools combine both — rules first, ML for tie-breaking. This is how spam classifiers, web application firewalls, and antivirus engines actually work.
> **Don't run any of these challenges on real user data without permission.** The challenges assume publicly-released breach archives, your own test data, or CTF inputs. Identifying hashes from data you don't have rights to is a different conversation and outside the scope of this project.
## Where to go after the challenges
If you finished tier 4 or 5, you should now have a strong feel for the *whole loop*: read code → understand → modify → test → measure → document. That loop is the entire job of a security engineer. The specific topic doesn't matter much — you'd do the same loop for a port scanner, a fuzzer, a SIEM rule, or a vulnerability scanner.
When you're ready for more:
- **`PROJECTS/beginner/hash-cracker`** — the natural sibling to this tool. You learned to identify a hash; that project teaches you how to crack one.
- **`PROJECTS/foundations/http-headers-scanner`** — another foundations-tier project, single-file Python. Slightly more involved I/O (it makes a network request) but the same architectural shape.
- **`PROJECTS/foundations/password-manager`** — the hardest foundations project. Encryption, key derivation, and a real on-disk vault. Read [01-CONCEPTS.md](./01-CONCEPTS.md) first to understand why Argon2id is the right choice for the key derivation step.
- **`PROJECTS/beginner/simple-port-scanner`** — once you've absorbed the patterns here, port scanning is a natural next step. Sockets, concurrency, and timing all show up.
Good luck. Read everything. Write code. Break it. Fix it. That's how you learn this.

View File

@ -0,0 +1,216 @@
# ©AngelaMos | 2026
# pyproject.toml
#
# This file is the "instruction manual" for our Python project. Every
# modern Python project has one. It tells tools like uv, pip, pytest,
# ruff, mypy, and pylint:
#
# - What our project is called
# - Which third-party libraries we depend on
# - How to build and install it
# - How to lint, type-check, and test it
#
# The file format is called TOML — a simple, human-readable config
# language. Square brackets like [project] start a "table" (a section).
# Lines like name = "..." are key-value pairs inside that table.
#
# Read this top-to-bottom. Each section has a short comment explaining
# what it does and why it is here.
# =============================================================================
# [project] — the metadata that describes WHAT this project is
# =============================================================================
# This section is standardized across the whole Python ecosystem (PEP 621).
# Anyone who runs `pip install` or `uv sync` reads from here.
[project]
# The name people will install this package under.
# Must be unique on PyPI if we ever publish there.
name = "hash-identifier"
# Semantic version: MAJOR.MINOR.PATCH.
# 1.0.0 means "first stable release."
# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
version = "1.0.0"
# One-line description that shows up in `pip show` and on PyPI.
description = "Identify hash types by prefix, length, and charset (foundations tier)"
# The minimum Python version this project supports.
# >=3.13 means "Python 3.13 or anything newer."
# We need 3.13 because we use modern type-hint syntax (X | None).
requires-python = ">=3.13"
# Who wrote it. Shows up in package metadata.
authors = [
{name = "CarterPerez-dev", email = "support@certgames.com"}
]
# The README file is shown on PyPI and in `pip show`.
readme = "README.md"
# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
# service, they must publish their changes. It protects the project
# from being privatized.
license = {text = "AGPL-3.0-or-later"}
# -----------------------------------------------------------------------------
# dependencies — third-party libraries we MUST have at runtime
# -----------------------------------------------------------------------------
# When a user runs `pip install hash-identifier`, these get installed too.
# The string format is "<name><operator><version>". We use ranges so we
# get bug fixes (>=) but never an incompatible major version (<).
#
# Just one runtime dependency — the detection logic is pure stdlib.
dependencies = [
# rich: pretty terminal output — colors, tables, panels.
# Used to render the results of identification as a clean table.
"rich>=15.0.0",
]
# -----------------------------------------------------------------------------
# optional-dependencies — extra libraries that are NOT needed at runtime
# -----------------------------------------------------------------------------
# These only matter to developers (testing, linting, formatting). End users
# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
[project.optional-dependencies]
dev = [
# pytest — runs our test suite (the test_*.py file in this directory).
"pytest>=9.0.3",
# ruff — extremely fast linter and formatter. Catches bugs, style
# issues, dead imports. Modern replacement for flake8/black/isort.
"ruff>=0.15.12",
# mypy — static type checker. Reads our type hints and tells us if
# we passed a string where an int was expected, BEFORE running the code.
"mypy>=2.1.0",
# pylint — second linter that catches deeper logic issues ruff misses.
"pylint>=4.0.5",
# yapf — code formatter. Keeps every file looking identical.
"yapf>=0.43.0,<1.0.0",
]
# -----------------------------------------------------------------------------
# project.scripts — command-line entry points
# -----------------------------------------------------------------------------
# After `uv sync`, the user can type `hashid <hash>` instead of
# `python hash_identifier.py <hash>`. The right-hand side is
# "<module>:<function>" — the function gets called when the command runs.
# Here it points to the `main` function inside hash_identifier.py.
[project.scripts]
hashid = "hash_identifier:main"
# =============================================================================
# [build-system] — how to BUILD this project into an installable package
# =============================================================================
# Required by PEP 517. Tools like uv read this to know which builder
# to use. We chose hatchling — modern, fast, zero-config for our case.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# Tell hatchling exactly which file to package. This project is a SINGLE
# Python file (no src/<package>/ layout), so we use `only-include` to
# point at it directly. Without this, hatchling would not know what
# to ship.
[tool.hatch.build.targets.wheel]
only-include = ["hash_identifier.py"]
# =============================================================================
# [tool.ruff] — linter / formatter configuration
# =============================================================================
# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
[tool.ruff]
# Target Python version — ruff adjusts which warnings apply.
target-version = "py313"
# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
line-length = 88
# -----------------------------------------------------------------------------
# Which lint rules to enable. Each "code" is a category of check.
# -----------------------------------------------------------------------------
[tool.ruff.lint]
select = [
"E", # pycodestyle errors — basic PEP 8 spacing/indent rules
"F", # Pyflakes — unused imports, undefined names, real bugs
"W", # pycodestyle warnings — softer style issues
"B", # Bugbear — sneaky bugs (mutable default args, etc.)
"C4", # Comprehensions — encourages cleaner list/dict comprehensions
"UP", # Pyupgrade — flags old syntax we should modernize
"SIM", # Simplify — suggests cleaner equivalents
]
# Some rules we deliberately ignore.
ignore = [
# Line length is handled by yapf, not ruff. Avoids double-flagging.
"E501",
]
# =============================================================================
# [tool.mypy] — static type checker configuration
# =============================================================================
# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
# without running the code. Catches whole categories of bugs at edit time.
[tool.mypy]
python_version = "3.13"
# Require type annotations on every function — strict mode.
disallow_untyped_defs = true
disallow_incomplete_defs = true
# Don't auto-add Optional just because a default is None. Be explicit.
no_implicit_optional = true
# Warn if we cast a value to a type it already has (dead code).
warn_redundant_casts = true
# Warn if a function might fall off the end without returning.
warn_no_return = true
# Pretty error output — helps when reading mypy output.
show_error_codes = true
pretty = true
# =============================================================================
# [tool.pylint] — second-opinion linter
# =============================================================================
# Slower than ruff but catches different things — class design issues,
# dead variables, complex code patterns.
[tool.pylint.main]
# Match the project's Python version.
py-version = "3.13"
# Run lint checks in parallel across 4 cores for speed.
jobs = 4
# Specific pylint warnings we deliberately turn off.
[tool.pylint.messages_control]
disable = [
"R0903", # too-few-public-methods — small data classes are fine
"C0103", # invalid-name — short names like `s` for hash strings are clearer
]
# =============================================================================
# [tool.pytest.ini_options] — test runner configuration
# =============================================================================
[tool.pytest.ini_options]
# Where to look for tests. "." = the project root, since this project's
# test file lives next to the source file.
testpaths = ["."]
# Files that match this pattern are treated as test modules.
python_files = ["test_*.py"]
# Default flags every `pytest` invocation gets:
# -v → verbose, show each test name
# --tb=short → short tracebacks on failure (less wall of text)
addopts = "-v --tb=short"

View File

@ -0,0 +1,554 @@
"""
©AngelaMos | 2026
test_hash_identifier.py
Tests for hash_identifier focused on the most-relied-on cases
What "tests" are and why we write them
A test is a tiny Python function that calls our real code with a
known input and then ASSERTS that the result is what we expected.
If the assertion fails, pytest prints a red FAIL message which
means we changed something and broke a behavior we cared about
Tests are insurance. The first time you write the code, the test
just confirms it works. But six months later when you refactor or
add a new feature, the existing tests catch any accidental breakage.
This is why every senior codebase has tests: not because the code is
hard to write, but because the code is hard to keep WORKING over time
The shape of a pytest test
def test_<what_we_are_checking>() -> None:
result = some_function(some_input)
assert result == expected
Three rules
1. The function name must start with `test_` pytest only collects
functions that match that pattern
2. The function takes no arguments (unless using fixtures)
3. Use the `assert` keyword to declare what should be true. If the
condition is false, the test fails
We follow the "Arrange-Act-Assert" structure inside each test
- Arrange: set up inputs (the `sample = ...` line)
- Act: call the real code (`candidates = identify(sample)`)
- Assert: check the result (`assert candidates[0]...`)
Coverage strategy
We do NOT try to test every algorithm in the table that would be
hundreds of nearly identical tests. Instead we exercise each BRANCH
of identify() at least once
- prefix matches (one bcrypt, one Argon2id, one Django, one crypt)
- special MySQL5 format
- hex length matches (MD5, SHA-1, SHA-256 lengths)
- the empty / garbage / whitespace fallbacks
- HashCandidate's immutability
Together these give us confidence that every code path runs without
explosion, and that the most-common inputs produce the expected
top-ranked candidate
"""
# Importing from `hash_identifier` (NOT `hash_identifier.py`) tells
# Python to load the module that lives in this same directory. We
# pull in three things:
# - `identify` — the function under test
# - `HashCandidate`— the return-type dataclass (used in the
# immutability test)
# - `PREFIX_RULES` — the prefix lookup table (used by the
# parametrized "every row is covered" test
# at the bottom of this file)
# `pytest` is also imported so we can use the @pytest.mark.parametrize
# decorator to expand one test function into many test cases
# Third-party: the test runner itself. We also need it imported here
# so we can use its `@pytest.mark.parametrize` decorator below.
import pytest
# Local: our own module. We pull in the public pieces under test —
# the prefix-rule table, the result dataclass, and the entry function.
from hash_identifier import PREFIX_RULES, HashCandidate, identify
# =============================================================================
# Prefix matches (high confidence)
# =============================================================================
# These tests verify Step 1 of identify(): when the input starts with a
# known prefix, we report HIGH confidence. The exact hash payload after
# the prefix does not matter to identify() — it only inspects the
# leading characters
def test_bcrypt_prefix_is_recognized() -> None:
"""
A real bcrypt hash starts with `$2b$` and should be reported as bcrypt
"""
# Sample: an actual bcrypt hash for the password "password" with cost 12.
# The interesting part for our test is just `$2b$` — we never even
# decode the rest
sample = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
# Call the function under test. `candidates` is a list of HashCandidate
candidates = identify(sample)
# First assert the list is non-empty. `assert <thing>` fails when
# <thing> is falsy — empty lists are falsy, so this catches the
# "no candidates returned" bug
assert candidates
# Then check the FIRST candidate (highest-priority guess) is bcrypt.
# `candidates[0]` is the first item; `.algorithm` is the field we
# check. `==` compares for equality
assert candidates[0].algorithm == "bcrypt"
# And confidence must be "high" — prefix matches are definitive
assert candidates[0].confidence == "high"
def test_argon2id_prefix_is_recognized() -> None:
"""
Argon2id PHC strings begin with `$argon2id$`
"""
# PHC format for Argon2id: $argon2id$v=<version>$m=...,t=...,p=...$<salt>$<hash>
sample = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaA"
candidates = identify(sample)
# `any(...)` returns True if at least one element of the iterable
# makes the inner expression true. We check that AT LEAST ONE
# candidate is Argon2id — using any() instead of [0] keeps the test
# robust if we ever add a second guess to the same prefix
assert any(c.algorithm == "Argon2id" for c in candidates)
def test_sha512_crypt_prefix_is_recognized() -> None:
"""
`$6$` is the marker for SHA-512 crypt what /etc/shadow uses on Linux
"""
sample = "$6$rounds=10000$salt$hashedpasswordhere"
candidates = identify(sample)
# `[0]` because we want the TOP candidate. If anything else was
# ranked first, this assertion would fail loudly
assert candidates[0].algorithm == "SHA-512 crypt"
def test_django_pbkdf2_prefix_is_recognized() -> None:
"""
Django stores passwords as `pbkdf2_sha256$<iter>$<salt>$<hash>`
"""
sample = "pbkdf2_sha256$260000$salt$hash"
candidates = identify(sample)
assert candidates[0].algorithm == "Django PBKDF2-SHA256"
def test_apr1_prefix_is_recognized() -> None:
"""
Apache `.htpasswd` MD5 hashes start with $apr1$
The htpasswd tool generates these by default with the `-m` flag.
Same MD5 family as the Unix $1$ format but with Apache's own salt
handling and FAR more common in the wild because every Apache
basic-auth tutorial ends with one of these in a .htpasswd file
"""
# Real-looking apr1 hash. The trailing payload after the second
# `$` is the base64-flavored encoding of the MD5 digest + salt.
# identify() never decodes it — only the leading `$apr1$` matters
sample = "$apr1$rsalt$mp7TYYDvbgvNCJN3JTd6q1"
candidates = identify(sample)
assert candidates[0].algorithm == "Apache MD5-crypt"
assert candidates[0].confidence == "high"
# =============================================================================
# Special formats
# =============================================================================
# Step 2 of identify(): formats that are NOT PHC strings but still have
# unmistakable shapes. Today we recognize NetNTLMv1, NetNTLMv2, and
# MySQL5 — three structurally-distinct records that all benefit from
# the same "check the literal shape before falling back to length" path
def test_mysql5_format_is_recognized() -> None:
"""
MySQL5 = literal `*` followed by 40 uppercase hex chars
MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
a leading asterisk. So the whole hash is exactly 41 characters
"""
# The * matters — without it, this would just be 40 hex chars
# and would fall through to the SHA-1 length rule
sample = "*23AE809DDACAF96AF0FD78ED04B6A265E05AA257"
candidates = identify(sample)
# MySQL5 is a definitive shape, so we expect HIGH confidence
assert candidates[0].algorithm == "MySQL5"
assert candidates[0].confidence == "high"
def test_mysql5_rejects_lowercase_body() -> None:
"""
Lowercase hex after the leading `*` is not real MySQL5 output
MySQL emits uppercase via `%02X`, so a `*` followed by lowercase
hex is almost certainly hand-edited junk rather than a real
MySQL5 hash. We would rather return nothing than return a
confident WRONG answer here
"""
# Lowercase version of the previous test's body. The leading `*`
# is the only thing it shares with real MySQL5 output, and the
# case mismatch alone should disqualify it
lowercase_body = "23ae809ddacaf96af0fd78ed04b6a265e05aa257"
candidates = identify("*" + lowercase_body)
# Either the list is empty (preferred, what we expect today) OR
# whatever did match must NOT be labeled MySQL5. The `if`
# guard makes the test robust to future rules that might catch
# this shape under a different (correct) label — what matters is
# that we do not LIE and call it MySQL5
if candidates:
assert candidates[0].algorithm != "MySQL5"
def test_netntlmv2_format_is_recognized() -> None:
"""
NetNTLMv2 records from Responder look like
`user::domain:challenge:hmac:blob`
The hmac field is exactly 32 hex chars. The leading `::` (empty
LM-hash slot) is the giveaway that this is an AD challenge-
response record, not a stored password hash
"""
# Build a realistic NetNTLMv2 record:
# alice :: CORP : <16-char challenge> : <32 hex hmac> : <64 hex blob>
# The actual hash values do not matter — only the structural
# shape (colon count + hex field lengths) is what identify() checks
sample = "alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 64
candidates = identify(sample)
# NetNTLMv2 is a definitive shape — HIGH confidence
assert candidates[0].algorithm == "NetNTLMv2"
assert candidates[0].confidence == "high"
def test_netntlmv1_format_is_recognized() -> None:
"""
NetNTLMv1 records have 48-hex-char lmhash AND nthash before the challenge
Layout: `user::domain:lm(48 hex):nt(48 hex):challenge`. We
recognize this by looking at field index 3 in NetNTLMv1 it is
exactly 48 hex chars, while in NetNTLMv2 it is the (shorter)
challenge field
"""
# Build a realistic NetNTLMv1 record. The 48-char lmhash and
# nthash are the load-bearing signal; identify() never decodes
# them — only their length and charset matter
sample = "alice::CORP:" + "a" * 48 + ":" + "b" * 48 + ":1122334455667788"
candidates = identify(sample)
assert candidates[0].algorithm == "NetNTLMv1"
assert candidates[0].confidence == "high"
def test_descrypt_format_is_recognized() -> None:
"""
Traditional DES crypt has NO prefix only length and charset identify it
Legacy /etc/passwd from pre-shadow Unix systems used this format:
13 characters drawn from the alphabet `./0-9A-Za-z`. Rare today
but still turns up in retro CTFs, and hashcat still ships a mode
(1500) for cracking it
"""
# A realistic 13-char DES crypt output. Content does not matter
# for the test — only the length and the all-valid-charset
# property are what _is_descrypt checks
sample = "kRq14pmccuMOA"
candidates = identify(sample)
assert candidates[0].algorithm == "DES crypt"
# MEDIUM (not HIGH) confidence because a 13-char `./0-9A-Za-z`
# string CAN technically be other things (session IDs, random
# tokens). An honest medium beats a confident false-positive
assert candidates[0].confidence == "medium"
# =============================================================================
# Hex length matches (medium / low confidence)
# =============================================================================
# Step 3 of identify(): when the input is pure hex, length narrows down
# the algorithm. The FIRST listed algorithm for each length gets
# medium confidence (the modern default); the rest are low
def test_mysql323_length_returns_mysql323_first() -> None:
"""
16 hex chars points at MySQL323 (legacy MySQL OLD_PASSWORD output)
MySQL versions before 4.1 stored passwords as a 16-char hex string
from a custom (now-broken) hash function. The OLD_PASSWORD() SQL
function still produces this format on modern MySQL for legacy
compatibility, so these still show up in CTFs and old MySQL
breach dumps
"""
# A 16-hex-char string. The content does not matter — only the
# length and the all-hex charset are what identify() checks
sample = "5d2e19393cc5ef67"
candidates = identify(sample)
# MySQL323 ranks ABOVE CRC-64 because in a security context (a
# CTF challenge, a breach dump, a password column) MySQL323 is
# by far the more likely source. A 64-bit CRC almost never
# arrives at a hash-identifier tool
assert candidates[0].algorithm == "MySQL323"
# MEDIUM confidence: length alone is suggestive but cannot rule
# out CRC-64 with certainty without seeing the surrounding context
assert candidates[0].confidence == "medium"
def test_md5_length_returns_md5_first() -> None:
"""
32 hex chars matches MD5, NTLM, MD4, RIPEMD-128
MD5 is BY FAR the most common 32-hex hash in the wild, so it must
be the top candidate. NTLM (a Windows hash) is also 32 hex but
far less common in pasted hash dumps, so it should appear later
"""
# The literal MD5 of the string "password". A useful sample because
# any reader can verify it: `echo -n password | md5sum`
sample = "5f4dcc3b5aa765d61d8327deb882cf99"
candidates = identify(sample)
# Top candidate is MD5
assert candidates[0].algorithm == "MD5"
# And we report MEDIUM confidence — length alone is suggestive
# but not definitive (no prefix to confirm)
assert candidates[0].confidence == "medium"
# NTLM should appear in the candidate list as a less-likely option.
# We pull just the algorithm names into a list using a comprehension,
# then check membership with `in`
algorithms = [c.algorithm for c in candidates]
assert "NTLM" in algorithms
def test_sha256_length_returns_sha256_first() -> None:
"""
64 hex chars points at SHA-256 first
SHA3-256 and BLAKE2s also produce 64 hex chars but are rarer in
real systems, so SHA-256 takes the top spot
"""
# `"a" * 64` is Python shorthand for "the character 'a' repeated 64
# times" — a quick way to make a 64-char string for length tests.
# The actual content does not matter; only the length does
sample = "a" * 64
candidates = identify(sample)
assert candidates[0].algorithm == "SHA-256"
def test_sha1_length_returns_sha1_first() -> None:
"""
40 hex chars = SHA-1 (RIPEMD-160 as a backup guess)
"""
sample = "a" * 40
candidates = identify(sample)
assert candidates[0].algorithm == "SHA-1"
# =============================================================================
# No-match / edge cases
# =============================================================================
# Always test the boring edge cases. Empty inputs, whitespace-only,
# garbage. These caused real bugs in real codebases more than once
def test_empty_input_returns_no_candidates() -> None:
"""
Empty string returns an empty list never blows up
"""
# Two checks because both of these have caused crashes elsewhere:
# truly empty AND whitespace-only (which strips down to empty)
assert identify("") == []
assert identify(" ") == []
def test_garbage_returns_no_candidates() -> None:
"""
A string with neither a known prefix nor a hex shape returns []
"""
# Sentence with spaces and punctuation: cannot be hex, has no
# PHC prefix. identify() should return an empty list, not guess
assert identify("hello, this is not a hash") == []
def test_input_is_trimmed_of_whitespace() -> None:
"""
Trailing newlines and leading spaces should not block recognition
This matters because copy-paste from a terminal often picks up
invisible whitespace. We strip it inside identify()
"""
# Note the leading spaces and trailing \n — escaped newline character
sample = " 5f4dcc3b5aa765d61d8327deb882cf99\n"
candidates = identify(sample)
# If trim works, we still recognize MD5 despite the surrounding noise
assert candidates[0].algorithm == "MD5"
# =============================================================================
# Soft-match fallbacks (shape hints, LOW confidence)
# =============================================================================
# Steps 4 and 5 of identify(): when nothing in the PREFIX_RULES table
# or the special-formats step or the hex-length table fires, we still
# try two soft matches — generic PHC string shape, then "this looks
# like a JWT / base64 blob, not a hash." Both return LOW confidence
# because shape alone is a weaker signal than a known prefix
def test_unknown_phc_string_falls_back_to_generic() -> None:
"""
A PHC string from an algorithm we don't have a specific rule for
is still reported as a PHC string with the extracted algorithm name
This is a SOFT match (LOW confidence) but still beats the
alternative of returning nothing on an obviously-PHC-shaped input
"""
# Passlib's pbkdf2-sha512 PHC encoding. We have no specific rule
# for it in PREFIX_RULES — but the `$pbkdf2-sha512$...` shape is
# unambiguous, so the generic fallback should pick it up
sample = "$pbkdf2-sha512$25000$cnNhbHQ$aGFzaA"
candidates = identify(sample)
assert candidates
# The algorithm column should say "PHC string (pbkdf2-sha512)" —
# both the marker word "PHC" and the extracted algorithm name
# should appear so the user knows WHAT kind of thing they pasted
assert "PHC" in candidates[0].algorithm
assert "pbkdf2-sha512" in candidates[0].algorithm
# LOW confidence because we matched on shape only, not on a
# specific rule that would let us confirm the algorithm
assert candidates[0].confidence == "low"
def test_jwt_input_is_called_out_as_not_a_hash() -> None:
"""
JWTs start with `eyJ` and should be called out as not-a-hash
Beginners often paste JWTs into a hash identifier because they
look hash-like. Saying "this is a JWT, not a hash" is more
useful than silence it teaches the user what they have
"""
# A short but real-shaped JWT (header.payload.signature). The
# signature here is intentionally not real — identify() never
# validates it, only the leading `eyJ` matters
sample = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig"
candidates = identify(sample)
assert candidates
# The algorithm column should contain "JWT" so the user knows
# exactly what kind of thing they pasted
assert "JWT" in candidates[0].algorithm
# LOW confidence is honest: we are not 100% sure this is a JWT,
# we are 100% sure it starts with `eyJ` and JWTs start with `eyJ`
assert candidates[0].confidence == "low"
def test_base64_blob_is_called_out_as_not_a_hash() -> None:
"""
A string containing base64-only chars (`+`, `/`, `=`) is not hex
Hex hashes never contain those characters, so their presence is a
strong signal that the input is base64-encoded data of some kind
rather than a hash even when we cannot say what it decodes to
"""
# A base64-looking blob with the `=` padding character
sample = "VGhpcyBpcyBub3QgYSBoYXNoLCBpdHMgYmFzZTY0Lg=="
candidates = identify(sample)
assert candidates
# "Base64 blob" should appear in the algorithm name so the user
# knows we did identify SOMETHING, just not a hash
assert "Base64" in candidates[0].algorithm
# =============================================================================
# HashCandidate is immutable
# =============================================================================
# We declared HashCandidate with @dataclass(frozen=True). Frozen means
# you cannot reassign fields after construction — like a sealed envelope.
# This test makes sure the seal actually holds, so future refactors do
# not accidentally drop the `frozen` flag
def test_hash_candidate_is_frozen() -> None:
"""
Attempting to mutate a HashCandidate must raise an error
"""
# First, construct a normal instance
candidate = HashCandidate(
algorithm = "MD5",
confidence = "medium",
reason = "test",
)
# try/except is Python's "guard against an error" syntax. Inside
# `try`, we attempt the operation we EXPECT to fail. The `except`
# block runs ONLY if the listed exception types fire — and our
# frozen dataclass raises one of these on assignment
try:
# `# type: ignore[misc]` tells mypy "I know this is a type error;
# I am doing it on purpose to verify it actually fails at runtime"
candidate.algorithm = "SHA-1" # type: ignore[misc]
except (AttributeError, TypeError):
# Got the expected exception — the test passes by returning
return
# If we reached this line, no exception was raised — frozen is
# broken. Fail the test loudly with a clear message
raise AssertionError(
"HashCandidate should be frozen; assignment should have raised"
)
# =============================================================================
# Comprehensive PREFIX_RULES table coverage
# =============================================================================
# The individual prefix tests above (bcrypt, Argon2id, etc.) double as
# readable EXAMPLES of how identify() handles known prefixes. This last
# test plays a different role: it is the safety-net that guarantees
# EVERY row of PREFIX_RULES is exercised, so a typo in any single row's
# algorithm name or note string fails its own test case rather than
# slipping through silently.
#
# `@pytest.mark.parametrize(name, values)` is pytest's mechanism for
# expanding ONE test function into MANY test cases. Here we hand it
# the entire PREFIX_RULES list. Pytest unpacks each `(prefix, algorithm,
# note)` tuple into the three parameters of the test function and runs
# the body once per row. The leading `_` in `_note` tells linters
# (and the next reader) that we intentionally do not assert on the
# note string — we accept whatever the table author wrote
@pytest.mark.parametrize("prefix,algorithm,_note", PREFIX_RULES)
def test_every_prefix_rule_is_recognized_with_high_confidence(
prefix: str,
algorithm: str,
_note: str,
) -> None:
"""
Every entry in PREFIX_RULES produces a HIGH-confidence candidate
with the matching algorithm when its prefix sits at the start
of the input
The body of the hash after the prefix does not matter to
identify() it only inspects the leading characters so we
just glue any junk onto the end to form a syntactically-plausible
input
"""
sample = prefix + "fakebodydoesntmatter"
candidates = identify(sample)
# If identify() returned nothing, the prefix-loop branch is
# broken — fail with a message that names the offending prefix
# so the failure is debuggable at a glance
assert candidates, f"no candidates returned for prefix `{prefix}`"
assert candidates[0].algorithm == algorithm
assert candidates[0].confidence == "high"

View File

@ -0,0 +1,350 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "astroid"
version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
[[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 = "dill"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
name = "hash-identifier"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pylint" },
{ name = "pytest" },
{ name = "ruff" },
{ name = "yapf" },
]
[package.metadata]
requires-dist = [
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0,<2.0.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.0,<5.0.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.0,<10.0.0" },
{ name = "rich", specifier = ">=14.2.0,<15.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.0,<0.15.0" },
{ name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
]
[[package]]
name = "librt"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/cb/c1945e506893b5b8577fb45a60c80e3ffe4a82092a04a6f29b0b951d9a24/librt-0.10.0.tar.gz", hash = "sha256:1aba1e8aa4e3307a7be68a74149545fde7451964dc0235a8bec5704a17bdda42", size = 191799, upload-time = "2026-05-05T16:31:23.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/29/681a75c82f4cc90d29e4b257a3299b79fe13fe927a04c57b8109d70b6957/librt-0.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f0ede79d682e73f91c1b599a76d78b7464b9b5d213754cedb13372d9df36e596", size = 77299, upload-time = "2026-05-05T16:30:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/62/24/0c7ca445a55d04be79cac19819437fd094782347fa116f6681844fa6143e/librt-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0ba0b131fdb336c8b9c948e397f4a7e649d0f783b529f07b647bf4961df392e", size = 79930, upload-time = "2026-05-05T16:30:01.555Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1f/1e2b8f6443ef9e9a81e89486ca70e22f3684f93db003ce6eaefc3d0839b9/librt-0.10.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2728117da2afb96fb957768725ee43dc9a2d73b031e02da424b818a3cdd3a275", size = 246195, upload-time = "2026-05-05T16:30:03.261Z" },
{ url = "https://files.pythonhosted.org/packages/74/61/9dc9e03de0439ad84c1c240aac8b747f12c90cb797ea6042f7bdb8d3410f/librt-0.10.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:723ba80594c49cdf0584196fc430752262605dc9449902fc9bd3d9b79976cb77", size = 234951, upload-time = "2026-05-05T16:30:04.881Z" },
{ url = "https://files.pythonhosted.org/packages/55/f4/635223117d7590875bca441275065a3bf491203ad4208bd1cc3ffd90c5a1/librt-0.10.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7292edaaca294a61a978c53a3c7d6130d099b0dfbc8f0a65916cdc6b891b9852", size = 262768, upload-time = "2026-05-05T16:30:06.638Z" },
{ url = "https://files.pythonhosted.org/packages/e5/66/b04152d0cd8b6ca2b428a8bd3230343230c35ed304a932f35b5375f2f828/librt-0.10.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89fe9d539f2c10a1666633eeeac507ce95dd06d9ecc58de3c6390dba156a3d3a", size = 255075, upload-time = "2026-05-05T16:30:08.216Z" },
{ url = "https://files.pythonhosted.org/packages/35/1e/25bac4c7f2ca36f0e612cade186970683cf79153d96beccc3a11a9e19b97/librt-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4efa7b9587503fa5b67f40593302b9c8836d211d222ff9f7cafe67be5f8f0b10", size = 268559, upload-time = "2026-05-05T16:30:10.1Z" },
{ url = "https://files.pythonhosted.org/packages/18/54/4601faab35b6632a13200faa146ca62bfd111ffbe2568be430d65c89493a/librt-0.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:22dc982ef59df0136df36092ccbdbb570ced8aafb33e49585739b2f1de1c13b6", size = 261753, upload-time = "2026-05-05T16:30:11.912Z" },
{ url = "https://files.pythonhosted.org/packages/1b/cf/39f4023509e94fade8b074666fa3292db9cb6b34ea5dcbe7af53df9fca1d/librt-0.10.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6f2e5f3606253a84cea719c94a3bb1c54487b5d617d0254d46e0920d8a06be3f", size = 264055, upload-time = "2026-05-05T16:30:13.465Z" },
{ url = "https://files.pythonhosted.org/packages/8e/00/40247209fc46a8e308a91412d5206aedf8efb667ee89eb625820106a5c2f/librt-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:40884bfaa1e29f6b6a9be255007d8f359bfc9e61d68bdef8ed3158bfcbc95df9", size = 286190, upload-time = "2026-05-05T16:30:15.073Z" },
{ url = "https://files.pythonhosted.org/packages/d8/6e/5566beb94431a985abe1787af5ef86e087750172ff9d0bbf20f93e88132d/librt-0.10.0-cp313-cp313-win32.whl", hash = "sha256:3cd34cd8254eba756660bff6c2da91278248184301054fe3e4feb073bdd49b14", size = 62949, upload-time = "2026-05-05T16:30:16.503Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c2/3ea3301d6c8dff51d39dbe8ed75db3dc92896947d4afb5eeadf821c1e67f/librt-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:7baac5313e2d8dce1386f97777a8d03ab28f5fe1e780b3b9ac2ee7544551fedc", size = 71152, upload-time = "2026-05-05T16:30:17.766Z" },
{ url = "https://files.pythonhosted.org/packages/3c/de/5d49cb92cadcbc77d3abc27b93fd6030ed8437487dde2eae38cab5e6704d/librt-0.10.0-cp313-cp313-win_arm64.whl", hash = "sha256:afc5b4406c8e2515698d922a5c7823a009312835ea58196671fff40e35cb8166", size = 61336, upload-time = "2026-05-05T16:30:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/6a/64/7165e08108cc185a13a9c069f0685e6ef92e70e07fddf7edf5e7348c6316/librt-0.10.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f09588a30e6a22ec624090d72a3ab1a6d4d5485c3ed739603e76aa3c16efa688", size = 76794, upload-time = "2026-05-05T16:30:20.392Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ef/bf8613febf651b90c5222ee79dea5ae58d4cc2b544df69d3033424448934/librt-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:131ade118d12bd7a0adc4e655474a553f1b76cf78385868885944d21d51e45e0", size = 79662, upload-time = "2026-05-05T16:30:22.025Z" },
{ url = "https://files.pythonhosted.org/packages/b6/67/9eddd165c1d8397bdf99b38bf12b5a55b3def5035b49eedb49f2775d1430/librt-0.10.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8b9ab28e40d011c373a189eae900c916e66d6fbecf7983e9e4883089ee085ef", size = 242390, upload-time = "2026-05-05T16:30:23.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/d1/d95da80334501866cd37004ab5d7483220d05862fab4b5405394f0264f0d/librt-0.10.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:67c39bb30da73bae1f293d1ed8bc2f8f6642649dd0928d3600aeff3041ac23d6", size = 232603, upload-time = "2026-05-05T16:30:25.198Z" },
{ url = "https://files.pythonhosted.org/packages/0c/fa/e6d64d28718bc1be4e1736fcb037ca1c4dfca927e7167df75a7d5215665e/librt-0.10.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c3273c6b774614f093c8927c2bf1b077d0fefde988fe98f46a333734e5597ab", size = 259187, upload-time = "2026-05-05T16:30:26.772Z" },
{ url = "https://files.pythonhosted.org/packages/72/3f/3fdb77e7f937dad59cfd76b720be7e7643400ec76b2da35befab8d66ba30/librt-0.10.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9dd7c1b86a4baa583ab5db977484b93a2c474e69e96ef3e9538387ea54229cb9", size = 251846, upload-time = "2026-05-05T16:30:28.56Z" },
{ url = "https://files.pythonhosted.org/packages/18/ca/f4d49133dd86a6f55d79eca30bf412fa722f511a9abe67f62f57aa64e66a/librt-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a77385c5a202e831149f7ad03be9e67cf80e957e52c614e83dcb822c95222eb8", size = 264936, upload-time = "2026-05-05T16:30:30.491Z" },
{ url = "https://files.pythonhosted.org/packages/de/66/a8df2fbadc1f6c1827a096d11c40175bd526133480bd3bc88ec64a03d257/librt-0.10.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c6a5eafa74b5655bad59886138ed68426f098a6beb8cb95a71f2cc3cd8bb33fe", size = 258699, upload-time = "2026-05-05T16:30:32.002Z" },
{ url = "https://files.pythonhosted.org/packages/bb/73/1e3c83613fe05451bb969e27b68a573d177f08d5f63533cc29fec0989658/librt-0.10.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1fc93d0439204c50ab4d1512611ce2c206f1b369b419f69c7c27c761561e3291", size = 259825, upload-time = "2026-05-05T16:30:35.077Z" },
{ url = "https://files.pythonhosted.org/packages/09/24/5e2f926ee9d3ef348d9339526d7062abb5c44d8419e3179528c01d78c102/librt-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:79e713c178bc7a744adfbee6b4619a288eecc0c914da2a9313a20255abe2f0cf", size = 282548, upload-time = "2026-05-05T16:30:36.639Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7d/3e89ed6ad0162561fa8bef9df3195e24263104c955713cd0237d3711fad2/librt-0.10.0-cp314-cp314-win32.whl", hash = "sha256:2eba9d955a68c41d9f326be3da42f163ec3518b7ab20f1c826224e7bed71e0bf", size = 58970, upload-time = "2026-05-05T16:30:38.183Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/579e731c94a7086a268bfa3e7a4945cd47836bebd3cbf3faeafd2e7eaef9/librt-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbfaf7f5145e9917f5d18bffa298eff6a19d74e7b8b11dabdca95785befe8dbf", size = 67260, upload-time = "2026-05-05T16:30:39.804Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f8/235822b7ae0b2334f12ee18bcf2476d07924077a5efeea57dbe927704be2/librt-0.10.0-cp314-cp314-win_arm64.whl", hash = "sha256:8d6d385d1969849a6b1397114df22714b6ded917bada98668e3e974dc663477e", size = 57156, upload-time = "2026-05-05T16:30:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e3/9b919cbf1e8eb770bf91bb7df28125e0f1daf4587169afefd95402636e9a/librt-0.10.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:6c3a82d3bd32631ef5c79922dfc028520c9ad840255979ab4d908271818039ee", size = 79150, upload-time = "2026-05-05T16:30:42.761Z" },
{ url = "https://files.pythonhosted.org/packages/6a/f5/72a944aa3bc3498169a168087eff58ca48b58bf1b704e59d091fd30739f3/librt-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d64cc66005dc324c9bb1fa3fc2841f529002f6eb15966d55e46d430f56955a6a", size = 82304, upload-time = "2026-05-05T16:30:44.082Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e3/fcc290a33e295019759472dfa794d204e43504b276ac65eab7fd9da20ea3/librt-0.10.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb562cd28c88cd2c6a9a6c78f99dc39348d6b16c94adc25de0e574acf1176e9", size = 272556, upload-time = "2026-05-05T16:30:45.497Z" },
{ url = "https://files.pythonhosted.org/packages/fd/54/546975e4c997573885e7f040a05012f8838e06fb12b0c3c1fbb76254e9d7/librt-0.10.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b809aa2854d019c28773b03605df22adc675ee4f3f4402d673581313e8906119", size = 256941, upload-time = "2026-05-05T16:30:47.059Z" },
{ url = "https://files.pythonhosted.org/packages/70/8c/f1d03401571b331653acddbd4e8cd955c06d945241dd08b25192fac0d04b/librt-0.10.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc15acabdd519bd4176fdadc2119e5e3093485d86f89138daf47e5b4cedb983a", size = 285855, upload-time = "2026-05-05T16:30:48.86Z" },
{ url = "https://files.pythonhosted.org/packages/0c/08/62cf80ff046c339faf56718b3a940244d4beb70f1c6407289b5830ec11e9/librt-0.10.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b1b2d835307d08ddadd94568e2369648ec9173bd3eea6d7f52a1abe717c81f98", size = 275321, upload-time = "2026-05-05T16:30:50.63Z" },
{ url = "https://files.pythonhosted.org/packages/d9/ea/da5918d4070362e9a4d2ee9cd34f9dc84902daad8fd4275f8504a727ff4e/librt-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d261c6a2f93335a5167887fb0223e8b98ffce20ee3fde242e8e58a37ece6d0e5", size = 293993, upload-time = "2026-05-05T16:30:52.577Z" },
{ url = "https://files.pythonhosted.org/packages/c9/8d/68b6086bed1fcdc314c640ea04e31e52d18052e08059fa595409d66a51a9/librt-0.10.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e2ffd44963f8e7f68995504d90f9881d64e94dc1d8e310039b9526108fc0c0f7", size = 284254, upload-time = "2026-05-05T16:30:55.086Z" },
{ url = "https://files.pythonhosted.org/packages/06/c8/b810f1d84ec34a5a7ed93d7b510ab04164d75fbdf23088d5c3fbe6b08357/librt-0.10.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f285f6455ed495791c4d8630e5af732960adea93cac4c893d15619f2eae53e8", size = 284925, upload-time = "2026-05-05T16:30:56.728Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/3c82d4158c5a2c62528b8fccce65a8c9ad700e480e86f9389387435089a5/librt-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6034ff52e663d34c7b82ef2aa2f94ad7c1d939e2368e63b06844bc4d127d2e1", size = 307830, upload-time = "2026-05-05T16:30:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/99/3a/9c635ac3e8a00383ff689161d3eac8a30b3b2ddc711b40471e6b8983ea29/librt-0.10.0-cp314-cp314t-win32.whl", hash = "sha256:657860fd877fba6a241ea088ef99f63ca819945d3c715265da670bad56c37ebe", size = 60147, upload-time = "2026-05-05T16:31:00.293Z" },
{ url = "https://files.pythonhosted.org/packages/dc/e8/6f65f3e565d4ac212cddddd552eacc8035ffdf941ca0ad6fe945a211d41f/librt-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56ded2d66010203a0cb5af063b609e3f079531a0e5e576d618dece859fd2e1af", size = 68649, upload-time = "2026-05-05T16:31:01.778Z" },
{ url = "https://files.pythonhosted.org/packages/51/78/a0705a67cacd81e5fa01a5035b3adbdfbb43a7b8d4bd27e2b282ae61baf2/librt-0.10.0-cp314-cp314t-win_arm64.whl", hash = "sha256:1ee63f30abf18ed4830fdbaf87b2b6f4bba1e198d46085c314edde4045e56715", size = 58247, upload-time = "2026-05-05T16:31:03.191Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mccabe"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "1.20.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
{ url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
{ url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
{ url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
{ url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
{ url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[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.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pylint"
version = "4.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "rich"
version = "14.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
]
[[package]]
name = "ruff"
version = "0.14.14"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
]
[[package]]
name = "tomlkit"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
]

View File

@ -0,0 +1,70 @@
# ©AngelaMos | 2026
# .gitignore
#
# This file lists patterns of files and folders that git should NEVER
# track. Anything matching a pattern below is invisible to `git status`
# and won't be committed. We do this to avoid two problems:
#
# 1. Privacy — we never want to accidentally commit a sensitive file
# a user might pass to the tool.
#
# 2. Noise — build artifacts and caches change constantly. Tracking
# them would clutter every commit and create merge conflicts.
#
# Each line is a "glob" pattern (* matches any characters).
# -----------------------------------------------------------------------------
# Python build/install artifacts
# -----------------------------------------------------------------------------
# __pycache__: bytecode Python generates from .py files when it runs them.
# We never edit these, and they regenerate on every run, so ignore them.
__pycache__/
# *.py[cod] = .pyc, .pyo, .pyd — older bytecode formats Python may emit.
*.py[cod]
*$py.class
# Build outputs from `pip wheel`, hatchling, etc.
*.egg-info/
*.egg
build/
dist/
.eggs/
*.so
# -----------------------------------------------------------------------------
# Virtual environments — created by `uv venv`, never committed
# -----------------------------------------------------------------------------
# A virtual environment is a per-project Python install. It lives inside
# the project directory. Committing it would balloon the repo and pin
# the user to your operating system. Always recreate locally.
.venv/
venv/
env/
# -----------------------------------------------------------------------------
# Tooling caches — these speed up repeated runs but never need committing
# -----------------------------------------------------------------------------
.mypy_cache/
.ruff_cache/
.pytest_cache/
# Coverage report files from pytest-cov.
.coverage
htmlcov/
# -----------------------------------------------------------------------------
# IDE / editor scratch files
# -----------------------------------------------------------------------------
# Each editor drops its own metadata. None of it belongs in the repo.
.vscode/
.idea/
*.swp
*.swo
# macOS Finder dumps these in every directory it has opened.
.DS_Store
# -----------------------------------------------------------------------------
# Local environment files (may contain secrets)
# -----------------------------------------------------------------------------
# We never use .env in this project, but list it just in case. Anything
# containing secrets should never reach the public repo.
.env
.env.local

View File

@ -0,0 +1,104 @@
# ©AngelaMos | 2026
# .style.yapf
#
# Configuration for yapf, our Python code formatter. yapf rewrites
# Python files to match these rules so every file looks identical
# no matter who wrote it.
#
# The two settings most worth knowing:
# based_on_style = pep8 → start from PEP 8 (the official style)
# column_limit = 75 → wrap lines at 75 characters
#
# Run with: `just format`
[style]
# Start from PEP 8 (Python's official style guide). Every other
# setting below tweaks PEP 8 in some specific direction.
based_on_style = pep8
# Wrap any line longer than this many columns.
column_limit = 75
# Use 4 spaces per indent level (PEP 8 standard).
indent_width = 4
# Continuation lines (when a statement wraps) also indent by 4.
continuation_indent_width = 4
# Don't indent the closing bracket of a multi-line call/list/dict
indent_closing_brackets = false
# DO de-indent the closing bracket — line it up with the opener.
dedent_closing_brackets = true
# Don't add 4 spaces of indent to blank lines inside functions.
indent_blank_lines = false
# Two spaces between code and the # of a trailing comment.
spaces_before_comment = 2
# No spaces around ** (power) operator: 2**3 not 2 ** 3.
spaces_around_power_operator = false
# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
spaces_around_default_or_named_assign = true
# No extra space before a comma followed by a closing bracket.
space_between_ending_comma_and_closing_bracket = false
# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
space_inside_brackets = false
# Spaces around colons in slices: x[1 : 5] not x[1:5].
spaces_around_subscript_colon = true
# No blank line before nested def/class.
blank_line_before_nested_class_or_def = false
# No blank line between a class and its docstring.
blank_line_before_class_docstring = false
# Two blank lines around top-level functions and classes (PEP 8).
blank_lines_around_top_level_definition = 2
# Two blank lines between top-level imports and module-level variables.
blank_lines_between_top_level_imports_and_variables = 2
# No blank line before the module docstring.
blank_line_before_module_docstring = false
# When wrapping `a and b`, put `and` at the start of the new line.
split_before_logical_operator = true
# When wrapping a function call, put the first argument on a new line.
split_before_first_argument = true
# Put each named arg on its own line when wrapping.
split_before_named_assigns = true
# Multi-line comprehensions wrap onto separate lines.
split_complex_comprehension = true
# Don't split right after the opening parenthesis.
split_before_expression_after_opening_paren = false
# Put the closing bracket on its own line when wrapping.
split_before_closing_bracket = true
# When splitting at commas, give every value its own line.
split_all_comma_separated_values = true
# But don't recursively force ALL top-level commas onto new lines.
split_all_top_level_comma_separated_values = false
# Don't merge separate brackets into one.
coalesce_brackets = false
# Each dict entry gets its own line in multi-line dicts.
each_dict_entry_on_separate_line = true
# Disallow lambdas that span multiple lines.
allow_multiline_lambdas = false
# Disallow dict keys that span multiple lines.
allow_multiline_dictionary_keys = false
# Penalty score for splitting at import names — 0 means free to split.
split_penalty_import_names = 0
# Don't try to fit an already-broken statement back onto fewer lines.
join_multiple_lines = false
# Closing bracket lines up with the visual indent of the opener.
align_closing_bracket_with_visual_indent = true
# Don't add parentheses to clarify operator precedence automatically.
arithmetic_precedence_indication = false
# Penalty for splitting after each line — yapf weighs this against rules.
split_penalty_for_added_line_split = 275
# Use spaces for indentation, never tabs (PEP 8).
use_tabs = false
# Don't split on a `.` when wrapping (so `obj.method()` stays together).
split_before_dot = false
# When the last argument in a call has a trailing comma, force a split.
split_arguments_when_comma_terminated = true
# Function names yapf treats as i18n calls — leave their args alone.
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
# Comment markers yapf treats as i18n metadata.
i18n_comment = ['# Translators:', '# i18n:']
# Penalty for splitting comprehension iterables.
split_penalty_comprehension = 80
# Penalty for splitting right after an opening bracket.
split_penalty_after_opening_bracket = 280
# Penalty for splitting before an `if` in a ternary expression.
split_penalty_before_if_expr = 0
# Penalty for splitting before a bitwise operator.
split_penalty_bitwise_operator = 290
# Penalty for splitting before a logical operator (already lowered).
split_penalty_logical_operator = 0

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,170 @@
```ruby
██╗ ██╗███████╗ █████╗ ██████╗ ███████╗██████╗ ███████╗
██║ ██║██╔════╝██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝
███████║█████╗ ███████║██║ ██║█████╗ ██████╔╝███████╗
██╔══██║██╔══╝ ██╔══██║██║ ██║██╔══╝ ██╔══██╗╚════██║
██║ ██║███████╗██║ ██║██████╔╝███████╗██║ ██║███████║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝╚══════╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Foundations-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/http-headers-scanner)
[![Tier: Foundations](https://img.shields.io/badge/Tier-Foundations-00C9A7?style=flat&logo=bookstack&logoColor=white)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
[![Python 3.13](https://img.shields.io/badge/Python-3.13-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Tests](https://img.shields.io/badge/tests-pytest-0A9EDC?style=flat&logo=pytest&logoColor=white)](https://pytest.org)
[![Lint](https://img.shields.io/badge/lint-ruff%20%2B%20mypy%20%2B%20pylint-D7FF64?style=flat)](https://github.com/astral-sh/ruff)
[![HTTP Client](https://img.shields.io/badge/httpx-0.28+-1f5582?style=flat)](https://www.python-httpx.org/)
> Fetch a URL once and grade its HTTP security headers A through F using the same weighted-rubric model as Mozilla Observatory.
*This is a quick overview, security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
> [!NOTE]
> **Foundations tier**, this project is built for someone who has never written Python before. The source code is heavily commented as a teaching aid, the `learn/` folder explains every concept from zero, and the whole tool is one readable file. If you already know Python, the natural next step is [`PROJECTS/foundations/password-manager`](../password-manager), the hardest foundations project, which adds Argon2id, AES-GCM, and on-disk state.
## What It Does
- Performs one polite HTTPS request to the URL you provide and inspects the response headers
- Grades six security-critical headers against a weighted rubric (high = 30 pts, medium = 15 pts, low = 5 pts)
- Reports each finding as `ok`, `weak`, or `missing` with a one-line explanation of why
- Computes a 0 to 100 score and maps it to an A through F letter grade (90+ = A, 80+ = B, etc.)
- Catches subtly broken values like `Strict-Transport-Security: max-age=0` (header present, actively disabled) and flags them as `weak`, not `ok`
- Follows redirects and grades the **final** URL, the one your browser would actually land on
- Prints a colored Rich table plus a grade panel plus a recommendation list for every non-`ok` finding
- Returns meaningful exit codes: `0` for A/B, `1` for C/D, `2` for F or network error, useful in CI pipelines
## The Headers It Grades
| Header | Severity | What it stops |
|---|---|---|
| `Strict-Transport-Security` | high | SSL stripping on coffee-shop wifi |
| `Content-Security-Policy` | high | XSS via injected `<script>` tags |
| `X-Content-Type-Options` | medium | MIME-sniffing of uploaded files |
| `X-Frame-Options` | medium | Clickjacking via hidden iframes |
| `Referrer-Policy` | low | Leaking secret tokens via the Referer header |
| `Permissions-Policy` | low | Compromised third-party scripts abusing camera/mic/etc. |
Every header maps to a real attack class with a real history. The [`01-CONCEPTS.md`](learn/01-CONCEPTS.md) module walks through each one with concrete attack examples.
## Quick Start
```bash
./install.sh
just run -- https://example.com
# Grade: B, Score: 85 / 100 (example.com is missing CSP and Permissions-Policy)
```
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Demo URLs
Try these, each demonstrates a different grading path:
| URL | Expected grade | Why |
|---|---|---|
| `https://github.com` | A | Comprehensive CSP, HSTS with `includeSubDomains`, almost every header set |
| `https://web.dev` | A | Google's own developer-docs site, full modern header set |
| `https://mozilla.org` | A | Mozilla practices what Observatory preaches |
| `https://example.com` | B / C | Has HSTS, but missing CSP, Permissions-Policy, and others |
| `http://neverssl.com` | F | Intentionally serves plain HTTP, no security headers at all |
```bash
just run -- https://github.com
just run -- https://example.com
just run -- https://web.dev --timeout 5
just run -- http://neverssl.com
```
> [!IMPORTANT]
> Always include the `http://` or `https://` scheme. The scanner refuses bare hostnames like `github.com` because it cannot guess which scheme you meant, and guessing wrong is exactly the SSL-stripping problem HSTS exists to prevent.
## Sample Output
```
Headers for https://github.com/ (HTTP 200)
┌─────────────────────────────┬─────────┬──────────┬─────────────────────────┐
│ header │ status │ severity │ note │
├─────────────────────────────┼─────────┼──────────┼─────────────────────────┤
│ Strict-Transport-Security │ ok │ high │ Present and contains... │
│ Content-Security-Policy │ ok │ high │ Present │
│ X-Content-Type-Options │ ok │ medium │ Present and contains... │
│ X-Frame-Options │ ok │ medium │ Present │
│ Referrer-Policy │ ok │ low │ Present │
│ Permissions-Policy │ missing │ low │ Header ... is not set │
└─────────────────────────────┴─────────┴──────────┴─────────────────────────┘
╭─ Result ───────────────────╮
│ Grade: A │
│ Score: 95 / 100 │
╰────────────────────────────╯
```
Followed by a `Recommendations:` block for every non-`ok` finding, with the exact header value to add.
## Exit Codes
The scanner returns shell-friendly exit codes so you can wire it into CI:
| Grade | Exit code | Meaning |
|---|---|---|
| A, B | `0` | Green light, no action needed |
| C, D | `1` | Worth investigating, often acceptable depending on context |
| F or network error | `2` | Hard fail, must fix |
```bash
just run -- https://my-deployed-site.com
if [ $? -gt 1 ]; then exit 1; fi # fail the build only on F or error
```
## Tooling
```bash
just # list available recipes
just test # run pytest (11 tests, runs in under a second, network-mocked with respx)
just lint # ruff + mypy --strict + pylint
just format # yapf
just run -- <url> # scan a URL
```
## Requirements
- **Python 3.13+**, the install script will check.
- [`uv`](https://github.com/astral-sh/uv), modern Python package manager (auto-installed by `./install.sh`).
- [`just`](https://github.com/casey/just), command runner (auto-installed by `./install.sh`).
- A working internet connection at runtime (the scanner makes one real HTTPS request per scan, but the test suite mocks the network with `respx` and runs fully offline).
No compilers, no system libraries. The project is one Python file plus tests.
## Learn
This project includes step-by-step learning materials covering security theory, architecture, and implementation, written for someone who has never touched Python before.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, expected output, common first-run problems |
| [01 - Concepts](learn/01-CONCEPTS.md) | What HTTP is, what a header is, each security header with the real attack it stops (SSL stripping, clickjacking, MIME sniffing, XSS, referer leakage) |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | The four-stage pipeline, dataclasses as value objects, the I/O fence pattern (functional core / imperative shell) |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Function-by-function walkthrough, every Python feature explained when first encountered, plus test patterns and tooling |
| [04 - Challenges](learn/04-CHALLENGES.md) | Twelve extension ideas, from "add a seventh header rule" up through "wrap it in a FastAPI service with rate limiting" |
## Real-World Context
This scanner is a teaching-scale version of tools that do the same job at production scale:
- **[Mozilla Observatory](https://observatory.mozilla.org/)**, the canonical version. Same weighted-rubric approach, deeper CSP analysis, cookie checks, TLS configuration grading.
- **[securityheaders.com](https://securityheaders.com)**, simpler UI, same idea.
- **[nmap http-security-headers script](https://nmap.org/nsedoc/scripts/http-security-headers.html)**, for command-line workflows.
Once you understand how this scanner makes decisions, those tools become readable instead of magical. The [04-CHALLENGES.md](learn/04-CHALLENGES.md) module includes ideas for growing this project toward what Observatory does.
## See Also
- [`PROJECTS/foundations/hash-identifier`](../hash-identifier), the easier foundations project, pure logic and no network at all.
- [`PROJECTS/foundations/password-manager`](../password-manager), the hardest foundations project, covers Argon2id, AES-GCM, and on-disk vaults.
- [`PROJECTS/advanced/bug-bounty-platform`](../../advanced/bug-bounty-platform), what a serious web-security tool looks like once it grows up.
## License
AGPL 3.0

View File

@ -0,0 +1,639 @@
"""
©AngelaMos | 2026
http_headers_scanner.py
Scan a URL and grade its HTTP security headers AF
When a browser asks a website for a page, the server sends back the
page itself PLUS a bunch of metadata called "HTTP response headers."
Some of those headers are security-critical: they tell the browser
"only talk to me over HTTPS," "do not let other sites embed me in
an iframe," "ignore guesses about file types," and more
If a website forgets these headers, real attacks become easier:
clickjacking, MIME-sniffing, mixed-content downgrades, XSS that
otherwise would have been stopped by a good Content-Security-Policy.
This script connects to a URL, pulls the headers, and tells you which
ones are missing or weak
The headers we care about
Strict-Transport-Security forces HTTPS for return visits
Content-Security-Policy controls which scripts/styles may load
X-Content-Type-Options disables MIME-sniffing
X-Frame-Options controls iframe embedding (clickjacking)
Referrer-Policy limits Referer leakage
Permissions-Policy disables browser features the page does
not need (camera, microphone, etc.)
Each rule has a severity (high / medium / low). The score is the
percentage of weighted points the site earned. The grade comes from
the score: 90+ A, 80+ B, 70+ C, 60+ D, otherwise F. This mirrors the
model used by the Mozilla Observatory
What this script does NOT do
- It does not crawl the site, only the URL you give it
- It does not parse complex CSP directives (just checks presence)
- It does not test for actual XSS or open redirects
This is foundational: learn the headers, then graduate to bigger
tools like Mozilla Observatory or `securityheaders.com`
What this file exposes
HeaderRule one rule (header name, severity, description, ...)
HeaderFinding the result of evaluating one rule
ScanReport full report (url, status_code, findings, score, grade)
evaluate_header() run one rule against a set of headers
scan() fetch a URL and run every rule
main() CLI entry point used by `headers <url>`
"""
# Standard library: parse command-line flags like `--timeout 5` into
# a tidy object so we do not have to slice `sys.argv` by hand.
import argparse
# Standard library: regular expressions — we use `re.search` to match
# header values against rule patterns (e.g. `max-age\s*=\s*[1-9]` for
# HSTS, which must reject `max-age=0`).
import re
# Standard library: access to interpreter internals — we use it for
# stderr writes and to exit the process with a specific status code.
import sys
# Standard library: a decorator that turns a class into a small,
# immutable data record without writing `__init__` boilerplate.
from dataclasses import dataclass
# Standard library: a type hint that pins a value to a small fixed
# set of strings (here: severity levels like "good"/"warn"). Mypy
# catches typos.
from typing import Literal
# Third-party (httpx): the HTTP client that actually fetches the URL.
# Modern replacement for `requests` — supports timeouts and HTTP/2.
import httpx
# Third-party (rich): the printer that draws colored output to the
# terminal, with full Unicode and width handling.
from rich.console import Console
# Third-party (rich): draws a bordered box around content — we use
# it for the summary banner at the top of the report.
from rich.panel import Panel
# Third-party (rich): builds the colored ASCII table that lists each
# header finding with its severity.
from rich.table import Table
# =============================================================================
# Severity type — three valid values
# =============================================================================
# Literal["high", "medium", "low"] is a type hint that says "this string
# can ONLY be one of these three values." Mypy will catch typos like
# "hgih" at edit time. We chose Literal over an Enum because Carter's
# style guide prefers Literals for small fixed sets
Severity = Literal["high", "medium", "low"]
Status = Literal["ok", "weak", "missing"]
# =============================================================================
# HeaderRule — one rule we evaluate against the response
# =============================================================================
@dataclass(frozen = True, slots = True)
class HeaderRule:
"""
A single security-header check
`frozen=True` makes the dataclass immutable once created, its
fields cannot change. `slots=True` makes instances lightweight in
memory. Together these two flags create a clean "value object"
Fields
------
header
The HTTP header name to look for (case-insensitive)
severity
How important the header is. Drives the point value below
description
One sentence explaining what the header does. Shown in output
recommendation
Concrete fix the user should apply if the header is missing
must_match
Optional regex pattern (case-insensitive) the value MUST match.
Use a plain word for substring matching (e.g. ``nosniff``), or a
real regex for stricter checks. Example: ``max-age\\s*=\\s*[1-9]``
requires ``max-age`` to be a positive integer, which rejects the
actively-harmful ``max-age=0``. If this is set and the value
does not match, we report `weak` instead of `ok`
"""
header: str
severity: Severity
description: str
recommendation: str
must_match: str | None = None
# =============================================================================
# The rules table — single source of truth for what we check
# =============================================================================
# Adding a header to this list is the only change needed to extend
# the scanner. The check logic is generic — it walks this list at
# runtime and applies each rule the same way
RULES: list[HeaderRule] = [
HeaderRule(
header = "Strict-Transport-Security",
severity = "high",
description = (
"Tells the browser to ONLY connect over HTTPS for the "
"next N seconds, defeating SSL-stripping attacks"
),
recommendation = (
"Add: Strict-Transport-Security: "
"max-age=31536000; includeSubDomains"
),
# Require max-age to be a positive integer — `max-age=0`
# actively disables HSTS, so we must reject it. The regex
# accepts whitespace around `=` to tolerate `max-age = 60`.
must_match = r"max-age\s*=\s*[1-9]",
),
HeaderRule(
header = "Content-Security-Policy",
severity = "high",
description = (
"Controls which scripts, styles, frames, and connections "
"the browser may load — the strongest XSS defense"
),
recommendation = (
"Add a Content-Security-Policy that disallows "
"'unsafe-inline' and limits sources to trusted origins"
),
),
HeaderRule(
header = "X-Content-Type-Options",
severity = "medium",
description = (
"Stops browsers from second-guessing the Content-Type "
"and treating a .txt file as HTML — defeats MIME-sniffing"
),
recommendation = "Add: X-Content-Type-Options: nosniff",
# Value must literally be `nosniff`; anything else is broken.
# `re.search("nosniff", ...)` is a substring match here — no
# special regex characters in the pattern.
must_match = "nosniff",
),
HeaderRule(
header = "X-Frame-Options",
severity = "medium",
description = (
"Prevents another site from embedding this page in an "
"iframe, defeating clickjacking attacks"
),
recommendation = (
"Add: X-Frame-Options: DENY (or use "
"Content-Security-Policy: frame-ancestors 'none')"
),
),
HeaderRule(
header = "Referrer-Policy",
severity = "low",
description = (
"Limits how much of the current URL leaks to other sites "
"when the user clicks an outbound link"
),
recommendation = (
"Add: Referrer-Policy: strict-origin-when-cross-origin"
),
),
HeaderRule(
header = "Permissions-Policy",
severity = "low",
description = (
"Disables browser features the page does not use "
"(camera, microphone, geolocation, payments, etc.)"
),
recommendation = (
"Add: Permissions-Policy: "
"camera=(), microphone=(), geolocation=()"
),
),
]
# =============================================================================
# Severity → points. Drives the final score
# =============================================================================
# Each present-and-correct header earns its full points; weak presence
# earns half points; missing earns zero. Total achievable = sum of
# all rule points. The score is (earned / total) * 100, rounded
SEVERITY_POINTS: dict[Severity,
int] = {
"high": 30,
"medium": 15,
"low": 5,
}
# =============================================================================
# HeaderFinding — the result of evaluating one rule
# =============================================================================
@dataclass(frozen = True, slots = True)
class HeaderFinding:
"""
Outcome of running one HeaderRule against the response headers
Fields
------
rule
The rule we evaluated. Carrying it inside the finding means
the renderer never has to look up the rule again
status
"ok" header is present and (if applicable) the value
matches must_match
"weak" header is present but the value is wrong
"missing" header is not in the response at all
actual_value
Whatever the server actually sent for this header, or None
when the header was missing entirely
note
Short human-readable explanation. Shown in the table next
to the status column
"""
rule: HeaderRule
status: Status
actual_value: str | None
note: str
# =============================================================================
# ScanReport — the full result returned by scan()
# =============================================================================
@dataclass(frozen = True, slots = True)
class ScanReport:
"""
A full scan result for one URL
The `score` and `grade` properties are computed on demand from
the findings, so they always reflect whatever the rules table
looked like at scan time
"""
url: str
final_url: str
status_code: int
findings: list[HeaderFinding]
@property
def score(self) -> int:
"""
Return a 0100 score reflecting the weighted findings
Formula
-------
earned = full points for every "ok"
+ half points for every "weak"
+ zero for every "missing"
score = round(earned / total * 100)
"""
total = sum(SEVERITY_POINTS[r.severity] for r in RULES)
# Guard against an empty rules table — would only matter if
# someone deletes RULES while testing. Keeps the code total
if total == 0:
return 0
earned = 0.0
for finding in self.findings:
full = SEVERITY_POINTS[finding.rule.severity]
if finding.status == "ok":
earned += full
elif finding.status == "weak":
earned += full / 2
# "missing" earns 0 — no else branch needed
# Round-half-up via int(x + 0.5) avoids Python's banker's
# rounding, which would map round(0.5) -> 0 and round(2.5) -> 2
# — surprising for a score that should always round up at the
# .5 boundary
return int((earned / total) * 100 + 0.5)
@property
def grade(self) -> str:
"""
Map the score to a letter grade AF
"""
score = self.score
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"
# =============================================================================
# Header evaluation — pure function, no I/O
# =============================================================================
# Splitting this out from scan() makes it trivially testable: pass in
# a rule and a dict of headers, get back a finding. No network needed
def evaluate_header(
rule: HeaderRule,
response_headers: dict[str,
str],
) -> HeaderFinding:
"""
Apply a single HeaderRule to a set of response headers
HTTP header names are case-insensitive per RFC 7230 `HSTS` and
`hsts` and `Hsts` are the same header. We normalize both sides
to lowercase before comparing
"""
target = rule.header.lower()
# Walk the response headers manually instead of building a
# case-insensitive dict. The input is always a plain dict here —
# scan() converts httpx's Headers object before calling us, so
# tests can pass any dict[str, str] without ceremony
actual_value: str | None = None
for name, value in response_headers.items():
if name.lower() == target:
actual_value = value
break
if actual_value is None:
return HeaderFinding(
rule = rule,
status = "missing",
actual_value = None,
note = f"Header `{rule.header}` is not set",
)
# If the rule has no must_match check, presence is enough
if rule.must_match is None:
return HeaderFinding(
rule = rule,
status = "ok",
actual_value = actual_value,
note = "Present",
)
# Otherwise verify the value matches the required pattern.
# re.search finds the pattern anywhere in the string — for a plain
# word like `nosniff` that behaves as a substring check; for a
# real regex like `max-age\s*=\s*[1-9]` it enforces a richer
# condition (positive integer after `max-age=`)
if re.search(rule.must_match, actual_value, re.IGNORECASE):
return HeaderFinding(
rule = rule,
status = "ok",
actual_value = actual_value,
note = f"Present and matches `{rule.must_match}`",
)
return HeaderFinding(
rule = rule,
status = "weak",
actual_value = actual_value,
note = (
f"Present but does not match `{rule.must_match}` "
f"(got `{actual_value}`)"
),
)
# =============================================================================
# scan() — fetch the URL and apply every rule
# =============================================================================
# A polite, identifiable User-Agent. Some servers block requests with
# the default httpx UA or no UA at all
DEFAULT_USER_AGENT: str = (
"http-headers-scanner/1.0 "
"(+https://github.com/CarterPerez-dev/Cybersecurity-Projects)"
)
def scan(
url: str,
*,
timeout: float = 10.0,
user_agent: str = DEFAULT_USER_AGENT,
) -> ScanReport:
"""
Fetch `url` once and grade its response headers
Parameters
----------
url
Full URL including the scheme. Bare hostnames like
"example.com" are NOT supported because we cannot guess
whether the user wanted http or https
timeout
Seconds before we give up on a slow server. Default 10
user_agent
Sent as the User-Agent header. Some sites serve different
responses to bots; the default identifies us honestly
Returns
-------
ScanReport
Containing the findings, status code, and final URL after
any redirects
Raises
------
httpx.RequestError
On DNS failure, connection refusal, timeout, etc. The CLI
catches these to print a clean error message
"""
# follow_redirects=True means http://example.com → https://example.com
# is followed automatically. We grade the FINAL URL, not the first
# one, because that is the one users actually see
response = httpx.get(
url,
timeout = timeout,
follow_redirects = True,
headers = {"User-Agent": user_agent},
)
# httpx Headers object behaves like a dict for our purposes.
# dict(response.headers) gives us a regular dict[str, str]
response_headers = dict(response.headers)
# Run every rule against the response. List comprehension is
# cleaner than a for-loop with .append() here
findings = [evaluate_header(rule, response_headers) for rule in RULES]
return ScanReport(
url = url,
final_url = str(response.url),
status_code = response.status_code,
findings = findings,
)
# =============================================================================
# CLI rendering — keeps display logic out of the data layer
# =============================================================================
# How each status / severity should be colored in the terminal
STATUS_COLORS: dict[Status,
str] = {
"ok": "green",
"weak": "yellow",
"missing": "red",
}
GRADE_COLORS: dict[str,
str] = {
"A": "bright_green",
"B": "green",
"C": "yellow",
"D": "red",
"F": "bright_red",
}
def _render_report(report: ScanReport, console: Console) -> None:
"""
Print the scan report as a rich table plus a grade panel
"""
# The header table — one row per rule
table = Table(
title = (
f"Headers for {report.final_url} "
f"(HTTP {report.status_code})"
),
title_style = "bold cyan",
show_lines = False,
)
table.add_column("header", style = "bold white", no_wrap = True)
table.add_column("status", no_wrap = True)
table.add_column("severity", no_wrap = True)
table.add_column("note", style = "dim")
for finding in report.findings:
status_color = STATUS_COLORS[finding.status]
table.add_row(
finding.rule.header,
f"[{status_color}]{finding.status}[/{status_color}]",
finding.rule.severity,
finding.note,
)
console.print(table)
# Browsers IGNORE HSTS received over plain HTTP per RFC 6797 §8.1
# — if the final response was served over http://, any HSTS grade
# above is misleading. Warn so the user does not walk away with a
# false sense of security
if report.final_url.startswith("http://"):
console.print(
"[yellow]Note:[/yellow] this response was served over plain "
"HTTP. Browsers IGNORE HSTS over HTTP, so any HSTS grade "
"above is misleading until the site enforces HTTPS"
)
# The grade panel — big, color-coded, eye-catching
grade_color = GRADE_COLORS[report.grade]
panel = Panel(
f"[bold {grade_color}]Grade: {report.grade}[/bold {grade_color}]\n"
f"Score: {report.score} / 100",
title = "Result",
border_style = grade_color,
)
console.print(panel)
# Print recommendations for any non-ok findings, so the user has
# an action list — what to add or fix
actionable = [f for f in report.findings if f.status != "ok"]
if actionable:
console.print("\n[bold]Recommendations:[/bold]")
for finding in actionable:
console.print(
f" • [yellow]{finding.rule.header}[/yellow] "
f"{finding.rule.recommendation}"
)
# =============================================================================
# argparse plumbing — broken out so tests can call it directly
# =============================================================================
def _build_argument_parser() -> argparse.ArgumentParser:
"""
Construct the argparse parser used by main()
"""
parser = argparse.ArgumentParser(
prog = "headers",
description = (
"Scan a URL for HTTP security headers and grade the result AF."
),
)
parser.add_argument(
"url",
help = "Full URL to scan (must include http:// or https://).",
)
parser.add_argument(
"--timeout",
type = float,
default = 10.0,
help =
"Seconds to wait before giving up on the request (default: 10).",
)
return parser
# =============================================================================
# main() — exit codes mean something
# =============================================================================
# 0 → grade A or B (green light for CI)
# 1 → grade C or D (warn but do not fail by default)
# 2 → grade F or network error (fail loudly)
def main() -> int:
"""
CLI entry point return an exit code reflecting the scan result
"""
parser = _build_argument_parser()
args = parser.parse_args()
console = Console()
# Catch network errors here so the user sees a clean message
# instead of a raw traceback. We let httpx's own message bubble
# through after our prefix — the underlying error usually has
# useful detail (DNS failure, connection refused, etc.)
try:
report = scan(args.url, timeout = args.timeout)
except httpx.RequestError as exc:
console.print(
f"[red]Request failed:[/red] {type(exc).__name__}: {exc}"
)
return 2
_render_report(report, console)
if report.grade in ("A", "B"):
return 0
if report.grade in ("C", "D"):
return 1
return 2
# Standard "if invoked directly as a script" guard — lets the file be
# imported by tests without firing main()
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,177 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# install.sh
#
# Zero-friction install script. Anyone who clones this project should
# be able to run `./install.sh` and end up with a working setup,
# regardless of whether they have uv or just installed yet.
#
# What this script does, in order:
# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
# 2. Installs uv if it is missing (uv is the Python package manager we use)
# 3. Installs just if it is missing (just is the command runner)
# 4. Calls `just setup` to create the venv and install dependencies
# 5. Prints next steps
#
# Run with: ./install.sh
# Or: bash install.sh
# -----------------------------------------------------------------------------
# Bash safety flags — fail fast and loud
# -----------------------------------------------------------------------------
# -e : exit immediately if any command returns a non-zero (error) status
# -u : treat unset variables as an error
# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
set -euo pipefail
# -----------------------------------------------------------------------------
# Color helpers — pretty terminal output without external dependencies
# -----------------------------------------------------------------------------
# These are ANSI escape codes. \033 is the ESC character; the bracketed
# digits tell the terminal which color to switch to. NC = "no color",
# resets back to whatever the terminal had before.
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
# Tiny helper functions so we don't repeat the format strings everywhere.
# `>&2` redirects to stderr (where errors belong) instead of stdout.
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
success() { echo -e "${GREEN}[OK]${NC} $1"; }
# -----------------------------------------------------------------------------
# Step 1 — Confirm Python 3.13+ is on the system
# -----------------------------------------------------------------------------
check_python() {
info "Checking for Python 3.13+..."
# `command -v <name>` prints the path of <name> if it exists, nothing
# otherwise. `&>/dev/null` discards both stdout and stderr — we only
# care about the exit code (0 = found, non-zero = missing).
if ! command -v python3 &>/dev/null; then
error "python3 not found. Please install Python 3.13 or newer."
error " macOS: brew install python@3.13"
error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
error " Windows: download from python.org"
exit 1
fi
# Read the version from Python itself — the most reliable source.
# `local` makes these variables function-scoped instead of leaking
# into the rest of the script.
local version
version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
local major minor
# `cut -d. -f1` splits the string on `.` and takes field 1.
major=$(echo "$version" | cut -d. -f1)
minor=$(echo "$version" | cut -d. -f2)
# `(( ... ))` is bash arithmetic context — lets us write `<` `>` etc.
# The compound condition fails if major < 3, OR if major == 3 and
# minor < 13. So Python 3.12 fails, 3.13 passes, 4.0 passes.
if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
error "Python 3.13+ is required, found Python $version"
exit 1
fi
success "Python $version detected"
}
# -----------------------------------------------------------------------------
# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
# -----------------------------------------------------------------------------
install_uv() {
# Already installed? Print confirmation and bail out of this function.
# `return 0` exits the function with success — the caller continues.
if command -v uv &>/dev/null; then
success "uv already installed ($(uv --version))"
return 0
fi
info "Installing uv (Python package manager)..."
# Pipe the official install script into sh. `-LsSf`:
# -L : follow redirects
# -s : silent (no progress meter)
# -S : show errors even when silent
# -f : fail on HTTP errors instead of writing them to disk
curl -LsSf https://astral.sh/uv/install.sh | sh
# The installer drops uv into ~/.local/bin or ~/.cargo/bin.
# Add both to PATH for the rest of THIS script's run.
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
# If we still cannot find uv, something went wrong.
if ! command -v uv &>/dev/null; then
error "uv install completed but \`uv\` is still not on PATH."
error "Restart your shell and re-run this script, or add uv to PATH manually."
exit 1
fi
success "uv installed"
}
# -----------------------------------------------------------------------------
# Step 3 — Install just if missing (https://github.com/casey/just)
# -----------------------------------------------------------------------------
install_just() {
if command -v just &>/dev/null; then
success "just already installed ($(just --version))"
return 0
fi
info "Installing just (command runner)..."
# Make sure the install destination exists first.
mkdir -p "$HOME/.local/bin"
# Official install script. `--to <dir>` controls where the binary lands.
# `--proto '=https'` rejects any protocol but HTTPS.
# `--tlsv1.2` insists on a modern TLS version.
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
| bash -s -- --to "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
success "just installed"
}
# -----------------------------------------------------------------------------
# Step 4 — Use just to set up the project (venv + dependencies)
# -----------------------------------------------------------------------------
project_setup() {
info "Running 'just setup'..."
# Calling our own justfile recipe — single source of truth for setup.
just setup
}
# -----------------------------------------------------------------------------
# Main — orchestrate the steps and print next instructions
# -----------------------------------------------------------------------------
main() {
echo ""
echo "================================================"
echo " http-headers-scanner — install"
echo "================================================"
echo ""
check_python
install_uv
install_just
project_setup
echo ""
echo "================================================"
success "Install complete!"
echo "================================================"
echo ""
echo "Next steps:"
echo " just run -- https://example.com # scan a real URL"
echo " just run -- --help # see options"
echo " just test # run the test suite"
echo ""
}
# `"$@"` forwards every argument the script was called with into main.
# We do not use any args today, but keeping this pattern means future
# flags can be added without changing the bottom of the file.
main "$@"

View File

@ -0,0 +1,189 @@
# ©AngelaMos | 2026
# justfile
#
# A "justfile" is a list of commands you can run with `just <name>`.
# Think of it as a project's command center — instead of remembering
# `uv run pytest -v`, you just type `just test`.
#
# Why use just instead of make? It is simpler, cross-platform,
# and the syntax is easier to read.
#
# Show all commands: `just`
# Run a command: `just <name>` (e.g. `just setup`)
# Export every variable defined here as an environment variable for
# the recipes that just runs.
set export
# On Linux/macOS, run recipe lines with bash and -u (error on
# unset variables) and -c (read commands from a string).
set shell := ["bash", "-uc"]
# On Windows, fall back to PowerShell with no logo / non-interactive.
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Make recipe arguments available to shebang scripts as `$1`, `$2`,
# `$@`, and `$#`. Without this, shebang recipes only see args via the
# textual `{{args}}` substitution, which is unsafe for inputs that
# contain `$` (the substituted text gets re-expanded by bash).
set positional-arguments
# Show available commands when you run `just` with no args.
default:
@just --list --unsorted
# =============================================================================
# Setup Commands
# =============================================================================
# One-shot first-time setup — creates .venv and installs everything
[group('setup')]
setup:
@echo "Creating virtual environment with uv..."
# uv venv creates a .venv/ folder using the system Python that
# matches `requires-python` in pyproject.toml.
# --allow-existing makes the recipe safe to re-run after a partial install.
uv venv --allow-existing
@echo ""
@echo "Installing dependencies (including dev tools)..."
# --all-extras pulls in every optional-dependencies group (just `dev`
# for us). Without this, dev tools like pytest do not get installed.
uv sync --all-extras
@echo ""
@echo "✓ Setup complete!"
@echo ""
@echo "Try it out:"
@echo " just run -- https://example.com"
@echo " just test"
# Install runtime dependencies only (no dev tools)
[group('setup')]
install:
uv sync
# Install runtime + dev dependencies
[group('setup')]
install-dev:
uv sync --all-extras
# =============================================================================
# Testing & Quality Checks
# =============================================================================
# Run the test suite
[group('test')]
test:
@echo "Running tests..."
# `uv run` runs a command inside the project's virtual environment
# without us having to `source .venv/bin/activate` first.
uv run pytest
# Run all linters in sequence (ruff + pylint + mypy)
[group('test')]
lint:
@echo "=== Ruff ==="
uv run ruff check http_headers_scanner.py test_http_headers_scanner.py
@echo ""
@echo "=== Pylint ==="
uv run pylint http_headers_scanner.py
@echo ""
@echo "=== Mypy ==="
uv run mypy http_headers_scanner.py
@echo ""
@echo "✓ All linters passed"
# Auto-format every Python file with yapf
[group('test')]
format:
@echo "Formatting code with yapf..."
# -i = in place. Edits the files directly instead of printing diffs.
uv run yapf -i http_headers_scanner.py test_http_headers_scanner.py
@echo "✓ Code formatted"
# Auto-fix what ruff can fix on its own (unused imports, etc.)
[group('test')]
fix:
uv run ruff check http_headers_scanner.py test_http_headers_scanner.py --fix
# =============================================================================
# Run the CLI
# =============================================================================
# Run headers — pass the URL after `--`
# Example: just run -- https://example.com
# just run -- https://github.com --timeout 5
# [no-exit-message] silences just's "Recipe `run` failed with exit
# code N" line when the scanner exits non-zero. The scanner uses
# exit 1 for grade C/D and exit 2 for grade F or network error —
# meaningful CI signals, not "the recipe is broken." The exit code
# itself is still propagated to whoever invoked just.
[group('run')]
[no-exit-message]
run *args:
#!/usr/bin/env bash
# If no args were given, print a friendly usage block and exit
# cleanly. Without this, `just run` (no args) would invoke
# `uv run headers` with nothing, argparse would exit 2, and just
# would tack a "Recipe `run` failed" error on top — confusing for
# someone just trying to see how the command works.
if [ $# -eq 0 ]; then
cat <<'EOF'
Usage: just run -- <url> [options]
Examples:
just run -- https://example.com
just run -- https://github.com --timeout 5
See all options:
just run -- --help
EOF
exit 0
fi
# Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
# TEXTUAL substitution done by just before bash runs the script,
# so a URL or option value with $ in it would get those $-references
# expanded as bash variables and arrive at the headers CLI mangled.
# "$@" hands bash the original argv unchanged.
uv run headers "$@"
# =============================================================================
# Utility / Cleanup
# =============================================================================
# Delete the venv and all build / cache artifacts
[group('utility')]
clean:
rm -rf .venv
rm -rf __pycache__
rm -rf .mypy_cache .ruff_cache .pytest_cache
rm -rf *.egg-info build dist
rm -rf .coverage htmlcov
@echo "✓ Cleaned"
# Lock the exact dependency versions to uv.lock
[group('utility')]
lock:
uv lock
# Upgrade all dependencies to latest allowed versions
[group('utility')]
update:
uv lock --upgrade
uv sync --all-extras
# =============================================================================
# CI Pipeline
# =============================================================================
# Full pipeline: setup + lint + test. For first-time runs.
[group('ci')]
all: setup lint test
@echo ""
@echo "✓ Setup, lint, and tests all passed"
# Lint + test only — what CI runs after dependencies are installed
[group('ci')]
ci: lint test
@echo "✓ CI checks passed"

View File

@ -0,0 +1,179 @@
# http-headers-scanner
A small command line tool that visits a website, asks for the "rules" that the website tells your browser to follow, and grades the website A through F based on how good those rules are.
This is the middle project in the **foundations** tier. The first foundations project (`hash-identifier`) was pure logic with no network. The third one (`password-manager`) is a full small application. This project sits between them: one trip across the internet, then some grading math.
## Why anyone built this
Every time you visit a website, your browser and the website's server have a short conversation. The browser asks "give me the page" and the server sends back two things:
1. The page itself (the HTML, images, scripts).
2. A short list of **rules** about how to treat that page.
That second list is what we care about here. It is called the **response headers**. Some of those headers are security related. They tell your browser things like:
- "From now on, only talk to me over HTTPS. Never plain HTTP."
- "Do not let any other website put me inside an `<iframe>`."
- "If you receive a file from me and you are not sure what type it is, do not guess. Treat it strictly."
- "Block scripts unless they come from this exact list of trusted places."
If a website forgets to send these headers, real attacks become easier. A few examples that actually happened to real companies:
- **Clickjacking.** Without `X-Frame-Options`, an attacker can load a victim's website inside a hidden iframe on a malicious page, trick you into clicking what looks like a button on the malicious page, but your click really lands on the victim's site. Used against Twitter, Facebook, and Adobe Flash settings in the 2008 to 2012 era.
- **SSL stripping.** Without `Strict-Transport-Security`, an attacker on the same coffee shop wifi can downgrade your first visit to plain HTTP, sit in the middle, and read or change everything you send. Moxie Marlinspike demoed this at Black Hat 2009 and it is still effective on any site that forgets HSTS.
- **MIME sniffing.** Without `X-Content-Type-Options: nosniff`, a browser may treat an uploaded file as something it is not (an "image" that the browser decides looks like HTML and runs as a script). Real attack against legacy IE that worked because the browser tried to be helpful.
This scanner does NOT fix any of those. It just tells you whether a website is missing the headers that would have prevented them. That is the first useful job in security: knowing what is wrong before you can try to fix it.
## What you will learn by building it
This is not a tutorial that teaches you Python from the absolute first line. It assumes you can install something on your computer and run a command in the terminal. Everything past that, we will walk through.
After working through this project you should understand:
**Security concepts**
- What HTTP headers are and why some of them matter for security
- The specific attacks each major security header prevents (clickjacking, MIME sniffing, mixed content, XSS, referer leakage)
- How a "grading rubric" lets you turn many small checks into one final score, which is what real scanners like Mozilla Observatory and securityheaders.com do
**Python concepts**
- How to make an HTTP request from code with `httpx`
- How `dataclasses` give you small "shapes" of data without writing constructors by hand
- How `Literal` type hints pin a value to a small fixed set of strings so typos get caught early
- How to split "code that does I/O" (talks to the network) from "code that is pure math" so you can test the math without touching the network
- How `pytest` runs tests, what a fixture is, and how `respx` lets you fake HTTP responses
**Command line tooling**
- How `argparse` parses flags like `--timeout 5` into a tidy object
- How exit codes (the number a program returns when it finishes) can communicate success or failure to other programs and to CI systems
- How `rich` draws colored tables and panels in the terminal
## What it looks like when you run it
```bash
$ just run -- https://github.com
```
You will see something like this (colors are real in the terminal):
```
Headers for https://github.com/ (HTTP 200)
┌─────────────────────────────┬─────────┬──────────┬─────────────────────────┐
│ header │ status │ severity │ note │
├─────────────────────────────┼─────────┼──────────┼─────────────────────────┤
│ Strict-Transport-Security │ ok │ high │ Present and contains... │
│ Content-Security-Policy │ ok │ high │ Present │
│ X-Content-Type-Options │ ok │ medium │ Present and contains... │
│ X-Frame-Options │ ok │ medium │ Present │
│ Referrer-Policy │ ok │ low │ Present │
│ Permissions-Policy │ missing │ low │ Header ... is not set │
└─────────────────────────────┴─────────┴──────────┴─────────────────────────┘
╭─ Result ───────────────────╮
│ Grade: A │
│ Score: 95 / 100 │
╰────────────────────────────╯
Recommendations:
• Permissions-Policy — Add: Permissions-Policy: camera=(), microphone=(), geolocation=()
```
The big takeaways:
- A green table row means "this header is set and useful."
- A yellow row means "this header is set but the value is wrong" (for example, `Strict-Transport-Security` was sent but with `max-age=0`, which actively disables itself).
- A red row means "this header is missing entirely."
- The grade panel summarises everything into one letter so you can tell at a glance whether a site has its basics in order.
## Who this is for
You can be absolutely brand new to Python and to security. You should already know:
- How to open a terminal on your computer.
- How to clone a git repository (or download a folder of files).
- How to read text and not panic when something does not work on the first try.
You do NOT need to know in advance:
- What HTTP is. We will explain it.
- What a dataclass is. We will explain it.
- What pytest is, what mocking is, what argparse is. All explained.
## Prerequisites in real terms
**Software you need installed on your computer.** All three are free.
- **Python 3.13 or newer.** Python is the programming language this is written in. Version 3.13 added some of the syntax we use. If you have an older Python (3.10, 3.11, 3.12) you will get errors. Check with `python3 --version`.
- **A terminal.** Mac and Linux have one built in (Terminal.app or any of dozens on Linux). Windows users should install Windows Terminal from the Microsoft Store, or use the one inside VS Code.
- **A working internet connection.** The scanner makes one real HTTPS request to whatever URL you point it at.
The install script (`install.sh`) will set up everything else for you: `uv` (a Python package manager), `just` (a command runner), a virtual environment, and all the libraries this project uses.
## Quick start
From the project folder:
```bash
# One-shot install. Sets up Python tooling, installs dependencies.
./install.sh
# Scan a real site.
just run -- https://example.com
# Add a custom timeout if a site is slow to respond.
just run -- https://github.com --timeout 5
# Run the tests to confirm the code works on your machine.
just test
```
If `./install.sh` errors out with "permission denied," run `chmod +x install.sh` first to mark the file as executable, then try again.
## Project layout
The whole project is intentionally small. Two Python files plus tooling.
```
http-headers-scanner/
├── http_headers_scanner.py the actual scanner: rules, scoring, CLI
├── test_http_headers_scanner.py tests for the scanner
├── pyproject.toml project metadata + dependency list
├── uv.lock exact versions of every dependency
├── justfile shortcut commands (just test, just run, etc.)
├── install.sh one-shot installer
├── README.md a brief readme for the GitHub page
└── learn/ this folder you are reading
├── 00-OVERVIEW.md you are here
├── 01-CONCEPTS.md what HTTP is, what each header does, real attacks
├── 02-ARCHITECTURE.md how the code is organised and why
├── 03-IMPLEMENTATION.md line by line walkthrough of the code
└── 04-CHALLENGES.md extension ideas to make it your own
```
Everything important lives in **one Python file** (`http_headers_scanner.py`). That is on purpose for a foundations project. One file you can hold in your head. Bigger projects (the password manager, anything in `intermediate/`) split into many files because they have to. This one does not have to.
## Common first-time issues
**`python3: command not found`**
You probably have Python installed but under a different name. Try `python --version`. If that says 3.13+, edit `install.sh` and replace `python3` with `python`. If neither works, install Python from python.org (or `brew install python@3.13` on Mac, `sudo apt install python3.13` on Debian/Ubuntu).
**`./install.sh: Permission denied`**
The file is not marked as executable. Run `chmod +x install.sh` and try again.
**`just: command not found` after install**
The install script puts `just` in `~/.local/bin`. That folder may not be on your PATH in a fresh terminal. Either restart your terminal or run `export PATH="$HOME/.local/bin:$PATH"`. To make it permanent, add that line to `~/.bashrc` or `~/.zshrc`.
**Network errors when scanning a real URL**
If you are behind a corporate firewall or VPN, some sites may refuse to respond, or they may block the scanner's default User-Agent. That is not a bug in the code, it is the world being annoying. Try a different URL like `https://example.com` first to confirm the basic plumbing works.
## Where to go next
1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** explains HTTP from scratch, what each header actually does, and the real-world attacks they prevent. Read this before the code. The code makes much more sense once you understand what the headers are protecting against.
2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** explains how the code is split into pieces and why. Useful when you want to extend the scanner without making a mess.
3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks through the code line by line. This is the longest of the five files.
4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** is for after you have read everything and want ideas for extending the project.
## Related projects in this repo
- `PROJECTS/foundations/hash-identifier/`: even smaller. Pure logic, no network. Good warmup if this one feels too big.
- `PROJECTS/foundations/password-manager/`: the next step up from this one. Multiple files, real cryptography.
- `PROJECTS/advanced/bug-bounty-platform/`: what a serious version of this idea looks like when you grow it into a whole product.

View File

@ -0,0 +1,349 @@
# Core Concepts
This file explains the security ideas the scanner is built on. By the end you should know what HTTP is, what a header is, why the six headers we check exist, and what kind of attack each one stops.
Read this file before the code. The code is short; the concepts behind it are what take the time.
## 1. What HTTP actually is
When you type `github.com` into your browser, something has to go fetch the page. That "something" speaks a protocol called **HTTP** (HyperText Transfer Protocol). HTTPS is the same protocol with encryption wrapped around it.
The basic shape of an HTTP conversation is:
```
HTTP request
┌─────────────┐ ───────────────────────▶ ┌─────────────┐
│ │ │ │
│ Browser │ │ Server │
│ (you) │ │ (github) │
│ │ ◀─────────────────────── │ │
└─────────────┘ HTTP response └─────────────┘
```
You (the browser) send a **request**. The server sends back a **response**. The request says "I would like the page at /home/index.html, please." The response says "Here is that page. Also, here is some information about the page."
Both the request and the response are just **text**, with a specific layout.
### What a real response looks like
If you stripped away the encryption and watched the bytes coming back from `https://example.com`, you would see something like this:
```
HTTP/2 200
content-type: text/html; charset=UTF-8
content-length: 1256
strict-transport-security: max-age=31536000
x-frame-options: DENY
cache-control: max-age=600
date: Tue, 13 May 2026 12:00:00 GMT
<!doctype html>
<html>
<head><title>Example Domain</title></head>
<body>...</body>
</html>
```
Three parts to notice:
1. **The status line.** `HTTP/2 200` means "HTTP version 2, status code 200." 200 means "OK, here is your page." 404 would mean "I do not have that page." 500 would mean "I broke trying to make that page."
2. **The headers.** Everything between the status line and the blank line. Each header is one line, in the format `name: value`. There can be dozens of them.
3. **The body.** Everything after the blank line. The actual HTML, image, JSON, or whatever else the server is sending you.
The **headers** are what this scanner cares about. Some headers are about caching, content type, cookies, and so on. We ignore those. A specific six headers exist for security, and that is what we grade on.
### Why headers are case insensitive
You will sometimes see `Strict-Transport-Security` and sometimes `strict-transport-security`. **They mean the same thing.** RFC 7230, the official spec for HTTP, says header names are case insensitive. Different servers and proxies use different casing. The scanner has to handle all of them, which is why we lowercase both sides before comparing.
## 2. What a security header actually is
A security header is just a regular HTTP header with a name the browser has been programmed to recognise as a security instruction. There is nothing magic about them. The server sends `Strict-Transport-Security: max-age=31536000` and the browser thinks "ah, the website is telling me to remember to only use HTTPS to talk to it for the next 31,536,000 seconds (one year)."
If the server forgets to send the header, the browser falls back to its default behaviour, which is usually "do whatever, no special protections." That default is what attackers count on.
So security headers are basically a **promise** the website makes to your browser. "Trust me, I never serve content over plain HTTP. If you ever see me on plain HTTP, somebody is lying to you, ignore them."
## 3. The six headers we grade
The scanner checks six headers. Each one stops a specific class of attack. We will go through them one by one.
### 3.1 Strict-Transport-Security (HSTS): severity HIGH
**What it tells the browser**
"For the next N seconds, only ever talk to me over HTTPS. Never plain HTTP. If somebody hands you a link to `http://my-site.com`, upgrade it to `https://` before you make the request."
**The attack it stops: SSL stripping**
Imagine you are at a coffee shop and you type `github.com` into your browser (no `https://` prefix). Your browser, by default, tries `http://github.com` first. GitHub's server then says "actually, please use HTTPS" and redirects you. The browser follows the redirect, switches to HTTPS, and now everything is encrypted.
In the gap between "you sent a plain HTTP request" and "the redirect came back," an attacker on the same wifi (running a laptop with `bettercap` or `sslstrip`) can intercept everything. They sit in the middle:
```
┌────────┐ plain HTTP ┌──────────┐ HTTPS ┌────────┐
│ You │ ───────────────▶ │ Attacker │ ──────────▶ │ GitHub │
└────────┘ │ (MITM) │ └────────┘
└──────────┘
```
The attacker keeps talking to GitHub over real HTTPS, but talks to you over plain HTTP, and rewrites every `https://` link in the page back to `http://` so you never escape. You think the site looks normal. The attacker reads your password.
**How HSTS stops it.** The first time you visit GitHub successfully over HTTPS, your browser remembers the `Strict-Transport-Security` header. Next time, even if you type `http://github.com`, the browser refuses to send a plain HTTP request at all. It upgrades to `https://` locally, before any packet leaves your machine. The attacker's "intercept the plain HTTP step" trick stops working.
**What the value looks like**
```
Strict-Transport-Security: max-age=31536000; includeSubDomains
```
- `max-age=31536000`: remember this rule for 31,536,000 seconds (one year).
- `includeSubDomains`: apply the rule to `api.github.com`, `docs.github.com`, everything ending in `github.com`.
**Why the scanner requires a positive `max-age`.** A server could send `Strict-Transport-Security:` with no value, or `Strict-Transport-Security: max-age=0`. Both are useless. `max-age=0` actively **disables** HSTS (it tells the browser to forget any previous HSTS rule for this site). So presence alone is not enough. The scanner reports `weak` whenever the value does not match the pattern `max-age = <positive integer>` — which catches both the empty case and the deliberately-zero case.
**Real example.** Moxie Marlinspike's sslstrip demo at Black Hat 2009 made this attack famous. Every major bank moved to enforce HTTPS-only after that talk. As of 2026 almost every serious site sends HSTS. Sites that do not are usually old internal systems.
### 3.2 Content-Security-Policy (CSP): severity HIGH
**What it tells the browser**
"Here is the exact list of places I am willing to load scripts, styles, images, fonts, frames, and other resources from. If you see something on the page asking you to load from anywhere else, refuse."
**The attack it stops: cross-site scripting (XSS)**
XSS is when an attacker manages to inject their own JavaScript into a page that other users will see. Classic example:
```
Comment box on the site allows: I love this product!
Attacker types: <script>steal_cookie()</script>
Site displays it verbatim.
Now every user who loads the comment runs the attacker's JS in their session.
```
That JS runs with the full trust of the website, so it can read cookies, read the page's session token, send requests to the API as the user, and so on. This is bug class number 7 in the OWASP Top 10 for years.
**How CSP stops it.** The website tells the browser "scripts may only come from `https://my-site.com` or from `https://cdn.my-site.com`." When the browser sees `<script>steal_cookie()</script>` embedded inline in the HTML, it goes "that script does not come from one of the allowed origins, I refuse to run it." The injected XSS just becomes inert text.
**What the value looks like**
```
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com
```
- `default-src 'self'`: for anything not specifically listed below, only allow content from this exact site.
- `script-src 'self' https://cdn.example.com`: JavaScript can come from this site or from cdn.example.com.
A real CSP is much longer because real sites pull in fonts, analytics, embeds, etc. A good CSP **never** contains `'unsafe-inline'` for scripts (which would allow the inline `<script>` tags an XSS attack relies on).
**Real example.** GitHub's CSP is one of the longest in the industry. After their 2018 GitHub-Pages XSS, they tightened it significantly. You can see it yourself: load github.com in your browser, open dev tools, look at the response headers.
**Why we only check for presence.** Parsing CSP properly is hard. Mozilla Observatory does a much deeper analysis (checks for `unsafe-inline`, wildcard origins, missing `default-src`, etc.). Our scanner only checks the header is present. That is the right call for a foundations project: graduate to Observatory once you want the deeper analysis.
### 3.3 X-Content-Type-Options: severity MEDIUM
**What it tells the browser**
"When I send you a file, I am telling you its type with the `Content-Type` header. Believe me. Do not look at the bytes and decide for yourself."
**The attack it stops: MIME sniffing**
Older versions of Internet Explorer had a "helpful" feature: if a server said a file was `text/plain` but the contents looked like HTML, IE would treat it as HTML and render it. The intent was to be forgiving toward misconfigured servers. The effect was a massive security hole.
Attack: a website lets users upload a profile picture. Attacker uploads a file named `cute_cat.gif` whose first bytes look like a valid GIF, but contains `<script>steal_everything()</script>` further in. Server stores it. Server later serves it back to other users with `Content-Type: image/gif`. IE looks at the bytes, sees the `<script>` tag, says "this is actually HTML" and runs the script. Now the attacker has XSS via image upload.
**How the header stops it.** `X-Content-Type-Options: nosniff` tells the browser "do not second-guess my Content-Type." If I say `image/gif`, you treat it as an image, full stop. The script tag never runs.
**What the value looks like**
```
X-Content-Type-Options: nosniff
```
That is literally the only allowed value. The header is the most boring of the six because there is one correct setting and that is it.
**Why the scanner requires `nosniff`.** Some misconfigured servers send `X-Content-Type-Options: off` or other garbage. The header has to literally contain the string `nosniff` to be useful. If it does not, we report `weak`.
**Real example.** Most of the MIME sniffing vulnerabilities were in IE 6 through 9. Modern browsers default to nosniff behaviour for `script` and `style` resources regardless. The header still matters for older browsers and for non-script contexts.
### 3.4 X-Frame-Options: severity MEDIUM
**What it tells the browser**
"Do not let other websites put me inside an `<iframe>` on their pages."
**The attack it stops: clickjacking**
Imagine the attacker makes a webpage that says:
```
┌────────────────────────────────────────┐
│ Win a free iPhone! Click here! │
│ │
│ ┌──────────────────────────┐ │
│ │ [INVISIBLE iframe with │ │
│ │ github.com loaded │ │
│ │ inside it, positioned │ │
│ │ so the "Delete repo" │ │
│ │ button sits exactly │ │
│ │ over the "Click here" │ │
│ │ button] │ │
│ └──────────────────────────┘ │
└────────────────────────────────────────┘
```
You are logged into GitHub in another tab. You see the "free iPhone" page on attacker's site. You click. The click does not land on the visible "Click here" button. It passes through the transparent iframe and lands on GitHub's "Delete repo" button. Your session cookie travels with the click. GitHub thinks you wanted to delete the repo, so it deletes it.
This is **clickjacking**. The CSS to set up the overlay is trivial. The only thing stopping it from working on every login session everywhere is the victim site refusing to be framed.
**How the header stops it.** `X-Frame-Options: DENY` tells the browser "if you see me being loaded inside an iframe on any other page, refuse to render me." The clickjacking iframe stays blank. Click goes nowhere harmful.
**What the value looks like**
```
X-Frame-Options: DENY # never allow framing, ever
X-Frame-Options: SAMEORIGIN # only allow framing by pages on the same domain
```
`X-Frame-Options` is the old way. The modern way is the `frame-ancestors` directive inside `Content-Security-Policy`. Most real sites send both for browser-compatibility reasons. The scanner only checks `X-Frame-Options` to keep things simple.
**Real example.** Adobe's Flash settings page in 2008 was clickjackable. Twitter's "Follow this user" button got clickjacked into "Re-tweet anything" attacks in 2009. Facebook had a clickjacking worm in 2010 that spread via "likes" on rigged pages. The pattern is the same every time.
### 3.5 Referrer-Policy: severity LOW
**What it tells the browser**
"When you leave my page to go to another site, do not tell that other site exactly which page you came from."
**The leak it stops: referer leakage**
Browsers, by default, send a `Referer` header with every outgoing request that says "the user got here from this URL." Yes, the spelling is `Referer` with one R. That was a typo in the original HTTP spec from 1996 and we are stuck with it forever. Hilarious.
Why this matters. Suppose your website has URLs like:
```
https://my-site.com/password-reset?token=abc123xyz
```
A user lands on that page, clicks an external link (say, to a YouTube help video). Their browser tells YouTube "this user came from `https://my-site.com/password-reset?token=abc123xyz`." YouTube now has the user's password reset token in their access logs. Anyone with log access at YouTube has it too.
This has happened in real life many times. The pattern: secret tokens in URLs leak via the Referer header to every third-party resource the page loads.
**How the header stops it.** `Referrer-Policy: strict-origin-when-cross-origin` is a sensible default. It says "when the user goes to another site, only tell that site my origin (`https://my-site.com`), never the full URL with query params. Hide the path and query string."
**What the value looks like**
```
Referrer-Policy: strict-origin-when-cross-origin
Referrer-Policy: no-referrer # most paranoid
Referrer-Policy: same-origin # safe for outbound links
```
**Why severity is LOW.** Referer leakage is real and bad, but it depends on the site putting secrets in URLs in the first place, which is a separate mistake. The header is a useful belt-and-suspenders defence, but missing it is not a guaranteed loss.
### 3.6 Permissions-Policy: severity LOW
**What it tells the browser**
"This page does not use the camera. Do not let any code on this page, including embedded third-party scripts, ask the user for camera access."
You can do this for camera, microphone, geolocation, USB devices, payment APIs, accelerometer, and a long list of other browser features.
**The attack it stops: feature abuse through compromised third parties**
Imagine your site embeds an analytics script from a third party. That third party gets hacked. The attacker pushes a malicious update to the analytics script. Now every page on your site that includes the script is running attacker code with access to whatever the browser allows. If the attacker writes `navigator.mediaDevices.getUserMedia({ audio: true })`, the user gets a "this site wants to use your microphone" prompt. Many users will click yes because they trust your site.
**How the header stops it.** `Permissions-Policy: camera=(), microphone=()` tells the browser "no code on this page may request camera or microphone, regardless of source. Do not even show the prompt." The attacker's script gets a denied response and moves on.
**What the value looks like**
```
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
```
The empty parentheses mean "no origins are allowed to use this feature." You can also explicitly allowlist origins, but for most sites the right answer for most features is "nobody."
**Why severity is LOW.** This only matters if (a) your site embeds third-party code, AND (b) that third-party gets compromised, AND (c) the third party would otherwise try to use these features. It is genuinely defence in depth, but it is several steps removed from the immediate attack surface.
## 4. The scoring rubric
So we have six headers. Each one has a severity (high, medium, low) that maps to a point value:
```
high = 30 points each
medium = 15 points each
low = 5 points each
```
The current rules table has 2 highs, 2 mediums, 2 lows. Total achievable: `2*30 + 2*15 + 2*5 = 90` points. Wait, that does not add up to 100? Correct. The math:
```
60 (two highs) + 30 (two mediums) + 10 (two lows) = 100 points
```
I miscounted. Let me redo it: high=30, two highs is 60. Medium=15, two mediums is 30. Low=5, two lows is 10. Total: 60+30+10 = **100**.
For each header, the scanner produces a finding:
- `ok` → earn the full point value for that rule
- `weak` → earn half points (the header is present but the value is broken)
- `missing` → earn zero
Then:
```
score = round( (earned points / total points) * 100 )
```
The score becomes a grade by a standard letter-grade cutoff:
```
score >= 90 → A
score >= 80 → B
score >= 70 → C
score >= 60 → D
otherwise → F
```
This mirrors how Mozilla Observatory and securityheaders.com work. They use different exact point values and check more headers, but the shape is the same: weighted findings, percentage score, letter grade.
## 5. What this scanner does NOT do
Being clear about scope is important. This is a foundations project. It is **not**:
- **A crawler.** It scans exactly the URL you give it. One request. It does not follow links inside the page and grade every subpage.
- **A CSP analyser.** We only check that `Content-Security-Policy` exists. We do not look inside the value for `unsafe-inline`, wildcard sources, missing `default-src`, etc.
- **A vulnerability scanner.** It does not try to find SQL injection, XSS, open redirects, or any actual exploitable flaw. It only reports on missing defensive configuration.
- **An authority.** Real sites sometimes intentionally drop certain headers because they break a feature they need. A missing header is a signal worth investigating, not an automatic verdict.
When you want a fuller picture, graduate to:
- **Mozilla Observatory** (`observatory.mozilla.org`): does everything we do plus deep CSP analysis, cookie checks, TLS configuration grading.
- **securityheaders.com**: similar idea, simpler UI.
- **`nmap` with the http-security-headers script**: for command line nerds.
## 6. Industry references
If you want to look these up yourself in official docs:
- **MDN** (developer.mozilla.org) has authoritative articles on each header. Search for the header name plus "MDN."
- **OWASP Secure Headers Project** (owasp.org/www-project-secure-headers) has the canonical list and recommended values.
- **RFC 6797** is the spec for HSTS. RFC 7034 is the spec for X-Frame-Options. Reading specs is a useful skill even when the spec is boring.
- **CWE-693** "Protection Mechanism Failure" is the common-weakness ID for missing or misconfigured defensive headers.
## 7. Quick self check
You should be able to answer these before moving on to architecture:
1. What is the difference between an HTTP request and an HTTP response?
2. Where do headers sit in a response (relative to the status line and the body)?
3. Which header would have prevented SSL stripping at the coffee shop?
4. Which header would have prevented the clickjacking-style "delete repo" attack?
5. Why does the scanner report `weak` instead of `ok` when `X-Content-Type-Options` is present but its value is not literally `nosniff`?
6. What kind of attack does CSP defend against, and why does it not parse the CSP value deeply?
7. What is the grade for a site with score 75? What about 60? What about 59?
If any of these feel fuzzy, re-read the relevant section. The implementation in the next two files will make much more sense once these are solid.
## Next
Move on to **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** for how the scanner is organised in code, or jump straight to **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** for the line-by-line walkthrough.

View File

@ -0,0 +1,322 @@
# Architecture
This file explains how the code is organised and why. It is the bridge between "I get what HTTP headers are" (the previous file) and "I can read this exact Python code" (the next file). Read it after concepts and before implementation.
The whole scanner is one Python file plus one test file. Less than 700 lines total. That is small enough that you might wonder why we are using the word "architecture" at all. The answer: even small programs benefit from being split into pieces, and the WAY they get split matters. A real production scanner is going to grow into many files. Learning what the split looks like at this size makes the bigger ones less scary.
## 1. The big picture
The scanner is a pipeline. Data flows left to right through four stages:
```
┌────────┐ URL ┌──────────┐ bytes ┌────────────┐ findings ┌──────────┐
│ User │ ────▶ │ scan() │ ──────▶ │ evaluate_ │ ────────▶ │ render │
│ CLI │ │ fetches │ │ header() │ │ report │
└────────┘ └──────────┘ loop │ ×6 │ └──────────┘
└────────────┘
```
1. **CLI layer.** The user types `headers https://example.com`. `argparse` turns that into a normal Python object with `args.url` and `args.timeout`.
2. **Network layer (`scan()`).** Makes one HTTPS request, follows redirects, returns the raw headers as a dict.
3. **Evaluation layer (`evaluate_header()`).** Pure function. Takes one rule and the response headers, returns one finding. Called once per rule. No network. No printing.
4. **Render layer (`_render_report()`).** Takes the report, prints a coloured table plus the grade panel plus recommendations.
The reason it is laid out this way: each stage is independently testable. We can hand `evaluate_header()` a fake dict of headers and check the finding, without ever touching the internet. We can build a fake `ScanReport` and check the score and grade, without running `scan()` at all.
If any of these stages were mashed together (for example, if `scan()` directly printed its output and the math lived inside the print logic), testing would mean spinning up a real or fake web server every time. Splitting them apart is the whole reason you can write 20 tests that run in under a second.
## 2. The four key data shapes
We use **dataclasses** for our data. A dataclass is just a class where Python writes the boilerplate for you. Instead of this:
```python
class HeaderRule:
def __init__(self, header, severity, description, recommendation, must_match=None):
self.header = header
self.severity = severity
self.description = description
self.recommendation = recommendation
self.must_match = must_match
```
You write this:
```python
@dataclass(frozen=True, slots=True)
class HeaderRule:
header: str
severity: Severity
description: str
recommendation: str
must_match: str | None = None
```
The `@dataclass` decorator on top reads the field declarations and generates the `__init__` for you. It also generates equality (`==`), a string representation, and a few other useful things.
Two flags worth understanding:
- **`frozen=True`** makes the dataclass **immutable**. Once you create a `HeaderRule`, you cannot do `rule.severity = "low"`. Trying to modify a field raises an error. This is good for "value object" types where you want guarantees that no other piece of code can change them out from under you.
- **`slots=True`** makes instances **smaller in memory**. Without slots, every Python object carries around a hidden dictionary for its attributes, which is flexible but uses more memory. With slots, attributes go into fixed slots, no dict. For a record type with a known set of fields, this is pure win. Costs you nothing.
We have four shapes total:
### 2.1 `HeaderRule`: "what we are looking for"
One rule is one thing-to-check. Header name, severity, description (for humans), a recommendation (what to set if missing), and an optional `must_match` regex (for headers like `X-Content-Type-Options` where the *value* has to be right, not just present — a plain word like `"nosniff"` works as a substring check, while a richer pattern like `r"max-age\s*=\s*[1-9]"` rejects HSTS values that disable themselves).
This is the rule the scanner walks down. The whole list of rules lives in a module-level constant called `RULES`. Adding a new header check means appending to that list. The rest of the code is generic.
### 2.2 `HeaderFinding`: "what we found"
One finding is the result of running one rule against the server's response. It carries:
- The rule it came from (so the renderer can show severity, recommendation, etc. without doing a second lookup).
- A `status`: `ok`, `weak`, or `missing`.
- The `actual_value` the server sent (or `None` if the header was missing).
- A short human-readable `note` describing what happened ("Present", "Present and contains `nosniff`", "Header `X` is not set", etc.).
Findings are also frozen. Once we evaluate a rule, the result does not change. That makes the report safe to pass around to multiple functions without worrying that one of them mutates it.
### 2.3 `ScanReport`: "the whole result"
One report wraps everything from one scan. The original URL (what the user typed), the final URL (after redirects), the HTTP status code, and the list of findings (one per rule).
The interesting part: `score` and `grade` are **computed properties**, not stored fields. They are functions decorated with `@property` that look at the findings on the fly. This means:
- We do not have to remember to recalculate them when findings change (they cannot change, the report is frozen, but the principle stands).
- A test can build a report with a synthetic set of findings and immediately ask `report.score`, no plumbing required.
### 2.4 The `Severity` and `Status` types
These are **Literal types**:
```python
Severity = Literal["high", "medium", "low"]
Status = Literal["ok", "weak", "missing"]
```
A `Literal` type tells the type checker "this is a string, but it can only ever be one of these exact values." Why bother? Two reasons:
1. **Typo protection.** If you write `severity = "hgih"` somewhere, mypy catches it before you even run the code. Without `Literal`, the type would just be `str` and any typo would slip through.
2. **Documentation.** Just by looking at the type signature you know the legal values. You do not have to grep through the code to find out.
We could have used an `Enum` instead. Carter's style guide prefers `Literal` for small fixed sets because it keeps the values as plain strings (easy to print, easy to log, easy to use as dict keys).
## 3. Layer separation: the I/O fence
The single most important architectural decision in this project is the **fence between code that touches the network and code that does not**. Let us trace it.
```
┌─────────────────────────────────────────────────────────────────────┐
│ THE I/O LAYER │
│ - scan() (calls httpx.get, hits the network) │
│ - main() (reads sys.argv, calls scan) │
│ - _render_report() (writes to the terminal) │
└───────────────────────────────┬─────────────────────────────────────┘
│ passes a dict[str, str] or a ScanReport
┌─────────────────────────────────────────────────────────────────────┐
│ THE PURE LAYER │
│ - evaluate_header(rule, headers) -> HeaderFinding │
│ - ScanReport.score property │
│ - ScanReport.grade property │
│ - RULES list, SEVERITY_POINTS dict │
└─────────────────────────────────────────────────────────────────────┘
```
Everything in the **pure layer**:
- Takes inputs.
- Returns outputs.
- Touches no network. Touches no filesystem. Prints nothing.
Everything in the **I/O layer**:
- Talks to the outside world.
- Eventually passes its results into the pure layer.
Why this matters: the pure layer is **trivially testable**. You build inputs by hand, you check outputs. No mocking required. The I/O layer is harder to test (you have to mock the network or the terminal), but it is also the small part. Most of the bugs in a scanner are in the math, not in the call to httpx. Putting the math in a pure function means you test the math with confidence and the network code with only a few smoke tests.
This is sometimes called **functional core, imperative shell**. Coined by Gary Bernhardt. Same idea: keep the part that decides what to do pure, push the part that actually does it to the edges.
## 4. The data flow, end to end
Let us trace one scan from terminal to output. The user types:
```
$ just run -- https://github.com
```
```
1. SHELL → main()
─────────────────────────────────────────────────────
`just run` calls `uv run headers https://github.com`.
`uv run` activates the venv, runs `headers` which is
declared in pyproject.toml as the entry point that
maps to http_headers_scanner:main.
sys.argv is now ["headers", "https://github.com"].
2. main() → _build_argument_parser()
─────────────────────────────────────────────────────
We construct an argparse parser, add the `url` arg
and the `--timeout` option, then call parse_args().
Result: args.url = "https://github.com", args.timeout = 10.0
3. main() → scan(url, timeout)
─────────────────────────────────────────────────────
scan() calls httpx.get() with follow_redirects=True
and a custom User-Agent. httpx does DNS, opens a TCP
connection, negotiates TLS, sends the GET request,
reads the response, follows any redirects. Returns a
Response object.
4. scan() → response_headers (a dict[str, str])
─────────────────────────────────────────────────────
We convert httpx's Headers object to a plain dict.
This is the "leave the I/O world, enter the pure
world" handoff. Past this point, nothing knows or
cares about httpx.
5. scan() → [evaluate_header(rule, headers) for rule in RULES]
─────────────────────────────────────────────────────
A list comprehension. Run evaluate_header() once for
each of the six rules. Each call is pure: it looks
up the header in the dict (case-insensitive), checks
must_match (via re.search, case-insensitive) if set,
returns a HeaderFinding.
6. scan() → ScanReport
─────────────────────────────────────────────────────
Bundle the URL, final URL, status code, and findings
into a frozen ScanReport. scan() is done.
7. main() → _render_report(report, console)
─────────────────────────────────────────────────────
The renderer builds a rich Table, adds one row per
finding, prints it. Then builds the grade Panel,
prints it. Then iterates over non-ok findings and
prints their recommendations.
8. main() → exit code
─────────────────────────────────────────────────────
Look at report.grade. A or B → return 0 (success).
C or D → return 1 (warning). F or network error → 2.
sys.exit(main()) sends the code to the shell.
```
The key thing to notice: steps 5 and 6 are pure. If you want to write a test that exercises the scoring and finding logic, you skip step 3 entirely and call `evaluate_header()` directly with hand-built inputs. That is exactly what `test_http_headers_scanner.py` does.
## 5. Why each function is the size it is
A common question for beginners: how do you know when to split a chunk of code into a new function?
A useful rule of thumb: **one job per function**. If you can describe what a function does in one sentence without "and," it is probably the right size. If you have to say "this fetches the URL AND parses the headers AND grades them AND prints the table," it is too big.
Look at our functions through that lens:
- **`evaluate_header()`**: "Apply one rule to one set of headers and return a finding." One job.
- **`scan()`**: "Fetch a URL and return a report." One job. It does also call `evaluate_header()` internally, but that is delegation, not a second job.
- **`_render_report()`**: "Pretty-print a report to the terminal." One job.
- **`_build_argument_parser()`**: "Build the argparse parser." One job. Worth splitting out so tests can call it without firing main().
- **`main()`**: "Glue the others together and pick an exit code." One job: orchestration.
The underscore prefix on `_render_report` and `_build_argument_parser` is a Python convention meaning "this is private, not part of the public API." Other code in the same file can still call them, but anyone importing the module should consider them internal.
## 6. The single source of truth: the `RULES` list
Notice that `RULES` is defined once, at the top of the file, as a list of `HeaderRule` objects. The scoring functions, the evaluation function, and the renderer all walk this list at runtime. None of them have hardcoded knowledge of which headers exist.
What this buys us: **adding a seventh header to check is a one-line change**. Append a `HeaderRule` to the list. The scanner picks it up automatically. The test suite picks it up automatically (the synthetic-report helper uses `RULES` directly). No code in `scan()`, in the rendering, in the scoring needs to change.
This is what people mean when they say "data driven" code. The behaviour is determined by data (the rules table), not by hardcoded logic per case. It is also one of the easiest patterns to recognise once you start looking for it.
The same pattern, with the same benefits, shows up in real production scanners:
- Nuclei (a vuln scanner) reads YAML templates that look a lot like our HeaderRule, just bigger.
- ESLint plugins are mostly rules tables.
- Nmap NSE scripts are individual rule files in a directory.
## 7. Why we use httpx, not requests
`requests` is the famous Python HTTP library. `httpx` is the newer one. They have very similar APIs. We picked httpx because:
- **First-class type hints.** mypy understands httpx out of the box. requests still requires `types-requests` stubs.
- **HTTP/2 support.** Newer sites speak HTTP/2 by default. requests is HTTP/1.1 only.
- **Async-ready.** httpx has a sync API (what we use) and an async API for when you grow up.
- **Active maintenance.** requests is in maintenance mode. httpx is where new development happens.
For this project we only use the sync API. The async API would let us scan many URLs in parallel, which is a great extension challenge in `04-CHALLENGES.md`.
## 8. Why we use respx for testing
When you write a test that calls `scan("https://example.com")`, you have a problem: the test now depends on example.com being reachable, fast, and returning predictable headers. None of those are guaranteed. The test would be **flaky** (sometimes pass, sometimes fail, for reasons unrelated to your code).
`respx` solves this by intercepting every httpx call inside a test and returning whatever you set up. Schematically:
```
Test code respx (interceptor) The real internet
───────── ─────────────────── ─────────────────
respx.get(URL).mock( intercepts httpx.get,
return_value=... never sends a packet,
) hands back the fake
scan(URL) ───▶ Response object ──╳ never reached
```
A test using respx is fast (no network), deterministic (the fake response is exactly what you set up), and offline-friendly. The cost is that you have to be careful: `respx` only intercepts httpx, so a scan that used `requests` under the hood would silently bypass the mock.
## 9. Error handling philosophy
Two layers of error handling.
In `scan()`, we **let errors propagate**. If DNS fails, if the host refuses the connection, if the request times out, httpx raises an exception. We do not catch it. The function's job is to fetch and grade, not to decide what to do when fetching fails.
In `main()`, we **catch `httpx.RequestError` once** and turn it into a clean message plus exit code 2. The user does not need to see a 30-line Python traceback for "could not connect." They need to see "request failed."
This is a pattern worth internalising: **the library code lets errors bubble up, the CLI code translates them into user-friendly output.** The library author does not know what context the library is being called from, so they should not pretend to know how to handle errors. The CLI author knows exactly what context the call is being made in (a user typed a command), so they can present errors appropriately.
## 10. Exit codes that mean something
Most CLIs return exit code 0 for success and 1 for any kind of failure. Our scanner uses three:
```
0 → grade A or B (CI: green, no action needed)
1 → grade C or D (CI: yellow, worth investigating)
2 → grade F or network error (CI: red, must fix)
```
This is useful when you wire the scanner into CI. A pipeline can run:
```
just run -- https://my-deployed-site.com
if [ $? -gt 1 ]; then exit 1; fi # fail the build only on F or error
```
You can decide for yourself what threshold counts as "fail the build." The point is the scanner gives you the information to decide. A binary success/failure exit code throws away too much detail.
## 11. What does NOT belong in this architecture
For a foundations project, we deliberately keep things out:
- **No async.** A single URL does not need it. The sync API is easier to read.
- **No subcommands.** No `headers scan`, `headers explain`, `headers config`. Just one job, run it.
- **No config file.** All settings come from CLI flags. No `~/.config/headers.toml`. Add one if you extend it.
- **No database.** Each run is independent. No history. Add one if you want trends over time (challenge file has details).
- **No plugin system.** The rules table is just a list. To "extend" the scanner, you edit the list.
These are all things a more mature scanner would have, and every one of them is a great extension idea. None of them belong in a project whose goal is "be the smallest possible thing that teaches the core idea."
## 12. Key files reference
A quick map of the project:
| Path | What is in it |
|------|---------------|
| `http_headers_scanner.py` | The whole scanner. Rules, evaluation, scan, CLI. |
| `test_http_headers_scanner.py` | All tests. Uses pytest and respx. |
| `pyproject.toml` | Project metadata, dependencies, tool configs (ruff, mypy, pylint, yapf, pytest). |
| `uv.lock` | Exact versions of every transitive dependency. Reproducible builds. |
| `justfile` | Shortcut commands: `just test`, `just lint`, `just run`. |
| `install.sh` | One-shot installer for new clones. |
| `learn/` | The documentation you are reading. |
## Next
Move on to **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** for the line-by-line walkthrough, or jump to **[04-CHALLENGES.md](./04-CHALLENGES.md)** for extension ideas.

View File

@ -0,0 +1,670 @@
# Implementation Walkthrough
This file walks through the actual code in `http_headers_scanner.py` (and a bit of `test_http_headers_scanner.py`) line by line. By the end you should understand every piece of the file: what it does, why it is there, and what would break if you removed it.
This is the longest file in the learn folder. Take it in chunks. The order below matches the order things appear in the source.
## 0. Reading conventions
Each section names a function, class, or constant from `http_headers_scanner.py`. Open the file in your editor on the side and search for the name. The code excerpts in this guide are real, copied directly from the file, but the file is also short enough that you can scroll the whole thing in a couple of pages.
## 1. The file docstring
The file starts with a long triple-quoted string. In Python, a string at the very top of a file is called the **module docstring**. It is the official place to explain what the file is about.
```python
"""
©AngelaMos | 2026
http_headers_scanner.py
Scan a URL and grade its HTTP security headers AF
When a browser asks a website for a page, the server sends back the
page itself PLUS a bunch of metadata called "HTTP response headers."
...
"""
```
A few things to notice:
- **The first three lines are the project's standard file header.** Every file in the project starts this way: a copyright line, a blank-ish line, the filename. The `©AngelaMos | 2026` part is the project's branding, not something you would normally see in a generic Python tutorial.
- **The body is unusually long for a docstring.** Most files have a one-line summary. This one is detailed because it is a teaching project. The docstring is the first thing any reader sees (`help(http_headers_scanner)` prints it, IDEs show it on hover), so we use it to teach.
- **It ends with a list of "what this file exposes."** This is a real convention. Tells readers what they can import from the module without scrolling through 600 lines.
## 2. Imports
```python
import argparse
import re
import sys
from dataclasses import dataclass
from typing import Literal
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
```
PEP 8 (Python's style guide) wants imports grouped into three sections separated by blank lines:
1. **Standard library**: things that ship with Python. Here: `argparse`, `re`, `sys`, `dataclasses`, `typing`.
2. **Third-party**: things you installed with `uv` / `pip`. Here: `httpx`, `rich`.
3. **Local**: things from this same project. We have none.
`re` is the standard library's regular-expression module. We use `re.search(pattern, value, re.IGNORECASE)` inside `evaluate_header()` to check whether a header's value matches a rule's required pattern. Regexes give us a way to express "max-age must be a positive integer" in one line, instead of having to parse the HSTS directive structure ourselves.
Each module is imported with a tight inline comment explaining what we use it for. Beginners often ask "what does `import` even do?" Short answer: it tells Python "go find this module and make its names available in this file." `import argparse` makes `argparse.ArgumentParser` available. `from dataclasses import dataclass` makes the bare name `dataclass` available so we can use `@dataclass` directly without writing `@dataclasses.dataclass`.
## 3. The Severity and Status types
```python
Severity = Literal["high", "medium", "low"]
Status = Literal["ok", "weak", "missing"]
```
These are **type aliases**. They give a friendly name to a more complex type. Anywhere you write `Severity` from now on, the type checker reads `Literal["high", "medium", "low"]`.
The reason `Literal` exists: a regular `str` means "any string." If we annotated `severity: str`, then `severity = "hgih"` would compile fine and only blow up at runtime when something tried to look it up. With `severity: Severity`, mypy refuses to let `"hgih"` near the field. The typo is caught at edit time.
This is more discipline than most beginner Python you will see online. It is a deliberate choice for a teaching project: we want you to absorb the habit early.
## 4. `HeaderRule` dataclass
```python
@dataclass(frozen=True, slots=True)
class HeaderRule:
header: str
severity: Severity
description: str
recommendation: str
must_match: str | None = None
```
A dataclass is a regular class that has the boring parts (constructor, equality, string repr) written for you by the `@dataclass` decorator. We covered the `frozen` and `slots` flags in `02-ARCHITECTURE.md`. The short version: `frozen` prevents anyone from modifying the fields after construction, `slots` makes the instances smaller in memory.
The fields:
- **`header`**: the HTTP header name we are looking for. Stored with canonical casing (e.g. `"Strict-Transport-Security"`) but compared case-insensitively at lookup time.
- **`severity`**: drives the score. `"high"` = 30 points, `"medium"` = 15, `"low"` = 5.
- **`description`**: one sentence explaining the header. Currently used for documentation; we could also render it in the table.
- **`recommendation`**: what to add to fix a missing or weak header. Shown in the "Recommendations" section at the bottom of the output.
- **`must_match`**: optional. A regex pattern the value must match (case-insensitive) to be considered `ok`. For HSTS the pattern is `r"max-age\s*=\s*[1-9]"` (rejects `max-age=0`); for `X-Content-Type-Options` it is `"nosniff"` (a plain word works as a substring match under `re.search`). If `None`, presence alone is enough.
The trailing `= None` on `must_match` is its **default value**. Means you can construct a `HeaderRule` without specifying it. Only fields with defaults can be omitted at construction time.
## 5. The `RULES` table
```python
RULES: list[HeaderRule] = [
HeaderRule(
header="Strict-Transport-Security",
severity="high",
...
must_match=r"max-age\s*=\s*[1-9]",
),
HeaderRule(
header="Content-Security-Policy",
severity="high",
...
),
...
]
```
This is the **single source of truth** for which headers we check. The list-of-dataclasses pattern is one of the most useful in Python: each entry is structured, immutable, and easy to add to.
A pattern detail: we use **keyword arguments** for every field, not positional. We write `HeaderRule(header="...", severity="...", ...)`, not `HeaderRule("...", "...", ...)`. Why? Two reasons:
1. **Readability.** When someone reads the code, they see `severity="high"` and know exactly what the second value means. Positional `("Strict-Transport-Security", "high", ...)` makes them count fields.
2. **Refactor safety.** If you add a new field later (say `references: list[str]`), positional calls might land the new value in the wrong place. Keyword calls are unambiguous.
Why is this list at module level, not inside a function? Because it never changes. Building it once at import time is cheaper than rebuilding it on every scan. It is also accessible to the test suite (`from http_headers_scanner import RULES`).
## 6. `SEVERITY_POINTS` mapping
```python
SEVERITY_POINTS: dict[Severity, int] = {
"high": 30,
"medium": 15,
"low": 5,
}
```
A dictionary mapping each severity to its point value. Notice the type annotation: `dict[Severity, int]`. That tells the type checker "keys must be `"high"` / `"medium"` / `"low"`, values must be ints." If you tried to add `"critical": 50` to this dict, mypy would refuse: `"critical"` is not in the `Severity` Literal type.
Why a dict and not a function with three if statements? Because it is data, not logic. Data driven code is easier to extend (add another severity, edit one line) and easier to test (you can assert the exact point values).
## 7. `HeaderFinding` dataclass
```python
@dataclass(frozen=True, slots=True)
class HeaderFinding:
rule: HeaderRule
status: Status
actual_value: str | None
note: str
```
A finding is the result of evaluating one rule against one response. It carries:
- **`rule`**: the rule that was evaluated. Storing the whole rule inside the finding (rather than just its name) means the renderer never has to do a second lookup to know the severity or recommendation.
- **`status`**: one of `"ok"`, `"weak"`, `"missing"`. The Literal type catches typos.
- **`actual_value`**: whatever the server actually sent. `None` if the header was missing.
- **`note`**: a short human-friendly string. Shown in the table.
Why is `actual_value` typed `str | None`? Because the field is genuinely sometimes a string and sometimes nothing. `None` is Python's way of saying "no value." The type `str | None` makes that explicit. Anywhere you use `finding.actual_value`, the type checker forces you to either handle the None case or assert that it cannot be None.
The `|` syntax (e.g. `str | None`) is the modern way (Python 3.10+). The older way was `Optional[str]` from the `typing` module. Both work; the new syntax is shorter.
## 8. `ScanReport` dataclass with computed properties
```python
@dataclass(frozen=True, slots=True)
class ScanReport:
url: str
final_url: str
status_code: int
findings: list[HeaderFinding]
@property
def score(self) -> int:
...
@property
def grade(self) -> str:
...
```
A report has four stored fields plus two computed properties.
### 8.1 Why `final_url` is separate from `url`
`url` is what the user typed. `final_url` is where they ended up after redirects. They are often the same. They are different when, say, `http://example.com/` redirects to `https://example.com/`. We track both because:
- The user wants to see the URL they typed acknowledged in the output.
- The grade really belongs to the final URL (the redirected destination is what their browser actually shows).
### 8.2 The `score` property
```python
@property
def score(self) -> int:
total = sum(SEVERITY_POINTS[r.severity] for r in RULES)
if total == 0:
return 0
earned = 0.0
for finding in self.findings:
full = SEVERITY_POINTS[finding.rule.severity]
if finding.status == "ok":
earned += full
elif finding.status == "weak":
earned += full / 2
return int((earned / total) * 100 + 0.5)
```
Step by step:
1. **`@property`** on the line above turns the method into something you access without parentheses. `report.score`, not `report.score()`. Looks like a field, computed on demand.
2. **`total = sum(SEVERITY_POINTS[r.severity] for r in RULES)`** computes the total achievable points by walking the rules. The expression inside `sum(...)` is a **generator expression**: it produces one number per rule (the point value for that rule's severity), then sum adds them up. With the current 2-high, 2-medium, 2-low rules, total = 100.
3. **`if total == 0: return 0`** is a guard. If somebody deleted the rules table at runtime, we would otherwise divide by zero. Returning zero is a safe answer.
4. **The main loop** walks every finding. For each one, look up the full point value for its rule's severity. If status is `ok`, add the full points. If `weak`, add half. If `missing`, add nothing (no explicit branch; the variable is unchanged).
5. **`int((earned / total) * 100 + 0.5)`** is the final score. The `+ 0.5` then `int(...)` is a manual round-half-up. We use it because Python's built-in `round()` uses banker's rounding (round half to even), which would map `round(0.5)` to `0` and `round(2.5)` to `2`. Mathematically defensible (it cancels out bias over a large sample) but surprising at the `.5` boundary, where a score should always round up. `int(x + 0.5)` is the form everyone expects.
### 8.3 The `grade` property
```python
@property
def grade(self) -> str:
score = self.score
if score >= 90:
return "A"
if score >= 80:
return "B"
if score >= 70:
return "C"
if score >= 60:
return "D"
return "F"
```
Notice that each branch `return`s directly, so there are no `elif`s and no final `else`. This is a common idiom called **early return**. The function reads top to bottom: as soon as a condition matches, you are done. It also avoids "arrow code" where each branch is more indented than the last.
Notice also that **we call `self.score` once**, store the result, then compare it five times. If we wrote `if self.score >= 90:` etc., the property would re-run each time. For a tiny score function it would not matter, but the habit of caching repeated expensive lookups is worth forming early.
## 9. `evaluate_header()`: the heart of the scanner
This is the **pure function** at the core of everything. No network. No prints. Just rule plus headers in, finding out.
```python
def evaluate_header(
rule: HeaderRule,
response_headers: dict[str, str],
) -> HeaderFinding:
target = rule.header.lower()
actual_value: str | None = None
for name, value in response_headers.items():
if name.lower() == target:
actual_value = value
break
if actual_value is None:
return HeaderFinding(
rule=rule,
status="missing",
actual_value=None,
note=f"Header `{rule.header}` is not set",
)
if rule.must_match is None:
return HeaderFinding(
rule=rule,
status="ok",
actual_value=actual_value,
note="Present",
)
if re.search(rule.must_match, actual_value, re.IGNORECASE):
return HeaderFinding(
rule=rule,
status="ok",
actual_value=actual_value,
note=f"Present and matches `{rule.must_match}`",
)
return HeaderFinding(
rule=rule,
status="weak",
actual_value=actual_value,
note=(
f"Present but does not match `{rule.must_match}` "
f"(got `{actual_value}`)"
),
)
```
Three branches, in order:
1. **Missing.** We loop over the response headers, lowercase each name, compare against the lowercased target. If we never find a match, `actual_value` stays `None`, and we return a `missing` finding.
2. **Present, no must_match check.** If the rule does not require a specific pattern, presence alone is enough. Return `ok`.
3. **Present, must_match check.** If the rule has a `must_match`, run `re.search(pattern, value, re.IGNORECASE)`. A plain word like `"nosniff"` works as a substring check; a richer pattern like `r"max-age\s*=\s*[1-9]"` enforces a real condition (HSTS must be set to a positive integer, not the actively-harmful `max-age=0`). If the pattern matches, `ok`. If not, `weak`.
A few things worth pointing out:
**The case-insensitive lookup.** HTTP header names are case insensitive per RFC 7230. Different servers return them with different casings. Some return `Strict-Transport-Security`, some `strict-transport-security`, some even `STRICT-TRANSPORT-SECURITY` (rare but legal). Lowercasing both sides is the simplest portable way to handle this.
We could have used a case-insensitive dict (httpx returns one), but the function should accept a plain dict for testing purposes. In practice `scan()` already converts the response headers to a plain `dict[str, str]` before calling `evaluate_header`, so this function never sees an `httpx.Headers` object directly — but the contract is "any `dict[str, str]` works," which is what makes hand-built test inputs trivial.
**Why we `break` out of the loop early.** Once we found the header, we have what we need. Continuing the loop would waste CPU.
**The f-strings in `note`.** An f-string is a string with `{expression}` placeholders that get filled in at runtime. `f"Header `{rule.header}` is not set"` becomes `Header `Strict-Transport-Security` is not set` if the rule's header is HSTS. The backticks around the header name make it look monospaced if the renderer happens to be markdown-aware, and it generally helps the eye.
**No else branches.** Each branch returns. Once you return, the function is done. No need to write `elif` or `else`. This is the same early-return pattern from the `grade` property.
## 10. `scan()`: the network call
```python
DEFAULT_USER_AGENT: str = (
"http-headers-scanner/1.0 "
"(+https://github.com/CarterPerez-dev/Cybersecurity-Projects)"
)
def scan(
url: str,
*,
timeout: float = 10.0,
user_agent: str = DEFAULT_USER_AGENT,
) -> ScanReport:
response = httpx.get(
url,
timeout=timeout,
follow_redirects=True,
headers={"User-Agent": user_agent},
)
response_headers = dict(response.headers)
findings = [evaluate_header(rule, response_headers) for rule in RULES]
return ScanReport(
url=url,
final_url=str(response.url),
status_code=response.status_code,
findings=findings,
)
```
### 10.1 The User-Agent
The User-Agent string identifies who is making the request. Browsers send things like `Mozilla/5.0 (X11; Linux x86_64) ...`. Our scanner sends `http-headers-scanner/1.0 (+https://...)`. This is polite for two reasons:
- Server operators reading their access logs can tell who is hitting them and check our project page if they wonder why.
- Some sites block the default httpx UA. A custom UA is more likely to get a real response.
### 10.2 The `*,` in the signature
```python
def scan(
url: str,
*,
timeout: float = 10.0,
user_agent: str = DEFAULT_USER_AGENT,
) -> ScanReport:
```
The `*,` between `url` and `timeout` forces callers to pass `timeout` and `user_agent` by keyword. You cannot call `scan("https://example.com", 5.0)`. You have to call `scan("https://example.com", timeout=5.0)`.
Why force that? Because `5.0` does not obviously mean "five seconds of timeout" when you read the call site. `timeout=5.0` does. Keyword-only arguments make call sites more readable and refactor-safe. The cost is exactly one extra character (`timeout=`) when calling the function.
### 10.3 `follow_redirects=True`
When the server says "this URL has moved, try this other one," we follow the redirect automatically. Many sites redirect `http://` to `https://` or `www.` to bare domain. The user typed one URL but their browser would end up on a different one. We want to grade the one their browser would actually see.
### 10.4 The handoff to the pure layer
```python
response_headers = dict(response.headers)
findings = [evaluate_header(rule, response_headers) for rule in RULES]
```
These two lines are the "leave I/O world, enter pure world" handoff we talked about in the architecture file. `dict(response.headers)` converts the httpx Headers object into a plain dict. The list comprehension on the next line runs `evaluate_header()` for each rule.
A **list comprehension** is shorthand for "make a list by running an expression over each item in a source." The equivalent for-loop would be:
```python
findings = []
for rule in RULES:
findings.append(evaluate_header(rule, response_headers))
```
Same result, more lines. The comprehension is preferred when the body is one expression.
## 11. Rendering
```python
STATUS_COLORS: dict[Status, str] = {
"ok": "green",
"weak": "yellow",
"missing": "red",
}
GRADE_COLORS: dict[str, str] = {
"A": "bright_green",
"B": "green",
"C": "yellow",
"D": "red",
"F": "bright_red",
}
def _render_report(report: ScanReport, console: Console) -> None:
table = Table(...)
table.add_column(...)
...
for finding in report.findings:
status_color = STATUS_COLORS[finding.status]
table.add_row(...)
console.print(table)
if report.final_url.startswith("http://"):
console.print(
"[yellow]Note:[/yellow] this response was served over plain "
"HTTP. Browsers IGNORE HSTS over HTTP, ..."
)
grade_color = GRADE_COLORS[report.grade]
panel = Panel(...)
console.print(panel)
actionable = [f for f in report.findings if f.status != "ok"]
if actionable:
console.print("\n[bold]Recommendations:[/bold]")
for finding in actionable:
console.print(...)
```
The renderer uses **rich**, a third-party library for pretty terminal output. The patterns:
- **A `Table` object** with columns. You add rows one at a time. `console.print(table)` draws it as a Unicode-bordered table.
- **`[green]something[/green]`** is rich's markup syntax. It is roughly like HTML for terminal colors. `[bold cyan]Result[/bold cyan]` would render "Result" in bold cyan.
- **`Panel(...)`** wraps content in a bordered box.
The renderer is intentionally separate from `scan()` and `evaluate_header()`. The pure code does not know or care about colors. If we ever want a JSON output mode for CI, we add a second renderer (`_render_json(report)`) and keep all the other code unchanged.
The `actionable = [f for f in report.findings if f.status != "ok"]` line is another comprehension: build a list of every finding whose status is not `ok`. These are the ones we have recommendations for. If the list is empty (perfect score), we skip the section entirely.
**The HTTP warning.** Right after the table, we check `report.final_url.startswith("http://")`. Per RFC 6797 §8.1, browsers MUST IGNORE the `Strict-Transport-Security` header when it arrives over plain HTTP — only HSTS received over HTTPS counts. So if a user points the scanner at `http://example.com` and the server returns HSTS, that HSTS earns full credit in our grading even though no real browser would honor it. The yellow note makes the caveat visible at the only place that matters: the user-facing report. We do not change the grading logic — one rule, one outcome — but the user sees an honest "this grade is misleading until the site enforces HTTPS" line right next to the score.
## 12. The argparse plumbing
```python
def _build_argument_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="headers",
description="Scan a URL for HTTP security headers and grade the result AF.",
)
parser.add_argument(
"url",
help="Full URL to scan (must include http:// or https://).",
)
parser.add_argument(
"--timeout",
type=float,
default=10.0,
help="Seconds to wait before giving up on the request (default: 10).",
)
return parser
```
**argparse** is the standard library's command-line argument parser. You declare what your program accepts, argparse handles the rest: parsing, type conversion, generating `--help` output, rejecting bad input.
Two arguments declared:
- **`url`**: positional (no `--` prefix). Required. If the user does not provide it, argparse errors out and prints the usage automatically.
- **`--timeout`**: optional. Defaults to `10.0`. `type=float` tells argparse to convert the string `"5"` into the float `5.0`.
The function is intentionally separate from `main()` so tests can build the parser and call `parse_args([...])` on a synthetic list, without having to mess with `sys.argv`.
## 13. `main()`: orchestration
```python
def main() -> int:
parser = _build_argument_parser()
args = parser.parse_args()
console = Console()
try:
report = scan(args.url, timeout=args.timeout)
except httpx.RequestError as exc:
console.print(f"[red]Request failed:[/red] {type(exc).__name__}: {exc}")
return 2
_render_report(report, console)
if report.grade in ("A", "B"):
return 0
if report.grade in ("C", "D"):
return 1
return 2
```
This function is small on purpose. Its job is to be the glue. Steps:
1. Build the argparse parser. `parse_args()` with no argument reads `sys.argv` implicitly.
2. Create a `Console` (rich's main object for printing).
3. Try to scan. If `httpx.RequestError` (the parent of every network-related error) is raised, print a clean message and return exit code 2.
4. Render the report.
5. Pick an exit code based on the grade.
The `try / except` here is the **only** place we catch network errors. We let them propagate from `httpx.get()` up through `scan()` up to `main()`. The reason: lower-level functions cannot know what to do with errors. The CLI knows what to do (show the user, exit). The CLI is the right layer to catch.
`type(exc).__name__` is "the name of the exception's class as a string." For a connection timeout it would be `ConnectTimeout`. For DNS failure, `ConnectError`. Including this in the output gives the user a clue about what went wrong without dumping a full traceback.
## 14. The script entrypoint
```python
if __name__ == "__main__":
sys.exit(main())
```
This pattern shows up in every Python script. It means "if this file was invoked directly (not imported as a module), run main."
When you `python http_headers_scanner.py`, Python sets a special variable `__name__` to `"__main__"`. When some other code does `import http_headers_scanner`, `__name__` is set to `"http_headers_scanner"` instead.
So `if __name__ == "__main__":` is "only when running as a script, not when being imported." Tests import the file, so they need `main()` to NOT run automatically.
`sys.exit(main())` calls main, then passes its return value (0, 1, or 2) to the operating system as the exit code.
## 15. The test file walkthrough
The tests live in `test_http_headers_scanner.py`. We will not go through every line, but here are the key patterns.
### 15.1 Fixtures
```python
@pytest.fixture
def hsts_rule() -> HeaderRule:
return HeaderRule(
header="Strict-Transport-Security",
severity="high",
...
must_match=r"max-age\s*=\s*[1-9]",
)
```
A **fixture** is pytest's way of saying "before this test runs, set up this thing for it." Any test function that has a parameter named `hsts_rule` will receive whatever this fixture returns. Pytest matches by name.
We use fixtures so the rule is constructed in one place. If the `HeaderRule` shape changes (new field added), we update the fixture, not five different tests.
### 15.2 The pure-function tests
```python
def test_evaluate_header_present_with_required_substring(
hsts_rule: HeaderRule,
) -> None:
headers = {"Strict-Transport-Security": "max-age=31536000"}
finding = evaluate_header(hsts_rule, headers)
assert finding.status == "ok"
assert finding.actual_value == "max-age=31536000"
```
Each test follows the **arrange-act-assert** pattern:
1. **Arrange.** Build the input. Here: a tiny dict of headers.
2. **Act.** Call the function under test.
3. **Assert.** Check the result is what we expected.
`assert` is Python's "this must be true or fail the test." If `finding.status != "ok"`, pytest raises an AssertionError and prints what the actual value was.
Because `evaluate_header` is pure, these tests are dead simple. No mocking, no setup beyond the fixture, no teardown.
### 15.3 The score and grade tests
The `_make_report` helper builds a synthetic `ScanReport` by pairing each rule with a status. Then the test asks `report.score` and `report.grade` and asserts they are what we expected.
This is the payoff for making `score` and `grade` properties of `ScanReport`: we can test them without running `scan()`. We just hand-build the inputs.
### 15.4 The respx-mocked scan tests
```python
@respx.mock
def test_scan_mocks_a_clean_response_and_grades_it_correctly() -> None:
respx.get("https://safe.example.com/").mock(
return_value=httpx.Response(
status_code=200,
headers={
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
...
},
)
)
report = scan("https://safe.example.com/")
assert report.status_code == 200
assert report.score == 100
```
The `@respx.mock` decorator above the function tells respx: "during this test, intercept every httpx call and use the routes I set up below."
`respx.get("https://safe.example.com/").mock(return_value=httpx.Response(...))` says "when something does an HTTP GET to that URL, hand back this canned response, do not actually go to the internet."
Then `scan("https://safe.example.com/")` does its thing. httpx tries to fetch the URL; respx intercepts; the fake response comes back; the rest of the code never knows the difference. We assert on the score.
The redirect test (`test_scan_records_final_url_after_redirect`) sets up two mocked routes: the first returns a 301 to the second, the second returns 200. The scanner follows the redirect, and we assert that `report.final_url` reflects where we ended up.
## 16. Tooling: lint, type-check, format
The project ships with four quality tools wired up through `just`:
```
just lint # runs ruff, then pylint, then mypy
just format # runs yapf in place
just test # runs pytest
just fix # runs ruff with --fix (auto-fixes what it can)
```
What each tool does:
- **ruff** is a fast Python linter. Catches a long list of style and correctness issues. Modern replacement for flake8.
- **pylint** is a slower, more opinionated linter. Catches different issues than ruff. We run both because their checks complement each other.
- **mypy** is the static type checker. It reads the type annotations and checks every call against them. Catches `severity = "hgih"` typos and many other bugs at edit time.
- **yapf** is the code formatter. It rewrites the file to match a configured style (column limit, indentation, etc.). Means the project has a single consistent look regardless of who wrote each line.
- **pytest** is the test runner. Discovers files starting with `test_`, runs every function in them whose name starts with `test_`, reports passes and failures.
In a real workflow you would set up a **pre-commit hook** that runs `just lint` and `just test` before each commit, so broken code never gets committed. We have not done that in this project to keep the foundations tier minimal, but extending it is one of the challenges.
## 17. The pyproject.toml
`pyproject.toml` is the modern Python project metadata file. It replaces the old `setup.py` + `setup.cfg` combo. Worth glancing at, even though you do not usually edit it day to day.
Key sections:
- **`[project]`**: name, version, description, Python version requirement, dependencies.
- **`[project.optional-dependencies]`**: dev dependencies (pytest, mypy, etc.) that end users do not need.
- **`[project.scripts]`**: declares the `headers` command-line script. This is why `uv run headers` works: it knows to invoke `http_headers_scanner:main`.
- **`[tool.ruff]`, `[tool.mypy]`, `[tool.pylint.*]`, `[tool.pytest.ini_options]`**: config for each tool. Centralising config in one file is convenient.
## 18. Common pitfalls when extending
A few things that have tripped people up when adding new rules or features:
**Forgetting to bump the score total in tests.** Currently `RULES` is six rules totalling 100 points. If you add a seventh, the score calculation still works (it sums whatever is in the list), but tests that hardcoded the expected score (e.g. "score should be 50 when half are missing") may break. Fix: write tests in terms of percentages, not absolute point counts.
**Adding a rule whose value parsing is non-trivial.** Our `must_match` field is a single regex. That's plenty for "starts with `nosniff`" or "max-age is a positive integer," but some real headers need much more complex parsing (CSP, for instance, has its own grammar of directives, source expressions, and nonces). If your new rule needs structured parsing, do the parsing in `evaluate_header()` based on the rule's header name, or extend `HeaderRule` with a new field like `value_validator: Callable[[str], bool] | None`.
**Forgetting the case-insensitive comparison.** New code that does `if "X-Frame-Options" in response.headers` will miss servers that return `x-frame-options`. Always lowercase both sides for header name comparison.
**Trying to scan multiple URLs without async.** The sync API blocks one URL at a time. Scanning 100 URLs in sequence is slow. If you want concurrency, switch to `httpx.AsyncClient` and use `asyncio.gather`. The challenges file has a sketch of this.
## 19. Debugging tips
When something goes wrong:
**Run with `-v` for pytest verbose output.**
```
uv run pytest -v
```
Shows you each test name as it runs. Easier to spot which one failed.
**Use the `--pdb` flag for an interactive debugger.**
```
uv run pytest --pdb
```
Drops into Python's debugger on the first failing test. Type `l` for the source around the failure, `p variable_name` to inspect, `c` to continue.
**Print the actual headers when the scanner gives wrong results.**
Edit `scan()` to print `response_headers` before the loop. Run the scanner against a known site. Compare what you see to what your browser's dev tools say. Different User-Agents sometimes get different responses.
**Use `curl -I` as a sanity check.**
```
curl -I https://example.com
```
The `-I` flag fetches only headers. If the headers you see there do not match what the scanner reports, something is up with the request the scanner is making.
## 20. Next
Read **[04-CHALLENGES.md](./04-CHALLENGES.md)** for ideas to extend the scanner. Pick one that interests you, try it, and see how the architecture holds up when you push on it.

View File

@ -0,0 +1,244 @@
# Extension Challenges
You have read the code and you understand what it does. Time to make it yours.
These challenges are ordered roughly by difficulty. The early ones are 30-minute changes. The later ones are weekend projects that meaningfully change how the scanner works. None of them have "the right answer" hidden in this folder. Try them, get them wrong, get them right, learn something.
If you get stuck, the source of truth is always the code. Re-read `evaluate_header()` and `scan()` and `_render_report()`. The whole scanner fits on a screen.
## Warm-up challenges
### 1. Add a seventh header to check
**Goal:** Extend `RULES` with one more security header. Pick one of:
- **`Cross-Origin-Opener-Policy`** (COOP): prevents the page from being interacted with by other origins via window.opener. Recommended value: `same-origin`.
- **`Cross-Origin-Embedder-Policy`** (COEP): controls which cross-origin resources can be loaded. Recommended value: `require-corp`.
- **`Cross-Origin-Resource-Policy`** (CORP): controls who can embed this resource. Recommended value: `same-origin`.
**Why it is useful.** These three headers together (COOP, COEP, CORP) are the modern way to isolate a page from cross-origin attacks like Spectre. Real high-security sites set all three.
**Hints.**
- Add a `HeaderRule(...)` to `RULES`. Pick a severity. Decide whether you need a `must_match` pattern (a plain word like `"same-origin"` works as a substring check; use a richer regex if "the value has to be exactly one of N options" matters).
- The score totals will change. Your existing tests that assert exact scores may break. Either update those tests or write them in terms of percentages.
- Test it against a real site. `https://web.dev` is a good one to check.
**Done when.** `just test` passes, `just run -- https://web.dev` shows your new header in the table.
### 2. Add a `--json` flag for machine-readable output
**Goal.** Make the scanner emit a JSON blob when the user passes `--json`, instead of the colored table.
**Why useful.** CI systems and dashboards need structured output. A grep-friendly table is fine for humans, JSON is fine for everyone else.
**Hints.**
- Add a `--json` boolean flag to `_build_argument_parser()` using `action="store_true"`.
- In `main()`, after `scan()` returns, branch on `args.json`. If True, use the standard library's `json` module to dump a dict with the report fields. If False, call the existing `_render_report()`.
- `HeaderFinding` is not directly JSON-serializable because it contains a nested `HeaderRule`. You can flatten it manually, or use `dataclasses.asdict()` which converts a frozen dataclass tree into nested dicts.
**Done when.** `just run -- https://example.com --json | jq '.score'` prints just the score number.
### 3. Add a `--verbose` flag that prints raw response headers
**Goal.** Optional flag that, when set, prints every header the server returned (including non-security ones) above the table.
**Why useful.** When debugging "why does the scanner say my HSTS is weak," you want to see the raw value the server sent.
**Hints.**
- Add the flag the same way as `--json`.
- The `scan()` function only stores findings, not the raw response. You will need to thread the raw headers through `ScanReport` (add a new field) or have `scan()` return a tuple.
- Print the raw headers in `_render_report` when a verbose flag is passed. The renderer signature will need updating.
**Done when.** `just run -- https://example.com --verbose` shows all server headers above the findings table.
## Intermediate challenges
### 4. Scan multiple URLs in one run
**Goal.** Allow the user to pass several URLs and get a report per URL. Bonus: a summary table at the end.
**Why useful.** When auditing a company's sites, you do not want to run the scanner once per URL. Run it once over the whole list.
**Hints.**
- Change the `url` argparse argument to accept `nargs="+"` (one or more). `args.url` becomes a list of strings.
- Loop over the URLs in `main()`. Call `scan()` for each, render each report, collect them.
- For the summary, add a small final table with one row per URL: URL, score, grade.
- The exit code becomes interesting. Probably the worst grade across all URLs.
**Edge cases.**
- One URL fails (network error) but others succeed. Do you still exit with code 2?
- Two URLs return the same final URL (after redirects). Dedupe or show both?
### 5. Add an `--allow-warnings` style threshold flag
**Goal.** Let the user say "I am ok with grade C, only exit non-zero if grade D or below."
**Why useful.** CI integrations. Different teams have different acceptance bars.
**Hints.**
- Add `--min-grade` taking a value from `{"A", "B", "C", "D", "F"}`. Default to `"C"` (the current behaviour roughly).
- In `main()`, compare `report.grade` to the threshold and pick exit code accordingly.
- Grades have a natural order. You can compare them with a small helper that maps each to an integer.
**Done when.** `just run -- https://example.com --min-grade C` exits 0 if the site got C or better, exits 1 otherwise.
### 6. Cache results to disk
**Goal.** When the user re-scans the same URL within the last hour, return the cached result instead of hitting the network.
**Why useful.** When you are iterating on the renderer or scoring, you do not want to spam a real site every test run.
**Hints.**
- Store cached reports in `~/.cache/http-headers-scanner/`.
- File name from a hash of the URL (`hashlib.sha256(url.encode()).hexdigest()`).
- Use `dataclasses.asdict(report)` to JSON-ify, `json.dump()` to write.
- Include a timestamp in the cached file. Skip the cache when it is older than an hour.
- Add a `--no-cache` flag to bypass.
**Tricky bit.** `HeaderFinding` contains a `HeaderRule`. When you load from cache, you need to rebuild those rules from the JSON. Or, simpler, only cache the bits you care about and rebuild the report shape from scratch.
### 7. Detect and warn about mixed HTTP/HTTPS
**Goal.** If the user passes an `http://` URL and the server redirects to `https://`, mention it prominently in the output.
**Why useful.** Many sites enforce HTTPS via redirect, but the redirect chain itself is unencrypted on the first hop and gets stripped by attackers (this is what HSTS protects against). Knowing whether a redirect happened is a useful signal.
**Hints.**
- After `scan()`, compare `report.url` (what the user typed) to `report.final_url` (where they ended up).
- If the user typed `http://...` and the final URL is `https://...`, print a one-line note: "Note: this URL was upgraded from HTTP to HTTPS via redirect. Without HSTS, the first request is vulnerable to interception."
- Better yet, deduct points if HSTS is missing AND the user came in via http.
## Advanced challenges
### 8. Make it async to scan many sites in parallel
**Goal.** Use `httpx.AsyncClient` plus `asyncio.gather` so that scanning 50 URLs takes about the time of one scan, not 50 scans.
**Why useful.** Real audits cover many hosts. Sequential scanning is the bottleneck.
**Hints.**
- Add an `async def scan_async(url, ...)` alongside `scan()`. Use `async with httpx.AsyncClient() as client:` and `client.get(...)`.
- In `main()`, build an `asyncio.gather()` over a list of `scan_async` calls.
- Concurrency limit: do not blast 5000 URLs at once. Use `asyncio.Semaphore(20)` to cap parallelism.
- The pure functions (`evaluate_header`, `score`, `grade`) do not change. That is the payoff of separating pure logic from I/O.
**Watch out for.** Some sites rate-limit aggressive scanning. The default User-Agent identifies us; respect any `Retry-After` headers if they come back.
### 9. Add a static analyser for the CSP value
**Goal.** Currently we only check that `Content-Security-Policy` is present. Build a sub-analyser that looks inside the CSP value and reports specific problems:
- Contains `'unsafe-inline'` in `script-src`? Major weakness, points off.
- Contains a wildcard origin (`*`) in `script-src`? Same.
- Missing `default-src`? Worth noting.
**Why useful.** A CSP that allows `'unsafe-inline'` is barely a CSP at all. Real scanners (Mozilla Observatory) do this analysis. Yours can too.
**Hints.**
- Add a new dataclass `CSPAnalysis` with fields like `has_unsafe_inline: bool`, `wildcard_origins: list[str]`, etc.
- Add a `parse_csp(value: str) -> CSPAnalysis` pure function. CSP grammar: directives are semicolon-separated; each directive is `name source1 source2 ...`.
- Make the CSP rule's `weak` status take into account the analysis result. The base `evaluate_header` will need an escape hatch for rules that have a custom validator.
**Tricky bit.** CSP is genuinely complex. The official spec is at `w3.org/TR/CSP3`. Start by parsing only `script-src`; ignore the rest.
### 10. Build a continuous monitor
**Goal.** Run the scanner against a list of URLs every 24 hours and alert when a site's grade drops.
**Why useful.** Configuration drifts. A site that had grade A six months ago can drop to B because someone disabled HSTS to debug something and forgot to put it back. You want to know.
**Hints.**
- Need persistent storage. Easiest start: a SQLite database with columns `(url, run_at, score, grade)`. Standard library has `sqlite3` so no new dependency.
- A separate script reads a URL list (one URL per line, in a file), runs the scanner, writes results.
- Compare today's grade to the most recent previous grade per URL. If today is worse, emit an alert (print to stdout, send an email, push a Slack message, your choice).
- A `cron` entry or a systemd timer fires the script once a day.
**Watch out for.** Networks are flaky. A timeout one day is not necessarily a grade drop. You probably want a "two failures in a row" rule before alerting.
### 11. Browser-based comparison: scan the same URL with three different User-Agents
**Goal.** Sites sometimes serve different headers to bots versus real browsers. Add a `--compare-uas` mode that scans the same URL with the default scanner UA, with a Chrome UA, and with a Googlebot UA, then shows a side-by-side comparison of the headers.
**Why useful.** A site might serve strict CSP to real users but loose CSP to bots, or vice versa. Knowing this is real evidence of a misconfiguration.
**Hints.**
- The `scan()` function already takes a `user_agent` argument. Run it three times with three different values.
- A real Chrome UA looks like: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36`.
- Googlebot's UA is `Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)`.
- The render layer needs a "three-column comparison" mode. A rich Table with columns "Default", "Chrome", "Googlebot" and one row per header.
## Expert challenge
### 12. Make it a service with a REST API
**Goal.** Wrap the scanner in a small HTTP server. POST a URL, get back a JSON report.
**Why useful.** Other services in a security pipeline can call yours. Plus, it is the natural way to make the scanner accessible from a frontend.
**Estimated time.** A weekend if you have not used FastAPI before, a few hours if you have.
**Prerequisites.** Some exposure to web frameworks would help. The intermediate `siem-dashboard` project in this repo is a deeper example of building one.
**Architecture sketch.**
```
┌─────────────────────────────────────────────────────┐
│ FastAPI app │
│ │
│ POST /scan { "url": "..." } │
│ └──▶ async scan_async(url) ────▶ ScanReport │
│ ◀── { "url": ..., "grade": ..., findings: ... }│
│ │
│ GET /healthz │
│ └──▶ {"ok": true} │
└─────────────────────────────────────────────────────┘
```
**Steps.**
1. **Add fastapi and uvicorn to dependencies.** `uv add fastapi uvicorn[standard]`.
2. **Create a new file `server.py`** that imports `scan_async` from your async-ified scanner.
3. **Define a Pydantic model** for the request body (`class ScanRequest(BaseModel): url: HttpUrl`).
4. **Define a response model** that mirrors `ScanReport` for serialisation. `HttpUrl` validates URLs at the API boundary.
5. **Add the route.** `@app.post("/scan", response_model=ScanResponse)` then `async def scan_endpoint(req: ScanRequest)`.
6. **Add a healthz endpoint** so external monitors can confirm the service is alive.
7. **Add rate limiting.** Otherwise people will use your service to scan strangers. The advanced `api-rate-limiter` project in this repo is a good reference.
**Production checklist.**
- [ ] Validation: reject internal IPs (`127.0.0.1`, `10.0.0.0/8`, etc.) to prevent SSRF.
- [ ] Timeouts: every scan has a hard cap so a slow target cannot tie up workers.
- [ ] Logging: every request is logged with `url`, `grade`, `duration_ms`.
- [ ] CORS: decide which origins can call your API.
- [ ] Deploy: Dockerfile that runs uvicorn, env var for port.
**Stretch goal.** Build a tiny single-page frontend (vanilla HTML + JS, no framework needed) that lets a user paste a URL and shows the report. Now you have a real product.
## Other directions
A few smaller ideas if none of the above grab you:
- **Color-blind mode.** Replace the green/yellow/red colors with green/yellow/red plus distinct shapes (✓, ⚠, ✗) so the table is readable for color-blind users.
- **HAR file import.** Instead of scanning a live URL, read headers from a `.har` file (HTTP Archive, what browsers export from dev tools). Lets you scan responses you captured earlier.
- **Show diff against a known good baseline.** Save a "reference report" for a URL. On next scan, show only the changes ("HSTS got weaker," "X-Frame-Options went missing").
- **Add a `--strict` mode** that lowers the bar: deduct points even for minor issues (e.g. `max-age` less than six months, missing `includeSubDomains`).
## What to do when stuck
The strategy that works every single time:
1. **Reduce.** Strip the problem down. Comment out everything except the smallest piece that still misbehaves.
2. **Print.** When `evaluate_header` does not return what you expected, print the inputs and the outputs. Do not guess what they look like. Look.
3. **Read the test.** If a test is failing, the test is telling you exactly what it expected. Read the assertion. Read the inputs. Compare to the actual output.
4. **Diff against known good.** Use `git diff` to see what changed since the tests last passed. The bug is in your changes 99% of the time.
If after all that you still need help, the GitHub Discussions tab on the repository is the right place. Bring:
- What you tried.
- What you expected.
- What actually happened (the full error if any).
- The smallest reproducer that shows the problem.
"It does not work" is not enough information to help with. The above four bullets are.
## When you finish a challenge
Make it yours. If you build something interesting, write it up. Push it to your own fork. Open a pull request back to the main repo if your improvement is generally useful. The best way to lock in what you learned is to teach someone else.

View File

@ -0,0 +1,224 @@
# ©AngelaMos | 2026
# pyproject.toml
#
# This file is the "instruction manual" for our Python project. Every
# modern Python project has one. It tells tools like uv, pip, pytest,
# ruff, mypy, and pylint:
#
# - What our project is called
# - Which third-party libraries we depend on
# - How to build and install it
# - How to lint, type-check, and test it
#
# The file format is called TOML — a simple, human-readable config
# language. Square brackets like [project] start a "table" (a section).
# Lines like name = "..." are key-value pairs inside that table.
#
# Read this top-to-bottom. Each section has a short comment explaining
# what it does and why it is here.
# =============================================================================
# [project] — the metadata that describes WHAT this project is
# =============================================================================
# This section is standardized across the whole Python ecosystem (PEP 621).
# Anyone who runs `pip install` or `uv sync` reads from here.
[project]
# The name people will install this package under.
# Must be unique on PyPI if we ever publish there.
name = "http-headers-scanner"
# Semantic version: MAJOR.MINOR.PATCH.
# 1.0.0 means "first stable release."
# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
version = "1.0.0"
# One-line description that shows up in `pip show` and on PyPI.
description = "Scan a URL for HTTP security headers and grade the result (foundations tier)"
# The minimum Python version this project supports.
# >=3.13 means "Python 3.13 or anything newer."
# We need 3.13 because we use modern type-hint syntax (X | None).
requires-python = ">=3.13"
# Who wrote it. Shows up in package metadata.
authors = [
{name = "CarterPerez-dev", email = "support@certgames.com"}
]
# The README file is shown on PyPI and in `pip show`.
readme = "README.md"
# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
# service, they must publish their changes. It protects the project
# from being privatized.
license = {text = "AGPL-3.0-or-later"}
# -----------------------------------------------------------------------------
# dependencies — third-party libraries we MUST have at runtime
# -----------------------------------------------------------------------------
# When a user runs `pip install http-headers-scanner`, these get installed.
# The string format is "<name><operator><version>". We use ranges so we
# get bug fixes (>=) but never an incompatible major version (<).
dependencies = [
# httpx: a modern HTTP client. We use it instead of `requests` because
# it is async-ready, supports HTTP/2, and ships first-class type hints.
# (We use the SYNC API in this project for simplicity — async would
# be overkill for a single-URL scanner.)
"httpx>=0.28.1",
# rich: pretty terminal output — colors, tables, panels.
# Used to render the scan report as a clean grading table.
"rich>=15.0.0",
]
# -----------------------------------------------------------------------------
# optional-dependencies — extra libraries that are NOT needed at runtime
# -----------------------------------------------------------------------------
# These only matter to developers (testing, linting, formatting). End users
# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
[project.optional-dependencies]
dev = [
# pytest — runs our test suite (the test_*.py file in this directory).
"pytest>=9.0.3",
# respx — mocks httpx responses so our tests don't need the network.
# Lets us assert "scan() correctly handles a missing-CSP response"
# without depending on a live server.
"respx>=0.23.1",
# ruff — extremely fast linter and formatter. Catches bugs, style
# issues, dead imports. Modern replacement for flake8/black/isort.
"ruff>=0.15.12",
# mypy — static type checker. Reads our type hints and tells us if
# we passed a string where an int was expected, BEFORE running the code.
"mypy>=2.1.0",
# pylint — second linter that catches deeper logic issues ruff misses.
"pylint>=4.0.5",
# yapf — code formatter. Keeps every file looking identical.
"yapf>=0.43.0,<1.0.0",
]
# -----------------------------------------------------------------------------
# project.scripts — command-line entry points
# -----------------------------------------------------------------------------
# After `uv sync`, the user can type `headers <url>` instead of
# `python http_headers_scanner.py <url>`. The right-hand side is
# "<module>:<function>" — the function gets called when the command runs.
[project.scripts]
headers = "http_headers_scanner:main"
# =============================================================================
# [build-system] — how to BUILD this project into an installable package
# =============================================================================
# Required by PEP 517. Tools like uv read this to know which builder
# to use. We chose hatchling — modern, fast, zero-config for our case.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# Tell hatchling exactly which file to package. This project is a SINGLE
# Python file (no src/<package>/ layout), so we use `only-include` to
# point at it directly. Without this, hatchling would not know what
# to ship.
[tool.hatch.build.targets.wheel]
only-include = ["http_headers_scanner.py"]
# =============================================================================
# [tool.ruff] — linter / formatter configuration
# =============================================================================
# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
[tool.ruff]
# Target Python version — ruff adjusts which warnings apply.
target-version = "py313"
# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
line-length = 88
# -----------------------------------------------------------------------------
# Which lint rules to enable. Each "code" is a category of check.
# -----------------------------------------------------------------------------
[tool.ruff.lint]
select = [
"E", # pycodestyle errors — basic PEP 8 spacing/indent rules
"F", # Pyflakes — unused imports, undefined names, real bugs
"W", # pycodestyle warnings — softer style issues
"B", # Bugbear — sneaky bugs (mutable default args, etc.)
"C4", # Comprehensions — encourages cleaner list/dict comprehensions
"UP", # Pyupgrade — flags old syntax we should modernize
"SIM", # Simplify — suggests cleaner equivalents
]
# Some rules we deliberately ignore.
ignore = [
# Line length is handled by yapf, not ruff. Avoids double-flagging.
"E501",
]
# =============================================================================
# [tool.mypy] — static type checker configuration
# =============================================================================
# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
# without running the code. Catches whole categories of bugs at edit time.
[tool.mypy]
python_version = "3.13"
# Require type annotations on every function — strict mode.
disallow_untyped_defs = true
disallow_incomplete_defs = true
# Don't auto-add Optional just because a default is None. Be explicit.
no_implicit_optional = true
# Warn if we cast a value to a type it already has (dead code).
warn_redundant_casts = true
# Warn if a function might fall off the end without returning.
warn_no_return = true
# Pretty error output — helps when reading mypy output.
show_error_codes = true
pretty = true
# =============================================================================
# [tool.pylint] — second-opinion linter
# =============================================================================
# Slower than ruff but catches different things — class design issues,
# dead variables, complex code patterns.
[tool.pylint.main]
# Match the project's Python version.
py-version = "3.13"
# Run lint checks in parallel across 4 cores for speed.
jobs = 4
# Specific pylint warnings we deliberately turn off.
[tool.pylint.messages_control]
disable = [
"R0903", # too-few-public-methods — small data classes are fine
"C0103", # invalid-name — short names are OK in small scripts
]
# =============================================================================
# [tool.pytest.ini_options] — test runner configuration
# =============================================================================
[tool.pytest.ini_options]
# Where to look for tests. "." = the project root, since this project's
# test file lives next to the source file.
testpaths = ["."]
# Files that match this pattern are treated as test modules.
python_files = ["test_*.py"]
# Default flags every `pytest` invocation gets:
# -v → verbose, show each test name
# --tb=short → short tracebacks on failure (less wall of text)
addopts = "-v --tb=short"

View File

@ -0,0 +1,405 @@
"""
©AngelaMos | 2026
test_http_headers_scanner.py
Tests for http_headers_scanner covers rule evaluation, the score
calculation, grade thresholds, and a mocked end-to-end scan
What "tests" are and why we write them
A test is a tiny Python function that calls our real code with a
known input and then ASSERTS that the result is what we expected.
If the assertion fails, pytest prints a red FAIL message which
means we changed something and broke a behavior we cared about
Tests are insurance. The first time you write the code, the test
just confirms it works. But six months later when you refactor or
add a new feature, the existing tests catch any accidental breakage
Why we mock the network with respx
A test that hits a real website is FRAGILE. The site might be down,
slow, redesigned, or behind a captcha. None of that has anything to
do with whether OUR code is correct
`respx` is a library that intercepts httpx calls and returns a
canned response we control. So when we test scan("https://test"),
respx hands back EXACTLY the headers we tell it to letting us
verify the scanning logic without touching the network
Coverage strategy
We exercise each branch of the code at least once
- evaluate_header: ok / weak / missing / case-insensitive lookup
- ScanReport.score: all-ok, all-missing, mixed
- ScanReport.grade: each band (A, B, C, D, F)
- scan(): full pipeline against a mocked response, including a redirect
That is enough confidence adding ten variations of "another header"
would not catch any new bugs
"""
# Third-party (httpx): we need its `Response` type to construct fake
# responses inside our mocked routes.
import httpx
# Third-party: the test runner. We also use its `@pytest.mark.parametrize`
# decorator to expand one test function into many test cases.
import pytest
# Third-party (respx): intercepts httpx calls and returns fakes we
# define, so tests do not actually hit the real internet.
import respx
# Local: our own module. We pull in the public pieces under test —
# the rules table, dataclasses, and the two entry functions.
from http_headers_scanner import (
RULES,
HeaderFinding,
HeaderRule,
ScanReport,
Status,
evaluate_header,
scan,
)
# =============================================================================
# Fixtures — small helpers used by multiple tests
# =============================================================================
# A pytest "fixture" is a setup function pytest runs before a test
# that needs it. Tests ask for fixtures by listing them as parameters
@pytest.fixture
def hsts_rule() -> HeaderRule:
"""
A representative HeaderRule that requires a positive max-age value
"""
# We construct one inline rather than reaching into RULES so this
# test is robust to future additions / reorderings of the table.
# The regex matches `max-age` followed by `=` and a digit 1-9 —
# which rejects `max-age=0` (HSTS deliberately disabled)
return HeaderRule(
header = "Strict-Transport-Security",
severity = "high",
description = "Forces HTTPS",
recommendation = "Add: Strict-Transport-Security: max-age=31536000",
must_match = r"max-age\s*=\s*[1-9]",
)
@pytest.fixture
def referrer_rule() -> HeaderRule:
"""
A rule with NO must_contain presence alone earns full points
"""
return HeaderRule(
header = "Referrer-Policy",
severity = "low",
description = "Limits Referer leakage",
recommendation =
"Add: Referrer-Policy: strict-origin-when-cross-origin",
)
# =============================================================================
# evaluate_header — the pure function at the heart of the scanner
# =============================================================================
# These tests do not touch the network at all — we hand-build the
# headers dict and check the finding
def test_evaluate_header_present_with_required_substring(
hsts_rule: HeaderRule,
) -> None:
"""
Header is present AND contains must_contain status = ok
"""
headers = {"Strict-Transport-Security": "max-age=31536000"}
finding = evaluate_header(hsts_rule, headers)
assert finding.status == "ok"
assert finding.actual_value == "max-age=31536000"
def test_evaluate_header_present_without_required_substring(
hsts_rule: HeaderRule,
) -> None:
"""
Header is present but does NOT match must_match status = weak
A real-world example: someone sets the header to an empty string
or only `includeSubDomains` without `max-age=`. The header exists
but is functionally useless
"""
headers = {"Strict-Transport-Security": "includeSubDomains"}
finding = evaluate_header(hsts_rule, headers)
assert finding.status == "weak"
def test_evaluate_header_missing(hsts_rule: HeaderRule) -> None:
"""
Header is not in the response at all status = missing
"""
# Empty dict — no headers at all
headers: dict[str, str] = {}
finding = evaluate_header(hsts_rule, headers)
assert finding.status == "missing"
# And actual_value should be None when the header was absent
assert finding.actual_value is None
def test_evaluate_header_hsts_max_age_zero_is_weak(
hsts_rule: HeaderRule,
) -> None:
"""
`max-age=0` actively DISABLES HSTS for return visits the header
is present but does the opposite of what we want, so it must be
flagged as weak rather than ok
This pins the behavior fixed in the audit substring-based
matching graded this case as ok and left users thinking HSTS was
on when it was deliberately turned off
"""
headers = {"Strict-Transport-Security": "max-age=0; includeSubDomains"}
finding = evaluate_header(hsts_rule, headers)
assert finding.status == "weak"
assert finding.actual_value == "max-age=0; includeSubDomains"
def test_evaluate_header_case_insensitive_lookup(
referrer_rule: HeaderRule,
) -> None:
"""
HTTP header names are case-insensitive per RFC 7230 `Referrer-Policy`
and `referrer-policy` and `REFERRER-POLICY` mean the same thing
"""
# The server returned the header with lowercase letters, but the
# rule asks for "Referrer-Policy" with the canonical case.
# Without case-insensitive lookup, this test would fail
headers = {"referrer-policy": "no-referrer"}
finding = evaluate_header(referrer_rule, headers)
assert finding.status == "ok"
def test_evaluate_header_no_must_match_treats_presence_as_ok(
referrer_rule: HeaderRule,
) -> None:
"""
A rule with must_match=None passes whenever the header exists
"""
headers = {"Referrer-Policy": "anything-here-works"}
finding = evaluate_header(referrer_rule, headers)
assert finding.status == "ok"
# =============================================================================
# ScanReport.score and .grade — the math behind the report
# =============================================================================
# Score is computed from the findings on the fly, not stored. So we
# can build a synthetic ScanReport with whatever findings we want and
# assert exactly what the score should come out to
def _make_report(statuses: list[Status]) -> ScanReport:
"""
Build a fake ScanReport pairing each rule with the given status
Helper so each test does not have to construct findings by hand.
The first item in `statuses` pairs with the first rule, etc.
Pad the list with "missing" if it is shorter than RULES.
The parameter is typed as `list[Status]` so mypy enforces the
Literal contract at every call site no runtime check or
type-ignore escape hatch needed
"""
findings: list[HeaderFinding] = []
for index, rule in enumerate(RULES):
# When the caller passed fewer statuses than rules, treat the
# rest as missing. Common when a test only cares about the
# first few rules
status: Status = (
statuses[index] if index < len(statuses) else "missing"
)
findings.append(
HeaderFinding(
rule = rule,
status = status,
actual_value = None,
note = "synthetic",
)
)
return ScanReport(
url = "https://example.com",
final_url = "https://example.com",
status_code = 200,
findings = findings,
)
def test_score_all_ok_is_100() -> None:
"""
Every rule passing should yield a perfect score
"""
statuses: list[Status] = ["ok"] * len(RULES)
report = _make_report(statuses)
assert report.score == 100
# And the grade follows the score
assert report.grade == "A"
def test_score_all_missing_is_zero() -> None:
"""
Nothing present, nothing earned. Score = 0, grade = F
"""
statuses: list[Status] = ["missing"] * len(RULES)
report = _make_report(statuses)
assert report.score == 0
assert report.grade == "F"
def test_grade_threshold_a_at_90_percent() -> None:
"""
Passing both highs and both mediums (90/100 = 90%) lands exactly
on the A boundary
The current rules table totals 100 points (30 + 30 + 15 + 15 + 5 + 5)
Two `high` rules = 60 / 100 = 60%, which is grade D
Both highs + both mediums = 90 / 100 = 90%, which is grade A
"""
statuses_by_severity: dict[str, Status] = {
"high": "ok",
"medium": "ok",
"low": "missing",
}
statuses: list[Status] = [
statuses_by_severity[r.severity] for r in RULES
]
report = _make_report(statuses)
assert report.score == 90
assert report.grade == "A"
def test_grade_threshold_b_at_83_percent() -> None:
"""
Both highs ok, one medium ok and the other weak (60 + 15 + 7.5 = 82.5
rounds up to 83) drops below 90 and lands in the B band
"""
# Two highs ok, mediums split between ok and weak, lows missing
statuses: list[Status] = []
medium_seen = 0
for rule in RULES:
if rule.severity == "high":
statuses.append("ok")
elif rule.severity == "medium":
statuses.append("ok" if medium_seen == 0 else "weak")
medium_seen += 1
else:
statuses.append("missing")
report = _make_report(statuses)
assert 80 <= report.score < 90
assert report.grade == "B"
# =============================================================================
# scan() — full pipeline against a mocked response
# =============================================================================
# `@respx.mock` intercepts every httpx request inside the test and
# returns whatever we set up in the body. The real network is never
# touched
@respx.mock
def test_scan_mocks_a_clean_response_and_grades_it_correctly() -> None:
"""
A response with every recommended header set should score 100
"""
# We respond to GET https://safe.example.com/ with a 200 and the
# full set of security headers. respx.get(...).mock(return_value=...)
# registers the mock; the next httpx.get inside this test fires it
respx.get("https://safe.example.com/").mock(
return_value = httpx.Response(
status_code = 200,
headers = {
"Strict-Transport-Security":
"max-age=31536000; includeSubDomains",
"Content-Security-Policy": "default-src 'self'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "camera=(), microphone=()",
},
)
)
report = scan("https://safe.example.com/")
assert report.status_code == 200
assert report.score == 100
assert report.grade == "A"
# Every finding should be `ok`
assert all(f.status == "ok" for f in report.findings)
@respx.mock
def test_scan_flags_missing_and_weak_headers() -> None:
"""
A response missing CSP and with a weak X-Content-Type-Options
should produce findings of mixed status
"""
respx.get("https://weak.example.com/").mock(
return_value = httpx.Response(
status_code = 200,
headers = {
"Strict-Transport-Security": "max-age=600",
# X-Content-Type-Options is present but value is wrong:
# the rule requires `nosniff`, this says something else.
# We pick a value that genuinely does NOT contain the
# substring `nosniff` — `"snifftest"` would actually be
# treated as ok because it embeds the word
"X-Content-Type-Options": "off",
# Note: Content-Security-Policy is NOT included
},
)
)
report = scan("https://weak.example.com/")
findings_by_header = {f.rule.header: f for f in report.findings}
assert findings_by_header["Content-Security-Policy"
].status == "missing"
assert findings_by_header["X-Content-Type-Options"].status == "weak"
assert findings_by_header["Strict-Transport-Security"].status == "ok"
# Score should be less than 100 since CSP is missing and XCTO is weak
assert report.score < 100
@respx.mock
def test_scan_records_final_url_after_redirect() -> None:
"""
When http://x.example.com/ https://x.example.com/, the report
must remember the final URL that is what the user actually
landed on
"""
# First request: 301 to the https version
respx.get("http://redirect.example.com/").mock(
return_value = httpx.Response(
status_code = 301,
headers = {"Location": "https://redirect.example.com/"},
)
)
# Final request: 200 with one header set
respx.get("https://redirect.example.com/").mock(
return_value = httpx.Response(
status_code = 200,
headers = {"X-Frame-Options": "DENY"},
)
)
report = scan("http://redirect.example.com/")
assert report.url == "http://redirect.example.com/"
assert report.final_url == "https://redirect.example.com/"
assert report.status_code == 200

View File

@ -0,0 +1,471 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version < '3.15'",
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "ast-serialize"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" },
{ url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" },
{ url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" },
{ url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" },
{ url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" },
{ url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" },
{ url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" },
{ url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" },
{ url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" },
{ url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" },
{ url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" },
{ url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" },
{ url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" },
{ url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" },
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" },
]
[[package]]
name = "astroid"
version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[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 = "dill"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "http-headers-scanner"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "rich" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pylint" },
{ name = "pytest" },
{ name = "respx" },
{ name = "ruff" },
{ name = "yapf" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.23.1" },
{ name = "rich", specifier = ">=15.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" },
{ name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
]
[[package]]
name = "librt"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mccabe"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[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.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pylint"
version = "4.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "respx"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]
[[package]]
name = "tomlkit"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
]

View File

@ -0,0 +1,67 @@
# ©AngelaMos | 2026
# .gitignore
#
# This file lists patterns of files and folders that git should NEVER
# track. Anything matching a pattern below is invisible to `git status`
# and won't be committed. We do this for two reasons:
#
# 1. Privacy/security — we never want to accidentally commit a real
# password vault. The vault file should stay on the user's machine.
#
# 2. Noise — build artifacts and caches change constantly. Tracking
# them would clutter every commit and create merge conflicts.
#
# Each line is a "glob" pattern (* matches any characters).
# -----------------------------------------------------------------------------
# Vault files — NEVER commit a real password vault to git
# -----------------------------------------------------------------------------
*.vault
vault.json
.password-vault/
# -----------------------------------------------------------------------------
# Python build/install artifacts
# -----------------------------------------------------------------------------
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
*.egg
build/
dist/
.eggs/
*.so
# -----------------------------------------------------------------------------
# Virtual environments — created by `uv venv`, never committed
# -----------------------------------------------------------------------------
.venv/
venv/
env/
# -----------------------------------------------------------------------------
# Tooling caches
# -----------------------------------------------------------------------------
.mypy_cache/
.ruff_cache/
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# -----------------------------------------------------------------------------
# IDE / editor scratch files
# -----------------------------------------------------------------------------
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# -----------------------------------------------------------------------------
# Local environment files (may contain secrets)
# -----------------------------------------------------------------------------
.env
.env.local

View File

@ -0,0 +1,104 @@
# ©AngelaMos | 2026
# .style.yapf
#
# Configuration for yapf, our Python code formatter. yapf rewrites
# Python files to match these rules so every file looks identical
# no matter who wrote it.
#
# The two settings most worth knowing:
# based_on_style = pep8 → start from PEP 8 (the official style)
# column_limit = 75 → wrap lines at 75 characters
#
# Run with: `just format`
[style]
# Start from PEP 8 (Python's official style guide). Every other
# setting below tweaks PEP 8 in some specific direction.
based_on_style = pep8
# Wrap any line longer than this many columns.
column_limit = 75
# Use 4 spaces per indent level (PEP 8 standard).
indent_width = 4
# Continuation lines (when a statement wraps) also indent by 4.
continuation_indent_width = 4
# Don't indent the closing bracket of a multi-line call/list/dict
indent_closing_brackets = false
# DO de-indent the closing bracket — line it up with the opener.
dedent_closing_brackets = true
# Don't add 4 spaces of indent to blank lines inside functions.
indent_blank_lines = false
# Two spaces between code and the # of a trailing comment.
spaces_before_comment = 2
# No spaces around ** (power) operator: 2**3 not 2 ** 3.
spaces_around_power_operator = false
# Spaces around = in default arguments: foo(x = 1) not foo(x=1).
spaces_around_default_or_named_assign = true
# No extra space before a comma followed by a closing bracket.
space_between_ending_comma_and_closing_bracket = false
# No spaces just inside [ ] brackets: [a, b] not [ a, b ].
space_inside_brackets = false
# Spaces around colons in slices: x[1 : 5] not x[1:5].
spaces_around_subscript_colon = true
# No blank line before nested def/class.
blank_line_before_nested_class_or_def = false
# No blank line between a class and its docstring.
blank_line_before_class_docstring = false
# Two blank lines around top-level functions and classes (PEP 8).
blank_lines_around_top_level_definition = 2
# Two blank lines between top-level imports and module-level variables.
blank_lines_between_top_level_imports_and_variables = 2
# No blank line before the module docstring.
blank_line_before_module_docstring = false
# When wrapping `a and b`, put `and` at the start of the new line.
split_before_logical_operator = true
# When wrapping a function call, put the first argument on a new line.
split_before_first_argument = true
# Put each named arg on its own line when wrapping.
split_before_named_assigns = true
# Multi-line comprehensions wrap onto separate lines.
split_complex_comprehension = true
# Don't split right after the opening parenthesis.
split_before_expression_after_opening_paren = false
# Put the closing bracket on its own line when wrapping.
split_before_closing_bracket = true
# When splitting at commas, give every value its own line.
split_all_comma_separated_values = true
# But don't recursively force ALL top-level commas onto new lines.
split_all_top_level_comma_separated_values = false
# Don't merge separate brackets into one.
coalesce_brackets = false
# Each dict entry gets its own line in multi-line dicts.
each_dict_entry_on_separate_line = true
# Disallow lambdas that span multiple lines.
allow_multiline_lambdas = false
# Disallow dict keys that span multiple lines.
allow_multiline_dictionary_keys = false
# Penalty score for splitting at import names — 0 means free to split.
split_penalty_import_names = 0
# Don't try to fit an already-broken statement back onto fewer lines.
join_multiple_lines = false
# Closing bracket lines up with the visual indent of the opener.
align_closing_bracket_with_visual_indent = true
# Don't add parentheses to clarify operator precedence automatically.
arithmetic_precedence_indication = false
# Penalty for splitting after each line — yapf weighs this against rules.
split_penalty_for_added_line_split = 275
# Use spaces for indentation, never tabs (PEP 8).
use_tabs = false
# Don't split on a `.` when wrapping (so `obj.method()` stays together).
split_before_dot = false
# When the last argument in a call has a trailing comma, force a split.
split_arguments_when_comma_terminated = true
# Function names yapf treats as i18n calls — leave their args alone.
i18n_function_call = ['_', 'N_', 'gettext', 'ngettext']
# Comment markers yapf treats as i18n metadata.
i18n_comment = ['# Translators:', '# i18n:']
# Penalty for splitting comprehension iterables.
split_penalty_comprehension = 80
# Penalty for splitting right after an opening bracket.
split_penalty_after_opening_bracket = 280
# Penalty for splitting before an `if` in a ternary expression.
split_penalty_before_if_expr = 0
# Penalty for splitting before a bitwise operator.
split_penalty_bitwise_operator = 290
# Penalty for splitting before a logical operator (already lowered).
split_penalty_logical_operator = 0

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are 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.
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.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
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 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
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 AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,168 @@
```ruby
██████╗ █████╗ ███████╗███████╗ ██╗ ██╗ █████╗ ██╗ ██╗██╗ ████████╗
██╔══██╗██╔══██╗██╔════╝██╔════╝ ██║ ██║██╔══██╗██║ ██║██║ ╚══██╔══╝
██████╔╝███████║███████╗███████╗ ██║ ██║███████║██║ ██║██║ ██║
██╔═══╝ ██╔══██║╚════██║╚════██║ ╚██╗ ██╔╝██╔══██║██║ ██║██║ ██║
██║ ██║ ██║███████║███████║ ╚████╔╝ ██║ ██║╚██████╔╝███████╗ ██║
╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═══╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝
```
[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Foundations-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/password-manager)
[![Tier: Foundations](https://img.shields.io/badge/Tier-Foundations-00C9A7?style=flat&logo=bookstack&logoColor=white)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations)
[![Difficulty: Hardest Foundation](https://img.shields.io/badge/Difficulty-Hardest_Foundation-FF6B6B?style=flat&logo=target&logoColor=white)](#)
[![Python 3.13](https://img.shields.io/badge/Python-3.13%2B-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org)
[![Argon2id](https://img.shields.io/badge/KDF-Argon2id-8E44AD?style=flat&logo=keepassxc&logoColor=white)](https://en.wikipedia.org/wiki/Argon2)
[![AES-256-GCM](https://img.shields.io/badge/Cipher-AES--256--GCM-2E86C1?style=flat&logo=letsencrypt&logoColor=white)](https://en.wikipedia.org/wiki/Galois/Counter_Mode)
[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Tests](https://img.shields.io/badge/tests-pytest-0A9EDC?style=flat&logo=pytest&logoColor=white)](https://pytest.org)
[![Lint](https://img.shields.io/badge/lint-ruff%20%2B%20mypy%20%2B%20pylint-D7FF64?style=flat)](https://github.com/astral-sh/ruff)
> Encrypted command-line password manager — Argon2id key derivation, AES-256-GCM authenticated encryption, atomic durable writes, advisory file locking. One master password protects every credential you trust to it.
*This is a quick overview — security theory, architecture, and full walkthroughs are in the [learn modules](#learn).*
> [!NOTE]
> **Foundations tier — the hardest of the three.** This is a stepping stone *into* the beginner tier. It assumes no prior Python experience but ramps faster than [`hash-identifier`](../hash-identifier) and [`http-headers-scanner`](../http-headers-scanner). The source is heavily commented as a teaching aid, the `learn/` folder explains every cryptographic idea from zero, and every Python feature is introduced when it first appears. If "what's a `@dataclass`" feels like the wrong question, start with `hash-identifier` first.
## What It Does
- Stores credentials in a single encrypted JSON file at `~/.password-vault/vault.json` (mode `0600`)
- Derives a 32-byte AES key from your master password via **Argon2id** (OWASP-recommended parameters, ~0.5s per derivation)
- Encrypts vault contents with **AES-256-GCM** — confidentiality + tamper detection in one primitive
- **Atomic, durable, concurrent-safe** writes: tmp file → fsync → atomic rename → directory fsync, with advisory `fcntl` lock to serialize concurrent `pv` invocations
- Master password rotation that re-encrypts the entire vault under a fresh salt and key
- Cryptographically secure password generator using `secrets` (never `random`) with a Fisher-Yates shuffle on top of `secrets.randbelow`
- Stores KDF parameters *in the file* — old vaults remain readable when defaults change, and rotation can upgrade them transparently
- Typed exception hierarchy (`WrongPasswordError`, `VaultFormatError`, `EntryNotFoundError`, …) for precise error handling
- Rich-rendered colored panels and tables; pipe-friendly stdout/stderr separation
- Refuses to distinguish "wrong password" from "tampered file" — both look the same cryptographically, exposing the difference helps attackers
## Quick Start
```bash
./install.sh
just run -- init
just run -- add github
just run -- get github
```
```text
$ pv init
New master password: ************
Confirm master password: ************
Vault created at /home/you/.password-vault/vault.json
$ pv add github
Username for github: alice
Password for github (hidden): ************
URL (optional, press Enter to skip): https://github.com
Notes (optional, press Enter to skip):
Added entry: github
$ pv get github
╭────────────────── github ──────────────────╮
│ username alice │
│ password hunter2-but-better │
│ url https://github.com │
│ created 2026-05-13T14:22:10+00:00 │
│ updated 2026-05-13T14:22:10+00:00 │
╰────────────────────────────────────────────╯
```
> [!TIP]
> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available recipes.
>
> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin`
## Commands
| Command | What it does |
|---------|--------------|
| `pv init` | Create a new empty vault. Prompts for the master password twice. |
| `pv add <name>` | Add an entry. Prompts for username, password, optional URL and notes. `--generate` / `-g` to use a random password. |
| `pv get <name>` | Show every field of one entry in a colored panel. |
| `pv list` | Print every entry name as a table (no passwords shown). |
| `pv delete <name>` | Remove an entry by name. |
| `pv gen [length]` | Generate a strong random password and print it to stdout. No vault required. |
| `pv change-password` | Rotate the master password — re-encrypts the entire vault under a fresh salt and key. |
Every command takes `--vault PATH` (or `$PV_VAULT`) to point at an alternate vault file.
## Demo: pipe-friendly generation
```bash
# Generate and copy to clipboard (macOS)
just run -- gen 32 | pbcopy
# Generate and copy to clipboard (Linux)
just run -- gen 32 | xclip -selection clipboard
# Generate into a shell variable
PASSWORD=$(just run -- gen 32)
# Letters + digits only, no symbols
just run -- gen 24 --no-symbols
```
> [!IMPORTANT]
> `pv` *never* accepts the master password as a CLI flag. Passwords passed as flags leak into shell history (`history` command) and process listings (`ps aux`). Every prompt uses `getpass.getpass()` — same primitive `sudo` uses — so the password is never echoed and never logged.
## Cryptographic guarantees
| Concern | Mitigation |
|---------|-----------|
| Vault file stolen | Argon2id with 64 MiB / 3 passes / 4 lanes makes each guess ~0.5s; a billion guesses ≈ 15 years |
| Vault file tampered | AES-GCM authentication tag refuses to decrypt; same error as "wrong password" by design |
| Power loss mid-save | Atomic write: tmp → fsync → `os.replace` → parent-dir fsync. Always old-or-new, never half |
| Two `pv` processes racing | Advisory `fcntl.LOCK_EX` on sidecar `.lock` file (POSIX; NTFS atomic-rename on Windows) |
| Vault tmp world-readable | `os.open` with mode `0o600` at the very first syscall — no chmod race window |
| Predictable random output | `secrets` module everywhere — for salts, nonces, passwords, and the Fisher-Yates shuffle |
| Aging KDF parameters | Parameters stored in the vault file; `change-password` can upgrade them transparently |
| KDF parameter corruption | Validated against Argon2's algorithmic floors on load; clean `VaultFormatError` instead of library crash |
| Forward-incompatible format | Top-level `version` field; future versions can refuse or migrate |
What this project does *not* defend against — and why — is documented honestly in [`learn/01-CONCEPTS.md §12`](learn/01-CONCEPTS.md#12-putting-it-all-together-the-threat-model).
## Tooling
```bash
just # list available recipes
just test # run pytest (60+ tests across crypto, vault, generator)
just test-cov # tests + coverage report
just lint # ruff + mypy + pylint
just format # yapf
just run -- <cmd> [args]
```
## Requirements
- **Python 3.13+** — the install script will check.
- [`uv`](https://github.com/astral-sh/uv) — modern Python package manager (auto-installed by `./install.sh`).
- [`just`](https://github.com/casey/just) — command runner (auto-installed by `./install.sh`).
- Linux, macOS, or WSL2 strongly recommended over native Windows — file locking and directory `fsync` paths are POSIX-flavored. NTFS gives atomic `os.replace` regardless, so native Windows works with reduced concurrency guarantees.
No compilers or system libraries beyond what `argon2-cffi` and `cryptography` install through `uv`. No network access required at runtime.
## Learn
This project includes step-by-step learning materials covering the security theory, architecture, and implementation — written for someone who has never touched Python *or* cryptography before. Read them in order.
| Module | Topic |
|--------|-------|
| [00 - Overview](learn/00-OVERVIEW.md) | Quick start, prerequisites, project layout, common problems |
| [01 - Concepts](learn/01-CONCEPTS.md) | What encryption *is*, KDFs, Argon2id, salts, AES-GCM, nonces, the threat model, real breaches |
| [02 - Architecture](learn/02-ARCHITECTURE.md) | Five-file layout, on-disk format, per-command flow diagrams, the atomic-write pipeline |
| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Line-by-line walkthrough of every source file — every Python feature explained when first encountered |
| [04 - Challenges](learn/04-CHALLENGES.md) | Fifteen extension ideas across four tiers, from a `search` command to porting the vault format to another language |
## See Also
- [`PROJECTS/foundations/hash-identifier`](../hash-identifier) — the easiest foundations project. Start here if password-manager feels too dense.
- [`PROJECTS/foundations/http-headers-scanner`](../http-headers-scanner) — the middle foundations project; covers HTTP and basic I/O.
- [`PROJECTS/beginner/hash-cracker`](../../beginner/hash-cracker) — the natural cracking companion. Once you understand *why* Argon2id is slow, that project shows you what it's slowing *down*.
- [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) — the authoritative reference for the parameter choices in [`constants.py`](src/password_manager/constants.py).
- [`age`](https://github.com/FiloSottile/age) — a production-quality file encryption tool that makes many of the same trade-offs as this project at a much larger scale.
## License
AGPL 3.0

View File

@ -0,0 +1,149 @@
#!/usr/bin/env bash
# ©AngelaMos | 2026
# install.sh
#
# Zero-friction install script. Anyone who clones this project should
# be able to run `./install.sh` and end up with a working setup,
# regardless of whether they have uv or just installed yet.
#
# What this script does, in order:
# 1. Verifies Python 3.13+ is installed (we need modern type-hint syntax)
# 2. Installs uv if it is missing (uv is the Python package manager we use)
# 3. Installs just if it is missing (just is the command runner)
# 4. Calls `just setup` to create the venv and install dependencies
# 5. Prints next steps
#
# Run with: ./install.sh
# Or: bash install.sh
# -----------------------------------------------------------------------------
# Bash safety flags — fail fast and loud
# -----------------------------------------------------------------------------
# -e : exit immediately if any command returns a non-zero (error) status
# -u : treat unset variables as an error
# -o pipefail : if any command in a pipeline fails, the whole pipeline fails
set -euo pipefail
# -----------------------------------------------------------------------------
# Color helpers — pretty terminal output without external dependencies
# -----------------------------------------------------------------------------
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # "no color" — resets the terminal back to default
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
success() { echo -e "${GREEN}[OK]${NC} $1"; }
# -----------------------------------------------------------------------------
# Step 1 — Confirm Python 3.13+ is on the system
# -----------------------------------------------------------------------------
check_python() {
info "Checking for Python 3.13+..."
if ! command -v python3 &>/dev/null; then
error "python3 not found. Please install Python 3.13 or newer."
error " macOS: brew install python@3.13"
error " Linux: sudo apt install python3.13 (Debian/Ubuntu)"
error " Windows: download from python.org"
exit 1
fi
# Extract version "3.13.1" → split on dots → take major.minor
local version
version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
local major minor
major=$(echo "$version" | cut -d. -f1)
minor=$(echo "$version" | cut -d. -f2)
if (( major < 3 )) || { (( major == 3 )) && (( minor < 13 )); }; then
error "Python 3.13+ is required, found Python $version"
exit 1
fi
success "Python $version detected"
}
# -----------------------------------------------------------------------------
# Step 2 — Install uv if missing (https://docs.astral.sh/uv)
# -----------------------------------------------------------------------------
install_uv() {
if command -v uv &>/dev/null; then
success "uv already installed ($(uv --version))"
return 0
fi
info "Installing uv (Python package manager)..."
curl -LsSf https://astral.sh/uv/install.sh | sh
# The installer drops uv into ~/.local/bin or ~/.cargo/bin.
# Add it to PATH for the rest of THIS script's run.
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
if ! command -v uv &>/dev/null; then
error "uv install completed but `uv` is still not on PATH."
error "Restart your shell and re-run this script, or add uv to PATH manually."
exit 1
fi
success "uv installed"
}
# -----------------------------------------------------------------------------
# Step 3 — Install just if missing (https://github.com/casey/just)
# -----------------------------------------------------------------------------
install_just() {
if command -v just &>/dev/null; then
success "just already installed ($(just --version))"
return 0
fi
info "Installing just (command runner)..."
# uv can install Cargo binaries via Cargo, but easier: use the
# official installer. Drops binary into ~/.local/bin.
mkdir -p "$HOME/.local/bin"
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh \
| bash -s -- --to "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
success "just installed"
}
# -----------------------------------------------------------------------------
# Step 4 — Use just to set up the project (venv + dependencies)
# -----------------------------------------------------------------------------
project_setup() {
info "Running 'just setup'..."
just setup
}
# -----------------------------------------------------------------------------
# Main — orchestrate the steps and print next instructions
# -----------------------------------------------------------------------------
main() {
echo ""
echo "================================================"
echo " password-vault — install"
echo "================================================"
echo ""
check_python
install_uv
install_just
project_setup
echo ""
echo "================================================"
success "Install complete!"
echo "================================================"
echo ""
echo "Next steps:"
echo " just run -- --help # see available commands"
echo " just run -- init # create your first vault"
echo " just test # run the test suite"
echo ""
}
main "$@"

View File

@ -0,0 +1,184 @@
# ©AngelaMos | 2026
# justfile
#
# A "justfile" is a list of commands you can run with `just <name>`.
# Think of it as a project's command center — instead of remembering
# `uv run pytest tests/ -v --cov=src`, you just type `just test`.
#
# Why use just instead of make? It is simpler, cross-platform,
# and the syntax is easier to read.
#
# Show all commands: `just`
# Run a command: `just <name>` (e.g. `just setup`)
set export
set shell := ["bash", "-uc"]
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
# Make recipe arguments available to shebang scripts as `$1`, `$2`,
# `$@`, and `$#`. Without this, shebang recipes only see args via the
# textual `{{args}}` substitution, which is unsafe for inputs that
# contain `$` (the substituted text gets re-expanded by bash).
set positional-arguments
# Show available commands when you run `just` with no args.
default:
@just --list --unsorted
# =============================================================================
# Setup Commands
# =============================================================================
# One-shot first-time setup — creates .venv and installs everything
[group('setup')]
setup:
@echo "Creating virtual environment with uv..."
uv venv --allow-existing
@echo ""
@echo "Installing dependencies (including dev tools)..."
uv sync --all-extras
@echo ""
@echo "✓ Setup complete!"
@echo ""
@echo "Try it out:"
@echo " just run -- --help"
@echo " just test"
# Install runtime dependencies only (no dev tools)
[group('setup')]
install:
uv sync
# Install runtime + dev dependencies
[group('setup')]
install-dev:
uv sync --all-extras
# =============================================================================
# Testing & Quality Checks
# =============================================================================
# Run the test suite
[group('test')]
test:
@echo "Running tests..."
uv run pytest
# Run tests with a coverage report
[group('test')]
test-cov:
@echo "Running tests with coverage..."
uv run pytest --cov=password_manager --cov-report=term-missing
# Run all linters (ruff + pylint + mypy)
[group('test')]
lint:
@echo "=== Ruff ==="
uv run ruff check src tests
@echo ""
@echo "=== Pylint ==="
uv run pylint src/password_manager
@echo ""
@echo "=== Mypy ==="
uv run mypy src/password_manager
@echo ""
@echo "✓ All linters passed"
# Auto-format every Python file with yapf
[group('test')]
format:
@echo "Formatting code with yapf..."
uv run yapf -i -r src tests
@echo "✓ Code formatted"
# Auto-fix what ruff can fix on its own (unused imports, etc.)
[group('test')]
fix:
uv run ruff check src tests --fix
# =============================================================================
# Run the CLI
# =============================================================================
# Run the password-vault CLI — pass extra args after `--`
# Example: just run -- list
# just run -- add github
# [no-exit-message] silences just's "Recipe `run` failed with exit
# code N" line when pv exits non-zero. pv uses non-zero exits to
# signal "wrong password", "entry not found", etc — real outcomes
# the CLI already explained, not "the recipe is broken." The exit
# code itself is still propagated to whoever invoked just.
[group('run')]
[no-exit-message]
run *args:
#!/usr/bin/env bash
# If no args were given, print a friendly usage block and exit
# cleanly. Without this, `just run` (no args) would invoke
# `uv run pv` with nothing, argparse would exit 2, and just
# would tack a "Recipe `run` failed" error on top — confusing for
# someone just trying to see how the command works.
if [ $# -eq 0 ]; then
cat <<'EOF'
Usage: just run -- <command> [args]
Examples:
just run -- list
just run -- add github
just run -- get github
just run -- remove github
See all commands:
just run -- --help
EOF
exit 0
fi
# Forward args via "$@" — NOT via {{args}}. Why: {{args}} is a
# TEXTUAL substitution done by just before bash runs the script,
# so a password with $ in it would get those $-references expanded
# as bash variables and arrive at pv mangled. "$@" hands bash the
# original argv unchanged.
uv run pv "$@"
# =============================================================================
# Utility / Cleanup
# =============================================================================
# Delete the venv and all build / cache artifacts
[group('utility')]
clean:
rm -rf .venv
rm -rf __pycache__ src/**/__pycache__ tests/__pycache__
rm -rf .mypy_cache .ruff_cache .pytest_cache
rm -rf *.egg-info build dist
rm -rf .coverage htmlcov
@echo "✓ Cleaned"
# Lock the exact dependency versions to uv.lock
[group('utility')]
lock:
uv lock
# Upgrade all dependencies to latest allowed versions
[group('utility')]
update:
uv lock --upgrade
uv sync --all-extras
# =============================================================================
# CI Pipeline (lint + test, no setup)
# =============================================================================
# Full pipeline: setup + lint + test. For first-time runs.
[group('ci')]
all: setup lint test
@echo ""
@echo "✓ Setup, lint, and tests all passed"
# Lint + test only — what CI runs after dependencies are installed
[group('ci')]
ci: lint test
@echo "✓ CI checks passed"

View File

@ -0,0 +1,229 @@
# Password Vault
## What this is
A small command-line password manager written in Python. You type a master password once, and the tool stores every other password you give it inside a single encrypted file. Later, you ask the tool for "github" or "email" or "bank" and it hands the password back.
The whole thing is roughly 1,400 lines of code spread across five files. No web server, no browser extension, no cloud account. One file on disk, one master password in your head.
```
$ pv init
New master password: ************
Confirm master password: ************
Vault created at /home/you/.password-vault/vault.json
$ pv add github
Username for github: alice
Password for github (hidden): ************
URL (optional, press Enter to skip): https://github.com
Notes (optional, press Enter to skip):
Added entry: github
$ pv get github
╭────────────────── github ──────────────────╮
│ username alice │
│ password hunter2-but-better │
│ url https://github.com │
│ created 2026-05-13T14:22:10+00:00 │
│ updated 2026-05-13T14:22:10+00:00 │
╰────────────────────────────────────────────╯
```
That's the whole tool. It also has `list`, `delete`, `gen` (generate a strong random password), and `change-password` (rotate the master password).
## Why anyone needs this
The honest answer is "you do." Every site needs a password. Every password should be different. Nobody can remember 200 different passwords, so people reuse one — and the moment any single site gets breached, the attacker tries that same password everywhere else. This is called **credential stuffing** and it is how most account takeovers actually happen in 2025.
A password manager fixes this by giving you exactly one password to remember (the master) while it remembers all the rest. The "every site different" goal becomes achievable because you no longer have to hold them in your head.
**Real-world moments where the security choices in this project matter:**
- The [2013 Adobe breach](https://en.wikipedia.org/wiki/2013_Adobe_breach) leaked 153 million passwords. Adobe had encrypted them with a single key in ECB mode — no salt, no per-record randomness — so identical passwords produced identical ciphertexts. Researchers could see clusters of users who'd all picked `password123` just by looking at the encrypted file. This project's use of a fresh random nonce per save and Argon2id with a per-vault salt is the direct fix for that class of mistake.
- The [2022 LastPass breach](https://blog.lastpass.com/posts/notice-of-recent-security-incident) leaked encrypted vault backups. Vaults with weak master passwords got cracked offline at scale because the attacker had unlimited time. The defense — make each guess expensive — is exactly what Argon2id gives us. Our defaults push each guess to roughly half a second on a modern laptop, which makes a billion-guess attack take ~15 years on the attacker's machine.
- Every CTF challenge or pentest engagement where someone hands you "an encrypted file" and asks "is this safe at rest?" — by the end of this project you'll know what questions to ask (which KDF? which cipher mode? what's the salt situation? is the auth tag verified?).
## What you will learn
**Security ideas:**
- What **symmetric encryption** is — one key encrypts and decrypts. We use [AES-256-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode), the same algorithm protecting your bank's HTTPS connection.
- What a **key derivation function (KDF)** does — it turns a human password (short, weak, predictable) into a real cryptographic key (32 bytes of pure unpredictability). We use [Argon2id](https://en.wikipedia.org/wiki/Argon2), the winner of the 2015 Password Hashing Competition and the algorithm OWASP currently recommends for password storage.
- Why **salts** and **nonces** are different things even though they're both "random bytes you store next to the ciphertext."
- What **authenticated encryption** means and why "just encrypted" is not enough — without authentication, an attacker can flip bits in your file in predictable ways without knowing the key.
- The difference between `random` (fine for a dice roll) and `secrets` (the only thing you should ever use when an attacker wants to predict the output).
- Why we **store the KDF parameters in the file** instead of just hard-coding them, and how that decision enables `change-password` to upgrade old vaults to new defaults years later.
- Why writing a file "atomically" (write to `.tmp`, fsync, rename) matters when a power loss could otherwise leave you with zero passwords.
**Python ideas (assuming this is your first time):**
- What a **package** is and what `__init__.py` does for it.
- **Modules** and how `import` actually finds files.
- **Type hints**`str`, `int`, `bytes`, `list[str]`, `dict[str, Entry]`, `Path | None`. They are not enforced at runtime, but they are the most useful documentation in the language.
- **`@dataclass`** — the shortcut for making record-like classes without writing `__init__` by hand.
- **`Final`** type — telling Python "this value is a constant, never reassign it."
- **Context managers** (`with vault.unlock(...) as v:`) — the cleanest way to say "set up something, use it, tear it down even on errors."
- **Exceptions and custom exception classes** — defining your own error types and catching them by category.
- **Generators**, **dict comprehensions**, **f-strings** — modern Python idioms you'll see everywhere.
- How `pytest` works and why `conftest.py` is special.
- How a CLI built with [Typer](https://typer.tiangolo.com) works — turning a function into a command just by writing its type hints.
**Tools you'll touch:**
- [`uv`](https://github.com/astral-sh/uv) — the modern Python package manager. Like `pip` but ~100× faster.
- [`just`](https://github.com/casey/just) — a command runner. Instead of memorizing long commands, you type `just test` or `just run`.
- [`typer`](https://typer.tiangolo.com) — the CLI framework.
- [`rich`](https://github.com/Textualize/rich) — the library that prints the pretty colored panels and tables.
- [`argon2-cffi`](https://github.com/hynek/argon2-cffi) — the Python binding for the Argon2 reference implementation.
- [`cryptography`](https://cryptography.io) — the Python Cryptographic Authority's library. The gold standard.
- [`pytest`](https://pytest.org) + [`ruff`](https://github.com/astral-sh/ruff) + [`mypy`](https://mypy-lang.org) + [`pylint`](https://pylint.org) — testing and linting.
## What you need before starting
**Knowledge you should have:**
- You've used a terminal at least once (you know what `cd` and `ls` do).
- You've at least seen the words "hash" and "encryption" before. If they're meaningless to you, [01-CONCEPTS.md](./01-CONCEPTS.md) is built to start from zero — read it before the code.
- You can read code, or you're willing to try. Every Python feature gets explained when it first appears.
**Knowledge you do NOT need:**
- Prior Python experience. The whole point of the **foundations** tier is that you start here. This project is the *hardest* of the three foundations projects, though — if anything in here feels too dense, try [hash-identifier](../../hash-identifier/) first.
- Any prior cryptography knowledge. You'll learn what a KDF, a nonce, and an authentication tag are in [01-CONCEPTS.md](./01-CONCEPTS.md). No math beyond counting required — the math lives inside the libraries we call.
- Any prior cybersecurity background.
**Software you need installed:**
- Python 3.13 or newer (3.14 recommended).
- The `uv` tool (the install script will get this for you if you don't have it).
- The `just` tool (also handled by the install script).
- A terminal. Mac: Terminal.app or iTerm2. Linux: whatever your distro shipped. Windows: WSL2 + Ubuntu (strongly recommended over native Windows — the file-locking and fsync code paths are POSIX-flavored).
You do *not* need an IDE — any text editor works. [VS Code](https://code.visualstudio.com) with the Python extension is a fine default.
## Quick start
From inside `PROJECTS/foundations/password-manager/`:
```bash
./install.sh
```
That script installs `uv` and `just` if missing, creates a virtual environment (an isolated Python sandbox just for this project), installs every dependency, and runs the test suite to confirm everything works. Read the output as it goes — don't just close the terminal.
Then create your first vault:
```bash
just run -- init
```
It will ask for a master password, ask you to type it again to confirm, and create an empty vault at `~/.password-vault/vault.json`. The first `init` is slow on purpose — that's Argon2id doing its job, deliberately taking about half a second to derive the key. An attacker who steals your vault file has to pay that same half-second cost for every password they want to guess.
Add an entry:
```bash
just run -- add github
```
It will prompt for the username, the password (input hidden), and optional URL and notes.
Look at the entry:
```bash
just run -- get github
```
You'll see a colored panel with every field. The password is shown in plain text — this is a local CLI tool, the user already trusts their own screen.
Generate a strong random password without touching the vault:
```bash
just run -- gen 32
```
This prints one 32-character password to stdout (nothing else). You can pipe it directly into your clipboard:
```bash
just run -- gen 32 | pbcopy # macOS
just run -- gen 32 | xclip -sel c # Linux
```
List every entry name:
```bash
just run -- list
```
Change your master password (the vault gets re-encrypted under a new key):
```bash
just run -- change-password
```
Delete an entry:
```bash
just run -- delete github
```
## Project layout
```
password-manager/
├── src/password_manager/
│ ├── __init__.py package metadata + re-exports
│ ├── __main__.py lets `python -m password_manager` work
│ ├── constants.py every magic number and fixed string
│ ├── crypto.py Argon2id + AES-256-GCM primitives
│ ├── generator.py cryptographically secure random passwords
│ ├── vault.py file format, atomic writes, locking
│ └── main.py the CLI commands (init, add, get, …)
├── tests/
│ ├── conftest.py shared pytest fixtures
│ ├── test_crypto.py round-trip + tamper tests
│ ├── test_generator.py pool/length/randomness tests
│ └── test_vault.py end-to-end vault tests
├── install.sh one-shot setup
├── justfile shortcuts for run / test / lint / format
├── pyproject.toml project config + dependencies + linter rules
├── README.md short pointer to this folder
├── learn/ you are here
│ ├── 00-OVERVIEW.md quick start (this file)
│ ├── 01-CONCEPTS.md KDFs, AES-GCM, salts, nonces, real breaches
│ ├── 02-ARCHITECTURE.md module layout, data flows, file format
│ ├── 03-IMPLEMENTATION.md line-by-line walkthrough
│ └── 04-CHALLENGES.md extensions if you want to keep going
└── assets/ images, screenshots
```
The split across five source files is the one place this project leaves the "single-file" simplicity of `hash-identifier`. Cryptography wants strict boundaries: the file that talks to `secrets.token_bytes` is a different file from the file that talks to your filesystem, and *both* are different from the file that prints colored panels. If a bug ever creeps in, the small file it lives in is the first place to look.
## Where to go next
1. **[01-CONCEPTS.md](./01-CONCEPTS.md)** — the security ideas. What a KDF is, why we use Argon2id, what AES-GCM actually does for us, why nonces matter, what authenticated encryption means. Read this before the code, even if you think you know it. The framing matters more than the words.
2. **[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** — how the code is organized into modules, what the vault file looks like on disk, and the step-by-step flow of `init` / `add` / `get` / `change-password`. Diagrams for each.
3. **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** — read every source file with us, in order, with every Python feature explained as it first appears.
4. **[04-CHALLENGES.md](./04-CHALLENGES.md)** — extension ideas (search, export, TOTP, key-stretching upgrade path) once you've absorbed the rest.
## Common problems
**"command not found: just"**
The install script should set this up, but if it didn't: `curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash`. Then close and reopen your terminal.
**"command not found: uv"**
Same idea: `curl -LsSf https://astral.sh/uv/install.sh | sh`, then reopen your terminal.
**"`pv` is slow on `init`"**
That's the point. Argon2id deliberately takes about half a second per call with the defaults. The user pays it once per session; an attacker pays it on every guess. If it's painfully slow (multiple seconds), your CPU is on the older end — `constants.py` has the three Argon2 tuning knobs you can lower.
**"Wrong master password (or vault file is corrupted)"**
The tool can't tell those two cases apart — that's on purpose, see [01-CONCEPTS.md](./01-CONCEPTS.md). If you're sure you typed the password right, try `ls -la ~/.password-vault/` and check the `vault.json` file size is non-zero.
**"ModuleNotFoundError: No module named 'argon2'"**
You ran `python src/password_manager/main.py` directly instead of going through `just run`. The `just run` recipe uses the virtual environment that has every dependency installed. Either use `just run`, or activate the venv first: `source .venv/bin/activate`, then run `pv` or `python -m password_manager`.
**Tests fail right after install**
Tests should pass on a fresh `./install.sh`. If they don't, check `python --version` — you need 3.13+. On Ubuntu, install a newer Python via [`deadsnakes`](https://launchpad.net/~deadsnakes/+archive/ubuntu/ppa); on Mac use [Homebrew](https://brew.sh).
**"I forgot my master password"**
There is no recovery. That is by design — if there were a way to recover the password, anyone who steals the file would have it too. This is the same trade-off every real password manager makes. Pick something memorable, write down a hint (NOT the password) in a safe physical place, and use `change-password` to rotate it occasionally.

View File

@ -0,0 +1,357 @@
# Concepts
This file explains every cryptographic idea the password vault uses, starting from absolute zero. If you've never thought hard about what "encryption" actually is, this is the right place to start. If you already know a KDF from a hash, you can skim — but read the LastPass and Adobe sections, because they are *why* every design choice in the code looks the way it does.
## Table of contents
1. [The problem: storing secrets you'll need later](#1-the-problem-storing-secrets-youll-need-later)
2. [What "encryption" actually is](#2-what-encryption-actually-is)
3. [Symmetric vs asymmetric — we use symmetric](#3-symmetric-vs-asymmetric--we-use-symmetric)
4. [The key problem: where does the key come from?](#4-the-key-problem-where-does-the-key-come-from)
5. [Key derivation functions: making passwords expensive](#5-key-derivation-functions-making-passwords-expensive)
6. [Argon2id specifically, and why](#6-argon2id-specifically-and-why)
7. [Salts: defeating precomputation](#7-salts-defeating-precomputation)
8. [Block ciphers and modes: just "encrypted" is not enough](#8-block-ciphers-and-modes-just-encrypted-is-not-enough)
9. [AES-256-GCM: confidentiality + authenticity in one package](#9-aes-256-gcm-confidentiality--authenticity-in-one-package)
10. [Nonces: the most dangerous thing in this codebase](#10-nonces-the-most-dangerous-thing-in-this-codebase)
11. [random vs secrets: the most common Python-side mistake](#11-random-vs-secrets-the-most-common-python-side-mistake)
12. [Putting it all together: the threat model](#12-putting-it-all-together-the-threat-model)
13. [Real breaches that made these choices the right ones](#13-real-breaches-that-made-these-choices-the-right-ones)
---
## 1. The problem: storing secrets you'll need later
A password manager has a strange job. It needs to:
- Remember your passwords *exactly* (no fuzzy matching — `hunter2` and `Hunter2` are different passwords).
- Refuse to give them up to anyone but you.
- Survive your computer being stolen.
- Survive your computer being seized and forensically imaged.
- Still let *you* in with one short string you can hold in your head.
These goals are in tension. "Remember the password exactly" pulls toward "store it in plain text somewhere." "Don't give it to anyone but you" pulls toward "store nothing at all." The whole rest of this document is about navigating that tension.
The plan that resolves it: **store the passwords scrambled with a key that only the master password can recreate.** If anyone steals the file, all they have is a scrambled blob and a *very expensive* problem to solve. If you type the master password, the key is recreated in seconds and the blob is unscrambled.
That's the entire shape of the project. The rest of this document is how each piece of that plan actually works.
---
## 2. What "encryption" actually is
Encryption is a function. It takes three things in:
- A piece of data (the **plaintext**), like `"my github password is hunter2"`.
- A **key**, which is just a chunk of random bytes — usually 16 or 32 bytes.
- An **algorithm** (a specific procedure for scrambling data using that key).
And it produces one thing out:
- The **ciphertext** — the scrambled version. The same length as the plaintext (plus a tiny overhead, more on that later), but every byte looks like noise.
```
┌──────────────┐
│ "hunter2..." │ ──┐
└──────────────┘ │
┌────────────┐ ┌─────────────────────────┐
│ ENCRYPT() │ ─► │ "Vh\x91\x03\x7f\xe2..." │
└────────────┘ └─────────────────────────┘
┌──────────────┐ │
│ 32-byte key │ ──┘
└──────────────┘
```
**Decryption** is the same function in reverse. Same key, same algorithm, ciphertext goes in, plaintext comes out.
```
┌─────────────────────────┐
│ "Vh\x91\x03\x7f\xe2..." │ ──┐
└─────────────────────────┘ │
┌────────────┐ ┌──────────────┐
│ DECRYPT() │ ─► │ "hunter2..." │
└────────────┘ └──────────────┘
┌──────────────┐ │
│ 32-byte key │ ─────────────┘
└──────────────┘
```
If you have the key, decryption is fast and gives you the original. If you have the wrong key — even one bit off — you get garbage out, or (for modern algorithms) the decryption function refuses to run at all and raises an error. We use the second kind.
**Important thing this *isn't*:** encryption is not a one-way operation like a hash. A hash function (SHA-256, MD5, etc.) deliberately throws away information so the original can never be recovered. Encryption keeps every bit — it just shuffles them so you can't read them without the key. The whole point is that the original is recoverable, *but only by you*.
---
## 3. Symmetric vs asymmetric — we use symmetric
There are two big families of encryption.
**Symmetric encryption** uses the *same* key to encrypt and decrypt. Like a locker with one physical key — whoever holds the key can both lock and unlock it. AES is the famous symmetric algorithm. We use it.
**Asymmetric encryption** uses two *different* keys that are mathematically related. One ("public") locks things, the other ("private") unlocks them. You can hand out the public key to the world and they can send you encrypted things only you can read. RSA and elliptic-curve algorithms are the famous asymmetric ones. This is what powers HTTPS at the start of every connection, signs your software updates, and powers SSH login. We do **not** use this.
Why not asymmetric? Because a password manager is a one-person operation. There is no "you encrypt, then someone else decrypts" — *you* encrypt, *you* decrypt. Symmetric is the right tool. Asymmetric algorithms are also massively slower per byte, which matters for the rare cases where they make sense and rules them out for everything else.
---
## 4. The key problem: where does the key come from?
We just said "encryption needs a 32-byte key." Where does a human get 32 random bytes from?
Not from their head. Humans cannot remember 32 random bytes — that's 256 bits of entropy, which is the same as asking someone to memorize a 78-digit number. The best a human can do without writing it down is something like `correct horse battery staple` (~40 bits) or maybe a long sentence (~60 bits). The 256-bit gap is *enormous*.
So we don't ask the human for a key. We ask them for a **password** (which they can remember) and we transform it into a key using a **key derivation function**.
```
┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ "correct horse │ │ KEY │ │ <32 random
│ battery staple" │ ───► │ DERIVATION │ ──► │ bytes> │
└──────────────────┘ │ FUNCTION │ └─────────────────┘
└─────────────────┘
┌──────────────────┐ │
│ random salt │ ─────────────┘
│ (stored in file) │
└──────────────────┘
```
The transformation is deterministic: same password + same salt always produces the same key. That's how we can re-derive the key tomorrow when the user comes back. But the transformation is also **deliberately slow**, which is the trick that makes the whole system safe.
---
## 5. Key derivation functions: making passwords expensive
Imagine an attacker has stolen your vault file. Inside is:
- The salt (16 bytes of public random data).
- The ciphertext (your encrypted passwords).
- The algorithm name and parameters.
The attacker now wants to guess your master password. The naive way: take each candidate password, hash it with the salt, try the result as a key against the ciphertext, see if the result is valid.
If our key derivation were just `SHA-256(password + salt)`, the attacker could do **billions of guesses per second** on a modern GPU. Even a strong-looking password like `Tr0ub4dor&3` would crack in under a minute. That's because SHA-256 was designed for *speed* — it's optimized to verify integrity of huge files in fractions of a second.
**The fix: use a function that is deliberately slow to compute.**
If each guess takes half a second, then a million guesses take 6 days, a billion guesses take 16 years, and a trillion guesses (the universe of "common 12-character passwords") takes 16,000 years. The legitimate user only pays the cost *once* per session — half a second is fine. The attacker pays it on *every* guess, forever.
This is the entire point of a key derivation function (a "KDF"). It's a hash-like operation, but tuned to be expensive — both in CPU time and in memory.
Why both? Because attackers don't use CPUs. They use GPUs and custom hardware called ASICs. A GPU has thousands of small compute cores but very little fast memory per core. So if we use an algorithm that needs a lot of memory (say, 64 megabytes) per guess, the GPU's thousands of cores suddenly can't run in parallel — they'd need a hundred gigabytes of fast memory just to attempt parallel guesses. ASICs face the same problem. **Making the function memory-hungry is what shuts down hardware-based attacks.**
The technical term for this is "memory-hard." Modern KDFs are all memory-hard. Old ones (PBKDF2 from 2000) are CPU-hard but not memory-hard, which is why they've fallen out of favor.
---
## 6. Argon2id specifically, and why
In 2013, the cryptographic community ran the **Password Hashing Competition** — an open contest to pick a new standard KDF. Researchers submitted designs, attacked each other's submissions, and after two years of analysis, the winner was [**Argon2**](https://www.password-hashing.net/argon2-specs.pdf), specifically the variant called **Argon2id**.
Three variants exist:
- **Argon2d** — maximizes resistance to GPU cracking, but is vulnerable to side-channel attacks (where an attacker watches your CPU's memory access timings).
- **Argon2i** — maximizes resistance to side-channel attacks, but is weaker against GPU cracking.
- **Argon2id** — a hybrid that does both, picked as the recommended default.
We use Argon2id. So does the [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html), so do password managers like 1Password and Bitwarden, so does the file encryption tool `age`.
Argon2id has **three tuning knobs** that control how expensive it is:
| Knob | What it controls | Our default | Why |
| ----------------- | ----------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `time_cost` | Number of passes over the memory buffer | 3 | Strong for interactive use; each extra pass is another factor of slowdown. |
| `memory_cost` | KiB of memory used during derivation (1 KiB = 1024 bytes) | 65536 (64 MiB) | Comfortably above every OWASP profile; defeats GPU/ASIC attackers who lack fast memory per core. |
| `parallelism` | Threads Argon2 may use | 4 | A single user on a modern laptop benefits from parallel speedup. Attacker gets the same speedup, so it's net-neutral on security but improves UX. |
Those numbers live in [`constants.py`](../src/password_manager/constants.py) so they're easy to bump in five years when laptops are faster.
**Important detail:** the parameters are stored *inside the vault file*. If you change the defaults in code, old vaults still open because they remember which parameters they were created with. We'll come back to this — it's also what makes the `change-password` command capable of upgrading old vaults to new parameters.
---
## 7. Salts: defeating precomputation
A **salt** is a piece of random data mixed in with the password before key derivation. It's not secret — we store it in plain text inside the vault file.
Why does it matter?
Imagine you didn't use a salt. Then `derive_key("hunter2") = <some fixed 32 bytes>` — the same on every machine, for every user. An attacker could, *years before any breach*, compute the keys for the top million common passwords and store them in a giant lookup table. Steal a vault → look up the key → unlock the vault. No per-vault work at all.
This precomputation is called a **rainbow table**. It used to be a real attack — there are downloadable rainbow tables for unsalted MD5 covering trillions of common-password hashes.
A salt destroys this attack. Now `derive_key("hunter2", <16 random bytes>) = <different 32 bytes>` for every vault, because every vault has a different salt. The attacker can't precompute anything — they have to do the full Argon2 work *after* they steal the vault file, *per vault they want to attack*.
**Two more uses for salts:**
1. **Per-user uniqueness.** Two users picking the same password (`hunter2` again) get *different* keys, because their salts differ. Useful for password databases at scale, where lots of users do unfortunately pick the same passwords.
2. **Same-user-different-vault uniqueness.** If you have two vaults with the same master password, their ciphertexts are completely different because their salts are different. This isn't an active threat for a single-user password manager, but it's a free benefit of having salts.
Salt size matters less than you'd think. 16 bytes (128 bits) is the standard recommendation. Larger is fine; smaller starts to risk collisions across the global population of vaults.
---
## 8. Block ciphers and modes: just "encrypted" is not enough
Now we have a key. We need to actually encrypt the vault's contents with it. This is where most beginner cryptography projects go wrong, so pay attention.
**AES** is a "block cipher." It encrypts data in fixed-size chunks (16 bytes at a time). Given a 16-byte chunk and a key, it produces another 16-byte chunk that looks random. By itself, AES is just a function from one 16-byte block to another.
But your vault is way more than 16 bytes. So you need a way to chain blocks together. That way is called a **mode of operation**.
The simplest mode is **ECB** ("electronic codebook"): chop the plaintext into 16-byte chunks, encrypt each one independently, concatenate the results. This is wrong. It is famously, illustratively wrong:
```
ECB-encrypted version
of an image of Tux the Linux penguin
(you can still see the penguin)
┌────────────────┐
│ ░░░░░░░░░░░░░░ │
│ ░░░██████░░░░░ │
│ ░░██░░░░██░░░░ │ Identical input blocks
│ ░░░░██████░░░░ │ → produce identical output
│ ░░░░░██░░░░░░░ │ blocks, so the image
│ ░░░░██████░░░░ │ contour leaks through.
│ ░░░░██░░██░░░░ │
│ ░░██░░░░░░██░░ │
│ ░░░░░░░░░░░░░░ │
└────────────────┘
```
This is why "we use AES" without specifying the mode tells you almost nothing about whether a system is secure.
The next mode up is **CBC** (cipher block chaining), which XORs each block with the previous ciphertext block before encrypting. Better than ECB — identical plaintext blocks now produce different ciphertext blocks. But CBC has *another* problem: it doesn't tell you if the ciphertext was tampered with. An attacker can flip specific bits in the ciphertext to flip *predictable* bits in the decrypted plaintext, even without the key. This is called a **bit-flipping attack** and it's been used against real systems.
The right answer is an **authenticated mode** — one that doesn't just encrypt, but also stamps the output with a tamper-evident seal.
---
## 9. AES-256-GCM: confidentiality + authenticity in one package
**GCM** ("Galois/Counter Mode") is an authenticated mode for AES. It produces two things:
1. The encrypted bytes (same length as the plaintext).
2. A 16-byte **authentication tag** — a small fingerprint of "this exact ciphertext was produced by someone holding this exact key."
```
┌──────────────────┐
│ plaintext bytes │
│ (vault contents) │
└──────────────────┘
┌─────────────────────────────────────────┐
│ AES-256-GCM │
│ │
│ key ─┐ │
│ nonce ├─► [scramble & authenticate] │
│ │ │
└─────────┴───────────────────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ ciphertext │ │ 16-byte auth │
│ (same length as │ │ tag │
│ plaintext) │ │ │
└─────────────────┘ └─────────────────┘
```
The library bundles these into a single byte string for you — the "ciphertext" returned by the cryptography library is actually `ciphertext || tag`, with the tag concatenated at the end.
**On decryption**, the cipher checks the tag *before* it gives you the plaintext. If the tag doesn't match (because the key is wrong, the ciphertext was modified, or the nonce is wrong), the cipher raises an error and refuses to produce any plaintext. This is the protection that makes "encrypted with AES-GCM" actually safe.
We use **AES-256**-GCM specifically — the "256" is the key size in bits. AES-128 is also fine, but 256-bit keys are the standard for "I want to never have to think about it again" levels of margin. The performance difference on modern CPUs is negligible because AES has dedicated CPU instructions (AES-NI on Intel/AMD, ARMv8 Crypto Extensions on ARM).
---
## 10. Nonces: the most dangerous thing in this codebase
A **nonce** ("number used once") is a value passed to AES-GCM alongside the key. It must be unique for every encryption performed with the same key. **Reusing a nonce with the same key in GCM is catastrophic** — it leaks plaintext information to anyone watching, and in some cases reveals the authentication key itself.
This is the single sharpest edge of AES-GCM and the reason we are extremely careful about it in [`crypto.py`](../src/password_manager/crypto.py).
What does "leaks plaintext" mean concretely? If you encrypt two different messages M1 and M2 with the same key K and the same nonce N, then `C1 XOR C2 = M1 XOR M2`. An attacker who sees C1 and C2 can compute `M1 XOR M2` without knowing the key. From there, if they can guess any part of M1 or M2, they can recover the corresponding part of the other.
**The fix is mechanical: generate a fresh random 12-byte nonce on every single encryption.** GCM allows up to roughly 2³² (4 billion) encryptions safely under one key with random 12-byte nonces. A single human will never save their vault 4 billion times.
A nonce is *not* a salt. The differences:
| Property | Salt | Nonce |
| --------------- | ------------------------------ | ---------------------------------- |
| Used in | Key derivation (Argon2id) | Encryption (AES-GCM) |
| How often | Once per *vault* (set at init) | Once per *encryption* (every save) |
| Must be unique? | Yes (across all vaults) | Yes (per key, lifetime) |
| Secret? | No | No |
Both are random, both go in the file, both are public. But they have different jobs and different lifetimes.
---
## 11. random vs secrets: the most common Python-side mistake
Python has two modules that produce random numbers, and the difference matters more than almost any other API choice in this project:
- [`random`](https://docs.python.org/3/library/random.html) — uses the **Mersenne Twister** algorithm. Fast, statistically uniform, **predictable**. If an attacker sees 624 consecutive outputs, they can reconstruct the internal state and predict every future output forever.
- [`secrets`](https://docs.python.org/3/library/secrets.html) — pulls bytes from the operating system's cryptographic random source (`/dev/urandom` on Linux/Mac, `BCryptGenRandom` on Windows). Unpredictable by design.
Salts and nonces and keys MUST come from `secrets`. Generated passwords MUST come from `secrets`. If you used `random` for any of these, an attacker who saw one output could predict every subsequent password your tool generated — for *every* user, on *every* machine, forever.
This project uses `secrets` everywhere it matters:
- `crypto.generate_salt()` and `crypto.generate_nonce()``secrets.token_bytes()`.
- `generator.generate_password()``secrets.choice()` and `secrets.randbelow()`.
- The Fisher-Yates shuffle in `generator._secure_shuffle()``secrets.randbelow()` instead of `random.shuffle()`.
The rule is simple: **if the output is meant to be hard to predict, use `secrets`. Always.**
---
## 12. Putting it all together: the threat model
A "threat model" is a written-down answer to "who can break this, and how?" Here's ours:
**What we defend against:**
- **Theft of the vault file.** Someone copies `vault.json` from your laptop. Without the master password, all they have is a slow, expensive guessing problem (Argon2id with our defaults: ~0.5 seconds/guess, ~15 years for a billion guesses).
- **Tampering of the vault file.** Someone modifies bytes in `vault.json` to try to cause weird decryption behavior. AES-GCM's authentication tag refuses to decrypt at all.
- **Power loss mid-save.** The atomic-rename + fsync pattern means you always end up with either the OLD vault or the NEW vault, never half of either. Detailed in [02-ARCHITECTURE.md](./02-ARCHITECTURE.md).
- **Two `pv` processes saving at once.** An advisory file lock serializes them, so neither one's save gets silently overwritten.
- **Forgetting your master password (and the file being safe at rest because of it).** This is the entire point.
**What we explicitly do NOT defend against:**
- **A keylogger on your machine.** If something is reading every keystroke, it sees your master password as you type it, and that ends the game. The defense for this lives at a different layer (full-disk encryption, OS-level keylogger detection, hardware security keys). This is not a system-level secure-input project.
- **An attacker watching your screen while the vault is unlocked.** The `pv get` command prints passwords to stdout in plain text. The attacker reading your screen owns the session anyway.
- **A truly compromised OS that can read process memory.** While the vault is unlocked, the decrypted entries and the AES key live in your process's memory. A privileged attacker who can read that memory wins. The `close()` method on `UnlockedVault` is a best-effort wipe, not a guarantee.
- **A weak master password.** Argon2id makes brute force expensive but not impossible. If your master password is `password123`, an attacker willing to wait will eventually crack it. The defense is on you: pick something long.
- **Backups under your control.** The tool writes one file, atomically and durably. Backing it up to somewhere else (a USB stick, Syncthing, etc.) is your job. The encryption means it's safe to back up to places you wouldn't trust with plaintext.
Being honest about what a tool does and does not defend against is a security skill in its own right. Real-world incidents almost always happen at the boundaries of a threat model, not inside it.
---
## 13. Real breaches that made these choices the right ones
**[Adobe 2013](https://www.troyhunt.com/adobe-credentials-and-serious/)** — 153 million records. Adobe encrypted passwords with a single key in **ECB mode** with **no per-record salt**. Result: identical passwords produced identical ciphertexts. Researchers could group users with the same password without knowing the password itself. Combined with password hints stored in plain text, large fractions of the leaked passwords were recovered the same week. Lesson: per-encryption randomness (salts, nonces) and authenticated modes (not ECB) are not optional.
**[LinkedIn 2012](https://en.wikipedia.org/wiki/2012_LinkedIn_hack)** — 6.5 million records. Passwords stored as **unsalted SHA-1**. SHA-1 is fast on a GPU; without salts, the attacker could precompute a rainbow table once and use it forever. 90% of the hashes were cracked within days. Lesson: salts plus a slow KDF (not a fast hash) are the modern minimum.
**[LastPass 2022](https://blog.lastpass.com/posts/notice-of-recent-security-incident)** — encrypted vault backups stolen. The vaults themselves used a real KDF (PBKDF2 with 100,100 iterations at the time of the breach), but PBKDF2 isn't memory-hard, so GPU attacks against weak master passwords have been industrial-scale ever since. Several public reports describe attackers cracking subsets of vaults and using the recovered passwords for cryptocurrency theft. Lesson: a memory-hard KDF (Argon2id, scrypt) is meaningfully stronger than PBKDF2 against modern hardware. We use Argon2id.
**[Heartbleed 2014](https://heartbleed.com)** — a memory-disclosure bug in OpenSSL. Not directly about password storage, but it demonstrated a related principle: the bytes of secret material that live in process memory are real and vulnerable. The discipline of `UnlockedVault.close()` clearing the key and the `with` statement minimizing how long the vault stays unlocked is a downstream of this lesson.
**[Yahoo 2013 / 2016](https://en.wikipedia.org/wiki/Yahoo!_data_breaches)** — 3 billion records (the largest breach in history). Passwords stored as **MD5**. By 2016, MD5 was already 20 years past being considered broken for password storage. Lesson: cryptographic agility (the ability to upgrade hash/KDF choices over time) matters. The reason this project stores the KDF parameters *in the vault file* — instead of hard-coding them — is so old vaults can be upgraded later without forcing users to lose data. The `change-password` command exercises that capability.
---
## Where to go next
Now you know *why* every design choice in the code is what it is. Time to see *how* it's organized.
**[02-ARCHITECTURE.md](./02-ARCHITECTURE.md)** explains how the project is split into modules, what the vault file looks like on disk, and the step-by-step flow of every CLI command.
After that, **[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks every source file line-by-line with the Python features explained as they appear.

View File

@ -0,0 +1,554 @@
# Architecture
This file is the map. By the end you should be able to draw the project from memory: which file holds what, how the layers depend on each other, what the file on disk actually contains, and the step-by-step flow of every CLI command.
## Table of contents
1. [The five-file layout (and why)](#1-the-five-file-layout-and-why)
2. [Dependency direction — who imports whom](#2-dependency-direction--who-imports-whom)
3. [The vault file format on disk](#3-the-vault-file-format-on-disk)
4. [Flow: `pv init`](#4-flow-pv-init)
5. [Flow: `pv add`](#5-flow-pv-add)
6. [Flow: `pv get` and `pv list`](#6-flow-pv-get-and-pv-list)
7. [Flow: `pv change-password`](#7-flow-pv-change-password)
8. [Flow: `pv gen` (no vault)](#8-flow-pv-gen-no-vault)
9. [Atomic + durable + concurrent-safe writes, drawn out](#9-atomic--durable--concurrent-safe-writes-drawn-out)
10. [Lifecycle of an `UnlockedVault`](#10-lifecycle-of-an-unlockedvault)
---
## 1. The five-file layout (and why)
```
src/password_manager/
├── __init__.py package entry — re-exports the public API
├── __main__.py lets `python -m password_manager` work
├── constants.py every magic number and fixed string
├── crypto.py Argon2id + AES-256-GCM primitives
├── generator.py cryptographically secure password generation
├── vault.py file format, atomic writes, locking, entry CRUD
└── main.py CLI commands (Typer): init, add, get, list, …
```
Compared to `hash-identifier` (one file, 680 lines), this project is split across five source files (~1,400 lines total). The split isn't decoration — each file has a strict reason to exist:
| File | Talks to | Doesn't talk to | Job |
| ------------- | --------------------- | ---------------------------- | ---------------------------------------------------------------------------------- |
| `constants.py` | Nothing | Anything | Single source of truth for numbers, strings, and tunables |
| `crypto.py` | `constants` only | Filesystem, network, CLI | Pure cryptography. Bytes in, bytes out. No I/O. |
| `generator.py`| `constants` only | Filesystem, network, CLI | Random password generation. Pure function, no I/O. |
| `vault.py` | `crypto`, `constants` | The terminal, command-line | File format, atomic writes, file locking, entry add/get/delete |
| `main.py` | All of the above | — | Glue layer between user keyboard and the rest of the code |
**Why these boundaries matter:**
- The crypto file calls *no I/O functions*. No file reads, no `print`, no `input`. This means it's trivial to test (just call `encrypt(b"hello", key)`) and impossible to introduce a "let me just print the key for debugging" bug at the wrong layer.
- The vault file knows nothing about the terminal. It raises typed exceptions (`VaultNotFoundError`, `WrongPasswordError`, etc.). The CLI layer catches them and turns them into colored error messages. A future GUI or web frontend could be built on `vault.py` without changing any of it.
- The CLI file knows nothing about cryptography. It calls `UnlockedVault.create(...)` and `vault.add_entry(...)` and `vault.save()`. If we swap Argon2id for something newer next year, `main.py` doesn't change.
This is called **layered architecture**. The lower layers don't import from higher layers. Cryptography is at the bottom; the CLI is at the top.
---
## 2. Dependency direction — who imports whom
```
┌─────────────────────┐
│ main.py │ the CLI (Typer + Rich)
│ (commands, glue) │
└──────────┬──────────┘
┌──────────────┼──────────────┐
▼ ▼ ▼
┌───────────────┐ ┌──────────┐ ┌────────────┐
│ vault.py │ │ crypto │ │ generator │
│ (file │ │ .py │ │ .py │
│ format) │ │ │ │ │
└───────┬───────┘ └────┬─────┘ └─────┬──────┘
│ │ │
└───────┬───────┴──────┬───────┘
│ │
▼ ▼
┌───────────────────────┐
│ constants.py │ no imports of our code
│ (numbers + strings) │
└───────────────────────┘
```
**Arrows point in the direction of imports.** `main.py` imports from `vault.py`, `crypto.py`, `generator.py`, and `constants.py`. `vault.py` imports from `crypto.py` and `constants.py`. Nothing imports back the other way. No cycles.
If you ever see an arrow pointing the wrong way (e.g. `crypto.py` importing from `vault.py`), that's a code smell — it usually means a piece of logic landed in the wrong layer. The compiler/linter won't stop you, but the design will start to rot.
---
## 3. The vault file format on disk
The vault is a single JSON file. By default it lives at `~/.password-vault/vault.json` with file permissions `0600` (owner-only).
Here's what the file looks like *roughly* (the base64 fields are abbreviated):
```json
{
"version": 1,
"kdf": {
"name": "argon2id",
"salt": "X3lkR1d2hcKLwk0PXfQpPg==",
"time_cost": 3,
"memory_cost": 65536,
"parallelism": 4
},
"cipher": {
"name": "aes-256-gcm",
"nonce": "8tNTPwoq8uTXkpKt",
"ciphertext": "Yk7eEVTSfA9wL...<lots more base64>...kw=="
}
}
```
Two layers of JSON live here, and that's important:
**Outer layer (the envelope):** plain JSON containing the metadata needed to *decrypt* the inner layer. Anybody who steals the file can read this — it tells them which KDF and cipher were used, the salt, the nonce. None of this is secret; cryptographic security depends on the *key*, not on hiding the algorithm.
**Inner layer (the ciphertext):** when decrypted, this is *another* JSON document — a dictionary of credential entries:
```json
{
"github": {
"username": "alice",
"password": "hunter2-but-better",
"url": "https://github.com",
"notes": "",
"created_at": "2026-05-13T14:22:10+00:00",
"updated_at": "2026-05-13T14:22:10+00:00"
},
"email": {
"username": "alice@example.com",
"password": "another-secret",
"url": "",
"notes": "personal Fastmail",
"created_at": "2026-05-13T14:30:01+00:00",
"updated_at": "2026-05-14T09:11:42+00:00"
}
}
```
So: **JSON envelope wrapping encrypted JSON.** Boring, inspectable, portable. Boring is good in security — fewer custom things to get wrong.
**Why JSON specifically?**
- Human-inspectable. You can `cat vault.json` and at least confirm the structure. Useful for debugging.
- Trivially portable. Every language has a JSON parser. If you ever wanted to write a reader for this format in Rust or Go, you'd have it working in an hour.
- Forward-compatible. The `"version": 1` field lets future versions know how to read today's vaults — and lets us refuse to read vaults from a *future* version we don't understand yet.
**Why base64?**
JSON has no way to represent raw bytes. The standard fix is base64: a way to write any binary data as a string of printable ASCII characters (`A-Z`, `a-z`, `0-9`, `+`, `/`, `=`). It bloats the data by ~33% but lets us round-trip bytes through JSON cleanly. Salts, nonces, and ciphertexts are all stored base64-encoded.
---
## 4. Flow: `pv init`
This creates a brand-new empty vault. Trace through what happens step-by-step:
```
user types: `pv init`
┌─────────────────────────────────────────────────┐
│ main.init() │
│ - parse --vault flag (or env, or default path) │
│ - exists check: refuse if vault.json exists │
│ - prompt for master password (twice, confirm) │
│ - validate: non-empty, >= 8 chars, matches │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ UnlockedVault.create(path, master) │
│ - generate fresh 16-byte salt (secrets) │
│ - derive 32-byte key from master + salt │
│ via Argon2id (~0.5s on modern laptop) │
│ - build empty entries dict │
│ - call self.save() │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ vault.save() │
│ - serialize entries (empty {}) to JSON │
│ - generate fresh 12-byte nonce (secrets) │
│ - AES-256-GCM encrypt the inner JSON │
│ - build outer JSON envelope │
│ - atomic write to vault.json.tmp │
│ - fsync data │
│ - os.replace onto vault.json │
│ - fsync parent directory │
└─────────────────────────────────────────────────┘
`Vault created at ~/.password-vault/vault.json`
```
Two key things to notice:
1. **The salt is generated once, at `create()` time, and never changes for the life of the vault.** Even after `change-password` re-encrypts everything under a new key, the salt itself is regenerated only because the password changed — for a given password, the salt is stable.
2. **The nonce is generated *every save*, never reused.** The slowest path (Argon2id) happens once per session; the second-slowest path (AES-GCM encrypt) happens on every save and uses a fresh nonce each time.
---
## 5. Flow: `pv add`
This unlocks the vault, adds an entry, and saves it back. The Argon2id cost is paid *once* on unlock, then `add` and `save` are both fast.
```
user types: `pv add github`
┌─────────────────────────────────────────────────┐
│ main.add() │
│ - prompt for master password │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ UnlockedVault.unlock(path, master) │
│ - read vault.json from disk │
│ - parse JSON envelope │
│ - validate version + algorithm names │
│ - validate Argon2 parameters (sanity floors) │
│ - extract salt, KDF params, nonce, ciphertext │
│ - derive_key(master, salt, params) ← slow │
│ - AES-256-GCM decrypt(ciphertext, nonce, key) │
│ ↓ if auth tag fails: raise │
│ WrongPasswordError → CLI exits with msg │
│ - parse inner JSON → entries dict │
│ - return UnlockedVault(path, salt, params, │
│ key, entries) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ main.add() body, inside `with` block │
│ - prompt for username (visible) │
│ - if --generate: generate_password(length) │
│ else: getpass for password (hidden) │
│ - prompt for url, notes (optional) │
│ - build Entry(username, password, url, notes, │
│ created_at=now, updated_at=now) │
│ - vault.add_entry(name, entry, force=...) │
│ ↓ if name exists and not force: │
│ EntryAlreadyExistsError → CLI exits │
│ ↓ if name is empty or has whitespace: │
│ ValueError → CLI exits │
│ - vault.save() (atomic, durable, locked) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ end of `with` block → vault.__exit__() │
│ - vault.entries = {} │
│ - vault.key = bytes(32) (zero-filled) │
│ (best-effort wipe; Python bytes are immutable, │
│ but we drop the references at minimum) │
└─────────────────────────────────────────────────┘
`Added entry: github`
```
Notice the **two failure modes after decryption**:
- "Wrong password" gets one error message.
- "Vault file is corrupted" gets the *same* error message ("Wrong master password (or vault file is corrupted)").
This is on purpose. GCM authentication failure means one of three things and we can't tell which: wrong password, tampered file, corrupted file. From the user's perspective they're indistinguishable, and *exposing* the difference helps an attacker (who'd know whether their guess was "almost right" vs "definitely wrong key"). We collapse them into one honest message.
---
## 6. Flow: `pv get` and `pv list`
Both follow the same pattern: unlock, read, render, close. The vault is unlocked just long enough to grab the data and is dropped immediately after rendering.
```
pv get github
├─► prompt master password
├─► UnlockedVault.unlock(...) (slow once, Argon2id)
├─► entry = vault.get_entry("github")
│ ↓ if not found:
│ EntryNotFoundError → CLI exits 1
├─► console.print(rich.Panel(...)) ← colored panel
└─► end of `with` → wipe key + entries
```
```
pv list
├─► prompt master password
├─► UnlockedVault.unlock(...)
├─► names = vault.names() (sorted alphabetically)
├─► if not names: print "vault is empty" message
├─► build a rich.Table with one row per entry
│ columns: name, username, updated_at
│ (passwords are NOT shown in `list`)
├─► console.print(table)
└─► end of `with` → wipe key + entries
```
`list` shows usernames and update times but **not passwords**. The user has to explicitly ask for a password with `get <name>`. This is a small friction that reduces the chance of leaking an entire credential set to anyone watching your terminal.
---
## 7. Flow: `pv change-password`
This is the most cryptographically interesting command. It rotates the master password by:
1. Unlocking the vault with the *old* password (full Argon2id cost).
2. Generating a *fresh salt* and deriving a *new key* from the *new* password.
3. Saving the vault — `save()` will encrypt the existing entries under the new key, with a fresh nonce.
```
pv change-password
├─► prompt: "Current master password: "
├─► UnlockedVault.unlock(path, current_password)
│ ↓ if wrong: WrongPasswordError → exit 1
├─► prompt: "New master password: " (twice, confirm)
│ ↓ validate: non-empty, >= 8 chars, matches
├─► vault.change_master_password(new_password)
│ - new_salt = secrets.token_bytes(16)
│ - new_key = derive_key(new, new_salt, defaults)
│ - self.salt = new_salt
│ - self.kdf_parameters = defaults()
│ - self.key = new_key
│ (only mutates in-memory state — disk untouched)
├─► vault.save()
│ - serializes the SAME entries dict (preserved)
│ - generates a NEW nonce
│ - encrypts under the NEW key
│ - atomic write replaces the old file
└─► "Master password changed. Vault re-encrypted at <path>"
```
**Why this is interesting:** the vault file stores the KDF parameters and salt next to the ciphertext. That's *exactly* why this operation is possible. If the KDF params lived only in the code, then "change my password" would have no way to also "upgrade my Argon2 parameters from last year's defaults to this year's." Putting them in the file makes the upgrade path possible. The `kdf_parameters` argument on `change_master_password` is the hook for it.
**Crash safety:** if the process dies between mutating the in-memory state and the atomic save completing, the *file on disk* still has the old salt and old ciphertext — fully readable with the old password. The new key only "wins" after `os.replace` lands. This is the entire reason atomic writes matter for password managers: a botched rotation must never lock you out.
---
## 8. Flow: `pv gen` (no vault)
The simplest command. Doesn't touch the vault at all. Doesn't prompt for the master password. Just generates a strong random password and prints it.
```
pv gen 32
generate_password(length=32,
use_lowercase=True,
use_uppercase=True,
use_digits=True,
use_symbols=True)
├─► length >= MIN (8)?
├─► at least one pool enabled?
├─► length >= number of enabled pools?
│ (need to fit one char from each)
├─► required = [secrets.choice(pool) for pool in pools]
│ one char guaranteed from each enabled pool
├─► fill = [secrets.choice(combined) for _ in range(length - len(required))]
├─► chars = required + fill
├─► _secure_shuffle(chars)
│ Fisher-Yates with secrets.randbelow
│ (NOT random.shuffle — predictable)
└─► return "".join(chars)
```
The output goes to stdout via plain `print()` (not the rich console), so it's pipe-friendly:
```bash
pv gen 32 | pbcopy
PASSWORD=$(pv gen 32)
```
This is the only command that uses `print` instead of `console.print`. The reason is exactly piping: we don't want rich's color escape codes inside the password that gets piped to `pbcopy`.
---
## 9. Atomic + durable + concurrent-safe writes, drawn out
The `save()` method does more work than you'd guess. The "just write the file" version of this would be one line; ours is a few dozen. Here's why each piece is there.
### What can go wrong with the naive approach
```python
# DON'T DO THIS
path.write_bytes(envelope_bytes)
```
This has three problems, all of which we've seen happen in real systems:
1. **Crash mid-write → corrupt file.** If the process dies after writing 4096 bytes of a 6000-byte file, the file is half-written. Next time the user tries to unlock, JSON parsing fails and they think their vault is destroyed.
2. **Power loss → 0-byte file (or worse).** Even if the process completes, the bytes live in the kernel's page cache. The OS will write them to disk *eventually*, but a power loss between write and disk-write means the file appears to exist but contains nothing.
3. **Two `pv` instances racing.** User runs `pv add github` in one terminal and `pv add email` in another simultaneously. Both unlock the vault (slow), both add their entry, both save. Whichever saves *second* loses the other's entry — silently. No error.
### How we fix each one
```
┌──────────────────────────────────────────────┐
│ 1. acquire advisory flock on vault.json.lock │
│ (POSIX systems — Windows skips this) │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 2. os.open(vault.json.tmp, …, mode=0600) │
│ file created world-unreadable from the │
│ very first syscall (no chmod race) │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 3. os.write(fd, envelope_bytes) │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 4. os.fsync(fd) │
│ forces kernel page cache → disk. │
│ without this, "we wrote it" is a story │
│ the page cache tells; a power loss erases │
│ it. │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 5. os.close(fd) │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 6. os.replace(vault.json.tmp, vault.json) │
│ atomic rename. after this instant, │
│ readers see EITHER the old file OR the │
│ new file. never half of either. │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 7. fsync the parent directory (POSIX) │
│ so the rename itself survives power loss. │
│ without this, an OS crash right after the │
│ rename can revert the directory entry. │
└──────────────────┬───────────────────────────┘
┌──────────────────────────────────────────────┐
│ 8. release advisory flock │
└──────────────────────────────────────────────┘
```
Each step plugs one hole:
| Step | Plugs |
| ---- | -------------------------------------- |
| 1, 8 | Two `pv` processes racing |
| 2 | Brief window where tmp file is world-readable |
| 4 | "Power loss right after write" loses data |
| 6 | "Crash mid-write" corrupts the live file |
| 7 | "Power loss right after rename" reverts |
The advisory lock is interesting: it's not enforced by the OS, only by code that opts in. A process that ignores `flock` can still write the file. But *every* `save()` in our code opts in, so two `pv` invocations can't race against each other. An external editor (vim, `sed`) doesn't lock, but a user editing the vault file by hand has already left the tool's contract.
Windows doesn't have `fcntl.flock` or directory `fsync`, so we skip both there. NTFS gives us atomic `os.replace` regardless. The trade-off is: on Windows we lose cross-process serialization (rare edge case for a single-user tool) and we lose the absolute-guarantee directory durability (NTFS journaling covers most cases).
---
## 10. Lifecycle of an `UnlockedVault`
An `UnlockedVault` is a Python object that holds:
- The path to the vault file on disk.
- The 16-byte salt.
- The Argon2 parameters that were used.
- The 32-byte AES key (sensitive!).
- The decrypted entries (also sensitive! they contain plaintext passwords).
Holding the key in memory means subsequent operations (add, get, delete, save) don't have to re-derive it — they'd otherwise pay the Argon2 cost on every save. But it also means we want a clear "I'm done with this" signal.
Python's `with` statement (a "context manager") is exactly that signal:
```python
with UnlockedVault.unlock(path, master) as vault:
vault.add_entry("github", entry)
vault.save()
# at this point, vault.__exit__ has been called
# vault.entries is now {}
# vault.key is now b"\x00" * 32
```
`__enter__` runs when the block starts. `__exit__` runs when the block ends — *whether normally or by exception*. So even if `vault.save()` raises, the cleanup still happens.
The cleanup itself (`vault.close()`) replaces the entries dict with `{}` and the key with 32 zero bytes. This is a **best-effort** wipe. Python's `bytes` objects are immutable, so the original key bytes might still live in memory until the garbage collector runs. True wipe-on-free in Python requires `bytearray` plus `ctypes` tricks that this teaching project deliberately avoids — the discipline of "drop secrets explicitly when done" is the more important habit.
```
┌────────────────────────────────────────────┐
│ UnlockedVault.unlock(path, master) │
│ ─ slow: Argon2id derives 32-byte key │
│ ─ AES-GCM decrypts ciphertext │
│ ─ returns instance: { path, salt, params, │
│ key, entries } │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
__enter__ → returns self │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
│ body of `with` block │
│ ─ get_entry, add_entry, delete_entry │
│ ─ save() (fast: key already in memory, │
│ just AES-GCM + atomic write) │
└────────────────────────────────────────────┘
┌────────────────────────────────────────────┐
__exit__ → close() │
│ ─ self.entries = {} │
│ ─ self.key = b"\x00" * 32 │
│ (best-effort wipe; Python bytes are │
│ immutable, GC may still hold copies) │
└────────────────────────────────────────────┘
```
This is the same pattern Python's `open()` uses (`with open("x.txt") as f:`). The vault is just a more security-sensitive resource than a file handle.
---
## Where to go next
You now have the shape of the project in your head: which file does what, what the file on disk looks like, what each command does step-by-step.
**[03-IMPLEMENTATION.md](./03-IMPLEMENTATION.md)** walks every source file line-by-line. Open `crypto.py`, `vault.py`, and `main.py` in a second window and read along.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,224 @@
# Challenges
You've read the whole project. You understand why every line is what it is. Now what?
The honest answer is: **build something on top of it.** The fastest way to actually learn what you just read is to extend it. The challenges below are ordered roughly easy → hard. Pick one that interests you, sketch what you'd change, and try.
If you get stuck, the relevant pieces of the existing code are linked. If you finish one and want to share it, fork the repo and open a PR — the foundations tier is meant to be a stepping stone, not a finished thing.
## A note on scope
Don't try to do all of these. Don't even try to do five. Pick *one*, do it well, and stop. The point isn't to add features — it's to internalize the existing code by interacting with it.
For each challenge below, you'll find:
- **What** — a sentence or two describing the feature.
- **Why it's interesting** — what you'll learn from building it.
- **Where to start** — which file(s) you'd touch.
- **Watch out for** — the security or correctness traps that catch beginners.
---
## Tier 1 — small features (~30 minutes each)
### 1. Add a `search` command
**What:** `pv search <substring>` lists every entry name that contains the substring (case-insensitive). Like `pv list`, but filtered.
**Why it's interesting:** Easy to do, but forces you to read `main.py` and `vault.py` carefully and find the right seam to add a new command. Good "first PR" warmup.
**Where to start:** Copy the `list_entries` command in `main.py`. Filter `unlocked.names()` before rendering the table.
**Watch out for:**
- Case-insensitive comparison: use `name.lower()` and `query.lower()`.
- Empty search query should be rejected, not return "every entry."
### 2. Add a `count` command
**What:** `pv count` prints just the number of entries. Useful for shell scripts.
**Why it's interesting:** Smallest possible new command. Forces you to think about output format (number + newline, no decoration) so it can be piped: `if [ "$(pv count)" -eq 0 ]; then ...`.
**Where to start:** Copy `gen` — it's the simplest existing command. Read entries, print `len(unlocked.entries)`.
**Watch out for:**
- Use `print()`, not `console.print()`, so the output is pipe-friendly.
- An empty vault still prints `0`, not "vault is empty."
### 3. Show a "last used" timestamp
**What:** Add a `last_used_at` field to `Entry`. The `get` command updates it (and saves the vault).
**Why it's interesting:** You'll touch every layer — the `Entry` dataclass, its `from_dict`/`to_dict`, the save path, and `get` in `main.py`. Good way to see the architecture in motion.
**Where to start:** Add the field with `field(default_factory=lambda: "")` so old vaults open without it. Bump the implicit field count in `from_dict`.
**Watch out for:**
- Old vaults won't have the field — handle the missing-key case the same way `created_at` and `updated_at` do.
- `get` now mutates the vault → must call `save()` before the `with` block ends.
- This changes the threat model: an attacker who steals the vault now learns *which entry you used most recently*. Document the trade-off.
### 4. Hide passwords in `get` unless `--show` is passed
**What:** `pv get github` shows everything except the password (`••••••••`). `pv get github --show` shows the real password.
**Why it's interesting:** Tiny UX change, but it's the kind of feature real password managers ship for "in a meeting screen-sharing" moments.
**Where to start:** Add a `--show / -s` flag to the `get` command. In `_render_entry`, branch on the flag.
**Watch out for:**
- Default to hidden, opt-in to visible — secure by default.
- Bullet character `•` may not render in every terminal — provide a fallback.
---
## Tier 2 — medium features (a few hours each)
### 5. Implement `pv export` and `pv import`
**What:** `pv export <path>` writes every entry to a plain-text JSON file (after prompting for the master password and a strong "ARE YOU SURE" warning). `pv import <path>` does the reverse.
**Why it's interesting:** This is real, useful, and dangerous. Real because every password manager needs migration in and out. Useful because users die, lose phones, switch tools. Dangerous because plaintext credentials on disk is exactly what we built this tool to avoid.
**Where to start:** Add two commands to `main.py`. Export serializes `unlocked.entries` and writes a JSON file with mode 0600. Import reads JSON, validates structure, calls `add_entry` for each.
**Watch out for:**
- The export file is plaintext. Print a giant red warning before writing.
- Default the export path to `./pv-export.json`, not the user's home directory — make them think about where it lives.
- Import should handle "entry already exists" gracefully — either ask, or take a `--force` flag, or skip.
- Validate the imported JSON the same way `Entry.from_dict` validates — don't trust the file structure.
- An exported file with weak filesystem permissions is a real foot-gun. Set mode 0600 explicitly with `os.open` (same trick `_atomic_write` uses).
### 6. Add password strength scoring
**What:** When a user runs `pv add`, show them a strength score (weak/fair/strong/excellent) before saving. Use a library like [`zxcvbn-python`](https://github.com/dwolfhub/zxcvbn-python).
**Why it's interesting:** Practical UX feature. Forces you to add a new dependency (touching `pyproject.toml` and `uv.lock`) and understand the difference between "looks random to a human" and "would survive an offline guessing attack."
**Where to start:** Add `zxcvbn` to `pyproject.toml`. Call it on the entered password. Map the 0-4 score to a color and label. Allow the user to proceed anyway.
**Watch out for:**
- Don't *block* the user from saving a weak password — they may have a good reason. Warn and ask.
- The score should be displayed before the password is permanent — if you wait until after `save()`, the user has to delete and re-add to try again.
### 7. Add a `pv copy <name>` command
**What:** Copy a password to the system clipboard without printing it. Use [`pyperclip`](https://github.com/asweigart/pyperclip).
**Why it's interesting:** Real password managers do this. Forces you to think about platform differences (clipboard APIs differ on Linux/Mac/Windows) and the security trade-off of having credentials in the clipboard.
**Where to start:** Add `pyperclip` to dependencies. New command that unlocks the vault, fetches the entry, copies its password, and prints a confirmation (NOT the password).
**Watch out for:**
- On Linux you need `xclip` or `wl-clipboard` installed at the system level — document this.
- Clipboard contents persist until something else overwrites them. Bonus challenge: spawn a background thread that clears the clipboard after 30 seconds.
- Pyperclip on remote SSH sessions doesn't work — handle the import-time failure with a clear error.
### 8. Add a `--verify` flag to `init`
**What:** After creating a vault, immediately try to unlock it with the same password. If unlock fails, panic — something is corrupt.
**Why it's interesting:** Defense in depth. The save → re-unlock cycle would have caught some classes of bugs during development. Builds the habit of "test the read path on every write" that file-format projects benefit from.
**Where to start:** Add a `--verify / -V` flag to `init`. After `UnlockedVault.create()` returns, call `UnlockedVault.unlock()` with the same password. Print a green check or a red panic.
**Watch out for:**
- The verify call costs another full Argon2 derivation (~0.5s). Make it opt-in, not default.
- If the verify *fails*, something is very wrong — refuse to leave the new vault in place. Delete it.
---
## Tier 3 — bigger features (a weekend each)
### 9. Add TOTP (time-based one-time passwords)
**What:** Some sites use TOTP for 2FA. Currently you store the secret somewhere else (Google Authenticator, Authy). Add a TOTP secret field to `Entry`, and a `pv totp <name>` command that prints the current 6-digit code.
**Why it's interesting:** You'll learn what TOTP actually is (it's [RFC 6238](https://datatracker.ietf.org/doc/html/rfc6238) and surprisingly simple — HMAC-SHA1 of the current 30-second window). The Python library `pyotp` does it in two lines, but writing the core yourself is a 30-line exercise.
**Where to start:** Add `pyotp` to dependencies. Add an optional `totp_secret` field to `Entry`. Add the command in `main.py`.
**Watch out for:**
- The TOTP secret is at least as sensitive as the password. It belongs *inside* the encrypted vault, not in a sidecar file.
- Time skew matters. Print "this code is valid for X more seconds" so the user doesn't try to use one that's about to roll over.
- Importing TOTP secrets from QR codes is its own project — don't try to do that here.
### 10. Make the KDF cost upgrade transparent
**What:** When a user unlocks a vault whose Argon2 parameters are below the current code's defaults, *automatically* re-derive the key with the new defaults and save — same password, stronger derivation. Print a message: "Vault parameters upgraded to current defaults."
**Why it's interesting:** This is what real password managers do. It's why we store the KDF parameters in the file. Forces you to fully understand the `change_master_password` flow and to think about UX for "long-running operation users didn't ask for."
**Where to start:** Inside `UnlockedVault.unlock`, after successful decryption, compare `kdf_parameters` to `KdfParameters.defaults()`. If they differ, do an in-place change (call something like `change_master_password(same_password, new_kdf_parameters=...)`) and save.
**Watch out for:**
- Pay the new Argon2 cost only once, not twice. Refactor `change_master_password` so it can also "upgrade in place" without rotating the password.
- The user will see two sequential ~0.5-second pauses. Tell them why with a message before the second one.
- Add a `--no-auto-upgrade` flag for users who don't want this.
### 11. Implement a `pv backup` command with versioned snapshots
**What:** `pv backup` writes a copy of the current vault to `~/.password-vault/backups/vault-YYYY-MM-DD-HHMMSS.json` and keeps the last N backups. Add a `pv restore <timestamp>` command that overwrites the live vault from a backup.
**Why it's interesting:** Real systems need backups, but backups are *also* a security surface — they're more files an attacker can steal. Forces you to think about: how many to keep, where to store, whether to also encrypt the backup index, what happens on restore (you want atomic semantics).
**Where to start:** Add the two commands. Use the existing atomic-write pattern (don't write the backup with `Path.write_bytes`; use `_atomic_write`). Use the existing file-lock pattern.
**Watch out for:**
- Backups are full copies of the encrypted file — they're encrypted, so they're "safe to lose to disk forensics" *to the same extent* as the live vault is, but no more.
- Pruning old backups requires care — don't delete the file that's currently being read by another `pv` process. Use the same advisory lock.
- "Restore from backup N" needs to validate that backup N is a *real* vault (parse the envelope, check version) before overwriting the live file.
### 12. Add a web UI in a separate `pv web` command
**What:** A local-only web server (`localhost:8080`) that serves a simple UI for browsing the vault. Auto-shuts down after 10 minutes of inactivity.
**Why it's interesting:** Forces you to think about *every* security trade-off you didn't have to think about with a CLI. Master password handling in a browser, CSRF, XSS, session timeout, HTTPS-or-not on localhost, what to do when a second tab opens.
**Where to start:** Use [`starlette`](https://www.starlette.io) or [`fastapi`](https://fastapi.tiangolo.com) for the server. Render entries with Jinja2 templates. Don't use cookies — use a single in-memory session that auto-expires.
**Watch out for:**
- This is genuinely harder than it looks. Real password managers' web UIs are full-time engineering jobs. The point of *this* version is to learn what the trade-offs are, not to ship a production tool.
- The browser is now a part of your threat model. Browser extensions can read DOM. Other tabs can navigate to your `localhost:8080`. Same-origin policy is your only friend.
- Logging — Starlette/FastAPI will helpfully log every request. Make sure the access log doesn't include the master password (it won't, if you do form POST correctly, but check).
---
## Tier 4 — research-flavored projects
These don't have a clean "build this exact feature" shape. They're directions to push the project in if you've absorbed everything and want to keep going.
### 13. Audit a real password manager's threat model
Pick a real, open-source password manager: [Bitwarden](https://github.com/bitwarden), [KeePassXC](https://github.com/keepassxreboot/keepassxc), [pass](https://www.passwordstore.org). Read its docs and source for the equivalent pieces of *this* project: how does it derive keys, how does it store the file, how does it handle master password rotation? Write up a comparison.
You'll learn that real password managers make different trade-offs — sometimes for good reasons, sometimes for legacy reasons. The exercise of identifying *which is which* is the kind of analysis security engineers do for a living.
### 14. Write a vault-format reader in another language
The vault format is documented in [02-ARCHITECTURE.md §3](./02-ARCHITECTURE.md#3-the-vault-file-format-on-disk) and the JSON keys are constants in `constants.py`. Write a read-only client in Rust, Go, or whatever you're learning next. You'll find the libraries to use (`argon2`, `aes-gcm`), match versions/parameters, and validate the cross-language round trip.
This is a really good exercise. It demonstrates *why* we wrote the format down — and probably exposes places where the format underspecifies something (which is the kind of thing real-world interop projects find all the time).
### 15. Threat-model a deliberate weakness
Pick one assumption from the threat model section of [01-CONCEPTS.md §12](./01-CONCEPTS.md#12-putting-it-all-together-the-threat-model). Try to defeat it.
Examples:
- "We don't defend against a keylogger." Try writing a Python keylogger that watches the `pv` process's stdin (you'll find it's surprisingly hard because `getpass` reads from the terminal device, not stdin). Then try writing one that hooks at the OS level on Linux — what permissions does it need?
- "We don't truly wipe the key from memory." Use a memory-debugging tool (`gcore` + `strings`) on a running `pv` process to find the AES key. Then design what would *actually* protect against this and explain why we didn't implement it.
The goal isn't to weaponize anything. It's to viscerally feel the difference between "we don't claim to defend against X" and "we couldn't even if we wanted to."
---
## What to read next
If you got through 03-IMPLEMENTATION and you're done with this project:
- **The [intermediate tier](../../../intermediate/)** — projects that involve web servers, databases, and multiple files. The jump from this project to those is much smaller now than it was before you started.
- **The [advanced tier](../../../advanced/)** — projects that involve real distributed systems and serious security primitives.
- **[Crypto 101](https://www.crypto101.io)** by Laurens Van Houtven — a free book that goes deeper on every cryptographic idea in this project. Especially recommended if you found §8-9 in [01-CONCEPTS.md](./01-CONCEPTS.md) interesting and want the full background.
- **[Cryptography Engineering](https://www.schneier.com/books/cryptography-engineering/)** by Ferguson, Schneier, and Kohno — the textbook. Heavier than Crypto 101, but the definitive reference for "how cryptographic systems actually fail in practice."
You finished the hardest project in the foundations tier. You now know more about real-world password storage than the engineers responsible for [most of the breaches we cited](./01-CONCEPTS.md#13-real-breaches-that-made-these-choices-the-right-ones). That's worth pausing to appreciate.

View File

@ -0,0 +1,294 @@
# ©AngelaMos | 2026
# pyproject.toml
#
# This file is the "instruction manual" for our Python project. Every
# modern Python project has one. It tells tools like uv, pip, pytest,
# ruff, mypy, and pylint:
#
# - What our project is called
# - Which third-party libraries we depend on
# - How to build and install it
# - How to lint, type-check, and test it
#
# The file format is called TOML — a simple, human-readable config
# language. Square brackets like [project] start a "table" (a section).
# Lines like name = "..." are key-value pairs inside that table.
#
# Read this top-to-bottom. Each section has a short comment explaining
# what it does and why it is here.
# =============================================================================
# [project] — the metadata that describes WHAT this project is
# =============================================================================
# This section is standardized across the whole Python ecosystem (PEP 621).
# Anyone who runs `pip install` or `uv sync` reads from here.
[project]
# The name people will install this package under.
# Must be unique on PyPI if we ever publish there.
name = "password-vault"
# Semantic version: MAJOR.MINOR.PATCH.
# 1.0.0 means "first stable release."
# When we add features → bump MINOR (1.1.0). When we break things → MAJOR.
version = "1.0.0"
# One-line description that shows up in `pip show` and on PyPI.
description = "Beginner-friendly encrypted password manager (Argon2id + AES-256-GCM)"
# The minimum Python version this project supports.
# >=3.13 means "Python 3.13 or anything newer."
# We need 3.13 because we use modern type-hint syntax (X | None).
requires-python = ">=3.13"
# Who wrote it. Shows up in package metadata.
authors = [
{name = "CarterPerez-dev", email = "support@certgames.com"}
]
# The README file is shown on PyPI and in `pip show`.
readme = "README.md"
# AGPL-3.0 means: anyone can use it, but if they modify and run it as a
# service, they must publish their changes. It protects the project
# from being privatized.
license = {text = "AGPL-3.0-or-later"}
# -----------------------------------------------------------------------------
# dependencies — third-party libraries we MUST have at runtime
# -----------------------------------------------------------------------------
# When a user runs `pip install password-vault`, these get installed too.
# The string format is "<name><operator><version>". We use ranges so we
# get bug fixes (>=) but never an incompatible major version (<).
dependencies = [
# argon2-cffi: implements Argon2id, the modern password-hashing
# algorithm we use to turn the master password into an encryption key.
"argon2-cffi>=25.1.0",
# cryptography: provides AES-256-GCM, the actual encryption that
# scrambles vault contents. Maintained by the Python Cryptographic
# Authority — the gold-standard library, audited and trusted.
"cryptography>=48.0.0",
# typer: builds nice CLIs from regular Python functions.
# Lets us write `add`, `get`, `list` commands with auto-generated help.
"typer>=0.25.1",
# rich: pretty terminal output — colors, tables, panels, prompts.
# Makes the CLI pleasant to use without writing a bunch of formatting.
"rich>=15.0.0",
]
# -----------------------------------------------------------------------------
# optional-dependencies — extra libraries that are NOT needed at runtime
# -----------------------------------------------------------------------------
# These only matter to developers (testing, linting, formatting). End users
# don't install them. Activated with: `uv sync --extra dev` or `--all-extras`.
[project.optional-dependencies]
dev = [
# pytest — runs our test suite (the test_*.py files in tests/).
"pytest>=9.0.3",
# pytest-cov — measures how much of our code the tests actually
# exercise. "Coverage" — high coverage means well-tested code.
"pytest-cov>=7.1.0",
# ruff — extremely fast linter and formatter. Catches bugs, style
# issues, dead imports. Modern replacement for flake8/black/isort.
"ruff>=0.15.12",
# mypy — static type checker. Reads our type hints and tells us if
# we passed a string where an int was expected, BEFORE running the code.
"mypy>=2.1.0",
# pylint — second linter that catches deeper logic issues ruff misses.
"pylint>=4.0.5",
# yapf — code formatter (Google's). Keeps every file looking identical.
"yapf>=0.43.0,<1.0.0",
]
# -----------------------------------------------------------------------------
# project.urls — links shown on PyPI / package pages
# -----------------------------------------------------------------------------
[project.urls]
Homepage = "https://github.com/CarterPerez-dev/Cybersecurity-Projects"
Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/foundations/password-manager"
# -----------------------------------------------------------------------------
# project.scripts — command-line entry points
# -----------------------------------------------------------------------------
# These create real shell commands when the package is installed. After
# `uv sync`, the user can type `pv add github` instead of
# `python -m password_manager add github`. The right-hand side is
# "<module>:<function>" — the function gets called when the command runs.
[project.scripts]
pv = "password_manager.main:app"
password-vault = "password_manager.main:app"
# =============================================================================
# [build-system] — how to BUILD this project into an installable package
# =============================================================================
# Required by PEP 517. Tools like uv read this to know which builder
# to use. We chose hatchling — modern, fast, zero-config for our case.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# Tell hatchling where the actual Python code lives. Without this, it
# guesses — and guesses wrong when the layout is src/<package>/.
[tool.hatch.build.targets.wheel]
packages = ["src/password_manager"]
# =============================================================================
# [tool.ruff] — linter / formatter configuration
# =============================================================================
# Ruff is the FAST one. It runs in milliseconds and catches 90% of issues.
[tool.ruff]
# Target Python version — ruff adjusts which warnings apply.
target-version = "py313"
# Wrap lines at 88 characters (PEP 8 says 79, but 88 is the modern norm).
line-length = 88
# Tell ruff "the source code lives in src/" so imports resolve correctly.
src = ["src"]
# Don't lint these directories.
exclude = [
".git",
".venv",
"__pycache__",
"venv",
"build",
"dist",
]
# -----------------------------------------------------------------------------
# Which lint rules to enable. Each "code" is a category of check.
# -----------------------------------------------------------------------------
[tool.ruff.lint]
select = [
"E", # pycodestyle errors — basic PEP 8 spacing/indent rules
"F", # Pyflakes — unused imports, undefined names, real bugs
"W", # pycodestyle warnings — softer style issues
"B", # Bugbear — sneaky bugs (mutable default args, etc.)
"C4", # Comprehensions — encourages cleaner list/dict comprehensions
"UP", # Pyupgrade — flags old syntax we should modernize
"SIM", # Simplify — suggests cleaner equivalents
]
# Some rules we deliberately ignore.
ignore = [
# Line length is handled by yapf, not ruff. Avoids double-flagging.
"E501",
]
# =============================================================================
# [tool.mypy] — static type checker configuration
# =============================================================================
# Mypy reads our type hints (the `: int`, `-> str` parts) and verifies them
# without running the code. Catches whole categories of bugs at edit time.
[tool.mypy]
python_version = "3.13"
# Treat anything returning Any as a warning — Any is a type-system escape
# hatch and we want to know when we're using it.
warn_return_any = true
# Warn if our config has unused settings (typos, etc.).
warn_unused_configs = true
# Require type annotations on every function. STRICT mode — appropriate
# for a security-critical project where bugs = leaked passwords.
disallow_untyped_defs = true
disallow_incomplete_defs = true
# Don't auto-add Optional just because a default is None. Be explicit.
no_implicit_optional = true
# Warn if we cast a value to a type it already has (dead code).
warn_redundant_casts = true
# Warn if a function might fall off the end without returning.
warn_no_return = true
# Pretty error output — helps when reading mypy output.
show_error_codes = true
show_column_numbers = true
pretty = true
exclude = [
".venv",
"venv",
"tests", # tests use fixtures and don't need strict typing
]
# -----------------------------------------------------------------------------
# Per-module overrides — silence missing stubs for argon2.
# argon2-cffi doesn't ship type hints, so mypy can't check it. That's fine.
# -----------------------------------------------------------------------------
[[tool.mypy.overrides]]
module = ["argon2.*"]
ignore_missing_imports = true
# =============================================================================
# [tool.pylint] — second-opinion linter
# =============================================================================
# Slower than ruff but catches different things — class design issues,
# dead variables, complex code patterns.
[tool.pylint.main]
py-version = "3.13"
jobs = 4 # run in parallel across 4 cores
persistent = true # cache results between runs
ignore = [
"venv",
".venv",
"__pycache__",
"build",
"dist",
".git",
"tests",
]
# Specific pylint warnings we deliberately turn off.
[tool.pylint.messages_control]
disable = [
"R0903", # too-few-public-methods — small data classes are fine
"C0103", # invalid-name — we use short names like `kdf` on purpose
"C0325", # superfluous-parens — fights with yapf formatting
]
# Override pylint's default complexity limits where they're too strict.
[tool.pylint.design]
max-args = 8 # default is 5; we sometimes need a few more
max-attributes = 10 # default is 7
# Foundation-tier modules carry heavy teaching comments and multi-
# paragraph docstrings, which push a single self-contained module
# above the default 1000-line ceiling. The right answer is not to
# split the module artificially — keeping vault.py as one place to
# read end-to-end is the pedagogical point — so we lift the cap.
[tool.pylint.format]
max-module-lines = 1500
# =============================================================================
# [tool.pytest.ini_options] — test runner configuration
# =============================================================================
[tool.pytest.ini_options]
# Where the test files live.
testpaths = ["tests"]
# Files that match this pattern are treated as test modules.
python_files = ["test_*.py"]
# Default flags every `pytest` invocation gets:
# -v → verbose, show each test name
# --tb=short → short tracebacks on failure (less wall of text)
addopts = "-v --tb=short"

View File

@ -0,0 +1,108 @@
"""
©AngelaMos | 2026
__init__.py
What an __init__.py file even IS
The one-sentence version
A folder that contains a file named `__init__.py` becomes a Python
"package" which is just a fancy word for "a folder you can import
from like it were a single module"
The slightly longer version
Imagine you have this layout
password_manager/
__init__.py
vault.py
crypto.py
generator.py
WITHOUT the `__init__.py`, the folder is just a folder. Python does
not know it is allowed to look inside. Writing `import
password_manager` would fail
WITH the `__init__.py`, Python looks at the folder and goes "ah, a
package". Now `import password_manager` works, and so does
`from password_manager.vault import UnlockedVault`
The file does NOT have to contain anything. An EMPTY `__init__.py`
is perfectly valid its mere PRESENCE is the signal. The contents,
if any, are bonus
When the file DOES contain stuff (like this one)
Whatever code is inside `__init__.py` runs the FIRST time anybody
imports the package. It is the package's "front door" or setup
script. Two common things people put inside
1. Re-exports pulling names up from submodules so callers can
write the short form instead of the long form
# without re-exports
from password_manager.vault import UnlockedVault
# with re-exports (what we do below)
from password_manager import UnlockedVault
This lets us reorganize the internals (split vault.py into
three files later, say) without breaking anyone's imports
2. Package metadata things like `__version__` so other tools
can ask `password_manager.__version__` and get an answer
What THIS specific __init__.py does
- Re-exports the public classes and errors from vault.py and
crypto.py so callers do not need to know which file holds what
- Sets `__version__` so `pip` and tooling can read it
- Defines `__all__` the explicit list of "what `from
password_manager import *` is allowed to bring in"
Connects to
vault.py re-exports UnlockedVault, Entry, and every vault error
crypto.py re-exports CryptoError, KdfParameters, WrongPasswordError
"""
# Local: re-export the crypto-layer pieces callers actually need —
# the KDF parameter record plus the two error types they may catch.
from password_manager.crypto import (
CryptoError,
KdfParameters,
WrongPasswordError,
)
# Local: re-export the vault-layer pieces — the Entry record, the
# main UnlockedVault class, and every domain-specific error.
from password_manager.vault import (
Entry,
EntryAlreadyExistsError,
EntryNotFoundError,
UnlockedVault,
VaultAlreadyExistsError,
VaultError,
VaultFormatError,
VaultNotFoundError,
)
__version__ = "1.0.0"
__all__ = [
"CryptoError",
"Entry",
"EntryAlreadyExistsError",
"EntryNotFoundError",
"KdfParameters",
"UnlockedVault",
"VaultAlreadyExistsError",
"VaultError",
"VaultFormatError",
"VaultNotFoundError",
"WrongPasswordError",
]

View File

@ -0,0 +1,17 @@
"""
©AngelaMos | 2026
__main__.py
Allows the package to be run with `python -m password_manager`
When Python sees `python -m <package>`, it looks for this file and
runs it. Same effect as the `pv` script entry point declared in
pyproject.toml useful when the script is not on PATH yet
"""
# Local: the Typer application object — this is what actually parses
# the CLI args and dispatches to subcommands like `list` or `add`.
from password_manager.main import app
if __name__ == "__main__":
app()

View File

@ -0,0 +1,247 @@
"""
©AngelaMos | 2026
constants.py
Every "magic number" and fixed string the project uses, in one place
A "constant" is a value that never changes while the program runs
Beginners often hard-code numbers like `length = 16` directly into
the code that uses them and then six months later, nobody remembers
why 16. By collecting every constant up here with a name and a
comment explaining its meaning, the rest of the codebase becomes
self-documenting
Three big buckets live here
1. Crypto parameters sizes and tuning knobs for Argon2id (the key
derivation function) and AES-256-GCM (the encryption). These are
chosen based on current OWASP and NIST guidance. If you ever
need to bump them in five years, you change them HERE and nowhere
else
2. Vault file format the schema version, JSON keys, and default
file location. Storing the format version means future versions
of this code can still read today's vaults
3. CLI strings prompts, error messages, success messages. Kept
here so they are easy to translate or re-word later
Connects to
crypto.py imports KDF and cipher constants
vault.py imports format constants and vault path defaults
main.py imports prompt and message strings
"""
# Standard library: object-oriented filesystem paths — safer and
# more readable than gluing strings with `os.path.join`.
from pathlib import Path
# Standard library: a type hint that marks a variable as a constant —
# mypy will reject any later attempt to reassign it.
from typing import Final
# =============================================================================
# Argon2id — Key Derivation Function parameters
# =============================================================================
# Argon2id turns a human password into a cryptographic key
# It is deliberately slow and memory-hungry to defeat brute-force
# attacks. These three knobs control HOW slow and HOW memory-hungry
#
# These values are informed by the OWASP Password Storage Cheat
# Sheet, but tuned for a single-user local password manager. OWASP's
# server-oriented configurations use parallelism=1 because a server
# parallelizes ACROSS many simultaneous logins. We use parallelism=4
# because a single user benefits from the speedup on their own
# machine — and an attacker gets the same speedup, so the security
# trade-off is net-neutral. Memory at 64 MiB is comfortably above
# every OWASP profile, which is what makes GPU brute-force expensive
# Number of passes Argon2 makes over its memory buffer
# More passes = slower derivation = harder to brute-force
# 3 is a strong choice for interactive use on modern CPUs
ARGON2_TIME_COST: Final[int] = 3
# Memory used per derivation, in kibibytes (1 KiB = 1024 bytes)
# 65536 KiB = 64 MiB. This defeats GPU/ASIC attackers because
# they have lots of compute but limited fast memory per core
ARGON2_MEMORY_KIB: Final[int] = 65536
# How many parallel threads Argon2 may use
# 4 is a safe default that works on every modern CPU
ARGON2_PARALLELISM: Final[int] = 4
# Salt length in bytes. The salt is random data mixed in with the
# password before hashing — it makes two identical passwords produce
# DIFFERENT keys, so attackers cannot precompute results
# 16 bytes (128 bits) is the standard recommendation
SALT_LENGTH_BYTES: Final[int] = 16
# Argon2 algorithmic invariants — the values below which the math
# does not even make sense. We use these to validate parameters
# loaded from a vault file on disk, so a corrupted or hand-edited
# file cannot make us call Argon2 with nonsense
# - time_cost >= 1: at least one pass over memory
# - parallelism >= 1: at least one lane
# - memory_cost >= 8 * parallelism: Argon2's hard floor (each lane
# needs at least 8 KiB of memory to function)
ARGON2_TIME_COST_MIN: Final[int] = 1
ARGON2_PARALLELISM_MIN: Final[int] = 1
ARGON2_MEMORY_KIB_PER_LANE_MIN: Final[int] = 8
# =============================================================================
# AES-256-GCM — Symmetric encryption parameters
# =============================================================================
# AES-256-GCM is the encryption algorithm we use to scramble vault
# contents. "256" means a 256-bit key. "GCM" is a "mode" that adds
# tamper-detection — if anyone changes one byte of the ciphertext,
# decryption will refuse and raise an error
# Key size in bytes. AES-256 wants exactly 32 bytes (256 bits)
# This is also what we ask Argon2 to produce when deriving the key
KEY_LENGTH_BYTES: Final[int] = 32
# Nonce ("number used once") size in bytes
# A nonce is a random value generated FRESH for every encryption
# Reusing a nonce with the same key is catastrophic for GCM —
# it leaks plaintext. So we generate a new 12-byte nonce every save
# 12 bytes is the GCM-recommended size (NIST SP 800-38D)
NONCE_LENGTH_BYTES: Final[int] = 12
# =============================================================================
# Vault file format
# =============================================================================
# The vault is stored as a single JSON file. The structure looks
# roughly like this (base64 fields shown as <...>)
#
# {
# "version": 1,
# "kdf": {
# "name": "argon2id",
# "salt": "<base64>",
# "time_cost": 3,
# "memory_cost": 65536,
# "parallelism": 4
# },
# "cipher": {
# "name": "aes-256-gcm",
# "nonce": "<base64>",
# "ciphertext": "<base64>"
# }
# }
#
# Storing kdf params IN the file (not just in code) lets us bump
# defaults later without breaking old vaults — the file says how
# it was encrypted, and we believe it
# Bump this when the on-disk format changes incompatibly
VAULT_FORMAT_VERSION: Final[int] = 1
# Top-level JSON keys
VAULT_KEY_VERSION: Final[str] = "version"
VAULT_KEY_KDF: Final[str] = "kdf"
VAULT_KEY_CIPHER: Final[str] = "cipher"
# KDF section keys
KDF_KEY_NAME: Final[str] = "name"
KDF_KEY_SALT: Final[str] = "salt"
KDF_KEY_TIME_COST: Final[str] = "time_cost"
KDF_KEY_MEMORY_COST: Final[str] = "memory_cost"
KDF_KEY_PARALLELISM: Final[str] = "parallelism"
# Cipher section keys
CIPHER_KEY_NAME: Final[str] = "name"
CIPHER_KEY_NONCE: Final[str] = "nonce"
CIPHER_KEY_CIPHERTEXT: Final[str] = "ciphertext"
# Algorithm names written into the file (for self-documentation
# and so future versions can switch without breaking old vaults)
KDF_NAME_ARGON2ID: Final[str] = "argon2id"
CIPHER_NAME_AES_256_GCM: Final[str] = "aes-256-gcm"
# File mode: 0o600 means "owner can read+write, nobody else can
# touch it." Octal in Python is written 0o<digits>. We set this on
# the vault file the moment we create it
VAULT_FILE_MODE: Final[int] = 0o600
# Default location: ~/.password-vault/vault.json
# Path.home() resolves to /home/<user> on Linux, C:/Users/<user>
# on Windows, /Users/<user> on macOS
DEFAULT_VAULT_DIRECTORY: Final[Path] = Path.home() / ".password-vault"
DEFAULT_VAULT_FILENAME: Final[str] = "vault.json"
DEFAULT_VAULT_PATH: Final[Path] = (
DEFAULT_VAULT_DIRECTORY / DEFAULT_VAULT_FILENAME
)
# =============================================================================
# Password generator defaults
# =============================================================================
# When the user runs `pv gen` to generate a random password, these
# are the defaults. Everything is overridable on the command line
# Default length when the user does not specify one
DEFAULT_GENERATED_PASSWORD_LENGTH: Final[int] = 24
# Minimum length we will allow. Passwords shorter than this are
# weak enough to brute-force in reasonable time
MINIMUM_GENERATED_PASSWORD_LENGTH: Final[int] = 8
# Minimum length for the MASTER password (the one that locks the
# whole vault). This is a floor, not a recommendation — users
# should pick something much longer. We reject anything shorter
# than this to prevent the obvious footgun of an empty or trivial
# master password silently "encrypting" the vault under no real
# secret. 8 mirrors NIST SP 800-63B's minimum for memorized secrets
MINIMUM_MASTER_PASSWORD_LENGTH: Final[int] = 8
# Character pools the generator can pick from
LOWERCASE_LETTERS: Final[str] = "abcdefghijklmnopqrstuvwxyz"
UPPERCASE_LETTERS: Final[str] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
DIGITS: Final[str] = "0123456789"
# Symbols deliberately exclude characters that confuse copy-paste
# (quotes, backticks) or get eaten by shells (backslash, dollar)
SAFE_SYMBOLS: Final[str] = "!@#$%^&*()-_=+[]{};:,.<>/?"
# =============================================================================
# CLI prompt and message strings
# =============================================================================
# Putting human-readable text here means we can tweak wording without
# hunting through source code, and it sets us up for future
# translation if we ever want to ship a non-English version
PROMPT_MASTER_PASSWORD: Final[str] = "Master password: "
PROMPT_MASTER_PASSWORD_NEW: Final[str] = "New master password: "
PROMPT_MASTER_PASSWORD_CONFIRM: Final[str] = "Confirm master password: "
PROMPT_ENTRY_PASSWORD: Final[str] = "Password for {entry}: "
PROMPT_ENTRY_USERNAME: Final[str] = "Username for {entry}: "
PROMPT_ENTRY_URL: Final[str] = "URL (optional, press Enter to skip): "
PROMPT_ENTRY_NOTES: Final[str] = "Notes (optional, press Enter to skip): "
MSG_VAULT_CREATED: Final[str] = "Vault created at {path}"
MSG_VAULT_ALREADY_EXISTS: Final[str] = "Vault already exists at {path}"
MSG_VAULT_NOT_FOUND: Final[str] = (
"No vault at {path}. Run `pv init` to create one"
)
MSG_ENTRY_ADDED: Final[str] = "Added entry: {name}"
MSG_ENTRY_DELETED: Final[str] = "Deleted entry: {name}"
MSG_ENTRY_NOT_FOUND: Final[str] = "No entry named: {name}"
MSG_ENTRY_ALREADY_EXISTS: Final[str] = (
"Entry already exists: {name}. Use --force to overwrite"
)
MSG_PASSWORDS_DO_NOT_MATCH: Final[str] = "Passwords did not match"
MSG_WRONG_MASTER_PASSWORD: Final[str] = (
"Wrong master password (or vault file is corrupted)"
)
MSG_VAULT_EMPTY: Final[str] = "Vault is empty. Add an entry with `pv add`"
MSG_MASTER_PASSWORD_EMPTY: Final[str] = ("Master password cannot be empty")
MSG_MASTER_PASSWORD_TOO_SHORT: Final[str] = (
"Master password must be at least {minimum} characters"
)
MSG_MASTER_PASSWORD_CHANGED: Final[str] = (
"Master password changed. Vault re-encrypted at {path}"
)

View File

@ -0,0 +1,428 @@
"""
©AngelaMos | 2026
crypto.py
All the cryptography for the password manager lives in this one file
"Cryptography" is just the math we use to scramble data so nobody can
read it without the right key. Two big ideas live in this file, and
together they protect the user's vault
Idea 1: Key Derivation turning a password into a key
When the user types their master password, we cannot use it directly
as an encryption key. Two reasons
1. Passwords are short and predictable. A real key needs to be 256
bits of randomness, but "hunter2" is maybe 50 bits at best
2. If we used the password directly, an attacker who got their
hands on the encrypted vault could try millions of passwords
per second on a GPU until one worked
The fix is a Key Derivation Function (KDF). It takes the password
plus a random "salt" and runs them through a deliberately slow,
memory-hungry algorithm to produce a key. We use Argon2id the
algorithm that won the 2015 Password Hashing Competition and is the
modern standard recommended by OWASP
Why slow on purpose? Because the legitimate user only does this ONCE
per session. An attacker has to do it for every password they guess.
Even a half-second delay multiplied by billions of guesses makes
brute-force impractical
Idea 2: Authenticated Encryption locking and tamper-proofing
Once we have a key, we use it to encrypt the vault contents. We use
AES-256-GCM. AES is the encryption algorithm itself. GCM is a "mode"
that does two jobs at once
1. Confidentiality scrambles the data so nobody can read it
without the key
2. Authenticity stamps the result with a tamper-proof seal. If
anyone changes one byte of the ciphertext (or even the nonce),
decryption refuses and raises an error
Without GCM (or another authenticated mode), an attacker could flip
bits in the encrypted file in ways that flip bits in the decrypted
plaintext, even without knowing the key. GCM defeats that
What this file exposes
derive_key(...) Argon2id: master password + salt 32-byte key
generate_salt() fresh random salt for a new vault
generate_nonce() fresh random nonce for every encryption
encrypt(plaintext, key) AES-256-GCM scramble
decrypt(ciphertext, ...) AES-256-GCM unscramble (raises on tampering)
WrongPasswordError exception raised when decryption fails
Connects to
vault.py calls these functions to encrypt and decrypt vault data
constants.py pulls KDF and cipher parameters from here
"""
# Standard library: cryptographically-secure random bytes — used
# here to generate fresh salts and nonces. NEVER use `random` for
# anything security-related.
import secrets
# Standard library: a decorator that turns a class into a small,
# immutable data record without writing `__init__` boilerplate.
from dataclasses import dataclass
# Third-party (argon2-cffi): the password-hashing function we use
# as our KDF (key-derivation function). `Type` selects the Argon2id
# variant; `hash_secret_raw` returns raw key bytes (no PHC string).
from argon2.low_level import Type, hash_secret_raw
# Third-party (cryptography): the specific exception raised when
# AES-GCM decryption fails its authentication check — we catch it
# and turn it into our own WrongPasswordError.
from cryptography.exceptions import InvalidTag
# Third-party (cryptography): authenticated symmetric encryption.
# AES-GCM gives us both confidentiality AND tamper detection in one.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Local: pull every magic number from the constants module — sizes
# and Argon2 cost parameters all live there, never in this file.
from password_manager.constants import (
ARGON2_MEMORY_KIB,
ARGON2_PARALLELISM,
ARGON2_TIME_COST,
KEY_LENGTH_BYTES,
NONCE_LENGTH_BYTES,
SALT_LENGTH_BYTES,
)
# =============================================================================
# Custom exceptions — give meaningful names to the errors we raise
# =============================================================================
# Defining our own exception classes (instead of just raising plain
# `Exception`) lets callers handle them precisely. The CLI layer can
# say "if WrongPasswordError, print a friendly message" without
# accidentally catching unrelated errors
class CryptoError(Exception):
"""
Base class for every cryptography error we raise
Catching this catches every error from this module
"""
class WrongPasswordError(CryptoError):
"""
Raised when decryption fails
GCM authentication failure means one of three things, and we
cannot tell which without more context
1. The user typed the wrong master password
2. The vault file was tampered with
3. The vault file is corrupted
From the user's perspective, all three look the same: "I cannot
open this vault." So we raise a single error and let the CLI
show a single, honest message
"""
# =============================================================================
# KDF parameters — bundled so we can pass them around as one value
# =============================================================================
@dataclass(frozen = True, slots = True)
class KdfParameters:
"""
The Argon2id tuning knobs used to derive a key
`frozen=True` makes instances immutable once created, they
cannot be modified. `slots=True` makes them lightweight in
memory. These two flags together give us a "value object" a
bundle of fields that behaves like a single value
Why bundle them? Because the vault file stores these parameters
alongside the ciphertext. If we change the defaults later, old
vaults still decrypt correctly because they remember their own
parameters
Fields
------
time_cost
Number of passes Argon2 makes. Higher = slower = harder to crack
memory_cost
Memory in KiB. Higher = harder to attack with GPUs
parallelism
Threads Argon2 may use. Should match available cores
"""
time_cost: int
memory_cost: int
parallelism: int
@classmethod
def defaults(cls) -> "KdfParameters":
"""
Return the current recommended Argon2id parameters
The values live in constants.py and are informed by the OWASP
Password Storage Cheat Sheet. They are tuned for a single-user
local password manager (parallelism=4 instead of the
server-oriented parallelism=1) see constants.py for the
full reasoning
"""
return cls(
time_cost = ARGON2_TIME_COST,
memory_cost = ARGON2_MEMORY_KIB,
parallelism = ARGON2_PARALLELISM,
)
# =============================================================================
# Random byte generation — salts and nonces
# =============================================================================
# Both salts and nonces need to be unpredictable. We use the `secrets`
# module from the Python standard library. It pulls bytes from the
# operating system's cryptographically secure random source
# (/dev/urandom on Linux, BCryptGenRandom on Windows)
#
# DO NOT use `random.randbytes()` for this. The `random` module is
# fast but predictable — given enough output, an attacker can predict
# future values. The `secrets` module is built for exactly this case
def generate_salt() -> bytes:
"""
Return SALT_LENGTH_BYTES of fresh, unpredictable random bytes
The salt is mixed in with the password before key derivation, so
that two users picking the same password get DIFFERENT keys. It
also defeats "rainbow table" attacks where an attacker
pre-computes a giant lookup table of common-password key
The salt is NOT secret we store it in plain text inside the
vault file. Its job is to be unique, not hidden
Returns
-------
bytes
A new random byte string of length SALT_LENGTH_BYTES (16 bytes)
"""
# secrets.token_bytes(n) returns n random bytes from the OS-level
# cryptographic random pool. Same call we would use to generate
# session tokens or API keys
return secrets.token_bytes(SALT_LENGTH_BYTES)
def generate_nonce() -> bytes:
"""
Return NONCE_LENGTH_BYTES of fresh, unpredictable random bytes
A nonce ("number used once") is generated FRESH for every single
encryption. Reusing a nonce with the same key in GCM mode is
catastrophic it leaks plaintext to anyone watching. So we
generate a new one every time we save the vault
GCM allows nonces up to 2^32 messages safely with random 12-byte
nonces, which is far more vault saves than any human will ever
perform. So random generation is fine here
Returns
-------
bytes
A new random byte string of length NONCE_LENGTH_BYTES (12 bytes)
"""
return secrets.token_bytes(NONCE_LENGTH_BYTES)
# =============================================================================
# Key derivation — the slow part
# =============================================================================
def derive_key(
master_password: str,
salt: bytes,
parameters: KdfParameters | None = None,
) -> bytes:
"""
Turn a master password and a salt into a 32-byte encryption key
This is the SLOW step. On a modern laptop with the default
parameters, expect about 0.31 second per call. That is on
purpose it is the cost an attacker must pay for every guess
The output is suitable as input to AES-256-GCM (which wants
exactly 32 bytes of key material)
Parameters
----------
master_password
What the user typed at the prompt, as a normal Python string
salt
The vault's salt (16 random bytes). Passing the SAME password
and SAME salt always produces the SAME key, which is how we
re-derive the key when the user comes back later
parameters
Argon2id tuning. Defaults to the project's recommended values
(see KdfParameters.defaults). The vault file stores whatever
was used so old vaults keep working when defaults change
Returns
-------
bytes
A 32-byte (256-bit) key, ready to hand to AES-256-GCM
Notes
-----
Argon2 needs BYTES, not strings. Cryptographic libraries always
work in raw bytes because that is the natural form for hashing
and arithmetic. We encode the password to UTF-8 bytes here
"""
# Refuse an empty password outright. Argon2 itself would happily
# derive a key from b"" + the salt, but the resulting "lock" has
# effectively no secret in it — anyone who steals the vault file
# can re-derive the same key from the public salt. We treat this
# as a programming error and raise loudly
if not master_password:
raise ValueError("master_password must not be empty")
# Default to recommended parameters if the caller did not specify.
# We do this lazily (`is None` check) instead of using
# `parameters = KdfParameters.defaults()` as a default argument,
# because Python evaluates default arguments ONCE at function
# definition time, which would make the default a singleton.
# That is fine for an immutable dataclass, but the lazy pattern
# is the safer general habit
if parameters is None:
parameters = KdfParameters.defaults()
# Strings live in memory as Unicode code points. Argon2 needs raw
# bytes. .encode("utf-8") is the standard way to convert
password_bytes = master_password.encode("utf-8")
# hash_secret_raw is the low-level Argon2 function. The argon2-cffi
# library also offers a high-level PasswordHasher that produces a
# formatted string — we do NOT want that here. We want the raw
# 32 bytes of key material to feed into AES
return hash_secret_raw(
secret = password_bytes,
salt = salt,
time_cost = parameters.time_cost,
memory_cost = parameters.memory_cost,
parallelism = parameters.parallelism,
hash_len = KEY_LENGTH_BYTES,
# Type.ID = Argon2id, the recommended variant
# Argon2id resists both side-channel attacks (Argon2i strength)
# and GPU-cracking attacks (Argon2d strength) — best of both
type = Type.ID,
)
# =============================================================================
# Symmetric encryption — AES-256-GCM
# =============================================================================
# "Symmetric" means the same key is used to encrypt AND decrypt
# (as opposed to public-key crypto where the keys differ)
#
# We use the high-level AESGCM class from the cryptography library.
# It bundles the cipher, the authentication tag, and constant-time
# tag verification into a single API. Using AES-GCM "by hand" with
# raw primitives is one of the easier ways to get cryptography wrong
def encrypt(plaintext: bytes, key: bytes) -> tuple[bytes, bytes]:
"""
Encrypt plaintext bytes with AES-256-GCM and return (nonce, ciphertext)
A new random nonce is generated for every call. The caller MUST
store the nonce alongside the ciphertext without it, decryption
is impossible
The "ciphertext" returned actually contains both the encrypted
data AND a 16-byte authentication tag appended to the end. The
AESGCM library handles this concatenation automatically. The
tag is what makes the encryption tamper-evident
Parameters
----------
plaintext
Raw bytes to encrypt. Anything JSON-encoded vault entries,
plain text, an image. The cipher does not care
key
The 32-byte key from derive_key. Wrong size raises ValueError
Returns
-------
tuple[bytes, bytes]
(nonce, ciphertext_with_tag). Both must be stored to allow
later decryption
"""
# Construct a cipher object bound to our key. AESGCM validates
# the key size: must be 16, 24, or 32 bytes (AES-128, 192, or 256)
cipher = AESGCM(key)
# Fresh nonce for every encryption — never reuse with the same key
nonce = generate_nonce()
# encrypt() returns ciphertext concatenated with the auth tag.
# `associated_data` is for data we want authenticated but NOT
# encrypted (like a packet header). We do not have any, so None
ciphertext = cipher.encrypt(
nonce = nonce,
data = plaintext,
associated_data = None,
)
return nonce, ciphertext
def decrypt(ciphertext: bytes, nonce: bytes, key: bytes) -> bytes:
"""
Decrypt ciphertext produced by encrypt() and verify it was not tampered with
If the key is wrong, the nonce is wrong, or anyone modified even
one byte of the ciphertext, the GCM authentication tag will not
validate and we raise WrongPasswordError. We do NOT distinguish
between "wrong password" and "tampered file" they look the same
cryptographically and exposing the difference helps attackers
Parameters
----------
ciphertext
The output from encrypt() encrypted data + 16-byte auth tag
nonce
The same nonce used during encryption
key
The 32-byte key derived from the master password
Returns
-------
bytes
The original plaintext bytes
Raises
------
WrongPasswordError
If decryption fails for any reason (wrong key, wrong nonce,
modified ciphertext, corruption)
"""
cipher = AESGCM(key)
# InvalidTag is the cryptography library's signal that the auth
# tag did not match. We catch it and re-raise as our own error
# so callers do not need to know about cryptography internals
try:
return cipher.decrypt(
nonce = nonce,
data = ciphertext,
associated_data = None,
)
except InvalidTag as exc:
# `from exc` preserves the original traceback for debugging
# while still raising our cleaner exception type
raise WrongPasswordError(
"Decryption failed: wrong master password or corrupted vault"
) from exc

View File

@ -0,0 +1,170 @@
"""
©AngelaMos | 2026
generator.py
Cryptographically secure random password generation
A password generator is just a function that picks N random
characters from a pool. The interesting part is HOW you pick them
Why `secrets`, not `random`
Python has TWO modules that produce random numbers
random fast, predictable, fine for games and simulations
secrets slow-ish, unpredictable, made for cryptography
If we used `random.choice` to pick characters, an attacker who saw
ONE password could deduce the internal state of the random generator
and predict every other password it produced. The `secrets` module
pulls bytes from the operating system's cryptographic source, which
is unpredictable by design
Rule of thumb: any time the output is meant to be hard to guess by
humans OR computers, use `secrets`
What this module exposes
generate_password(length, ...) return a random password string
PasswordTooShortError raised when length is below the floor
Connects to
main.py the `pv gen` command and the `pv add` command call this
constants.py pulls character pools and length defaults from here
"""
# Standard library: cryptographically-secure random — we pick each
# character from the alphabet with `secrets.choice`. Plain `random`
# would be predictable to an attacker and unsafe for passwords.
import secrets
# Local: pull every character pool and length default from constants —
# no magic strings or numbers ever live in this file.
from password_manager.constants import (
DEFAULT_GENERATED_PASSWORD_LENGTH,
DIGITS,
LOWERCASE_LETTERS,
MINIMUM_GENERATED_PASSWORD_LENGTH,
SAFE_SYMBOLS,
UPPERCASE_LETTERS,
)
class PasswordTooShortError(ValueError):
"""
Raised when the requested password length is below the safe minimum
"""
def generate_password(
length: int = DEFAULT_GENERATED_PASSWORD_LENGTH,
*,
use_lowercase: bool = True,
use_uppercase: bool = True,
use_digits: bool = True,
use_symbols: bool = True,
) -> str:
"""
Return a random password of the given length
All character-pool flags after `length` are keyword-only (the *
in the signature). That keeps call sites readable
generate_password(20, use_symbols=False)
is much clearer than
generate_password(20, True, True, True, False)
The function GUARANTEES at least one character from each enabled
pool without this, a 12-character password might randomly end
up as all lowercase, which fails most "must contain a digit"
rules even though it is technically random
Parameters
----------
length
How many characters in the result. Must be at least
MINIMUM_GENERATED_PASSWORD_LENGTH. Must also be at least
as large as the number of enabled pools (we have to fit
one character from each)
use_lowercase, use_uppercase, use_digits, use_symbols
Which character pools to draw from. At least one must be True
Returns
-------
str
A random password of exactly `length` characters
Raises
------
PasswordTooShortError
If `length` is below the floor or below the number of pools
ValueError
If every pool flag is False
"""
if length < MINIMUM_GENERATED_PASSWORD_LENGTH:
raise PasswordTooShortError(
f"Password length must be >= "
f"{MINIMUM_GENERATED_PASSWORD_LENGTH}, got {length}"
)
# Build the lookup of enabled pools so we can pick one char from
# each. This dict comprehension only includes pools whose flag is
# True — any False flag is left out entirely
enabled_pools = {
"lower": LOWERCASE_LETTERS if use_lowercase else "",
"upper": UPPERCASE_LETTERS if use_uppercase else "",
"digit": DIGITS if use_digits else "",
"symbol": SAFE_SYMBOLS if use_symbols else "",
}
enabled_pools = {k: v for k, v in enabled_pools.items() if v}
if not enabled_pools:
raise ValueError("At least one character pool must be enabled")
if length < len(enabled_pools):
raise PasswordTooShortError(
f"length={length} is too small to include one character "
f"from each of {len(enabled_pools)} enabled pools"
)
alphabet = "".join(enabled_pools.values())
# Step 1: take ONE character from each enabled pool, guaranteeing
# the password contains at least one of each kind
required = [secrets.choice(pool) for pool in enabled_pools.values()]
# Step 2: fill the rest from the combined alphabet
fill_count = length - len(required)
fill = [secrets.choice(alphabet) for _ in range(fill_count)]
# Step 3: combine and shuffle. Shuffling matters — without it, the
# required characters would always be at positions 0..N-1
chars = required + fill
_secure_shuffle(chars)
return "".join(chars)
def _secure_shuffle(items: list[str]) -> None:
"""
Shuffle a list in place using a cryptographically secure source
The standard library's random.shuffle uses the predictable Mersenne
Twister. We implement Fisher-Yates (also called Knuth shuffle) on
top of secrets.randbelow so the order is unpredictable
Mutates `items` in place returns None
"""
# Fisher-Yates: walk the list from the end to the beginning, and
# at each position swap the current item with a randomly chosen
# earlier item (or itself). This produces a uniform random
# permutation if the random source is uniform — and secrets is
for i in range(len(items) - 1, 0, -1):
# randbelow(n) returns 0..n-1 uniformly. We need 0..i inclusive,
# so we ask for i+1
j = secrets.randbelow(i + 1)
items[i], items[j] = items[j], items[i]

View File

@ -0,0 +1,542 @@
"""
©AngelaMos | 2026
main.py
CLI entry point wires the user's keyboard to the vault
Everything below is glue: take the arguments the user typed, prompt
for the master password without echoing it, call the right method on
UnlockedVault, and print results in a friendly format. The actual
work happens in vault.py and crypto.py
Why Typer
Typer turns a regular Python function into a CLI command, just by
reading its type hints and docstrings. Compare these two ways of
saying "add a `--length` option that defaults to 24"
Manual argparse:
parser.add_argument("--length", type=int, default=24,
help="Password length")
Typer:
length: Annotated[int, typer.Option(help="Password length")] = 24
Typer also generates --help text, validates types automatically, and
plays well with rich for colorful output
Master password handling
We use getpass.getpass() so the password never appears on screen as
the user types. We never accept the master password as a CLI flag
that would leak it into shell history (`history` command) and into
process listings (`ps`). Pass-through-stdin is fine for scripting
Commands exposed
init create a new empty vault
add <name> add an entry (prompts for fields)
get <name> show an entry's details
list list every entry name
delete <name> remove an entry
change-password rotate the master password (re-encrypts vault)
gen generate a random password (no vault touched)
Connects to
vault.py instantiates UnlockedVault for read/write operations
generator.py used by `gen` and offered inside `add`
constants.py pulls prompt strings and default vault path
"""
# Standard library: reads a password from the terminal WITHOUT
# echoing the characters as the user types — same trick `sudo` uses.
import getpass
# Standard library: object-oriented filesystem paths — safer and
# more readable than gluing strings with `os.path.join`.
from pathlib import Path
# Standard library: lets us attach extra metadata (Typer Option/Argument
# specs) to a parameter's type hint without changing its type.
from typing import Annotated
# Third-party (Typer): the CLI framework. Turns a regular function
# into a subcommand with parsed args, help text, and auto-completion.
import typer
# Third-party (rich): the printer that draws colored output to the
# terminal — every user-facing message goes through this.
from rich.console import Console
# Third-party (rich): draws a bordered box — used for the welcome
# banner and the "vault created" confirmation panel.
from rich.panel import Panel
# Third-party (rich): builds the colored ASCII table that lists
# vault entries in the `list` command.
from rich.table import Table
# Local: pull every prompt string, error message, and default value
# from constants — main.py never holds magic strings of its own.
from password_manager.constants import (
DEFAULT_GENERATED_PASSWORD_LENGTH,
DEFAULT_VAULT_PATH,
MINIMUM_MASTER_PASSWORD_LENGTH,
MSG_ENTRY_ADDED,
MSG_ENTRY_ALREADY_EXISTS,
MSG_ENTRY_DELETED,
MSG_ENTRY_NOT_FOUND,
MSG_MASTER_PASSWORD_CHANGED,
MSG_MASTER_PASSWORD_EMPTY,
MSG_MASTER_PASSWORD_TOO_SHORT,
MSG_PASSWORDS_DO_NOT_MATCH,
MSG_VAULT_ALREADY_EXISTS,
MSG_VAULT_CREATED,
MSG_VAULT_EMPTY,
MSG_VAULT_NOT_FOUND,
MSG_WRONG_MASTER_PASSWORD,
PROMPT_ENTRY_NOTES,
PROMPT_ENTRY_URL,
PROMPT_ENTRY_USERNAME,
PROMPT_MASTER_PASSWORD,
PROMPT_MASTER_PASSWORD_CONFIRM,
PROMPT_MASTER_PASSWORD_NEW,
)
# Local: the one crypto-layer error we want to translate into a
# friendly "wrong master password" message for the user.
from password_manager.crypto import WrongPasswordError
# Local: the password generator — its custom error type and the
# function that actually builds a random password.
from password_manager.generator import (
PasswordTooShortError,
generate_password,
)
# Local: every vault-layer name we need — the Entry record, the
# UnlockedVault class, and every domain-specific error we catch.
from password_manager.vault import (
Entry,
EntryAlreadyExistsError,
EntryNotFoundError,
UnlockedVault,
VaultAlreadyExistsError,
VaultError,
VaultFormatError,
VaultNotFoundError,
)
# =============================================================================
# Typer app + consoles — module-level singletons
# =============================================================================
# Typer.app is the registry every @app.command() decorator attaches to.
# rich.Console handles colorful output. Both are created once and
# reused by every command
#
# We keep TWO consoles, one for each output stream. The convention
# is universal in CLI tools
#
# stdout — the "result" of the command. Pipe-safe. Capturable
# stderr — diagnostics, errors, progress. Always shown to the user
#
# Splitting them lets users redirect cleanly. `pv gen 32 | pbcopy`
# pipes ONLY the password into the clipboard, even if pv prints an
# error. `pv get foo 2>/dev/null` swallows error chatter without
# also swallowing the panel of credentials
app = typer.Typer(
name = "pv",
help = "Encrypted password manager (Argon2id + AES-256-GCM)",
no_args_is_help = True,
add_completion = False,
)
console = Console()
error_console = Console(stderr = True)
# =============================================================================
# Shared option type — every command takes --vault
# =============================================================================
# Annotated[T, typer.Option(...)] is how Typer reads option metadata
# without polluting the function signature. The Annotated wrapper is
# fully transparent at runtime — it only matters to Typer at startup
#
# We define the type alias once so every command takes the same flag
VaultPath = Annotated[
Path,
typer.Option(
"--vault",
"-v",
help = "Path to the vault file",
envvar = "PV_VAULT",
),
]
# =============================================================================
# Helpers — keep command bodies focused on flow, not plumbing
# =============================================================================
def _prompt_master_password(prompt: str = PROMPT_MASTER_PASSWORD) -> str:
"""
Read a master password from the terminal without echoing it
Wraps getpass so we can swap implementations later (e.g. read
from stdin in non-interactive scripts) without touching every
command. getpass falls back to a noisy "echo enabled" warning
if the terminal does not support hidden input that is the
library's behavior, not ours
"""
return getpass.getpass(prompt)
def _prompt_master_password_with_confirmation() -> str:
"""
Prompt for a new master password twice, validate it, and return it
Used by `init` and `change-password` to set or rotate the master
password. Three checks happen before the password is returned
1. Non-empty an empty password "encrypts" the vault under no
real secret. Anyone who steals the file can re-derive the
same key from the public salt
2. At least MINIMUM_MASTER_PASSWORD_LENGTH characters a hard
floor below which we refuse to proceed
3. Confirmation match both prompts must produce the same
string, so a typo does not lock the user out of their vault
the first time they try to unlock it
Exits with code 1 on any of the above. The caller does not have
to handle these cases by the time this returns, the password
is known good
"""
first = _prompt_master_password(PROMPT_MASTER_PASSWORD_NEW)
if not first:
error_console.print(f"[red]{MSG_MASTER_PASSWORD_EMPTY}[/red]")
raise typer.Exit(code = 1)
if len(first) < MINIMUM_MASTER_PASSWORD_LENGTH:
error_console.print(
f"[red]"
f"{MSG_MASTER_PASSWORD_TOO_SHORT.format(minimum=MINIMUM_MASTER_PASSWORD_LENGTH)}"
f"[/red]"
)
raise typer.Exit(code = 1)
second = _prompt_master_password(PROMPT_MASTER_PASSWORD_CONFIRM)
if first != second:
error_console.print(f"[red]{MSG_PASSWORDS_DO_NOT_MATCH}[/red]")
raise typer.Exit(code = 1)
return first
def _unlock_or_exit(path: Path, master_password: str) -> UnlockedVault:
"""
Open a vault, exiting cleanly on every kind of failure
Each error gets the right message and the right exit code.
`typer.Exit(code=N)` raises an exception that Typer turns into
`sys.exit(N)` cleanly we never call sys.exit ourselves
Errors go through error_console (stderr); informational and
success messages go through console (stdout). That split is
what makes the CLI pipe-friendly
"""
try:
return UnlockedVault.unlock(path, master_password)
except VaultNotFoundError:
error_console.print(
f"[red]{MSG_VAULT_NOT_FOUND.format(path=path)}[/red]"
)
raise typer.Exit(code = 1) from None
except WrongPasswordError:
error_console.print(f"[red]{MSG_WRONG_MASTER_PASSWORD}[/red]")
raise typer.Exit(code = 1) from None
except VaultFormatError as exc:
error_console.print(f"[red]Vault file is invalid: {exc}[/red]")
raise typer.Exit(code = 1) from None
except VaultError as exc:
error_console.print(f"[red]Vault error: {exc}[/red]")
raise typer.Exit(code = 1) from None
def _render_entry(name: str, entry: Entry) -> Panel:
"""
Format an entry as a rich Panel for terminal display
A Panel is a bordered box. We list each field on its own line and
let the terminal handle long values. The password is shown
verbatim this is a CLI tool, the user already trusts the screen
"""
body_lines = [
f"[bold]username[/bold] {entry.username}",
f"[bold]password[/bold] {entry.password}",
]
if entry.url:
body_lines.append(f"[bold]url[/bold] {entry.url}")
if entry.notes:
body_lines.append(f"[bold]notes[/bold] {entry.notes}")
body_lines.append(f"[dim]created {entry.created_at}[/dim]")
body_lines.append(f"[dim]updated {entry.updated_at}[/dim]")
return Panel(
"\n".join(body_lines),
title = name,
border_style = "cyan",
)
# =============================================================================
# Commands
# =============================================================================
# Each @app.command decorates a function as a CLI command. The
# function name becomes the command name (init → `pv init`)
@app.command()
def init(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
"""
Create a new empty vault at --vault (or PV_VAULT or default path)
"""
# The pre-check is a UX nicety: it lets us refuse without
# prompting for a password we would only throw away. The check
# inside create() is the AUTHORITATIVE one — between this check
# and the prompts finishing, another process could have created
# the vault, and we still need to handle that race
if vault.exists():
error_console.print(
f"[red]{MSG_VAULT_ALREADY_EXISTS.format(path=vault)}[/red]"
)
raise typer.Exit(code = 1)
master = _prompt_master_password_with_confirmation()
try:
# The create() call writes the empty vault and returns an
# UnlockedVault. We have nothing else to do with it, so we
# use `with` purely to drop the AES key right away
with UnlockedVault.create(vault, master):
pass
except VaultAlreadyExistsError:
error_console.print(
f"[red]{MSG_VAULT_ALREADY_EXISTS.format(path=vault)}[/red]"
)
raise typer.Exit(code = 1) from None
console.print(f"[green]{MSG_VAULT_CREATED.format(path=vault)}[/green]")
@app.command(name = "list")
def list_entries(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
"""
Print every entry name in the vault, one per line
"""
master = _prompt_master_password()
# `with` ensures the AES key and plaintext entries are dropped
# as soon as the table has been printed. We render INSIDE the
# block because we still need to read the entries
with _unlock_or_exit(vault, master) as unlocked:
names = unlocked.names()
if not names:
console.print(f"[yellow]{MSG_VAULT_EMPTY}[/yellow]")
return
table = Table(title = f"Entries in {vault}", show_lines = False)
table.add_column("name", style = "cyan", no_wrap = True)
table.add_column("username", style = "white")
table.add_column("updated", style = "dim")
for name in names:
entry = unlocked.entries[name]
table.add_row(name, entry.username, entry.updated_at)
console.print(table)
@app.command()
def get(
name: Annotated[str,
typer.Argument(help = "Entry name to retrieve")],
vault: VaultPath = DEFAULT_VAULT_PATH,
) -> None:
"""
Show every field of one entry by name
"""
master = _prompt_master_password()
with _unlock_or_exit(vault, master) as unlocked:
try:
entry = unlocked.get_entry(name)
except EntryNotFoundError:
error_console.print(
f"[red]{MSG_ENTRY_NOT_FOUND.format(name=name)}[/red]"
)
raise typer.Exit(code = 1) from None
# entry is a frozen Entry instance — its fields remain
# readable after the vault closes, but we still render
# inside the block to keep the lifecycle obvious
console.print(_render_entry(name, entry))
@app.command()
def add(
name: Annotated[str,
typer.Argument(help = "Entry name (must be unique)")],
vault: VaultPath = DEFAULT_VAULT_PATH,
force: Annotated[
bool,
typer.Option("--force",
"-f",
help = "Overwrite if exists"),
] = False,
generate: Annotated[
bool,
typer.Option(
"--generate",
"-g",
help = "Generate a random password instead of prompting",
),
] = False,
length: Annotated[
int,
typer.Option(
"--length",
"-n",
help = "Length when --generate is used",
),
] = DEFAULT_GENERATED_PASSWORD_LENGTH,
) -> None:
"""
Add (or overwrite with --force) an entry in the vault
"""
master = _prompt_master_password()
with _unlock_or_exit(vault, master) as unlocked:
# Collect entry fields. We use plain input() for
# username/url/notes because they are not secret — getpass
# for the entry's password
username = input(PROMPT_ENTRY_USERNAME.format(entry = name))
if generate:
try:
password = generate_password(length)
except PasswordTooShortError as exc:
error_console.print(f"[red]{exc}[/red]")
raise typer.Exit(code = 1) from None
console.print(f"[green]Generated password:[/green] {password}")
else:
password = _prompt_master_password(
f"Password for {name} (hidden): "
)
url = input(PROMPT_ENTRY_URL).strip()
notes = input(PROMPT_ENTRY_NOTES).strip()
entry = Entry(
username = username,
password = password,
url = url,
notes = notes,
)
try:
unlocked.add_entry(name, entry, force = force)
except EntryAlreadyExistsError:
error_console.print(
f"[red]{MSG_ENTRY_ALREADY_EXISTS.format(name=name)}[/red]"
)
raise typer.Exit(code = 1) from None
except ValueError as exc:
# Empty / whitespace entry name caught by add_entry's
# validation. Surface it as a clean error
error_console.print(f"[red]{exc}[/red]")
raise typer.Exit(code = 1) from None
unlocked.save()
console.print(f"[green]{MSG_ENTRY_ADDED.format(name=name)}[/green]")
@app.command()
def delete(
name: Annotated[str,
typer.Argument(help = "Entry name to delete")],
vault: VaultPath = DEFAULT_VAULT_PATH,
) -> None:
"""
Remove an entry by name
"""
master = _prompt_master_password()
with _unlock_or_exit(vault, master) as unlocked:
try:
unlocked.delete_entry(name)
except EntryNotFoundError:
error_console.print(
f"[red]{MSG_ENTRY_NOT_FOUND.format(name=name)}[/red]"
)
raise typer.Exit(code = 1) from None
unlocked.save()
console.print(f"[green]{MSG_ENTRY_DELETED.format(name=name)}[/green]")
@app.command()
def gen(
length: Annotated[
int,
typer.Argument(help = "Password length"),
] = DEFAULT_GENERATED_PASSWORD_LENGTH,
no_symbols: Annotated[
bool,
typer.Option("--no-symbols",
help = "Letters and digits only"),
] = False,
no_digits: Annotated[
bool,
typer.Option("--no-digits",
help = "Letters and symbols only"),
] = False,
no_uppercase: Annotated[
bool,
typer.Option("--no-uppercase",
help = "No uppercase letters"),
] = False,
) -> None:
"""
Print a fresh random password and exit (no vault required)
"""
try:
password = generate_password(
length,
use_lowercase = True,
use_uppercase = not no_uppercase,
use_digits = not no_digits,
use_symbols = not no_symbols,
)
except (PasswordTooShortError, ValueError) as exc:
error_console.print(f"[red]{exc}[/red]")
raise typer.Exit(code = 1) from None
# Plain print() so the output is pipe-friendly:
# PASSWORD=$(pv gen 32)
print(password)
@app.command(name = "change-password")
def change_password(vault: VaultPath = DEFAULT_VAULT_PATH) -> None:
"""
Change the master password (re-encrypts the vault end-to-end)
The whole reason the on-disk format stores the salt and KDF
parameters next to the ciphertext is so this operation is
possible. We unlock with the OLD password, derive a fresh salt
+ key from the NEW password, and save which re-encrypts every
entry under the new key. Old vault file content is replaced
atomically by the save() pattern, so a crash mid-rotation
leaves the user with either the old or the new vault, never
half of either
"""
current = _prompt_master_password("Current master password: ")
with _unlock_or_exit(vault, current) as unlocked:
new_password = _prompt_master_password_with_confirmation()
unlocked.change_master_password(new_password)
unlocked.save()
console.print(
f"[green]"
f"{MSG_MASTER_PASSWORD_CHANGED.format(path=vault)}"
f"[/green]"
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
"""
©AngelaMos | 2026
__init__.py
Why this file exists even though it is empty
The one-sentence version
The mere PRESENCE of a file named `__init__.py` in a folder tells
Python "this folder is a package — you may import from it". The
file does not need to contain anything
Why THIS one is empty (and the src one is not)
Two folders in this project both have `__init__.py`, but they use
it for different jobs
src/password_manager/__init__.py
Has CODE inside. It re-exports the most useful classes so
callers can write `from password_manager import UnlockedVault`
instead of digging into submodules. This is the package's
front door
tests/__init__.py <-- you are here, and it is EMPTY
Has no code. It exists only as a MARKER so Python and pytest
treat `tests/` as a package. That matters because some test
files do this
from tests.conftest import TEST_KDF_PARAMETERS
For `tests.conftest` to be a valid dotted import path,
`tests` has to be a package, which means this file has to
exist. The header docstring is the only content; everything
else is silence
Takeaway
`__init__.py` is dual-purpose
EXISTENCE "this folder is a package" (always, even when empty)
CONTENTS optional code that runs on first import (re-exports,
version constants, etc.)
A folder full of `.py` files with no `__init__.py` is just files;
add the `__init__.py` and it becomes something you can import as a
single unit
"""

View File

@ -0,0 +1,139 @@
"""
©AngelaMos | 2026
conftest.py
Shared pytest fixtures for the test suite
A "fixture" is a setup function pytest runs before a test that needs
it. The test asks for a fixture by listing its name as a parameter,
and pytest wires the result in automatically no manual `setUp` or
`tearDown` like older unittest-style frameworks. Fixtures defined in
conftest.py are auto-discovered and available to every test file in
the same directory, with no `import` statement needed
Why conftest.py specifically
pytest treats `conftest.py` as a magic filename. Anything defined
there is shared across every test file in the directory (and
subdirectories). This is the canonical place to put
- Shared fixtures (`@pytest.fixture` functions)
- Shared test constants (like TEST_KDF_PARAMETERS below)
- Test-suite-wide hooks (e.g. autouse fixtures)
Tests in test_crypto.py or test_vault.py can reference `vault_path`,
`master_password`, or `fresh_vault` by listing them as parameters,
and pytest finds them here automatically
Two costs we balance when choosing fixture scope
1. Time Argon2 derivation is intentionally slow (~0.3 sec/call
in production). If every test created a new vault with real
parameters, the suite would take minutes
2. Isolation tests must NOT share writable state, or one test
can corrupt another (especially dangerous with disk-based
tests where leftover files leak across runs)
We solve both: a "fast" KDF parameter set just for tests (so
derivation runs in milliseconds, not seconds) and a fresh temporary
vault path per test (`tmp_path` is a built-in pytest fixture that
auto-creates and auto-cleans a unique temp directory each time)
"""
# Standard library: object-oriented filesystem paths — used here as
# a type hint for the `tmp_path` fixture pytest hands us.
from pathlib import Path
# Third-party: the test runner. We need it here to declare fixtures
# with the `@pytest.fixture` decorator.
import pytest
# Local: the KDF parameter record — fixtures build a fast variant of
# this so tests do not wait seconds on real Argon2 work.
from password_manager.crypto import KdfParameters
# Local: the main vault class — one fixture spins up a freshly
# created vault that individual tests can mutate.
from password_manager.vault import UnlockedVault
# =============================================================================
# Fast Argon2 parameters — for tests only, never use these in production
# =============================================================================
# These values are well below OWASP recommendations but they cut test
# runtime from minutes to milliseconds. The cryptographic correctness
# of the code is the same regardless of parameter strength — Argon2
# does the same operations, just fewer of them
#
# memory_cost = 8 KiB is the absolute floor that Argon2 accepts when
# parallelism = 1 (the algorithm requires memory_cost >= 8 *
# parallelism — see the Argon2 spec). If you change parallelism here,
# bump memory_cost too or Argon2 will reject the parameter set
TEST_KDF_PARAMETERS = KdfParameters(
time_cost = 1,
memory_cost = 8,
parallelism = 1,
)
# =============================================================================
# Fixtures
# =============================================================================
# Each `@pytest.fixture` below defines a setup helper. Pytest sees a
# test like `def test_x(vault_path): ...` and matches the parameter
# name against a fixture name — when it finds one, the fixture runs
# first and its return value is wired into the test
@pytest.fixture
def vault_path(tmp_path: Path) -> Path:
"""
Provide a path to a fresh, non-existent vault file
`tmp_path` is a built-in pytest fixture that creates a fresh
empty temp directory for each test. Combined with our filename,
every test gets a unique vault path that is auto-cleaned after
the test ends. No global state, no cleanup code in test bodies
"""
return tmp_path / "test-vault.json"
@pytest.fixture
def master_password() -> str:
"""
A reasonable test master password
Pulled into a fixture so every test uses the same value and a
future refactor can swap it in one place. The xkcd-inspired
string is long enough that the empty-password check below
cannot accidentally accept it
"""
return "correct horse battery staple"
@pytest.fixture
def fresh_vault(
vault_path: Path,
master_password: str,
) -> UnlockedVault:
"""
Create an empty UnlockedVault using fast test KDF parameters
Notice we pass kdf_parameters explicitly into create() no
monkey-patching of `KdfParameters.defaults` anywhere. The
constructor takes what it needs as an argument, the test pays
no global-state tax, and the production code path is untouched
`fresh_vault` depends on TWO other fixtures (`vault_path` and
`master_password`). pytest figures out the dependency chain
automatically it builds them in the right order so this
fixture receives an already-resolved path and password
"""
return UnlockedVault.create(
vault_path,
master_password,
kdf_parameters = TEST_KDF_PARAMETERS,
)

View File

@ -0,0 +1,415 @@
"""
©AngelaMos | 2026
test_crypto.py
Tests for the crypto module KDF (Argon2id) and authenticated
encryption (AES-256-GCM)
These tests verify the cryptographic primitives in isolation. They
do not touch disk and they do not depend on the vault module. Each
test exercises ONE property of ONE function, so a failure points
directly at the broken piece
What is "the crypto" doing that we need to verify?
Two cryptographic ideas live in crypto.py
1. Key derivation turning a master password into a 32-byte AES
key. The KDF must be
deterministic so the user can come back tomorrow and
re-derive the SAME key from their password
salt-sensitive so two users with the same password get
DIFFERENT keys (defeats rainbow tables)
slow so brute-force is expensive
We test all three properties below
2. Authenticated encryption encrypting bytes with AES-256-GCM in
a way that ANY tampering (wrong key, modified ciphertext, or
modified nonce) causes decryption to refuse. GCM gives us this
for free; we verify each failure mode in turn
Why TEST_KDF_PARAMETERS exists
Production Argon2 parameters are expensive on purpose (~300 ms per
derivation). Running 20 tests at that cost would mean a 6-second
test suite, which discourages running tests during development.
TEST_KDF_PARAMETERS (in conftest.py) uses the absolute minimum
Argon2-acceptable values derivation finishes in milliseconds while
still exercising the same code path. Crypto correctness does not
depend on parameter strength
A short pytest primer for this file
`assert <expr>`
pytest fails the test if <expr> is falsy
`pytest.raises(SomeError)`
Context manager that requires the block to raise SomeError. This
is how we verify "refuses bad input" cases wrong password,
tampered ciphertext, modified nonce
`bytearray(...)` vs `bytes(...)`
Python's bytes is IMMUTABLE; you cannot do `b[0] = 0x42`. To
flip a bit for a tampering test, copy into a bytearray (mutable),
mutate, then cast back to bytes when handing it to a function
that expects bytes
"""
# Third-party: the test runner. Used for `pytest.raises` and the
# `@pytest.mark.parametrize` decorator below.
import pytest
# Local: every crypto helper under test — the dataclass, the error
# type, the encrypt/decrypt pair, and the random-bytes helpers.
from password_manager.crypto import (
KdfParameters,
WrongPasswordError,
decrypt,
derive_key,
encrypt,
generate_nonce,
generate_salt,
)
# Local: byte-length constants — we assert outputs match these
# without ever hard-coding 32 or 16 inside this file.
from password_manager.constants import (
KEY_LENGTH_BYTES,
NONCE_LENGTH_BYTES,
SALT_LENGTH_BYTES,
)
# Local: the fast KDF parameters defined in conftest. Importing
# directly (rather than via a fixture) keeps test bodies short.
from tests.conftest import TEST_KDF_PARAMETERS
# =============================================================================
# Random byte generation — salts and nonces must be the right size + unpredictable
# =============================================================================
def test_generate_salt_returns_correct_length() -> None:
"""
Verify generate_salt() produces exactly SALT_LENGTH_BYTES of output
The salt length is part of our on-disk format contract change
it without updating SALT_LENGTH_BYTES and every existing vault
would fail to decrypt. The most basic invariant to pin down
"""
salt = generate_salt()
assert len(salt) == SALT_LENGTH_BYTES
def test_generate_salt_returns_different_values_on_each_call() -> None:
"""
Verify generate_salt() yields a fresh random value every time
The whole point of a salt is that two users with the same
password get different keys. If generate_salt() returned the same
bytes every call, that property collapses
We collect 50 salts into a set; if the source is random, all 50
will be unique with overwhelming probability (16 random bytes =
128 bits of entropy, collisions are astronomically unlikely).
A failure here means `secrets.token_bytes` is broken or has been
replaced with a non-secure source
"""
salts = {generate_salt() for _ in range(50)}
# 50 unique 16-byte values is overwhelmingly likely if the source
# is truly random. A failure here means token_bytes is broken
assert len(salts) == 50
def test_generate_nonce_returns_correct_length() -> None:
"""
Verify generate_nonce() produces exactly NONCE_LENGTH_BYTES of output
AES-GCM's recommended nonce length is 12 bytes. Other lengths are
technically valid but degrade security guarantees. We pin to
NONCE_LENGTH_BYTES (12) and verify here so an accidental refactor
to 16 or 8 would fail this test immediately
"""
nonce = generate_nonce()
assert len(nonce) == NONCE_LENGTH_BYTES
def test_generate_nonce_returns_different_values_on_each_call() -> None:
"""
Verify nonces are fresh on every call the most security-critical property in the file
Nonce reuse in GCM is CATASTROPHIC. Two messages encrypted with
the same (key, nonce) pair leak the XOR of the plaintexts to
anyone watching, and let an attacker forge messages. So
`generate_nonce` is the most security-critical function here,
and "no two values are ever the same" is the property that matters
Same 50-into-a-set check as for salts. Different values = working
"""
nonces = {generate_nonce() for _ in range(50)}
assert len(nonces) == 50
# =============================================================================
# Key derivation — Argon2id properties
# =============================================================================
def test_derive_key_returns_correct_length() -> None:
"""
Verify derive_key produces exactly KEY_LENGTH_BYTES of output
KEY_LENGTH_BYTES is 32 what AES-256-GCM expects. The Argon2
library lets us request any output length; if we accidentally
asked for 16 we would get an AES-128 vault, and if we asked for
64 the cipher would refuse to construct at all
"""
key = derive_key(
"password",
generate_salt(),
TEST_KDF_PARAMETERS,
)
assert len(key) == KEY_LENGTH_BYTES
def test_derive_key_is_deterministic() -> None:
"""
Verify derive_key is deterministic same inputs always produce the same key
This is THE property that makes the password manager work. When
the user types their master password tomorrow, we must derive
the EXACT same key we used to encrypt the vault yesterday. If
Argon2 were non-deterministic (say, mixed in a timestamp
internally), the user could never unlock their vault again
Notice we generate the salt ONCE and reuse it for both
derivations that is what isolates the "same inputs" condition
"""
salt = generate_salt()
key_a = derive_key("hunter2", salt, TEST_KDF_PARAMETERS)
key_b = derive_key("hunter2", salt, TEST_KDF_PARAMETERS)
assert key_a == key_b
def test_derive_key_different_passwords_yield_different_keys() -> None:
"""
Verify changing the password (with the salt held constant) yields a different key
This is the basic "the password matters" property. If derive_key
produced the same key for "password-a" and "password-b" with the
same salt, the function would be ignoring its first argument
which would mean every vault could be unlocked with any password.
Catastrophic
"""
salt = generate_salt()
key_a = derive_key("password-a", salt, TEST_KDF_PARAMETERS)
key_b = derive_key("password-b", salt, TEST_KDF_PARAMETERS)
assert key_a != key_b
def test_derive_key_different_salts_yield_different_keys() -> None:
"""
Verify changing the salt (with the password held constant) yields a different key
The salt is what defeats rainbow tables two users with the same
password must end up with different keys because their salts
differ. Without this property, an attacker who steals 10 million
encrypted vaults could pre-compute keys for the top 1000 common
passwords and instantly unlock every vault belonging to anyone
who picked one
"""
key_a = derive_key("hunter2", generate_salt(), TEST_KDF_PARAMETERS)
key_b = derive_key("hunter2", generate_salt(), TEST_KDF_PARAMETERS)
assert key_a != key_b
def test_kdf_parameters_defaults_are_immutable() -> None:
"""
Verify KdfParameters instances reject attribute assignment after construction
`frozen=True` on the dataclass is what makes this work. Trying
to assign to a frozen instance raises AttributeError at runtime.
We confirm here so a future refactor that drops `frozen=True`
would fail this test immediately
The `# type: ignore[misc]` comment tells mypy "yes, we KNOW this
line is illegal that is the whole point of the test." Without
the comment, type-checking would refuse before pytest could even
run the test
"""
params = KdfParameters.defaults()
with pytest.raises(AttributeError):
params.time_cost = 999 # type: ignore[misc]
def test_derive_key_rejects_empty_password() -> None:
"""
Verify derive_key refuses an empty master password
Argon2 itself would happily derive a key from b"" + the salt,
but the resulting "lock" has effectively no secret in it
anyone who steals the vault file can re-derive the same key
from the public salt. derive_key refuses outright as a
programming-error floor, and we verify here that the refusal
takes the form of a ValueError
"""
with pytest.raises(ValueError):
derive_key("", generate_salt(), TEST_KDF_PARAMETERS)
# =============================================================================
# Encrypt / decrypt round-trip — the basic AES-GCM contract
# =============================================================================
def test_encrypt_decrypt_round_trip() -> None:
"""
Verify decrypt(encrypt(x)) returns the original x
The most basic encryption test imaginable: encrypt some bytes,
decrypt them, get the same bytes back. If this ever fails, the
encryption module is fundamentally broken and nothing else in
the test suite will be meaningful
Notice the structure: we derive a key from a password (same flow
as production), encrypt, decrypt, compare. We use a real
password+salt rather than hard-coded key bytes so the full
derive-then-encrypt path is exercised
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
plaintext = b"the quick brown fox jumps over the lazy dog"
nonce, ciphertext = encrypt(plaintext, key)
recovered = decrypt(ciphertext, nonce, key)
assert recovered == plaintext
def test_encrypt_produces_fresh_nonce_each_call() -> None:
"""
Verify two calls to encrypt() with the same plaintext+key still produce different output
Each encrypt() call internally calls generate_nonce(). Because
the nonce is fresh, the ciphertext for the same plaintext+key
WILL differ across calls both the nonce bytes and the
ciphertext bytes
If a future refactor accidentally cached the nonce (or used a
counter that reset), this test would catch it. Nonce reuse with
the same key in GCM is one of the all-time worst cryptographic
mistakes see "Nonce Disrespecting Adversaries" if you want a
deep dive
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
plaintext = b"hello"
nonce1, ct1 = encrypt(plaintext, key)
nonce2, ct2 = encrypt(plaintext, key)
# Same plaintext + same key but different nonce → different ciphertext
assert nonce1 != nonce2
assert ct1 != ct2
def test_encrypt_handles_empty_plaintext() -> None:
"""
Verify encrypt() handles an empty bytes object cleanly
The empty plaintext is a real edge case: a freshly-initialized
vault encrypts an empty entries dict, which JSON-serializes to
b"{}" but if a user deletes every entry, we end up in the
same place. Pinning down b"" specifically here guards against
off-by-one bugs in length-prefix code or similar
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"", key)
assert decrypt(ciphertext, nonce, key) == b""
# =============================================================================
# Decryption refuses tampered or wrong-key input — GCM authentication
# =============================================================================
def test_decrypt_with_wrong_key_raises_wrong_password_error() -> None:
"""
Verify decrypt() refuses when the key does not match the encryption key
This is the "wrong master password" path. Encrypt with one
derived key, attempt to decrypt with a DIFFERENT derived key,
GCM refuses because the authentication tag will not validate.
We re-raise that low-level failure as WrongPasswordError (in
crypto.py) so the CLI can show the user a clean message instead
of leaking cryptography-library internals
This is the primary security property of the entire password
manager: only the correct master password can unlock the vault
"""
salt = generate_salt()
correct_key = derive_key("correct", salt, TEST_KDF_PARAMETERS)
wrong_key = derive_key("wrong", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"secret", correct_key)
with pytest.raises(WrongPasswordError):
decrypt(ciphertext, nonce, wrong_key)
def test_decrypt_with_modified_ciphertext_raises() -> None:
"""
Verify flipping any byte of the ciphertext causes decryption to fail
This is GCM's "tamper-evidence" property. The authentication tag
appended to the ciphertext covers every byte of it flip one
bit and the tag no longer matches, GCM refuses. We do NOT get a
quietly-mangled plaintext back; we get an exception
The bit-flip we perform `tampered[middle] ^= 0x01` uses XOR
against 0x01 to flip the lowest bit of the byte at the middle of
the ciphertext. The exact bit does not matter; ANY change to ANY
byte of the ciphertext must trip the auth tag
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"important data", key)
# Flip one bit somewhere in the middle. We need a mutable copy
# because Python's `bytes` is immutable — `b[0] = 0x42` would
# raise. `bytearray` is the mutable cousin. After flipping we
# cast back to bytes for the decrypt call (which expects bytes,
# not bytearray, as a matter of type-strict API discipline)
middle = len(ciphertext) // 2
tampered = bytearray(ciphertext)
tampered[middle] ^= 0x01
tampered_bytes = bytes(tampered)
with pytest.raises(WrongPasswordError):
decrypt(tampered_bytes, nonce, key)
def test_decrypt_with_modified_nonce_raises() -> None:
"""
Verify modifying the nonce also causes decryption to fail
Many beginners assume only the ciphertext is authenticated. In
fact GCM authenticates the (nonce, ciphertext) pair together
if an attacker swaps the nonce for some other valid-looking
value, the tag still will not match and decryption refuses
Flipping every bit of the first nonce byte with `^= 0xFF` is a
high-confidence way to produce a definitively-different nonce
(a tampered byte may collide with the original; flipping every
bit guarantees it does not)
"""
salt = generate_salt()
key = derive_key("master", salt, TEST_KDF_PARAMETERS)
nonce, ciphertext = encrypt(b"important data", key)
bad_nonce = bytearray(nonce)
bad_nonce[0] ^= 0xFF # flip every bit of the first byte
with pytest.raises(WrongPasswordError):
decrypt(ciphertext, bytes(bad_nonce), key)

View File

@ -0,0 +1,273 @@
"""
©AngelaMos | 2026
test_generator.py
Tests for the password generator what it produces and what it refuses
Every test below is one Python function whose name starts with `test_`.
pytest discovers them automatically when you run `just test`, and runs
each one in isolation so a failure in one cannot poison another
What the generator must guarantee
1. The result is exactly the requested length
2. Every character class the user enabled appears at least once.
This is what makes the generated password pass "must contain a
digit / symbol / uppercase" rules on websites
3. Short or impossible requests are rejected loudly, never silently
"fixed" to something else
A short pytest primer for this file
`assert <expr>`
pytest fails the test if <expr> is falsy
`pytest.raises(SomeError)`
A context manager that wraps a code block. The block MUST raise
SomeError or the test fails. This is how we verify "refuses bad
input" without having to predict the exact wording of the error
`any(c in pool for c in password)`
Returns True if AT LEAST one character of password is in pool
`all(c in pool for c in password)`
Returns True only if EVERY character of password is in pool
`set(...)`
Builds a Python set, which silently discards duplicates.
`len({generate_password(16) for _ in range(100)}) == 100` is a
clean way to assert "100 generated passwords were all unique"
"""
# Standard library: a few prebuilt character-set strings (digits,
# ascii_letters, etc.) — handy for "every char is allowed" assertions.
import string
# Third-party: the test runner. Used for `pytest.raises` and the
# `@pytest.mark.parametrize` decorator below.
import pytest
# Local: the character pools and length minimum. Importing them lets
# the tests stay in sync if production code ever tweaks the alphabet.
from password_manager.constants import (
DIGITS,
LOWERCASE_LETTERS,
MINIMUM_GENERATED_PASSWORD_LENGTH,
SAFE_SYMBOLS,
UPPERCASE_LETTERS,
)
# Local: the function under test plus the error type we expect when
# the caller asks for a too-short password.
from password_manager.generator import (
PasswordTooShortError,
generate_password,
)
# =============================================================================
# Length — what the user asks for is what they get
# =============================================================================
def test_generate_password_default_length_matches_argument() -> None:
"""
Verify the generator produces a password of exactly the requested length
This is the most basic contract: if I ask for length=20, I must
get back a 20-character string. An off-by-one mistake here would
break every downstream caller, so it is the first thing we pin
down
"""
password = generate_password(20)
assert len(password) == 20
def test_generate_password_below_minimum_raises() -> None:
"""
Verify the generator refuses lengths below the project minimum
Why a minimum at all? A 4-character password is trivially
crackable even with a slow KDF in front of it. Letting the caller
produce one silently would betray the user. PasswordTooShortError
is our way of saying "this is not a number we will produce a
password for"
The `with pytest.raises(...)` block fails the test if no
exception is raised, OR if the wrong exception type is raised
so this single line asserts both "an error happened" and "it was
the right error type"
"""
with pytest.raises(PasswordTooShortError):
generate_password(MINIMUM_GENERATED_PASSWORD_LENGTH - 1)
def test_generate_password_with_no_pools_enabled_raises() -> None:
"""
Verify the generator refuses when every character pool is disabled
The generator draws characters from a pool built up by combining
the four character classes (lowercase / uppercase / digits /
symbols). If the caller sets every flag to False, the pool is
empty there is literally nothing to draw from
Rather than fall back to some default silently, the generator
raises ValueError so the caller learns about the bug. Silent
fallbacks in security code are how subtle weaknesses ship to
production
"""
with pytest.raises(ValueError):
generate_password(
16,
use_lowercase = False,
use_uppercase = False,
use_digits = False,
use_symbols = False,
)
# =============================================================================
# Character class coverage — every enabled pool contributes
# =============================================================================
def test_generate_password_contains_at_least_one_from_each_pool() -> None:
"""
Verify every enabled character class is represented in the output
Most websites enforce composition rules: "must contain a
lowercase letter, an uppercase letter, a digit, and a symbol."
A generator that produced a 16-character password of only
lowercase letters would pass `len() == 16` but fail half the
websites the user actually has accounts on
The fix: after building the pool, the generator guarantees AT
LEAST one character from each enabled pool ends up in the result.
We verify that property here by checking each pool with
`any(c in pool for c in password)`
"""
password = generate_password(
16,
use_lowercase = True,
use_uppercase = True,
use_digits = True,
use_symbols = True,
)
# any(...) returns True if at least one char satisfies the predicate
assert any(c in LOWERCASE_LETTERS for c in password)
assert any(c in UPPERCASE_LETTERS for c in password)
assert any(c in DIGITS for c in password)
assert any(c in SAFE_SYMBOLS for c in password)
def test_generate_password_only_lowercase_when_others_disabled() -> None:
"""
Verify disabling every pool except lowercase yields a lowercase-only string
Useful for passphrases or for systems that refuse symbols / mixed
case. If we accidentally leaked an uppercase letter or a digit
into the result here, the generator would be ignoring the user's
pool selection a quiet correctness bug
`all(c in LOWERCASE_LETTERS for c in password)` is True only when
EVERY character of password is in the lowercase pool
"""
password = generate_password(
16,
use_lowercase = True,
use_uppercase = False,
use_digits = False,
use_symbols = False,
)
assert all(c in LOWERCASE_LETTERS for c in password)
def test_generate_password_excludes_symbols_when_disabled() -> None:
"""
Verify use_symbols=False truly removes all symbols from the result
Some legacy systems reject special characters. Users need an
escape hatch flipping `use_symbols=False` must yield zero
symbols, never just "fewer symbols." We check this with
`not any(...)`, which is True only when NO character is in the
disallowed pool
"""
password = generate_password(
16,
use_symbols = False,
)
assert not any(c in SAFE_SYMBOLS for c in password)
# =============================================================================
# Randomness — successive calls must produce different output
# =============================================================================
def test_generate_password_uniqueness_across_calls() -> None:
"""
Verify successive calls produce distinct passwords
Two truly-random 16-character passwords colliding has probability
around 1 / (62 ^ 16), which is ~3e-29. So the chance of 100 calls
producing 100 unique values is overwhelmingly close to 1
If this test EVER fails, the only reasonable explanation is that
the underlying random source (secrets.choice) is broken for
instance, someone swapped it for `random.choice` which seeds off
a predictable source
Building a set from the 100 calls and asserting `len(set) == 100`
is the cleanest way to express "all of them were different"
"""
passwords = {generate_password(16) for _ in range(100)}
assert len(passwords) == 100
# =============================================================================
# Alphabet boundaries — no garbage characters slip in
# =============================================================================
def test_generate_password_only_uses_expected_alphabet() -> None:
"""
Verify the generator never produces characters outside the configured pools
A subtle bug would be the generator emitting a tab, a newline, or
a Unicode character that breaks copy-paste. Building the allowed
set by hand (union of all four pools) and asserting every char is
in it catches that
The defense-in-depth check at the end explicitly looks for
whitespace string.whitespace is the standard library's
canonical set of " \\t\\n\\r\\v\\f", so we cannot mistype it
"""
password = generate_password(32)
allowed = set(
LOWERCASE_LETTERS + UPPERCASE_LETTERS + DIGITS + SAFE_SYMBOLS
)
assert all(c in allowed for c in password)
# Defense-in-depth: also check there are no whitespace chars
assert not any(c in string.whitespace for c in password)
def test_generate_password_short_request_with_many_pools_raises() -> None:
"""
Verify the minimum-length path interacts correctly with pool counts
Two related checks could conceivably reject a request
1. length below MINIMUM_GENERATED_PASSWORD_LENGTH (project floor)
2. length below the pool count (cannot fit one char from each)
For our defaults (minimum 8, four pools) the minimum-length check
fires first there is no value of `length` that passes (1) but
fails (2). This test pins down the boundary: length == 8 with
four pools enabled must succeed, proving the two checks combine
sensibly. The test name says "raises" but the test verifies
success at the floor kept as-is to preserve the existing name
"""
# 8 is exactly the minimum and fits 4 pools, should succeed
password = generate_password(8)
assert len(password) == 8

View File

@ -0,0 +1,959 @@
"""
©AngelaMos | 2026
test_vault.py
Tests for the vault module file format, persistence, entry CRUD
These tests DO touch the filesystem (via pytest's `tmp_path` fixture)
because the vault's whole job is reading and writing files. Every
fixture cleans up after itself when the test ends, so the suite
leaves no debris behind
What is being verified, organized by section
1. Vault creation `UnlockedVault.create()` writes a file at the
given path with the right format, the right permissions, and
refuses to clobber an existing vault
2. Round-trip save a vault, reopen it, get back exactly what was
stored. Verify wrong-password, missing-file, malformed-file,
and out-of-range KDF parameters all raise the right exceptions
3. Entry dataclass Entry is frozen (cannot be mutated after
construction); from_dict refuses corrupted input loudly instead
of silently filling in defaults for required fields
4. Entry operations add / get / delete / names. Duplicate
detection, the `force` overwrite flag, sort order, entry-name
validation that rejects leading/trailing whitespace
5. Master password rotation change_master_password rotates BOTH
the salt AND the key. After rotation the old password no longer
works; the new password unlocks; all entries survive
6. Lifecycle close() and the context-manager `with` block both
drop the key + entries from memory when done
7. Persistence every save produces a fresh nonce, never leaves a
stale .tmp file, and uses the secure 0600 file mode from the
very first syscall
A short pytest primer for this file
`tmp_path` (built-in fixture)
Pytest creates a fresh empty temporary directory for each test
and passes its Path here. Auto-cleaned at test end
`monkeypatch` (built-in fixture)
Lets you temporarily replace attributes on objects (functions,
modules, classes) for the duration of one test. We use it to
spy on `os.open` and to simulate `os.replace` failing. After
the test ends, the original attributes are restored
`@pytest.mark.parametrize("name", [v1, v2, v3])`
Runs the same test function once per value. So a single
`test_x(name)` becomes 3 separate tests, each with a different
name. Cleaner than writing 3 near-identical test functions
`fresh_vault` and `master_password` (from conftest.py)
Fixtures we defined ourselves. Pytest sees them in the test
function's parameters and wires them in automatically
"""
# Standard library: `dataclasses.replace` lets us copy an Entry with
# one field changed — handy for building "tampered" inputs.
import dataclasses
# Standard library: parse the on-disk vault file so we can assert
# its structure looks right (versions, key names, etc.).
import json
# Standard library: `os.stat` reads filesystem metadata — we use it
# to check the vault file's permission bits.
import os
# Standard library: symbolic names for permission bits (S_IRUSR
# etc.) — makes the perm check read like English.
import stat
# Standard library: object-oriented filesystem paths — type hint
# for the `tmp_path` fixture and our own helpers.
from pathlib import Path
# Third-party: the test runner. Used for `pytest.raises` and
# `@pytest.mark.parametrize`.
import pytest
# Local: file-mode and format-version constants. Tests assert
# against these so the values stay defined in exactly one place.
from password_manager.constants import (
VAULT_FILE_MODE,
VAULT_FORMAT_VERSION,
)
# Local: the one crypto error we expect when an unlock attempt
# uses the wrong master password.
from password_manager.crypto import WrongPasswordError
# Local: the full surface of the vault module — entry record,
# main class, and every error type tests need to raise or catch.
from password_manager.vault import (
Entry,
EntryAlreadyExistsError,
EntryNotFoundError,
UnlockedVault,
VaultAlreadyExistsError,
VaultFormatError,
VaultNotFoundError,
)
# Local: fast KDF parameters from conftest, so Argon2 work in
# tests stays in milliseconds.
from tests.conftest import TEST_KDF_PARAMETERS
# =============================================================================
# Helpers
# =============================================================================
def _sample_entry(password: str = "s3cret") -> Entry:
"""
Return a fully-populated Entry that tests can drop into a vault
Centralizing this means every test that wants "an entry" gets a
consistent shape with username, password, url, and notes set.
Tests that care about a specific password override it by passing
one in
"""
return Entry(
username = "alice",
password = password,
url = "https://example.com",
notes = "primary account",
)
def _create_test_vault(
path: Path,
master_password: str,
) -> UnlockedVault:
"""
Tiny convenience wrapper that always uses TEST_KDF_PARAMETERS
The whole point of test fixtures is to keep noise out of test
bodies this exists so every test that needs to call create()
directly can do so without restating the kdf_parameters kwarg
every time
"""
return UnlockedVault.create(
path,
master_password,
kdf_parameters = TEST_KDF_PARAMETERS,
)
# =============================================================================
# Vault creation
# =============================================================================
def test_create_writes_file_at_path(
vault_path: Path,
master_password: str,
) -> None:
"""
Verify UnlockedVault.create() puts a real file at the path we gave it
The most basic post-condition: after `create(path, ...)` returns,
`path` exists on disk. If a future refactor accidentally returned
an in-memory-only vault that forgot to save, this test catches
it before the user loses data on the next process restart
"""
_create_test_vault(vault_path, master_password)
assert vault_path.exists()
def test_create_refuses_to_overwrite_existing_file(
vault_path: Path,
master_password: str,
) -> None:
"""
Verify create() refuses to clobber a vault that already exists
`pv init` is a destructive-feeling operation: if it silently
overwrote an existing vault, a user who mistyped a command could
lose every credential they had stored. Refusing with
VaultAlreadyExistsError forces the user to make a conscious
choice (delete the old file first if they really mean to start
over)
"""
_create_test_vault(vault_path, master_password)
with pytest.raises(VaultAlreadyExistsError):
_create_test_vault(vault_path, master_password)
def test_create_sets_file_mode_to_0600(
vault_path: Path,
master_password: str,
) -> None:
"""
Verify the vault file is created with restrictive POSIX permissions
0600 means "the owner can read/write, nobody else can read OR
write." Even though the contents are encrypted, broadcasting
metadata (file size, timestamps, even that the file exists at
all) to other local users is sloppy. Pin permissions tight at
creation time
`stat.S_IMODE` masks off file-type bits, leaving just the
permission bits we care about. On Windows POSIX modes do not
apply at all, so we skip the assertion there
"""
_create_test_vault(vault_path, master_password)
mode = stat.S_IMODE(os.stat(vault_path).st_mode)
# On Windows, POSIX modes do not apply. Skip that platform's check
if os.name != "nt":
assert mode == VAULT_FILE_MODE
def test_create_writes_valid_envelope_json(
vault_path: Path,
master_password: str,
) -> None:
"""
Verify the file create() writes is a valid JSON envelope with the expected fields
We do not crack open the encrypted payload here that is
`unlock`'s job. We just confirm the OUTER envelope has the right
shape: version field, kdf section naming argon2id, cipher
section naming aes-256-gcm. If this drifts we lose forward
compatibility with existing vaults
"""
_create_test_vault(vault_path, master_password)
envelope = json.loads(vault_path.read_text(encoding = "utf-8"))
assert envelope["version"] == VAULT_FORMAT_VERSION
assert envelope["kdf"]["name"] == "argon2id"
assert envelope["cipher"]["name"] == "aes-256-gcm"
def test_create_makes_parent_directory_if_missing(
tmp_path: Path,
master_password: str,
) -> None:
"""
Verify create() auto-creates intermediate directories on the way to the vault path
Users specify vault paths like `~/.config/pv/vault.json` where
`~/.config/pv` may not exist yet. We make the parent tree on
their behalf (with `parents=True, exist_ok=True`) so they do not
have to mkdir first. This test pins down that convenience
"""
nested = tmp_path / "deep" / "nested" / "dir" / "vault.json"
_create_test_vault(nested, master_password)
assert nested.exists()
def test_create_rejects_empty_master_password(
vault_path: Path,
) -> None:
"""
Verify create() refuses an empty master password
Empty master password = no real lock on the vault. derive_key
refuses internally (see test_crypto.py for the underlying
check), and create() lets that ValueError bubble up unchanged.
We confirm here that the rejection makes it all the way out
"""
with pytest.raises(ValueError):
_create_test_vault(vault_path, "")
# =============================================================================
# Round-trip — create, save, unlock, read
# =============================================================================
def test_unlock_reads_back_what_was_saved(
fresh_vault: UnlockedVault,
master_password: str,
) -> None:
"""
Verify the canonical save unlock round-trip preserves entry data
This is THE end-to-end happy-path test: add an entry, save,
reopen with the same password, every field of the entry
survives. If this test ever fails the password manager is
fundamentally broken
"""
fresh_vault.add_entry("github", _sample_entry())
fresh_vault.save()
reopened = UnlockedVault.unlock(fresh_vault.path, master_password)
assert "github" in reopened.entries
assert reopened.entries["github"].username == "alice"
assert reopened.entries["github"].password == "s3cret"
def test_unlock_with_wrong_password_raises(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify unlock() raises WrongPasswordError when the master password is wrong
The primary security guarantee of the whole project. We create a
vault with one password, attempt to unlock with a different
password, and expect a clean WrongPasswordError. NOT a garbled
plaintext, NOT a silent empty vault, NOT a crypto-library
InvalidTag exception leaking out a clean named error
"""
with pytest.raises(WrongPasswordError):
UnlockedVault.unlock(fresh_vault.path, "not the right password")
def test_unlock_missing_file_raises(tmp_path: Path) -> None:
"""
Verify unlock() on a nonexistent file raises VaultNotFoundError
Distinct from "wrong password" so the CLI can show a different
message ("no vault at this path" vs "wrong password"). This is
the only file-related error that does NOT need to be ambiguous
with the wrong-password case confirming "this file does not
exist" is safe information to give attackers
"""
with pytest.raises(VaultNotFoundError):
UnlockedVault.unlock(tmp_path / "nope.json", "any-password")
def test_unlock_invalid_json_raises(
vault_path: Path,
) -> None:
"""
Verify unlock() raises VaultFormatError when the file is not valid JSON
Someone hand-edited the vault, or a disk error corrupted it. We
cannot proceed but we also should not show a Python traceback
catch json.JSONDecodeError internally and re-raise as our own
typed error
"""
vault_path.write_text("this is not json")
with pytest.raises(VaultFormatError):
UnlockedVault.unlock(vault_path, "any-password")
def test_unlock_unsupported_version_raises(
vault_path: Path,
) -> None:
"""
Verify unlock() refuses a vault with an unknown format version
Future-proofing. If a future build of pv writes vaults with
`version: 2`, today's build needs to refuse cleanly (rather than
try to parse a v2 file with v1 rules and fail mysteriously).
`VaultFormatError` is the contract the CLI can render it as
"this vault was created by a newer version of pv"
"""
vault_path.write_text(
json.dumps({
"version": 99,
"kdf": {},
"cipher": {}
})
)
with pytest.raises(VaultFormatError):
UnlockedVault.unlock(vault_path, "any-password")
def test_unlock_rejects_zero_time_cost(
vault_path: Path,
) -> None:
"""
Verify a corrupted vault with time_cost=0 surfaces as VaultFormatError
Without parameter validation, time_cost=0 would crash deep
inside argon2-cffi with a confusing low-level message. Catching
invalid KDF parameters at parse time means the user sees a clean
"invalid vault" message and the cryptography library never gets
handed garbage
The base64 fields below (`AAAA...==`) are dummy placeholders
we never get far enough to use them because the parameter check
fires first
"""
vault_path.write_text(
json.dumps(
{
"version": 1,
"kdf": {
"name": "argon2id",
"salt": "AAAAAAAAAAAAAAAAAAAAAA==",
"time_cost": 0,
"memory_cost": 8,
"parallelism": 1,
},
"cipher": {
"name": "aes-256-gcm",
"nonce": "AAAAAAAAAAAAAAAA",
"ciphertext": "AAAAAAAAAAAAAAAA",
},
}
)
)
with pytest.raises(VaultFormatError):
UnlockedVault.unlock(vault_path, "any-password")
def test_unlock_rejects_memory_cost_below_lane_floor(
vault_path: Path,
) -> None:
"""
Verify a vault that violates Argon2's "memory_cost >= 8 * parallelism" rule is rejected
Argon2 algorithmically requires memory_cost >= 8 * parallelism.
A vault file that violates this invariant cannot decrypt; we
surface that as VaultFormatError BEFORE handing the parameters
to argon2-cffi. Same reasoning as the time_cost=0 test: validate
at the boundary so internal libraries never see bad input
"""
vault_path.write_text(
json.dumps(
{
"version": 1,
"kdf": {
"name": "argon2id",
"salt": "AAAAAAAAAAAAAAAAAAAAAA==",
"time_cost": 1,
"memory_cost": 4,
"parallelism": 2,
},
"cipher": {
"name": "aes-256-gcm",
"nonce": "AAAAAAAAAAAAAAAA",
"ciphertext": "AAAAAAAAAAAAAAAA",
},
}
)
)
with pytest.raises(VaultFormatError):
UnlockedVault.unlock(vault_path, "any-password")
# =============================================================================
# Entry — frozen + strict from_dict
# =============================================================================
def test_entry_is_immutable() -> None:
"""
Verify Entry instances reject attribute assignment after construction
Frozen dataclass instances raise FrozenInstanceError on any
attempted attribute write. The whole point is to force every
"edit" through `UnlockedVault.add_entry`, which is the ONLY
method that knows how to update the `updated_at` timestamp
correctly. Making the wrong move impossible at the type level
beats writing a comment saying "do not do that"
`# type: ignore[misc]` tells mypy "yes I know this is illegal,
that is the point of the test"
"""
entry = Entry(username = "alice", password = "x")
with pytest.raises(dataclasses.FrozenInstanceError):
entry.password = "y" # type: ignore[misc]
def test_entry_from_dict_missing_password_raises() -> None:
"""
Verify Entry.from_dict refuses a dict without a `password` field
The required fields are username and password. If a vault on
disk has been corrupted or hand-edited so an entry is missing
its password, the right answer is to refuse the whole load loud
enough that the user notices NOT to invent an empty password
and pretend everything is fine
"""
with pytest.raises(VaultFormatError):
Entry.from_dict({"username": "alice"})
def test_entry_from_dict_missing_username_raises() -> None:
"""
Verify Entry.from_dict refuses a dict without a `username` field
Mirror of the missing-password test. Required fields cannot be
invented from defaults; the load must fail loudly
"""
with pytest.raises(VaultFormatError):
Entry.from_dict({"password": "x"})
def test_entry_from_dict_non_string_password_raises() -> None:
"""
Verify Entry.from_dict refuses a non-string password
JSON allows numbers, booleans, nulls, and nested objects in any
field. If somebody hand-edited the decrypted vault to put 12345
(an int) in the password field, we must refuse rather than try
to use the int as a password. VaultFormatError is the contract
"""
with pytest.raises(VaultFormatError):
Entry.from_dict({"username": "alice", "password": 12345})
def test_entry_from_dict_uses_empty_string_for_missing_timestamps(
) -> None:
"""
Verify Entry.from_dict defaults missing timestamps to "" rather than _now_iso()
The two non-required fields, `created_at` and `updated_at`,
default to empty strings when absent. Inventing a current
timestamp on read would make an OLD entry look freshly created,
which is actively misleading better to admit "we do not know
when this was created" with an empty string
"""
entry = Entry.from_dict({"username": "alice", "password": "x"})
assert entry.created_at == ""
assert entry.updated_at == ""
# =============================================================================
# Entry operations
# =============================================================================
def test_add_entry_appears_in_names(fresh_vault: UnlockedVault) -> None:
"""
Verify an added entry shows up in names()
The minimum contract for `add_entry`: after calling it, the
entry name appears in the names() list. If this ever fails, the
add path is silently dropping data
"""
fresh_vault.add_entry("github", _sample_entry())
assert "github" in fresh_vault.names()
def test_names_returns_sorted(fresh_vault: UnlockedVault) -> None:
"""
Verify names() returns entries in alphabetical order, not insertion order
Users grep / scan a list of names; alphabetical is what they
expect. We add entries in scrambled order (zebra, apple, mango)
and confirm names() yields them in alphabetical order. If we
accidentally returned insertion order, this test would catch it
"""
fresh_vault.add_entry("zebra", _sample_entry())
fresh_vault.add_entry("apple", _sample_entry())
fresh_vault.add_entry("mango", _sample_entry())
assert fresh_vault.names() == ["apple", "mango", "zebra"]
def test_add_entry_refuses_duplicate(fresh_vault: UnlockedVault) -> None:
"""
Verify add_entry refuses a duplicate name when force is False
Default behavior: do not let the user accidentally overwrite an
existing entry. They must pass `force=True` explicitly to
overwrite. EntryAlreadyExistsError is the cleanly-typed signal
so the CLI can prompt the user with "did you mean to overwrite?"
"""
fresh_vault.add_entry("github", _sample_entry())
with pytest.raises(EntryAlreadyExistsError):
fresh_vault.add_entry("github", _sample_entry())
def test_add_entry_with_force_overwrites(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify add_entry with force=True replaces an existing entry
The intentional-overwrite path. After a force-replace, the
entry's data should reflect the NEW values, not the old. We
confirm the password specifically because that is the field
users rotate most often
"""
fresh_vault.add_entry("github", _sample_entry(password = "old"))
fresh_vault.add_entry(
"github",
_sample_entry(password = "new"),
force = True,
)
assert fresh_vault.get_entry("github").password == "new"
def test_overwrite_preserves_created_at(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify a force-overwrite preserves the original created_at timestamp
When a user rotates a password, the entry's CREATION date should
not change only the LAST-UPDATED date. Otherwise the user
cannot answer "how old is this credential" after a rotation.
This invariant is a small detail but it is the kind of polish
that distinguishes a real tool from a homework assignment
"""
fresh_vault.add_entry("github", _sample_entry())
original_created = fresh_vault.get_entry("github").created_at
fresh_vault.add_entry(
"github",
_sample_entry(password = "rotated"),
force = True,
)
assert fresh_vault.get_entry("github").created_at == original_created
def test_get_entry_missing_raises(fresh_vault: UnlockedVault) -> None:
"""
Verify get_entry on a name that does not exist raises EntryNotFoundError
Returning None on miss would be a quieter API but quiet
failures make for hard-to-debug bugs. EntryNotFoundError is
impossible to ignore: either the caller catches it and handles
the miss, or it propagates and the CLI shows a clean error
"""
with pytest.raises(EntryNotFoundError):
fresh_vault.get_entry("does-not-exist")
def test_delete_entry_removes_it(fresh_vault: UnlockedVault) -> None:
"""
Verify delete_entry actually removes the entry from names()
Round-trip: add, delete, confirm it is gone. If delete were
accidentally a no-op, this test catches it before the user finds
out the hard way
"""
fresh_vault.add_entry("github", _sample_entry())
fresh_vault.delete_entry("github")
assert "github" not in fresh_vault.names()
def test_delete_entry_missing_raises(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify delete_entry on a name that does not exist raises EntryNotFoundError
Mirror of the get_entry missing test. We do not silently succeed
on a delete that had nothing to delete that is the kind of
forgiving behavior that masks real bugs in calling code
"""
with pytest.raises(EntryNotFoundError):
fresh_vault.delete_entry("does-not-exist")
@pytest.mark.parametrize(
"bad_name",
["",
" ",
"\t",
"\n",
" github",
"github ",
" github "],
)
def test_add_entry_rejects_invalid_names(
fresh_vault: UnlockedVault,
bad_name: str,
) -> None:
"""
Verify add_entry rejects empty, whitespace-only, or whitespace-surrounded names
`@pytest.mark.parametrize` runs this test ONCE PER VALUE in the
list so pytest reports 7 separate test results, one per bad
name. Cleaner than seven near-identical test functions
The trailing-whitespace case is the subtle one: without this
check, "github" and "github " would silently become two
different keys, which looks identical on screen and wastes
hours of debugging time
"""
with pytest.raises(ValueError):
fresh_vault.add_entry(bad_name, _sample_entry())
# =============================================================================
# Master password rotation
# =============================================================================
def test_change_master_password_rotates_key_and_salt(
fresh_vault: UnlockedVault,
master_password: str,
) -> None:
"""
Verify change_master_password generates a fresh salt AND a fresh key
Rotation is not just "encrypt with a new password" it is
"generate a NEW salt, derive a NEW key from (new password +
new salt), re-encrypt." Reusing the old salt would let an
attacker who got both versions of the file run their guesses
twice as cheaply. Fresh salt independent key independent
cracking work for each
"""
fresh_vault.add_entry("github", _sample_entry())
original_salt = fresh_vault.salt
original_key = fresh_vault.key
fresh_vault.change_master_password(
"an entirely new master pass",
kdf_parameters = TEST_KDF_PARAMETERS,
)
fresh_vault.save()
assert fresh_vault.salt != original_salt
assert fresh_vault.key != original_key
def test_change_master_password_old_password_no_longer_unlocks(
fresh_vault: UnlockedVault,
master_password: str,
) -> None:
"""
Verify the previous master password stops working after rotation
The whole point of rotation is "if the old password leaked,
rotating to a new one kills the leak." We confirm the old
password is dead by attempting an unlock with it and expecting
WrongPasswordError
"""
fresh_vault.add_entry("github", _sample_entry())
fresh_vault.change_master_password(
"the new one",
kdf_parameters = TEST_KDF_PARAMETERS,
)
fresh_vault.save()
with pytest.raises(WrongPasswordError):
UnlockedVault.unlock(fresh_vault.path, master_password)
def test_change_master_password_new_password_unlocks_with_entries_intact(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify the new master password unlocks the vault AND all entries are preserved
The other half of the rotation contract: the user keeps their
data. Reopen with the new password, every entry that was there
before is still there with the same password field
"""
fresh_vault.add_entry("github", _sample_entry())
fresh_vault.change_master_password(
"the new one",
kdf_parameters = TEST_KDF_PARAMETERS,
)
fresh_vault.save()
reopened = UnlockedVault.unlock(fresh_vault.path, "the new one")
assert reopened.entries["github"].password == "s3cret"
def test_change_master_password_rejects_empty(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify change_master_password refuses an empty new password
The same "empty password = no real lock" rule that applies to
creation also applies to rotation. We refuse before generating
a salt both because there is no reason to generate one we
will throw away, and because raising sooner gives a cleaner
error
"""
with pytest.raises(ValueError):
fresh_vault.change_master_password("")
# =============================================================================
# Lifecycle — context manager + close()
# =============================================================================
def test_close_zeroes_key_and_drops_entries(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify close() wipes the in-memory secrets
After `close()`, the AES key field becomes 32 zero bytes and
the entries dict becomes empty. This is best-effort Python's
bytes are immutable so the ORIGINAL key bytes may still live in
memory until GC runs but the discipline of "explicitly drop
secrets when done" matters. A test that pins the post-close
state guards against accidental refactoring that skips the wipe
"""
fresh_vault.add_entry("github", _sample_entry())
fresh_vault.close()
assert fresh_vault.entries == {}
assert fresh_vault.key == bytes(32)
def test_unlocked_vault_works_as_context_manager(
vault_path: Path,
master_password: str,
) -> None:
"""
Verify `with UnlockedVault.create(...) as vault:` triggers close() on exit
The recommended call site pattern is the with-block, because it
guarantees secrets get dropped even if the caller forgets to
call close(). We confirm here that exiting the `with` block
actually triggers the close behavior (empty entries, zeroed key)
"""
with _create_test_vault(vault_path, master_password) as vault:
vault.add_entry("github", _sample_entry())
assert vault.entries["github"].username == "alice"
# On block exit, sensitive material is dropped
assert vault.entries == {}
assert vault.key == bytes(32)
def test_context_manager_cleans_up_on_exception(
vault_path: Path,
master_password: str,
) -> None:
"""
Verify the context manager wipes secrets even when the body raises
The HAPPY path is easy close() runs at normal end-of-block.
The interesting case is the EXCEPTION path: if user code inside
the `with` block raises, does Python still invoke __exit__? Yes
it does, and we confirm here that __exit__ drops secrets all
the same. Without this guarantee, an unexpected error in user
code could leave plaintext credentials sitting in memory
"""
with (
pytest.raises(RuntimeError),
_create_test_vault(vault_path,
master_password) as vault,
):
vault.add_entry("github", _sample_entry())
raise RuntimeError("boom")
assert vault.entries == {}
assert vault.key == bytes(32)
# =============================================================================
# Atomicity / persistence
# =============================================================================
def test_save_uses_fresh_nonce_each_time(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify two consecutive saves produce different ciphertext (because the nonce is fresh)
The save path internally regenerates a nonce on every call.
Save once record the nonce + ciphertext. Save again with NO
entry change record again. Both fields must differ. If they
did not, we would either be reusing a nonce (security bug) or
using a counter (different security bug)
"""
fresh_vault.save()
cipher_a = json.loads(fresh_vault.path.read_text())["cipher"]
fresh_vault.save()
cipher_b = json.loads(fresh_vault.path.read_text())["cipher"]
assert cipher_a["nonce"] != cipher_b["nonce"]
assert cipher_a["ciphertext"] != cipher_b["ciphertext"]
def test_save_does_not_leave_temp_file(
fresh_vault: UnlockedVault,
) -> None:
"""
Verify the .tmp file used during save is gone after save() returns
The save flow writes vault.json.tmp first, then renames it onto
vault.json. After the rename, the .tmp name no longer exists
the same inode is now reachable under the final name. We
confirm by stat-ing the .tmp path after a successful save and
expecting "does not exist." If anyone refactored save() to copy
instead of rename, the leftover .tmp would fail this test
"""
fresh_vault.save()
tmp = fresh_vault.path.with_suffix(fresh_vault.path.suffix + ".tmp")
assert not tmp.exists()
def test_save_creates_temp_file_with_secure_mode_only(
fresh_vault: UnlockedVault,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Verify the .tmp file is created with mode 0600 from the very first syscall
Plain `Path.write_bytes` would create the file with the
process's default umask (often 0644 = world-readable). Even if
we chmod 0600 afterward, there is a microsecond window where
the .tmp is world-readable, and on a shared system that is
enough for an attacker to grab a copy
The fix in vault.py is to use raw `os.open(..., O_CREAT, 0o600)`
so the file's mode is correct from the first syscall. To VERIFY
that behavior in a test we use `monkeypatch.setattr` to swap in
a spy version of `os.open` that records the mode argument used
for any .tmp file. After save() runs, we assert every recorded
mode is exactly VAULT_FILE_MODE (0o600)
`monkeypatch` is a built-in pytest fixture that undoes its
patches automatically when the test ends no manual cleanup
"""
if os.name == "nt":
pytest.skip("POSIX-only check — Windows ignores Unix file modes")
captured_modes: list[int] = []
real_open = os.open
def spy_open( # type: ignore[no-untyped-def]
path,
flags,
mode = 0o777,
*,
dir_fd = None,
):
# Record the mode used for .tmp files, then defer to the real
# os.open so the actual write still happens normally
if str(path).endswith(".tmp"):
captured_modes.append(mode)
return real_open(path, flags, mode, dir_fd = dir_fd)
monkeypatch.setattr(os, "open", spy_open)
fresh_vault.save()
assert captured_modes, "no .tmp file was opened during save()"
assert all(m == VAULT_FILE_MODE for m in captured_modes)
def test_save_cleans_up_temp_file_on_replace_failure(
fresh_vault: UnlockedVault,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Verify save() removes the .tmp file when os.replace fails partway through
Simulated failure mode: we monkey-patch `os.replace` to raise
OSError. The save flow is "write .tmp, replace .tmp onto
vault.json." If the replace step blows up, the .tmp is still
sitting there left behind it would clutter the directory and
confuse the NEXT save (which would then try to write to a
.tmp that already exists). The cleanup in save()'s except
branch is what we are verifying here
We save once cleanly first so vault.json itself exists, then
install the broken replace, then save() again and confirm
the .tmp does not survive the failed save
"""
def explode(*_args: object, **_kwargs: object) -> None:
raise OSError("simulated replace failure")
# Save once cleanly so the vault file exists
fresh_vault.save()
monkeypatch.setattr(os, "replace", explode)
with pytest.raises(OSError, match = "simulated replace failure"):
fresh_vault.save()
tmp = fresh_vault.path.with_suffix(fresh_vault.path.suffix + ".tmp")
assert not tmp.exists()

View File

@ -0,0 +1,675 @@
version = 1
revision = 3
requires-python = ">=3.13"
resolution-markers = [
"python_full_version >= '3.15'",
"python_full_version == '3.14.*'",
"python_full_version < '3.14'",
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "argon2-cffi"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "argon2-cffi-bindings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
]
[[package]]
name = "argon2-cffi-bindings"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" },
{ url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" },
{ url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" },
{ url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" },
{ url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" },
{ url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" },
{ url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
{ url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
{ url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
{ url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
{ url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
{ url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
{ url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
{ url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
]
[[package]]
name = "ast-serialize"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" },
{ url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" },
{ url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" },
{ url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" },
{ url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" },
{ url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" },
{ url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" },
{ url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" },
{ url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" },
{ url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" },
{ url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" },
{ url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" },
{ url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" },
{ url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" },
{ url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" },
{ url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" },
{ url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" },
{ url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" },
{ url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" },
{ url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" },
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" },
]
[[package]]
name = "astroid"
version = "4.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[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.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
]
[[package]]
name = "cryptography"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
]
[[package]]
name = "dill"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "8.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" },
]
[[package]]
name = "librt"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" },
{ url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" },
{ url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" },
{ url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" },
{ url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" },
{ url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" },
{ url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" },
{ url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" },
{ url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mccabe"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ast-serialize" },
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
{ url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
{ url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
{ url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
{ url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
{ url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "password-vault"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "argon2-cffi" },
{ name = "cryptography" },
{ name = "rich" },
{ name = "typer" },
]
[package.optional-dependencies]
dev = [
{ name = "mypy" },
{ name = "pylint" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
{ name = "yapf" },
]
[package.metadata]
requires-dist = [
{ name = "argon2-cffi", specifier = ">=25.1.0" },
{ name = "cryptography", specifier = ">=48.0.0" },
{ name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" },
{ name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" },
{ name = "rich", specifier = ">=15.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" },
{ name = "typer", specifier = ">=0.25.1" },
{ name = "yapf", marker = "extra == 'dev'", specifier = ">=0.43.0,<1.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "pathspec"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[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 = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pylint"
version = "4.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "dill" },
{ name = "isort" },
{ name = "mccabe" },
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" },
]
[[package]]
name = "pytest"
version = "9.0.3"
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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.15.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "tomlkit"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
]
[[package]]
name = "typer"
version = "0.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "yapf"
version = "0.43.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" },
]