first commit

This commit is contained in:
Izalia Mae 2022-02-18 16:45:32 -05:00
commit 185d204bdc
67 changed files with 5558 additions and 0 deletions

121
.gitignore vendored Normal file
View file

@ -0,0 +1,121 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# Symlink to izzylib
izzylib
test*.py
reload.cfg

524
LICENSE Normal file
View file

@ -0,0 +1,524 @@
IzzyLib
Copyright Zoey Mae 2021
COOPERATIVE NON-VIOLENT PUBLIC LICENSE v6
Preamble
The Cooperative Non-Violent Public license is a freedom-respecting sharealike
license for both the author of a work as well as those subject to a work.
It aims to protect the basic rights of human beings from exploitation,
the earth from plunder, and the equal treatment of the workers involved in the
creation of the work. It aims to ensure a copyrighted work is forever
available for public use, modification, and redistribution under the same
terms so long as the work is not used for harm. For more information about
the CNPL refer to the official webpage
Official Webpage: https://thufie.lain.haus/NPL.html
Terms and Conditions
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
COOPERATIVE NON-VIOLENT PUBLIC LICENSE v5 ("LICENSE"). THE WORK IS
PROTECTED BY COPYRIGHT AND ALL OTHER APPLICABLE LAWS. ANY USE OF THE
WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
PROHIBITED. BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED IN THIS
LICENSE, YOU AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.
TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT,
THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN AS CONSIDERATION
FOR ACCEPTING THE TERMS AND CONDITIONS OF THIS LICENSE AND FOR AGREEING
TO BE BOUND BY THE TERMS AND CONDITIONS OF THIS LICENSE.
1. DEFINITIONS
a. "Act of War" means any action of one country against any group
either with an intention to provoke a conflict or an action that
occurs during a declared war or during armed conflict between
military forces of any origin. This includes but is not limited
to enforcing sanctions or sieges, supplying armed forces,
or profiting from the manufacture of tools or weaponry used in
military conflict.
b. "Adaptation" means a work based upon the Work, or upon the
Work and other pre-existing works, such as a translation,
adaptation, derivative work, arrangement of music or other
alterations of a literary or artistic work, or phonogram or
performance and includes cinematographic adaptations or any
other form in which the Work may be recast, transformed, or
adapted including in any form recognizably derived from the
original, except that a work that constitutes a Collection will
not be considered an Adaptation for the purpose of this License.
For the avoidance of doubt, where the Work is a musical work,
performance or phonogram, the synchronization of the Work in
timed-relation with a moving image ("synching") will be
considered an Adaptation for the purpose of this License. In
addition, where the Work is designed to output a neural network
the output of the neural network will be considered an
Adaptation for the purpose of this license.
c. "Bodily Harm" means any physical hurt or injury to a person that
interferes with the health or comfort of the person and that is more
than merely transient or trifling in nature.
d. "Collection" means a collection of literary or artistic
works, such as encyclopedias and anthologies, or performances,
phonograms or broadcasts, or other works or subject matter other
than works listed in Section 1(i) below, which, by reason of the
selection and arrangement of their contents, constitute
intellectual creations, in which the Work is included in its
entirety in unmodified form along with one or more other
contributions, each constituting separate and independent works
in themselves, which together are assembled into a collective
whole. A work that constitutes a Collection will not be
considered an Adaptation (as defined above) for the purposes of
this License.
e. "Distribute" means to make available to the public the
original and copies of the Work or Adaptation, as appropriate,
through sale, gift or any other transfer of possession or
ownership.
f. "Incarceration" means confinement in a jail, prison, or
any other place where individuals of any kind are held against
either their will or (if their will cannot be determined) the
will of their legal guardian or guardians. In the case of a
conflict between the will of the individual and the will of
their legal guardian or guardians, the will of the
individual will take precedence.
g. "Licensor" means the individual, individuals, entity or
entities that offer(s) the Work under the terms of this License.
h. "Original Author" means, in the case of a literary or
artistic work, the individual, individuals, entity or entities
who created the Work or if no individual or entity can be
identified, the publisher; and in addition (i) in the case of a
performance the actors, singers, musicians, dancers, and other
persons who act, sing, deliver, declaim, play in, interpret or
otherwise perform literary or artistic works or expressions of
folklore; (ii) in the case of a phonogram the producer being the
person or legal entity who first fixes the sounds of a
performance or other sounds; and, (iii) in the case of
broadcasts, the organization that transmits the broadcast.
i. "Work" means the literary and/or artistic work offered under
the terms of this License including without limitation any
production in the literary, scientific and artistic domain,
whatever may be the mode or form of its expression including
digital form, such as a book, pamphlet and other writing; a
lecture, address, sermon or other work of the same nature; a
dramatic or dramatico-musical work; a choreographic work or
entertainment in dumb show; a musical composition with or
without words; a cinematographic work to which are assimilated
works expressed by a process analogous to cinematography; a work
of drawing, painting, architecture, sculpture, engraving or
lithography; a photographic work to which are assimilated works
expressed by a process analogous to photography; a work of
applied art; an illustration, map, plan, sketch or
three-dimensional work relative to geography, topography,
architecture or science; a performance; a broadcast; a
phonogram; a compilation of data to the extent it is protected
as a copyrightable work; or a work performed by a variety or
circus performer to the extent it is not otherwise considered a
literary or artistic work.
j. "You" means an individual or entity exercising rights under
this License who has not previously violated the terms of this
License with respect to the Work, or who has received express
permission from the Licensor to exercise rights under this
License despite a previous violation.
k. "Publicly Perform" means to perform public recitations of the
Work and to communicate to the public those public recitations,
by any means or process, including by wire or wireless means or
public digital performances; to make available to the public
Works in such a way that members of the public may access these
Works from a place and at a place individually chosen by them;
to perform the Work to the public by any means or process and
the communication to the public of the performances of the Work,
including by public digital performance; to broadcast and
rebroadcast the Work by any means including signs, sounds or
images.
l. "Reproduce" means to make copies of the Work by any means
including without limitation by sound or visual recordings and
the right of fixation and reproducing fixations of the Work,
including storage of a protected performance or phonogram in
digital form or other electronic medium.
m. "Software" means any digital Work which, through use of a
third-party piece of Software or through the direct usage of
itself on a computer system, the memory of the computer is
modified dynamically or semi-dynamically. "Software",
secondly, processes or interprets information.
n. "Source Code" means the human-readable form of Software
through which the Original Author and/or Distributor originally
created, derived, and/or modified it.
o. "Surveilling" means the use of the Work to either
overtly or covertly observe and record persons and or their
activities.
p. "Network Service" means the use of a piece of Software to
interpret or modify information that is subsequently and directly
served to users over the Internet.
q. "Discriminate" means the use of a work to differentiate between
humans in a such a way which prioritizes some above others on the
basis of percieved membership within certain groups.
r. "Hate Speech" means communication or any form
of expression which is solely for the purpose of expressing hatred
for some group or advocating a form of Discrimination
(to Discriminate per definition in (q)) between humans.
s. "Coercion" means leveraging of the threat of force or use of force
to intimidate a person in order to gain compliance, or to offer
large incentives which aim to entice a person to act against their
will.
2. FAIR DEALING RIGHTS
Nothing in this License is intended to reduce, limit, or restrict any
uses free from copyright or rights arising from limitations or
exceptions that are provided for in connection with the copyright
protection under copyright law or other applicable laws.
3. LICENSE GRANT
Subject to the terms and conditions of this License, Licensor hereby
grants You a worldwide, royalty-free, non-exclusive, perpetual (for the
duration of the applicable copyright) license to exercise the rights in
the Work as stated below:
a. to Reproduce the Work, to incorporate the Work into one or
more Collections, and to Reproduce the Work as incorporated in
the Collections;
b. to create and Reproduce Adaptations provided that any such
Adaptation, including any translation in any medium, takes
reasonable steps to clearly label, demarcate or otherwise
identify that changes were made to the original Work. For
example, a translation could be marked "The original work was
translated from English to Spanish," or a modification could
indicate "The original work has been modified.";
c. to Distribute and Publicly Perform the Work including as
incorporated in Collections; and,
d. to Distribute and Publicly Perform Adaptations. The above
rights may be exercised in all media and formats whether now
known or hereafter devised. The above rights include the right
to make such modifications as are technically necessary to
exercise the rights in other media and formats. Subject to
Section 8(g), all rights not expressly granted by Licensor are
hereby reserved, including but not limited to the rights set
forth in Section 4(i).
4. RESTRICTIONS
The license granted in Section 3 above is expressly made subject to and
limited by the following restrictions:
a. You may Distribute or Publicly Perform the Work only under
the terms of this License. You must include a copy of, or the
Uniform Resource Identifier (URI) for, this License with every
copy of the Work You Distribute or Publicly Perform. You may not
offer or impose any terms on the Work that restrict the terms of
this License or the ability of the recipient of the Work to
exercise the rights granted to that recipient under the terms of
the License. You may not sublicense the Work. You must keep
intact all notices that refer to this License and to the
disclaimer of warranties with every copy of the Work You
Distribute or Publicly Perform. When You Distribute or Publicly
Perform the Work, You may not impose any effective technological
measures on the Work that restrict the ability of a recipient of
the Work from You to exercise the rights granted to that
recipient under the terms of the License. This Section 4(a)
applies to the Work as incorporated in a Collection, but this
does not require the Collection apart from the Work itself to be
made subject to the terms of this License. If You create a
Collection, upon notice from any Licensor You must, to the
extent practicable, remove from the Collection any credit as
required by Section 4(h), as requested. If You create an
Adaptation, upon notice from any Licensor You must, to the
extent practicable, remove from the Adaptation any credit as
required by Section 4(h), as requested.
b. Subject to the exception in Section 4(e), you may not
exercise any of the rights granted to You in Section 3 above in
any manner that is primarily intended for or directed toward
commercial advantage or private monetary compensation. The
exchange of the Work for other copyrighted works by means of
digital file-sharing or otherwise shall not be considered to be
intended for or directed toward commercial advantage or private
monetary compensation, provided there is no payment of any
monetary compensation in connection with the exchange of
copyrighted works.
c. If the Work meets the definition of Software, You may exercise
the rights granted in Section 3 only if You provide a copy of the
corresponding Source Code from which the Work was derived in digital
form, or You provide a URI for the corresponding Source Code of
the Work, to any recipients upon request.
d. If the Work is used as or for a Network Service, You may exercise
the rights granted in Section 3 only if You provide a copy of the
corresponding Source Code from which the Work was derived in digital
form, or You provide a URI for the corresponding Source Code to the
Work, to any recipients of the data served or modified by the Web
Service.
e. You may exercise the rights granted in Section 3 for
commercial purposes only if:
i. You are a worker-owned business or worker-owned
collective; and
ii. after tax, all financial gain, surplus, profits and
benefits produced by the business or collective are
distributed among the worker-owners unless a set amount
is to be allocated towards community projects as decided
by a previously-established consensus agreement between the
worker-owners where all worker-owners agreed
iii. You are not using such rights on behalf of a business
other than those specified in 4(e.i) and elaborated upon in
4(e.ii), nor are using such rights as a proxy on behalf of a
business with the intent to circumvent the aforementioned
restrictions on such a business.
f. Any use by a business that is privately owned and managed,
and that seeks to generate profit from the labor of employees
paid by salary or other wages, is not permitted under this
license.
g. You may exercise the rights granted in Section 3 for
any purposes only if:
i. You do not use the Work for the purpose of inflicting
Bodily Harm on human beings (subject to criminal
prosecution or otherwise) outside of providing medical aid
or undergoing a voluntary procedure under no form of
Coercion.
ii.You do not use the Work for the purpose of Surveilling
or tracking individuals for financial gain.
iii. You do not use the Work in an Act of War.
iv. You do not use the Work for the purpose of supporting
or profiting from an Act of War.
v. You do not use the Work for the purpose of Incarceration.
vi. You do not use the Work for the purpose of extracting,
processing, or refining, oil, gas, or coal. Or to in any other
way to deliberately pollute the environment as a byproduct
of manufacturing or irresponsible disposal of hazardous materials.
vii. You do not use the Work for the purpose of
expediting, coordinating, or facilitating paid work
undertaken by individuals under the age of 12 years.
viii. You do not use the Work to either Discriminate or
spread Hate Speech on the basis of sex, sexual orientation,
gender identity, race, age, disability, color, national origin,
religion, or lower economic status.
h. If You Distribute, or Publicly Perform the Work or any
Adaptations or Collections, You must, unless a request has been
made pursuant to Section 4(a), keep intact all copyright notices
for the Work and provide, reasonable to the medium or means You
are utilizing: (i) the name of the Original Author (or
pseudonym, if applicable) if supplied, and/or if the Original
Author and/or Licensor designate another party or parties (e.g.,
a sponsor institute, publishing entity, journal) for attribution
("Attribution Parties") in Licensor's copyright notice, terms of
service or by other reasonable means, the name of such party or
parties; (ii) the title of the Work if supplied; (iii) to the
extent reasonably practicable, the URI, if any, that Licensor
to be associated with the Work, unless such URI does
not refer to the copyright notice or licensing information for
the Work; and, (iv) consistent with Section 3(b), in the case of
an Adaptation, a credit identifying the use of the Work in the
Adaptation (e.g., "French translation of the Work by Original
Author," or "Screenplay based on original Work by Original
Author"). The credit required by this Section 4(h) may be
implemented in any reasonable manner; provided, however, that in
the case of an Adaptation or Collection, at a minimum such credit
will appear, if a credit for all contributing authors of the
Adaptation or Collection appears, then as part of these credits
and in a manner at least as prominent as the credits for the
other contributing authors. For the avoidance of doubt, You may
only use the credit required by this Section for the purpose of
attribution in the manner set out above and, by exercising Your
rights under this License, You may not implicitly or explicitly
assert or imply any connection with, sponsorship or endorsement
by the Original Author, Licensor and/or Attribution Parties, as
appropriate, of You or Your use of the Work, without the
separate, express prior written permission of the Original
Author, Licensor and/or Attribution Parties.
i. For the avoidance of doubt:
i. Non-waivable Compulsory License Schemes. In those
jurisdictions in which the right to collect royalties
through any statutory or compulsory licensing scheme
cannot be waived, the Licensor reserves the exclusive
right to collect such royalties for any exercise by You of
the rights granted under this License;
ii. Waivable Compulsory License Schemes. In those
jurisdictions in which the right to collect royalties
through any statutory or compulsory licensing scheme can
be waived, the Licensor reserves the exclusive right to
collect such royalties for any exercise by You of the
rights granted under this License if Your exercise of such
rights is for a purpose or use which is otherwise than
noncommercial as permitted under Section 4(b) and
otherwise waives the right to collect royalties through
any statutory or compulsory licensing scheme; and,
iii.Voluntary License Schemes. The Licensor reserves the
right to collect royalties, whether individually or, in
the event that the Licensor is a member of a collecting
society that administers voluntary licensing schemes, via
that society, from any exercise by You of the rights
granted under this License that is for a purpose or use
which is otherwise than noncommercial as permitted under
Section 4(b).
j. Except as otherwise agreed in writing by the Licensor or as
may be otherwise permitted by applicable law, if You Reproduce,
Distribute or Publicly Perform the Work either by itself or as
part of any Adaptations or Collections, You must not distort,
mutilate, modify or take other derogatory action in relation to
the Work which would be prejudicial to the Original Author's
honor or reputation. Licensor agrees that in those jurisdictions
(e.g. Japan), in which any exercise of the right granted in
Section 3(b) of this License (the right to make Adaptations)
would be deemed to be a distortion, mutilation, modification or
other derogatory action prejudicial to the Original Author's
honor and reputation, the Licensor will waive or not assert, as
appropriate, this Section, to the fullest extent permitted by
the applicable national law, to enable You to reasonably
exercise Your right under Section 3(b) of this License (right to
make Adaptations) but not otherwise.
k. Do not make any legal claim against anyone accusing the
Work, with or without changes, alone or with other works,
of infringing any patent claim.
5. REPRESENTATIONS, WARRANTIES AND DISCLAIMER
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR
OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY
KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE,
INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY,
FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF
LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF
ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW
THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO
YOU.
6. LIMITATION ON LIABILITY
EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL
LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF
THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED
OF THE POSSIBILITY OF SUCH DAMAGES.
7. TERMINATION
a. This License and the rights granted hereunder will terminate
automatically upon any breach by You of the terms of this
License. Individuals or entities who have received Adaptations
or Collections from You under this License, however, will not
have their licenses terminated provided such individuals or
entities remain in full compliance with those licenses. Sections
1, 2, 5, 6, 7, and 8 will survive any termination of this
License.
b. Subject to the above terms and conditions, the license
granted here is perpetual (for the duration of the applicable
copyright in the Work). Notwithstanding the above, Licensor
reserves the right to release the Work under different license
terms or to stop distributing the Work at any time; provided,
however that any such election will not serve to withdraw this
License (or any other license that has been, or is required to
be, granted under the terms of this License), and this License
will continue in full force and effect unless terminated as
stated above.
8. REVISED LICENSE VERSIONS
a. This License may receive future revisions in the original
spirit of the license intended to strengthen This License.
Each version of This License has an incrementing version number.
b. Unless otherwise specified like in Section 8(c) The Licensor
has only granted this current version of This License for The Work.
In this case future revisions do not apply.
c. The Licensor may specify that the latest available
revision of This License be used for The Work by either explicitly
writing so or by suffixing the License URI with a "+" symbol.
d. The Licensor may specify that The Work is also available
under the terms of This License's current revision as well
as specific future revisions. The Licensor may do this by
writing it explicitly or suffixing the License URI with any
additional version numbers each separated by a comma.
9. MISCELLANEOUS
a. Each time You Distribute or Publicly Perform the Work or a
Collection, the Licensor offers to the recipient a license to
the Work on the same terms and conditions as the license granted
to You under this License.
b. Each time You Distribute or Publicly Perform an Adaptation,
Licensor offers to the recipient a license to the original Work
on the same terms and conditions as the license granted to You
under this License.
c. If the Work is classified as Software, each time You Distribute
or Publicly Perform an Adaptation, Licensor offers to the recipient
a copy and/or URI of the corresponding Source Code on the same
terms and conditions as the license granted to You under this License.
d. If the Work is used as a Network Service, each time You Distribute
or Publicly Perform an Adaptation, or serve data derived from the
Software, the Licensor offers to any recipients of the data a copy
and/or URI of the corresponding Source Code on the same terms and
conditions as the license granted to You under this License.
e. If any provision of this License is invalid or unenforceable
under applicable law, it shall not affect the validity or
enforceability of the remainder of the terms of this License,
and without further action by the parties to this agreement,
such provision shall be reformed to the minimum extent necessary
to make such provision valid and enforceable.
f. No term or provision of this License shall be deemed waived
and no breach consented to unless such waiver or consent shall
be in writing and signed by the party to be charged with such
waiver or consent.
g. This License constitutes the entire agreement between the
parties with respect to the Work licensed here. There are no
understandings, agreements or representations with respect to
the Work not specified here. Licensor shall not be bound by any
additional provisions that may appear in any communication from
You. This License may not be modified without the mutual written
agreement of the Licensor and You.
h. The rights granted under, and the subject matter referenced,
in this License were drafted utilizing the terminology of the
Berne Convention for the Protection of Literary and Artistic
Works (as amended on September 28, 1979), the Rome Convention of
1961, the WIPO Copyright Treaty of 1996, the WIPO Performances
and Phonograms Treaty of 1996 and the Universal Copyright
Convention (as revised on July 24, 1971). These rights and
subject matter take effect in the relevant jurisdiction in which
the License terms are sought to be enforced according to the
corresponding provisions of the implementation of those treaty
provisions in the applicable national law. If the standard suite
of rights granted under applicable copyright law includes
additional rights not granted under this License, such
additional rights are deemed to be included in the License; this
License is not intended to restrict the license of any rights
under applicable law.

1
MANIFEST.in Normal file
View file

@ -0,0 +1 @@
recursive-include barkshark_http/frontend *

20
Pipfile Normal file
View file

@ -0,0 +1,20 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
colour = "*"
hamlish-jinja = "*"
http-router = "*"
python-magic = "*"
pycryptodome = "*"
tldextract = "*"
jinja2 = "*"
argon2-cffi = "*"
markdown = "*"
[dev-packages]
[requires]
python_version = "3.9"

351
Pipfile.lock generated Normal file
View file

@ -0,0 +1,351 @@
{
"_meta": {
"hash": {
"sha256": "b556a2c8ff27882c23a6c2eb27b3f661501055d8cafc22e88db1508e6af072a2"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.9"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"argon2-cffi": {
"hashes": [
"sha256:8c976986f2c5c0e5000919e6de187906cfd81fb1c72bf9d88c01177e77da7f80",
"sha256:d384164d944190a7dd7ef22c6aa3ff197da12962bd04b17f64d4e93d934dba5b"
],
"index": "pypi",
"version": "==21.3.0"
},
"argon2-cffi-bindings": {
"hashes": [
"sha256:20ef543a89dee4db46a1a6e206cd015360e5a75822f76df533845c3cbaf72670",
"sha256:2c3e3cc67fdb7d82c4718f19b4e7a87123caf8a93fde7e23cf66ac0337d3cb3f",
"sha256:3b9ef65804859d335dc6b31582cad2c5166f0c3e7975f324d9ffaa34ee7e6583",
"sha256:3e385d1c39c520c08b53d63300c3ecc28622f076f4c2b0e6d7e796e9f6502194",
"sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c",
"sha256:5e00316dabdaea0b2dd82d141cc66889ced0cdcbfa599e8b471cf22c620c329a",
"sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082",
"sha256:6a22ad9800121b71099d0fb0a65323810a15f2e292f2ba450810a7316e128ee5",
"sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f",
"sha256:93f9bf70084f97245ba10ee36575f0c3f1e7d7724d67d8e5b08e61787c320ed7",
"sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d",
"sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f",
"sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae",
"sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3",
"sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86",
"sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367",
"sha256:d4966ef5848d820776f5f562a7d45fdd70c2f330c961d0d745b784034bd9f48d",
"sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93",
"sha256:ed2937d286e2ad0cc79a7087d3c272832865f779430e0cc2b4f3718d3159b0cb",
"sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e",
"sha256:f9f8b450ed0547e3d473fdc8612083fd08dd2120d6ac8f73828df9b7d45bb351"
],
"markers": "python_version >= '3.6'",
"version": "==21.2.0"
},
"certifi": {
"hashes": [
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
],
"version": "==2021.10.8"
},
"cffi": {
"hashes": [
"sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3",
"sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2",
"sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636",
"sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20",
"sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728",
"sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27",
"sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66",
"sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443",
"sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0",
"sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7",
"sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39",
"sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605",
"sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a",
"sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37",
"sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029",
"sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139",
"sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc",
"sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df",
"sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14",
"sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880",
"sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2",
"sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a",
"sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e",
"sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474",
"sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024",
"sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8",
"sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0",
"sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e",
"sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a",
"sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e",
"sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032",
"sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6",
"sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e",
"sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b",
"sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e",
"sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954",
"sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962",
"sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c",
"sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4",
"sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55",
"sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962",
"sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023",
"sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c",
"sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6",
"sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8",
"sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382",
"sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7",
"sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc",
"sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997",
"sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"
],
"version": "==1.15.0"
},
"charset-normalizer": {
"hashes": [
"sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597",
"sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"
],
"markers": "python_version >= '3'",
"version": "==2.0.12"
},
"colour": {
"hashes": [
"sha256:33f6db9d564fadc16e59921a56999b79571160ce09916303d35346dddc17978c",
"sha256:af20120fefd2afede8b001fbef2ea9da70ad7d49fafdb6489025dae8745c3aee"
],
"index": "pypi",
"version": "==0.1.5"
},
"filelock": {
"hashes": [
"sha256:9cd540a9352e432c7246a48fe4e8712b10acb1df2ad1f30e8c070b82ae1fed85",
"sha256:f8314284bfffbdcfa0ff3d7992b023d4c628ced6feb957351d4c48d059f56bc0"
],
"markers": "python_version >= '3.7'",
"version": "==3.6.0"
},
"hamlish-jinja": {
"hashes": [
"sha256:4a694a0eb51d0ab7237a96dd7d87aab478bcbdd112b9601097f9731ed7bd173c"
],
"index": "pypi",
"version": "==0.3.3"
},
"http-router": {
"hashes": [
"sha256:0a754392dff0169489daebbc6dc521a452347dff4b56aeea631087dafd8d2223",
"sha256:0bf70959e6fe412b58d010af07310f4d703af27df23b2a13caaf7c69e5499d86",
"sha256:0eb96e9148ab1500f825e6f6b40c20571dcbb7ac7846e52414305b24fb9f6779",
"sha256:3a4cc60da8fe811efb8a5cfc0e55ee205b7a0b08c48074c6f05afc87f6bca6c0",
"sha256:3a675ca74f3c5b2e8532d4a3a5f9441eded46313776be17595e7e53bb9c3bb2f",
"sha256:3d8ba7209051c03b6e78e304774966fc0177850a0f836f307ab79174816f15a5",
"sha256:508ecb308bae9a9f42cf4a48245fae027aaf826211808c9da0fca6c851f15e3d",
"sha256:74e2cf082d7b3bb5c9837b0c583837e6accd77f2ec1fe69aa0a90751c546a123",
"sha256:814694ca90adb79793ae8822d5ab6bf39631f3e35526dafd1f54c8ecfa26c54f",
"sha256:8d4d635aacbc8d110a0ee6331ca5feaab27e77ef1a6841a2c994fe49f8a18458",
"sha256:91d6615d25d28420d8e701d14e50fc8c5e2e6ca05dc609f896852304f49f3dac",
"sha256:9c0e9e324c26ffbac83cc588a6141bca31e875ef9558a273109d4e159684b380",
"sha256:a03ad3addba310f9ea5a0a52fb852e27a19dabff95419e1661a93b56d70c0d51",
"sha256:c37a415951532c3db79e69412bd290c9dafa19d2aad4180c24eaa114a45604b3",
"sha256:cb6cc3da118eb4a41e566632d5dc5860fbbd9b58bf836fb1c9ceec19fffa35fa",
"sha256:dd1a72901b30dd65b72556e291b992735784730e8802b0e943e0aafea110bf80",
"sha256:dd2c513e3fd385a3b15a6ff3fd59f15890bccfa41746c5b305d898e5a66e6d0e",
"sha256:e531cc214c63bb640a0cd03c73c83601d5ee69ffc64f763bd905b024b9b7c3eb",
"sha256:efe4280a999d2d7b68062a085e3d12a73b6a6551f1da546a4ac3bcdc793eae0b",
"sha256:fb7455d318f43abe26a525d7963cdacb9b0763b5e9fdd04054af2d2e2b60dc73"
],
"index": "pypi",
"version": "==2.6.5"
},
"idna": {
"hashes": [
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '3.5'",
"version": "==3.3"
},
"importlib-metadata": {
"hashes": [
"sha256:175f4ee440a0317f6e8d81b7f8d4869f93316170a65ad2b007d2929186c8052c",
"sha256:e0bc84ff355328a4adfc5240c4f211e0ab386f80aa640d1b11f0618a1d282094"
],
"markers": "python_version < '3.10'",
"version": "==4.11.1"
},
"jinja2": {
"hashes": [
"sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8",
"sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"
],
"index": "pypi",
"version": "==3.0.3"
},
"markdown": {
"hashes": [
"sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006",
"sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"
],
"index": "pypi",
"version": "==3.3.6"
},
"markupsafe": {
"hashes": [
"sha256:023af8c54fe63530545f70dd2a2a7eed18d07a9a77b94e8bf1e2ff7f252db9a3",
"sha256:09c86c9643cceb1d87ca08cdc30160d1b7ab49a8a21564868921959bd16441b8",
"sha256:142119fb14a1ef6d758912b25c4e803c3ff66920635c44078666fe7cc3f8f759",
"sha256:1d1fb9b2eec3c9714dd936860850300b51dbaa37404209c8d4cb66547884b7ed",
"sha256:204730fd5fe2fe3b1e9ccadb2bd18ba8712b111dcabce185af0b3b5285a7c989",
"sha256:24c3be29abb6b34052fd26fc7a8e0a49b1ee9d282e3665e8ad09a0a68faee5b3",
"sha256:290b02bab3c9e216da57c1d11d2ba73a9f73a614bbdcc027d299a60cdfabb11a",
"sha256:3028252424c72b2602a323f70fbf50aa80a5d3aa616ea6add4ba21ae9cc9da4c",
"sha256:30c653fde75a6e5eb814d2a0a89378f83d1d3f502ab710904ee585c38888816c",
"sha256:3cace1837bc84e63b3fd2dfce37f08f8c18aeb81ef5cf6bb9b51f625cb4e6cd8",
"sha256:4056f752015dfa9828dce3140dbadd543b555afb3252507348c493def166d454",
"sha256:454ffc1cbb75227d15667c09f164a0099159da0c1f3d2636aa648f12675491ad",
"sha256:598b65d74615c021423bd45c2bc5e9b59539c875a9bdb7e5f2a6b92dfcfc268d",
"sha256:599941da468f2cf22bf90a84f6e2a65524e87be2fce844f96f2dd9a6c9d1e635",
"sha256:5ddea4c352a488b5e1069069f2f501006b1a4362cb906bee9a193ef1245a7a61",
"sha256:62c0285e91414f5c8f621a17b69fc0088394ccdaa961ef469e833dbff64bd5ea",
"sha256:679cbb78914ab212c49c67ba2c7396dc599a8479de51b9a87b174700abd9ea49",
"sha256:6e104c0c2b4cd765b4e83909cde7ec61a1e313f8a75775897db321450e928cce",
"sha256:736895a020e31b428b3382a7887bfea96102c529530299f426bf2e636aacec9e",
"sha256:75bb36f134883fdbe13d8e63b8675f5f12b80bb6627f7714c7d6c5becf22719f",
"sha256:7d2f5d97fcbd004c03df8d8fe2b973fe2b14e7bfeb2cfa012eaa8759ce9a762f",
"sha256:80beaf63ddfbc64a0452b841d8036ca0611e049650e20afcb882f5d3c266d65f",
"sha256:84ad5e29bf8bab3ad70fd707d3c05524862bddc54dc040982b0dbcff36481de7",
"sha256:8da5924cb1f9064589767b0f3fc39d03e3d0fb5aa29e0cb21d43106519bd624a",
"sha256:961eb86e5be7d0973789f30ebcf6caab60b844203f4396ece27310295a6082c7",
"sha256:96de1932237abe0a13ba68b63e94113678c379dca45afa040a17b6e1ad7ed076",
"sha256:a0a0abef2ca47b33fb615b491ce31b055ef2430de52c5b3fb19a4042dbc5cadb",
"sha256:b2a5a856019d2833c56a3dcac1b80fe795c95f401818ea963594b345929dffa7",
"sha256:b8811d48078d1cf2a6863dafb896e68406c5f513048451cd2ded0473133473c7",
"sha256:c532d5ab79be0199fa2658e24a02fce8542df196e60665dd322409a03db6a52c",
"sha256:d3b64c65328cb4cd252c94f83e66e3d7acf8891e60ebf588d7b493a55a1dbf26",
"sha256:d4e702eea4a2903441f2735799d217f4ac1b55f7d8ad96ab7d4e25417cb0827c",
"sha256:d5653619b3eb5cbd35bfba3c12d575db2a74d15e0e1c08bf1db788069d410ce8",
"sha256:d66624f04de4af8bbf1c7f21cc06649c1c69a7f84109179add573ce35e46d448",
"sha256:e67ec74fada3841b8c5f4c4f197bea916025cb9aa3fe5abf7d52b655d042f956",
"sha256:e6f7f3f41faffaea6596da86ecc2389672fa949bd035251eab26dc6697451d05",
"sha256:f02cf7221d5cd915d7fa58ab64f7ee6dd0f6cddbb48683debf5d04ae9b1c2cc1",
"sha256:f0eddfcabd6936558ec020130f932d479930581171368fd728efcfb6ef0dd357",
"sha256:fabbe18087c3d33c5824cb145ffca52eccd053061df1d79d4b66dafa5ad2a5ea",
"sha256:fc3150f85e2dbcf99e65238c842d1cfe69d3e7649b19864c1cc043213d9cd730"
],
"markers": "python_version >= '3.7'",
"version": "==2.1.0"
},
"pycparser": {
"hashes": [
"sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9",
"sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"
],
"version": "==2.21"
},
"pycryptodome": {
"hashes": [
"sha256:028dcbf62d128b4335b61c9fbb7dd8c376594db607ef36d5721ee659719935d5",
"sha256:12ef157eb1e01a157ca43eda275fa68f8db0dd2792bc4fe00479ab8f0e6ae075",
"sha256:2562de213960693b6d657098505fd4493c45f3429304da67efcbeb61f0edfe89",
"sha256:27e92c1293afcb8d2639baf7eb43f4baada86e4de0f1fb22312bfc989b95dae2",
"sha256:36e3242c4792e54ed906c53f5d840712793dc68b726ec6baefd8d978c5282d30",
"sha256:50a5346af703330944bea503106cd50c9c2212174cfcb9939db4deb5305a8367",
"sha256:53dedbd2a6a0b02924718b520a723e88bcf22e37076191eb9b91b79934fb2192",
"sha256:69f05aaa90c99ac2f2af72d8d7f185f729721ad7c4be89e9e3d0ab101b0ee875",
"sha256:75a3a364fee153e77ed889c957f6f94ec6d234b82e7195b117180dcc9fc16f96",
"sha256:766a8e9832128c70012e0c2b263049506cbf334fb21ff7224e2704102b6ef59e",
"sha256:7fb90a5000cc9c9ff34b4d99f7f039e9c3477700e309ff234eafca7b7471afc0",
"sha256:893f32210de74b9f8ac869ed66c97d04e7d351182d6d39ebd3b36d3db8bda65d",
"sha256:8b5c28058102e2974b9868d72ae5144128485d466ba8739abd674b77971454cc",
"sha256:924b6aad5386fb54f2645f22658cb0398b1f25bc1e714a6d1522c75d527deaa5",
"sha256:9924248d6920b59c260adcae3ee231cd5af404ac706ad30aa4cd87051bf09c50",
"sha256:9ec761a35dbac4a99dcbc5cd557e6e57432ddf3e17af8c3c86b44af9da0189c0",
"sha256:a36ab51674b014ba03da7f98b675fcb8eabd709a2d8e18219f784aba2db73b72",
"sha256:aae395f79fa549fb1f6e3dc85cf277f0351e15a22e6547250056c7f0c990d6a5",
"sha256:c880a98376939165b7dc504559f60abe234b99e294523a273847f9e7756f4132",
"sha256:ce7a875694cd6ccd8682017a7c06c6483600f151d8916f2b25cf7a439e600263",
"sha256:d1b7739b68a032ad14c5e51f7e4e1a5f92f3628bba024a2bda1f30c481fc85d8",
"sha256:dcd65355acba9a1d0fc9b923875da35ed50506e339b35436277703d7ace3e222",
"sha256:e04e40a7f8c1669195536a37979dd87da2c32dbdc73d6fe35f0077b0c17c803b",
"sha256:e0c04c41e9ade19fbc0eff6aacea40b831bfcb2c91c266137bcdfd0d7b2f33ba",
"sha256:e24d4ec4b029611359566c52f31af45c5aecde7ef90bf8f31620fd44c438efe7",
"sha256:e64738207a02a83590df35f59d708bf1e7ea0d6adce712a777be2967e5f7043c",
"sha256:ea56a35fd0d13121417d39a83f291017551fa2c62d6daa6b04af6ece7ed30d84",
"sha256:f2772af1c3ef8025c85335f8b828d0193fa1e43256621f613280e2c81bfad423",
"sha256:f403a3e297a59d94121cb3ee4b1cf41f844332940a62d71f9e4a009cc3533493",
"sha256:f572a3ff7b6029dd9b904d6be4e0ce9e309dcb847b03e3ac8698d9d23bb36525"
],
"index": "pypi",
"version": "==3.14.1"
},
"python-magic": {
"hashes": [
"sha256:1a2c81e8f395c744536369790bd75094665e9644110a6623bcc3bbea30f03973",
"sha256:21f5f542aa0330f5c8a64442528542f6215c8e18d2466b399b0d9d39356d83fc"
],
"index": "pypi",
"version": "==0.4.25"
},
"requests": {
"hashes": [
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==2.27.1"
},
"requests-file": {
"hashes": [
"sha256:07d74208d3389d01c38ab89ef403af0cfec63957d53a0081d8eca738d0247d8e",
"sha256:dfe5dae75c12481f68ba353183c53a65e6044c923e64c24b2209f6c7570ca953"
],
"version": "==1.5.1"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"tldextract": {
"hashes": [
"sha256:d2034c3558651f7d8fdadea83fb681050b2d662dc67a00d950326dc902029444",
"sha256:f55e05f6bf4cc952a87d13594386d32ad2dd265630a8bdfc3df03bd60425c6b0"
],
"index": "pypi",
"version": "==3.1.2"
},
"urllib3": {
"hashes": [
"sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed",
"sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.8"
},
"zipp": {
"hashes": [
"sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d",
"sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"
],
"markers": "python_version >= '3.7'",
"version": "==3.7.0"
}
},
"develop": {}
}

7
README.md Normal file
View file

@ -0,0 +1,7 @@
# Barkshark Async HTTP
A simple HTTP client and server framework based on the built-in asyncio module.
# NOTE
Not in a stable state yet. Expect major changes.

View file

@ -0,0 +1,7 @@
__software__ = 'Barkshark Async HTTP'
__version__ = '0.0.1'
from . import client
try: from . import server
except ImportError: pass

View file

@ -0,0 +1,771 @@
import json, mimetypes, traceback
from datetime import datetime, timezone
from functools import partial
from typing import Union
from xml.etree.ElementTree import fromstring
from .dotdict import DotDict
from .misc import DateString, Url, boolean
pubstr = 'https://www.w3.org/ns/activitystreams#Public'
actor_types = ['Application', 'Group', 'Organization', 'Person', 'Service']
activity_types = [
'Accept', 'Add', 'Announce', 'Arrive', 'Block', 'Create', 'Delete', 'Dislike',
'Flag', 'Follow', 'Ignore', 'Invite', 'Join', 'Leave', 'Like', 'Listen',
'Move', 'Offer', 'Question', 'Reject', 'Read', 'Remove', 'TentativeAccept',
'TentativeReject', 'Travel', 'Undo', 'Update', 'View'
]
link_types = ['Mention']
object_types = [
'Article', 'Audio', 'Document', 'Event', 'Image', 'Note', 'Page', 'Place',
'Profile', 'Relationship', 'Tombstone', 'Video'
]
url_keys = [
'attributedTo', 'url', 'href', 'object', 'id', 'actor', 'partOf', 'target'
]
def parse_privacy_level(to: list=[], cc: list=[], followers=None):
if pubstr in to and followers in cc:
return 'public'
elif followers in to and pubstr in cc:
return 'unlisted'
elif pubstr not in to and pubstr not in cc and followers in cc:
return 'private'
elif not tuple(item for item in [*to, *cc] if item not in [pubstr, followers]):
return 'direct'
else:
logging.warning('Not sure what this privacy level is')
logging.debug(f'to: {json.dumps(to)}')
logging.debug(f'cc: {json.dumps(cc)}')
logging.debug(f'followers: {followers}')
def generate_privacy_fields(privacy='public', followers=None, to=[], cc=[]):
if privacy == 'public':
to = [pubstr, *to]
cc = [followers, *to]
elif privacy == 'unlisted':
to = [followers, *to]
cc = [pubstr, *to]
elif privacy == 'private':
cc = [followers, *cc]
elif privacy == 'direct':
pass
else:
raise ValueError(f'Unknown privacy level: {privacy}')
return to, cc
class Object(DotDict):
def __setitem__(self, key, value):
if type(key) == str and key in url_keys:
value = Url(value)
elif key == 'object' and isinstance(key, dict):
value = Object(value)
super().__setitem__(key, value)
@classmethod
def new_activity(cls, id: str, type: str, actor_src: Union[str, dict], object: Union[str, dict], to: list=[pubstr], cc: list=[]):
assert type in activity_types
activity = cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id,
'object': object,
'type': type,
'actor': actor_src
})
if to:
activity.to = to
if cc:
activity.cc = cc
return activity
@classmethod
def new_note(cls, id, url, actor, content, **kwargs):
assert False not in map(isinstance, [id, actor, url], [Url])
if kwargs.get('date'):
date = DateString.from_datetime(kwargs['date'], 'activitypub')
else:
date = DateString.now('activitypub')
return cls({
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"sensitive": "as:sensitive",
"toot": "http://joinmastodon.org/ns#",
#"votersCount": "toot:votersCount",
#"litepub": "http://litepub.social/ns#",
#"directMessage": "litepub:directMessage"
}
],
"id": id,
"type": "Note",
"summary": kwargs.get('summary'),
#"inReplyTo": kwargs.get('replyto'),
"published": date,
"url": url,
"attributedTo": actor,
"to": [
"https://www.w3.org/ns/activitystreams#Public"
],
"cc": [
f'{actor}/followers'
],
"sensitive": kwargs.get('sensitive', False),
"content": f'{content}',
#"contentMap": {
#"en": content
#},
#"attachment": [],
#"tag": [],
#"replies": {
#"id": f"{id}/replies",
#"type": "Collection",
#"first": {
#"type": "CollectionPage",
#"next": f"{id}/replies?only_other_accounts=true&page=true",
#"partOf": f"{id}/replies",
#"items": []
#}
#}
})
@classmethod
def new_actor(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs):
actor_type = kwargs.get('type', 'Person').title()
assert actor_type in actor_types
actor = Url(actor)
data = cls({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'schema': 'http://schema.org',
'toot': 'https://joinmastodon.org/ns#',
#'Device': 'toot:Device',
#'Ed25519Signature': 'toot:Ed25519Signature',
#'Ed25519Key': 'toot:Ed25519Key',
#'Curve25519Key': 'toot:Curve25519Key',
#'EncryptedMessage': 'toot:EncryptedMessage',
#'publicKeyBase64': 'toot:publicKeyBase64',
#'deviceId': 'toot:deviceId',
#'messageFranking': 'toot:messageFranking',
'messageType': 'toot:messageType',
#'cipherText': 'toot:cipherText',
#'suspended': 'toot:suspended',
"claim": {
"@type": "@id",
"@id": "toot:claim"
}
}
],
'id': actor,
'type': actor_type,
'inbox': kwargs.get('inbox', f'{actor}'),
'outbox': f'{actor}/outbox',
'preferredUsername': handle,
'url': kwargs.get('url', actor),
'manuallyApprovesFollowers': kwargs.get('locked', False),
'discoverable': kwargs.get('discoverable', False),
'published': published or DateString.now('activitypub'),
'publicKey': {
'id': f'{actor}#main-key',
'owner': actor,
'publicKeyPem': pubkey
},
'endpoints': {
'sharedInbox': kwargs.get('shared_inbox', f'https://{actor.host}/inbox')
}
})
for key, value in table.items():
data.attachment.append(PropertyValue(key, value))
if kwargs.get('avatar_url'):
data.icon = Object.new_image(kwargs.get('avatar_url'), kwargs.get('avatar_type'))
if full:
data.update({
'name': kwargs.get('display_name', handle),
'summary': kwargs.get('bio'),
'featured': f'{actor}/collections/featured',
'tags': f'{actor}/collections/tags',
'following': f'{actor}/following',
'followers': f'{actor}/followers',
'tag': [],
'attachment': [],
})
data['@context'][2].update({
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'discoverable': 'toot:discoverable',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
'Emoji': 'toot:Emoji',
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
},
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"claim": {
"@type": "@id",
"@id": "toot:claim"
},
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
}
})
return data
# not complete
@classmethod
def new_actor_old(cls, actor, handle, pubkey, published=None, table={}, full=True, **kwargs):
actor_type = kwargs.get('type', 'Person').title()
assert actor_type in actor_types
actor = Url(actor)
data = cls({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'schema': 'http://schema.org',
'toot': 'https://joinmastodon.org/ns#',
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
'IdentityProof': 'toot:IdentityProof',
'discoverable': 'toot:discoverable',
'Device': 'toot:Device',
'Ed25519Signature': 'toot:Ed25519Signature',
'Ed25519Key': 'toot:Ed25519Key',
'Curve25519Key': 'toot:Curve25519Key',
'EncryptedMessage': 'toot:EncryptedMessage',
'publicKeyBase64': 'toot:publicKeyBase64',
'deviceId': 'toot:deviceId',
'messageFranking': 'toot:messageFranking',
'messageType': 'toot:messageType',
'cipherText': 'toot:cipherText',
'suspended': 'toot:suspended',
'Emoji': 'toot:Emoji',
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
},
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"claim": {
"@type": "@id",
"@id": "toot:claim"
},
"fingerprintKey": {
"@type": "@id",
"@id": "toot:fingerprintKey"
},
"identityKey": {
"@type": "@id",
"@id": "toot:identityKey"
},
"devices": {
"@type": "@id",
"@id": "toot:devices"
},
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
}
}
],
'id': actor,
'type': actor_type,
'following': f'{actor}/following',
'followers': f'{actor}/followers',
'inbox': kwargs.get('inbox', f'{actor}'),
'outbox': f'{actor}/outbox',
'featured': f'{actor}/collections/featured',
'featuredTags': f'{actor}/collections/tags',
'preferredUsername': handle,
'name': kwargs.get('display_name', handle),
'summary': kwargs.get('bio'),
'url': kwargs.get('url', actor),
'manuallyApprovesFollowers': kwargs.get('locked', False),
'discoverable': kwargs.get('discoverable', False),
'published': published or DateString.now('activitypub'),
'devices': f'{actor}/collections/devices',
'publicKey': {
'id': f'{actor}#main-key',
'owner': actor,
'publicKeyPem': pubkey
},
'tag': [],
'attachment': [],
'endpoints': {
'sharedInbox': kwargs.get('shared_inbox', f'https://{actor.host}/inbox')
}
})
for key, value in table.items():
data.attachment.append(PropertyValue(key, value))
if kwargs.get('avatar_url'):
data.icon = Object.new_image(kwargs.get('avatar_url'), kwargs.get('avatar_type'))
if kwargs.get('header_url'):
data.image = Object.new_image(kwargs.get('header_url'), kwargs.get('header_type'))
# need to add data when "full" is true
if not full:
del data.featured
del data.featuredTags
del data.devices
del data.following
del data.followers
del data.outbox
return data
@classmethod
def new_follow(cls, id, actor, target):
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id,
'type': 'Follow',
'actor': actor,
'object': target
})
@classmethod
def new_emoji(cls, id, name, url, image):
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id,
'type': 'Emoji',
'name': name,
'updated': updated or DateTime.now('activitypub'),
'icon': image
})
@property
def privacy_level(self):
return parse_privacy_level(
self.get('to', []),
self.get('cc', []),
self.get('attributedTo', '') + '/followers'
)
@property
def shared_inbox(self):
try: return self.endpoints.shared_inbox
except AttributeError: pass
@property
def pubkey(self):
try: return self.publicKey.publicKeyPem
except AttributeError: pass
@property
def handle(self):
return self['preferredUsername']
@property
def display_name(self):
return self.get('name')
@property
def type(self):
return self['type'].capitalize()
@property
def info_table(self):
return DotDict({p['name']: p['value'] for p in self.get('attachment', {})})
@property
def domain(self):
return self.id.host
@property
def bio(self):
return self.get('summary')
@property
def avatar(self):
return self.icon.url
@property
def header(self):
return self.image.url
class Collection(Object):
@classmethod
def new_replies(cls, statusid):
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': f'{statusid}/replies',
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': f'{statusid}/replies?only_other_accounts=true&page=true',
'partOf': f'{statusid}/replies',
'items': []
}
})
@classmethod
def new_collection(cls, outbox, min_id=0, total=0):
return cls({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': outbox,
'type': 'OrderedCollection',
'totalItems': total,
'first': f'{outbox}?page=true',
'last': f'{outbox}?min_id=0&page=true'
})
@classmethod
def new_page(cls, outbox, min_id, max_id, *items):
return cls({
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'sensitive': 'as:sensitive',
'toot': 'http://joinmastodon.org/ns#',
'votersCount': 'toot:votersCount',
'litepub': 'http://litepub.social/ns#',
'directMessage': 'litepub:directMessage',
}
],
'id': f'{outbox}?page=true',
'type': 'OrderedCollectionPage',
'next': f'{outbox}?max_id={max_id}&page=true',
'prev': f'{outbox}?min_id={min_id}&page=true',
'partOf': outbox,
'orderedItems': items
})
### sub-objects ###
class PropertyValue(DotDict):
def __init__(self, key, value):
super().__init__({
'type': 'PropertyValue',
'name': key,
'value': value
})
def __setitem__(self, key, value):
key = key.lower()
assert key in ['type', 'name', 'value']
assert type(value) == str
super().__setitem__(key, value)
def set_pair(self, key, value):
self.name = key
self.value = value
class Media(Object):
@classmethod
def new(cls, type, url, mime=None):
return cls(
type = 'Image',
mediaType = mime or mimetypes.guess_type(url)[0] or 'image/png',
url = url
)
@classmethod
def new_image(cls, url, mime=None):
return cls.new('Image', url, mime)
@classmethod
def new_video(cls, url, mime=None):
return cls.new('Video', url, mime)
@classmethod
def new_audio(cls, url, mime=None):
return cls.new('Audio', url, mime)
class Emoji(DotDict):
@classmethod
def new(cls, id, name, image):
return cls({
'id': id,
'type': Emoji,
'name': f':{name}:',
'icon': image
})
### Not activitypub objects, but related ###
class Nodeinfo(DotDict):
@property
def name(self):
return self.software.name
@property
def version(self):
return self.software.version
@property
def repo(self):
return self.software.repository
@property
def homepage(self):
return self.software.homepage
@property
def users(self):
return self.usage.users.total
@property
def posts(self):
return self.usage.localPosts
@classmethod
def new_20(cls, name, version, **metadata):
return cls.new(name, version, '2.0', **metadata)
@classmethod
def new_21(cls, name, version, **metadata):
return cls.new(name, version, '2.1', **metadata)
@classmethod
def new(cls, name, version, niversion='2.1', **kwargs):
assert niversion in ['2.0', '2.1']
open_regs = boolean(kwargs.pop('open_regs', True))
posts = int(kwargs.pop('posts', 0))
users = int(kwargs.pop('users', 0))
users_halfyear = int(kwargs.pop('halfyear', 0))
users_month = int(kwargs.pop('month', 0))
comments = int(kwargs.pop('comments', 0))
repository = kwargs.pop('repository', None)
homepage = kwargs.pop('homepage', None)
data = cls(
version = niversion,
openRegistrations = open_regs,
software = {
'name': name.lower().replace(' ', '-'),
'version': version
},
usage = {
'users': {
'total': users
}
},
protocols = [
'activitypub'
],
services = {
'inbound': kwargs.pop('inbound', []),
'outbound': kwargs.pop('outbound', [])
}
)
if niversion == '2.1':
if repository:
data.software.repository = repository
if homepage:
data.software.homepage = homepage
if users_halfyear:
data.users.activeHalfyear = halfyear
if users_month:
data.users.activeMonth = month
if posts:
data.usage.localPosts = posts
if comments:
data.usage.localComments = comments
if kwargs:
data.metadata = kwargs
return data
class WellknownNodeinfo(DotDict):
def url(self, version='2.1'):
assert version in ['2.0', '2.1']
for link in self.links:
if link['rel'].endswith(version):
return link['href']
@classmethod
def new(cls, path, version='2.1'):
data = cls(links=[])
data.append(path, version)
return data
def append(self, path, version='2.1'):
assert version in ['2.0', '2.1']
self.links.append({
'rel': f'http://nodeinfo.dispora.software/ns/schema/{version}',
'href': path
})
class Hostmeta(str):
def __new__(cls, text):
return str.__new__(cls, text)
@classmethod
def new(cls, domain):
return cls(f'<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" template="https://{domain}/.well-known/webfinger?resource={{uri}}"/></XRD>')
@property
def link(self):
return Url(fromstring(self)[0].attrib['template'])
class Webfinger(DotDict):
@property
def profile(self):
for link in self.links:
if link['rel'] == 'http://webfinger.net/rel/profile-page':
return link['href']
@property
def actor(self):
for link in self.links:
if link['rel'] == 'self':
return link['href']
@property
def fullname(self):
return self.subject[5:]
@property
def handle(self):
return self.fullname.split('@')[0]
@property
def domain(self):
return self.fullname.split('@')[1]
@classmethod
def new(cls, handle, domain, actor, profile=None):
data = cls(
subject = f'acct:{handle}@{domain}',
aliases = [actor],
links = [
{
'rel': 'self',
'type': 'application/activity+json',
'href': actor
}
]
)
if profile:
data.aliases.append(profile)
data.links.append({
'rel': 'http://webfinger.net/rel/profile-page',
'type': 'text/html',
'href': profile
})
return data

View file

@ -0,0 +1,5 @@
__version__ = '0.1.0'
from .client import Client
from .request import Request
from .response import Response

View file

@ -0,0 +1,67 @@
import asyncio
from functools import partial
from .config import Config
from .. import __version__
from ..http_utils import methods
class Client:
def __init__(self, loop=None, **kwargs):
self.cfg = Config(**kwargs)
if not loop:
try:
self.loop = asyncio.get_event_loop()
except:
self.loop = asyncio.new_event_loop()
else:
self.loop = loop
for method in methods:
setattr(self, method.lower(), partial(self.request, method=method))
async def request(self, *args, **kwargs):
request = self.create_request(*args, **kwargs)
return await self.send_request(request)
async def send_request(self, request):
connection = asyncio.open_connection(request.host, request.port, ssl=request.secure)
reader, writer = await asyncio.wait_for(connection, timeout=self.cfg.timeout)
transport = self.cfg.transport_class(reader, writer, self.cfg.timeout)
for key, value in self.cfg.headers.items():
request.headers.setall(key, value)
await transport.write(request.compile())
data = await transport.read_headers(request=False)
return self.cfg.response_class(transport, *data)
def create_request(self, url, body=b'', headers={}, cookies={}, method='GET', privkey=None, keyid=None):
if (keyid and not privkey) or (not keyid and privkey):
raise ValueError('Please provide a keyid and a privkey, not either')
request = self.cfg.request_class(url, body, headers, cookies, method)
if keyid and privkey:
request.sign_headers(privkey, key_id)
return request
def run_request(self, request):
return self.loop.run_until_complete(self.send_request(request))
def read_body(self, response):
return self.loop.run_until_complete(response.read())

View file

@ -0,0 +1,38 @@
from izzylib import (
BaseConfig,
LowerdotDict
)
from .request import Request
from .response import Response
from .. import __version__
from ..http_utils import AsyncTransport
class Config(BaseConfig):
def __init__(self, **kwargs):
super().__init__(
headers = LowerDotDict({'User-Agent': f'IzzyLib/{__version__}'}),
request_class = Request,
response_class = Response,
transport_class = AsyncTransport,
timeout = 60
)
useragent = kwargs.pop('useragent', None)
appagent = kwargs.pop('appagent', None)
if useragent:
self.headers['User-Agent'] = useragent
appagent = None
if appagent:
self.headers['User-Agent'] = f'IzzyLib/{__version__} ({appagent})'
self.update(kwargs)
@property
def agent(self):
return self.headers['user-agent']

View file

@ -0,0 +1,85 @@
import json
from datetime import datetime
from izzylib import url
from ..http_utils import (
Headers,
Cookies,
convert_to_bytes,
create_message,
first_line,
methods,
ports
)
try: from ..http_signatures import sign_request
except ImportError: sign_request = None
class Request:
def __init__(self, url, body=None, headers={}, cookies={}, method='GET'):
method = method.upper()
if method not in methods:
raise ValueError(f'Invalid HTTP method: {method}')
self._body = b''
self.version = 1.1
self.url = Url(url)
self.headers = Headers(headers)
self.cookies = Cookies(cookies)
self.method = method
if self.url.proto not in ['http', 'https', 'ws', 'wss']:
raise ValueError(f'Invalid protocol in url: {self.url.proto}')
if not self.headers.get('host'):
self.headers.host = self.host
@property
def body(self):
return self._body
@body.setter
def body(self, data):
self._body = convert_to_bytes(data)
self.headers.setall('Content-Length', str(len(self._body)))
@property
def host(self):
return self.url.host
@property
def port(self):
return self.url.port or ports[self.url.proto]
@property
def secure(self):
return self.url.proto in ['https', 'wss']
def set_header(self, key, value):
self.headers.setall(key, value)
def unset_header(self, key):
self.headers.pop(key, None)
def sign_headers(self, privkey, keyid):
if not sign_request:
raise ImportError(f'Could not import HTTP signatures. Header signing disabled')
return sign_request(self, privkey, keyid)
def compile(self):
first = first_line(method=self.method, path=self.url.path_full)
return create_message(first, self.headers, self.cookies, self.body)

View file

@ -0,0 +1,60 @@
from asyncio.exceptions import TimeoutError
from io import BytesIO
from izzylib import DotDict
from ..http_utils import (
Cookies,
Headers
)
class Response:
def __init__(self, transport, status, message, version, headers, cookies):
self._body = BytesIO()
self.transport = transport
self.version = version
self.status = status
self.message = message
self.headers = headers if type(headers) == Headers else Headers(headers)
self.cookies = cookies if type(cookies) == Cookies else Cookies(cookies)
@property
def type(self):
return self.headers.getone('content-type')
@type.setter
def type(self, data):
self.headers.setall('content-type', data)
@property
def length(self):
return int(self.headers.getone('content-length', 0))
async def read(self, length=None):
data = await self.transport.read(length or self.length or 8192)
if data:
self._body.write(data)
return data
def body(self, encoding=None):
data = self._body.readall()
return data.decode(encoding) if encoding else data
def text(self, encoding='utf-8'):
return self.body(encoding)
def dict(self):
return DotDict(self.text())
def close(self):
self._body.close()

View file

@ -0,0 +1,47 @@
# todo: rename any *Exception class to *Error
__all__ = [
'HttpError',
'HttpFileDownloadedError',
'InvalidMethodException',
'MethodNotHandledException',
'NoBlueprintForPath',
'NoConnectionError',
'MaxConnectionsError'
]
class HttpError(Exception):
def __init__(self, status, message):
self.status = status
self.message = message
super().__init__(f'HTTP ERROR {status}: {message[:100]}')
class HttpFileDownloadedError(Exception):
'raise when a download failed for any reason'
class InvalidMethodException(Exception):
def __init__(self, method):
super().__init__(f'Invalid HTTP method: {method}')
self.method = method
class MethodNotHandledException(Exception):
def __init__(self, method):
super().__init__(f'HTTP method not handled by handler: {method}')
self.method = method
class NoBlueprintForPath(Exception):
'raise when no blueprint is found for a specific path'
class NoConnectionError(Exception):
'Raise when a function requiring a connection gets called when there is no connection'
class MaxConnectionsError(Exception):
'Raise when the max amount of connections has been reached'

View file

@ -0,0 +1,461 @@
:root {
--text: #eee;
--hover: {{primary.desaturate(50).lighten(50)}};
--primary: {{primary}};
--background: {{background}};
--ui: {{primary.desaturate(25).lighten(5)}};
--ui-background: {{background.lighten(7.5)}};
--shadow-color: {{black.rgba(25)}};
--shadow: 0 4px 4px 0 var(--shadow-color), 3px 0 4px 0 var(--shadow-color);
--negative: {{negative}};
--negative-dark: {{negative.darken(85)}};
--positive: {{positive}};
--positive-dark: {{positive.darken(85)}};
--message: var(--positive);
--error: var(--negative);
--gap: 15px;
--easing: cubic-bezier(.6, .05, .28, .91);
--trans-speed: {{speed}}ms;
}
body {
color: var(--text);
background-color: var(--background);
font-family: sans undertale;
font-size: 16px;
margin: 15px 0;
}
a, a:visited {
color: var(--primary);
text-decoration: none;
}
a:hover {
color: var(--hover);
text-decoration: underline;
}
input:not([type='checkbox']), select, textarea {
color: var(--text);
background-color: var(--background);
border: 1px solid var(--background);
box-shadow: 0 2px 2px 0 var(--shadow-color);
padding: 5px;
}
input:hover, select:hover, textarea:hover {
border-color: var(--hover);
}
input:focus, select:focus, textarea:focus {
outline: 0;
border-color: var(--primary);
}
details:focus, summary:focus {
outline: 0;
}
/* Classes */
.button {
display: inline-block;
padding: 5px;
background-color: {{primary.darken(85)}};
text-align: center;
box-shadow: var(--shadow);
}
.button:hover {
background-color: {{primary.darken(65)}};
text-decoration: none;
}
.grid-container {
display: grid;
grid-template-columns: auto;
grid-gap: var(--gap);
}
.grid-item {
display: inline-grid;
}
.flex-container {
display: flex;
flex-wrap; wrap;
}
.menu {
list-style-type: none;
padding: 0;
margin: 0;
}
.menu li {
display: inline-block;
text-align: center;
min-width: 60px;
background-color: {{background.lighten(20)}};
}
.menu li a {
display: block;
padding-left: 5px;
padding-right: 5px;
}
.menu li:hover {
background-color: {{primary.lighten(25).desaturate(25)}};
}
.menu li a:hover {
text-decoration: none;
color: {{primary.darken(90).desaturate(50)}};
}
.section {
padding: 8px;
background-color: var(--ui-background);
box-shadow: var(--shadow);
}
.shadow {
box-shadow: 0 4px 4px 0 var(--shadow-color), 3px 0 4px 0 var(--shadow-color);
}
.message {
line-height: 2em;
display: block;
}
/* # this is kinda hacky and needs to be replaced */
.tooltip:hover::after {
position: relative;
padding: 8px;
bottom: 35px;
border-radius: 5px;
white-space: nowrap;
border: 1px solid var(--text);
color: var(--text);
background-color: {{primary.desaturate(50).darken(75)}};
box-shadow: var(--shadow);
/*z-index: -1;*/
}
/* ids */
#title {
font-size: 36px;
font-weight: bold;
text-align: center;
}
#message, #error {
padding: 10px;
color: var(--background);
margin-bottom: var(--gap);
text-align: center;
}
#message {
background-color: var(--message);
}
#error {
background-color: var(--error);
}
#body {
width: 790px;
margin: 0 auto;
}
#header {
display: flex;
margin-bottom: var(--gap);
text-align: center;
font-size: 2em;
line-height: 40px;
font-weight: bold;
}
#header > div {
/*display: inline-block;*/
height: 40px;
}
#header .page-title {
text-align: {% if menu_left %}right{% else %}left{% endif %};
white-space: nowrap;
overflow: hidden;
width: 100%;
}
#content-body .title {
text-align: center;
font-size: 2em;
font-weight: bold;
color: var(--primary);
}
#footer {
margin-top: var(--gap);
display: flex;
grid-gap: 5px;
font-size: 0.80em;
line-height: 20px;
}
#footer > div {
height: 20px;
}
#footer .avatar img {
margin: 0 auto;
}
#footer .user {
white-space: nowrap;
overflow: hidden;
width: 100%;
}
#footer .source {
white-space: nowrap;
}
#logreg input, textarea {
display: block;
margin: 8px auto;
}
#logreg textarea, input:not([type='submit']) {
width: 50%;
}
/* Main menu */
#btn {
cursor: pointer;
transition: left 500ms var(--easing);
}
#btn {
transition: background-color var(--trans-speed);
width: 55px;
margin-left: var(--gap);
background-image: url('/framework/static/menu.svg');
background-size: 50px;
background-position: center center;
background-repeat: no-repeat;
}
#btn div {
transition: transform var(--trans-speed) ease, opacity var(--trans-speed), background-color var(--trans-speed);
}
#btn.active {
margin-left: 0;
position: fixed;
z-index: 5;
top: 12px;
{% if menu_left %}right: calc(100% - 250px + 12px){% else %}right: 12px{% endif %};
background-color: {{primary.darken(75)}};
color: {{background}};
}
/*#btn.active div {
width: 35px;
height: 2px;
margin-bottom: 8px;
}*/
#btn.active:parent {
grid-template-columns: auto;
}
#menu {
position: fixed;
z-index: 4;
overflow: auto;
top: 0px;
opacity: 0;
padding: 20px 0px;
width: 250px;
height: 100%;
transition: all var(--trans-speed) ease;
{% if menu_left %}left{% else %}right{% endif %}: -250px;
}
#menu.active {
{% if menu_left %}left{% else %}right{% endif %}: 0;
opacity: 1;
}
#menu #items {
/*margin-top: 50px;*/
margin-bottom: 30px;
}
#menu a:hover {
text-decoration: none;
}
#menu {
font-weight: bold;
}
#menu .item {
display: block;
position: relative;
font-size: 2em;
transition: all var(--trans-speed);
padding-left: 20px;
}
#menu .title-item {
color: var(--primary);
}
#items .sub-item {
padding-left: 40px;
}
#items .item:not(.title-item):hover {
padding-left: 40px;
}
#items .sub-item:hover {
padding-left: 60px !important;
}
/*#menu details .item:hover {
padding-left: 60px;
}*/
#items summary {
cursor: pointer;
color: var(--primary);
}
#items details[open]>.item:not(details) {
animation-name: fadeInDown;
animation-duration: var(--trans-speed);
}
#items summary::-webkit-details-marker {
display: none;
}
#items details summary:after {
content: " +";
}
#items details[open] summary:after {
content: " -";
}
#btn, #btn * {
will-change: transform;
}
#menu {
will-change: transform, opacity;
}
@keyframes fadeInDown {
0% {
opacity: 0;
transform: translateY(-1.25em);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
{% for name in cssfiles %}
{% include 'style/' + name + '.css' %}
{% endfor %}
/* responsive design */
@media (max-width: 810px) {
body {
margin: 0;
}
#body {
width: auto;
}
}
@media (max-width: 610px) {
.settings .grid-container {
grid-template-columns: auto;
}
.settings .label {
text-align: center;
}
#logreg textarea, input:not([type='submit']) {
width: calc(100% - 16px);
}
}
/* scrollbar */
body {scrollbar-width: 15px; scrollbar-color: var(--primary) {{background.darken(10)}};}
::-webkit-scrollbar {width: 15px;}
::-webkit-scrollbar-track {background: {{background.darken(10)}};}
/*::-webkit-scrollbar-button {background: var(--primary);}
::-webkit-scrollbar-button:hover {background: var(--text);}*/
::-webkit-scrollbar-thumb {background: var(--primary);}
::-webkit-scrollbar-thumb:hover {background: {{primary.lighten(25)}};}
/* page font */
@font-face {
font-family: 'sans undertale';
src: local('Nunito Sans Bold'),
url('/framework/static/nunito/NunitoSans-SemiBold.woff2') format('woff2'),
url('/framework/static/nunito/NunitoSans-SemiBold.ttf') format('ttf');
font-weight: bold;
font-style: normal;
}
@font-face {
font-family: 'sans undertale';
src: local('Nunito Sans Light Italic'),
url('/framework/static/nunito/NunitoSans-ExtraLightItalic.woff2') format('woff2'),
url('/framework/static/nunito/NunitoSans-ExtraLightItalic.ttf') format('ttf');
font-weight: normal;
font-style: italic;
}
@font-face {
font-family: 'sans undertale';
src: local('Nunito Sans Bold Italic'),
url('/framework/static/nunito/NunitoSans-Italic.woff2') format('woff2'),
url('/framework/static/nunito/NunitoSans-Italic.ttf') format('ttf');
font-weight: bold;
font-style: italic;
}
@font-face {
font-family: 'sans undertale';
src: local('Nunito Sans Light'),
url('/framework/static/nunito/NunitoSans-Light.woff2') format('woff2'),
url('/framework/static/nunito/NunitoSans-Light.ttf') format('ttf');
font-weight: normal;
font-style: normal;
}

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
%html
%head
%title << {{cfg.title}}: {{page}}
%link(rel='shortcut icon', type='image/png', href='{{cfg.tpl_favicon_path}}')
%link(rel='stylesheet' type='text/css' href='/framework/style.css')
%link(rel='manifest' href='/framework/manifest.json')
%meta(charset='UTF-8')
%meta(name='viewport' content='width=device-width, initial-scale=1')
-block head
%body
#body
#header.flex-container
-if menu_left
#btn.section
.page-title.section -> %a.title(href='/') << {{cfg.title}}
-else
.page-title.section -> %a.title(href='/') << {{cfg.title}}
#btn.section
-if message
#message.section << {{message}}
-if error
#error.secion << {{error}}
#menu.section
.title-item.item << Menu
#items
-if not len(cfg.menu):
-include 'menu.haml'
-else:
-for label, path_data in cfg.menu.items()
-if path_data[1] == 1000 and request.user_level == 0:
.item -> %a(href='{{path_data[0]}}') << {{label}}
-elif request.user_level >= path_data[1]
.item -> %a(href='{{path_data[0]}}') << {{label}}
#content-body.section
-block content
#footer.grid-container.section
.avatar
.user
.source
%a(href='{{cfg.git_repo}}' target='_new') << {{cfg.name}}/{{cfg.version}}
%script(type='application/javascript' src='/framework/static/menu.js')

View file

@ -0,0 +1,8 @@
-extends 'base.haml'
-set page = 'Error'
-block content
%center
%font size='8'
HTTP {{response.status}}
%br
=error_message

View file

@ -0,0 +1 @@
.item -> %a(href='/') << Home

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,29 @@
const sidebarBox = document.querySelector('#menu'),
sidebarBtn = document.querySelector('#btn'),
pageWrapper = document.querySelector('html');
header = document.querySelector('#header')
sidebarBtn.addEventListener('click', event => {
sidebarBtn.classList.toggle('active');
sidebarBox.classList.toggle('active');
});
pageWrapper.addEventListener('click', event => {
itemId = event.srcElement.id
itemClass = event.srcElement.className
indexId = ['menu', 'btn', 'items'].indexOf(itemId)
indexClass = ['item', 'item name', 'items'].indexOf(itemClass)
if (sidebarBox.classList.contains('active') && (indexId == -1 && indexClass == -1)) {
sidebarBtn.classList.remove('active');
sidebarBox.classList.remove('active');
}
});
window.addEventListener('keydown', event => {
if (sidebarBox.classList.contains('active') && event.keyCode === 27) {
sidebarBtn.classList.remove('active');
sidebarBox.classList.remove('active');
}
});

View file

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="menu.svg"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
id="svg8"
version="1.1"
viewBox="0 0 132.29167 79.375002"
height="300"
width="500">
<defs
id="defs2" />
<sodipodi:namedview
inkscape:window-maximized="1"
inkscape:window-y="36"
inkscape:window-x="36"
inkscape:window-height="990"
inkscape:window-width="1644"
units="px"
showgrid="true"
inkscape:document-rotation="0"
inkscape:current-layer="layer2"
inkscape:document-units="mm"
inkscape:cy="151.34478"
inkscape:cx="232.18877"
inkscape:zoom="1.4"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
borderopacity="1.0"
bordercolor="#666666"
pagecolor="#ffffff"
id="base"
inkscape:snap-text-baseline="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-bbox="true"
inkscape:bbox-nodes="true"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0">
<inkscape:grid
dotted="true"
id="grid1402"
type="xygrid"
originx="-7.9375001"
originy="-27.781234" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
id="layer2"
inkscape:groupmode="layer"
transform="translate(-7.9374999,-27.781233)">
<path
style="fill:none;fill-opacity:1;stroke:#cfcfcf;stroke-width:13.2292;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 15.875,67.468765 c 116.41667,0 116.41667,0 116.41667,0 z"
id="path1590" />
<path
style="fill:none;fill-opacity:1;stroke:#cfcfcf;stroke-width:13.2292;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 15.875,35.718766 c 116.41667,0 116.41667,0 116.41667,0 z"
id="path1590-7" />
<path
style="fill:none;fill-opacity:1;stroke:#cfcfcf;stroke-width:13.2292;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 15.875,99.218766 c 116.41667,0 116.41667,0 116.41667,0 z"
id="path1590-7-8" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,44 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the copyright statement(s).
"Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.

View file

@ -0,0 +1,78 @@
import argon2, os
from .misc import time_function_pprint
class PasswordHasher:
'''
Argon2 password hasher and validator
Attributes:
config (dict): The settings used for the hasher
Methods:
get_config(key): Get the value of a config options
set_config(key, value): Set a config option
hash(password): hash a password and return the digest as a hex string
verify(hash, password): verify a password and the password hash match
iteration_test(string, passes, iterations): Time the hashing functionality
'''
aliases = {
'iterations': 'time_cost',
'memory': 'memory_cost',
'threads': 'parallelism'
}
def __init__(self, iterations=16, memory=100, threads=os.cpu_count(), type=argon2.Type.ID):
if not argon2:
raise ValueError('password hashing disabled')
self.config = {
'time_cost': iterations,
'memory_cost': memory * 1024,
'parallelism': threads,
'encoding': 'utf-8',
'type': type,
}
self.hasher = argon2.PasswordHasher(**self.config)
def get_config(self, key):
key = self.aliases.get(key, key)
value = self.config[key]
return value / 1024 if key == 'memory_cost' else value
def set_config(self, key, value):
key = self.aliases.get(key, key)
self.config[key] = value * 1024 if key == 'memory_cost' else value
self.hasher = argon2.PasswordHasher(**self.config)
def hash(self, password: str):
return self.hasher.hash(password)
def verify(self, passhash: str, password: str):
try:
return self.hasher.verify(passhash, password)
except argon2.exceptions.VerifyMismatchError:
return False
def iteration_test(self, string='hecking heck', passes=3, iterations=[8,16,24,32,40,48,56,64]):
original_iter = self.get_config('iterations')
for iteration in iterations:
self.set_config('iterations', iteration)
print('\nTesting hash iterations:', iteration)
time_function_pprint(self.verify, self.hash(string), string, passes=passes)
self.set_config('iterations', original_iter)

View file

@ -0,0 +1,22 @@
http_methods = ['CONNECT', 'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT', 'TRACE']
applications = {}
def get_app(name='default'):
return applications[name]
def set_app(app):
applications[app.name] = app
return app
def create_app(appname, **kwargs):
return set_app(Application(appname=appname, **kwargs))
from .application import Application, Blueprint
from .middleware import MediaCacheControl
from .request import Request
from .response import Response
from .view import View, Static

View file

@ -0,0 +1,398 @@
import asyncio
import signal
import socket
import sys
import time
import traceback
from functools import partial
from http_router import Router, MethodNotAllowed, NotFound
from izzylib import DotDict, Path, logging, signal_handler
from jinja2.exceptions import TemplateNotFound
from . import http_methods, error, __file__ as module_root
from .config import Config
from .response import Response
#from .router import Router
from .view import Static, Manifest, Robots, Style
from ..exceptions import MethodNotHandledException, NoBlueprintForPath
from ..http_utils import AsyncTransport
from ..template import Template
try:
from izzylib import Database
except ImportError:
Database = NotImplementedError('Failed to import SQL database class')
frontend = Path(module_root).parent.join('frontend')
class ApplicationBase:
ctx = DotDict()
def __init__(self, appname='default', views=[], middleware=[], dbtype=None, dbargs={}, dbclass=Database, **kwargs):
self.name = appname
self.cfg = Config(**kwargs)
self.db = None
self.router = Router(trim_last_slash=True)
self.middleware = DotDict({'request': [], 'response': []})
self.routes = {}
for view in views:
self.add_view(view)
for mw in middleware:
self.add_middleware(mw)
if dbtype or dbargs:
if isinstance(Database, Exception):
raise Database from None
self.db = dbclass(dbtype, **dbargs, app=self)
def __getitem__(self, key):
return self.ctx[key]
def __setitem__(self, key, value):
self.ctx[key] = value
def get_route(self, path, method='GET'):
return self.router(str(path), method.upper())
def add_route(self, handler, path, method='GET'):
self.router.bind(handler, path, methods=[method.upper()])
self.routes[f'{method.upper()}:{path}'] = handler
def compare_routes(self, route, path, method='GET'):
try:
return self.get_route(path, method) == self.routes[f'{method.upper()}:{route}']
except:
return False
async def run_handler(self, request, response, path, method=None, **kwargs):
handler = self.get_route(path, method or request.method)
return await handler.target(request, response, **kwargs)
def add_view(self, view):
paths = view.__path__ if isinstance(view.__path__, list) else [view.__path__]
view_class = view(self)
for path in paths:
for method in http_methods:
try:
self.add_route(view_class.get_handler(method), path, method)
except MethodNotHandledException:
pass
def add_static(self, path, src):
if Path(src).isdir:
path = Path(path).join('{path:.*}')
self.add_route(Static(src), path)
def add_middleware(self, handler, attach=None):
if not asyncio.iscoroutinefunction(handler):
raise TypeError('Middleware handler must be a coroutine function or method')
if not attach:
try:
arg_len = handler.__code__.co_argcount
if arg_len == 1:
attach = 'request'
elif arg_len == 2:
attach = 'response'
else:
raise TypeError(f'Middleware handler must have 1 (request) or 2 (response) arguments, not {arg_len}')
except Exception as e:
raise e from None
assert attach in ['request', 'response']
mwlist = self.middleware[attach]
if handler in mwlist:
return logging.error(f'Middleware handler already added to {attach}: {handler}')
mwlist.append(handler)
async def handle_request(self, request, response, path=None):
if request.host not in self.cfg.hosts and not request.path.startswith('/framework'):
raise error.BadRequest(f'Host not handled on this server: {request.host}')
handler = self.get_route(path or request.path, request.method)
request._params = handler.params
await self.handle_middleware(request)
if handler.params:
handler_response = await handler.target(request, response, **handler.params)
else:
handler_response = await handler.target(request, response)
if isinstance(handler_response, dict):
response = self.cfg.response_class(**handler_response)
elif isinstance(handler_response, Response):
response = handler_response
elif not handler_response:
pass
else:
raise error.InternalServerError()
await self.handle_middleware(request, response)
return response
async def handle_middleware(self, request, response=None):
for middleware in self.middleware['response' if response else 'request']:
if response:
await middleware(request, response)
else:
await middleware(request)
class Application(ApplicationBase):
def __init__(self, loop=None, **kwargs):
super().__init__(**kwargs)
if loop:
self.loop = loop
else:
try:
self.loop = asyncio.get_running_loop()
except RuntimeError:
self.loop = asyncio.new_event_loop()
self.client = self.cfg.client_class(loop, *self.cfg.client_args, **self.cfg.client_kwargs)
self._blueprints = {}
self._server = None
self._tasks = []
if self.cfg.tpl_default:
if type(Template) == NotImplementedError:
raise Template
self.template = Template(
self.cfg.tpl_search,
self.cfg.tpl_globals,
self.cfg.tpl_context,
self.cfg.tpl_autoescape
)
self.template.add_env('app', self)
self.template.add_env('cfg', self.cfg)
self.template.add_env('len', len)
self.template.add_search_path(frontend)
self.add_view(Manifest)
#self.add_view(Robots)
self.add_view(Style)
self.add_static('/framework/static/', frontend.join('static'))
else:
self.template = None
def add_blueprint(self, bp):
assert bp.prefix not in self._blueprints.values()
self._blueprints[bp.prefix] = bp
def get_blueprint_for_path(self, path):
for bppath, bp in self._blueprints.items():
if path.startswith(bppath):
return bp
raise NoBlueprintForPath(path)
def render(self, *args, **kwargs):
return self.template.render(*args, **kwargs)
async def create_task(self, log=True):
if self.cfg.socket:
if log:
logging.info(f'Starting server on {self.cfg.socket}')
return await asyncio.start_unix_server(
self.handle_client,
path = self.cfg.socket
)
else:
if log:
logging.info(f'Starting server on {self.cfg.listen}:{self.cfg.port}')
return await asyncio.start_server(
self.handle_client,
host = self.cfg.listen,
port = self.cfg.port,
family = socket.AF_INET,
reuse_address = True,
reuse_port = True
)
def stop(self, *_):
if not self._server:
print('server not running')
return
self._server.close()
for task in self._tasks:
task.cancel()
self._tasks.remove(task)
signal_handler(None)
def start(self, *tasks, log=True):
if self._server:
return
if self.cfg.socket:
if log:
logging.info(f'Starting server on {self.cfg.socket}')
server = asyncio.start_unix_server(
self.handle_client,
path = self.cfg.socket
)
else:
if log:
logging.info(f'Starting server on {self.cfg.listen}:{self.cfg.port}')
server = asyncio.start_server(
self.handle_client,
host = self.cfg.listen,
port = self.cfg.port,
family = socket.AF_INET,
reuse_address = True,
reuse_port = True
)
signal_handler(self.stop)
self._server = self.loop.run_until_complete(server)
for task in tasks:
asyncio.ensure_future(task, loop=self.loop)
self.loop.run_until_complete(self.handle_run_server())
async def handle_run_server(self):
while self._server.is_serving():
await asyncio.sleep(0.1)
await self._server.wait_closed()
self._server = None
logging.info('Server stopped')
async def handle_client(self, reader, writer):
transport = AsyncTransport(reader, writer, self.cfg.timeout)
request = None
response = None
try:
request = self.cfg.request_class(self, transport)
response = self.cfg.response_class(request=request)
try:
await request.parse_headers()
except asyncio.exceptions.IncompleteReadError as e:
request = None
raise e from None
try:
# this doesn't work all the time for some reason
blueprint = self.get_blueprint_for_path(request.path)
response = await blueprint.handle_request(request, response, blueprint.prefix)
except NoBlueprintForPath:
response = await self.handle_request(request, response)
#except Exception as e:
#traceback.print_exc()
except NotFound:
response = self.cfg.response_class(request=request).set_error('Not Found', 404)
except MethodNotAllowed:
response = self.cfg.response_class(request=request).set_error('Method Not Allowed', 405)
except error.RedirError as e:
response = self.cfg.response_class.new_redir(e.path, e.status)
except error.HttpError as e:
response = self.cfg.response_class(request=request).set_error(e.message, e.status)
except TemplateNotFound as e:
response = self.cfg.response_class(request=request).set_error(f'Template not found: {e}', 500)
except:
traceback.print_exc()
if not response.streaming:
## Don't use a custom response class here just in case it caused the error
response = Response(request=request).set_error('Server Error', 500)
if not response.streaming:
try:
response.headers.update(self.cfg.default_headers)
await transport.write(response.compile())
if request and request.log and not request.path.startswith('/framework'):
logging.info(f'{request.remote} {request.method} {request.path} {response.status} {len(response.body)} {request.agent}')
except:
traceback.print_exc()
await transport.close()
class Blueprint(ApplicationBase):
def __init__(self, prefix, **kwargs):
super().__init__(**kwargs)
self.prefix = prefix
## might keep this
def set_response(request, resp_class, func, *args, **kwargs):
try:
return getattr(resp_class, func)(*args, **kwargs)
except:
traceback.print_exc()
return Response(request=request).set_error('Server Error', 500)

View file

@ -0,0 +1,101 @@
from .request import Request
from .response import Response
from .. import __version__
from ..config import BaseConfig
from ..dotdict import LowerDotDict
from ..http_client_async import HttpClient
from ..misc import boolean
class Config(BaseConfig):
_startup = True
def __init__(self, **kwargs):
super().__init__(
name = 'IzzyLib Http Server',
title = None,
version = '0.0.1',
git_repo = 'https://git.barkshark.xyz/izaliamae/izzylib',
socket = None,
listen = 'localhost',
host = None,
web_host = None,
alt_hosts = [],
port = 8080,
proto = 'http',
access_log = True,
timeout = 60,
default_headers = LowerDotDict(),
request_class = Request,
response_class = Response,
sig_handler = None,
sig_handler_args = [],
sig_handler_kwargs = {},
tpl_search = [],
tpl_globals = {},
tpl_context = None,
tpl_autoescape = True,
tpl_default = True,
tpl_favicon_path = '/framework/static/icon64.png',
client_class = HttpClient,
client_args = [],
client_kwargs = {}
)
self._startup = False
self.default_headers.update(kwargs.pop('default_headers', {}))
self.set_data(kwargs)
if not self.default_headers.get('server'):
self.default_headers['server'] = f'{self.name}/{__version__}'
if not self.client_kwargs.get('appagent') and not self.client_kwargs.get('useragent'):
self.client_kwargs['appagent'] = f'{self.name}/{__version__}'
@property
def hosts(self):
return (f'{self.listen}:{self.port}', self.host, self.web_host, *self.alt_hosts)
def parse_value(self, key, value):
if self._startup:
return value
if key == 'listen':
if not self.host:
self.host = value
if not self.web_host:
self.web_host = value
elif key == 'host':
if not self.web_host or self.web_host == self.listen:
self.web_host = value
elif key == 'port' and not isinstance(value, int):
raise TypeError(f'{key} must be an integer')
elif key == 'socket':
value = Path(value)
elif key in ['access_log', 'tpl_autoescape', 'tpl_default'] and not isinstance(value, bool):
raise TypeError(f'{key} must be a boolean')
elif key in ['alt_hosts', 'sig_handler_args', 'tpl_search'] and not isinstance(value, list):
raise TypeError(f'{key} must be a list')
elif key in ['sig_handler_kwargs', 'tpl_globals'] and not isinstance(value, dict):
raise TypeError(f'{key} must be a dict')
elif key == 'tpl_context' and not getattr(value, '__call__', None):
raise TypeError(f'{key} must be a callable')
elif key == 'request_class' and not issubclass(value, Request):
raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Request')
elif key == 'response_class' and not issubclass(value, Response):
raise TypeError(f'{key} must be a subclass of izzylib.http_server_async.Response')
return value

View file

@ -0,0 +1,65 @@
from functools import partial
class HttpError(Exception):
def __init__(self, message, status=500):
super().__init__(f'HTTP Error {status}: {message}')
self.status = status
self.message = message
class RedirError(Exception):
def __init__(self, path, status=301):
super().__init__(f'HTTP Error {status}: {path}')
self.status = status
self.path = path
## 200 Errors
Ok = partial(HttpError, status=200)
Created = partial(HttpError,status=201)
Accepted = partial(HttpError,status=202)
NoContent = partial(HttpError,status=204)
ResetContent = partial(HttpError,status=205)
PartialContent = partial(HttpError,status=206)
## 300 Errors
NotModified = partial(HttpError, status=304)
## 400 Errors
BadRequest = partial(HttpError, status=400)
Unauthorized = partial(HttpError, status=401)
Forbidden = partial(HttpError, status=403)
NotFound = partial(HttpError, status=404)
MethodNotAllowed = partial(HttpError, status=405)
RequestTimeout = partial(HttpError,status=408)
Gone = partial(HttpError,status=410)
LengthRequired = partial(HttpError,status=411)
PreconditionFailed = partial(HttpError,status=412)
PayloadTooLarge = partial(HttpError,status=413)
UriTooLong = partial(HttpError,status=414)
UnsupportedMediaType = partial(HttpError,status=415)
RangeNotSatisfiable = partial(HttpError,status=416)
Teapot = partial(HttpError, status=418)
UpgradeRequired = partial(HttpError,status=426)
TooManyRequests = partial(HttpError,status=429)
RequestHeaderFieldsTooLarge = partial(HttpError,status=431)
UnavailableForLegalReasons = partial(HttpError,status=451)
## 500 Errors
InternalServerError = partial(HttpError, status=500)
NotImplemented = partial(HttpError,status=501)
BadGateway = partial(HttpError,status=502)
ServiceUnavailable = partial(HttpError,status=503)
GatewayTimeout = partial(HttpError,status=504)
HttpVersionNotSuported = partial(HttpError,status=505)
NetworkAuthenticationRequired = partial(HttpError,status=511)
## Redirects
MovedPermanently = partial(RedirError, status=301)
Found = partial(RedirError, status=302)
SeeOther = partial(RedirError, status=303)
TemporaryRedirect = partial(RedirError, status=307)
PermanentRedirect = partial(RedirError, status=309)

View file

@ -0,0 +1,14 @@
media_types = [
'application/octet-stream'
]
media_main_types = [
'audio',
'font',
'image',
'video'
]
async def MediaCacheControl(request, response):
if response.content_type in media_types or any(map(response.content_type.startswith, media_main_types)):
response.headers['Cache-Control'] = 'public,max-age=2628000,immutable'

View file

@ -0,0 +1,343 @@
from datetime import datetime, timezone, timedelta
from .. import logging, boolean
from ..dotdict import DotDict
from ..path import Path
UtcTime = timezone.utc
LocalTime = datetime.now(UtcTime).astimezone().tzinfo
cookie_params = {
'Expires': 'expires',
'Max-Age': 'maxage',
'Domain': 'domain',
'Path': 'path',
'SameSite': 'samesite',
'Secure': 'secure',
'HttpOnly': 'httponly'
}
request_methods = [
'GET', 'HEAD', 'POST', 'PUT', 'DELETE',
'CONNECT', 'OPTIONS', 'TRACE', 'PATCH'
]
class Cookies(DotDict):
def __setitem__(self, key, value):
if type(value) != CookieItem:
value = CookieItem(key, value)
super().__setitem__(key, value)
class Headers(DotDict):
def __getattr__(self, key):
return self[key.replace('_', '-')]
def __setattr__(self, key, value):
self[key.replace('_', '-')] = value
def __getitem__(self, key):
return super().__getitem__(key.title())
def __setitem__(self, key, value):
key = key.title()
if key in ['Cookie', 'Set-Cookie']:
logging.warning('Do not set the "Cookie" or "Set-Cookie" headers')
return
elif key == 'Date':
value = DateItem(value)
try:
self[key].append(value)
except KeyError:
super().__setitem__(key, HeaderItem(key, value))
def get(self, key, default=None):
return super().get(key.title(), default)
def as_dict(self):
data = {}
for k,v in self.items():
data[k] = str(v)
return data
def getone(self, key, default=None):
try:
return self[key].one()
except (KeyError, IndexError):
return default
def setall(self, key, value):
self[key].set(value)
#def update(self, data):
#for k,v in data.items():
#self.__setitem__(k,v)
class CookieItem:
def __init__(self, key, value, **kwargs):
self.key = key
self.value = value
self.args = DotDict()
for k,v in kwargs.items():
if k not in cookie_params.values():
raise AttributeError(f'Not a valid cookie parameter: {key}')
setattr(self, k, v)
def __str__(self):
text = f'{self.key}={self.value}'
if self.expires:
text += f'; Expires={self.expires.strftime("%a, %d %b %Y %H:%M:%S GMT")}'
if self.maxage != None:
text += f'; Max-Age={self.maxage}'
if self.domain:
text += f'; Domain={self.domain}'
if self.path:
text += f'; Path={self.path}'
if self.samesite:
text += f'; SameSite={self.samesite}'
if self.secure:
text += f'; Secure'
if self.httponly:
text += f'; HttpOnly'
return text
@classmethod
def from_string(cls, data):
kwargs = {}
for idx, pairs in enumerate(data.split(';')):
try:
k, v = pairs.split('=', 1)
v = v.strip()
except:
k, v = pairs, True
k = k.replace(' ', '')
if isinstance(v, str) and v.startswith('"') and v.endswith('"'):
v = v[1:-1]
if idx == 0:
key = k
value = v
elif k in cookie_params.keys():
kwargs[cookie_params[k]] = v
else:
logging.info(f'Invalid key/value pair for cookie: {k} = {v}')
return cls(key, value, **kwargs)
@property
def expires(self):
return self.args.get('Expires')
@expires.setter
def expires(self, data):
if isinstance(data, str):
data = datetime.strptime(data, '%a, %d %b %Y %H:%M:%S GMT').replace(tzinfo=UtcTime)
elif isinstance(data, int) or isinstance(data, float):
data = datetime.fromtimestamp(data).replace(tzinfo=UtcTime)
elif isinstance(data, datetime):
if not data.tzinfo:
data = data.replace(tzinfo=UtcTime)
elif isinstance(data, timedelta):
data = datetime.now(UtcTime) + data
else:
raise TypeError(f'Expires must be a http date string, timestamp, or datetime object, not {data.__class__.__name__}')
self.args['Expires'] = data
@property
def maxage(self):
return self.args.get('Max-Age')
@maxage.setter
def maxage(self, data):
if isinstance(data, int):
pass
elif isinstance(date, timedelta):
data = data.seconds
elif isinstance(date, datetime):
data = (datetime.now() - date).seconds
else:
raise TypeError(f'Max-Age must be an integer, timedelta object, or datetime object, not {data.__class__.__name__}')
self.args['Max-Age'] = data
@property
def domain(self):
return self.args.get('Domain')
@domain.setter
def domain(self, data):
if not isinstance(data, str):
raise ValueError(f'Domain must be a string, not {data.__class__.__name__}')
self.args['Domain'] = data
@property
def path(self):
return self.args.get('Path')
@path.setter
def path(self, data):
if not isinstance(data, str):
raise ValueError(f'Path must be a string or izzylib.Path object, not {data.__class__.__name__}')
self.args['Path'] = Path(data)
@property
def secure(self):
return self.args.get('Secure')
@secure.setter
def secure(self, data):
self.args['Secure'] = boolean(data)
@property
def httponly(self):
return self.args.get('HttpOnly')
@httponly.setter
def httponly(self, data):
self.args['HttpOnly'] = boolean(data)
@property
def samesite(self):
return self.args.get('SameSite')
@samesite.setter
def samesite(self, data):
if isinstance(data, bool):
data = 'Strict' if data else 'None'
elif isinstance(data, str) and data.title() in ['Strict', 'Lax', 'None']:
data = data.title()
else:
raise TypeError(f'SameSite must be a boolean or one of Strict, Lax, or None, not {data.__class__.__name__}')
self.args['SameSite'] = data
self.args['Secure'] = True
def as_dict(self):
data = DotDict({self.key: self.value})
data.update(self.args)
return data
def set_defaults(self):
for key in list(self.args.keys()):
del self.args[key]
def set_delete(self):
self.args.pop('Expires', None)
self.maxage = 0
return self
class HeaderItem(list):
def __init__(self, key, values):
super().__init__()
self.update(values)
self.key = key
def __str__(self):
return ','.join(str(v) for v in self)
def set(self, *values):
self.clear()
for value in values:
self.append(value)
def one(self):
return self[0]
def update(self, *items):
for item in items:
self.append(item)
class DateItem(str):
_datetime = None
def __new__(cls, date):
new_date = str.__new__(cls, date)
new_date._datetime = datetime.strptime(date, '%a, %d %b %Y %H:%M:%S GMT').replace(tzinfo=UtcTime)
return new_date
@property
def utc(self):
return self._datetime.astimezone(UtcTime)
@property
def local(self):
return self._datetime.astimezone(LocalTime)

View file

@ -0,0 +1,209 @@
import asyncio, email, traceback
from datetime import datetime, timezone
from urllib.parse import unquote_plus
from .misc import Cookies, Headers, CookieItem
from ..dotdict import DotDict, MultiDotDict
from ..misc import Url
try: from ..http_signatures import verify_headers
except ImportError: verify_headers = None
UtcTime = timezone.utc
LocalTime = datetime.now(UtcTime).astimezone().tzinfo
class Request:
__slots__ = [
'_body', '_form', '_method', '_app', '_params',
'address', 'path', 'version', 'headers', 'cookies',
'query', 'raw_query', 'transport', 'log'
]
ctx = DotDict()
def __init__(self, app, transport):
super().__init__()
self._app = app
self._body = b''
self._form = DotDict()
self._method = None
self._params = None
self.transport = transport
self.headers = Headers()
self.cookies = Cookies()
self.query = DotDict()
self.address = transport.client_address
self.path = None
self.version = None
self.raw_query = None
self.log = True
self.verified = False
def __getitem__(self, key):
return self.ctx[key]
def __setitem__(self, key, value):
self.ctx[key] = value
def __getattr__(self, key):
if key in self.__slots__:
return super().__getattribute__(self, key)
try:
return self.ctx[key]
except:
raise AttributeError(key)
def __setattr__(self, key, value):
try:
super().__setattr__(key, value)
except:
self.ctx[key] = value
@property
def app(self):
return self._app or get_app()
@app.setter
def app(self, app):
self._app = app
@property
def agent(self):
return self.headers.getone('User-Agent', 'no agent')
@property
def accept(self):
return self.headers.getone('Accept', '')
@property
def content_type(self):
return self.headers.getone('Content-Type', '')
@property
def date(self):
date_str = self.headers.getone('Date')
if date_str:
date = datetime.strptime(date_str, '%a, %d %b %Y %H:%M:%S GMT')
date = date.replace(tzinfo=UtcTime)
return date.astimezone(LocalTime)
# not sure if this should stay
return datetime.now(LocalTime)
@property
def host(self):
return self.headers.getone('Host')
@property
def length(self):
return int(self.headers.getone('Content-Length', 0))
@property
def method(self):
return self._method
@method.setter
def method(self, data):
self._method = data.upper()
@property
def params(self):
return self._params
@property
def protocol(self):
return self.headers.getone('X-Forwarded-Proto', 'http')
@property
def remote(self):
return self.headers.getone('X-Real-Ip', self.headers.getone('X-Forwarded-For', self.address))
async def body(self):
if not self._body and self.length:
self._body = await self.transport.read(self.length)
return self._body
async def text(self):
return (await self.body()).decode('utf-8')
async def dict(self):
logging.warning('Request.dict will be removed in a future update')
return DotDict(await self.body())
async def json(self):
return DotDict(await self.body())
async def form(self):
if not self._form and 'application/x-www-form-urlencoded' in self.content_type:
for line in unquote_plus(await self.text()).split('&'):
try: key, value = line.split('=', 1)
except: key, value = line, None
self._form[key] = value
return self._form
async def parse_headers(self):
method, path, version, headers, cookies = await self.transport.read_headers()
self.path = path or ''
self.version = version
self.method = method
self.headers = headers
self.cookies = cookies
self.query = Url(f'{self.protocol}://{self.host}{self.path or "/"}').query
def new_response(self, *args, **kwargs):
return self.app.cfg.response_class(*args, **kwargs)
async def verify_signature(self, actor):
if not verify_headers:
raise ImportError('Failed to import verify_headers from izzylib.http_signatures.')
self.verified = verify_headers(
headers = {k: self.headers.getone(k) for k in self.headers.keys()},
method = self.method,
path = self.path,
actor = actor,
body = await self.body()
)
return self.verified

View file

@ -0,0 +1,201 @@
import json, traceback
from datetime import datetime
from . import get_app
from .misc import Cookies, Headers, CookieItem
from ..dotdict import MultiDotDict
from ..http_utils import convert_to_bytes, create_message, first_line
class Response:
__slots__ = ['_app', '_body', 'headers', 'cookies', 'status', 'request', 'version']
def __init__(self, body=b'', status=200, headers={}, cookies={}, content_type='text/plain', request=None):
self._app = None
self._body = b''
self.headers = Headers(headers)
self.cookies = Cookies(cookies)
self.version = '1.1'
self.body = body
self.status = status
self.content_type = content_type
self.request = request
@property
def app(self):
return self._app or get_app()
@app.setter
def app(self, app):
self._app = app
@property
def body(self):
return self._body
@body.setter
def body(self, data):
self._body = convert_to_bytes(data)
@property
def content_type(self):
return self.headers.getone('Content-Type')
@content_type.setter
def content_type(self, data):
try:
self.headers['Content-Type'].set(data)
except KeyError:
self.headers['Content-Type'] = data
@property
def content_length(self):
return len(self.body)
@property
def streaming(self):
return self.headers.getone('Transfer-Encoding') == 'chunked'
def append(self, data):
self._body += convert_to_bytes(data)
def delete_cookie(self, cookie):
cookie.set_delete()
self.cookies[cookie.key] = cookie
@classmethod
def new_html(cls, body, **kwargs):
response = cls(**kwargs)
response.set_html(body)
return response
@classmethod
def new_json(cls, body, activity=False, **kwargs):
response = cls(**kwargs)
response.set_json(body, activity)
return response
@classmethod
def new_error(cls, message, status=500, **kwargs):
response = cls(**kwargs)
response.set_error(message, status)
return response
@classmethod
def new_redir(cls, path, status=302, **kwargs):
response = cls(**kwargs)
response.set_redir(path, status)
return response
def set_text(self, body=b'', status=None):
self.body = body
if status:
self.status = status
return self
def set_html(self, body=b'', status=None):
self.content_type = 'text/html'
self.body = body
if status:
self.status = status
return self
def set_template(self, template, context={}, content_type='text/html', status=None, request=None):
if not request:
request = self.request
if status:
self.status = status
self.body = request.app.render(template, context_data=context, request=request)
self.content_type = content_type
return self
def set_json(self, body={}, status=None, activity=False):
self.content_type = 'application/activity+json' if activity else 'application/json'
self.body = body
if status:
self.status = status
return self
def set_redir(self, path, status=302):
self.headers['Location'] = path
self.status = status
return self
def set_error(self, message, status=500):
try:
if self.request and 'json' in self.request.headers.getone('accept', ''):
return self.set_json({'error': message, 'code': status}, status=status)
return self.set_template('error.haml',
context = {
'error_message': message,
'response': self
},
status = status
)
except AttributeError:
pass
except Exception:
traceback.print_exc()
self.body = f'HTTP Error {status}: {message}'
self.status = status
return self
async def set_streaming(self, transport, headers={}):
self.headers.update(headers)
self.headers.update(transport.app.cfg.default_headers)
self.headers.setall('Transfer-encoding', 'chunked')
transport.write(self.compile(body=False))
def set_cookie(self, key, value, **kwargs):
self.cookies[key] = CookieItem(key, value, **kwargs)
def compile(self, body=True):
first = first_line(status=self.status)
return create_message(first, self.headers, self.cookies, self.body if body else None)

View file

@ -0,0 +1,65 @@
import asyncio
from ..dotdict import DotDict
class Transport:
def __init__(self, app, reader, writer):
self.app = app
self.reader = reader
self.writer = writer
@property
def client_address(self):
return self.writer.get_extra_info('peername')[0]
@property
def client_port(self):
return self.writer.get_extra_info('peername')[1]
@property
def closed(self):
return self.writer.is_closing()
async def read(self, length=2048, timeout=None):
return await asyncio.wait_for(self.reader.read(length), timeout or self.app.cfg.timeout)
async def readuntil(self, bytes, timeout=None):
return await asyncio.wait_for(self.reader.readuntil(bytes), timeout or self.app.cfg.timeout)
async def write(self, data):
if isinstance(data, DotDict):
data = data.to_json()
elif any(map(isinstance, [data], [dict, list, tuple])):
data = json.dumps(data)
# not sure if there's a better type to use, but this should be fine for now
elif any(map(isinstance, [data], [float, int])):
data = str(data)
elif isinstance(data, bytearray):
data = str(data)
elif not any(map(isinstance, [data], [bytes, str])):
raise TypeError('Data must be or a str, bytes, bytearray, float, it, dict, list, or tuple')
if isinstance(data, str):
data = data.encode('utf-8')
self.writer.write(data)
await self.writer.drain()
async def close(self):
if self.closed:
return
self.writer.close()
await self.writer.wait_closed()

View file

@ -0,0 +1,135 @@
import mimetypes
from . import http_methods, error
from ..dotdict import DotDict
from ..path import Path
from ..exceptions import (
InvalidMethodException,
MethodNotHandledException
)
try: import magic
except ImportError: magic = None
try:
from ..template import Color
default_theme = DotDict(
primary = Color('#e7a'),
secondary = Color('#a7e'),
background = Color('#191919'),
positive = Color('#aea'),
negative = Color('#e99'),
white = Color('#eee'),
black = Color('#111'),
speed = 250
)
except ModuleNotFoundError:
pass
class View:
__path__ = ''
def __init__(self, app):
self.app = app
def get_handler(self, method):
if method.upper() not in http_methods:
raise InvalidMethodException(method)
try:
return getattr(self, method.lower())
except AttributeError:
raise MethodNotHandledException(method)
#def get(self, request):
#pass
def Static(src):
src = Path(src)
async def StaticHandler(request, response, path=None):
src_path = src if not path else src.join(path)
try:
with open(src_path, 'rb') as fd:
data = fd.read()
if magic:
mime = mimetypes.guess_type(path)[0] or magic.from_buffer(data[:2048], mime=True)
else:
mime = mimetypes.guess_type(path)
except FileNotFoundError:
raise error.NotFound('Static file not found')
except IsADirectoryError:
index = src_path.join('index.html')
if not index.isfile:
raise error.NotFound('Static file not found')
with open(index, 'rb') as fd:
data = fd.read()
mime = 'text/html'
response.body = data
response.content_type = mime
return StaticHandler
### Frontend Template Views ###
class Manifest(View):
__path__ = '/framework/manifest.json'
async def get(self, request, response):
data = {
'name': self.cfg.name,
'short_name': self.cfg.name.replace(' ', ''),
'description': 'UvU',
'icons': [
{
'src': "/framework/static/icon512.png",
'sizes': '512x512',
'type': 'image/png'
},
{
'src': "/framework/static/icon64.png",
'sizes': '64x64',
'type': 'image/png'
}
],
'theme_color': str(response.default_theme.primary),
'background_color': str(response.default_theme.background),
'display': 'standalone',
'start_url': '/',
'scope': f'{self.cfg.proto}://{self.cfg.web_host}'
}
response.set_json(data)
class Robots(View):
__path__ = '/robots.txt'
async def get(self, request, response):
data = '# Disallow all\nUser-agent: *\nDisallow: /'
response.body = data
class Style(View):
__path__ = '/framework/style.css'
async def get(self, request, response):
response.body = self.app.render('base.css', default_theme)
response.content_type = 'text/css'

View file

@ -0,0 +1,218 @@
import json, sys
from Crypto.Hash import SHA256
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from base64 import b64decode, b64encode
from datetime import datetime
from functools import lru_cache
from tldextract import extract
from . import izzylog
from .dotdict import DefaultDotDict, DotDict
from .misc import Url
def generate_rsa_key():
privkey = RSA.generate(2048)
key = DotDict({'PRIVKEY': privkey, 'PUBKEY': privkey.publickey()})
key.update({'privkey': key.PRIVKEY.export_key().decode(), 'pubkey': key.PUBKEY.export_key().decode()})
return key
def parse_signature(signature: str):
return Signature(signature)
def verify_headers(headers: dict, method: str, path: str, actor: dict, body=None):
'''Verify a header signature
headers: A dictionary containing all the headers from a request
method: The HTTP method of the request
path: The path of the HTTP request
actor (optional): A dictionary containing the activitypub actor and the link to the pubkey used for verification
body (optional): The body of the request. Only needed if the signature includes the digest header
fail (optional): If set to True, raise an error instead of returning False if any step of the process fails
'''
headers = {k.lower(): headers[k] for k in headers}
headers['(request-target)'] = f'{method.lower()} {path}'
signature = Signature(headers.get('signature'))
digest = headers.get('digest')
missing_headers = [k for k in headers if k in ['date', 'host'] if headers.get(k) == None]
if not signature:
raise AssertionError('Missing signature')
## Add digest header to missing headers list if it doesn't exist
if method.lower() == 'post' and not digest:
missing_headers.append('digest')
## Fail if missing date, host or digest (if POST) headers
if missing_headers:
raise AssertionError(f'Missing headers: {missing_headers}')
## Fail if body verification fails
if digest:
digest_hash = parse_body_digest(headers.get('digest'))
if not verify_string(body, digest_hash.sig, digest_hash.alg):
raise AssertionError('Failed body digest verification')
pubkey = actor.publicKey['publicKeyPem']
return sign_pkcs_headers(pubkey, {k:v for k,v in headers.items() if k in signature.headers}, sig=signature)
def verify_request(request, actor: dict):
'''Verify a header signature from a SimpleASGI request
request: The request with the headers to verify
actor: A dictionary containing the activitypub actor and the link to the pubkey used for verification
'''
return verify_headers(
headers = request.headers,
method = request.method,
path = request.path,
actor = actor,
body = request.body
)
### Helper functions that shouldn't be used directly ###
def parse_body_digest(digest):
if not digest:
raise AssertionError('Empty digest')
parsed = DotDict()
alg, sig = digest.split('=', 1)
parsed.sig = sig
parsed.alg = alg.replace('-', '')
return parsed
def sign_pkcs_headers(key: str, headers: dict, sig=None):
if sig:
head_items = [f'{item}: {headers[item]}' for item in sig.headers]
else:
head_items = [f'{k.lower()}: {v}' for k,v in headers.items()]
head_string = '\n'.join(head_items)
head_bytes = head_string.encode('UTF-8')
KEY = RSA.importKey(key)
pkcs = PKCS1_v1_5.new(KEY)
h = SHA256.new(head_bytes)
if sig:
try:
return pkcs.verify(h, b64decode(sig.signature))
except ValueError:
return False
else:
return pkcs.sign(h)
def sign_request(request, privkey, keyid):
assert isinstance(request.body, bytes)
request.set_header('(request-target)', f'{request.method.lower()} {request.url.path}')
request.set_header('host', request.url.host)
request.set_header('date', datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT'))
if request.body:
body_hash = b64encode(SHA256.new(request.body).digest()).decode("UTF-8")
request.set_header('digest', f'SHA-256={body_hash}')
request.set_header('content-length', str(len(request.body)))
sig = {
'keyId': keyid,
'algorithm': 'rsa-sha256',
'headers': ' '.join([k.lower() for k in request.headers.keys()]),
'signature': b64encode(sign_pkcs_headers(privkey, request.headers)).decode('UTF-8')
}
sig_items = [f'{k}="{v}"' for k,v in sig.items()]
sig_string = ','.join(sig_items)
request.set_header('signature', sig_string)
request.unset_header('(request-target)')
request.unset_header('host')
return request
def verify_string(string, enc_string, alg='SHA256', fail=False):
if type(string) != bytes:
string = string.encode('UTF-8')
body_hash = b64encode(SHA256.new(string).digest()).decode('UTF-8')
if body_hash == enc_string:
return True
if fail:
raise AssertionError('String failed validation')
else:
return False
class Signature(str):
__parts = {}
def __init__(self, signature: str):
if not signature:
raise AssertionError('Missing signature header')
split_sig = signature.split(',')
for part in split_sig:
key, value = part.split('=', 1)
value = value.replace('"', '')
self.__parts[key.lower()] = value.split() if key == 'headers' else value
def __new__(cls, signature: str):
return str.__new__(cls, signature)
def __new2__(cls, signature: str):
data = str.__new__(cls, signature)
data.__init__(signature)
return
def __getattr__(self, key):
return self.__parts[key]
@property
def sig(self):
return self.__parts['signature']
@property
def actor(self):
return Url(self.keyid.split('#')[0])
@property
def domain(self):
return self.actor.host
@property
def top_domain(self):
return '.'.join(extract(self.domain)[1:])

View file

@ -0,0 +1,232 @@
import codecs, traceback, os, json, xml
from colour import Color as Colour
from functools import partial
from hamlish_jinja import HamlishExtension
from jinja2 import Environment, FileSystemLoader, ChoiceLoader, select_autoescape, Markup
from os import listdir, makedirs
from os.path import isfile, isdir, getmtime, abspath
from xml.dom import minidom
from . import izzylog
from .dotdict import DotDict
from .path import Path
try:
from sanic import response as Response
except ModuleNotFoundError:
Response = None
class Template(Environment):
def __init__(self, search=[], global_vars={}, context=None, autoescape=True):
self.search = FileSystemLoader([])
super().__init__(
loader=self.search,
extensions=[HamlishExtension],
lstrip_blocks=True,
trim_blocks=True
)
self.autoescape = autoescape
self.func_context = context
self.hamlish_file_extensions=('.haml',)
self.hamlish_enable_div_shortcut=True
self.hamlish_mode = 'indented'
for path in search:
self.add_search_path(Path(path))
self.globals.update({
'markup': Markup,
'cleanhtml': lambda text: ''.join(xml.etree.ElementTree.fromstring(text).itertext()),
'color': Color,
'lighten': partial(color_func, 'lighten'),
'darken': partial(color_func, 'darken'),
'saturate': partial(color_func, 'saturate'),
'desaturate': partial(color_func, 'desaturate'),
'rgba': partial(color_func, 'rgba')
})
self.globals.update(global_vars)
def add_search_path(self, path, index=None):
if not path.exists:
raise FileNotFoundError(f'Cannot find search path: {path}')
if path not in self.search.searchpath:
loader = os.fspath(path)
if index != None:
self.search.searchpath.insert(index, loader)
else:
self.search.searchpath.append(loader)
def set_context(self, context):
if not hasattr(context, '__call__'):
izzylog.error('Context is not callable')
return
if not isinstance(context({}), dict):
izzylog.error('Context does not return a dict or dict-like object')
return
self.func_context = context
def add_env(self, k, v):
self.globals[k] = v
def del_env(self, var):
if not self.globals.get(var):
raise ValueError(f'"{var}" not in global variables')
del self.var[var]
def update_env(self, data):
if not isinstance(data, dict):
raise ValueError(f'Environment data not a dict')
self.globals.update(data)
def add_filter(self, funct, name=None):
name = funct.__name__ if not name else name
self.filters[name] = funct
def del_filter(self, name):
if not self.filters.get(name):
raise valueError(f'"{name}" not in global filters')
del self.filters[name]
def update_filter(self, data):
if not isinstance(data, dict):
raise ValueError(f'Filter data not a dict')
self.filters.update(data)
def render(self, tplfile, context_data={}, headers={}, cookies={}, request=None, pprint=False):
if not isinstance(context_data, dict):
raise TypeError(f'context for {tplfile} not a dict: {type(context)} {context}')
context = DotDict(self.globals)
context.update(context_data)
context['request'] = request if request else {'headers': headers, 'cookies': cookies}
if self.func_context:
# Backwards compat
try:
context = self.func_context(context)
except TypeError:
context = self.func_context(context, {})
if context == None:
izzylog.warning('Template context was set to "None"')
context = {}
result = self.get_template(tplfile).render(context)
if pprint and any(map(tplfile.endswith, ['haml', 'html', 'xml'])):
return minidom.parseString(result).toprettyxml(indent=" ")
else:
return result
def response(self, request, tpl, ctype='text/html', status=200, **kwargs):
if not Response:
raise ModuleNotFoundError('Sanic is not installed')
html = self.render(tpl, request=request, **kwargs)
return Response.HTTPResponse(body=html, status=status, content_type=ctype, headers=kwargs.get('headers', {}))
class Color(Colour):
def __init__(self, color):
if isinstance(color, str):
super().__init__(f'#{str(color)}' if not color.startswith('#') else color)
elif isinstance(color, Colour):
super().__init__(str(color))
else:
raise TypeError(f'Color has to be a string or Color class, not {type(color)}')
def __repr__(self):
return self.__str__()
def __str__(self):
return self.hex_l
def lighten(self, multiplier):
return self.alter('lighten', multiplier)
def darken(self, multiplier):
return self.alter('darken', multiplier)
def saturate(self, multiplier):
return self.alter('saturate', multiplier)
def desaturate(self, multiplier):
return self.alter('desaturate', multiplier)
def rgba(self, multiplier):
return self.alter('rgba', multiplier)
def multi(self, multiplier):
if multiplier >= 100:
return 100
elif multiplier <= 0:
return 0
return multiplier / 100
def alter(self, action, multiplier):
new_color = Color(self)
if action == 'lighten':
new_color.luminance += ((1 - self.luminance) * self.multi(multiplier))
elif action == 'darken':
new_color.luminance -= (self.luminance * self.multi(multiplier))
elif action == 'saturate':
new_color.saturation += ((1 - self.saturation) * self.multi(multiplier))
elif action == 'desaturate':
new_color.saturation -= (self.saturation * self.multi(multiplier))
elif action == 'rgba':
red = self.red*255
green = self.green*255
blue = self.blue*255
trans = self.multi(multiplier)
return f'rgba({red:0.2f}, {green:0.2f}, {blue:0.2f}, {trans})'
return new_color
def color_func(action, color, multi):
return Color(color).alter(action, multi)

View file

@ -0,0 +1,638 @@
import asyncio, json
from datetime import datetime, timezone
from io import BytesIO
from .dotdict import DotDict
from .misc import DateString, Url
try:
from .http_signatures import sign_headers, verify_headers
except ImportError:
sign_headers = None
verify_headers = None
ports = {
'http': 80,
'https': 443,
'ws': 80,
'wss': 443
}
methods = [
'CONNECT',
'DELETE',
'GET',
'HEAD',
'OPTIONS',
'PATCH',
'POST',
'PUT',
'TRACE'
]
class Headers(DotDict):
def __getattr__(self, key):
return self[key.replace('_', '-')]
def __setattr__(self, key, value):
self[key.replace('_', '-')] = value
def __getitem__(self, key):
return super().__getitem__(key.title())
def __setitem__(self, key, value):
key = key.title()
if key in ['Cookie', 'Set-Cookie']:
logging.warning('Do not set the "Cookie" or "Set-Cookie" headers')
return
elif key == 'Date':
value = DateString(value, 'http')
try:
self[key].append(value)
except KeyError:
super().__setitem__(key, HeaderItem(key, value))
def get(self, key, default=None):
return super().get(key.title(), default)
def as_dict(self):
data = {}
for k,v in self.items():
data[k] = str(v)
return data
def getone(self, key, default=None):
try:
return self[key].one()
except (KeyError, IndexError):
return default
def setall(self, key, value):
try:
self[key].set(value)
except KeyError:
self[key] = value
class Cookies(DotDict):
def __setitem__(self, key, value):
if type(value) != CookieItem:
value = CookieItem(key, value)
super().__setitem__(key, value)
class HeaderItem(list):
def __init__(self, key, values):
super().__init__()
self.update(values)
self.key = key
def __str__(self):
return ','.join(str(v) for v in self)
def set(self, *values):
self.clear()
for value in values:
self.append(value)
def one(self):
return self[0]
def update(self, *items):
for item in items:
self.append(item)
class CookieItem:
def __init__(self, key, value, **kwargs):
self.key = key
self.value = value
self.args = DotDict()
for k,v in kwargs.items():
if k not in cookie_params.values():
raise AttributeError(f'Not a valid cookie parameter: {key}')
setattr(self, k, v)
def __str__(self):
text = f'{self.key}={self.value}'
if self.expires:
text += f'; Expires={self.expires.strftime("%a, %d %b %Y %H:%M:%S GMT")}'
if self.maxage != None:
text += f'; Max-Age={self.maxage}'
if self.domain:
text += f'; Domain={self.domain}'
if self.path:
text += f'; Path={self.path}'
if self.samesite:
text += f'; SameSite={self.samesite}'
if self.secure:
text += f'; Secure'
if self.httponly:
text += f'; HttpOnly'
return text
@classmethod
def from_string(cls, data):
kwargs = {}
for idx, pairs in enumerate(data.split(';')):
try:
k, v = pairs.split('=', 1)
v = v.strip()
except:
k, v = pairs, True
k = k.replace(' ', '')
if isinstance(v, str) and v.startswith('"') and v.endswith('"'):
v = v[1:-1]
if idx == 0:
key = k
value = v
elif k in cookie_params.keys():
kwargs[cookie_params[k]] = v
else:
logging.info(f'Invalid key/value pair for cookie: {k} = {v}')
return cls(key, value, **kwargs)
@property
def expires(self):
return self.args.get('Expires')
@expires.setter
def expires(self, data):
if isinstance(data, str):
data = DateString(data, 'http')
elif isinstance(data, int) or isinstance(data, float):
data = datetime.fromtimestamp(data).replace(tzinfo=timezone.utc)
elif isinstance(data, datetime):
if not data.tzinfo:
data = data.replace(tzinfo=timezone.utc)
elif isinstance(data, timedelta):
data = datetime.now(timezone.utc) + data
else:
raise TypeError(f'Expires must be a http date string, timestamp, or datetime object, not {data.__class__.__name__}')
self.args['Expires'] = data
@property
def maxage(self):
return self.args.get('Max-Age')
@maxage.setter
def maxage(self, data):
if isinstance(data, int):
pass
elif isinstance(date, timedelta):
data = data.seconds
elif isinstance(date, datetime):
data = (datetime.now() - date).seconds
else:
raise TypeError(f'Max-Age must be an integer, timedelta object, or datetime object, not {data.__class__.__name__}')
self.args['Max-Age'] = data
@property
def domain(self):
return self.args.get('Domain')
@domain.setter
def domain(self, data):
if not isinstance(data, str):
raise ValueError(f'Domain must be a string, not {data.__class__.__name__}')
self.args['Domain'] = data
@property
def path(self):
return self.args.get('Path')
@path.setter
def path(self, data):
if not isinstance(data, str):
raise ValueError(f'Path must be a string or izzylib.Path object, not {data.__class__.__name__}')
self.args['Path'] = Path(data)
@property
def secure(self):
return self.args.get('Secure')
@secure.setter
def secure(self, data):
self.args['Secure'] = boolean(data)
@property
def httponly(self):
return self.args.get('HttpOnly')
@httponly.setter
def httponly(self, data):
self.args['HttpOnly'] = boolean(data)
@property
def samesite(self):
return self.args.get('SameSite')
@samesite.setter
def samesite(self, data):
if isinstance(data, bool):
data = 'Strict' if data else 'None'
elif isinstance(data, str) and data.title() in ['Strict', 'Lax', 'None']:
data = data.title()
else:
raise TypeError(f'SameSite must be a boolean or one of Strict, Lax, or None, not {data.__class__.__name__}')
self.args['SameSite'] = data
self.args['Secure'] = True
def as_dict(self):
data = DotDict({self.key: self.value})
data.update(self.args)
return data
def set_defaults(self):
for key in list(self.args.keys()):
del self.args[key]
def set_delete(self):
self.args.pop('Expires', None)
self.maxage = 0
return self
class AsyncTransport:
def __init__(self, reader, writer, timeout=60):
self.reader = reader
self.writer = writer
self.timeout = timeout
@property
def client_address(self):
return self.writer.get_extra_info('peername')[0]
@property
def client_port(self):
return self.writer.get_extra_info('peername')[1]
@property
def closed(self):
return self.writer.is_closing()
def eof(self):
return self.reader.at_eof()
async def read(self, length=None, timeout=None):
if timeout == False:
return await self.reader.read(length or 2048)
return await asyncio.wait_for(self.reader.read(length or 2048), timeout or self.timeout)
async def read_until(self, bytes, timeout=None):
return await asyncio.wait_for(self.reader.readuntil(bytes), timeout or self.timeout)
async def read_headers(self, request=True, timeout=None):
raw_headers = await self.read_until(b'\r\n\r\n', timeout=timeout)
return parse_headers(raw_headers.decode('utf-8'), request)
async def write(self, data):
self.writer.write(convert_to_bytes(data))
await self.writer.drain()
async def close(self):
if self.closed:
return
self.writer.close()
await self.writer.wait_closed()
class Request:
def __init__(self, url, body=None, headers={}, cookies={}, method='GET'):
method = method.upper()
if method not in http_methods:
raise ValueError(f'Invalid HTTP method: {method}')
self._body = b''
self.version = 1.1
self.url = Url(url)
self.headers = Headers(headers)
self.cookies = Cookies(cookies)
self.method = method
if self.url.proto not in ['http', 'https', 'ws', 'wss']:
raise ValueError(f'Invalid protocol in url: {self.url.proto}')
if not self.headers.get('host'):
self.headers.host = self.host
@property
def body(self):
return self._body
@body.setter
def body(self, data):
self._body = convert_to_bytes(data)
self.headers.setall('Content-Length', str(len(self._body)))
@property
def host(self):
return self.url.host
@property
def length(self):
return self._body.getbuffer().nbytes
@property
def port(self):
return self.url.port or http_ports[self.url.proto]
@property
def secure(self):
return self.url.proto in ['https', 'wss']
def set_header(self, key, value):
self.headers.setall(key, value)
def unset_header(self, key):
self.headers.pop(key, None)
def sign_headers(self, privkey, keyid):
if not sign_request:
raise ImportError(f'Could not import HTTP signatures. Header signing disabled')
return sign_request(self, privkey, keyid)
def verify_headers(self, headhash, pubkey):
pass
def compile(self):
first = bytes(f'{self.method} {self.path} HTTP/{self.version}', 'utf-8')
self.set_header('Content-Length', self.length)
return create_message(first, self.headers, self.cookies, self.body)
class Response:
def __init__(self, status, message, version, headers, cookies):
self._body = BytesIO()
self.version = version
self.status = status
self.message = message
self.headers = headers if type(headers) == Headers else Headers(headers)
self.cookies = cookies if type(cookies) == Cookies else Cookies(cookies)
@property
def body(self):
return self._body
@body.setter
def body(self, data):
self._body = convert_to_bytes(data)
self.headers.setall('Content-Length', str(len(self._body)))
@property
def host(self):
return self.url.host
@property
def port(self):
return self.url.port or http_ports[self.url.proto]
@property
def secure(self):
return self.url.proto in ['https', 'wss']
def set_header(self, key, value):
self.headers.setall(key, value)
def unset_header(self, key):
self.headers.pop(key, None)
async def read(self, length=None):
data = await self.transport.read(length or self.length or 8192)
if data:
self._body.write(data)
return data
def body(self, encoding=None):
data = self._body.readall()
return data.decode(encoding) if encoding else data
def text(self, encoding='utf-8'):
return self.body(encoding)
def dict(self):
return DotDict(self.text())
def close(self):
self._body.close()
def parse_headers(raw_headers, request=True):
headers = Headers()
cookies = Cookies()
for idx, line in enumerate(raw_headers.splitlines()):
if idx == 0:
if request:
method, path, version = line.split()
else:
split_line = line.split()
version = split_line[0]
status = split_line[1]
try: message = ' '.join(split_line[2:])
except IndexError: message = None
else:
try: key, value = line.split(': ', 1)
except: continue
if key.lower() == 'cookie':
for cookie in value.split(';'):
try:
item = CookieItem.from_string(cookie)
except:
traceback.print_exc()
continue
cookies[item.key] = item
continue
else:
headers[key] = value
version = float(version.replace('HTTP/', ''))
if request:
return method, path, version, headers, cookies
return int(status), message, version, headers, cookies
def convert_to_bytes(data):
if isinstance(data, str):
data = data.encode('utf-8')
elif isinstance(data, bytearray):
data = bytes(data)
elif any(map(isinstance, [data], [dict, list, tuple])):
data = json.dumps(data).encode('utf-8')
elif not isinstance(data, bytes):
data = str(data).encode('utf-8')
return data
def create_message(data, headers, cookies=None, body=None):
for k,v in headers.items():
for value in v:
data += bytes(f'\r\n{k}: {value}', 'utf-8')
if cookies:
for cookie in cookies.values():
data += bytes(f'\r\nSet-Cookie: {cookie}', 'utf-8')
if not headers.get('Date'):
data += bytes(f'\r\nDate: {DateString.now("http")}', 'utf-8')
if not headers.get('Content-Length'):
data += bytes(f'\r\nContent-Length: {len(body)}', 'utf-8')
if body and not headers.get('Content-Type'):
data += bytes(f'\r\nContent-Type: text/plain')
data += b'\r\n\r\n'
if body:
data += body
return data
def first_line(method=None, path=None, status=None, message=None, version='1.1'):
if method and path:
return bytes(f'{method} {path} HTTP/{version}', 'utf-8')
elif status:
if message:
return bytes(f'HTTP/{version} {status} {message}', 'utf-8')
return bytes(f'HTTP/{version} {status}', 'utf-8')
raise TypeError('Please only provide a method and path or a status and message')

55
setup.cfg Normal file
View file

@ -0,0 +1,55 @@
[metadata]
name = Barkshark HTTP
version = 0.1.0
author = Zoey Mae
author_email = zoey@barkshark.xyz
url = https://git.barkshark.xyz/izaliamae/barkshark-http
description = Simple async HTTP client and server
license = CNPL
license_file = LICENSE
platform = any
keywords = python http activitypub html css
classifiers =
Development Status :: 3 - Alpha
Intended Audience :: Developers
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python 3.6
Programming Language :: Python 3.7
Programming Language :: Python 3.8
Programming Language :: Python 3.9
Topic :: Software Development :: Libraries :: Python Modules
project_urls =
Bug Tracker = https://git.barkshark.xyz/izaliamae/barkshark-http/issues
Documentation = https://git.barkshark.xyz/izaliamae/barkshark-http/wiki
Source Code = https://git.barkshark.xyz/izaliamae/barkshark-http
[options]
include_package_data = true
python_requires = >= 3.6
packages =
barkshark_http
barkshark_http.client
barkshark_http.server
setup_requires =
setuptools >= 38.3.0
install_requires =
izzylib >= 0.7.0
argon2-cffi == 21.3.0
colour == 0.1.5
hamlish-jinja == 0.3.3
http-router == 2.6.5
jinja2 == 3.0.3
markdown == 3.3.6
python-magic == 0.4.25
pycryptodome == 3.14.1
tldextract == 3.1.2
[options.package_data]
barkshark_http = barkshark_http/*
[bdist_wheel]
universal = false
[sdist]
formats = zip, gztar

2
setup.py Normal file
View file

@ -0,0 +1,2 @@
import setuptools
setuptools.setup()